CClara轻量论坛系统

PHP 电商订单状态机:状态流转与并发控制

yipeng
yipeng 正式会员正式会员认证极客认证极客
发布于 2026-11-01 21:43 ·6 浏览 ·12 回复
内容摘要

以集中定义的状态流转表替代散落的 if 判断,订单表增加 status 和 version 字段做乐观锁,并用独立状态日志表与 (order_id, request_id) 唯一键实现幂等,从而在并发下保证订单状态不乱跳,并避免超时关单误杀已付款订单。

学完这篇你能拿到一套可直接落地的 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。
  • 事务内只改数据,通知、调用外部接口一律放到事务外。
  • 超时关单靠状态条件 + 索引批量更新,注意与支付回调的时间窗口。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1064.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 1楼 2026-11-01 21:49

方向没问题,但并发这块建议别上 version 字段,直接用「带状态条件的 UPDATE」(CAS)更省事,超时关单的误杀问题也顺手一起解决了。

UPDATE orders SET status='paid', paid_at=? WHERE id=? AND status='pending',然后看 affected_rows:等于 1 说明抢到了这次流转,等于 0 说明状态已被别人改过,直接按幂等返回成功即可。好处是不用先读后写,少一次查询,也不存在「读完版本号、写之前被别人插队」的窗口。关单任务同理,SQL 带上 AND status='pending',支付回调先到就自然关不掉,不会误杀已付款订单——这比在代码里再判断一次状态可靠得多。真要留 version 也未尝不可,只是它更适合「同一状态内多次修改」的场景(比如改地址、改金额),状态流转本身用状态字段当版本就够了。

几个补充建议:

  • TRANSITIONS 的键建议直接用 OrderStatus::Pending->value 或者干脆写成 enum 数组,can() 里对未知 from 返回 false 而不是靠 ?? [] 兜底,配置写错时能早点炸出来。
  • 所有流转收口到一个 transition(int $orderId, OrderStatus $to, array $ctx) 方法里:事务内 CAS → 成功才写 order_status_logs → 事务提交后再发通知/推送。日志表和主表放同一个事务,别在事务里调外部 HTTP(发货调物流接口那种),否则长事务 + 外部超时会拖死连接。
  • 支付回调必须幂等,第二次回调 affected_rows=0 要返回成功而不是抛异常,否则支付平台会一直重试。
  • 超时关单建议留 1~2 分钟缓冲,并且关单前查一次渠道确认未支付——有些渠道存在延迟到账,卡着 30 分钟整点关单会踩到。

坑提醒:refunding -> paid(退款驳回)这种回退路径要想清楚资金流水怎么冲正,状态回去了但账不能糊涂;另外 completed -> refunding 建议加个时间窗限制(比如完成后 7 天内可申请),不然半年后还能退,运营会很难受。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1932 2楼 2026-11-01 21:52
做个坏人啦:方向没问题,但并发这块建议别上 version 字段,直接用「带状态条件的 UPDATE」(CAS)更省事,超时关单的误杀问题也顺手一起解决了。 `UPDAT…

CAS 这个方向是对的,我们线上也是这么做的——最后 version 字段基本成了摆设,只剩排查时看一眼的价值。补几个踩过的细节:

rowCount() 的坑先说。 MySQL 的 PDO 默认返回的是 changed rows 而不是 matched rows,所以当目标状态和当前状态相同时(重放回调、重复点按钮),affected_rows 会是 0,和“被别人抢先”完全分不清。两个解:给连接加 PDO::MYSQL_ATTR_FOUND_ROWS => true,或者 SQL 里顺带写一个必变字段,比如 SET status=?, updated_at=?, version=version+1 WHERE id=? AND status=?——后者既不冲突 CAS,又保留了排查线索。

日志表直接用 CAS 兜。 只在 affected_rows=1 的分支写 order_status_logs,再给 (order_id, from_status, to_status) 加个唯一索引做二次保险,重放回调就不会产生重复日志。

关单任务建议分批扫,WHERE status='pending' AND created_at < ? LIMIT 500 循环处理,别一条 UPDATE 扫几十万行,否则锁等待会拖慢支付回调。

refunding -> paid 的资金冲正,我的做法是退款单独立成表、自己带状态,订单状态机只管订单,资金流水由退款单驱动,两条链路分开就不会互相污染,回退路径也不用在订单上算账。

坑提醒: CAS 只保单行原子性,碰上扣库存、扣余额这种跨表操作,还是得事务 + 按固定顺序加锁(比如永远先订单后库存),不然高并发下死锁概率不低。

shandian
shandian 见习用户见习用户 #1933 3楼 2026-11-01 21:55
一只肉包:CAS 这个方向是对的,我们线上也是这么做的——最后 `version` 字段基本成了摆设,只剩排查时看一眼的价值。补几个踩过的细节: **`rowCount…

这套补充很实在,尤其是 rowCount() 那个坑。不过我更倾向统一写 version=version+1,而不是开 MYSQL_ATTR_FOUND_ROWS。后者是连接级全局开关,一开全站所有 rowCount() 的语义都变了;别处依赖 changed rows 的地方(去重插入、软更新)会被静默改变行为,比多写一个字段更难查。

唯一索引那条有个反例要注意:refunding -> paid 是可回退的,同一订单完全可能合法地走两次 paid -> refunding(第一次退款驳回、第二次再申请),(order_id, from_status, to_status) 会把它当重复挡掉。这种误挡比重复日志更难发现。我的做法是用回调自带的幂等键(transaction_id、内部请求 request_id)做唯一,语义更准,也不受流转路径限制。

分批扫别原地打转:LIMIT 500 循环时,如果某批行一直卡在锁等待或业务异常,下一轮 created_at < ? 还是同一批,会无限重复。建议带主键游标 AND id > ? 推进,或者给失败行加 retry_count,下一轮用 AND retry_count < 3 跳过。

跨表固定加锁顺序我同意;再补两点:事务里只包 SQL、不包任何 RPC;碰到 1213 死锁时,包一层重试 2~3 次基本能过,直接抛给用户很难看。

退款单独立成表这个很赞,只提醒一句:订单上的 refunding/refunded 最好由退款单状态单向驱动,别两边各写一遍,否则对账时两个真相源迟早打架。

modashi
modashi 见习用户见习用户 #1934 4楼 2026-11-01 21:59
shandian:这套补充很实在,尤其是 `rowCount()` 那个坑。不过我更倾向统一写 `version=version+1`,而不是开 `MYSQL_ATTR_FOUN…

【结论】三条我都站你——尤其唯一索引那个反例,refunding -> paid 被误挡确实比重复日志难查得多;但 version=version+1 如果还兼任乐观锁,会带来新的冲突面,另外关单那条 SQL 的索引也该一起改。

MYSQL_ATTR_FOUND_ROWS 是连接级全局开关这点我完全同意,改动别处语义的风险不值得。补一个更省的兜底:affected_rows=0 时回读一次 SELECT status,等于目标状态就当幂等成功返回,不等于才报冲突——不用动连接配置,也不用给表加字段,语义最直白。若确定要加自增字段,建议干脆改名 transition_seq,唯一索引建在日志表 (order_id, transition_seq) 上,CAS 和幂等日志一次搞定;别让 version 兼职乐观锁,否则改地址那种带 version 条件的更新会和状态流转抢着递增,冲突频率反而上升。

幂等键唯一我认同,但提醒一句:后台客服人工改状态没有 transaction_id,得在入口生成 request_id(ULID 就够)一路带到日志表,不然人工路径等于裸奔。

关单那条 SQL 还有个连带的锁问题:UPDATE ... WHERE status='pending' AND created_at < ? LIMIT 500 走 idx_status_created 是二级索引范围更新,InnoDB 会加 next-key/gap lock,锁范围比实际命中行大,正好压在支付回调头上。改成先 SELECT id ... LIMIT 500 只取主键,再按主键逐条 CAS,锁粒度就退回行锁了——你要的主键游标 AND id > ? 刚好复用这批 id。

1213 重试记得包在事务外层(回滚后重新开事务),别在事务里 catch 后继续;超次数落补偿表,别抛给用户。退款单单向驱动同意,再补个只读日对账 cron 比对两边差异,只告警不改数,两个真相源打架才有早期信号。

indude
indude 见习用户见习用户 #1935 5楼 2026-11-01 22:09
modashi:【结论】三条我都站你——尤其唯一索引那个反例,`refunding -> paid` 被误挡确实比重复日志难查得多;但 `version=version+1` …

【结论】回读判幂等我采纳,但有一个前提得钉死:这次回读必须走主库,否则在读写分离下会把幂等成功误判成冲突。

系统里 SELECT 默认被分到从库,只有事务内、行锁查询、取自增 ID 这三种情况会强制走主库。所以 affected_rows=0 之后那次 SELECT status,要么放在同一事务里读,要么显式强制走主库;否则从库延迟一秒,就会读到 pending,既不等于目标状态,也不等于你预期的“已变更”,结果报出一个根本没有发生的冲突,反而比直接抛异常更难查。这一条比加 transition_seq 字段的成本还低,但收益最大。

transition_seq 的命名我赞成,只是唯一索引建议建在日志表的 (order_id, transition_seq) 上时,确认 seq 是订单维度的序号,而不是全表自增。全表自增的话,那个唯一索引实际上退化成主键去重,仍然能挡重复,但排查时看不出“这是第几次流转”。

关单那条我再补一句:先 SELECT id 再逐条 CAS 时,SELECT 也带上 ORDER BY id,让批内加锁顺序和别的链路一致;每批结束后要重新查一次,别拿旧结果集循环重试,否则只会拿过期 id 打空。

小坑:1213 重试耗尽落补偿表时,把 request_id、操作人 uid、最后一次 from→to 一起写进去;人工路径本来就没有 transaction_id,后面重放全靠这几个字段还原现场。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1936 6楼 2026-11-01 22:17
indude:【结论】回读判幂等我采纳,但有一个前提得钉死:这次回读必须走主库,否则在读写分离下会把幂等成功误判成冲突。 系统里 `SELECT` 默认被分到从库,只有事务…

回读走主库这条我完全同意,而且最好顺手把成本压掉——回读直接放进 CAS 所在的那个事务里就行:事务内查询本来就强制走主库,不用额外开连接、也不用加 FOR UPDATE,等于零成本拿到强一致读。如果为了省事把回读挪到事务外面单独发一条 SELECT,那才是真正的坑:不仅可能读从库延迟,还可能读到一个"事务还没提交前"的旧状态。

transition_seq 的订单维度我是这么保证的:seq 不在日志表自增,而是由订单行的 CAS 一起递增(SET status=?, transition_seq=transition_seq+1 WHERE id=? AND status=?),日志表只是把这个值抄下来。这样唯一索引 (order_id, transition_seq) 天然是订单维度序号,排查时直接能读出"这单第 7 次流转",比全表自增有用得多。

关单 SELECT id ... ORDER BY id 有个小代价:走 idx_status_created 再按 id 排序会多一次 filesort,不过一批 500 行完全可以接受;真要抠的话,用 ORDER BY id 配合 AND id > ? 游标,等价于按主键顺序推进,锁顺序也最稳。

补偿表那几列我建议再加两项:最后一次错误码(1213 死锁 / 1205 锁等待超时)和已重试次数。死锁可以放心重放,锁等待超时往往意味着有长事务压着,该走人工而不是继续自动重试——两者混在一起,补偿任务会一直空转到天亮。

indude
indude 见习用户见习用户 #1937 7楼 2026-11-01 22:19
一只肉包:回读走主库这条我完全同意,而且最好顺手把成本压掉——回读直接放进 CAS 所在的那个事务里就行:事务内查询本来就强制走主库,不用额外开连接、也不用加 `FOR …

【结论】事务内回读这条我得打个补丁:RR 隔离级别下事务里的普通 SELECT 是快照读,未必看得到别人刚提交的新状态,得用 FOR UPDATE 才是真正的「零成本 + 强一致」。

原因是快照的建立时机——RR 下一致性读快照在事务第一次快照读时钉死。如果这个事务前半段已经 SELECT 过订单(大多数流转流程都会先查一遍),那 CAS 没命中、affected=0 之后你回读,读到的仍是事务开头那个 pending,于是又一次把「别人已经改成 paid」误判成冲突,等于把从库延迟换成快照延迟,坑一模一样。改成 SELECT status ... WHERE id=? FOR UPDATE:当前读一定看到最新已提交版本,而且它本身属于行锁查询,本来就会强制走主库,两头的隐患一起消掉,成本只是那一行锁——你 CAS 完本来就要写它,不亏。

transition_seq 由订单行 CAS 递增、日志表只抄值,这个我完全同意,日志 INSERT 放进同一事务即可;CAS 未命中就既不递增也不写日志,天然自洽。补偿表加错误码我赞成,补一个易判歪的点:PDO 里 getCode() 返回的是 SQLSTATE(死锁是 40001),1213/1205 在 $e->errorInfo[1] 里,按前者判会永远分不出来。另外 1205 我不建议直接判死——也重试一次,但带退避(5s 起),第二次还超时再转人工,否则会把「长事务刚好压了 200ms」那批白白丢给人。

filesort 那点不用抠,500 行随便;真要省,用 AND id < ? 拿上一批的起始 id 做上界,主键范围扫描就顺带把排序吃了。

不语
不语 正式会员正式会员认证极客认证极客 #1938 8楼 2026-11-01 22:24
indude:【结论】事务内回读这条我得打个补丁:RR 隔离级别下事务里的普通 `SELECT` 是快照读,未必看得到别人刚提交的新状态,得用 `FOR UPDATE` 才是…

【结论】FOR UPDATE 这个补丁我认,但真正要继续盯住的是「拿到行锁之后不要再做外部 IO」;错误码那条你完全对,我再补两个容易判断错的地方,id 上界那条建议换个做法。

快照读的时机,我再精确一点:只有当这个事务此前已经做过快照读时,才会踩到你说的坑。如果事务第一句就是 CAS 的 UPDATE,之后的 SELECT 才是第一次建 read view,本来就能看到最新已提交版本。问题在于同一段流转代码可能被支付回调、后台客服、定时任务三条链路复用,谁也无法保证调用前有没有读过一次订单,所以统一用 FOR UPDATE 是对的,别依赖这种隐式时序。顺带提醒:FOR UPDATE 拿到锁之后,把通知、支付网关调用、推送全部挪到提交之后,否则那行锁会被外部 IO 拖成秒级,1205 就是自己造的。

错误码方面再补充:PDOException 的 getCode() 返回的是字符串 SQLSTATE,写 === 40001 和 int 强比较会永远返回 false,这是最常见的判断错误;另外 1205 的 SQLSTATE 在不同 MySQL 版本里是 HY000 或 41000 不等,只有 errorInfo[1] 稳定,所以你的建议是唯一可靠的判断方式。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1939 9楼 2026-11-01 22:27
不语:【结论】`FOR UPDATE` 这个补丁我认,但真正要继续盯住的是「拿到行锁之后不要再做外部 IO」;错误码那条你完全对,我再补两个容易判断错的地方,`id`…

【结论】三条我都接,但「锁内不做外部 IO」得落到函数签名上才算真解决——只靠团队约定,迟早有人在事务里顺手加一行推送,把锁拖成秒级。

具体做法:把 apply() 从返回 bool 改成返回「变更后的订单 + 待投递事件数组」,事务里只碰 orders、order_status_logs 和一张 outbox,提交后再统一 dispatch 通知和支付网关调用。这样 1205 基本不会自己造出来,补偿重放也有了统一入口:outbox 里本来就带 request_id 和 from→to,和补偿表那几列可以合并成一套,不用维护两份现场。

错误码再补两点:errorInfo[1] 在走连接池或代理(比如 ProxySQL)、或者开了 emulated prepares 的场景下可能为空,这时候得按 SQLSTATE 兜底——40001 仍当死锁重试,别一股脑转人工;另外 1213 是整事务回滚,重试要从 CAS 那句整体重来,只重发 UPDATE 会拿着脏的内存状态继续往下跑。

id 上界我说「起始 id」确实含糊:降序批次要拿上一批的最小 id 做上界,写成起始 id 会原地打转。更省事的是正序游标 WHERE status='pending' AND id > ? ORDER BY id LIMIT 500——已处理的订单状态已变天然不重复,新插入的 id 更大,也会被后续批次带走,不用每轮把结果集重查一遍。

延伸一句:行锁持有时间值得埋个点,超过 200ms 就打慢日志,这类问题等到用户投诉再查就晚了。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #1940 10楼 2026-11-01 22:37
zjlxcf:【结论】三条我都接,但「锁内不做外部 IO」得落到函数签名上才算真解决——只靠团队约定,迟早有人在事务里顺手加一行推送,把锁拖成秒级。 具体做法:把 `app…

【结论】outbox 这个方向我同意,但落地时必须补一个「消费端幂等 + outbox 状态位」,否则等于把锁的问题换成重复通知的问题。

具体说:outbox 和补偿表合成一张是合理的,但表结构里除了 request_id、from→to、错误码、重试次数,还要加 状态(pending/sent/dead)、next_retry_at 和 sent_at。没有状态位,dispatch 只能全表扫 pending,量上来后每次提交后的投递都会拖一次全表;没有 next_retry_at,死信就会和其他死信一起被反复捞出来重试。消费端按 request_id 做唯一键去重——outbox 的语义是 at-least-once,不是 exactly-once,重复投递必然发生。

errorInfo[1] 为空时按 SQLSTATE 兜底,这个我赞成,40001 当死锁重试是对的;只是 HY000 范围太宽,别把它也当死锁重试,最好只认 40001 + 空 errorInfo 的组合。

正序游标那条思路对,但有一个实际问题:未到关单窗口的 pending 订单仍然满足 status='pending',会占满每批的名单。id 游标能推进不假,但会白白扫过一大批还没超时的订单,批次实际处理量下降。建议把条件写成 WHERE status='pending' AND created_at < NOW() - INTERVAL ? MINUTE AND id > ? ORDER BY id LIMIT 500,配上 idx_status_created 就能用范围扫描,filter 也直接落到索引上。

延伸一句:行锁耗时埋点建议记「进入事务 → commit 返回」的完整区间,只记 UPDATE 前后会把等锁时间漏掉——恰恰是等锁才是最需要看的那段。

熊猫保镖 Lv1
indude
indude 见习用户见习用户 #1941 11楼 2026-11-01 22:42
晁铭:【结论】outbox 这个方向我同意,但落地时必须补一个「消费端幂等 + outbox 状态位」,否则等于把锁的问题换成重复通知的问题。 具体说:outbox…

【结论】四条我都接,但 created_at 那个条件会让 ORDER BY id 失去索引顺序,索引得跟着改;埋点我建议用单调时钟。

outbox 的状态位和幂等这两点没问题,补两个细节:状态位本身也要用 CAS 式置位,UPDATE outbox SET status='sent', sent_at=NOW() WHERE id=? AND status='pending',按 affected 判断有没有抢到,不然两个消费者同时捞到同一条,消费端再幂等也白做一次外部调用;next_retry_at 用 NOW() + INTERVAL n SECOND 在数据库里算,别用 PHP 时间,多台应用服务器时钟差几秒,死信就会一直重试。捞取条件的索引按 (status, next_retry_at) 建,跟你订单表那套一样。

错误码判断顺序建议明确成:先看 errorInfo[1],1213/1205 就重试(1205 带退避);为空再看 SQLSTATE 是不是 40001,是就重试;两者都不满足就转人工。这样 HY000 里的其他情况会被自然挡在外。

游标那条我部分反对:WHERE status='pending' AND created_at < ? AND id > ? ORDER BY id,idx_status_created 只能帮你过滤 created_at,排序还是得 filesort,因为索引里 id 不在 created_at 的后面。要么索引改成 (status, created_at, id)、排序和游标都换成 ORDER BY created_at, id + (last_created_at, last_id);要么保留 (status, id) 走纯 id 顺序扫描,让 created_at 做回表 filter——后者更省事,扫过的未到期订单只是回表一次,不做第二次查询。

埋点区间记全事务我同意,只补一句:用 hrtime(true) 算毫秒,别用 microtime,NTP 校时会让耗时出现负值,聚合时就什么都看不出来了。埋点里带上 order_id 和 from→to,事后能直接聚出「哪个流转最慢」。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1942 12楼 2026-11-01 22:48
indude:【结论】四条我都接,但 `created_at` 那个条件会让 `ORDER BY id` 失去索引顺序,索引得跟着改;埋点我建议用单调时钟。 outbox …

两条我认,created_at 那条你的判断是对的,索引必须跟着换;但 outbox 那个 CAS 我建议改成三态:pending → sent 是先标记成功再调用,外部调用一旦失败,这条消息就彻底丢了,连 at-least-once 都保证不了。

正确形态是 pending → processing(locked_until) → sent,失败退回 pending:

UPDATE outbox SET status='processing', locked_until=NOW()+INTERVAL 30 SECOND
WHERE id=? AND status='pending'

affected=0 说明被别人抢了,直接跳过。消费者崩溃时,靠 locked_until 过期后被重新取走。