一次由「数据库 CAS 序列生成器 + 长事务」引发的生产问题复盘
事务是放大器,用错位置,必出事故
一、问题现象
线上出现如下异常表现:
- 多个请求卡在“生成编号”步骤
- CPU 利用率升高,但整体吞吐下降
- 线程栈显示大量请求阻塞在编号生成方法
直觉上看,问题似乎出在编号生成逻辑。
二、系统背景
系统中存在一个用于生成业务编号的组件,特点是:
- 使用 数据库单行 CAS(Compare-And-Set) 保证编号唯一性
- 通过
update ... where serial_number = oldValue判断抢占是否成功 - CAS 失败后重试(自旋)
简化后的 SQL 形态如下:
1 | update sys_code_rule |
在非高并发场景下,该方案长期运行稳定。
三、真实问题并不在“CAS”
进一步分析调用链后发现,编号生成逻辑处于一个长事务中:
-
事务内部包含 外部系统调用(RPC / HTTP)
-
外部调用耗时不可控
-
编号生成方法与外部调用处于同一事务上下文
抽象后的结构如下:
1 | @Transactional |
四、问题的真实根因
1. CAS 临界区被事务生命周期放大
原本预期的持锁时间是:
CAS 执行时间 ≈ 毫秒级
但在长事务中,实际变成了:
CAS 可见阻塞时间 ≈ 外部系统耗时 + 事务未提交时间
结果是:
-
前一个事务未结束
-
后续请求在 CAS 上不断失败并重试
-
表象变成:大量请求“卡在生成编号”
2. 本质问题总结
不是 CAS 错了,而是事务边界错了。
更准确地说:
事务边界 ≠ 临界区边界
五、解决方案
使用 Propagation.NOT_SUPPORTED 切断事务耦合
对编号生成方法增加如下声明:
1 |
|
其语义是:
-
如果当前存在事务 → 挂起事务
-
在 无事务上下文 中执行编号生成逻辑
-
执行完成后,恢复原事务
六、为什么这个改动立竿见影
加上 NOT_SUPPORTED 后:
-
编号生成不再被长事务包裹
-
CAS 的持有与重试窗口重新回到毫秒级
-
自旋不会被外部慢调用放大
对比前后效果:
| 对比项 | 修复前 | 修复后 |
|---|---|---|
| CAS 失败重试时间 | 不可控 | 极短 |
| 请求堆积 | 严重 | 消失 |
| CPU 消耗 | 偏高 | 恢复正常 |
七、对当前 CAS 序列生成器的评价
在明确前提下:
-
非高并发
-
单点或少量实例
-
允许编号空洞
-
编号规则复杂,依赖数据库配置
该实现是:
-
✔ 唯一性正确
-
✔ 行为可解释
-
✔ 工程上可接受
问题不在生成器本身,而在于它被放进了错误的事务边界中。
八、工程层面的经验总结
1. 一个重要原则
任何“看起来很短的逻辑”,一旦进入长事务,就不再是短逻辑。
2. 再具体一点
数据库 CAS 能保证唯一性,但不能保证你不会被自己的事务模型拖死。
3. 本次事故的一句话总结
本次生产问题由长事务中包含外部慢调用,叠加数据库 CAS 序列生成逻辑引发;
事务未提交导致 CAS 临界区被放大,多个请求在编号生成处堆积。
通过将编号生成方法声明为Propagation.NOT_SUPPORTED,切断事务耦合,问题得到解决。
九、结语
这次问题的价值不在于“换了什么实现”,
而在于再次验证了一个被反复忽略的事实:
事务是放大器,用错位置,必出事故。