返回博客

订单状态机被双写:先查再改中间的竞态窗口让两个请求同时"赢了"

某后台管理系统上线后收到反馈:偶发一笔订单被接单两次,过程日志表里出现两条 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() 之后的校验在并发下毫无意义

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,事务回滚,侧效应不执行。

正解:WHERE 内嵌前置条件 + affectedRows 检查

状态机转换的原子性由数据库层保证,不依赖应用层的先查后校验。affectedRows = 0 就是"我输了这场竞争"的信号,立即回滚,不写任何侧效应。同一模式可以推广到:审核通过/拒绝、提现确认、优惠券核销——凡是"只能从某几个状态触发"的一次性动作都适用。

适用边界

  • 适合:状态列表明确、转换是一次性的(接单、完成、取消、审核),并发读者可能同时发起请求
  • 不适合:新值需要根据旧值计算(如余额 = 旧余额 - 扣减额)——这种场景需要悲观锁 SELECT ... FOR UPDATE 或乐观锁版本号
  • 组合使用:如果同一个事务还要检查配额/限额,在同一事务里对配额表加 FOR UPDATE 悲观锁,再走上面的原子 UPDATE

一句话外卖

状态机转换永远不要"先 find 校验,再单独 update"——把 WHERE status IN (合法前驱态) 折进 UPDATE,用 affectedRows=0 判输,一条 SQL 消灭竞态窗口。