Redis 中的事务分析,Redis 中的事务可以满足ACID属性吗?

Redis 中的事务

什么是事务

数据库事务( transaction )是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成。

事务必须满足所谓的ACID属性

1、原子性(Atomicity)

事务中的全部操作在数据库中是不可分割的,要么全部完成,要么全部不执行;

  • 整个数据库事务是不可分割的工作单位;

  • 只有使数据库中所有的数据库操作都执行成功,才算整个事务成功;

  • 事务中任何一个 SQL 执行失败,已经执行成功的 SQL 也必须撤回,数据库应该退回到执行事务之前的状态;

2、一致性(Consistency)

事务的执行使数据从一个状态转换为另一个状态,在事务开始之前和事务结束之后,数据库的完整性约束没有被破坏。

有点绕,这里举个栗子

如果一个名字字段,在数据库中是唯一属性,执行了事务之后,涉及到了对该字段的修改,事务执行过程中发生了回滚,之后该字段变的不唯一了,这种情况下就是破坏了事务的一致性要求。

因为上面事务执行的过程中,导致里面名字字段属性的前后不一致,即数据库的状态从一种状态变成了一种不一致的状态。

上面的这个栗子就是数据库没有遵循一致性的表现。

3、隔离性(Isolation)

事务的隔离性要求每个读写事务的对象对其他事务的操作对象相互分离,即该事务提交前对其他事务都不可见。

通常使用锁来实现,数据库系统中会提供一种粒度锁的策略,允许事务仅锁住一个实体对象的子集,以此来提高事务之间的并发度。

4、持久性(Durability)

对于任意已提交事务,系统必须保证该事务对数据库的改变不被丢失,即使数据库出现故障。

当时如果一些人为的或者自然灾害导致数据库机房被破坏,比如火灾,机房爆炸等。这种情况下提交的数据可能会丢失。

因此可以理解,持久性保证的事务系统的高可靠性,而不是高可用性。

分析下 Redis 中的事务

Redis 中的事务如何使用

Redis 中提供了 MULTI、EXEC 这两个命令来进行事务的操作

# 初始化一个值
127.0.0.1:6379> set test-mult-key 100
OK
# 开启事务
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED
# 提交事务
127.0.0.1:6379> EXEC
1) (integer) 99
2) (integer) 98
3) (integer) 97

从上面的执行过程可以看出,事务的执行可以分成三个步骤

1、使用 MULTI 开启一个事务;

2、当开启一个事务之后,之后所有的命令不会马上被执行,而是会被放入到一个事务队列中,然后返回 QUEUED, 表示命令已入队;

3、那么当 EXEC 命令执行时, 服务器根据客户端所保存的事务队列, 以先进先出(FIFO)的方式执行事务队列中的命令:最先入队的命令最先执行,而最后入队的命令最后执行。

Redis 中的事务能够保证那些属性

原子性

如果命令正常运行,事务中的原子性是可以得到保证的。

在执行命令的过程中如果有命令失败了呢

关于失败命令,可分成下面三种情况

1、命令入队就报错

比如执行一个不存在的命令,或者命令的写错了

来个栗子

127.0.0.1:6379> set test-mult-key 100
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR test-mult-key
QUEUED
# DECR 命令拼写错了
127.0.0.1:6379> DECRR test-mult-key
(error) ERR unknown command `DECRR`, with args beginning with: `test-mult-key`,
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

可以看到事务中 DECR 的命令拼写错了,写成了 DECRR。这时候事务是不能执行的,在执行 EXEC 的时候,Redis 抛出了错误,整个事务的执行被丢弃了。

对于这种情况,在命令入队时,Redis就会报错并且记录下这个错误。此时,我们还能继续提交命令操作。等到执行了EXEC命令之后,Redis就会拒绝执行所有提交的命令操作,返回事务失败的结果。这样一来,事务中的所有命令都不会再被执行了,保证了原子性。

2、命令执行的时候报错

这种情况,就是我们操作 Redis 命令时候,命令的类型不匹配。

栗如:我们对一个 value 为 string 类型的 key,执行 DECR 操作。

127.0.0.1:6379> set test-mult-key 100
OK
127.0.0.1:6379> set test-mult-key-string 's100'
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED
# 对 value 为 string 的,执行 DECR 操作,结果会报错
# 模拟错误的命令
127.0.0.1:6379> DECR test-mult-key-string
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 99
2) (integer) 98
3) (error) ERR value is not an integer or out of range
4) (integer) 97

这种情况下,虽然错误的命令会报错,但是还是会把正确的命令执行完成。

这种情况下,命令的原子性就无法得到保证了。Redis 中没有提供事务的回滚机制。

3、EXEC命令执行时实例发生故障

如果 Redis 开启了 AOF 日志,那么,只会有部分的事务操作被记录到 AOF 日志中。

机器实例恢复后,我们可以使用 redis-check-aof 工具检查 AOF 日志文件,这个工具可以把已完成的事务操作从 AOF 文件中去除。这样一来,我们使用 AOF 恢复实例后,事务操作不会再被执行,从而保证了原子性。

所以关于 Redis 中事务原子性的总结,就是下面几点

1、命令入队时就报错,会放弃事务执行,保证原子性;

2、命令入队时没报错,实际执行时报错,不保证原子性;

3、EXEC 命令执行时实例故障,如果开启了 AOF 日志,可以保证原子性。

看下 Redis 事务中的几个命令

子命令 功能说明
DISCARD 取消事务,放弃执行事务块内的所有命令
EXEC 执行所有事务块内的命令
MULTI 标记一个事务块的开始
UNWATCH 取消 WATCH 命令对所有 key 的监视
WATCH key [key …] 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断

一致性

关于一致性的分析还是从上面三个点来展开

1、命令入队时就报错

事务本身就不会执行,一致性可以得到保证

2、命令执行的时候报错

有错误的命令不会被执行,正确的命令可以正常执行,也不会改变数据库的一致性。

3、EXEC命令执行时实例发生故障

如果没有开启持久化,那么实例故障重启后,数据都没有了,数据库是一致的。

如果使用 RDB 快照,因为 RDB 快照不会在事务执行时执行,所以事务执行的结果不会保存到 RDB 快照中,使用 RDB 快照进行恢复时,数据库中的数据也是一致性的。

如果我们使用了 AOF 日志,而事务操作还没有被记录到 AOF 日志时,实例就发生了故障,那么,使用 AOF 日志恢复的数据库数据是一致的。如果只有部分操作被记录到了 AOF 日志,我们可以使用 redis-check-aof 清除事务中已经完成的操作,数据库恢复后也是一致的。

总体看下来,Redis 中对于数据一致性属性还是有保证的。

隔离性

事务的隔离性要求每个读写事务的对象对其他事务的操作对象相互分离,即该事务提交前对其他事务都不可见。

这里分析下 Redis 中事务的隔离性,Redis 中事务的隔离性将从下面两个方面进行分析

1、如果在命令入队,EXEC执行之前,有并发操作

因为 Redis 在事务提交之前只是把命令,放入到了队列中,所以如果在命令入队,EXEC执行之前,有并发操作,这种情况下,事务是没有隔离性的。

这种情况下,可以借助于 watch 实现,来个栗子,看下 watch 如何使用

1、客户端 1 首先,使用 watch 监听一个 key,然后开始一个事务,在事务中写入一些命令;

127.0.0.1:6379> set test-mult-key 100
OK
127.0.0.1:6379> watch test-mult-key
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED

2、客户端 2 在客户端 1 事务提交之前,操作修改该键值;

127.0.0.1:6379> DECR test-mult-key
(integer) 99

3、客户端 1 提交事务;

127.0.0.1:6379> EXEC
(nil)

redis

从上面的结果可以看到如果使用 watch 之后,如果当前键值,在事务之外有了修改,那么当前事务就会放弃本次事务的执行。这样就实现了事务的隔离性。

1、如果在事务提交之后,有并发操作

这种情况下是没有问题的,Redis 会先把事务中的命令执行完成,然后再去执行后续的命令,因为 Redis 对于命令的执行是单线程的,这种情况下,可以保证事务的隔离性。

持久性

Redis 是会存在丢数据的情况的,如果在数据持久化之前,数据库宕机,那么就会有一部分数据没有及时持久化,而丢失。

所以,Redis 中不能保证事务的持久性。

为什么 Redis 不支持回滚

Redis 中为什么没有提供事务的回滚,有下面两个方面的考量

1、支持回滚会对 Redis 的简单性和性能有很大的影响;

2、Redis 中只有在 语法错误者键值的类型操作错误 中才会出错,这些问题应该在开发中解决,不应该出现在生产中。

基于上面两点的考虑,目前 Redis 中不支持事务的回滚。

源码分析

这里来简单分析下 Redis 中事务的实现过程

1、MULTI声明事务

Redis 中使用 MULTI 命令来声明和开启一个事务

// //github.com/redis/redis/blob/7.0/src/multi.c#L104
void multiCommand(client *c) {
	// 判断是否已经开启了事务
	// 不持之事务的嵌套
    if (c->flags & CLIENT_MULTI) {
        addReplyError(c,"MULTI calls can not be nested");
        return;
    }
	// 设置事务标识
    c->flags |= CLIENT_MULTI;

    addReply(c,shared.ok);
}

1、首先会判断当前客户端是是否已经开启了事务,Redis 中的事务不支持嵌套;

2、给 flags 设置事务标识 CLIENT_MULTI。

2、命令入队

开始事务之后,后面所有的命令都会被添加到事务队列中

// //github.com/redis/redis/blob/7.0/src/multi.c#L59
/* Add a new command into the MULTI commands queue */
void queueMultiCommand(client *c) {
    multiCmd *mc;

    // 这里有两种情况的判断  
    // 1、如果命令在入队是有问题就不入队了,CLIENT_DIRTY_EXEC 表示入队的时候,命令有语法的错误
    // 2、如果 watch 的键值有更改也不用入队了, CLIENT_DIRTY_CAS 表示该客户端监听的键值有变动
    if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC))
        return;
        
    // 在原commands后面配置空间以存放新命令
    c->mstate.commands = zrealloc(c->mstate.commands,
            sizeof(multiCmd)*(c->mstate.count+1));
    // 微信新配置的空间设置执行的命令和参数
    mc = c->mstate.commands+c->mstate.count;
    mc->cmd = c->cmd;
    mc->argc = c->argc;
    mc->argv = c->argv;
    mc->argv_len = c->argv_len;
    ...
}

入队的时候会做个判断:

1、如果命令在入队时有语法错误不入队了,CLIENT_DIRTY_EXEC 表示入队的时候,命令有语法的错误;

2、如果 watch 的键值有更改也不用入队了, CLIENT_DIRTY_CAS 表示该客户端监听的键值有变动;

3、client watch 的 key 有更新,当前客户端的 flags 就会被标记成 CLIENT_DIRTY_CAS,CLIENT_DIRTY_CAS 是在何时被标记,可继续看下文。

3、执行事务

命令入队之后,再来看下事务的提交

// //github.com/redis/redis/blob/7.0/src/multi.c#L140
void execCommand(client *c) {
    ...
    // 判断下是否开启了事务
    if (!(c->flags & CLIENT_MULTI)) {
        addReplyError(c,"EXEC without MULTI");
        return;
    }

    // 事务中不能 watch 有过期时间的键值
    if (isWatchedKeyExpired(c)) {
        c->flags |= (CLIENT_DIRTY_CAS);
    }

     // 检查是否需要中退出事务,有下面两种情况  
     // 1、 watch 的 key 有变化了
     // 2、命令入队的时候,有语法错误  
    if (c->flags & (CLIENT_DIRTY_CAS | CLIENT_DIRTY_EXEC)) {
        if (c->flags & CLIENT_DIRTY_EXEC) {
            addReplyErrorObject(c, shared.execaborterr);
        } else {
            addReply(c, shared.nullarray[c->resp]);
        }
        // 取消事务
        discardTransaction(c);
        return;
    }

    uint64_t old_flags = c->flags;

    /* we do not want to allow blocking commands inside multi */
    // 事务中不允许出现阻塞命令
    c->flags |= CLIENT_DENY_BLOCKING;

    /* Exec all the queued commands */
    unwatchAllKeys(c); /* Unwatch ASAP otherwise we'll waste CPU cycles */

    server.in_exec = 1;

    orig_argv = c->argv;
    orig_argv_len = c->argv_len;
    orig_argc = c->argc;
    orig_cmd = c->cmd;
    addReplyArrayLen(c,c->mstate.count);
    // 循环处理执行事务队列中的命令
    for (j = 0; j < c->mstate.count; j++) {
        c->argc = c->mstate.commands[j].argc;
        c->argv = c->mstate.commands[j].argv;
        c->argv_len = c->mstate.commands[j].argv_len;
        c->cmd = c->realcmd = c->mstate.commands[j].cmd;

        
        // 权限检查
        int acl_errpos;
        int acl_retval = ACLCheckAllPerm(c,&acl_errpos);
        if (acl_retval != ACL_OK) {
          ...
        } else {
            // 执行命令
            if (c->id == CLIENT_ID_AOF)
                call(c,CMD_CALL_NONE);
            else
                call(c,CMD_CALL_FULL);

            serverAssert((c->flags & CLIENT_BLOCKED) == 0);
        }

        // 命令执行后可能会被修改,需要更新操作
        c->mstate.commands[j].argc = c->argc;
        c->mstate.commands[j].argv = c->argv;
        c->mstate.commands[j].cmd = c->cmd;
    }

    // restore old DENY_BLOCKING value
    if (!(old_flags & CLIENT_DENY_BLOCKING))
        c->flags &= ~CLIENT_DENY_BLOCKING;
        
    // 恢复原命令
    c->argv = orig_argv;
    c->argv_len = orig_argv_len;
    c->argc = orig_argc;
    c->cmd = c->realcmd = orig_cmd;
    // 清除事务
    discardTransaction(c);

    server.in_exec = 0;
}

事务提交的时候,命令的执行逻辑还是比较简单的

1、首先会进行一些检查;

  • 检查事务有没有嵌套;

  • watch 监听的键值是否有变动;

  • 事务中命令入队列的时候,是否有语法错误;

2、循环执行,事务队列中的命令。

通过源码可以看到语法错误的时候事务才会结束执行,如果命令操作的类型不对,事务是不会停止的,还是会把正确的命令执行。

watch 是如何实现的呢

WATCH 命令用于在事务开始之前监视任意数量的键: 当调用 EXEC 命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了, 那么整个事务不再执行, 直接返回失败。

看下 watch 的键值对是如何和客户端进行映射的

// //github.com/redis/redis/blob/7.0/src/server.h#L918
typedef struct redisDb {
    ...
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    ...
} redisDb;

// //github.com/redis/redis/blob/7.0/src/server.h#L1083
typedef struct client {
    ...
    list *watched_keys;     /* Keys WATCHED for MULTI/EXEC CAS */
    ...
} client;

// //github.com/redis/redis/blob/7.0/src/multi.c#L262
// 服务端中每一个db 中都有一个 hash table 来记录客户端和 watching key 的映射,当这些 key 修改,可以标识监听这些 key 的客户端。   
//
// 每个客户端中也有一个被监听的键值对的列表,当客户端被释放或者 un-watch 被调用,可以取消监听这些 key .
typedef struct watchedKey {
    // 键值
    robj *key;
    // 键值所在的db
    redisDb *db;
    // 客户端
    client *client;
    // 正在监听过期key 的标识
    unsigned expired:1; /* Flag that we're watching an already expired key. */
} watchedKey;

redis

分析完数据结构,看下 watch 的代码实现

// //github.com/redis/redis/blob/7.0/src/multi.c#L441
void watchCommand(client *c) {
    int j;

    if (c->flags & CLIENT_MULTI) {
        addReplyError(c,"WATCH inside MULTI is not allowed");
        return;
    }
    /* No point in watching if the client is already dirty. */
    if (c->flags & CLIENT_DIRTY_CAS) {
        addReply(c,shared.ok);
        return;
    }
    for (j = 1; j < c->argc; j++)
        watchForKey(c,c->argv[j]);
    addReply(c,shared.ok);
}

// //github.com/redis/redis/blob/7.0/src/multi.c#L270
/* Watch for the specified key */
void watchForKey(client *c, robj *key) {
    list *clients = NULL;
    listIter li;
    listNode *ln;
    watchedKey *wk;

    // 检查是否正在 watch 传入的 key 
    listRewind(c->watched_keys,&li);
    while((ln = listNext(&li))) {
        wk = listNodeValue(ln);
        if (wk->db == c->db && equalStringObjects(key,wk->key))
            return; /* Key already watched */
    }
    // 没有监听,添加监听的 key 到 db 中的 watched_keys 中
    clients = dictFetchValue(c->db->**watched_keys**,key);
    if (!clients) {
        clients = listCreate();
        dictAdd(c->db->watched_keys,key,clients);
        incrRefCount(key);
    }
    // 添加 key 到 client 中的  watched_keys 中
    wk = zmalloc(sizeof(*wk));
    wk->key = key;
    wk->client = c;
    wk->db = c->db;
    wk->expired = keyIsExpired(c->db, key);
    incrRefCount(key);
    listAddNodeTail(c->watched_keys,wk);
    listAddNodeTail(clients,wk);
}

1、服务端中每一个db 中都有一个 hash table 来记录客户端和 watching key 的映射,当这些 key 修改,可以标识监听这些 key 的客户端;

2、每个客户端中也有一个被监听的键值对的列表,当客户端被释放或者 un-watch 被调用,可以取消监听这些 key ;

3、当用 watch 命令的时候,过期键会被分别添加到 redisDb 中的 watched_keys 中,和 client 中的 watched_keys 中。

上面事务的执行的时候,客户端有一个 flags, CLIENT_DIRTY_CAS 标识当前客户端 watch 的键值对有更新,那么 CLIENT_DIRTY_CAS 是在何时被标记的呢?

// //github.com/redis/redis/blob/7.0/src/db.c#L535
/*-----------------------------------------------------------------------------
 * Hooks for key space changes.
 *
 * Every time a key in the database is modified the function
 * signalModifiedKey() is called.
 *
 * Every time a DB is flushed the function signalFlushDb() is called.
 *----------------------------------------------------------------------------*/

// 每次修改数据库中的一个键时,都会调用函数signalModifiedKey()。
// 每次DB被刷新时,函数signalFlushDb()被调用。
/* Note that the 'c' argument may be NULL if the key was modified out of
 * a context of a client. */
// 当 键值对有变动的时候,会调用 touchWatchedKey 标识对应的客户端状态为 CLIENT_DIRTY_CAS
void signalModifiedKey(client *c, redisDb *db, robj *key) {
    touchWatchedKey(db,key);
    trackingInvalidateKey(c,key,1);
}

// //github.com/redis/redis/blob/7.0/src/multi.c#L348
/* "Touch" a key, so that if this key is being WATCHed by some client the
 * next EXEC will fail. */
// 修改 key 对应的客户端状态为 CLIENT_DIRTY_CAS,当前客户端 watch 的 key 已经发生了更新
void touchWatchedKey(redisDb *db, robj *key) {
    list *clients;
    listIter li;
    listNode *ln;

    // 如果 redisDb 中的 watched_keys 为空,直接返回
    if (dictSize(db->watched_keys) == 0) return;
    // 通过传入的 key 在 redisDb 的 watched_keys 中找到监听该 key 的客户端信息
    clients = dictFetchValue(db->watched_keys, key);
    if (!clients) return;

    /* Mark all the clients watching this key as CLIENT_DIRTY_CAS */
    /* Check if we are already watching for this key */
    // 将监听该 key 的所有客户端信息标识成 CLIENT_DIRTY_CAS 状态  
    listRewind(clients,&li);
    while((ln = listNext(&li))) {
        watchedKey *wk = listNodeValue(ln);
        client *c = wk->client;

        if (wk->expired) {
            /* The key was already expired when WATCH was called. */
            if (db == wk->db &&
                equalStringObjects(key, wk->key) &&
                dictFind(db->dict, key->ptr) == NULL)
            {
                /* Already expired key is deleted, so logically no change. Clear
                 * the flag. Deleted keys are not flagged as expired. */
                wk->expired = 0;
                goto skip_client;
            }
            break;
        }

        c->flags |= CLIENT_DIRTY_CAS;
        /* As the client is marked as dirty, there is no point in getting here
         * again in case that key (or others) are modified again (or keep the
         * memory overhead till EXEC). */
         // 这个客户端应该被表示成 dirty,这个客户端就不需要在判断监听了,取消这个客户端监听的 key
        unwatchAllKeys(c);

    skip_client:
        continue;
    }
}

Redis 中 redisClient 的 flags 设置被设置成 REDIS_DIRTY_CAS 位,有下面两种情况:

1、每次修改数据库中的一个键值时;

2、每次DB被 flush 时,整个 Redis 的键值被清空;

上面的这两种情况发生,redis 就会修改 watch 对应的 key 的客户端 flags 为 CLIENT_DIRTY_CAS 表示该客户端 watch 有更新,事务处理就能通过这个状态来进行判断。

几乎所有对 key 进行操作的函数都会调用 signalModifiedKey 函数,比如 setKey、delCommand、hsetCommand 等。也就所有修改 key 的值的函数,都会去调用 signalModifiedKey 来检查是否修改了被 watch 的 key,只要是修改了被 watch 的 key,就会对 redisClient 的 flags 设置 REDIS_DIRTY_CAS 位。

事务对比 Lua 脚本

事务

1、事务的使用只有在最后提交事务,并且执行完成获取到执行的结果;

2、事务的隔离性,需要引入 watch 机制的使用,会增加事务使用的复杂度;

Lua

1、命令执行的过程中,整个 Lua 脚本的执行都是原子性的,所以不会存在事务中的隔离性问题;

2、Lua 的执行中,在运行的过程中,就能获取到执行的结果,可以使用前面命令的执行结果,做后续的操作;

3、因为 Lua 执行过程中是原子性的,所以不推荐用来执行耗时的命令;

除了上面几个使用场景的限制,这里看下官方文档对此的描述

Something else to consider for transaction like operations in redis are redis scripts which are transactional. Everything you can do with a Redis Transaction, you can also do with a script, and usually the script will be both simpler and faster.

翻译下来就是

Redis Lua脚本的定义是事务性的,所以你可以用 Redis 事务做的所有事情,你也可以用 Lua 脚本来做,通常脚本会更简单和更快。

文档地址

所以可以知道,相比于事务,还是更推荐去使用 Lua 脚本。

总结

1、事务在执行过程中不会被中断,所有事务命令执行完之后,事务才能结束;

2、多个命令会被入队到事务队列中,然后按先进先出(FIFO)的顺序执行;

3、事务本身没有实现隔离性,可以借助于 watch 命令来实现;

4、Redis 事务在执行的过程中,发生语法问题,整个事务才会报错不执行,如果仅仅是类型操作的错误,事务还是正常执行,还是会把正确的命令执行完成;

5、Redis 中为什么没有提供事务的回滚,有下面两个方面的考量;

  • 1、支持回滚会对 Redis 的简单性和性能有很大的影响;

  • 2、Redis 中只有在 语法错误者键值的类型操作错误 中才会出错,这些问题应该在开发中解决,不应该出现在生产中。

6、Redis 中的 Lua 脚本也是事务性的,相比于事务,还是更推荐去使用 Lua 脚本。

参考

【Redis核心技术与实战】//time.geekbang.org/column/intro/100056701
【Redis设计与实现】//book.douban.com/subject/25900156/
【Redis 的学习笔记】//github.com/boilingfrog/Go-POINT/tree/master/redis
【数据库事务】//baike.baidu.com/item/数据库事务/9744607
【transactions】//redis.io/docs/manual/transactions/
【Redis中的事务分析】//boilingfrog.github.io/2022/06/19/Redis中的事务分析/

Tags: