Redis 并发控制实战:为什么 WATCH/EXEC 能挡住竞态

从 cancel 竞态问题讲清 CAS、分布式锁和线性化点

May 15, 2026·2 min read·Yimin
#Redis#并发控制#WATCH/EXEC#分布式系统

本文带你彻底搞懂一个高频误区: Redis 是单线程, 但你的系统依然有并发, WATCH/EXEC 的价值是让冲突可检测而不是让冲突消失。

🎯 问题现象

我们在处理 session cancel 时经常会遇到一个现象:

场景结果
低并发看起来一切正常, cancel 大多成功
高并发偶发冲突、重试, 甚至历史实现里会出现 500
团队直觉"Redis 单线程, 理论上不该有竞态吧?"

这时候最容易陷入一个误解:
既然 Redis 是单线程, 那我们是不是根本不需要并发控制?


🧠 先把概念摆正: Redis 单线程 != 系统无并发

Redis 单线程说的是: Redis 实例内部执行命令是串行的
但你的业务请求来自多个客户端, 它们会同时发起读写, 这就是系统并发。

可以用一句话概括:

  • 客户端侧有并发
  • Redis 侧做串行仲裁

所以问题不是"有没有并发", 而是"并发发生时, 你如何保证正确性和活性"。


🔍 为什么旧的"先读后写"会出问题

很多竞态都长这样:

  1. 请求 B 先读到 active = None
  2. 请求 A 先一步把 active 写成了某个 run
  3. B 继续按旧判断执行后续写入

这类流程的问题不是"读错了", 而是读到的前提在提交前过期了

时间轴可以直观看出来:

时间轴 →
A: -------- [写 active = run_1 成功] ----------------
B: [读 active=None] ---------------- [按 None 继续写]

旧实现里, B 可能会"错着成功", 这才是最危险的。


🧠 WATCH/EXEC 到底做了什么

WATCH/EXEC 是乐观并发控制 (CAS) 模型:

  1. WATCH key 盯住目标 key
  2. 读取当前值并做决策
  3. MULTI ... EXEC 提交
  4. 如果在 watch 窗口内 key 被改过, EXEC 失败, 客户端收到 WatchError

它解决的不是"永不冲突", 而是:

  • 不允许基于过期前提提交
  • 冲突变成显式失败, 然后重试

也就是说, 它把"错误结果"变成"可控冲突"。


⚙️ "校验也有时间"这个疑问, 到底怎么回答

这是最关键的一问:
"EXEC 校验通过后, 会不会立刻又被别人写入, 还是会错?"

答案是: 在 Redis 内部, EXEC 的校验与事务提交是一个原子执行点。
由于 Redis 串行执行命令, 不会出现"校验和提交之间被插队"的状态。

只有两种可能:

谁先到 Redis 执行点结果
别人的写入先执行你的 EXEC 失败 (WatchError)
你的 EXEC 先执行你的事务成功, 别人的写入排在后面

这就是并发理论里常说的线性化点:
WATCH/EXEC 来说, 线性化点就是 EXEC 被执行的那一刻。


🏛️ CAS 和分布式锁, 各管什么

实际工程里, 经常会把两者一起用, 但职责不同:

机制解决的问题常见语义
CAS (WATCH/EXEC)读改写期间前提过期冲突即失败, 然后重试
分布式锁 (SET NX PX)临界区一次只允许一个持有者持锁执行, 超时释放

如果你在 session 运行态管理里同时见到 active marker + writer lock, 很正常:

  • active marker 负责状态迁移正确
  • writer lock 负责写入者唯一

⚠️ 原子化不是银弹: 还要处理活性

很多人做完 CAS 就以为结束了, 但线上稳定性还差一步。

WatchError 在高竞争下可能连续发生, 所以要补齐:

  • 有上限的重试次数
  • 随机退避 (jitter backoff)
  • 超阈值返回可解释错误 (如 409/503)
  • 指标和日志: 冲突次数、重试耗时、最终失败率

一句话:

  • 原子化保证正确性
  • 重试退避保证活性

两个都要有。


📝 总结

最后用 4 句话收口:

  1. Redis 单线程不等于系统无并发
  2. WATCH/EXEC 不会消灭冲突, 但能阻止过期前提提交
  3. EXEC 是线性化点, 不存在校验后提交前被插队
  4. 生产可用方案一定是 CAS + 限次重试 + 退避 + 观测

如果你团队里还在争论"Redis 都单线程了为什么还会并发问题", 这篇可以直接丢过去 😄