订单状态机被双写:先查再改中间的竞态窗口让两个请求同时"赢了"
某后台管理系统上线后收到反馈:偶发一笔订单被接单两次,过程日志表里出现两条 status=接单 的记录,结算环节一核对,金额错了。代码里明明有状态前置校验,为什么还是漏了?
翻日志发现,两个请求到达时间相差不到 50ms,都通过了校验,都写进了日志。这不是逻辑 bug,而是一个经典的并发竞态问题。
现象
- 用户连点"接单"按钮,触发两个几乎同时到达的请求
- 两个请求都返回了成功,过程日志出现两条记录
- 提现审核:管理员快速双击"确认",余额被扣减两次
- 退款与取消并发:订单最终停在错误的中间状态
根因
问题代码是经典的"先查后改"两步走:
// 错误示范(TOCTOU 竞态)
$order = $db->name('p_order')->where('id', $id)->find();
if (!in_array($order['status'], ['1', '2', '3'], true)) {
$this->error('当前状态无法操作');
}
// ⚠ 竞态窗口在这里 —— 另一个请求已经改了 status,但你还没 update
$db->name('p_order')->where('id', $id)->update(['status' => '6']);两个请求的实际执行时序:
请求 A: find() → status=1,校验通过
请求 B: find() → status=1,校验通过(A 还没 update)
请求 A: update(status=6),成功
请求 B: update(status=6),也成功(覆盖写,不报错)
两个请求都继续写日志、扣余额……
find() 是一致性读(快照),不阻塞。两个事务同时读到同一个旧状态,各自通过校验,各自写入。MySQL InnoDB 的默认隔离级别 REPEATABLE READ 不会阻止这种情况——它保护的是同一事务内的重复读,不是跨事务的写冲突。
在 find() 和 update() 之间存在竞态窗口。第二个请求可以在第一个 update() 执行前顺利通过校验,因为它读到的还是旧快照。状态机的"前置条件"形同虚设,两个请求都认为自己合法。
正解
把前置条件折进 UPDATE 的 WHERE 子句,让状态校验和状态变更在一条 SQL 里原子完成,然后检查 affectedRows:
$db->startTrans();
try {
$affected = $db->name('p_order')
->where('id', $orderId)
->whereIn('status', ['1', '2', '3']) // 前置条件内嵌,不另做 find
->update([
'status' => '6',
'update_time' => date('Y-m-d H:i:s'),
'updater' => $operatorId,
]);
if ($affected === 0) {
// 没有行被修改 = 状态已被别人抢先改了
$db->rollback();
$this->error('操作失败,订单状态已变更');
}
// 到这里可以安全写侧效应(日志、退款单、余额变动等)
$db->name('order_process_log')->insert([...]);
$db->commit();
} catch (\Throwable $e) {
$db->rollback();
throw $e;
}核心逻辑等价于这条 SQL:
UPDATE p_order
SET status = 6, update_time = NOW()
WHERE id = ? AND status IN ('1', '2', '3');InnoDB 在执行这条 UPDATE 时会先加行锁再读、再写,第二个并发请求必须等第一个事务提交后才能拿到锁。它读到的 status 已经是 6,不满足 IN ('1','2','3'),affectedRows = 0,事务回滚,侧效应不执行。
状态机转换的原子性由数据库层保证,不依赖应用层的先查后校验。affectedRows = 0 就是"我输了这场竞争"的信号,立即回滚,不写任何侧效应。同一模式可以推广到:审核通过/拒绝、提现确认、优惠券核销——凡是"只能从某几个状态触发"的一次性动作都适用。
适用边界
- 适合:状态列表明确、转换是一次性的(接单、完成、取消、审核),并发读者可能同时发起请求
- 不适合:新值需要根据旧值计算(如余额 = 旧余额 - 扣减额)——这种场景需要悲观锁
SELECT ... FOR UPDATE或乐观锁版本号 - 组合使用:如果同一个事务还要检查配额/限额,在同一事务里对配额表加
FOR UPDATE悲观锁,再走上面的原子 UPDATE
一句话外卖
状态机转换永远不要"先 find 校验,再单独 update"——把
WHERE status IN (合法前驱态)折进 UPDATE,用affectedRows=0判输,一条 SQL 消灭竞态窗口。