title: 用户付款了,系统却不知道:掉单恢复器的设计 date: 2026-10-14 tags: [支付, 分布式, 可靠性]
用户付款了,系统却不知道:掉单恢复器的设计
收银员说顾客扣款成功了,系统里却没有这单。
这是被扫支付(收银台扫顾客付款码)最典型的掉单:顾客确认支付后,渠道的异步回调因为丢包、服务重启或网络抖动没到达,本地订单停在 USER_PAYING。顾客的钱已经出了,账没记——每多等一分钟,对账时就多一条要人工核的长款。
背景约束:为什么撤销必须后端自持
付款码场景有个特殊风险窗:顾客出示密码输入的几十秒里,订单状态是”用户支付中”。如果把”超时后撤销”这件事交给收银 App 驱动,App 被杀、退到后台、断网,撤销动作就永远没人执行——撤销是资金安全动作,必须由后端自持。所以 USER_PAYING 必须是一个持久化状态,而不是内存里的一瞬态;只要这行记录在库里,就有机会被扫到、被处置。
实现:一个照抄 Outbox 范式的恢复器
PosMicropayRecoverPoller 的骨架,是我把 Outbox 轮询器的 CAS 抢占范式直接搬过来的:
- 每 5 秒扫一批 USER_PAYING 在途单(每批 50 条),节奏与渠道方每 5 秒一次的查询建议对齐;用 fixedDelay 防止上一轮慢导致任务堆叠;
- 多实例部署下靠 CAS 条件更新抢锁(
recover_locked_by写实例标识 + 60 秒锁超时),同一单同一时刻只有一个实例处理;实例崩溃锁自然过期,不重复、不遗漏。
单条处置逻辑按状态分四路:
- 先查渠道(滞留 ≥5 秒才进入查询轮,避免刚受理就查):查到 SUCCESS 立即补记账——回调晚到也不怕重复,幂等键在存储层拦截;
- 查到已关单/明确失败:落 REVERSED 终态;
- 滞留 < 30 秒且仍无结果:本轮不动,留给下一轮;
- 滞留 ≥ 30 秒:强制执行渠道撤销。
30 秒这个阈值是有讲究的:付款码是一次性凭证(约一分钟有效),用户停留超过 30 秒还停在支付中,大概率已经放弃。此时主动撤销是”清位”——防止这单在系统里终结之后、渠道那边又支付成功,那笔钱就成了无人认领的长款。撤销后还要再查一次确认终态,因为存在”撤销发起的瞬间恰好支付成功”的竞态:此时渠道对已支付交易的撤销会转成原路退款——钱将退回用户,本地必须保持”已撤销”终态、由日终对账确认退回到账,绝不补记账(记了就是钱已退仍算收入的资损)。撤销前的查询轮里查到 SUCCESS 才补记账,撤销后的已支付只做退款处理——两条路径的分界线就是撤销是否已发起。
两条铁律
后端不信客户端。 结算记账前必须回查渠道、确认 query 结果是 SUCCESS 才落账,金额取渠道实付而非应收——前端上报的”支付成功”只是参考。
不假成功。 如果渠道确认已收款、但本地结算链路(生成销售单、记账)执行失败,订单置为挂起态 CHANNEL_RECEIVED_SETTLE_PENDING 并告警转人工——宁可挂在那里等人处理,也绝不把一笔账记成成功。假成功才是资损之源。
第二道防线:每日对账巡检
恢复器是分钟级的实时防线,兜底还有一层日级巡检:每天低峰时段扫一遍”已创建但过期未关未确认”的孤儿单(顾客可能在收银端放弃后仍然完成了付款),防御性上限 500 条。它默认跑在 dry-run 模式——只告警、不动账、不向用户展示;观察一段时间确认规则稳定后,再通过配置逐步放行。自动化动作的信任是挣来的,不是默认给的。
踩过的坑
调度线程没有登录态和租户上下文,而我的多租户拦截器会自动给 SQL 拼租户条件——上下文为空时渲染出的 tenant_id = NULL 会命中 0 行,扫描静默失效(日志一片正常,实际什么都没扫到)。修法:扫描 SQL 显式豁免租户拦截,逐行按记录自身的 tenantId 恢复上下文,finally 里清理;tenantId 为空的脏行直接跳过并告警,不让它流入下游。
收尾一句话
支付链路的可靠性不靠某一个机制,而是”轮询自愈 + 对账兜底”的层级防御:实时层解决分钟级恢复,日终层兜住一切漏网——每层都假设上一层会漏。