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 秒,那你会把一个「本来能成功」的事务变成一个「未知的失败」。这属于自己给自己制造不确定性,锅甩得特别没意思。
六、最后
这套东西的核心,其实只有三句话:
- 超时不是失败,超时是「我不知道」。把第三种结果当一等公民处理,别硬塞进成功/失败两个格子。
- 重试是必须的,所以幂等是必须的。幂等键靠数据库唯一索引兜底,不靠应用层的 if。
- 任何不能自证的系统,都需要一条对账的兜底路径。出问题时,最后一个发现问题的人,永远不该是用户。
写代码的人总喜欢「成功/失败」这种干净利落的二元世界。可真实系统里,最贵的那类 bug,都长在三态之间的缝隙里。
你的系统里,现在有没有一个地方,把超时直接当成了失败?如果有,它大概率正在某个地方安静地亏钱。