一次由「数据库 CAS 序列生成器 + 长事务」引发的生产问题复盘

事务是放大器,用错位置,必出事故


一、问题现象

线上出现如下异常表现:

  • 多个请求卡在“生成编号”步骤
  • CPU 利用率升高,但整体吞吐下降
  • 线程栈显示大量请求阻塞在编号生成方法

直觉上看,问题似乎出在编号生成逻辑


二、系统背景

系统中存在一个用于生成业务编号的组件,特点是:

  • 使用 数据库单行 CAS(Compare-And-Set) 保证编号唯一性
  • 通过 update ... where serial_number = oldValue 判断抢占是否成功
  • CAS 失败后重试(自旋)

简化后的 SQL 形态如下:

1
2
3
4
update sys_code_rule
set serial_number = serial_number + step
where id = #{id}
and serial_number = #{oldSerialNumber};

非高并发场景下,该方案长期运行稳定。


三、真实问题并不在“CAS”

进一步分析调用链后发现,编号生成逻辑处于一个长事务中:

  • 事务内部包含 外部系统调用(RPC / HTTP)

  • 外部调用耗时不可控

  • 编号生成方法与外部调用处于同一事务上下文

抽象后的结构如下:

1
2
3
4
5
6
@Transactional 
{
调用外部系统(慢)
生成业务编号(CAS + 自旋)
落库
}

四、问题的真实根因

1. CAS 临界区被事务生命周期放大

原本预期的持锁时间是:

CAS 执行时间 ≈ 毫秒级

但在长事务中,实际变成了:

CAS 可见阻塞时间 ≈ 外部系统耗时 + 事务未提交时间

结果是:

  • 前一个事务未结束

  • 后续请求在 CAS 上不断失败并重试

  • 表象变成:大量请求“卡在生成编号”

2. 本质问题总结

不是 CAS 错了,而是事务边界错了。

更准确地说:

事务边界 ≠ 临界区边界


五、解决方案

使用 Propagation.NOT_SUPPORTED 切断事务耦合

对编号生成方法增加如下声明:

1
2
@Transactional(propagation = Propagation.NOT_SUPPORTED) 
public String generateCode(String code) { ... }

其语义是:

  • 如果当前存在事务 → 挂起事务

  • 无事务上下文 中执行编号生成逻辑

  • 执行完成后,恢复原事务


六、为什么这个改动立竿见影

加上 NOT_SUPPORTED 后:

  • 编号生成不再被长事务包裹

  • CAS 的持有与重试窗口重新回到毫秒级

  • 自旋不会被外部慢调用放大

对比前后效果:

对比项 修复前 修复后
CAS 失败重试时间 不可控 极短
请求堆积 严重 消失
CPU 消耗 偏高 恢复正常

七、对当前 CAS 序列生成器的评价

明确前提下:

  • 非高并发

  • 单点或少量实例

  • 允许编号空洞

  • 编号规则复杂,依赖数据库配置

该实现是:

  • ✔ 唯一性正确

  • ✔ 行为可解释

  • ✔ 工程上可接受

问题不在生成器本身,而在于它被放进了错误的事务边界中。


八、工程层面的经验总结

1. 一个重要原则

任何“看起来很短的逻辑”,一旦进入长事务,就不再是短逻辑。

2. 再具体一点

数据库 CAS 能保证唯一性,但不能保证你不会被自己的事务模型拖死。

3. 本次事故的一句话总结

本次生产问题由长事务中包含外部慢调用,叠加数据库 CAS 序列生成逻辑引发;
事务未提交导致 CAS 临界区被放大,多个请求在编号生成处堆积。
通过将编号生成方法声明为 Propagation.NOT_SUPPORTED,切断事务耦合,问题得到解决。


九、结语

这次问题的价值不在于“换了什么实现”,
而在于再次验证了一个被反复忽略的事实:

事务是放大器,用错位置,必出事故。