Redis 事务
基本的 Redis 事务
Redis 有 5 个命令可以让用户在不被打断的情况下对多个键执行操作, 它们分别是 WATCH, MULTI, EXEC, UNWATCH 和 DISCARD.
Redis 的基本事务需要用到 MULTI, EXEC 命令, 这种事务可以让给一个客户端在不被其他客户端打断的情况下执行多个命令.
在 Redis 里面, 被 MULTI, EXEC 命令会一个接一个的执行, 直到所有命令都执行完毕为止. 当一个事务执行完毕之后, Redis 才会处理其他客户端的命令.
值得注意的是, 在执行完MULTI命令后, 还是会继续执行其他客户端的命令, 只要在执行EXEC命令后, 才不会去执行其他客户端的命令.Redis 的事务是不可嵌套的, 当客户端已经处于事务状态, 而客户端又再向服务器发送
MULTI时, 服务器只是简单地向客户端发送一个错误, 然后继续等待其他命令的入队.MULTI命令的发送不会造成整个事务失败, 也不会修改事务队列中已有的数据.
在执行完 MULTI 命令后, 然后添加想要在事务中执行的命令, 这一步只是添加, 添加到队列中, 最后在执行 EXEC 命令开始执行事务.
重点
在 Redis 中, 事务中的两个保证:
- 事务中的所有命令都会被序列化并按顺序执行. 在执行 Redis 事务的过程中, 不会出现执行另一个客户端的请求. 这保证 命令队列 作为一个单独的原子操作被执行.
- 队列中的命令要么全部被处理, 要么全部被忽略.
EXEC命令触发事务中所有命令的执行, 因此, 当客户端在事务上下文中失去与服务器的连接, 两种情况. - 如果发生在调用
EXEC命令之前, 则不执行任何 commands;
- 如果发生在调用
- 如果发生在调用
EXEC命令之后, 则所有的 commands 都被执行.
- 如果发生在调用
开始事务
MULTI 命令的执行标记着事务的开始: 将客户端的 REDIS_MULTI 选项打开, 让客户端从非事务状态切换到事务状态.

命令入队
当客户端处于非事务状态下时, 所有发送给服务器端的命令都会立即被服务器执行.
redis> SET msg "hello moto" OK redis> GET msg "hello moto"
但是, 当客户端进入事务状态之后, 服务器在收到来自客户端的命令时, 不会立即执行命令, 而是将这些命令全部放进一个事务队列里, 然后返回 QUEUED, 表示命令已入队:
redis> MULTI OK redis> SET msg "hello moto" QUEUED redis> GET msg QUEUED
以下流程图展示了这一行为:

事务队列是一个数组, 每个数组项是都包含三个属性:
- 要执行的命令 (cmd)
- 命令的参数 (argv)
- 参数的个数 (argc)
举个例子, 如果客户端执行以下命令:
redis> MULTI OK redis> SET book-name "Mastering C++ in 21 days" QUEUED redis> GET book-name QUEUED redis> SADD tag "C++" "Programming" "Mastering Series" QUEUED redis> SMEMBERS tag QUEUED
那么程序将为客户端创建以下事务队列:

执行事务
前面说到, 当客户端进入事务状态之后, 客户端发送的命令就会被放进事务队列里.
但其实并不是所有的命令都会被放进事务队列, 其中的例外就是 EXEC 、 DISCARD 、 MULTI 和 WATCH 这四个命令 —— 当这四个命令从客户端发送到服务器时, 它们会像客户端处于非事务状态一样, 直接被服务器执行:

如果客户端正处于事务状态, 那么当 EXEC 命令执行时, 服务器根据客户端所保存的事务队列, 以先进先出 (FIFO) 的方式执行事务队列中的命令: 最先入队的命令最先执行, 而最后入队的命令最后执行.
比如说, 对于以下事务队列:

程序会首先执行 SET 命令, 然后执行 GET 命令, 再然后执行 SADD 命令, 最后执行 SMEMBERS 命令.
执行事务中的命令所得的结果会以 FIFO 的顺序保存到一个回复队列中.
比如说, 对于上面给出的事务队列, 程序将为队列中的命令创建如下回复队列:

当事务队列里的所有命令被执行完之后, EXEC 命令会将回复队列作为自己的执行结果返回给客户端, 客户端从事务状态返回到非事务状态, 至此, 事务执行完毕.
在事务和非事务状态下执行命令
无论在事务状态下, 还是在非事务状态下, Redis 命令都由同一个函数执行, 所以它们共享很多服务器的一般设置, 比如 AOF 的配置、RDB 的配置, 以及内存限制, 等等.
不过事务中的命令和普通命令在执行上还是有一点区别的, 其中最重要的两点是:
- 非事务状态下的命令以单个命令为单位执行, 前一个命令和后一个命令的客户端不一定是同一个;
而事务状态则是以一个事务为单位, 执行事务队列中的所有命令: 除非当前事务执行完毕, 否则服务器不会中断事务, 也不会执行其他客户端的其他命令. - 在非事务状态下, 执行命令所得的结果会立即被返回给客户端;
而事务则是将所有命令的结果集合到回复队列, 再作为EXEC命令的结果返回给客户端.
Redis 事务不支持 Rollback
事实上 Redis 命令在事务执行时可能会失败, 但仍会继续执行剩余命令而不是 Rollback (事务回滚). 如果你使用过关系数据库, 这种情况可能会让你感到很奇怪. 然而针对这种情况具备很好的解释:
Redis 命令可能会执行失败, 仅仅是由于错误的语法被调用 (命令排队时检测不出来的错误), 或者使用错误的数据类型操作某个 Key.
这意味着, 实际上失败的命令都是编程错误造成的, 都是开发中能够被检测出来的, 生产环境中不应该存在. (这番话, 彻底甩锅, “都是你们自己编程错误, 与我们无关”.)
由于不必支持 Rollback, Redis 内部简洁并且更加高效.
事务中的错误
事务期间, 可能会遇到两种命令错误:
在调用 EXEC 命令之前出现错误 (COMMAND 排队失败)
- 例如, 命令可能存在语法错误 (参数数量错误, 错误的命令名称);
- 或者可能存在某些关键条件, 如内存不足的情况 (如果服务器使用
maxmemory指令做了内存限制).
客户端会在 EXEC 调用之前检测第一种错误. 通过检查排队命令的状态回复 (注意: 这里是指排队的状态回复, 而不是执行结果), 如果命令使用 QUEUED 进行响应, 则它已正确排队, 否则 Redis 将返回错误. 如果排队命令时发生错误, 大多数客户端将中止该事务并清除命令队列. 然而:
- 在 Redis 2.6.5 之前, 这种情况下, 在
EXEC命令调用后, 客户端会执行命令的子集 (成功排队的命令) 而忽略之前的错误. - 从 Redis 2.6.5 开始, 服务端会记住在累积命令期间发生的错误, 当
EXEC命令调用时, 将拒绝执行事务, 并返回这些错误, 同时自动清除命令队列.
>MULTI +OK >INCR a b c -ERR wrong number of arguments for 'incr' command
这是由于 INCR 命令的语法错误, 将在调用 EXEC 之前被检测出来, 并终止事务.
在调用 EXEC 命令之后出现错误
例如, 使用错误的值对某个 key 执行操作 (如针对 String 值调用 List 操作).
EXEC 命令执行之后发生的错误并不会被特殊对待: 即使事务中的某些命令执行失败, 其他命令仍会被正常执行.
>MULTI +OK >SET a 3 +QUEUED >LPOP a +QUEUED >EXEC *2 +OK -ERR Operation against a key holding the wrong kind of value
EXEC 返回一个包含两个元素的字符串数组, 一个元素是OK, 另一个是-ERR…….
能否将错误合理的反馈给用户这取决于客户端 library (如: Spring-data-redis.redisTemplate) 的自身实现.
需要注意的是, 即使命令失败, 队列中的所有其他命令也会被处理, 即 Redis 不会停止命令的处理.
清除命令队列
DISCARD 被用来中止事务. 事务中的所有命令将不会被执行, 连接将恢复正常状态.
> SET foo 1 OK > MULTI OK > INCR foo QUEUED > DISCARD OK > GET foo "1"
WATCH
WATCH 命令用于在事务开始之前监视任意数量的键: 当调用 EXEC 命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了(或删除), 那么整个事务不再执行, 直接返回失败.
值的注意的是,WATCH只能在客户端进入事务状态之前执行, 在事务状态下发送WATCH命令会引发一个错误, 但它不会造成整个事务失败, 也不会修改事务队列中已有的数据.
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> WATCH key (error) ERR WATCH inside MULTI is not allowed
client1 正常情况
127.0.0.1:6379> GET key "2" 127.0.0.1:6379> WATCH key OK 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET key 3 QUEUED 127.0.0.1:6379> GET key QUEUED 127.0.0.1:6379> EXEC 1) OK 2) "3" 127.0.0.1:6379>
client1 监视了 key 这个键, 然后执行事务. 事务正常执行, 并没有返回 (nil).
因为并没有在 client2(或其他客户端) 中来修改 key 这个键. 所以事务可以正常执行.
在 client2 中修改了 key 键
| 时间 | client1 | client2 |
|---|---|---|
| T1 | WATCH key | |
| T2 | MULTI | |
| T3 | SET key 4 | |
| T4 | SET key 1 | |
| T5 | EXEC |
执行 EXEC 后返回 (nil), 说明任务执行失败了.
原因是在 T4(时间) 的时候执行了 SET key 1, 来修改了 client1 正在监视的 key 键, 所以当客户端执行事务时, 事务不会被执行.
WATCH 命令的实现
在每个代表数据库的 redis.h/redisDb 结构类型中, 都保存了一个 watched_keys 字典, 字典的键是这个数据库被监视的键, 而字典的值则是一个链表, 链表中保存了所有监视这个键的客户端.
比如说, 以下字典就展示了一个 watched_keys 字典的例子:

其中, 键 key1 正在被 client2 、 client5 和 client1 三个客户端监视, 其他一些键也分别被其他别的客户端监视着.
WATCH 命令的作用, 就是将当前客户端和要监视的键在 watched_keys 中进行关联.
举个例子, 如果当前客户端为 client10086, 那么当客户端执行 WATCH key1 key2 时, 前面展示的 watched_keys 将被修改成这个样子:

通过 watched_keys 字典, 如果程序想检查某个键是否被监视, 那么它只要检查字典中是否存在这个键即可; 如果程序要获取监视某个键的所有客户端, 那么只要取出键的值 (一个链表), 然后对链表进行遍历即可.
WATCH 的触发
在任何对数据库键空间 (key space) 进行修改的命令成功执行之后 (比如 FLUSHDB 、 SET 、 DEL 、 LPUSH 、 SADD 、 ZREM, 诸如此类), multi.c/touchWatchedKey 函数都会被调用 —— 它检查数据库的 watched_keys 字典, 看是否有客户端在监视已经被命令修改的键, 如果有的话, 程序将所有监视这个/这些被修改键的客户端的 REDIS_DIRTY_CAS 选项打开:

当客户端发送 EXEC 命令、触发事务执行时, 服务器会对客户端的状态进行检查:
- 如果客户端的
REDIS_DIRTY_CAS选项已经被打开, 那么说明被客户端监视的键至少有一个已经被修改了, 事务的安全性已经被破坏. 服务器会放弃执行这个事务, 直接向客户端返回空回复, 表示事务执行失败. - 如果
REDIS_DIRTY_CAS选项没有被打开, 那么说明所有监视键都安全, 服务器正式执行事务.
举个例子,假设数据库的 watched_keys 字典如下图所示:

如果某个客户端对 key1 进行了修改 (比如执行 DEL key1) 那么所有监视 key1 的客户端, 包括 client2 、 client5 和 client1 的 REDIS_DIRTY_CAS 选项都会被打开, 当客户端 client2 、 client5 和 client1 执行 EXEC 的时候, 它们的事务都会以失败告终.
值得注意的是, 当一个客户端结束它的事务时, 无论事务是成功执行, 还是失败, watched_keys 字典中和这个客户端相关的资料都会被清除.