为什么订单用状态机 CAS 而不是悲观锁

交易链路最容易出事的地方不是「功能没写完」,而是同一件事被做了两次:同一个 request_id 提交了两遍、支付回调重发了三次、关单任务和用户支付撞在一起。这些都不是功能问题,是并发和幂等的问题。

悲观锁解决的是「同时写」,不是「重复写」

一开始很自然会想到 SELECT ... FOR UPDATE:把订单行锁住,改完再放开。它能保证同一时刻只有一个人在写,但下单这个场景有三个特点让它不太合适:

  1. 事务很短,冲突概率低。真去抢同一行的请求远少于想象,加锁的收益小、排队成本大。
  2. 锁的粒度选不对。订单行本身几乎不会冲突——每次下单都是新行。真正会冲突的是同一商品的库存行,锁订单行等于没锁。
  3. 长尾。一旦某个请求在事务里做了慢操作(调支付、发消息),锁就被拉长,后面全排队。

三层幂等各管一件事

最后拆成三层,各管各的:

  • request_id 幂等:客户端生成的请求号建唯一索引,重复提交直接命中唯一键,返回上次的结果。这一层管「用户点了两次按钮」。
  • 状态机 CAS:状态流转一律写成 UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'RESERVED',看影响行数。返回 0 就说明这条订单已经被别人推进过了,本次什么都不做。这一层管「同一个业务动作被并发执行」。
  • version 乐观锁:库存扣减、钱包余额这类有数值的字段,靠版本号重试。冲突了就重读重算,而不是阻塞。

三层里最关键的是状态机。它把「这件事能不能做」从代码里的 if 判断,变成了数据库上的一次原子条件更新——判断和执行在同一句 SQL 里,中间没有窗口。

代价是必须把状态画全

状态机的成本不在实现,在设计。所有状态、所有合法迁移、每条边由谁来触发,都得先画清楚。漏一个状态就是死单:订单卡在 RESERVED,库存占着,钱没扣,谁也推不动它。

所以我们还加了两个兜底:30 分钟未支付由延时消息自动关单并释放预占库存,以及定时扫描对账——把「超时还在 RESERVED」的订单捞出来。状态机负责正确性,兜底负责发现状态机的漏洞。

写完这段之后我的判断是:悲观锁适合「冲突频繁、事务短且确定」的场景;一旦业务本身有清晰的状态语义,用状态机 CAS 表达会比锁更贴近问题本身。