Redis通过MULTI、EXEC、WATCH等命令来实现事务(transaction)功能。事务将一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求。

redis->MULTI
OK

redis->SET "name" "hellp"
QUEUED

redis->GET "name"
QUEUED

redis->EXEC
1)OK
2)"hellp"

1. 事务的实现

一个事务从开始到结束会经历三个阶段:

  • 事务开始
  • 命令入队
  • 事务执行

(1)事务开始

通过MULTI命令可以将执行该命令的客户端从非事务状态切换至事务状态,这一切换是通过在客户端状态的flags属性中打开REDIS_MULTI标识来完成的。

当一个客户端已经处于非事务状态时,这个客户端发送的命令会被服务器执行。然而当切换到事务状态后,服务器会根据这个客户端发来的不同命令执行不同的操作:

  • 如果客户端发送EXEC,DISCARD,WATCH,MULTI这四个命令,则立即执行。
  • 如果发送的是其他命令,则放到事务队列里面,向客户端返回QUEUED回复。

(2)命令入队

事务的关键实现在于命令入队,每个Redis客户端都有自己的事务状态,这个事务状态保存在客户端状态的mstate属性里面:

typedef struct redisClient
{
    //...
    multiState mstate;
    //...
}

事务状态结构体又包含了一个事务队列,以及一个已入队命令的计数器

typedef struct multiState 
{ 
    // 事务队列,FIFO顺序 
    multiCmd *commands; 
    // 已入队命令计数 
    int count;
} multiState;

事务队列是一个结构体,实现了队列数据结构,执行FIFO先进先出的策略。真实结构是一个数组。

typedef struct multiCmd 
{ 
    // 参数 
    robj **argv; 
    // 参数数量
    int argc; 
    // 命令指针 
    struct redisCommand *cmd;
} multiCmd;

事务结构具体的包含逻辑是:客户端->事务状态multiState->事务队列multiCmd->具体命令cmd

(3)执行事务

当一个处于事务状态的客户端向服务器发送EXEC命令时,这个EXEC命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。

  1. 创建空白回复队列
  2. 抽取一条命令,读取参数、参数个数以及要执行的函数
  3. 执行命令,取得返回值
  4. 将返回值追加到1中的队列末尾,重复步骤2

完成所有命令后,将清除REDIS_MULTI标志,让客户端变为非事务状态,同时清零入队命令计数器,并释放事务队列。

2. WATCH命令的实现

WATCH可以别翻译为监视器。WATCH命令是一个乐观锁(optimistic locking)

悲观锁:有罪推定原则,每次有人操作数据时都会假定他要修改,每次都会上互斥锁。

乐观锁:无罪推定原则,每次别人拿数据都假定他不修改,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。

它可以在EXEC命令执行之前监视任意数量的数据库键,并在EXEC命令执行时,检查被监视的键是否至少有一个已经被修改过了如果是的话,服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复。

redis-> WATCH "name"
OK

redis-> MULTI
OK

redis-> SET "name" "peter"
QUEUED

redis-> EXEC
(nil)

上面的例子中,WATCH监视器发现了其他客户端事务修改了name的值,因此拒绝执行该事务,返回空回复。

比如我们可以通过WATCH来解决自增冲突的问题:

WATCH mykey

val = GET mykey
val = val + 1

MULTI
SET mykey $val
EXEC

2.1 监视原理

每个Redis数据库都保存着一个watched_keys字典,这个字典的键是某个被WATCH命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端:

typedef struct redisDb 
{ 
    // ... 
    // 正在被WATCH命令监视的键 
    dict *watched_keys;
    // ...
} redisDb;

下图说明:c1和c2客户端正在监视键”name”,c3客户端正在监视”age”….

2.2 监视触发

对数据库执行修改命令时,比如SET、LPUSH、SADD、ZREM、DEL、FLUSHDB等等,在执行之后都会调用multi.c/touchWatchKey函数对watched_keys字典进行检查。查看当前命令修改的键是否在watched_keys字典中,如果有,则客户端的REDIS_DIRTY_CAS标识打开,表示该客户端的事务安全性已经被破坏

2.3 判断事务是否安全

当服务器接收到一个客户端发来的EXEC命令时,服务器会根据这个客户端是否打开了REDIS_DIRTY_CAS标识来决定是否执行事务。

  • 如果标志被打开,则说明哨兵监视的键中被修改过了,所以当前提交的事务不再安全,拒绝执行客户端提交的事务。
  • 反之,是安全的,继续执行。

3. 事务的ACID性质

所谓ACID性质是指:有原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、耐久性(Durability)。

(1)原子性

所谓原子性就是某个操作不可再分,比如汇编语言里面的MOV DST,SRC。事务的定义就是:将多个命令打包成一个实现,要么全部执行,要么都不执行在命令入队的时候,用WATCH进行检查,不符合要求就直接返回。

Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制(rollback)。作者认为Redis追求简单高效,回滚机制太复杂。

回滚(rollback)是指当事务中某一条命令执行出错时,意味着前面的命令可能也不安全,这时候就会释放掉前面的操作,恢复到执行事务之前的状态。MySQL数据库支持回滚操作。

(2)一致性

一致”指的是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也应该仍然是一致的。Redis保证一致性的方法如下:

  • 入队错误:事务入队时命令格式不正确,则Redis拒绝执行
  • 执行错误:执行时操作不正确,会被服务器识别,并做错误处理,所以这些出错命令不会对数据库做任何修改
  • 服务器停机:停机分三种情况,
    • 无持久化:重启后清空,数据总是一致的
    • RDB模式:根据RDB恢复数据,还原为一致状态
    • AOF模式:根据AOF恢复数据,还原为一致状态

(3)隔离性

隔离性也可被理解为不存在竞争。即使数据库中有多个事务并发地执行,各个事务之间也不会互相影响。

因为Redis使用单线程的方式来执行事务(以及事务队列中的命令),并且服务器保证,在执行事务期间不会对事务进行中断。这种串行的方式保证了事务也总是具有隔离性的。

(4)耐久性

事务的耐久性指的是,当一个事务执行完毕时,执行这个事务所得的结果已经被保存到永久性存储介质(比如硬盘)里面了,即使服务器在事务执行完毕之后停机,执行事务所得的结果也不会丢失。

Redis事务只是简答包裹了一组Redis命令,耐久性由持久化实现。前面提到持久化分不同的情况

  • RDB模式下,只有特定条件被满足时才会执行BGSAVE,不具有耐久性。
  • AOF模式根据appendfsync选项来决定
    • always,每次执行命令后都会调用同步函数,具有耐久性。
    • everysec,每一秒才会同步到硬盘,不具有耐久性。
    • no,程序会交由操作系统来决定何时将命令数据同步到硬盘。不具有耐久性。

总的来说,Redis事务一定具有原子性,一致性和隔离性,但只有在特定条件下才具有耐久性。