Redis 事务(翻译)

173 阅读9分钟

事务

MULTI, EXEC, DISCARD 和 WATCH 是事务的基础。事务可以一次执行多个命令,并且带有一下两个重要的保证:

  • 事务中的所有命令都被序列化并按顺序执行。事务在执行的过程中,不会被其它客户端发送来的命令请求所打断。这保证了这些命令作为单个独立操作执行。
  • 事务中的命令要么全部被执行,要么全部都不执行。所以 Redis 事务也具有原子性的。EXEC 命令负责触发并执行事务中的所有命令。因此,如果在调用 EXEC 命令之前,客户端在事务的上下文中失去了与服务器的连接,则不会执行任何操作;相反,如果 EXEC 命令被调用,则会执行所有操作。当使用 AOF 方式做持久化的时候,Redis 会使用一个 write(2) 系统调用操作将事务写入到磁盘。然而,如果 Redis 服务器因为某些原因被管理员射死,或者遇到某种硬件故障,那么可能只有部分事务命令会被成功写入到磁盘。如果 Redis 在重新启动时发现 AOF 文件出了这样的问题,那么它会退出,并汇报一个错误。使用 redis-check-aof 工具可以修复这一问题:它会移除 AOF 文件中不完整事务的信息,确保服务器可以顺利启动。

从 2.2 版本开始,Redis 还可以通过乐观锁(optimistic lock)实现 CAS (check-and-set)操作,具体信息请参考本文的后半部分。

用法

MULTI 命令用于开启一个事务,它总是返回 OK。

MULTI 执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当 EXEC 命令被调用时,所有队列中的命令才会被执行。

下面是一个事务的例子,它原子地增加了 foo 和 bar 两个键的值:

image.png

从上图中可以看出,EXEC 命令的回复是一个数组,数组中的元素对应的是事务中的命令的回复。元素的顺序与命令的先后顺序保持一致。

当客户端处于事务状态时, 所有传入的命令都会返回一个内容为 QUEUED 的状态回复(status reply)。当调用 EXEC 命令时,入队的命令被执行。

事务中的错误

使用事务时,可能会遇到以下两种错误:

  1. 事务在执行 EXEC 命令之前,有的入队命令可能会出错. 举个例子,有的命令可能是语法错误(参数数量错误,参数名错误,关键字不存在,等等),或者其它更严重的错误,比如说内存不足(如果服务器使用 maxmemory 设置了最大内存限制的话)

  2. 在调用 EXEC 命令之后,有的命令可能会执行失败。举个例子,有的命令可能给 某个key 设置了错误的值(比如给 list 的key,设置了一个 string 的值)。

对于第一种错误,在 EXEC 命令调用之前,客户端之前是检查队列中命令的返回值:如果命令入队时返回 QUEUED,那么入队成功;否则,Redis 返回一个错误。如果有命令在入队时失败,那么大部分客户端都会停止并取消这个事务。

不过从 Redis 2.6.5 开始,服务器会对命令入队失败的情况进行记录,并在客户端调用 EXEC 命令时,拒绝执行并自动放弃这个事务。

在 Redis 2.6.5 之前,Redis 只执行事务中那些入队成功的命令,而忽略那些入队失败的命令。而新的处理方式,使得事务与管道(pipeline)结合起来变得更加简单,因为发送事务和读取事务的回复都只需要和服务器进行一次通讯。

至于那些在 EXEC 命令执行之后所产生的错误,并没有对它们进行特殊处理:即使某些命令在事务执行期间失败,也会执行所有其它命令。

从协议的角度来看这个问题,会更容易理解一些。下面的例子中,LPOP 命令的执行将出错,即管调用它的语法是正确的:

image.png

EXEC 返回两条批量回复(bulk reply):第一个是 OK,而第二条是 error(至于用什么样的方式来表示错误,取决客户端)。

需要记住的是:即使事务在执行过程中,有某个/某些命令执行失败了,事务队列中的其它命令仍然会继续执行——Redis 不会停止执行事务中的命令。

下面这个例子展示的是另一种情况,命令在入队时有语法错误:

image.png

因为调用 INCR 命令的参数格式不正确, 所以这个 INCR 命令入队失败,且第二个命令(set str good)也不会得到执行。

为什么 Redis 不支持回滚

如果你有关系式数据库的经验,那么 “Redis 在事务失败时不进行回滚,而是执行余下其它的命令” 这种做法可能会让你觉得有点奇怪。

以下是这种做法的有点:

  • Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这意味着在实际的情况下,失败的命令是由编程错误造成的,而这种类型的错误应该在开发的过程中被发现,而不应该出现在生产环境中。

  • 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

对这种观点持有相反的看法是:Redis 处理事务的做法会产生 bug。

然而需要注意的是,在通常情况下,回滚并不能解决编程错误带来的问题。举个例子,如果你本来想通过 INCR 命令将某个 key 的值加上 1,而不是 2,又或者对错误错误类型的键执行了 INCR,回滚是没有办法处理这些情况的。

鉴与没有任何机制能够避免程序员自己造成的错误,并且这类错误通常不会在生产环境中出现,所以 Redis 选择了更简单、更快速的无回滚方式来处理事务。

放弃事务

当执行 DISCRED 命令时,事务会被放弃。在这种情况下,不执行任何命令(事务队列会被清空),并且客户端会从事务状态中退出。

image.png

使用 check-and-set 操作实现乐观锁

WATCH 命令可以为 Redis 事务提供 check-and-set(CAS)行为。

被 WATCH 的键会被监视,并会发觉这些键是否被改动过了。如果有至少一个被监视的建在 EXEC 执行之前被修改了,那么整个事务都会被取消,EXEC 返回空(Null replay)来表示事务执行失败。

举个例子,假设我们需要将一个 key 的值原子地增加 1(假设 Redis 没有 INCR)。

第一次尝试可能是:

val = GET mykey
val = val + 1
set mykey $val

只有当我们有一个客户端在给定的时间内执行操作时,这种方法才能可靠地工作。如果多个客户端在相同的时间修改 key 的值,则会出现竞争条件。

例如:客户端 A 和 B 读取到旧的值,例如 10 。两个客户端都会将该值增加到 11,最后 set 到 mykey,所以最终 mykey 的值是 11 而不是 12。

有了 WATCH,我们就可以轻松地解决这类问题了。

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

使用上面的代码,如果存在竞态条件,在调用 WATCH 到 EXEC 命令之间有另外一个客户端修改 key 的值,则事务会失败。

我们只需要重复操作,希望在这个时间段内不会有新的竞争。这种形式的锁被称作乐观锁,它是一种非常强大的锁机制。因为在大多数情况下,不同的客户端会访问不同的key,碰撞的情况一般很少,所以通常并不需要重试。

了解 WATCH

WATCH 使得 EXEC 命令需要有条件地执行:事务只能在所有被监视的 key 都没有被修改的前提下进行。这包括客户端所做的修改以及 Redis 本身所做的修改,比如过期(expiration)或逐出(eviction-根据内存淘汰策略)。如果在监视 key 和接收到 EXEC 命令之前的这段时间, 被监视的 key 有了修改, 则整个事务将被终止。

注意:在 6.0.9 之前的 Redis 版本中,key 的过期不会导致事务终止。WATCH 可以被调用多次,对 key 的监视从 WATCH 执行之后开始生效,直到调用 EXEC 为止。当调用 EXEC 命令后,无论事务是否被中止,所有被监视的 key 都会自动被取消监视。此话,当客户端连接关闭时,所有被监视的 key 也会被取消监视。

用户还可以在单个 WATCH 命令中监视任意多个键,就像这样:

image.png

也可以使用 UNWATCH 命令(不带参数)来取消所有被监视的 key。有时这很有用,对于一些需要改动多个键的事务, 有时候程序需要同时对多个键进行加锁, 然后检查这些键的当前值是否符合程序的要求。 当值达不到要求时, 就可以使用 UNWATCH 命令来取消目前对键的监视, 中途放弃这个事务, 并等待事务的下次尝试。

使用 WATCH 实现 ZOP

一个很好的例子说明了 WATCH 如何用于创建 Redis 没有内置的原子操作。即实现以原子的方式从有序集合中移除 score 最小的元素:

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

程序只要重复执行这段代码,直到 EXEC 的返回值不是空回复(Null reply)即可。

Redis 脚本和事务

从定义上来说, Redis 中的脚本本身就是一种事务, 所以任何在事务里可以完成的事, 在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快。

因为脚本功能是 Redis 2.6 才引入的, 而事务功能则更早之前就存在了, 所以 Redis 才会同时存在两种处理事务的方法。

不过并不打算在短时间内就移除事务功能, 因为事务提供了一种即使不使用脚本, 也可以避免竞争条件的方法, 而且事务本身的实现并不复杂。

不过在不远的将来, 可能所有用户都会只使用脚本来实现事务也说不定。 如果真的发生这种情况的话, 那么我们将废弃并最终移除事务功能。