楔子
Redis 也是有事务功能的,尽管它不像关系型数据库那样常用,但在面试的时候经常会被问到,下面我们就来总结一下 Redis 的事务。
通过 Redis 事务的原理以及实际操作,来彻底攻略 Redis 中的事务。
事务介绍
Redis 事务是一组命令的集合,将多个命令进行打包,然后这些命令会被顺序地添加到队列中,并按照添加的顺序依次执行。
但 Redis 不像 MySQL 那样有事务隔离级别的概念,不能保证原子性操作,也没有像 MySQL 那样执行事务失败时可以进行回滚的操作。
这个与 Redis 的特点:「 快速、高效 」有着紧密的联系,因为回滚操作、以及像事务隔离级别那样的加锁解锁,是非常消耗性能的。所以 Redis 中执行事务的流程只需要以下简单的三个步骤:
1)MULTI:表示开启一个事务,此命令之后的所有对 Redis key 的操作命令「都会被顺序地放入队列中」。当执行 EXEC 命令后,队列中的命令会被依次执行。
2)DISCARD:放弃执行队列中的命令,可以类比为 MySQL 的回滚操作,并且将当前的状态从事务状态改为非事务状态。
3)EXEC:表示要「顺序执行队列中的命令」,执行完之后并将结果显示在客户端,「同时将当前状态从事务状态改为非事务状态」。
除了以上三个命令之外,还有 WATCH 和 UNWATCH,我们先来介绍上面三个。
开启事务
MULTI 命令表示开启一个事务,当返回 OK 的时候表示已经进入事务状态。
127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)>
该命令执行之后客户端会将当前的状态从非事务状态修改为事务状态 ,这一状态的切换是通过打开客户端 flags 属性中的 REDIS_MULTI 来完成的,该命令可以理解为 MySQL 中的 BEGIN 语句。
注意:multi 命令不能嵌套使用,如果在已经开启了事务的情况下,再执行 multi 命令,会提示如下错误:(error) ERR MULTI calls can not be nested,因为事务是不可重复的。
127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)> multi (error) ERR MULTI calls can not be nested 127.0.0.1:6379(TX)>
当客户端是非事务状态时,使用 multi 命令,客户端会返回结果 OK,如果客户端已经是事务状态,再执行 multi 命令则会报出 multi 命令不能嵌套的错误,但不会终止客户端当前的事务状态,如下图所示:
不管是哪种情况,最终都是处于事务开启的一个状态,因为在 MULTI 中执行 MULTI 虽然会报错,但是不会结束事务。
命令入队
客户端进入事务状态之后,执行的所有常规 Redis 操作命令(非触发事务执行或放弃、以及导致入队异常的命令)会依次入列,命令入列成功后会返回 QUEUED,这也是真正的业务逻辑的部分,代码如下所示:
127.0.0.1:6379> multi OK # 不会终止事务,完全可以将第二个 MULTI 忽略掉 127.0.0.1:6379(TX)> multi (error) ERR MULTI calls can not be nested 127.0.0.1:6379(TX)> 127.0.0.1:6379(TX)> set name satori QUEUED 127.0.0.1:6379(TX)> set age 16 QUEUED 127.0.0.1:6379(TX)> get name QUEUED 127.0.0.1:6379(TX)> get age QUEUED 127.0.0.1:6379(TX)>
若是当前处于事务状态,那么 Redis 客户端的命令执行后就会进入到队列(FIFO)中,并且返回 QUEUED 字符串;否则的话,则会立即执行命令,并将结果返回给客户端。流程图如下:
我们说事务开启之后,命令会进入到队列中,而命令队列里面有如下内容:「要执行的命令」、「命令的参数」、「参数的个数」。以我们上面的事务为例,那么队列中的内容如下:
执行、放弃事务
当客户端执行 EXEC 命令的时候,队列里面的命令就会按照先进先出的顺序被执行;如果是 DISCARD,那么会放弃事务。
先来看看提交事务:
127.0.0.1:6379(TX)> exec 1) OK 2) OK 3) "satori" 4) "16" 127.0.0.1:6379>
当执行 EXEC 的时候,会先执行两个 SET 命令、再执行两个 GET 命令,并且执行后的结果也会进入一个队列中保存,最后返回给客户端:
至此一个事务就完整地执行完毕了,并且此时客户端也从事务状态更改为非事务状态。
另外命令在提交事务之后,如果成功执行,那么影响是全局的,我们再举个例子:
# 设置 name 为 satori 127.0.0.1:6379> set name satori OK # 获取 name,显然没问题 127.0.0.1:6379> get name "satori" # 开启事务 127.0.0.1:6379> multi OK # 在事务中设置 name 为 koishi 127.0.0.1:6379(TX)> set name koishi QUEUED 127.0.0.1:6379(TX)> get name QUEUED # 执行事务,get name 的结果为 koishi # 显然结果正常 127.0.0.1:6379(TX)> exec 1) OK 2) "koishi" # 但我们说事务中的命令的影响是全局的 # 即便事务结束,里面执行的命令在外部也是生效的 127.0.0.1:6379> get name "koishi" 127.0.0.1:6379>
再来看看放弃事务:
127.0.0.1:6379> set name satori OK 127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)> set name koishi QUEUED 127.0.0.1:6379(TX)> get name QUEUED # 取消事务,里面的命令根本没有执行 127.0.0.1:6379(TX)> discard OK # 所以外部的 name 还是 satori 127.0.0.1:6379> get name "satori" 127.0.0.1:6379>
DISCARD 命令取消事务的时候,会将命令队列清空,并且将客户端的状态从事务状态修改为非事务状态。
事务错误 & 回滚
再来探讨一个问题,要是事务在执行过程中出错了怎么办?首先事务执行中出现的错误分为三种:
- 执行时才会出现的错误(简称:执行时错误);
- 入队时错误,不会终止整个事务;
- 入队时错误,会终止整个事务;
我们分别演示一下这几种错误。
1)执行时错误
# 设置 name 为 satori 127.0.0.1:6379> set name satori OK # 获取 name,结果正常 127.0.0.1:6379> get name "satori" # 开启事务 127.0.0.1:6379> multi OK # 因为 name 不是数值,所以执行时会出错 # 但这条命令本身是合法的 127.0.0.1:6379(TX)> incr name QUEUED # 设置 name 为 koishi 127.0.0.1:6379(TX)> set name koishi QUEUED # 我们看到事务里面第一条命令执行失败 # 但是第二条执行成功了 127.0.0.1:6379(TX)> exec 1) (error) ERR value is not an integer or out of range 2) OK # 事务结束后,获取 name 发现被修改了 127.0.0.1:6379> get name "koishi" 127.0.0.1:6379>
从以上结果来看,即使事务队列中某个命令在执行期间出现了错误,事务也会继续执行,直到事务队列中所有命令都执行完成。
所以这样就会导致正确的命令被执行,而错误的命令不会被执行。而这也反映了 Redis 的事务不能保证数据的一致性,因为执行的途中出现了错误,但有些语句还是被执行了。因此最终的结果只能是程序猿根据之前的命令自己一步一步地回滚,所以自己的烂摊子自己收拾。
2)不会导致事务结束的入队时错误:
127.0.0.1:6379> set name satori OK 127.0.0.1:6379> multi OK # 在入队时就已经出现了错误 # 但是事务依旧没有结束 127.0.0.1:6379(TX)> multi (error) ERR MULTI calls can not be nested # 修改 name 127.0.0.1:6379(TX)> set name koishi QUEUED 127.0.0.1:6379(TX)> exec 1) OK # 事务结束后,name 也被修改 127.0.0.1:6379> get name "koishi" 127.0.0.1:6379>
可以看出,重复执行 multi 会导致入列错误,但不会终止事务,最终查询的结果表示事务执行成功了。除了重复执行 multi 命令,还有在事务状态下执行 watch 也是同样的效果,下文会详细讲解关于 watch 的内容。
3)会导致事务结束的入队时错误:
127.0.0.1:6379> set name satori OK 127.0.0.1:6379> multi OK # 输入一个正确的命令 127.0.0.1:6379(TX)> set name koishi QUEUED # 输入一个不存在的错误命令 127.0.0.1:6379(TX)> 古明地觉 (error) ERR unknown command `古明地觉` # 再输入一个正确的命令 127.0.0.1:6379(TX)> set length 156 QUEUED # 执行事务,提示我们报错了 # 整个事务被取消 127.0.0.1:6379(TX)> exec (error) EXECABORT Transaction discarded because of previous errors. # 但是对 name 和 length 做的修改全丢失了 # 所以对于当前这种错误而言,不管错误在事务的哪个地方 # 只要出现了,整个事务就完蛋了 127.0.0.1:6379> get name "satori" 127.0.0.1:6379> get length (nil) 127.0.0.1:6379>
所以我们看到错误可以分为两种:一种是事务执行时才会发现的错误;另一种是在入队的时候就能发现的错误。
- 执行时出现的错误,不会影响事务队列中的其它命令;即使某条命令失败,其它命令依旧可以正常执行;
- 入队时发现的错误,如果是 multi、watch 这种错误也不会终止事务,只是不会让它入队;但如果是命令不符合 Redis 的规则,那么这种错误就类似于编程语言的语法错误,直接编译时就报错,没必要等到执行了。所以在 Redis 中的表现就是整个事务都废弃掉,里面的命令一条也不会执行;
并且从执行时错误的例子中我们可以看到,Redis 是不支持事务回滚的。而不支持事务回滚的原因,Redis 作者给出了两个理由:
- 作者认为 Redis 事务在执行时,错误通常是编程错误造成的,这种错误通常只会出现在开发环境中,而很少在生产环境中出现,所以作者认为没有必要为 Redis 开发事务回滚功能;
- 不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计宗旨不符合;
监控
Redis 的监控会使用到锁机制,而锁分为悲观锁和乐观锁。
- 悲观锁(pessimistic lock),每次拿数据的时候都认为别人会修改,所以会上锁,这样别人想拿到这个数据就会 block 住,直到拿到锁。
- 乐观锁(optimistic lock),每次拿数据的时候都认为别人不会修改,所以不会上锁。但是在更新数据的时候会通过版本号机制,判断一下在此期间别人有没有更新这条数据,策略就是:提交版本必须大于记录的当前版本才能更新。乐观锁适用于多读的应用类型,这样可以提高吞吐量。
而 watch 命令则用于客户端并发情况下,为事务提供一个乐观锁(CAS,Check And Set),也就是可以用 watch 命令来监控一个或多个 key。如果在事务的过程中,某个监控项被修改了,那么整个事务就会终止执行。
watch key:表示监视指定的 key,该命令只能在 MULTI 命令之前执行,如果监视的 key 被其它客户端修改,那么 EXEC 将会放弃执行队列中的所有命令。下面来演示一下:
127.0.0.1:6379> set money 100 OK # 监控 money 这个 key 127.0.0.1:6379> watch money OK # 开启事务 127.0.0.1:6379> multi OK # 减去 20 127.0.0.1:6379(TX)> decrby money 20 QUEUED # 执行 127.0.0.1:6379(TX)> exec 1) (integer) 80 # 查看 money 的值 127.0.0.1:6379> get money "80" 127.0.0.1:6379>
上面执行的结果显然没有问题,但是往下看。
127.0.0.1:6379> flushdb OK 127.0.0.1:6379> set money 100 OK 127.0.0.1:6379> watch money OK # 开启事务之前将 money 修改了 127.0.0.1:6379> set money 200 OK 127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)> incr money QUEUED # 此时执行会返回一个 nil 127.0.0.1:6379(TX)> exec (nil) # money 是我们开启事务之前修改的 200 127.0.0.1:6379> get money "200" 127.0.0.1:6379> 127.0.0.1:6379> get name (nil) # 监控一个不存在的 key 也是可以的 127.0.0.1:6379> watch name OK # 开启事务之前设置 name 127.0.0.1:6379> set name satori OK 127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)> set name koishi QUEUED # 执行不会成功 127.0.0.1:6379(TX)> exec (nil) # name 依旧是之前设置的 satori 127.0.0.1:6379> get name "satori" 127.0.0.1:6379>
因此我们可以得出一个结论,一旦监视了 key,那么这个 key 如果想改变,则需要开启一个事务,在事务中修改,然后 exec 提交事务。如果先将 watch 监视的 key 修改了,那么不好意思,后续开启的事务所做的任何修改都将失效,因为整个事务就失效了。
另外注意:一个 watch 对应一个事务,如果 watch 之后,执行了事务,那么对这个 key 的监视就算结束了。如果想继续监视,那么必须再次 watch key。
# 设置两个 key 127.0.0.1:6379> set name satori OK 127.0.0.1:6379> set money 100 OK # 监视 money 127.0.0.1:6379> watch money OK # 开启事务之间修改 money 127.0.0.1:6379> incr money (integer) 101 # 开启事务之前修改了监视的 key # 那么再开启事务就没有意义了 # 里面做的任何修改都会无效化 127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)> set name koishi QUEUED 127.0.0.1:6379(TX)> exec (nil) # 事务里面对 name 做的修改没有生效 127.0.0.1:6379> get name "satori" 127.0.0.1:6379> # 但一个 watch 对应一个事务 # 事务结束之后,watch 也结束了 127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)> set name koishi QUEUED 127.0.0.1:6379(TX)> exec 1) OK # name 被修改 127.0.0.1:6379> get name "koishi" 127.0.0.1:6379>
所以一个 watch 对应一个事务,watch 之后只要执行了事务,不管里面的命令是成功还是失败,这个 watch 就算是结束了。再次开启事务,设置的 key 就是不被监视的 key 了。
但如果在事务中使用了 watch,那么会报错:(error) ERR WATCH inside MULTI is not allowed,所以 watch 只可以在开启事务之前使用。但在事务中使用 watch 和使用 multi 一样,虽然会报错,然而事务不会终止。
想要取消对 key 的监视,只需要开启一个事务并执行即可,也就是调用 exec 或 discard 命令,不管事务执行是成功还是失败。但除此之外,我们还可以通过 unwatch,也能取消对 key 的监视。并且 unwatch 后面不需要指定具体的 key,因为它会解除对所有 key 的监视(也说明了我们可以同时监视多个 key)。
127.0.0.1:6379> set name satori OK 127.0.0.1:6379> watch name OK # 监视 name 之后修改 name # 显然是可以修改成功的,这没问题 # 但接下来的事务就会失效 127.0.0.1:6379> set name koishi OK 127.0.0.1:6379> get name "koishi" # 先取消对 name 的监视 127.0.0.1:6379> unwatch OK # 重新监视 127.0.0.1:6379> watch name OK # 在不修改 name 的情况下开启事务 # 此时事务是有效的 127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)> set name marisa QUEUED 127.0.0.1:6379(TX)> exec 1) OK # 成功修改,但伴随着事务的结束 # name 这个 key 也会再度被取消监视 127.0.0.1:6379> get name "marisa" 127.0.0.1:6379>
再来探讨一个问题,如果监视之后先开启的事务,但在另一个终端中将 key 修改了会是什么后果呢?
127.0.0.1:6379> set money 100 OK 127.0.0.1:6379> watch money OK 127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)> set money 120 QUEUED # 事务开启后、提交前 # 在另一个终端将 money 设置成了 250 127.0.0.1:6379(TX)> exec (nil) # 事务执行结果依旧是 nil # money 是另一个终端中设置的结果 127.0.0.1:6379> get money "250" 127.0.0.1:6379>
因为不开启事务,直接修改的话,那么修改立即生效。而当前终端在提交的时候,发现 key 被修改了,值和监视的时候不一样,于是就会中止事务。
正如 MySQL 的行锁一样,两个人都可以对同一条记录做修改,但是一个事务先改好之后,另一个事务就会提交失败。
小结
最后总结一下 Redis 中关于事务的特性:
1)单独的隔离操作:事务中所有的命令都会被序列化,按照顺序执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断;
2)没有隔离级别的状态:队列中的命令在没有提交之前(exec),都不会被实际地执行。因为开启事务之后、事务提交之前,任何指令都不会被实际地执行,也就不存在"事务内的查询要看到更新,事务外查询无法看到"这个让人头疼的问题;
3)不保证原子性:我们之前演示过,如果是在运行时出错,那么后面的命令会继续执行,不会回滚;
正常情况下 Redis 事务分为三个阶段:开启事务、命令入队、执行事务。Redis 事务并不支持运行时错误的事务回滚,但在某些入队错误,如命令本身错误或者是 watch 监控项被修改时,提供整个事务回滚的功能(准确的说是直接把事务给取消了)。