*Redis事务机制详解
Redis 事务允许在单个步骤中执行一组命令,主要围绕 MULTI、EXEC、DISCARD 和 WATCH 命令展开。Redis 事务提供两个重要保证:
事务中的所有命令会被序列化并依次执行。在执行 Redis 事务过程中,其他客户端的请求永远不会在事务执行中途被处理。这确保了命令作为单个独立操作执行。
EXEC 命令会触发事务中所有命令的执行。如果客户端在调用 EXEC 命令前与服务器的连接中断,则不会执行任何操作;如果调用了 EXEC 命令,则所有操作都会被执行。当使用 append-only file(AOF) 时,Redis 会通过单个
write(2)
系统调用将事务写入磁盘。然而,如果 Redis 服务器因系统管理员强制关闭或崩溃而提前终止,则可能只记录部分操作。Redis 会在重启时检测到这种情况,并以错误退出。使用redis-check-aof
工具可以修复 AOF 文件,移除部分事务,使服务器能够重新启动。
从版本 2.2 开始,Redis 通过乐观锁机制(类似于检查-设置 [CAS] 操作)为上述两个保证增加了额外的保障。这在本文的 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 返回了两个元素的 批量字符串回复,其中一个是 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"
*使用检查-设置实现乐观锁
WATCH 用于为 Redis 事务提供检查-设置(CAS)行为。
被 WATCH 的键会被监控以检测其变化。如果在调用 EXEC 之前,至少有一个被监控的键被修改,则整个事务会中止,并且 EXEC 返回 空值回复 以通知事务失败。
例如,假设我们需要原子地将键值递增 1(假设 Redis 没有 INCR 命令)。第一次尝试如下:
val = GET mykey
val = val + 1
SET mykey $val
这在单一客户端操作时能可靠工作。但如果多个客户端同时尝试递增该键,则会发生竞态条件。例如,客户端 A 和 B 读取旧值 10,两个客户端都会将其递增到 11,最终键值为 11 而非 12。
通过 WATCH,我们可以很好地建模这个问题:
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
使用上述代码,如果在调用 WATCH 和 EXEC 之间有其他客户端修改了 val
的值,事务会失败。我们只需重复操作,希望这次不会有新的竞态条件。这种锁机制称为 乐观锁。在许多用例中,多个客户端访问不同键,因此冲突的可能性较低——通常无需重复操作。
*WATCH 解释
WATCH 实际上是一个条件命令:我们要求 Redis 仅在被监控的键未被修改时才执行事务。这包括客户端的修改(如写命令)和 Redis 本身的修改(如过期或驱逐)。如果在 WATCH 和 EXEC 之间键被修改,则整个事务会被中止。
注意: * 在 Redis 6.0.9 之前,过期键不会导致事务中止。更多信息 * 事务中的命令不会触发 WATCH 条件,因为它们仅在 EXEC 调用前排队。
WATCH 可以多次调用。所有 WATCH 调用从调用开始到 EXEC 调用时都会监控变化。你还可以在单个 WATCH 调用中监控任意数量的键。
当 EXEC 被调用时,所有键都会被取消监控,无论事务是否被中止。此外,当客户端连接关闭时,所有监控的键也会被取消监控。
还可以使用不带参数的 UNWATCH 命令来清除所有监控的键。有时这很有用,因为我们乐观地锁定了几个键,可能需要执行事务来修改这些键,但在读取键值后我们决定不继续操作。在这种情况下,只需调用 UNWATCH,以便连接可以立即用于新的事务。
*使用 WATCH 实现 ZPOP
一个很好的示例是展示如何使用 WATCH 创建 Redis 不直接支持的新原子操作。例如,实现 ZPOP
(ZPOPMIN、ZPOPMAX 及其阻塞变体仅在 5.0 版本中添加),即从有序集中以原子方式弹出最低分的元素。这是最简单的实现:
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
*Redis 脚本与事务
另一个需要考虑的事务类操作是 Redis 脚本,它们是事务性的。通过脚本可以实现所有 Redis 事务能做的事,而且通常脚本更简单、更快。