学完这篇你能拿到一套可直接落地的 PHP 订单状态机写法:状态怎么定义、流转怎么校验、并发下怎么保证不乱跳、超时关单怎么不误杀已付款订单。
第一步:先把状态和允许的流转写成一张表
别在业务代码里到处 if ($order['status'] == 'paid')。状态机第一件事是把"能怎么走"集中成数据。
enum OrderStatus: string {
case Pending = 'pending'; // 待支付
case Paid = 'paid'; // 已支付
case Shipped = 'shipped'; // 已发货
case Completed = 'completed'; // 已完成
case Cancelled = 'cancelled'; // 已取消
case Refunding = 'refunding'; // 退款中
case Refunded = 'refunded'; // 已退款
}
final class OrderStateMachine
{
public const TRANSITIONS = [
'pending' => ['paid', 'cancelled'],
'paid' => ['shipped', 'refunding'],
'shipped' => ['completed', 'refunding'],
'refunding' => ['refunded', 'paid'], // 退款驳回可回到已支付
'completed' => ['refunding'],
'cancelled' => [],
'refunded' => [],
];
public static function can(string $from, string $to): bool
{
return in_array($to, self::TRANSITIONS[$from] ?? [], true);
}
}
注意:状态用字符串存,别用数字。数字存库后维护时没人记得住 3 是发货还是退款,日志排查会非常痛苦。
第二步:表结构要给并发留位置
订单表加 status 和 version 两个字段,流转日志单独一张表。
ALTER TABLE orders
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending',
ADD COLUMN version INT UNSIGNED NOT NULL DEFAULT 0,
ADD KEY idx_status_created (status, created_at);
CREATE TABLE order_status_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(20) NOT NULL,
to_status VARCHAR(20) NOT NULL,
operator VARCHAR(50) NOT NULL,
request_id VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_idem (order_id, request_id)
) ENGINE=InnoDB;
uk_idem 是幂等的最后一道保险:同一个支付回调重复到达,第二次插入日志直接撞唯一键,整个事务回滚,状态不会二次流转。
第三步:用条件更新做乐观锁,而不是先查后改
错误写法是 SELECT 出旧状态、PHP 判断、再 UPDATE——两个请求同时读到 pending,都会去更新,一个订单可能被既取消又支付。正确做法是把旧状态写进 WHERE:
public function transition(int $orderId, string $to, string $operator, string $requestId): bool
{
$pdo->beginTransaction();
try {
// 行锁:同一订单的流转串行化
$st = $pdo->prepare('SELECT status, version FROM orders WHERE id = ? FOR UPDATE');
$st->execute([$orderId]);
$row = $st->fetch();
if (!$row) { throw new RuntimeException('订单不存在'); }
$from = $row['status'];
if (!OrderStateMachine::can($from, $to)) {
throw new RuntimeException("非法流转: {$from} -> {$to}");
}
// 乐观锁:版本号不匹配说明有人先改了
$up = $pdo->prepare(
'UPDATE orders SET status = ?, version = version + 1, updated_at = NOW()
WHERE id = ? AND status = ? AND version = ?'
);
$up->execute([$to, $orderId, $from, $row['version']]);
if ($up->rowCount() !== 1) { throw new RuntimeException('状态已被并发修改'); }
$log = $pdo->prepare(
'INSERT INTO order_status_logs
(order_id, from_status, to_status, operator, request_id, created_at)
VALUES (?, ?, ?, ?, ?, NOW())'
);
$log->execute([$orderId, $from, $to, $operator, $requestId]);
$pdo->commit();
return true;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
FOR UPDATE 加行锁,version 做二次校验,两道保险叠加;高并发下基本不会出现状态错乱。
注意:事务里绝对不要调用支付网关、发短信、发邮件。远程请求可能超时几秒,行锁会一直被持有,订单表很快就被堵死。正确姿势是事务只改状态,事务提交后再发消息,用消息队列或异步任务处理通知。
第四步:支付回调必须幂等
第三方支付普遍会重复推送同一条通知。回调入口第一件事是查本地支付流水号是否已处理,而不是直接流转状态:
$st = $pdo->prepare('SELECT id FROM payments WHERE trade_no = ?');
$st->execute([$tradeNo]);
if ($st->fetch()) { echo 'success'; exit; } // 已处理,直接确认
配合第三步的 request_id(用支付平台流水号填充),即使并发两个回调同时进来,也只有一个能成功插入日志。
注意:回调里校验金额和订单号,别只信 out_trade_no。金额不一致时不要流转到 paid,记异常日志人工介入。
第五步:超时关单用批量条件更新,别逐条查
UPDATE orders
SET status = 'cancelled', version = version + 1, updated_at = NOW()
WHERE status = 'pending'
AND created_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)
LIMIT 500;
WHERE status = 'pending' 保证已支付的订单不会被误关,这就是第二步那个联合索引的用处。
注意:如果支付回调有延迟,可能存在"用户刚付款、关单任务同时执行"的窗口。稳妥做法是关单前先向支付渠道查一次该订单的支付结果,或引入 paying 中间状态,让关单任务跳过处于 paying 的订单。
小结
- 状态和允许的流转集中在一张表里,业务代码只问"能不能走",不写散落判断。
- 状态存字符串,日志单独建表,
version 字段做乐观锁。
- 流转用
SELECT ... FOR UPDATE + WHERE status = 旧状态 的条件更新,杜绝先查后改。
- 幂等靠唯一键兜底,支付回调用平台流水号做
request_id。
- 事务内只改数据,通知、调用外部接口一律放到事务外。
- 超时关单靠状态条件 + 索引批量更新,注意与支付回调的时间窗口。