(译)Redis事务

本文翻译自Transactions

MULTIEXECDISCARDWATCH是Redis中事务的基础。它们允许将一组命令一起执行,这里有两个重要保证:

  • 事务中的所有命令都是串行地被序列化和解析的。永远不会发生这种情况:在一个Redis事务的执行过程中,其他客户端的请求被插入到事务中间执行这种事情。这保证了事务中的所有命令是作为一个不可分离的整体被执行的。

  • 要么所有的命令都没有被处理,因此Redis事务也是原子性的。EXEC命令触发事务中所有命令的执行,因此,如果一个客户端在调用MULTI命令之前丢失了服务器的连接,那么将不会执行任何命令,相反,如果调用了EXEC命令,则所有操作都会被执行。当使用AOF文件时,Redis将确保使用后一个单一的write(2)系统调用将事务写到磁盘上。然而,如果Redis服务器崩溃或被系统管理员强制杀掉的话,可能只有部分操作被写入磁盘。Redis会在重启时检测到这种情况,然后以一个错误退出。使用redis-check-aof工具可能可以修复AOF文件,这个工具将会移除那些未能被完整记录下来的部分事务,以便让服务器可以再次启动。

从Redis 2.2开始,Redis提供了上述两个保证,以乐观锁的形式,非常类似于一个检查-设置(CAS)操作。这些会在本文后面说明。

用法

一个Redis事务使用MULTI命令开启。这个命令总是使用OK作为响应。用户可以发送多个命令让服务器一次执行。Redis将对这些命令进行排队,而不是立刻执行它们。事务中的所有命令将在执行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命令时,Redis会处理队列中的命令。

事务中的错误

在事务中有可能遇到两种命令错误:

  • 命令可能无法进行排队,因此在执行EXEC之前可能会有错误。例如可能命令存在语法错误(错误的参数个数,错误的命令名称等),或者可能有一些临界条件比如内存不足(如果我们使用maxmemory指令配置了系统内存上限的话)。
  • 一个命令可能在调用EXEC之后执行失败,例如我们对一个键执行了操作的操作(像是对一个字符串调用了一个列表操作)。

客户端通常通过在执行EXEC命令之前检查命令排队的返回值来感知第一类错误:如果命令返回QUEUED就表示命令排队成功,否则Redis会返回一个错误。如果这里在对命令排队时有一个错误,大多数客户端将终止并丢弃事务。

然而从Redis 2.6.5开始,服务器将记住在命令的添加过程中出现了一个错误,并将在执行EXEC时返回一个错误来拒绝执行事务,并自动丢弃事务。

Redis 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内部简化和速度快的原因是因为它不需要回滚的能力。

一个观点认为当错误发生时,回滚并不能把你从编程错误中拯救出来。例如,一个查询对一个键自增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命令会返回Null回复给客户端来通知事务失败了。

例如,想象我们需要自动对一个键执行自增1的操作(让我们假设Redis没有INCR命令)。

首次尝试可能是这样的:

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

上面的代码只有在一段时间内仅有一个客户端对键进行操作时才是可靠的。如果多个客户端都在同一时间尝试对键执行自增操作就会造成竞争条件。例如,客户端A和客户端B将读取到旧的值,比如10。键的值将被两个客户端自增到11,并最后使用SET命令将键的值设置为11。所以最后的值是11而不是12.

幸好我们可以WATCH命令来很好地解决这个问题:

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

使用上面的代码,如果有竞争条件且另一个客户端在我们调用WATCH命令和EXEC命令之间修改了val的值,事务将会失败。

我们只需要重复这个操作,这次我们将不会遇到新的竞争条件。这种形式的锁定叫做乐观锁定,这是一种非常强大的锁定方式。在很多用例中,多客户端将访问不同的键,所以不太可能发生冲突,通常不需要重复进行操作。

WATCH命令解析

那么WATCH命令到底是什么呢?它是一个能让EXEC命令以条件执行的命令:我们让Redis在没有被监控的键被修改的情况下执行事务。(但他们的值可能被相同的客户端在事务中改变却不会终止事务。更多关于这种情况的信息请看这里)否则事务根本不会被执行。(注意如果使用WATCH命令监控了一个很容易改变的键,且在你对其执行WATCH命令后键过期了,EXEC命令仍会执行。更多关于这种情况的信息请看这里

WATCH命令可以被多次调用。简单地说,所有的WATCH调用都将在调用开始观察键的改变情况时造成一些影响,直到EXEC命令被调用。你也可以在一个单一个的WATCH调用中给其传入任意数量的键。

当EXEC被调用时,所有的键都会变为未被监控的状态,而不管事务是否被终止。当一个客户端连接关闭时,所有键也会变成未被监控的状态。

还可以使用UNWATCH命令(没有参数)来刷新所有被监控的键。这在有时候我们乐观地锁定了一些键时很有用,因为我们可能需要执行一个事务来改变这些键,但是在读取了键的当前内容后,我们决定放弃我们的更改。如果发生这种情况,我们只要调用UNWATCH以便连接还可以被新的事务自由地使用。

使用WATCH命令里实现ZPOP

一个能很好地说明如何使用WATCH命令来创建新的原子操作的例子是Redis没有提供ZPOP的实现,这个命令将从一个有序列表中以原子性地弹出评分最低的元素。这里有一个最简单的实现:

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

如果EXEC执行失败(即返回一个Null回复),主需要重复怎么操作即可。

Redis脚本和事务

从定义上说一个Redis脚本是事务性的,所以你可以使用一个脚本来做任何在原来Redis事务内做的事情,并且一般来说使用脚本会更简单且更快一些。

这种重复是因为实际上脚本功能是在Redis 2.6以后被引入的,而事务功能很早以前就有了。然而,我们不可能在短期内移除事务的支持,因为它在语义上是合适的,即使不适使用Redis脚本还是可以避免竞争条件,特别是因为Redis的事务实现复杂度比较小。

然而,短期内我们不太可能看到整个用户群体都在使用Redis脚本。如果发生这种情况,我们可能会使事务成为一个过时的功能并最终移除它。