为什么订单用状态机 CAS 而不是悲观锁
交易链路最容易出事的地方不是「功能没写完」,而是同一件事被做了两次:同一个 request_id 提交了两遍、支付回调重发了三次、关单任务和用户支付撞在一起。这些都不是功能问题,是并发和幂等的问题。
悲观锁解决的是「同时写」,不是「重复写」
一开始很自然会想到 SELECT ... FOR UPDATE:把订单行锁住,改完再放开。它能保证同一时刻只有一个人在写,但下单这个场景有三个特点让它不太合适:
- 事务很短,冲突概率低。真去抢同一行的请求远少于想象,加锁的收益小、排队成本大。
- 锁的粒度选不对。订单行本身几乎不会冲突——每次下单都是新行。真正会冲突的是同一商品的库存行,锁订单行等于没锁。
- 长尾。一旦某个请求在事务里做了慢操作(调支付、发消息),锁就被拉长,后面全排队。
三层幂等各管一件事
最后拆成三层,各管各的:
-
request_id幂等:客户端生成的请求号建唯一索引,重复提交直接命中唯一键,返回上次的结果。这一层管「用户点了两次按钮」。 - 状态机 CAS:状态流转一律写成
UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'RESERVED',看影响行数。返回 0 就说明这条订单已经被别人推进过了,本次什么都不做。这一层管「同一个业务动作被并发执行」。 -
version乐观锁:库存扣减、钱包余额这类有数值的字段,靠版本号重试。冲突了就重读重算,而不是阻塞。
三层里最关键的是状态机。它把「这件事能不能做」从代码里的 if 判断,变成了数据库上的一次原子条件更新——判断和执行在同一句 SQL 里,中间没有窗口。
代价是必须把状态画全
状态机的成本不在实现,在设计。所有状态、所有合法迁移、每条边由谁来触发,都得先画清楚。漏一个状态就是死单:订单卡在 RESERVED,库存占着,钱没扣,谁也推不动它。
所以我们还加了两个兜底:30 分钟未支付由延时消息自动关单并释放预占库存,以及定时扫描对账——把「超时还在 RESERVED」的订单捞出来。状态机负责正确性,兜底负责发现状态机的漏洞。
写完这段之后我的判断是:悲观锁适合「冲突频繁、事务短且确定」的场景;一旦业务本身有清晰的状态语义,用状态机 CAS 表达会比锁更贴近问题本身。