首页 / Redis 入门教程 / 22. 事务 Transaction

Redis 入门教程

22. 事务 Transaction

本教程共 40 篇 · 第 22 篇 · 更新于 2026-08-02

事务MULTIEXECDISCARDWATCH乐观锁CAS

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"

事务保证三件事:

  1. EXEC 之前,命令只入队不执行;
  2. EXEC 之后,所有命令作为一个整体连续执行,期间不会被其他客户端的命令插队
  3. 单个命令本身仍是原子的。

如果想中途放弃,用 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 大版本,对事务命令与语义的讲述没有影响。