本文是官方Transactions小节的文档翻译,因为之前对redis的事务一直比较疑惑,这里做一次记录:

MULTI,EXEC,DISCARD和WATCH是redis里构成事务的4个基础命令。通过redis的事务,可以做到一次执行一组命令,关于事务redis有两方面的保证:

  1. 在一个事务中的所有命令会被串行化并且是按照顺序依次执行。在事务执行期间一定不会被插入其它客户端传入的命令。这一点保证了这些命令是一个独立的执行体(独立性)。

  2. 所有的命令要么全部被执行,要么没有一条被执行,所以一个redis的事务同样也具有原子性。EXEC这个命令会触发事务中所有的命令开始执行,所以在客户端在执行了MULTI命令之后(官方这里写的是之前,但是我觉得不对)的事务上下文中丢生了和服务器的连接,那么任何一条命令都不会被执行,但如果EXEC已经开始执行了的话,所有的命令都会被执行完。当使用append-only 文件作为Redis的持久化方式时,Redis会确保用write的系统调用来将事务写到磁盘上。然而如果Redis服务器在保存过程中崩溃了或者被系统管理员残忍地kill掉了,那么有可能只有一部分操作被注册下来。Redis会在重启的时候检测到这种情况,并且会以error的形式来退出。用redis-check-aof工具来修正这种情况下的AOF文件,这个工具会把文件末尾没有写完全的事务移除掉,然后redis服务就可以启动了。

从2.2版本开始,Redis对上述的两点有了更加充分的保证,使用的手段是类似于check-and-set(CAS)操作的乐观锁。

事务的使用

使用MULTI命令进入Redis事务。这个命令一般都会有OK作为响应。这时候用户就可以发出多条命令了。Redis并不直接执行这安命令,Redis会将这些命令先排队。当执行EXEC命令时,所有的命令才会被一次性被执行。

调用DISCARD命令会将事务队列里的内容清空并且会退出当前事务。下面的例子会原子性地为foo和bar加一。

> MULTI
OK  
> INCR foo
QUEUED  
> INCR bar
QUEUED  
> EXEC
1) (integer) 1  
2) (integer) 1  

从上面这次redis会话可以看出来,EXEC会返回一个响应的数组,事务中的每一个命令的响应都会存在这个数组中,响应数组中元素的顺序和你执行命令的顺序是一样的。

当一个Redis连接所在环境是一个MULTI请求的上下文中时,所有命令都会得到一条QUEUED的响应。在没有执行EXEC之前,这些命令只是简单地被排队而已。

事务中的错误:

在一次事务的过程中,可能会有两种类型的错误:

1.命令入队列失败,也就是说在执行EXEC之前就报错了。比如你可能打了一个错误的命令(比方说参数数量不对什么的,或者调用了一个根本不存在的命令名等等),或者也有可能是发生了一些严重的情况,比如OOM(out of memory,当服务器用maxmemory配置了内存上限时)了。

2.命令在EXEC执行之后失败,例如我们对一个key执行了其本身不支持的操作(比如对一个value是string类型的key调用了一些本来是对list类型才能做的操作)。  

对于以前老版本的Redis客户端来说,需要逐一检查每一个命令的返回值是不是QUEUED,以判断命令是否会执行成功:如果服务端返回QUEUED那么认为是正确,否则的话认为redis返回了错误。如果在对命令排队时碰到了错误,大多数的客户端需要直接丢弃掉该事务并退出。

不过从Redis 2.6.5开始,服务器会记住在对命令排队时发生的错误,并且能够比较智能地拒绝执行整个事务了,也就是说,这种情况下客户端执行EXEC,redis服务器也会给你返回一个错误,并且由服务端自动将这些排队的命令和整个事务废弃掉。(译注:在这种情况下,和mysql的事务的功能比较类似)

在2.6.5版本之前,Redis会执行这些入队的命令,但只会执行那些正确地入队的命令,而会忽略在将命令排队时的错误信息。新版本的行为使其用pipeline来混合事务更加简单了,因此整个事务发送和接收响应只要通信一次就好了。

而在EXEC执行之后才发生的错误则不会像上面这样处理了:所有正确的命令都会被正确执行。只有那些错误的不会被执行。(译注:这是我感觉不太好的一个地方,不一致的行为会导致很多情况下的错误使用,也比较难记。不过一般EXEC后才失误的命令看起来也就是因为key对应的value不匹配才会导致这种情况了。)

从协议的角度来描述这件事情应该可以更加清晰。在下面的例子中一个命令会在执行期间报错,即使它的语法(看来对于redis来说,语法正确只是指参数数目正确。。)是正确的:

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的客户端来决定要怎么向用户来呈现这些错误了。

这里需要强调的是,在这种情况下即使一个命令失败了,所有其它在队列中的命令也都会被处理--Redis并不会因为这个错误停止处理其它的命令。

另一个例子,同样使用telnet的wire protocol,表明了语法错误是怎么被尽早报出的:

MULTI  
+OK
INCR a b c  
-ERR wrong number of arguments for 'incr' command

这一次,因为语法错误,INCR命令压根就没有被塞到队列里。

为什么Redis不支持回滚呢?

如果你有关系数据库背景的话,像Redis这样在事务中就算出错,也能够执行其它正确命令,而不是回滚他们的行为可能会让你很奇怪。

然而对于这样的古怪行为还是有两方面的观点可以支持:

Redis命令只有在出现调用错误的时候(这种错误在命令入队的时候检测不到),或者key对应的数据类型不不匹配时:也就是说在实践中,错误的命令一般是我们程序的锅,而这种情况在开发阶段就很容易检测出来,而不是在生产环境中才发现。

因此Redis不需要回滚的能力,这样使其也可以做到内部实现的简单和快速。

还有一种和Redis设计相左的观点认为程序会经常出bug,你为什么不把程序的bug考虑在内。然而实际上就算redis支持回滚了,也没法帮你解决你自己代码的错误。例如你想要试图对一个key执行incr操作对其加2而不是1,或者在错误的key上执行incr操作,这种情况下没有任何的回滚策略可以帮助你来解决问题。也就是说没有人能够帮助一个码农解决他的错误。何况这些会导致Redis命令失败的错误一般也不会带人到生产环境。因此我们选择了最简单和快速的实现方式来进行报错时的回滚。

放弃事务

在事务中间可以执行DISCARD来放弃当前事务。这种情况下没有任何一条命令会被执行,并且当前连接会返回通常状态。(也就是没有在MULTI时的状态)

> SET foo 1
OK  
> MULTI
OK  
> INCR foo
QUEUED  
> DISCARD
OK  
> GET foo
"1"

使用check-and-set实现的乐观锁

WATCH是被用来在Redis事务中实现check-and-set(CAS)行为的。

被WATCH的key们会被监控变化。如果在EXEC执行之前有任何一个key被修改了,那么整个事务就都会退出,并且EXEC会直接返回一个Null响应来通知当前事务的失败。

举个例子,假如我们需要对一个redis中的key原子加一(假设Redis没有INCR这个命令)。

首先尝试的大概是下面的命令:

val = GET mykey  
val = val + 1  
SET mykey $val  

如果我们只有一个客户端在给定的时间进行这几项操作的时候,结果是可以预见的。但如果多个客户端同时尝试对key进行操作的话,将会产生竞态条件(多线程编程中的常见问题)。例如客户端A和客户端B将会读取旧值,比方说10。这个值会被两个客户端各自+1 = 11,然后再set mykey 11。所以最后我们得到的结果是11而不是12。

多谢WATCH,我们才能够正确地实现我们的原子操作了:

WATCH mykey  
val = GET mykey  
val = val + 1  
MULTI  
SET mykey $val  
EXEC  

使用上面的代码,如果存在竞态条件或者有其它客户端在我们调用WATCH到执行EXEC期间修改了val的结果,那么这个事务会执行失败。(会得到一个nil的响应)

为了使之执行成功,我们需要一遍一遍地继续重试,直到没有数据竞争,得到正确的结果。这种形式的锁就叫做乐观锁,乐观锁是一种很强大的锁。不过实际上在很多情境下多个客户端也很少会去修改相同的key值,所以冲突实际上也很少会发生,所以说是一遍遍重试,实际情况下需要重试的情况很少。

了解WATCH

那么WATCH到底是怎么一回事呢?这是一个能够使EXEC根据条件来执行的命令:我们使Redis可以在没有其它客户端修改被watch的key时才让事务执行成功,否则的话压根不进入事务。(需要注意,如果你WATCH的是一个不稳定的key,也就是说可能有过期时间的key,可能在你watch这个key之后redis让其过期了,这种情况下EXEC也是会执行成功的。)

WATCH可以被多次调用。简单地来说,对键的监视是从WATCH调用开始,一直到EXEC执行完毕结束。你也可以一次性watch多个key。

当EXEC被调用的时候,前面watch的所有key就都被unwatched了,无论事务是退出或者没退出。如果执行命令期间客户端连接断开了,那么这些key也会被unwatched。

假如你想要unwatch所有键的话,可以直接使用UNWATCH命令(不带任何参数),这样可以flush掉所有被watch的key。有时候我们只是watch一些键,并检查这些键是否满足要求,如果这时候有不满足要求的键,我们不想让程序逻辑继续向下走,那么就可以直接调用UNWATCH来释放这些锁。这样该条连接就可以继续执行其它事务了。

使用WATCH实现ZPOP

用一个例子来表明如何使用WATCH来创建Redis本来不支持的原子操作,这里我们实现一个ZPOP功能,这个命令能够将以原子方式将sorted set中的低分元素pop出来。下面大概是一个最简单的实现了:

WATCH zset  
element = ZRANGE zset 0 0  
MULTI  
ZREM zset element  
EXEC  

如果EXEC执行失败了(比如返回了Null),我们只需要重复执行一次操作。

Redis脚本和事务

Redis的脚本在定义上就是事务的,因此你可以在事务中做的所有事情都可以在脚本中进行,一般情况下Redis脚本也是很简单而且执行起来很快的。

Redis脚本和事务的重复性是在Redis 2.6中被引入的,由于事务已经在很早以前就被支持了,然而我们也没有办法在短时间内把事务特性移除掉。

不过将来Redis不支持事务的情况也是有可能的,如果我们的用户都只使用Redis脚本,我们也就能最终将事务本身从Redis中移除掉。