MySQL 说「提交成功」,用户却说「支付失败」:那个卡在中间的订单

2026-11-08 12 0

MySQL 说「提交成功」,用户却说「支付失败」:那个卡在中间的订单

先讲一个真实场景,我管它叫「薛定谔的订单」。

凌晨 12:03,一个用户点了支付。客户端拿到的结果是 500 超时。用户刷新一看,订单页面显示「已支付」,钱包里钱扣了,货也发了。但支付网关那边回调说我方没返回成功,于是又退了一次款。

最终结果:用户白嫖了一单,公司还倒贴了一份货加一次退款。

最气人的是,事后翻日志,数据库里明明写着提交成功,MySQL 自己也没报错。代码一行没错,事务语法也规规矩矩。那这笔钱到底是怎么没的?

答案藏在一个很少有人主动去想的概念里:事务的结果,可能是「未知」的。

不是成功,也不是失败。是「我断电之前正在写,我不确定我写到哪了」。绝大多数业务代码只处理前两种,于是第三种就变成了事故。

一、先拆开看:一条 UPDATE 到底经历了什么

MySQL 内部写一条数据的流程,大致是:

客户端: UPDATE orders SET status='PAID' WHERE id=123;
  |
  v
[InnoDB] 改内存页 -> 写 redo log (prepare 状态)
  |
  v
[Server] 写 binlog (事务事件)
  |
  v
[InnoDB] redo log 标记为 commit -> 释放行锁
  |
  v
客户端收到 OK

关键在于:redo log 和 binlog 是两个独立的东西,一个归 InnoDB 管(崩溃恢复用),一个归 Server 层管(主从复制、备份用)。而 redo log 的写和 binlog 的写,不可能在同一个瞬间原子完成——中间存在一个物理上无法避免的窗口。

于是经典的两阶段提交登场:

  • 阶段一(prepare):InnoDB 写 redo log,状态标记为 prepare,但事务不提交、行锁不释放。
  • 阶段二(commit):Server 层写 binlog,然后通知 InnoDB 把 redo log 标记为 commit。

MySQL 崩溃在哪个点,恢复逻辑都不一样:

  • 崩溃在写 binlog 之前 → redo log 是 prepare,恢复时直接回滚。用户没扣钱,但客户端可能超时了。
  • 崩溃在写 binlog 之后、commit 标记之前 → redo log 仍是 prepare,但 binlog 已经完整。恢复时 MySQL 判断「binlog 完整说明这个事务本来要提交」,于是补上 commit。
  • 崩溃在 commit 之后 → 正常恢复。

所以两阶段提交保证的是一件事:redo log 和 binlog 不会一个说提交、一个说回滚,数据不会凭空消失或凭空多出来。

请注意它没有保证什么。它没保证客户端能知道结果,没保证锁能一直持到客户端收到响应,更没保证你这次重试不会变成第二笔订单。

两阶段提交解决的是「主从数据一致」和「崩溃不丢数据」,而不是「客户端结果确定」。把这两件事混为一谈,是资损事故的起点。

二、真正的「未知结果」不是崩溃,是超时

生产环境里,客户端根本不会看到「事务在 prepare 状态时 MySQL 崩了」这种戏剧性场面。真正杀死你的,是两个更平淡的东西:

一是网络超时。你的应用把这条 UPDATE 发给 MySQL,MySQL 老老实实执行完了,commit 也成功了,但返回结果的那几个字节在网络上丢了。你的连接池等了 3 秒,拿不到结果,于是抛超时。

二是数据库侧的延迟。大事务、锁等待、从库切主、磁盘 fsync 抖动——MySQL 那边可能真的执行了 5 秒才提交,而你的超时设置是 3 秒。

这时候你的应用只知道一件事:「我不知道」。

但绝大多数代码是这么写的:

err := tx.Commit()
if err != nil {
    // 失败了 -> 退款 / 回滚库存 / 告诉用户支付失败
    return refund(orderID)
}
return success(orderID)

于是「超时 = 失败 = 退款」。可数据库那边事务其实提交了,钱扣了、库存减了。你退完款,账实两清地对不上。

这就是「薛定谔订单」的机制:不是系统算错了,是你的代码把「未知」强行翻译成了「失败」,然后按失败去做补偿。

三、把超时调大一点行不行?

先泼一盆冷水:很多团队的应急方案就是「把超时从 3 秒调到 30 秒」。这是把 bug 藏起来,不是修 bug。而且它会引出第二个灾难——连接池被慢查询占满,紧接着所有正常请求一起超时,故障从「一个订单异常」升级成「整个服务不可用」。

顺带说说两个最容易被误解的参数:

innodb_flush_log_at_trx_commit = 1  -- 每次提交都 fsync,数据最安全,但 fsync 会带来延迟抖动
sync_binlog = 1                  -- 每次写 binlog 都刷盘,复制最安全

把这两个值调成 2,MySQL 每次提交少一次 fsync,吞吐能涨一大截——代价是操作系统崩溃时可能丢掉最近一秒的事务。注意,是操作系统崩溃,不是 MySQL 崩溃:MySQL 自己崩了,redo log 照样能恢复;机器断电,redo log 还没落盘的那部分就真没了。

这个参数不是「安全 vs 不安全」的开关,它决定的是「你能接受多大事务在断电后凭空消失」。绝大多数业务,1 是对的,因为一个订单消失的代价远大于几毫秒延迟。

四、真解法:承认「未知」是一等公民

核心思路只有一句:不要对未知结果做补偿,要对未知结果做确认。

1. 用幂等键兜住重复

重试本身是必须的(网络抖动就该重试)。前提是重试不会造成第二次扣款。做法是业务流水号 + 数据库唯一约束:

-- 幂等表
CREATE TABLE idempotency (
  biz_key VARCHAR(64) PRIMARY KEY,   -- 业务流水号
  status VARCHAR(16) NOT NULL,        -- PENDING / SUCCESS / FAILED
  result JSON,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
// 伪代码:写操作前先抢占幂等键
INSERT INTO idempotency (biz_key, status) VALUES (?, 'PENDING');
// 唯一索引冲突 -> 说明这次业务已经发起过
// 直接走「查状态」分支,不要重放副作用

注意:唯一索引才是并发的裁判,应用层的 if 判断不是。两个副本同时收到同一个回调同时插幂等表,只有数据库能告诉你谁赢了。「先 select 再 insert」这种写法,在并发下就是个必然会翻车的许愿池。

2. 超时之后,先查状态再决定

正确的顺序是:

err := tx.Commit()
if err != nil {
    if isTimeout(err) {            // 超时 = 未知,不是失败
        // 2a. 主动查最终状态
        st, qerr := queryOrderState(orderID)
        if qerr == nil && st == PAID {
            return success(orderID)   // 其实成功了
        }
        // 2b. 还是未知 -> 记入待确认表,交给对账流程
        enqueueForReconciliation(orderID)
        return acceptedPending(orderID) // 告诉用户"处理中",别撒谎说失败
    }
    // 明确的错误(死锁、约束冲突)才算真失败
    return fail(orderID)
}

这里最反直觉但最重要的一点:对用户说「处理中」,比说「失败」更安全,也更诚实。失败是个结论,你得有证据;超时只是你缺证据。用户看到「处理中」会等,客服不会被打爆;用户看到「失败」立刻重试一次,那才叫灾难。

3. 一定要有对账兜底

对账不是大公司的专利。任何涉及资金的系统,都该有一张「疑似未知」流水表,记录超时、连接中断、进程被 kill 这些场景下的 order_id,然后定时任务去查真实状态并修正。

-- 兜底:对账任务
SELECT * FROM idempotency
WHERE status = 'PENDING' AND created_at < NOW() - INTERVAL 5 MINUTE;
-- 超时未确认的,逐笔查业务终态,回写 SUCCESS / FAILED

没有对账的系统,所有资损都靠用户投诉发现。没有对账机制,你只是把发现问题的成本从「客服工单」换成了「微博热搜」。

五、几个容易踩的细节

别在事务里做外部调用。支付网关、短信、MQ 消费都别放在事务中间。事务持锁期间做网络调用,等于把别人的延迟也算进你的锁等待,是死锁的经典制造机。

「不确定」要往上游传,不要吞掉。很多框架把 SQL 异常统一包装成「系统异常」,然后上层默认当成失败。你需要在错误类型上做区分:可重试的、超时的、确定失败的,这三类的处理动作完全不同。MySQL 的死锁错误码 1213 是可以重试的;连接被 kill 是未知的;唯一键冲突是确定失败的。

XA 和分布式事务不是这个问题的解药。有人会说那就上两阶段提交的分布式事务。但 XA 强一致方案在 prepare 状态下会持锁等待,占用连接,吞吐掉得厉害;TCC 和 Saga 那些柔性方案最终还是要面对「重试会不会重复执行」的老问题。分布式事务解决的是跨资源原子性,解决不了「客户端怎么知道结果」。

连接池超时要大于数据库语句超时。如果你的连接池 3 秒就掐断连接,而 MySQL 那边一条语句要跑 5 秒,那你会把一个「本来能成功」的事务变成一个「未知的失败」。这属于自己给自己制造不确定性,锅甩得特别没意思。

六、最后

这套东西的核心,其实只有三句话:

  1. 超时不是失败,超时是「我不知道」。把第三种结果当一等公民处理,别硬塞进成功/失败两个格子。
  2. 重试是必须的,所以幂等是必须的。幂等键靠数据库唯一索引兜底,不靠应用层的 if。
  3. 任何不能自证的系统,都需要一条对账的兜底路径。出问题时,最后一个发现问题的人,永远不该是用户。

写代码的人总喜欢「成功/失败」这种干净利落的二元世界。可真实系统里,最贵的那类 bug,都长在三态之间的缝隙里。

你的系统里,现在有没有一个地方,把超时直接当成了失败?如果有,它大概率正在某个地方安静地亏钱。

相关文章

NULL 的深渊:数据库三值逻辑如何在工程现场反复埋雷
你的服务卡了 30 秒,可能只是因为一个域名:DNS 解析那些没人愿意背的锅
重试风暴来的时候,你的服务还活着吗?——幂等性设计的实战笔记
CPU 才 50% 却卡成狗:你的服务没算力不够,是被内核掐了脖子
你的 JSON 序列化,正在偷偷吃掉 30% 的 CPU:Go 后端的 pprof 现场
你的 SQL 没写错,为什么还是会死锁?MySQL 锁机制实战与排查手册

发布评论