Redis 并发控制实战:为什么 WATCH/EXEC 能挡住竞态
从 cancel 竞态问题讲清 CAS、分布式锁和线性化点
本文带你彻底搞懂一个高频误区: Redis 是单线程, 但你的系统依然有并发, WATCH/EXEC 的价值是让冲突可检测而不是让冲突消失。
🎯 问题现象
我们在处理 session cancel 时经常会遇到一个现象:
| 场景 | 结果 |
|---|---|
| 低并发 | 看起来一切正常, cancel 大多成功 |
| 高并发 | 偶发冲突、重试, 甚至历史实现里会出现 500 |
| 团队直觉 | "Redis 单线程, 理论上不该有竞态吧?" |
这时候最容易陷入一个误解:
既然 Redis 是单线程, 那我们是不是根本不需要并发控制?
🧠 先把概念摆正: Redis 单线程 != 系统无并发
Redis 单线程说的是: Redis 实例内部执行命令是串行的。
但你的业务请求来自多个客户端, 它们会同时发起读写, 这就是系统并发。
可以用一句话概括:
- 客户端侧有并发
- Redis 侧做串行仲裁
所以问题不是"有没有并发", 而是"并发发生时, 你如何保证正确性和活性"。
🔍 为什么旧的"先读后写"会出问题
很多竞态都长这样:
- 请求 B 先读到
active = None - 请求 A 先一步把 active 写成了某个 run
- B 继续按旧判断执行后续写入
这类流程的问题不是"读错了", 而是读到的前提在提交前过期了。
时间轴可以直观看出来:
时间轴 →
A: -------- [写 active = run_1 成功] ----------------
B: [读 active=None] ---------------- [按 None 继续写]
旧实现里, B 可能会"错着成功", 这才是最危险的。
🧠 WATCH/EXEC 到底做了什么
WATCH/EXEC 是乐观并发控制 (CAS) 模型:
WATCH key盯住目标 key- 读取当前值并做决策
MULTI ... EXEC提交- 如果在 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 句话收口:
- Redis 单线程不等于系统无并发
WATCH/EXEC不会消灭冲突, 但能阻止过期前提提交EXEC是线性化点, 不存在校验后提交前被插队- 生产可用方案一定是 CAS + 限次重试 + 退避 + 观测
如果你团队里还在争论"Redis 都单线程了为什么还会并发问题", 这篇可以直接丢过去 😄