Redis事务原理

525 阅读7分钟

事务概要

数据库事务(简称:事务)通常包含了一个序列的对数据库的读/写操作。包含有以下两个目的:

1.为数据库操作序列提供了一个从失败中恢复到正常状态的方法,同时提供了数据库即使在异常状态下仍能保持一致性的方法。

2.当多个应用程序在并发访问数据库时,可以在这些应用程序之间提供一个隔离方法,以防止彼此的操作互相干扰。

当事务被提交给了数据库管理系统(DBMS),则DBMS需要确保该事务中的所有操作都成功完成且其结果被永久保存在数据库中,如果事务中有的操作没有成功完成,则事务中的所有操作都需要回滚,回到事务执行前的状态;同时,该事务对数据库或者其他事务的执行无影响,所有的事务都好像在独立的运行。

--以上摘自维基百科

Redis事务

Redis通过MULTIEXECDISCARDWATCH这几个命令来实现事务的。它们允许一次执行一组命令,为了实现这点,Redis做了俩点保证:

1、事务中的所有命令都被序列化并顺序执行。 在Redis事务的执行过程中,永远不会发生另一个客户端发出的请求被执行。这样可以确保命令作为单个隔离操作执行。

2、要么所有命令被全部执行,要么一个都没执行,所以Redis事务是原子性的。EXEC命令触发事务中所有命令的执行,因此,如果一个事务中,客户端在调用EXEC命令之前失去与服务器的连接,则不执行任何操作。当使用AOF时,如果Redis服务器崩溃或被系统管理员强制杀死,则可能仅注册了部分操作。 Redis将在重新启动时检测到这种情况,并且将退出并显示错误。 使用redis-check-aof工具可以修复AOF文件,将文件中的部分事务删除,以便服务器可以重新启动。

事务使用

MULTI命令开始事务,之后的命令都被缓存在一个数组中,直到Redis接受到EXEC命令,执行事务。

> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1

当客户端向Redis发送MULTI后,Redis将客户端状态信息的flagsCLIENT_MULTI位开启,

void multiCommand(client *c) {
    ...
    c->flags |= CLIENT_MULTI;
    ...
}

在Redis命令处理函数中:

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);
    } else {
        ...
    }
    ...
}

只要不是MULTIEXECDISCARDWATCH中的任一个命令,都会被追加到客户端状态信息的缓存数组中,当接收到EXEC命令后,Redis开始按顺序执行缓存的命令。 multi之后命令缓存数组

EXEC后,事务执行顺序:

当事务执行完后,Redis会清空缓存数组,事务执行结束,返回每个命令的执行结果。

EXEC之前出错

如果在EXEC命令没执行之前,出错了,如命令错误、参数错误、客户端断开连接等等,Redis会调用flagTransaction函数,将客户端状态信息的flagsCLIENT_DIRTY_EXEC位开启:

/* Flag the transaction 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;
}

当接收到EXEC命令后,EXEC命令会检查flagsCLIENT_DIRTY_EXEC位是否开启,如果是,Redis将调用discardTransaction函数清空事务缓存数组,并返回错误信息。

>MULTI
"OK"
>INCR foo
"QUEUED"
>INCR bar
"QUEUED"
>INCRB bar2
"ERR unknown command 'incrb'"
>INCR bar2
"QUEUED"
>EXEC
"EXECABORT Transaction discarded because of previous errors."

EXEC命令执行过程中出错

Redis会跳过错误的命令,继续执行事务中的下一个命令。

>MULTI
+OK
>SET a abc
+QUEUED
>LPOP a
+QUEUED
>EXEC
*2
+OK
-ERR Operation against a key holding the wrong kind of value
>GET a
"abc"

这点和MySQL不一样,Redis事务并不会回滚。关于这点,官网已经给了说明:

  • Redis commands can fail only if called with a wrong syntax (and the problem is not detectable during the command queueing), or against keys holding the wrong data type: this means that in practical terms a failing command is the result of a programming errors, and a kind of error that is very likely to be detected during development, and not in production.

  • Redis is internally simplified and faster because it does not need the ability to roll back.

An argument against Redis point of view is that bugs happen, however it should be noted that in general the roll back does not save you from programming errors. For instance if a query increments a key by 2 instead of 1, or increments the wrong key, there is no way for a rollback mechanism to help. Given that no one can save the programmer from his or her errors, and that the kind of errors required for a Redis command to fail are unlikely to enter in production, we selected the simpler and faster approach of not supporting roll backs on errors.

DISCARD命令

当我们想放弃事务时,可以通过DISCARD命令实现。

>MULTI
"OK"
>INCR foo
"QUEUED"
>INCR bar
"QUEUED"
>DISCARD
"OK"
>GET foo
null

DISCARD命令会将客户端状态信息flagsCLIENT_MULTICLIENT_DIRTY_CASCLIENT_DIRTY_EXEC位关闭(CLIENT_DIRTY_CAS介绍WATCH命令时说明),并清空事务缓存数组等资源回收。

到这里事务只是序列化执行命令,并没有隔离性。

client-1>MULTI
"OK"
client-1>INCR foo
"QUEUED"
client-1>INCR bar        client-2>INCR foo
"QUEUED"                 "1"
client-1>EXEC
 1)  "OK"
 2)  "2"
 3)  "OK"
 4)  "1"
 5)  "OK"
 client-1>GET foo
 "2"

这显然是错误的。

这时的事务和pipelining并没有多大区别,都是一次执行多个命令,只不过pipelining是先将命令缓存在客户端,然后一次性发送给Redis执行,而事务是缓存在服务端,再一次性执行。

众所周知,实现事务的隔离性一般都使用机制,Redis使用WATCH实现了乐观锁

WATCH命令

WATCH key [key ...]

Redis会将WATCH后面的key保存在服务端db的watched_keys字典中,字典的键就是每个key,字典的值是个列表,列表里每个元素就是WATCH这个key的所有客户端。

并且客户端状态信息中有个watched_keys列表,保存每个客户端WATCH的key,用于防止重复WATCH、判断是否关注某个key、UNWATCH时使用等。

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

每次执行修改命令时,Redis都会执行signalModifiedKey钩子函数函数,

/* Every time a key in the database is modified the function
 * signalModifiedKey() is called. */
void signalModifiedKey(redisDb *db, robj *key) {
    touchWatchedKey(db,key);
}

遍历客户端状态信息的watched_keys列表,判断客户端是否关注了这个key,如果关注了,就将客户端状态信息flagsCLIENT_DIRTY_CAS位开启。

当执行EXEC命令时,会判断flagsCLIENT_DIRTY_CAS位是否开启,如果开启,放弃事务,并报错。

相关命令源码

// MULTI命令
void multiCommand(client *c) {
    if (c->flags & CLIENT_MULTI) {
        addReplyError(c,"MULTI calls can not be nested");
        return;
    }
    // 开启CLIENT_MULTI位
    c->flags |= CLIENT_MULTI;
    addReply(c,shared.ok);
}

// DISCARD命令
void discardCommand(client *c) {
    if (!(c->flags & CLIENT_MULTI)) {
        addReplyError(c,"DISCARD without MULTI");
        return;
    }
    // 事务资源回收
    discardTransaction(c);
    addReply(c,shared.ok);
}

void discardTransaction(client *c) {
    // 回收事务缓存数组
    freeClientMultiState(c);
    // 重新初始化缓存数组
    initClientMultiState(c);
    // 关闭CLIENT_MULTI、CLIENT_DIRTY_CAS、CLIENT_DIRTY_EXEC位
    c->flags &= ~(CLIENT_MULTI|CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC);
    // unwatch所有key
    unwatchAllKeys(c);
}

// EXEC命令
void execCommand(client *c) {
    ...
    /* 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. */
    // 判断CLIENT_DIRTY_CAS、CLIENT_DIRTY_EXEC是否开启
    if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) {
        ...
        // 事务资源回收
        discardTransaction(c);
        ...
    }
    ...
}

// WATCH命令
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);
}

/* Watch for the specified key */
void watchForKey(client *c, robj *key) {
    ...
    /* 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);
}

// UNWATCH命令
void unwatchCommand(client *c) {
    // 回收watch keys
    unwatchAllKeys(c);
    // 关闭CLIENT_DIRTY_CAS位
    c->flags &= (~CLIENT_DIRTY_CAS);
    addReply(c,shared.ok);
}

后记

可以将Redis事务的实现原理和pipeliningpub/sub结合起来理解,有助于记忆。 没有使用WATCH时,和pipelining差不多,一次性执行一组命令,只不过事务时将命令缓存在服务端,pipelining是将命令缓存在客户端,再发送给服务端一次性执行。

使用WATCH命令后,watched keys的维护逻辑和pub/sub的维护逻辑差不多。大家可以看一下我之前写的《Redis的pub/sub 实现原理》。给自己打一波广告😁