支付回调重复扣款事故复盘:三层幂等防线、唯一约束与对账兜底实战

数据库没挂,日志也没写,但用户付了两次钱

这是一个真实发生在个人站接单项目上的事故:一台跑着 PHP + MySQL 的小服务器,主库磁盘瞬间写满导致 MySQL 短暂不可用。运维恢复得很快,磁盘清理完服务就正常了。但当天有几笔订单在后台显示为「已支付」,在支付平台的账单里也是「已支付」,只有对账时才发现——同一笔订单被写入了两条支付记录,用户被扣了两次款。

复盘之后发现,问题的根源不是磁盘写满这个故障本身,而是故障期间应用层的一次「重试」。这篇文章讲的是我在个人站项目里落地的三层幂等防线:从数据库唯一约束到应用层请求去重,再到状态机式的支付流水更新。它对个人站长尤其重要,因为个人站往往没有专业的对账系统,一次资损可能直接吃掉几个月收入。

先看事故是怎么发生的

典型的回调处理代码长这样,几乎所有教程都这么写:

// 支付平台回调处理(错误示范)
$order = $db->query("SELECT * FROM orders WHERE order_no = ?", [$order_no]);
if ($order['status'] == 'paid') {
    // 已经处理过,直接返回成功
    echo 'success';
    exit;
}
// 写入支付流水
$db->query("INSERT INTO payments (order_no, amount, trade_no)
            VALUES (?, ?, ?)", [$order_no, $amount, $trade_no]);
// 更新订单状态
$db->query("UPDATE orders SET status='paid' WHERE order_no = ?", [$order_no]);
echo 'success';

这段代码的逻辑没有错,「先查后写」看起来也考虑到了重复回调。但它有一个致命的时间窗口:查询和更新之间不是原子的。当磁盘写满、MySQL 拒绝服务时,应用层的 HTTP 客户端(或支付平台的重试机制)会重新发起回调请求。两个回调请求几乎同时到达,都执行了那句 SELECT,都看到状态还是 unpaid,于是两个请求都往下走,各自 INSERT 了一条流水。

如果用日志去查,你会看到两条时间戳相差不到 10 毫秒的回调记录,而数据库层面的错误信息可能早就滚掉了。所以排查这类问题的关键不是看错误日志,而是直接查数据库里有没有重复记录

SELECT order_no, COUNT(*) AS cnt
FROM payments
GROUP BY order_no
HAVING cnt > 1
ORDER BY cnt DESC;

这一条 SQL 应该在每个涉及资金的表上定期跑。它是发现幂等失效最快的手段,比任何日志告警都直接。

第一层防线:唯一约束是唯一可靠的保证

应用层的判断可以被并发绕过,唯一约束不能。数据库的唯一索引是并行安全的,无论如何并发,违反唯一约束的插入必然失败——这才是幂等的最终兜底。

-- 支付流水表:同一个支付平台的交易号只能出现一次
ALTER TABLE payments
  ADD UNIQUE KEY uk_trade_no (trade_no);

-- 如果需要允许同一订单分多次支付,用组合唯一
ALTER TABLE payments
  ADD UNIQUE KEY uk_order_trade (order_no, trade_no);

加上约束之后,代码必须相应地改:把 INSERT 失败当作正常分支处理,而不是当作异常抛出去。

try {
    $db->query("INSERT INTO payments (order_no, amount, trade_no)
                VALUES (?, ?, ?)", [$order_no, $amount, $trade_no]);
} catch (PDOException $e) {
    // 1062 = Duplicate entry,说明这个回调之前已经处理过了
    if ($e->errorInfo[1] == 1062) {
        echo 'success';   // 必须回 success,否则平台会一直重试
        exit;
    }
    throw $e;
}

这里有个非常容易踩的坑:捕获到重复插入之后,必须向支付平台返回「成功」。很多人的直觉是「重复了说明有问题,返个错误吧」,结果支付平台认为你没有收到通知,于是按退避策略继续重试,重试风暴会让本来只重复一次的请求变成重复十几次。幂等的目标就是「重复请求和第一次请求的返回值完全一样」。

另外注意 MySQL 的唯一约束只对「非 NULL」值生效(InnoDB 允许唯一索引上出现多个 NULL)。如果 trade_no 可能为空,那么所有为空的记录都能重复插入,约束形同虚设。要么把它设为 NOT NULL,要么改用组合键覆盖。

第二层防线:用状态机的条件更新代替「先查后写」

唯一约束保护的是流水表,但订单状态本身也需要防重复。这里的正确做法是把「先查后写」改造成条件更新,让数据库用行的锁来保证只有一个请求能成功:

UPDATE orders
   SET status = 'paid', paid_at = NOW()
 WHERE order_no = ?
   AND status = 'unpaid';        -- 关键条件

-- 检查受影响行数
if ($stmt->rowCount() === 0) {
    // 状态不是 unpaid,说明已经被处理过(或订单状态异常)
    $cur = $db->query("SELECT status FROM orders WHERE order_no=?", [$order_no])->fetch();
    if (in_array($cur['status'], ['paid', 'refunded', 'shipped'])) {
        echo 'success';   // 幂等:等同于已成功
        exit;
    }
    // 其他状态(如 cancelled)才是真正的异常,需要告警
}

UPDATE ... WHERE status='unpaid' 这个写法本身就是原子的:MySQL 会对匹配的行加排他锁,第二个并发请求会等待第一个提交,然后发现 status 已经变成 paidrowCount() 返回 0。这就是「条件更新」相对「先查后写」的本质优势——判断和执行在同一个原子操作里完成。

要注意 rowCount() 的语义陷阱:在 MySQL 里,如果 UPDATE 匹配到了行但值没有变化,默认返回 0 而不是 1。上面这个语句因为 statusunpaid 变到 paid,值必然改变,所以返回 1 是可靠的;但如果你写的是幂等更新(例如把某个字段设成同一个值),就必须在建立 PDO 连接时加上 PDO::MYSQL_ATTR_FOUND_ROWS => true,否则会误判成「没更新成功」。

第三层防线:请求级别的去重表

前两层保护了数据一致性,但还有一个场景:支付平台的同一个回调在几秒内被重放了三次,每次都成功插入(如果允许同一订单多次流水)。这时候需要在「请求进入」这一层就做去重。做法是建一张轻量的请求记录表,用回调的唯一标识(平台侧的 trade_no 或者回调 ID)做唯一键:

CREATE TABLE idempotent_keys (
    idem_key   VARCHAR(128) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (idem_key),
    KEY idx_created (created_at)
) ENGINE=InnoDB;
try {
    $db->query("INSERT INTO idempotent_keys (idem_key) VALUES (?)", [$idem_key]);
} catch (PDOException $e) {
    if ($e->errorInfo[1] == 1062) {
        // 这个请求正在被处理或已处理过,直接返回成功
        echo 'success';
        exit;
    }
    throw $e;
}
// 真正处理业务逻辑...

这张表必须定期清理,否则会无限增长变成新的隐患——用 DELETE FROM idempotent_keys WHERE created_at < NOW() - INTERVAL 7 DAY 每天清一次就够,保留 7 天远超过任何支付平台的重试窗口(通常是 24 小时内若干次)。清理时要限制单次删除行数(加 LIMIT 1000 循环执行),避免一次删除几十万行造成长事务锁表。

故障恢复后的对账,比幂等代码更重要

上面三层防线解决的是「同一笔请求重复执行」。但事故当天真正让我损失的是:磁盘写满期间有些回调根本没被处理,用户付了钱但订单还是 unpaid。这类漏单只能靠对账发现,靠的是每天定时任务把支付平台的流水和本地订单表做一次全量比对:

-- 1) 平台有、本地无:漏单,需要人工补单
-- 2) 本地有、平台无:脏数据,需要核对
-- 实践做法是把平台前一天的账单导出成临时表 platform_trades,
-- 然后做 FULL OUTER JOIN 的等价写法:
SELECT t.trade_no, t.order_no, t.amount
FROM platform_trades t
LEFT JOIN payments p ON p.trade_no = t.trade_no
WHERE p.id IS NULL;

反向的查询把 LEFT JOIN 两边对调即可。这两条 SQL 每天跑一次,输出非空就发告警,是个人站能做到的最低成本的资损防护。

个人站最容易漏掉的一步:把修复过程也做幂等

事故处理的当时,我手动执行了一条补单 SQL,把订单状态改成 paid。第二天发现这个订单被重复补了两次——因为我用的是一条没有条件限制的 UPDATE,而我把这条 SQL 存进了笔记,第二天又跑了一遍。

所以最后一条经验是:所有手动修复用的 SQL 都要写成带条件的幂等形式,例如 UPDATE orders SET status='paid' WHERE order_no='...' AND status='unpaid',并在脚本里检查 rowCount() 打印结果,让执行者看到「改了 0 行」这个信号。人的操作是最不可靠的一环,而写一条幂等的 SQL 只多花你十秒钟。

总结一下这套体系:数据库唯一约束兜底数据层,条件更新处理状态流转,去重表拦截重复请求,每日对账发现遗漏,手动修复也写幂等。这五件事加起来不到两百行代码,但它把「故障期间不会产生资损」变成了一个架构性质上的保证,而不是依赖运气。

Last modification:September 23rd, 2026 at 01:00 pm

Leave a Comment