Redis事务

1. 概述

Redis单条命令的执行可以保证原子性,但是如果需要保证多个命令执行的原子性,就需要使用到Redis的事务。Redis的事务有如下特点:

  1. 事务中的所有命令可以保证顺序的执行,不会出现事务执行过程中,被其他事务的命令中断。
  2. 事务中的命令要么所有的都被执行,要么所有的都不执行(这里说的执行,不是说的执行成功)。

2. 基本用法

2.1 基本命令

  • MULTI:该命令是事务的入口,该命令之后的所有命令都只会被放入队列;
  • EXEC:该命令是事务的结束,使用该命令之后,事务队列中的所有命令开始执行;
  • DISCARD:该命令也是事务的结束,使用该命令之后,将会丢弃事务队列中的命令。

2.2 用法举例

2.2.1 正常执行

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> GET k1
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) OK
3) "v1"
127.0.0.1:6379> GET k1
"v1"
127.0.0.1:6379> get k2
"v2"

EXEC执行的结果是一个数组,数组中每个元素是事务中一条命令的执行结果,结果的顺序按照命令进入队列顺序排列。

2.2.2 放弃事务

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 v11
QUEUED
127.0.0.1:6379> set k2 v22
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> get k2
"v2"

执行DISCARD命令后,所有指令都放弃执行,k1和k2的值都没有修改。

2.2.3 语法错误

EXEC命令执行前,入队列的命令可能会因为语法错误(命令名称或者命令参数错误),或者因为Redis服务器内存不足,而入队列失败。
Redis 2.6.5之后的版本,Redis会记住在命令入队列时存在错误,并在运行EXEC命令时,拒绝执行事务并返回一个错误。Redis 2.6.5之前的版本,可以通过指定EXEC忽略错误,而只执行正确的命令。

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INC k1
(error) ERR unknown command `INC`, with args beginning with: `k1`,
127.0.0.1:6379> inc k2
(error) ERR unknown command `inc`, with args beginning with: `k2`,
127.0.0.1:6379> set k2 v22
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> get k2
"v2"

可以看到,输入命令时就返回了错误,并且在执行EXEC时,也返回了错误。就算“set k2 v22”是正确的命令,但是也放弃执行了,最后k1和k2的值都未修改。

2.2.4 命令执行错误

EXEC执行之后,队列中的命令可能执行失败,比如对string类型的数据运行INCR指令。Redis遇到这样的错误,不会放弃事务,会继续执行正确的命令。

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> incr k1
QUEUED
127.0.0.1:6379> set k2 v22
QUEUED
127.0.0.1:6379> EXEC
1) (error) ERR value is not an integer or out of range
2) OK
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> get k2
"v22"

可以看到incr k1并没有执行成功,但是set k2 v22执行成功了。EXEC的结果与正常执行的结果一致,事务中各个指令的结果以数组的形式返回。

为什么Redis不实现回滚呢?

既然redis提供的是事务,为什么不跟RDB一样,在命令执行错误时,将整个事务回滚,而是要继续执行后续正确的命令呢?Redis团队的观点如下:

  • 这种场景下命令执行失败的原因可能是语法错误(并且是在输入命令时不能检测到的错误)或者key对应的数据类型不正确。这意味着出现这样的错误很可能是程序的bug,很有可能在开发过程中就能解决的,而不会延续的生产环境才发现。
  • 为了保证Redis的简单快速特性,如果引入回滚,就会影响到Redis的性能。

2.3 乐观锁(CAS)

WATCH命令用来为Redis的事务提供CAS功能,如果有一个watch的key在EXEC执行之前被修改过,那么整个事务都会退出并且EXEC返回一个nil。

2.3.1 事例

  1. 打开两个Redis客户端,在第一个客户端中输入如下命令:
127.0.0.1:6379> watch k1
OK
  1. 在第二个客户端输入(其实这个命令在客户端1执行,但是必须在multi之前,结果也是一样的):
127.0.0.1:6379> set k1 v111
OK
  1. 回到客户端1,输入:
127.0.0.1:6379> multi
OK
127.0.0.1:6379> SET k1 v11
QUEUED
127.0.0.1:6379> set k2 v22
QUEUED
127.0.0.1:6379> EXEC
(nil)
127.0.0.1:6379> get k1
"v111"
127.0.0.1:6379> get k2
"v2"

可以看到,在客户端1的事务EXEC执行时,返回了nil,表示事务没有执行成功,并且k1和k2的值并没有受到事务中的命令的影响。

2.3.2 总结

  1. WATCH不能在事务过程中运行,也就是不能在MULTIEXEC命令之间运行WATCH
  2. 只要开始了WATCH,不管在MULTI之前(当前客户端或者另一个客户端)还是MULTI之后(必须在另外一个客户端)修改,(只要在EXEC之前修改)事务都会退出;
  3. 一但执行 EXEC开启事务的执行后,无论事务使用执行成功, WATCH对变量的监控都将被取消。
    如果连接断开,WATCH也将自动停止。
    也可以通过UNWATCH手动取消。
  4. 如果WATCH的是一个定时key,如果key在EXEC时过期了,则事务仍然可以执行成功。但是如果在过期前修改过,然后再过期,则事务会执行失败;

3. 实现原理

要了解Redis事务的实现原理,无非就是了解以下几点:

  1. 执行MULTI命令时,Redis是怎么处理的?
  2. 执行MULTI命令后,Redis是怎么将命令入队列的?队列的结构是怎样的?
  3. 执行EXEC命令时,Redis是怎么处理的?
  4. 执行DISCARD命令时,Redis是怎么处理的?
  5. 执行WATCH后,Redis会怎么处理?

带着以上几点问题,下面逐一阅读源代码解答(源代码版本:5.0.7)。

3.1 MULTI原理

MULTI命令对应函数为:multi.c文件的multiCommand(),源码如下:

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);
}

运行MULTI命令后,即为client打上了CLIENT_MULTI标记,并返回OK。
扩展

  1. client的flags为int类型,占4个字节,即32位;
  2. CLIENT_MULTI定义为#define CLIENT_MULTI (1<<3),即flags的从右数第四位表示客户端进入了事务。

3.2 命令入队列原理

我们知道,运行了MULTI命令后,其后的命令会被加入队列,并返回QUEUE。那么:

  1. Redis是怎么判断命令应该进入队列?
  2. 命令进入的是哪个队列?队列中的元素数据结构是怎样的?
  3. 如果命令出现语法错误,怎么处理?

3.2.1 判断是否进入队列

Redis处理命令的入口函数是server.c文件的processCommand(),关键源代码如下:

int processCommand(client *c) {
    // 省略前方部分代码
    /* Exec the command */
    if (c->flags & CLIENT_MULTI &&
        c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
        c->cmd->proc != multiCommand && c->cmd->proc != watchCommand)
    {
        queueMultiCommand(c);
        addReply(c,shared.queued);
    } else {
        call(c,CMD_CALL_FULL);
        c->woff = server.master_repl_offset;
        if (listLength(server.ready_keys))
            handleClientsBlockedOnKeys();
    }
    return C_OK;
}

第一个if判断客户端是否已经开启事务,如果开启,并且当前命令不是EXECDISCARDMULTIWATCH,则会调用queueMultiCommand(c)函数将命令放入队列。

3.2.2 进入的是哪个队列

/* Add a new command into the MULTI commands queue */
void queueMultiCommand(client *c) {
    multiCmd *mc;
    int j;

    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 = zmalloc(sizeof(robj*)*c->argc);
    memcpy(mc->argv,c->argv,sizeof(robj*)*c->argc);
    for (j = 0; j < c->argc; j++)
        incrRefCount(mc->argv[j]);
    c->mstate.count++;
    c->mstate.cmd_flags |= c->cmd->flags;
}

可以看出,命令被放入的队列为client的mstate的commands数组。

3.2.3 队列数据结构

client.mstate

mstate的定义在server.h文件中,用于存储客户端的事务数据,命令会被保存到commands数组中。

typedef struct multiState {
    // 事务命令数组
    multiCmd *commands;     /* Array of MULTI commands */
    // 命令总个数
    int count;              /* Total number of MULTI commands */
    int cmd_flags;          /* The accumulated command flags OR-ed together.
                               So if at least a command has a given flag, it
                               will be set in this field. */
    int minreplicas;        /* MINREPLICAS for synchronous replication */
    time_t minreplicas_timeout; /* MINREPLICAS timeout as unixtime. */
} multiState;

multiCmd

commands数组中保存的元素是什么结构呢?multiCmd定义在server.h文件中:

/* Client MULTI/EXEC state */
typedef struct multiCmd {
    // 参数数组
    robj **argv;
    // 参数个数
    int argc;
    // 命令定义(命令名称、命令对应执行方法等)
    struct redisCommand *cmd;
} multiCmd;

3.2.4 命令错误,怎么处理

回到server.c文件的processCommand()方法,其中会判断命令是否正确。以命令语法检查代码为例:

int processCommand(client *c) {
    // 省略前方部分代码
    /* Now lookup the command and check ASAP about trivial error conditions
     * such as wrong arity, bad command name and so forth. */
    c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
    if (!c->cmd) {
        flagTransaction(c);
        sds args = sdsempty();
        int i;
        for (i=1; i < c->argc && sdslen(args) < 128; i++)
            args = sdscatprintf(args, "`%.*s`, ", 128-(int)sdslen(args), (char*)c->argv[i]->ptr);
        addReplyErrorFormat(c,"unknown command `%s`, with args beginning with: %s",
            (char*)c->argv[0]->ptr, args);
        sdsfree(args);
        return C_OK;
    } else if ((c->cmd->arity > 0 && c->cmd->arity != c->argc) ||
               (c->argc < -c->cmd->arity)) {
        flagTransaction(c);
        addReplyErrorFormat(c,"wrong number of arguments for '%s' command",
            c->cmd->name);
        return C_OK;
    }
    // 省略后方部分代码
}

如果检查到命令不存在或者命令参数不正确,则会调用flagTransaction(c)标记事务,然后返回错误信息。

flagTransaction(c)

该函数的定义在multi.c文件中:

/* Flag the transacation as DIRTY_EXEC so that EXEC will fail.
 * Should be called every time there is an error while queueing a command. */
void flagTransaction(client *c) {
    if (c->flags & CLIENT_MULTI)
        c->flags |= CLIENT_DIRTY_EXEC;
}

该函数的实现很简单,只是单纯的为客户端打上CLIENT_DIRTY_EXEC标记,当运行EXEC命令时,检查到该标记,则直接放弃运行。
扩展

  1. client的flags为int类型,占4个字节,即32位;
  2. CLIENT_DIRTY_EXEC定义为#define CLIENT_DIRTY_EXEC (1<<12),也即flags的从右数第13位表示客户端事务需要放弃执行。

3.3 EXEC原理

EXEC对应的函数是multi.c文件中的execCommand(),主要源代码为:

void execCommand(client *c) {
    // ...省略部分代码
    // 1.
    if (!(c->flags & CLIENT_MULTI)) {
        addReplyError(c,"EXEC without MULTI");
        return;
    }
    /* Check if we need to abort the EXEC because:
     * 1) Some WATCHed key was touched.
     * 2) There was a previous error while queueing commands.
     * A failed EXEC in the first case returns a multi bulk nil object
     * (technically it is not an error but a special behavior), while
     * in the second an EXECABORT error is returned. */
    // 2.
    if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) {
        addReply(c, c->flags & CLIENT_DIRTY_EXEC ? shared.execaborterr :
                                                  shared.nullmultibulk);
        discardTransaction(c);
        goto handle_monitor;
    }
    // ...省略部分代码
    // 3.
    for (j = 0; j < c->mstate.count; j++) {
        c->argc = c->mstate.commands[j].argc;
        c->argv = c->mstate.commands[j].argv;
        c->cmd = c->mstate.commands[j].cmd;

        /* Propagate a MULTI request once we encounter the first command which
         * is not readonly nor an administrative one.
         * This way we'll deliver the MULTI/..../EXEC block as a whole and
         * both the AOF and the replication link will have the same consistency
         * and atomicity guarantees. */
        if (!must_propagate && !(c->cmd->flags & (CMD_READONLY|CMD_ADMIN))) {
            execCommandPropagateMulti(c);
            must_propagate = 1;
        }

        call(c,server.loading ? CMD_CALL_NONE : CMD_CALL_FULL);

        /* Commands may alter argc/argv, restore mstate. */
        c->mstate.commands[j].argc = c->argc;
        c->mstate.commands[j].argv = c->argv;
        c->mstate.commands[j].cmd = c->cmd;
    }
    // ...省略部分代码
}

代码主要包括4个逻辑:

  1. 判断是否执行MULTI了,否则不能运行EXEC
  2. 判断是否有CLIENT_DIRTY_CAS或者CLIENT_DIRTY_EXEC标记,有则放弃执行事务;
  3. 遍历multiState的指令数组,逐个执行指令;
  4. 执行完成,清理事务资源,调用discardTransaction(c)函数。

3.4 DISCARD原理

对应的函数是multi.c文件中的discardCommand()函数:

void discardCommand(client *c) {
    if (!(c->flags & CLIENT_MULTI)) {
        addReplyError(c,"DISCARD without MULTI");
        return;
    }
    discardTransaction(c);
    addReply(c,shared.ok);
}

执行DISCARD命令时,会调用discardTransaction(c)函数,对事务进行清理。

discardTransaction(c)

void discardTransaction(client *c) {
    // 释放事务所占资源(命令数组、命令)
    freeClientMultiState(c);
    // 重置事务资源(命令数组设置为null、命令个数置为0)
    initClientMultiState(c);
    // 清理事务标记
    c->flags &= ~(CLIENT_MULTI|CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC);
    // 清理watch的key
    unwatchAllKeys(c);
}

概括起来,该函数主要用于释放事务相关资源,清除内存的占用。

3.5 WATCH原理

了解WATCH原理,主要是了解以下几点:

  1. WATCH执行时,怎么处理?
  2. WATCH的key被修改时,怎么处理?
  3. 怎么UNWATCH

3.5.1 WATCH执行时,怎么处理?

很简单,必须要将watch的key的信息保存起来,Redis是怎么处理以及怎么保存的呢?
对应函数为multi.c文件中的watchCommand():

void watchCommand(client *c) {
    int j;
    if (c->flags & CLIENT_MULTI) {
        addReplyError(c,"WATCH inside MULTI is not allowed");
        return;
    }
    for (j = 1; j < c->argc; j++)
        watchForKey(c,c->argv[j]);
    addReply(c,shared.ok);
}

该方法核心是调用watchForKey(),负责保存watch信息

/* Watch for the specified key */
void watchForKey(client *c, robj *key) {
    // ...省略部分代码
    watchedKey *wk;
    /* Check if we are already watching for this 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 */
    }
    /* This key is not already watched in this DB. Let's add it */
    clients = dictFetchValue(c->db->watched_keys,key);
    if (!clients) {
        clients = listCreate();
        dictAdd(c->db->watched_keys,key,clients);
        incrRefCount(key);
    }
    listAddNodeTail(clients,c);
    /* Add the new key to the list of keys watched by this client */
    wk = zmalloc(sizeof(*wk));
    wk->key = key;
    wk->db = c->db;
    incrRefCount(key);
    listAddNodeTail(c->watched_keys,wk);
}

可以看出:

  1. Redis会在DB中保存一份watch key的clients数组,数组元素为client。

  2. Redis会在client中保存一份watch的所有key的数组,数组元素为watchedKey,结构为:

typedef struct watchedKey {
     robj *key;
     redisDb *db;
} watchedKey;
  1. Redis会根据client中保存的数组来判断客户端是否已经watch了。

3.5.2 WATCH的key被修改时,怎么处理?

DB中的key每一次被修改时,都会调用db.c文件中的signalModifiedKey()方法,该方法的核心是调用multi.c文件中的touchWatchedKey():

/* "Touch" a key, so that if this key is being WATCHed by some client the
 * next EXEC will fail. */
void touchWatchedKey(redisDb *db, robj *key) {
    list *clients;
    listIter li;
    listNode *ln;

    if (dictSize(db->watched_keys) == 0) return;
    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 */
    listRewind(clients,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);

        c->flags |= CLIENT_DIRTY_CAS;
    }
}

该方法会检查DB中保存的watched_keys信息,如果检查到当前修改的key被watch了,则标记对应的client为CLIENT_DIRTY_CAS。后面EXEC命令执行时,检查到有该标记,就会放弃事务的执行。

3.5.3 怎么UNWATCH

我们知道有三种方式可以UNWATCH一个key:

  1. 事务EXEC/DISCARD
  2. 客户端断开连接(networking.c文件的freeClient()函数)
  3. 调用UNWATCH指令(multi.c文件的unwatchCommand()函数)
    这三种场景下都会调用multi.c文件中的unwatchAllKeys():
/* Unwatch all the keys watched by this client. To clean the EXEC dirty
 * flag is up to the caller. */
void unwatchAllKeys(client *c) {
    listIter li;
    listNode *ln;

    if (listLength(c->watched_keys) == 0) return;
    listRewind(c->watched_keys,&li);
    while((ln = listNext(&li))) {
        list *clients;
        watchedKey *wk;

        /* Lookup the watched key -> clients list and remove the client
         * from the list */
        wk = listNodeValue(ln);
        clients = dictFetchValue(wk->db->watched_keys, wk->key);
        serverAssertWithInfo(c,NULL,clients != NULL);
        listDelNode(clients,listSearchKey(clients,c));
        /* Kill the entry at all if this was the only client */
        if (listLength(clients) == 0)
            dictDelete(wk->db->watched_keys, wk->key);
        /* Remove this watched key from the client->watched list */
        listDelNode(c->watched_keys,ln);
        decrRefCount(wk->key);
        zfree(wk);
    }
}

该函数会:

  1. 清除DB中保存的watch 客户端,如果处理该客户端外,没有其他客户端watch了,则直接删除该key的watch信息;
  2. 清楚client的所有watch key信息。

4. 总结

  1. Redis事务与传统RDB提供的事务区别还是挺大的,其实只是简单的为了原子性的执行多个命令而设计的罢了;
  2. Redis实现事务的代码也是很直观的,没有什么特殊的逻辑。建议在看源代码之前,可以先想想如果是自己实现,会怎么处理。然后带着问题去看源代码,就可以快速理解Redis为什么会这么实现了。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,519评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,842评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,544评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,742评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,646评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,027评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,513评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,169评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,324评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,268评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,299评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,996评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,591评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,667评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,911评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,288评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,871评论 2 341

推荐阅读更多精彩内容