Redis 通过 MULTI 、 DISCARD 、 EXEC 和 WATCH 、UNWATCH 5个命令来实现事务功能, 本章首先讨论使用 MULTI 、 DISCARD 和 EXEC 三个命令实现的一般事务, 然后再来讨论带有 WATCH 的事务的实现,最后通过常见的 ACID 性质对 Redis 事务的安全性进行了说明。
事务提供了一种“将多个命令打包, 然后一次性、按顺序地执行”的机制, 并且事务在执行的期间不会主动中断 —— 服务器在执行完事务中的所有命令之后, 才会继续处理其他客户端的其他命令。
一个事务从开始到执行会经历以下三个阶段:
在客户端执行一个MULTI
命令,标记一个事务块的开始。将客户端的 REDIS_MULTI
选项打开, 让客户端从非事务状态切换到事务状态。该命令会被封装成Redis
协议的格式发送给服务器,服务器接收到该命令会调用multiCommand()
函数来执行。函数源码在multi.c,如下:
void multiCommand(client *c) {
// 不能在事务中嵌套事务
if (c->flags & CLIENT_MULTI) {
addReplyError(c,"MULTI calls can not be nested");
return;
}
// 打开客户的的事务状态标识
c->flags |= CLIENT_MULTI;
// 回复OK
addReply(c,shared.ok);
}
该函数首先先会判断当前客户端是否处于事务状态(CLIENT_MULTI),如果没有处于事务状态,那么会打开客户端的事务状态标识,并且回复客户端一个OK
。
当客户端进入事务状态之后, 服务器在收到来自客户端的命令时, 不会立即执行命令, 而是将这些命令全部放进一个事务队列里, 然后返回 QUEUED
, 表示命令已入队,在每个描述客户端状态的结构struct client
中,保存着有关事务状态的成员变量,源码在server.h定义如下:
typedef struct client {
// 事物状态
multiState mstate;
} client;
multiState
是一个结构体,源码在server.h,定义如下:
typedef struct multiState {
// 事务命令队列数组
multiCmd *commands; /* Array of MULTI commands */
// 事务命令的个数
int count; /* Total number of MULTI commands */
// 同步复制的标识
int minreplicas; /* MINREPLICAS for synchronous replication */
// 同步复制的超时时间
time_t minreplicas_timeout; /* MINREPLICAS timeout as unixtime. */
} multiState;
对于每一个事务命令,都使用multiCmd
类型来描述,源码在server.h,定义如下:
/* Client MULTI/EXEC state */
// 事务命令状态
typedef struct multiCmd {
// 命令的参数列表
robj **argv;
// 命令的参数个数
int argc;
// 命令函数指针
struct redisCommand *cmd;
} multiCmd;
在客户端连接到服务器的时候,服务器会为客户端创建一个client,当客户端发送命令给服务器,会触发客户端和服务器网络连接的套接字产生读事件,然后会调用在创建client时所设置的回调函数readQueryFromClient(),来执行处理客户端发来的请求。该函数首先要读取被包装为协议的命令,然后调用processInputBuffer()函数将协议格式命令转换为参数列表的形式,最后会调用processCommand()函数执行命令。具体可参见:《REDIS设计与实现》- 网络连接库剖析(client的创建/释放、命令接收/回复等)
接下来看下redis处理命令逻辑中的一段源码:server.c的processCommand方法:
/* Exec the command 执行命令 */
// client处于事务环境中,但是执行命令不是exec、discard、multi和watch
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;
// 如果因为BLPOP而阻塞的命令已经准备好,则处理client的阻塞状态
if (listLength(server.ready_keys))
handleClientsBlockedOnLists();
}
如果当前客户端处于事务状态,并且当前执行的命令不是EXEC
、DISCARD
、MULTI
和WATCH
命令,那么调用queueMultiCommand()
函数将当前命令入队。该函数源码在multi.c,如下:
/* 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]);
// 事务命令个数加1
c->mstate.count++;
}
当事务命令全部入队后,在客户端执行EXEC
命令,就可以执行事务。服务器会调用execCommand()
函数来执行EXEC
命令,代码稍长
// EXEC 命令实现
void execCommand(client *c) {
int j;
robj **orig_argv;
int orig_argc;
struct redisCommand *orig_cmd;
// 传播的标识
int must_propagate = 0; /* Need to propagate MULTI/EXEC to AOF / slaves? */
// 如果客户端当前不处于事务状态,回复错误后返回
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.
* 第一种情况返回空回复对象,第二种情况返回一个EXECABORT错误
*/
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;
}
/* Exec all the queued commands */
/* 执行队列数组中的命令*/
// 因为所有的命令都是安全的,因此取消对客户端的所有的键的监视
unwatchAllKeys(c); /* Unwatch ASAP otherwise we'll waste CPU cycles */
// 因为事务中的命令在执行时可能会修改命令和命令的参数
// 所以为了正确地传播命令,需要现备份这些命令和参数
orig_argv = c->argv;
orig_argc = c->argc;
orig_cmd = c->cmd;
// 回复一个事务命令的个数
addReplyMultiBulkLen(c,c->mstate.count);
// 遍历执行所有事务命令
for (j = 0; j < c->mstate.count; j++) {
// 因为 Redis 的命令必须在客户端的上下文中执行,所以要将事务队列中的命令、命令参数等设置给客户端
// 设置一个当前事务命令给客户端
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 write op.
* 当遇上第一个写命令时,传播 MULTI 命令。
* 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.
* 这可以确保服务器和 AOF 文件以及附属节点的数据一致性。
*/
if (!must_propagate && !(c->cmd->flags & CMD_READONLY)) {
// 发送一个MULTI命令给所有的从节点和AOF文件
execCommandPropagateMulti(c);
// 设置已经传播过的标识(只发送一次)
must_propagate = 1;
}
// 执行该命令
call(c,CMD_CALL_FULL);
/* Commands may alter argc/argv, restore mstate. */
// 因为执行后命令、命令参数可能会被改变
// 所以这里需要更新事务队列中的命令和参数,确保附属节点和 AOF 的数据一致性
c->mstate.commands[j].argc = c->argc;
c->mstate.commands[j].argv = c->argv;
c->mstate.commands[j].cmd = c->cmd;
}
// 还原命令和参数
c->argv = orig_argv;
c->argc = orig_argc;
c->cmd = orig_cmd;
// 取消事务状态
discardTransaction(c);
/* Make sure the EXEC command will be propagated as well if MULTI
* was already propagated. */
// 如果传播了EXEC命令,表示执行了写命令,更新数据库脏键数
if (must_propagate) server.dirty++;
handle_monitor:
/* Send EXEC to clients waiting data from MONITOR. We do it here
* since the natural order of commands execution is actually:
* MUTLI, EXEC, ... commands inside transaction ...
* Instead EXEC is flagged as CMD_SKIP_MONITOR in the command
* table, and we do it here with correct ordering. */
if (listLength(server.monitors) && !server.loading)
replicationFeedMonitors(c,server.monitors,c->db->id,c->argv,c->argc);
}
因为之前把一系列命令加入到事务命令数组中,可猜测redis的逻辑应该就是从客户端的事务命令数组中取出所有命令一个一个执行。核心逻辑就是先校验状态,如果在按顺序执行事务命令的过程中,被WATCH
命令监视的键发生修改,或者不合法或被调用discardTransaction()
函数取消执行事务。
如果不处于以上两种状态,那么表示可以执行事务命令,但是先调用unwatchAllKeys()函数,解除当前客户端所监视的所有命令。然后遍历事务队列中的所有命令,调用call()函数执行命令。如果事务状态中有写命令被执行,那么要将MULTI命令传播给从节点和AOF文件。并且会在最后更新数据库的脏键值。执行完毕会调用discardTransaction()
函数取消当前客户端的事务状态。
书上也给了解释: 除了 EXEC 之外, 服务器在客户端处于事务状态时, 不加入到事务队列而直接执行的另外三个命令是 DISCARD 、 MULTI 和 WATCH 。
DISCARD 命令用于取消一个事务, 它清空客户端的整个事务队列, 然后将客户端从事务状态调整回非事务状态, 最后返回字符串
OK
给客户端, 说明事务已被取消。Redis 的事务是不可嵌套的, 当客户端已经处于事务状态, 而客户端又再向服务器发送 MULTI 时, 服务器只是简单地向客户端发送一个错误, 然后继续等待其他命令的入队。 MULTI 命令的发送不会造成整个事务失败, 也不会修改事务队列中已有的数据。
WATCH 只能在客户端进入事务状态之前执行, 在事务状态下发送 WATCH 命令会引发一个错误, 但它不会造成整个事务失败, 也不会修改事务队列中已有的数据(和前面处理 MULTI 的情况一样)。
WATCH命令用于在事务开始之前监视任意数量的键: 当调用EXEC命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了, 那么整个事务不再执行, 直接返回失败。
在每个代表数据库的 server.h/redisDb
结构类型中, 都保存了一个 watched_keys
字典, 字典的键是这个数据库被监视的键, 而字典的值则是一个链表, 链表中保存了所有监视这个键的客户端。比如说,以下字典就展示了一个 watched_keys
字典的例子:
watch命令的作用, 就是将当前客户端和要监视的键在 watched_keys
中进行关联。 下面看下源码,在server.h
/* Redis database representation. There are multiple databases identified
* by integers from 0 (the default database) up to the max configured
* database. The database number is the 'id' field in the structure. */
typedef struct redisDb {
dict *dict; /* The keyspace for this DB 数据库键空间,保存数据库中所有的键值对*/
dict *expires; /* Timeout of keys with a timeout set 保存过期时间*/
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP) */
dict *ready_keys; /* Blocked keys that received a PUSH 已经准备好数据的阻塞状态的key*/
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS 事物模块,用于保存被WATCH命令所监控的键*/
// 当内存不足时,Redis会根据LRU算法回收一部分键所占的空间,而该eviction_pool是一个长为16数组,保存可能被回收的键
// eviction_pool中所有键按照idle空转时间,从小到大排序,每次回收空转时间最长的键
struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */
// 数据库ID
int id; /* Database ID */
// 键的平均过期时间
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;
服务器调用watchCommand()
函数执行WATCH
命令。源码在multi.cc,如下:
// 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++)
// 监控当前key
watchForKey(c,c->argv[j]);
// 回复OK
addReply(c,shared.ok);
}
如果执行WATCH
命令时,函数处于事务状态,则直接返回。必须在执行MULTI
命令执行前执行WATCH
。该函数会调用watchForKey()
函数来监控所有指定的键。该函数代码如下:
/* Watch for the specified key */
//让客户端 c 监视给定的键 key
void watchForKey(client *c, robj *key) {
list *clients = NULL;
listIter li;
listNode *ln;
watchedKey *wk;
/* Check if we are already watching for this key */
// 检查 key 是否已经保存在 watched_keys 链表中,
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 */
// 如果数据库中该键没有被client监视(检查 key是否存在于数据库的 watched_keys 字典中)则添加它
clients = dictFetchValue(c->db->watched_keys,key);
// 如果不存在的话,添加它
if (!clients) {
// 创建一个空链表
clients = listCreate();
// 值是被client监控的key,键是client,添加到数据库的watched_keys字典中
dictAdd(c->db->watched_keys,key,clients);
incrRefCount(key);
}
// 将当前client添加到监视该key的client链表的尾部
listAddNodeTail(clients,c);
/* Add the new key to the list of keys watched by this client */
// 将新的被监视的key和与该key关联的数据库加入到客户端的watched_keys中
wk = zmalloc(sizeof(*wk));
wk->key = key;
wk->db = c->db;
incrRefCount(key);
listAddNodeTail(c->watched_keys,wk);
}
其中监视链表,定义为list *watched_keys
。这个链表每一个节点保存一个watchedKey
类型的指针,该结构代码如下:
/* In the client->watched_keys list we need to use watchedKey structures
* as in order to identify a key in Redis we need both the key name and the
* DB
* 在监视一个键时, 我们既需要保存被监视的键, 还需要保存该键所在的数据库。
*/
typedef struct watchedKey {
// 被监视的键
robj *key;
// 键所在的数据库
redisDb *db;
} watchedKey;
在任何对数据库键空间(key space)进行修改的命令成功执行之后 (比如 FLUSHDB 、 SET 、 DEL 、 LPUSH 、 SADD 、 ZREM ,诸如此类), multi.c/touchWatchedKey
函数都会被调用 (修改命令会调用signalModifiedKey()
函数来处理数据库中的键被修改的情况,该函数直接调用touchWatchedKey()
函数)—— 它检查数据库的 watched_keys
字典, 看是否有客户端在监视已经被命令修改的键, 如果有的话, 程序将所有监视这个/这些被修改键的客户端的 REDIS_DIRTY_CAS
选项打开:
/* "Touch" a key, so that if this key is being WATCHed by some client the
* next EXEC will fail. */
// Touch 一个 key,如果该key正在被监视,那么客户端会执行EXEC失败
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 */
// 遍历所有客户端,打开他们的 REDIS_DIRTY_CAS 标识
listRewind(clients,&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
// 设置CLIENT_DIRTY_CAS标识
c->flags |= CLIENT_DIRTY_CAS;
}
}
在传统的关系式数据库中,常常用 ACID 性质来检验事务功能的安全性。
a)原子性atomicity:redis事务保证事务中的命令要么全部执行要不全部不执行。愿书 作者做了勘误,对于原子性和回滚功能混淆了。
b)一致性consistency:redis事务可以保证命令失败的情况下得以回滚,数据能恢复到没有执行之前的样子,是保证一致性的,除非redis进程意外终结。
c)隔离性Isolation:redis事务是严格遵守隔离性的,原因是redis是单进程单线程模式,可以保证命令执行过程中不会被其他客户端命令打断。
d)持久性Durability:redis事务是不保证持久性的,这是因为redis持久化策略中不管是RDB还是AOF都是异步执行的,不保证持久性是出于对性能的考虑。
关于一致性的讨论,引用如下:
Redis 的一致性问题可以分为三部分来讨论:入队错误、执行错误、Redis 进程被终结。
入队错误
在命令入队的过程中,如果客户端向服务器发送了错误的命令,比如命令的参数数量不对,等等, 那么服务器将向客户端返回一个出错信息, 并且将客户端的事务状态设为
REDIS_DIRTY_EXEC
。当客户端执行 EXEC 命令时, Redis 会拒绝执行状态为
REDIS_DIRTY_EXEC
的事务, 并返回失败信息。redis 127.0.0.1:6379> MULTI OK redis 127.0.0.1:6379> set key (error) ERR wrong number of arguments for 'set' command redis 127.0.0.1:6379> EXISTS key QUEUED redis 127.0.0.1:6379> EXEC (error) EXECABORT Transaction discarded because of previous errors.因此,带有不正确入队命令的事务不会被执行,也不会影响数据库的一致性。
执行错误
如果命令在事务执行的过程中发生错误,比如说,对一个不同类型的 key 执行了错误的操作, 那么 Redis 只会将错误包含在事务的结果中, 这不会引起事务中断或整个失败,不会影响已执行事务命令的结果,也不会影响后面要执行的事务命令, 所以它对事务的一致性也没有影响。
Redis 进程被终结
如果 Redis 服务器进程在执行事务的过程中被其他进程终结,或者被管理员强制杀死,那么根据 Redis 所使用的持久化模式,可能有以下情况出现:
内存模式:如果 Redis 没有采取任何持久化机制,那么重启之后的数据库总是空白的,所以数据总是一致的。
RDB 模式:在执行事务时,Redis 不会中断事务去执行保存 RDB 的工作,只有在事务执行之后,保存 RDB 的工作才有可能开始。所以当 RDB 模式下的 Redis 服务器进程在事务中途被杀死时,事务内执行的命令,不管成功了多少,都不会被保存到 RDB 文件里。恢复数据库需要使用现有的 RDB 文件,而这个 RDB 文件的数据保存的是最近一次的数据库快照(snapshot),所以它的数据可能不是最新的,但只要 RDB 文件本身没有因为其他问题而出错,那么还原后的数据库就是一致的。
AOF 模式:因为保存 AOF 文件的工作在后台线程进行,所以即使是在事务执行的中途,保存 AOF 文件的工作也可以继续进行,因此,根据事务语句是否被写入并保存到 AOF 文件,有以下两种情况发生:
1)如果事务语句未写入到 AOF 文件,或 AOF 未被 SYNC 调用保存到磁盘,那么当进程被杀死之后,Redis 可以根据最近一次成功保存到磁盘的 AOF 文件来还原数据库,只要 AOF 文件本身没有因为其他问题而出错,那么还原后的数据库总是一致的,但其中的数据不一定是最新的。
2)如果事务的部分语句被写入到 AOF 文件,并且 AOF 文件被成功保存,那么不完整的事务执行信息就会遗留在 AOF 文件里,当重启 Redis 时,程序会检测到 AOF 文件并不完整,Redis 会退出,并报告错误。需要使用 redis-check-aof 工具将部分成功的事务命令移除之后,才能再次启动服务器。还原之后的数据总是一致的,而且数据也是最新的(直到事务执行之前为止)。
上面提到的在Redis
中WATCH
命令的实现是基于乐观锁,不是通常数据库实现乐观锁的一般方法:检测版本号,而是在执行完一个写命令后,会进行检查,检查是否是被WATCH
监视的键。