22. 事务 Transaction
本教程共 40 篇 · 第 22 篇 · 更新于 2026-08-02
22. 事务 Transaction
很多从关系型数据库过来的开发者,会默认“事务 = 要么全做、要么全不做(原子性 + 回滚)”。但 Redis 的事务不是这个意思。它提供的是一种“把多条命令打包、按顺序、不被其他客户端插队执行”的机制,称为 Transaction。
理解 Redis 事务的边界,能帮你避开“以为回滚了、其实没回滚”的大坑。
本节目标
- 用
MULTI/EXEC把多条命令打包执行,理解QUEUED与队列缓存。 - 用
DISCARD放弃一个已开始的事务。 - 区分“EXEC 之前的错误”和“EXEC 之后的错误”,理解为何 Redis 不回滚。
- 用
WATCH实现乐观锁(CAS),在 key 被改动时让事务失效。 - 明确 Redis 事务的“隔离性有、原子回滚无”这一关键语义。
MULTI / EXEC:打包执行
一个事务从 MULTI 开始,随后的命令不再立即执行,而是进入队列(返回 QUEUED),直到 EXEC 才一次性、按顺序执行所有排队命令,并把每条结果依次返回:
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET book:name "Redis 入门"
QUEUED
127.0.0.1:6379> GET book:name
QUEUED
127.0.0.1:6379> SADD book:tags "cache" "db"
QUEUED
127.0.0.1:6379> SMEMBERS book:tags
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) "Redis 入门"
3) (integer) 2
4) 1) "cache"
2) "db"
事务保证三件事:
EXEC之前,命令只入队不执行;EXEC之后,所有命令作为一个整体连续执行,期间不会被其他客户端的命令插队;- 单个命令本身仍是原子的。
如果想中途放弃,用 DISCARD 清空队列、退出事务态:
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> GET k1
(nil)
Redis 事务不回滚
这是最有反差的一点:Redis 事务执行时,如果某条命令出错,前面已执行的命令不会回滚,后面剩下的命令也照常执行。
错误分两类:
第一类:EXEC 之前的错误(入队失败)。比如命令语法错、或内存不足导致无法入队。自 Redis 2.6.5 起,只要队列里有任何命令入队失败,EXEC 会直接拒绝整个事务:
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> NOTACOMMAND
(error) ERR unknown command 'NOTACOMMAND'
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
第二类:EXEC 之后的错误(运行时错误)。例如对错误类型的 key 执行了不匹配的命令。Redis 不会因此中断,也不会回滚,而是记下这条的错误、继续执行后续命令:
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET age 23
QUEUED
127.0.0.1:6379> SADD age 15
QUEUED
127.0.0.1:6379> SET age 29
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK
127.0.0.1:6379> GET age
"29"
可以看到:第 2 条 SADD 针对字符串 key 失败了,但第 1 条 SET age 23 已生效、第 3 条 SET age 29 也照常生效。
提示:Redis 官方对此的解释是——运行时错误通常是编程错误(你本不该把字符串当集合用),这类错误理应在开发期被发现,而非靠数据库回滚来兜底;并且不引入回滚机制,让 Redis 内部保持简单、高效。因此:不要把 Redis 事务当成关系库的“原子事务”。需要“全有或全无”的语义,请用下一章的 Lua 脚本,或在应用层做补偿。
WATCH:乐观锁(CAS)
MULTI/EXEC 能保证“不被插队”,却保证不了“执行时数据没被别人改过”。例如你想实现“读取余额 → 扣减 → 写回”的转账,读取后、写回前,另一个客户端可能已改了余额,导致覆盖丢更新。
WATCH 就是为解决这个问题而生:它监视一个或多个 key,如果在 WATCH 之后到 EXEC 之前,这些 key 被任何其他客户端修改过,那么本次 EXEC 会返回 nil(事务不执行),相当于乐观锁的 CAS(Compare-And-Set)失败。
# 客户端 A:监视并准备事务
127.0.0.1:6379> SET balance 100
OK
127.0.0.1:6379> WATCH balance
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY balance 30
QUEUED
此时,客户端 B 抢先改了 balance:
# 客户端 B
127.0.0.1:6379> DECRBY balance 10
(integer) 90
客户端 A 再执行 EXEC,因为 balance 已被改动,事务失效:
# 客户端 A
127.0.0.1:6379> EXEC
(nil)
127.0.0.1:6379> GET balance
"90"
返回 nil 表示事务没有执行,余额保持为 90(B 改后的结果)。应用层应检测 nil、重试整个“读—算—写”流程。UNWATCH 可手动取消对所有 key 的监视;事务执行后(无论成败)监视也会自动解除。
小结
- 事务 =
MULTI入队 +EXEC顺序执行;DISCARD放弃;命令入队后返回QUEUED。 - 隔离性有:
EXEC期间不会被其他客户端插队。 - 原子回滚无:运行时错误不中断、不回滚,前面已执行的命令保留。
EXEC前入队失败会让整个事务被EXECABORT拒绝(2.6.5+)。WATCH实现乐观锁:被监视 key 在事务前被改,EXEC返回nil,需应用层重试。- 需要“全有或全无”,请用 Lua 脚本(下一章)。
实战:用 WATCH 实现 CAS 重试
实际写事务时有个标准套路:先用 WATCH 监视关键 key,在 MULTI 之前把需要的值读出来做业务判断(注意判断在应用层做,不在事务里),再把写命令入队。若 EXEC 返回 nil,说明期间被别的客户端改过,就重新读取、重新计算、WATCH 后再试一次,直到成功。这个“读—判断—写—失败重试”的循环,就是 Redis 中实现 CAS 乐观锁的惯用法。
伪代码逻辑如下:
WATCH balance
val = GET balance
newval = val - cost
MULTI
SET balance newval
ret = EXEC
if ret == nil:
retry from WATCH again
注意:重试次数要设上限,避免极端并发下无限循环;而且 WATCH 只保证“没被改才提交”,并不保证“提交一定能成功”,二者都要在应用层处理。
常见误区
- 误区一:以为 Redis 事务会回滚。 这是最大的坑。运行时错误(如对错误类型 key 操作)发生后,前面已执行的命令不会撤销,后面的命令也照常执行。Redis 不提供关系库那样的原子回滚;需要“全有或全无”,请用 Lua 脚本。
- 误区二:以为
WATCH会“锁住” key。WATCH是乐观锁:它只监视 key 是否被改,被改后让本次EXEC返回nil,但并不阻塞其他客户端对该 key 的写入。高并发下要通过“检测nil→ 重试”来应对,而不是指望锁住。 - 误区三:以为
MULTI里能写if条件分支。MULTI只是把命令排入队列,直到EXEC才执行,期间无法根据中间结果动态决定下一步。真正的“读—判断—写”逻辑要放到 Lua 脚本里用redis.call实现。 - 误区四:把 Redis 事务当成关系库的强一致事务。 它的保证只有“隔离性(不被插队)”和“入队失败整体拒绝”,并不提供原子回滚与隔离级别。把它理解为“批量打包执行”更准确。
提示:本教程统一基于 Redis 8.x(最新稳定版)行文。补充说明:官方 redis.io 下载页另标 8.8 为 “Latest stable”,而 GitHub 上的最新发布 tag 为 8.10.0,二者同属 8.x 大版本,对事务命令与语义的讲述没有影响。