事务
Redis 事务的工作原理
Redis 事务允许以单个步骤执行一组命令,它们围绕 MULTI
、EXEC
、DISCARD
和 WATCH
命令展开。Redis 事务提供两个重要的保证
-
事务中的所有命令都已序列化并按顺序执行。另一个客户端发送的请求绝不会在 Redis 事务执行的中间被服务。这保证了命令作为单个独立的操作执行。
-
EXEC
命令触发事务中所有命令的执行,因此如果客户端在调用EXEC
命令之前在事务上下文中与服务器失去连接,则不会执行任何操作;相反,如果调用了EXEC
命令,则会执行所有操作。使用 append-only file 时,Redis 确保使用单个 write(2) 系统调用将事务写入磁盘。然而,如果 Redis 服务器崩溃或被系统管理员以某种强硬方式终止,则可能只有部分操作被记录下来。Redis 在重新启动时会检测到这种情况,并会伴随错误退出。使用redis-check-aof
工具可以修复 append only file,该工具将删除部分事务,以便服务器可以再次启动。
从版本 2.2 开始,Redis 允许在上述两个保证之外提供额外的保证,采用乐观锁的形式,其方式与 check-and-set (CAS) 操作非常相似。本文档稍后对此进行说明。
用法
使用 MULTI
命令进入 Redis 事务。该命令始终回复 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
时被调度执行。
事务中的错误
在事务执行期间,可能会遇到两种命令错误
- 命令可能无法入队,因此在调用
EXEC
之前可能就会发生错误。例如,命令可能存在语法错误(参数数量错误、命令名称错误等),或者可能存在一些关键条件,例如内存不足(如果服务器使用maxmemory
指令配置了内存限制)。 - 命令可能在调用
EXEC
之后失败,例如,因为我们对具有错误值的键执行了操作(例如,对字符串值调用列表操作)。
从 Redis 2.6.5 开始,服务器将在命令累积期间检测到错误。然后它将拒绝执行事务,在 EXEC
期间返回错误,并丢弃事务。
注意:对于 Redis < 2.6.5: 在 Redis 2.6.5 之前,客户端需要通过检查排队命令的返回值来检测在
EXEC
之前发生的错误:如果命令回复QUEUED
,则表示已正确入队,否则 Redis 返回错误。如果在命令入队时发生错误,大多数客户端将中止并丢弃事务。否则,如果客户端选择继续执行事务,EXEC
命令将执行所有成功入队的命令,无论之前是否存在错误。
相反,在 EXEC
之后发生的错误不会以特殊方式处理:即使事务执行期间某些命令失败,所有其他命令也会被执行。
这在协议层面更清楚。在以下示例中,即使语法正确,一个命令在执行时也会失败
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
MULTI
+OK
SET a abc
+QUEUED
LPOP a
+QUEUED
EXEC
*2
+OK
-WRONGTYPE Operation against a key holding the wrong kind of value
EXEC
返回一个包含两个元素的 bulk string reply,其中一个为 OK
代码,另一个为错误回复。客户端库有责任找到合理的方式将错误提供给用户。
需要注意的是,即使一个命令失败,队列中的所有其他命令也会被处理—— Redis 不会停止处理命令。
另一个示例,再次使用带 telnet
的线路协议,展示了语法错误是如何立即报告的。
MULTI
+OK
INCR a b c
-ERR wrong number of arguments for 'incr' command
这一次,由于语法错误,错误的 INCR
命令根本没有入队。
关于回滚?
Redis 不支持事务回滚,因为支持回滚将对 Redis 的简洁性和性能产生重大影响。
丢弃命令队列
可以使用 DISCARD
命令来中止事务。在这种情况下,不会执行任何命令,连接的状态恢复正常。
> SET foo 1
OK
> MULTI
OK
> INCR foo
QUEUED
> DISCARD
OK
> GET foo
"1"
使用 check-and-set 的乐观锁
WATCH
命令用于为 Redis 事务提供 check-and-set (CAS) 行为。
被 WATCH
的键受到监视,以检测对其的更改。如果在 EXEC
命令之前至少有一个被监视的键被修改,则整个事务将中止,并且 EXEC
返回一个 Null reply 通知事务失败。
例如,假设我们需要以原子方式将键的值自增 1(假设 Redis 没有 INCR
命令)。
第一次尝试可能如下所示
val = GET mykey
val = val + 1
SET mykey $val
只有在给定时间内只有一个客户端执行此操作时,这才能可靠地工作。如果多个客户端几乎同时尝试自增键,就会出现竞争条件。例如,客户端 A 和 B 将读取旧值,例如 10。两个客户端都将该值自增到 11,最后将 11 通过 SET
命令设置为键的值。因此最终值为 11 而不是 12。
得益于 WATCH
命令,我们可以很好地解决这个问题。
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
使用上面的代码,如果存在竞争条件,并且在调用 WATCH
和调用 EXEC
之间有另一个客户端修改了 val
的结果,则事务将失败。
我们只需要重复操作,希望这次不会再遇到竞争。这种形式的锁称为乐观锁。在许多用例中,多个客户端会访问不同的键,因此冲突的可能性很小——通常不需要重复操作。
WATCH 说明
那么 WATCH
命令究竟有什么用?它是一个使 EXEC
具有条件性的命令:我们要求 Redis 仅在没有被 WATCH
的键被修改的情况下执行事务。这包括客户端进行的修改(例如写入命令)以及 Redis 本身进行的修改(例如过期或逐出)。如果在被 WATCH
的时间和接收到 EXEC
的时间之间键被修改了,则整个事务将中止。
注意
- 在 Redis 6.0.9 之前的版本中,过期的键不会导致事务中止。更多信息
- 事务中的命令不会触发
WATCH
条件,因为它们只会在发送EXEC
之前排队。
WATCH
命令可以多次调用。所有 WATCH
调用都会监控从调用时刻到 EXEC
调用时刻之间的变化。您还可以向单个 WATCH
调用发送任意数量的键。
当调用 EXEC
时,所有键都会被 UNWATCH
,无论事务是否中止。此外,当客户端连接关闭时,所有键都会被 UNWATCH
。
也可以使用 UNWATCH
命令(不带参数)来清空所有被监视的键。有时这很有用,因为我们可能会乐观地锁定一些键,可能需要执行事务来更改这些键,但在读取键的当前内容后,我们不想继续。发生这种情况时,我们只需调用 UNWATCH
,以便连接可以自由地用于新的事务。
使用 WATCH 实现 ZPOP
一个很好的例子来说明如何使用 WATCH
创建 Redis 本身不支持的新原子操作,即实现 ZPOP (ZPOPMIN
、ZPOPMAX
及其阻塞变体仅在版本 5.0 中添加),ZPOP 是一个以原子方式从有序集合中弹出分数最低的元素的命令。这是最简单的实现:
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
如果 EXEC
失败(即返回一个 Null reply),我们只需重复操作。
Redis 脚本和事务
在 Redis 中,对于类似事务的操作,还需要考虑 Redis 脚本,它们是事务性的。使用 Redis 事务可以完成的所有事情,也可以使用脚本来完成,通常脚本更简洁、更快。