1. 概述
Redis单条命令的执行可以保证原子性,但是如果需要保证多个命令执行的原子性,就需要使用到Redis的事务。Redis的事务有如下特点:
- 事务中的所有命令可以保证顺序的执行,不会出现事务执行过程中,被其他事务的命令中断。
- 事务中的命令要么所有的都被执行,要么所有的都不执行(这里说的执行,不是说的执行成功)。
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 事例
- 打开两个Redis客户端,在第一个客户端中输入如下命令:
127.0.0.1:6379> watch k1
OK
- 在第二个客户端输入(其实这个命令在客户端1执行,但是必须在multi之前,结果也是一样的):
127.0.0.1:6379> set k1 v111
OK
- 回到客户端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 总结
- WATCH不能在事务过程中运行,也就是不能在MULTI和EXEC命令之间运行WATCH;
- 只要开始了WATCH,不管在MULTI之前(当前客户端或者另一个客户端)还是MULTI之后(必须在另外一个客户端)修改,(只要在EXEC之前修改)事务都会退出;
- 一但执行 EXEC开启事务的执行后,无论事务使用执行成功, WATCH对变量的监控都将被取消。
如果连接断开,WATCH也将自动停止。
也可以通过UNWATCH手动取消。 - 如果WATCH的是一个定时key,如果key在EXEC时过期了,则事务仍然可以执行成功。但是如果在过期前修改过,然后再过期,则事务会执行失败;
3. 实现原理
要了解Redis事务的实现原理,无非就是了解以下几点:
- 执行MULTI命令时,Redis是怎么处理的?
- 执行MULTI命令后,Redis是怎么将命令入队列的?队列的结构是怎样的?
- 执行EXEC命令时,Redis是怎么处理的?
- 执行DISCARD命令时,Redis是怎么处理的?
- 执行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。
扩展
- client的flags为int类型,占4个字节,即32位;
- CLIENT_MULTI定义为#define CLIENT_MULTI (1<<3),即flags的从右数第四位表示客户端进入了事务。
3.2 命令入队列原理
我们知道,运行了MULTI命令后,其后的命令会被加入队列,并返回QUEUE。那么:
- Redis是怎么判断命令应该进入队列?
- 命令进入的是哪个队列?队列中的元素数据结构是怎样的?
- 如果命令出现语法错误,怎么处理?
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判断客户端是否已经开启事务,如果开启,并且当前命令不是EXEC、DISCARD、MULTI、WATCH,则会调用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命令时,检查到该标记,则直接放弃运行。
扩展
- client的flags为int类型,占4个字节,即32位;
- 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个逻辑:
- 判断是否执行MULTI了,否则不能运行EXEC;
- 判断是否有CLIENT_DIRTY_CAS或者CLIENT_DIRTY_EXEC标记,有则放弃执行事务;
- 遍历multiState的指令数组,逐个执行指令;
- 执行完成,清理事务资源,调用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原理,主要是了解以下几点:
- WATCH执行时,怎么处理?
- WATCH的key被修改时,怎么处理?
- 怎么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);
}
可以看出:
Redis会在DB中保存一份watch key的clients数组,数组元素为client。
Redis会在client中保存一份watch的所有key的数组,数组元素为watchedKey,结构为:
typedef struct watchedKey {
robj *key;
redisDb *db;
} watchedKey;
- 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:
- 事务EXEC/DISCARD
- 客户端断开连接(networking.c文件的freeClient()函数)
- 调用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);
}
}
该函数会:
- 清除DB中保存的watch 客户端,如果处理该客户端外,没有其他客户端watch了,则直接删除该key的watch信息;
- 清楚client的所有watch key信息。
4. 总结
- Redis事务与传统RDB提供的事务区别还是挺大的,其实只是简单的为了原子性的执行多个命令而设计的罢了;
- Redis实现事务的代码也是很直观的,没有什么特殊的逻辑。建议在看源代码之前,可以先想想如果是自己实现,会怎么处理。然后带着问题去看源代码,就可以快速理解Redis为什么会这么实现了。