MULTI,EXEC,DISCARD和WATCH是Redis事务的基础。他们允许在一个步骤中执行一组命令,并且具有两种重要保障:
事务中的所有命令都被序列化并顺序执行。在一个Redis事务执行期间,它不会被其他客户端发出的请求所打断。这一点保证了命令作为一个隔离操作被执行。
要么所有命令都被执行,要么都不执行,所以Redis事务是也是原子性的。在一个事务中,EXEC命令会触发所有命令的执行,所以如果一个客户端在事务调用MULTI命令之前失去了与服务器的连接,那么一条操作都不会被执行,相反,如果EXEC命令被调用了,那么所有的操作会被执行。当使用AOF时,Redis会确保使用一个write(2)系统调用将事务写入磁盘。然而,如果Redis 服务端崩溃了或者是被系统管理员以某种暴力的方式停掉了服务,那么很有可能只有一部分操作被注册。Redis会在重启的时候检测到这种情况,然后退出并抛出错误。使用redis-check-aof工具可以移除这部分不完整的事务来修复AOF,这样一来服务器就可以重新启动了。
从2.2版本开始,Redis允许使用另外一种方式来保证以上两点,这是一种与CAS操作很相似的乐观锁。本文将在稍后介绍这一点。
用法
Redis使用MULTI命令开始一个事务。该命令总是返回OK。此时用户就可以发出多个命令。这些命令并不会被立即执行,而是会被放入队列中。所有的命令都会在EXEC被调用后一次性执行。
调用DISCARD命令将会清空事务队列并退出事务。
下面的示例以原子方式增加键foo和bar。
> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1
从上面的会话中可以看出,EXEC返回的是一个数组,数组中的每个元素都是事务中单个命令的返回值,并且元素顺序与命令的发送顺序相同。
当Redis连接处于一个MULTI请求的上下文中时,所有命令都会使用字符串QUEUED(从Redis协议的角度来看是作为一个回复的状态进行发送)进行回复。这些被放入队列的命令将会在EXEC被调用时执行。
事务中的错误
在事务执行过程中有可能会遇到两种类型的命令错误:
一个命令入队列失败,所以在EXEC被调用之前可能就会报错。例如命令可能有语法错误(参数个数不对,命令名字不对等等...),或者其它一些更严重的情形,比如说内存溢出(如果服务器使用maxmemory配置了内存限制的话)。
一个命令可能在EXEC被调用后失败,例如对键值使用了错误的操作(例如针对字符串调用列表操作)。
客户端曾经会通过检查入队列的返回值来感知第一类发生在EXEC调用之前错误:如果返回值为QUEUED,那么该命令入队列成功,否则Redis会返回错误。如果在命令入队列时发生了错误,大多数客户端会丢弃它,放弃事务的执行。
然而从2.6.5版本的Redis开始,服务器将会记住命令积累过程中的错误,并且会拒绝执行事务,它在执行EXEC期间会返回错误并自动丢弃事务。
在2.6.5之前的版本中,事务执行的行为是这样的:客户端调用EXEC命令时会忽略之前的错误,仅仅执行入队列成功的命令。新的行为使用流水线使事务变得更加简单,整个事务可以一次发送,然后稍后一次读取所有返回值。
在EXEC命令之后发生的错误并没有进行特殊处理:即使有些命令在事务执行期间失败了,所有的命令也都会被执行。
从协议层的角度来看这个问题将会更加清晰。在下面这个例子中,即使语法是正确的,还是有一个命令会执行失败。
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
MULTI
+OK
SET a 3
abc
+QUEUED
LPOP a
+QUEUED
EXEC
*2
+OK
-ERR Operation against a key holding the wrong kind of value
EXEC命令返回了一个二元体串,其中一个是OK,另一个是ERR。这是由客户端库来决定如何找到一种合适方式来向用户展示事务中的错误。
需要注意的一点是:即使某个命令执行失败了,事务中的其他命令仍然会被执行——Redis不会停止命令的处理。
下面另外一个例子,它使用了telnet协议来展示当出现语法错误时,它会尽快报告出来。
MULTI
+OK
INCR a b c
-ERR wrong number of arguments for 'incr' command
这次由于语法错误,这个INCR命令根本不会被放入队列。
为什么Redis不支持回滚
如果你有传统数据库的背景,那么这一事实将会让你感到很奇怪:Redis命令在事务执行中可能会失败,但是Redis还是会执行事务中剩下的命令而不是回滚。
然而,这种做法还是有以下好处:
Redis命令只会在出现语法错误(这种错误在命令入队列时并不会被检测到)或者键和对应的值类型不匹配时才会失败:这意味着在实际操作中,命令执行失败是编程错误的结果,并且这种错误通常可以在开发过程中就检测到,而不是在生产环境。
Redis内部的简化和更加的快速是因为它不支持回滚。
有一种观点认为Redis的这种机制会产生bug,然而我们应该注意到,通常来说,回滚并不能将你从编程错误中挽救出来。举个栗子:如果你本来想对一个键增加1,却不小心加了2,或者对错误的键进行了加1操作,此时没有任何回滚机制可以派上用场。考虑到没有人可以将程序员从他/她的编程错误中挽救出来,并且Redis中这种类型的错误很难进入生产环境,我们选择了不支持对错误进行回滚这种更加简化和快速的方式。
丢弃命令队列
DISCARD命令可以用于丢弃事务。在这种情况下,没有命令会被执行,而且连接状态也会恢复正常。
> SET foo 1
OK
> MULTI
OK
> INCR foo
QUEUED
> DISCARD
OK
> GET foo
"1"
使用CAS的乐观锁
WATCH命令用来为Redis事务提供一种CAS行为。
被WATCH的键将会被监控是否有变化。只要有一个被监控的键在EXEC命令执行前被修改了,那么整个事务都会被丢弃,EXEC命令会返回空,以此来提示事务执行失败了。
例如,如果我们需要原子性地为一个键增加1(假设Redis没有INCR命令),我们的第一次尝试可能是这样的:
val = GET mykey
val = val + 1
SET mykey $val
只有在只有一个客户端执行这个操作时,上述代码的执行结果才是可信的。如果有多个客户端几乎同时尝试给该键增加1,那么这里就会产生竞争条件。比如所,客户端A和B会读到旧的值10,然后两者都将该值增加至11,最后将11赋值给原来的键。这样一来最后键值会是11而不是12.
多亏有了WATCH命令,使得我们可以很好地解决这个问题:
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
使用上述代码,如果有竞争条件,即有另外一个客户端在我们调用WATCH和EXEC期间需改了val的值,那么这个事务将失败。
我们需要做的就是重复这个操作,希望这次不会有新的竞争条件。这种形式的锁被称为乐观锁,这是一种功能非常强大的锁形式。在很多应用场景中,多个客户端会访问不同的键,所以碰撞并不会经常发生,我们也就不需要进行重试。
了解WATCH
所以WATCH到底是什么?它是一个可以让我们有条件地执行EXEC的命令:当我们向Redis请求一个事务时,只有所有被监控的键都没有被修改,事务才会执行(但是在这个客户端的该事务内部可能会修改被监控的键,这种情况并不会导致事务被丢弃。了解更多详情请访问https://github.com/antirez/redis-doc/issues/734),否则事务将会被丢弃。(有一点需要注意的是如果你监控了一个易变的键,并且当你开始监控它后,该键过期了,此时事务还是会执行。了解更多详情请访问http://code.google.com/p/redis/issues/detail?id=270)
WATCH命令可以被多次调用。所有的WATCH调用都会监控从WATCH命令到EXEC命令之间的键值变化。所以你也可以对一个WATCH命令发送任意数目的命令。
当EXEC命令被调用后,所有的键将不再会被监控,无论最终事务是否执行成功。当客户端连接被关闭时,所有之前被监控的键也将不再会被监控。
使用无参数的UNWATCH命令可以取消对所有键的监控。有时候当我们使用乐观锁锁定多个键时这个命令会非常有用。我们开启了一个事务去修改这些键,但是当我们读完键内容后又不想继续处理了,这种情况下我们就可以调用UNWATCH命令,然后就可以自由开启新的事务了。
使用WATCH实现ZPOP
一个很好的用来解释WATCH如何用来创建一个新的原子操作的例子是实现ZPOP,这是一个以原子方式从有序集合中弹出较低分值元素的命令。如下是它的最简单实现:
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
如果EXEC调用失败(例如返回为空),上述代码就会进行重试。
Redis脚本和事务
从定义上来说,Redis脚本本身就是一个事务,所以任何可以用事务实现的功能,都可以用脚本来实现,并且通常来说使用脚本会更加简单,速度也更快。
这种功能的重复是因为这样一个事实:脚本是在Redis 2.6中才引进的,而事务在很久之前的版本中就已经存在。然而短期内我们并不想移除redis对事务的支持,因为从语义上来说它也是合适的,即使不借助Redis脚本我们依然可以避免竞争条件,更何况Redis事务的实现复杂度是最小的。
然而在不久的将来我们可能会看到所有用户都只使用脚本,这种情况也不是不可能发生。如果真的这样,我们将会废弃并最终移除事务功能。
2017-09-10
原文链接:https://redis.io/topics/transactions