返回首页

Redis进阶4.1: 事务与 WATCH 机制

适用版本:Redis 7.2.x

在很多初学者的印象里,Redis 是“单线程 + 命令原子执行”,于是很容易得出一个误解:既然单条命令已经是原子的,那是不是就不需要事务了?

答案是:单条命令的原子性,不等于多条命令组合后的业务原子性。

比如“先读取余额,再扣减金额,再写入流水状态”这种操作,往往由多条命令组成。如果多个客户端同时操作,同样可能产生竞争条件。Redis 为这类场景提供了两套常见能力:

  • 使用 MULTI / EXEC / DISCARD 组织事务
  • 使用 WATCH 做乐观锁控制

这篇文章重点讲清楚 Redis 事务到底解决什么问题、WATCH 的机制是什么、它和数据库事务有哪些不同,以及在实际业务里该怎么用、什么时候不该滥用。


一、先建立正确认知:Redis 事务不等于数据库事务

很多人第一次看到“事务”两个字,会自动代入 MySQL 里的 ACID 事务。但 Redis 的事务模型和关系型数据库并不是一回事。

Redis 事务主要提供的是:

  1. 把一组命令按顺序打包提交
  2. 在执行期间,不会被其他客户端命令插入到中间
  3. 可以配合 WATCH 做提交前的冲突检测

但它并不提供典型关系型数据库里的这些能力:

  • 复杂的隔离级别
  • 自动回滚到执行前状态
  • 事务内读未提交、可重复读之类的语义

换句话说,Redis 事务更像是:

“把多条命令排队,然后一次性按顺序执行;如果你担心并发修改,就在提交前用 WATCH 做版本检查。”

这一定义非常重要。后面理解 Redis 事务的行为时,几乎所有“为什么会这样”的问题,都能从这里找到答案。


二、Redis 事务的核心命令

Redis 事务常见命令有 4 个:

  • MULTI:开启事务
  • EXEC:提交事务
  • DISCARD:放弃事务
  • WATCH:监视一个或多个 key

另外还有一个常见配套命令:

  • UNWATCH:取消监视

先看最基本的事务流程。

127.0.0.1:6379> MULTI
OK

127.0.0.1:6379(TX)> SET order:1:status paid
QUEUED

127.0.0.1:6379(TX)> INCR stats:paid_count
QUEUED

127.0.0.1:6379(TX)> EXEC
1) OK
2) (integer) 1

这里有两个关键现象:

  1. 执行 MULTI 后,后续命令并不会立刻执行,而是进入队列
  2. 直到执行 EXEC,这些命令才真正按顺序执行

这就是 Redis 事务最基础的工作方式。


三、事务队列机制:为什么会看到 QUEUED

MULTI 之后,Redis 会把客户端状态切换到事务上下文。此时你发送的命令不会马上修改数据,而是先进入队列。

例如:

MULTI
SET user:1:name alice
INCR login:count
GET user:1:name
EXEC

它的执行顺序可以理解为:

  1. MULTI 开启事务上下文
  2. SETINCRGET 依次入队
  3. EXEC 到来时,Redis 依次执行队列中的命令
  4. 将每条命令的返回结果按数组形式返回给客户端

返回示例:

1) OK
2) (integer) 1
3) "alice"

因此,Redis 事务不是“边执行边提交”,而是“先排队,后统一执行”。


四、Redis 事务的原子性,到底体现在哪里

这也是最容易混淆的地方。

1. Redis 保证“事务中的命令串行执行”

一旦 EXEC 触发,Redis 会按顺序执行事务队列中的所有命令。执行过程中,不会插入其他客户端的命令。

也就是说,下面两组命令不会交错成这样:

  • 客户端 A:事务命令 1
  • 客户端 B:某条写命令
  • 客户端 A:事务命令 2

Redis 保证的是:事务队列在提交后会连续执行完。

2. Redis 不保证“像数据库一样失败后自动回滚”

例如:

SET balance 100
MULTI
DECRBY balance 30
INCRBY balance abc
EXEC

这里第二条事务内命令 INCRBY balance abc 是错误命令参数。执行结果可能是:

1) (integer) 70
2) (error) ERR value is not an integer or out of range

你会发现:

  • 第一条 DECRBY 已经生效
  • 第二条出错并不会让第一条自动撤销

这说明 Redis 事务不提供传统意义上的回滚。

3. 因此更准确的说法是

Redis 事务保证的是:

  • 提交后的执行顺序性
  • 执行期间的串行性
  • 可选的提交前冲突检测

但不保证:

  • 失败后自动恢复到事务前状态

五、事务里的两类错误:入队错误与执行期错误

理解这个细节,对排查问题很有帮助。

1. 入队阶段错误

如果某条命令在入队时就有明显语法问题,Redis 会记录事务状态为错误,EXEC 时整个事务不会执行。

示例:

MULTI
SET k1 v1
INCRBY
EXEC

这里 INCRBY 参数不完整,会导致事务在排队阶段就出错。

典型表现是:

  • 某条命令不是 QUEUED
  • 最后 EXEC 返回事务执行失败

2. 执行阶段错误

如果命令语法没问题,但执行时发现数据类型不匹配,那么事务会继续执行其他命令,只是报错的那条失败。

示例:

SET user:name alice
MULTI
SET counter 1
LPOP user:name
INCR counter
EXEC

因为 user:name 是字符串,不是列表,LPOP user:name 会报类型错误。但:

  • SET counter 1 会成功
  • LPOP user:name 会报错
  • INCR counter 仍会继续执行

这也是很多人误以为 Redis 事务“失效”的原因。其实不是失效,而是它的设计就是这样。


六、DISCARD:在提交前放弃事务

如果你在 MULTI 之后发现命令排错了,或者逻辑条件不满足,可以用 DISCARD 取消整个事务队列。

MULTI
SET draft:1 pending
INCR counter:test
DISCARD

执行 DISCARD 后:

  • 队列会被清空
  • 事务上下文结束
  • 已排队命令不会执行

这个命令很适合交互式调试时使用。


七、为什么还需要 WATCH

仅靠 MULTI / EXEC,只能保证“提交后连续执行”,但不能解决“提交前数据已经被别人改了”的问题。

看一个经典场景:扣库存。

初始库存为 1,两个客户端都这样做:

  1. GET stock:item:1001
  2. 判断库存是否大于 0
  3. 如果大于 0,则执行事务 DECR stock:item:1001

如果没有并发控制,两边都可能在读取时看到库存是 1,然后都去提交扣减,最后出现超卖。

这时就需要 WATCH


八、WATCH 的本质:乐观锁

WATCH 的作用不是“加锁阻塞别人”,而是:

在提交事务前,监视指定 key 是否被其他客户端修改;如果被改过,当前事务提交失败。

这属于典型的乐观锁思想。

它的假设是:

  • 大多数时候并发冲突并不频繁
  • 先乐观执行
  • 提交前再检查有没有人改过
  • 如果冲突了,就放弃并重试

和悲观锁相比,WATCH 不会长时间占住资源,也不会让其他客户端被阻塞。


九、WATCH 的基本使用流程

标准流程通常是这样的:

  1. WATCH key
  2. 读取 key 当前值
  3. 在客户端做业务判断
  4. MULTI
  5. 发送准备提交的命令
  6. EXEC
  7. 如果 EXEC 返回空结果,说明监视的 key 在提交前被改动,事务失败,需要重试

示例:

WATCH stock:item:1001
GET stock:item:1001
MULTI
DECR stock:item:1001
EXEC

如果 WATCH 之后、EXEC 之前,其他客户端改动了 stock:item:1001,那么本次 EXEC 返回:

(nil)

或者在不同客户端里表现为“空数组 / null reply”。这表示:

  • 事务没有执行
  • 不是命令出错
  • 而是乐观锁检查未通过

十、一个完整的库存扣减示例

先初始化数据:

SET stock:item:1001 5

客户端 A:

WATCH stock:item:1001
GET stock:item:1001

假设读到结果:

"5"

此时客户端 B 执行了:

DECR stock:item:1001

库存变成了 4。

客户端 A 继续提交:

MULTI
DECR stock:item:1001
EXEC

结果会是:

(nil)

因为在 A 提交前,B 已经修改了被监视的 key,A 的事务自动失效。

这说明 WATCH 解决的不是“执行中串行”,而是“提交前版本是否仍然有效”。


十一、UNWATCH 与自动取消监视

当你执行以下任一动作时,监视关系会结束:

  • EXEC
  • DISCARD
  • UNWATCH
  • 客户端连接关闭

如果你只是想读取一下数据,但后续决定不提交事务,可以主动执行:

UNWATCH

这在客户端代码里也很常见,尤其是分支逻辑较多时,可以避免让连接一直带着监视状态。


十二、Redis 事务 + WATCH 的典型应用场景

1. 条件更新

例如只有当前状态还是 pending 时,才允许改为 paid

思路:

  • WATCH order:1001:status
  • 读取状态
  • 如果还是 pending,进入事务设置为 paid
  • 否则放弃

命令示意:

WATCH order:1001:status
GET order:1001:status
MULTI
SET order:1001:status paid
EXEC

2. 基于当前值计算新值

例如“余额扣减”“版本号递增”“配额减少”等场景,常常都需要:

  • 先读取当前值
  • 再根据业务规则决定写回内容

3. 防止并发覆盖

比如多个客户端同时更新购物车汇总字段,如果没有版本检查,很容易把别人的更新覆盖掉。


十三、客户端伪代码示例:带重试的 WATCH 事务

在真实业务中,WATCH 往往要配合重试逻辑使用。

下面给一个接近 Python 的伪代码示例:

import time
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)


def deduct_stock(key: str, amount: int, max_retry: int = 5) -> bool:
    for _ in range(max_retry):
        try:
            with r.pipeline() as pipe:
                pipe.watch(key)
                current = pipe.get(key)

                if current is None:
                    pipe.unwatch()
                    return False

                stock = int(current)
                if stock < amount:
                    pipe.unwatch()
                    return False

                pipe.multi()
                pipe.decrby(key, amount)
                pipe.execute()
                return True
        except redis.WatchError:
            time.sleep(0.05)
            continue

    return False

这个示例里有几个关键点:

  • watch() 后先读取数据
  • 判断业务条件是否满足
  • multi() 后才开始组织事务命令
  • 如果发生并发冲突,捕获 WatchError 并重试

这正是 Redis 乐观锁的常规用法。


十四、事务与 Lua 脚本有什么区别

学习到这里,很多人会问:

既然 Redis 有事务,为什么后面还要学 Lua?

原因是:

1. 事务适合“客户端先读,再决定怎么写”

它依赖客户端参与逻辑判断,适合实现乐观锁流程。

2. Lua 更适合“把判断和写入一起放到服务端执行”

如果你想把“读取、判断、修改”合成一个不可拆分的原子操作,Lua 往往更直接。

例如库存扣减,Lua 可以一次完成:

  • 读取库存
  • 判断是否足够
  • 足够则扣减
  • 不足则返回失败

这样就不需要客户端 WATCH + 重试

所以从工程角度看:

  • 事务 + WATCH:适合乐观锁、冲突重试模型
  • Lua:适合把业务逻辑压缩成一次原子执行

十五、Redis 事务使用中的几个常见误区

误区 1:以为事务里的命令会立即执行

不会。MULTI 后只是入队,真正执行在 EXEC 时发生。

误区 2:以为事务失败会自动回滚

不会。执行期错误不会让前面成功的命令回退。

误区 3:以为 WATCH 会阻塞其他客户端

不会。WATCH 是乐观锁,不加阻塞锁。

误区 4:以为 WATCH 能解决所有并发问题

也不一定。高冲突场景下,WATCH 可能导致频繁重试,吞吐并不一定理想。这时更适合改用 Lua 脚本。


十六、实践建议

1. 先区分“命令原子”与“业务原子”

INCRDECR 这种单命令本身已经是原子的,不要为了一个简单计数器强行上事务。

2. 有“先读再写”需求时,再考虑 WATCH

如果你的逻辑依赖当前值,例如“库存足够才扣减”“状态是 pending 才能支付”,那么 WATCH 才有意义。

3. 为 WATCH 失败设计重试上限

乐观锁不是一定成功的。高并发下冲突很正常,因此一定要:

  • 控制最大重试次数
  • 设置合理的退避时间
  • 超限后返回明确失败结果

4. 高冲突热点场景优先考虑 Lua

如果某个 key 被大量并发竞争,WATCH 可能让客户端不停重试。把逻辑收敛到 Lua 脚本里,通常更稳。

5. 不要把 Redis 事务想象成数据库事务替代品

Redis 事务适合做轻量级原子提交与并发校验,不适合承载复杂跨对象强一致业务。


十七、小结

这篇文章的核心内容可以归纳为 4 点:

  1. Redis 事务通过 MULTI / EXEC 实现命令排队与顺序提交
  2. 它保证事务提交后的串行执行,但不提供数据库式自动回滚
  3. WATCH 本质上是乐观锁,用于提交前检查 key 是否被别人改动
  4. 对于“先读再写”的并发场景,事务 + WATCH 很实用;对于高竞争热点场景,Lua 往往更合适

如果你之前把 Redis 事务理解成“简化版 MySQL 事务”,那现在应该把模型修正过来了:

Redis 事务更像“批量顺序提交 + 可选的乐观锁检查”,而不是“完整的 ACID 事务系统”。

理解这一点,后面再学习 Lua 脚本、分布式锁、库存扣减、限流等内容时,思路会清晰很多。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Rddis高级数据结构3.4: Stream 消息流

下一篇

Redis进阶4.2: Lua 脚本入门