redis 事务回滚_客户端

本文详细接收了Redis事务的使用方式,并通过阅读源码,分析了Redis事务的实现原理,内容摘自新书《Redis核心原理与实践》。

Redis支持事务机制,但Redis的事务机制与传统关系型数据库的事务机制并不相同。
Redis事务的本质是一组命令的集合(命令队列)。事务可以一次执行多个命令,并提供以下保证:
(1)事务中的所有命令都按顺序执行。事务命令执行过程中,其他客户端提交的命令请求需要等待当前事务所有命令执行完成后再处理,不会插入当前事务命令队列中。
(2)事务中的命令要么都执行,要么都不执行,即使事务中有些命令执行失败,后续命令依然被执行。因此Redis事务也是原子的。
注意Redis不支持回滚,如果事务中有命令执行失败了,那么Redis会继续执行后续命令而不是回滚。
可能有读者疑惑Redis是否支持ACID?笔者认为,ACID概念起源于传统的关系型数据库,而Redis是非关系型数据库,而且Redis并没有声明是否支持ACID,所以本文不讨论该问题。

事务的应用示例

Redis提供了MULTI、EXEC、DISCARD和WATCH命令来实现事务功能:

> MULTI
OK
> SET points 1
QUEUED
> INCR points
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1
  • MULTI命令可以开启一个事务,后续的命令都会被放入事务命令队列。
  • EXEC命令可以执行事务命令队列中的所有命令,DISCARD命令可以抛弃事务命令队列中的命令,这两个命令都会结束当前事务。
  • WATCH命令可以监视指定键,当后续事务执行前发现这些键已修改时,则拒绝执行事务。

表17-1展示了一个WATCH命令的简单使用示例。

redis 事务回滚_客户端_02


可以看到,在执行EXEC命令前如果WATCH的键被修改,则EXEC命令不会执行事务,因此WATCH常用于实现乐观锁。

事务的实现原理

server.h/multiState结构体负责存放事务信息:

typedef struct multiState {
    multiCmd *commands;
    ...
} multiState;
  • commands:事务命令队列,存放当前事务所有的命令。
    客户端属性client.mstate指向一个multiState变量,该multiState作为客户端的事务上下文,负责存放该客户端当前的事务信息。
    下面看一下MULTI、EXEC和WATCH命令的实现。

WATCH命令的实现

提示:本章代码如无特殊说明,均在multi.c中。

WATCH命令的实现逻辑较独立,我们先分析该命令的实现逻辑。

redisDb中定义了字典属性watched_keys,该字典的键是数据库中被监视的Redis键,字典的值是监视字典键的所有客户端列表,如图17-1所示。

redis 事务回滚_客户端_03

client中也定义了列表属性watched_keys,记录该客户端所有监视的键。
watchCommand函数负责处理WATCH命令,该函数会调用watchForKey函数处理相关逻辑:

void watchForKey(client *c, robj *key) {
    ...
    // [1]
    clients = dictFetchValue(c->db->watched_keys,key);
    ...
    listAddNodeTail(clients,c);
    
    // [2]
    wk = zmalloc(sizeof(*wk));
    wk->key = key;
    wk->db = c->db;
    incrRefCount(key);
    listAddNodeTail(c->watched_keys,wk);
}

【1】将客户端添加到redisDb.watched_keys字典中该Redis键对应的客户端列表中。
【2】初始化watchedKey结构体(wk变量),该结构体可以存储被监视键和对应的数据库。 将wk变量添加到client.watched_keys中。
Redis中每次修改数据时,都会调用signalModifiedKey函数,将该数据标志为已修改。
signalModifiedKey函数会调用touchWatchedKey函数,通知监视该键的客户端数据已修改:

void touchWatchedKey(redisDb *db, robj *key) {
    ...
    clients = dictFetchValue(db->watched_keys, key);
    if (!clients) return;

    listRewind(clients,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);

        c->flags |= CLIENT_DIRTY_CAS;
    }
}

从redisDb.wzatched_keys中获取所有监视该键的客户端,给这些客户端添加CLIENT_ DIRTY_CAS标志,该标志代表客户端监视的键已被修改。

MULTI、EXEC命令的实现

MULTI命令由multiCommand函数处理,该函数的处理非常简单,就是打开客户端CLIENT_MULTI标志,代表该客户端已开启事务。
前面说过,processCommand函数执行命令时,会检查客户端是否已开启事务。如果客户端已开启事务,则调用queueMultiCommand函数,将命令请求添加到客户端事务命令队列client.mstate.commands中:

int processCommand(client *c) {
    ...
    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);
    } ...
    return C_OK;
}

可以看到,如果当前客户端开启了事务,则除了MULTI、EXEC、DISCARD和WATCH命令,其他命令都会放入到事务命令队列中。
EXEC命令由execCommand函数处理:

void execCommand(client *c) {
    ...

    // [1]
    if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) {
        addReply(c, c->flags & CLIENT_DIRTY_EXEC ? shared.execaborterr : shared.nullarray[c->resp]);
        discardTransaction(c);
        goto handle_monitor;
    }


    // [2]
    unwatchAllKeys(c);
    ...
    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->cmd = c->mstate.commands[j].cmd;

        // [3]
        if (!must_propagate &&
            !server.loading &&
            !(c->cmd->flags & (CMD_READONLY|CMD_ADMIN)))
        {
            execCommandPropagateMulti(c);
            must_propagate = 1;
        }
        // [4]
        int acl_keypos;
        int acl_retval = ACLCheckCommandPerm(c,&acl_keypos);
        if (acl_retval != ACL_OK) {
            ...
        } else {
            call(c,server.loading ? CMD_CALL_NONE : CMD_CALL_FULL);
        }
        ...
    }
    // [5]
    ...
    discardTransaction(c);

    // [6]
    if (must_propagate) {
        int is_master = server.masterhost == NULL;
        server.dirty++;
        ...
    }    
    ...
}

【1】当客户端监视的键被修改(客户端存在CLIENT_DIRTY_CAS标志)或者客户端已拒绝事务中的命令(客户端存在CLIENT_DIRTY_EXEC标志)时,直接抛弃事务命令队列中的命令,并进行错误处理。
当服务器处于异常状态(如内存溢出)时,Redis将拒绝命令,并给开启了事务的客户端添加CLIENT_DIRTY_EXEC标志。
【2】取消当前客户端对所有键的监视,所以WATCH命令只能作用于后续的一个事务。
【3】在执行事务的第一个写命令之前,传播MULTI命令到AOF文件和从节点。MULTI命令执行完后并不会被传播(MULTI命令并不属于写命令),如果事务中执行了写命令,则在这里传播MULTI命令。
【4】检查用户的ACL权限,检查通过后执行命令。
【5】执行完所有命令,调用discardTransaction函数重置客户端事务上下文client.mstate,并删除CLIENT_MULTI、CLIENT_DIRTY_CAS、CLIENT_DIRTY_EXEC标志,代表当前事务已经处理完成。
【6】如果事务中执行了写命令,则修改server.dirty,这样会使server.c/call函数将EXEC命令传播到AOF文件和从节点,从而保证一个事务的MULTI、EXEC命令都被传播。
关于Redis不支持回滚机制,Redis在官网中给出了如下解释:
(1)仅当使用了错误语法(并且该错误无法在命令加入队列期间检测)或者Redis命令操作数据类型错误(比如对集合类型使用了HGET命令)时,才可能导致事务中的命令执行失败,这意味着事务中失败的命令是编程错误的结果,所以这些问题应该在开发过程中发现并处理,而不是依赖于在生产环境中的回滚机制来规避。
(2)不支持回滚,Redis事务机制实现更简单并且性能更高。
Redis的事务非常简单,即在一个原子操作内执行多条命令。Redis的Lua脚本也是事务性的,所以用户也可以使用Lua脚本实现事务。Redis Lua脚本会在后续章节详细分析。
总结:

  • Redis事务保证多条命令在一个原子操作内执行。
  • Redis提供了MULTI、EXEC、DISCARD和WATCH命令来实现事务功能。
  • 使用WATCH命令可以实现乐观锁机制。