“和好的电话,谁来拨通?”单看这个问题,它像是在讨论人和人之间的修复关系。但在软件系统里,它同样是一个每天都在发生的工程问题。服务 A 调用服务 B,请求超时了,或者 B 返回了一个模棱两可的异常,这时候 A 在等 B 的结果,B 也可能在等 A 的下一次请求。如果两边都不主动,一次调用失败就会变成一笔永远悬空的业务。S1E11 这个编号本身不重要,重要的是它把“谁先打电话”这个问题摆到了台面上。这里不聊剧情,只把这句追问切换到后端开发场景:当一次跨服务调用失败之后,应该由哪一方、在什么条件下、用什么方式发起下一次“拨号”,以及重试、回调、对账三种机制分别解决什么问题,如何组合,如何排查。
1. 先理解“谁发起下一次通信”为什么是个架构问题
1.1 调用失败后的真实状态不是“失败”,而是“未知”
实际项目里,一次 HTTP 调用超时或连接异常时,调用方往往无法确定请求是否到达了被调用方,也无法确定被调用方是否已经执行成功。你只知道“没有得到预期响应”,但不知道对方处于什么状态。
用通俗一点的话说:A 给 B 打电话,响了很久没有回应。A 不知道 B 是没听见,还是在处理别的事情、暂时不能说话,或者已经处理完了但电话回不来。此时 A 如果重拨,B 可能重复处理;如果不再重拨,业务可能永远停在中间。
在分布式系统里,这个现象经常被概括为“两将军问题”:单靠一次消息交换,无法让双方对某个状态达成绝对确定的共识。所以“谁来拨通电话”本质上是一个一致性决策问题,不能靠碰运气,必须设计出可验证、可重放、可收敛的恢复机制。
1.2 把恢复责任全放在某一端,会出现三组问题
如果只让调用方负责重试,风险是重试风暴。调用方数量多时,一次下游故障可能让所有调用方同时发起重试,把已经脆弱的下游服务彻底压垮。而且如果调用方没有保存完整请求现场,重试时甚至无法构造出和原来一致的参数。
如果只让接收方负责通知,风险是通知本身不可靠。接收方保存了数据,但商户系统崩溃、回调接口超时、网络分区,都可能让通知丢失。更麻烦的是,接收方并不知道“通知丢了”,它可能认为回调已经成功。
如果没有兜底对账,长时间不一致只能靠人工发现。用户支付成功但订单还是待支付,等客服介入时,已经造成很差的体验。
三种典型机制对比如下:
| 恢复机制 | 发起方 | 典型适用情况 | 主要风险 |
|---|---|---|---|
| 调用方重试 | 调用方 | 瞬时故障、网络抖动 | 重试放大流量,重复写入数据 |
| 接收方补偿查询 | 接收方 | 上游回调不可达,需要主动查结果 | 查询覆盖不全,定时任务竞争 |
| 定时对账 | 中台/任意一方 | 最终一致,系统化纠偏 | 时效性差,开发成本高 |
1.3 设计目标不是“绝对不失败”,而是“失败后可恢复”
理解了上面的变化,就会明白恢复机制的核心不是消灭失败,而是让每次调用都成为可追踪的状态流转。
设计时可以把一次调用拆成几个阶段:调用方构造请求、发送请求、被调用方接收、被调用方处理、返回结果、调用方确认结果。哪一步都可能中断。为了让中断后可以恢复,需要做到两件事:
第一,参与方必须持久化关键状态,比如订单状态、请求 ID、上次执行时间。第二,失败后要根据状态迁移规则选择一个明确的恢复动作,比如重试、查单或对账。
后面几章围绕一个订单支付回调场景展开,把这套思路落到具体代码和配置上。
2. 把问题固定到一个场景:支付成功但订单没有更新
2.1 用订单支付回调作为主线
假设有一个非常常见的业务链路:
- 用户在商户系统下单。
- 商户系统请求支付网关,创建一笔支付单。
- 用户进入收银台完成支付。
- 支付网关确认支付成功后,回调商户系统的通知接口。
- 商户系统更新订单状态为已支付。
在这个链路里,“谁来拨通电话”等于“支付网关回调失败后,如何恢复订单状态”。支付网关会按照自己的规则重试回调,但不会无限重试。商户系统也不能只等回调,否则用户付了钱、订单还停留在待支付。
这个场景非常适合用来理解三种恢复机制:支付网关的回调是发送方重试,商户系统的主动查单是接收方补偿查询,双方账单核对就是定时对账。
2.2 最小数据模型:先定状态和幂等字段
先建一张最简单的支付订单表,把状态、金额、回调次数和幂等字段都放进去。
CREATE TABLE `payment_order` ( `order_id` VARCHAR(64) NOT NULL COMMENT '订单号', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1支付中 2已支付 3已关闭', `pay_amount` DECIMAL(12,2) NOT NULL COMMENT '支付金额', `channel_trade_no` VARCHAR(64) DEFAULT NULL COMMENT '支付网关交易号', `callback_count` INT NOT NULL DEFAULT 0 COMMENT '回调接收次数', `last_callback_time` DATETIME DEFAULT NULL COMMENT '最近一次回调时间', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`order_id`), KEY `idx_status_time` (`status`, `last_callback_time`) ) COMMENT='支付订单表';需要说明几个字段的用途。
status是状态机字段,表达订单当前处于哪个阶段。恢复逻辑的核心就是检查状态、判断能不能迁移。callback_count和last_callback_time不是业务必须字段,但排查“回调有没有进来”时非常有用。version用于乐观锁,防止重复请求并发地更新同一条订单,这种更新在回调重试和主动查单同时发生时很容易出现。
2.3 本地先跑通一个回调端点,再谈恢复策略
本地验证时,可以先用极简的 Controller 把回调端点跑通。这里不引入消息队列,重点是确认回调路径能够正常工作。
定义一个回调请求对象:
public class CallbackRequest { private String orderId; private String channelTradeNo; private Integer payStatus; // getter / setter 省略 }写一个最简回调接口:
@RestController public class PaymentCallbackController { @PostMapping("/api/payment/callback") public ResponseEntity<String> callback(@RequestBody CallbackRequest request) { // 生产环境必须先做验签,这里仅为演示提供占位 if (request.getPayStatus() == null || request.getPayStatus() != 2) { return ResponseEntity.badRequest().body("invalid status"); } boolean ok = orderService.markPaid(request.getOrderId(), request.getChannelTradeNo()); if (ok) { return ResponseEntity.ok("SUCCESS"); } return ResponseEntity.status(409).body("conflict"); } }这个示例只用于说明思路。真实环境中支付网关会有完整的验签规则,回调参数、加密方式和返回格式也要以具体网关文档为准。本地验证的目标很明确:能收到请求、能验签、能更新订单、能返回明确结果。
2.4 学习环境和生产环境要补充的东西不同
在本地,单实例、单数据源、手动触发定时任务就够了。到了生产环境,订单量和调用量都会放大,至少要补充以下几个方面:
- 回调接口必须先落库并快速返回,不能在里面同步执行长事务。
- 主动查询和对账任务需要分布式锁,避免多实例重复执行。
- 日志里必须带上
orderId和traceId,否则排查回调问题时找不到线索。 - 补偿查询不能全表扫描,要基于索引和分批查询。
- 对账任务要有批次号和重跑机制,避免重复比对造成数据混乱。
这些差异不是小事。很多项目在本地能重试成功,一到生产环境就出现重复更新、任务竞争和日志断链,就是因为只实现了主链路,没有实现恢复链路。
3. 方案一:调用方主动重试,先解决“由谁拨出第一通电话”
3.1 重试不是简单的 for 循环,要区分可重试错误
调用方主动重试是最直观的方案。请求失败后,调用方再发一次。但这里最容易犯的错误是“只要失败就重试”。
可重试错误通常是这些:
- 连接超时、连接重置。
- 下游返回 5xx 服务端错误。
- 下游返回 429 限流错误,并且带有重试提示。
不可重试错误包括:
- 请求参数不合法,返回 4xx。
- 验签失败。
- 业务规则拒绝,比如订单已经关闭。
如果对不可重试错误也做重试,不仅会反复触发错误日志,还会放大数据库和下游系统的压力。重试逻辑里应该只捕获明确的可重试异常。
3.2 重试次数、间隔、抖动和总超时要一起设计
重试参数不能拍脑袋。参数之间互相影响,设计时需要整体考虑。
| 参数 | 建议范围 | 影响 |
|---|---|---|
| 最大重试次数 | 3 到 5 次 | 太少覆盖不了瞬时故障,太多放大压力 |
| 基础间隔 | 1 秒 | 太短没有退避效果,太长增加延迟 |
| 最大间隔 | 10 秒到 1 分钟 | 控制总等待时间 |
| 随机抖动 | 0 到 200 毫秒 | 避免同一时刻大量重试形成尖峰 |
| 总超时时间 | 30 秒到 1 分钟 | 防止整个调用链悬挂 |
抖动经常被忽略。假设有 100 个请求同时失败,它们都按照 1 秒固定间隔重试,那么 1 秒后 100 个重试会同时到达下游。如果 100 个实例都这样,下游会形成一道明显的流量尖峰,这就是“重试风暴”。加入随机抖动后,重试时间被打散,恢复过程会更平滑。
3.3 一个可读的重试执行器示例
下面用一个简化版的重试执行器说明关键点。实际项目可以封装成通用组件或使用现有重试库。
public class RetryExecutor { private static final int MAX_RETRIES = 3; private static final long BASE_DELAY_MS = 1000L; private static final long MAX_DELAY_MS = 5000L; private static final Random RANDOM = new Random(); public <T> T execute(Callable<T> action) throws Exception { Exception lastException = null; for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { return action.call(); } catch (RetryableException e) { lastException = e; if (attempt == MAX_RETRIES) { break; } Thread.sleep(delay(attempt)); } } throw new RuntimeException("retry exhausted", lastException); } private long delay(int attempt) { long exp = (long) Math.pow(2, attempt - 1) * BASE_DELAY_MS; return Math.min(exp + RANDOM.nextInt(200), MAX_DELAY_MS); } }这段代码的关键点有三个。
第一,只有RetryableException才会触发重试,其他异常直接往外抛。第二,退避间隔按指数增长:第 1 次重试约 1 秒,第 2 次约 2 秒,第 3 次约 4 秒。第三,每次间隔都加了随机抖动,让多个重试请求不集中在同一时间点。
需要注意的是,重试执行器只是工具,真正重要的是调用方如何保存请求现场。如果调用方在事务里执行重试,又要保证事务不提交,逻辑会复杂很多。更稳妥的做法是先把待发送请求持久化到本地表,再由异步任务读取并重试。
3.4 调用方重试的两个前提:请求可重放,消费方幂等
调用方重试不是万能的,它有两个硬性前提。
第一个前提是请求可重放。意思是调用方要把请求 ID 和完整参数保存下来,而不是在内存里临时拼一个请求。应用重启后,重试任务能根据持久化记录重新发起请求。
第二个前提是消费方接口已经幂等。幂等的意思是:同一个请求无论被处理一次还是多次,最终业务结果都相同。比如支付回调里使用订单号作为幂等键,重复回调不会生成第二笔流水;更新订单状态时加上状态校验,只有“待支付”的订单才能被更新为“已支付”。
缺少幂等,重试越努力,问题越严重。
4. 方案二:接收方主动“回拨”,用补偿查询兜住漏掉的回调
4.1 为什么只靠发送方重试不够
支付网关对回调有自身的重试策略,但次数有限。如果商户接口持续返回 500,或者网络一直不通,支付网关重试几次之后通常不会再继续。此时用户已经在支付端完成了付款,商户系统却还停留在“待支付”。
这种时刻不应该寄希望于用户反馈或客服发现,而应该由商户系统作为接收方主动向支付网关发起查询,问一句“这个订单到底付了没有”。这就是“接收方主动回拨”。
从架构上看,发送方重试是上游驱动的恢复,接收方补偿查询是下游驱动的恢复。两者互补,不能互相替代。
4.2 补偿查询的任务设计:谁、多久、查哪些订单
补偿查询一般通过定时任务实现。先找出状态是“支付中”、并且一段时间内没有收到回调的订单,再逐个调用支付网关查询接口。
SQL 可以这样写:
SELECT order_id FROM payment_order WHERE status = 1 AND last_callback_time < DATE_SUB(NOW(), INTERVAL 5 MINUTE) ORDER BY last_callback_time LIMIT 100;这里的status = 1表示订单处于“支付中”,说明已经创建支付单,但没有最终结果。last_callback_time早于当前时间 5 分钟,说明回调已经五分钟没有进来。LIMIT 100限制单次处理数量,避免一次任务把数据库和支付网关压垮。
任务里还需要考虑三个点:
- 分布式锁:多实例部署时,同一个时刻只能有一个实例执行查询,否则同一个订单会被多个任务同时查询和更新。
- 分批处理:一次任务处理不完,要记录游标,下一轮继续。
- 查询频率:根据业务容忍度设置。支付场景通常可以 1 到 5 分钟查一次,对账场景可以按小时或按天。
4.3 实现状态机,避免补偿查询把订单改乱
补偿查询拿到支付结果后,不能无脑更新订单状态。要先用状态机判断当前状态是否允许迁移。
最小状态机可以这样定义:
- 0 待支付:可以迁移到 1 支付中。
- 1 支付中:可以迁移到 2 已支付,也可以迁移到 3 已关闭。
- 2 已支付:终态,不允许再被改成其他状态。
- 3 已关闭:如果支付网关确认未支付,可以关闭;如果支付网关返回已支付,需要告警人工处理。
对应的更新语句建议带状态条件:
int updated = orderRepository.updateStatusIfCurrent( orderId, OrderStatus.PAYING, // 当前状态 OrderStatus.PAID, // 目标状态 channelTradeNo, version);如果updated == 0,说明订单已经被别的请求改过,不能继续操作。出现这种情况要记录下来,而不是静默吞掉。
4.4 什么时候应该选补偿查询
当业务场景存在“上游已经完成操作,但回调可能不可达”的情况时,就应该引入补偿查询。典型场景包括支付回调、短信发送结果查询、第三方审核结果同步。
补偿查询的优势是恢复主动,延迟可控。代价是会增加定时任务、分布式锁和查询接口的调用量。如果业务不允许超过五分钟的不一致,补偿查询几乎是必需的。
5. 方案三:定时对账,最后一道兜底防线
5.1 对账解决的是“双方各自认为正确,但结果不一致”的问题
重试和补偿查询都是单笔订单视角。A 认为回调成功了,B 认为更新失败了,或者 A 的金额和 B 的金额差了一分钱,这些情况靠单笔查询很难发现。
对账的出发点是:拉取一段周期内的全量账单,和本地订单逐笔比对,找出差异。支付网关一般会提供按日或按小时的对账单,或者提供批量拉取接口。
对账不追求秒级恢复,它追求的是“即使前面所有机制都漏掉了,也必须在某个时间点被发现”。
5.2 对账任务的关键字段:批次、日期、状态、计数
对账任务不能没有批次管理。每跑一次对账,都要有唯一批次号,记录处理日期、状态和统计结果。
CREATE TABLE `reconcile_batch` ( `batch_no` VARCHAR(64) PRIMARY KEY, `biz_date` DATE NOT NULL COMMENT '业务日期', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败', `total_count` INT NOT NULL DEFAULT 0 COMMENT '账单总数', `matched_count` INT NOT NULL DEFAULT 0 COMMENT '一致笔数', `diff_count` INT NOT NULL DEFAULT 0 COMMENT '差异笔数', `create_time` DATETIME NOT NULL, `update_time` DATETIME NOT NULL ) COMMENT='对账批次表';批次表让对账任务具备可重跑能力。如果某一天的对账任务失败,可以重新生成一个批次来跑,而不是直接删除旧数据再跑。这样每次执行的痕迹都是完整的。
5.3 对账处理主流程:拉单、逐笔比对、生成差异、告警
对账主流程可以用下面这段伪代码表达:
public void reconcile(LocalDate bizDate) { String batchNo = createBatch(bizDate); List<GatewayBill> bills = gatewayClient.downloadBill(bizDate); for (GatewayBill bill : bills) { Order order = orderRepository.findByOrderId(bill.getOrderId()); boolean matched = order != null && order.getStatus().equals(OrderStatus.PAID) && order.getPayAmount().compareTo(bill.getAmount()) == 0; if (matched) { orderRepository.markMatched(batchNo, bill.getOrderId()); } else { orderRepository.markDiff(batchNo, bill.getOrderId(), "AMOUNT_OR_STATUS_MISMATCH"); } } finishBatch(batchNo); }这段代码要注意一个问题:对账任务通常不会自动改状态。发现差异后,正确的动作是记录差异并触发告警。自动修复只适用于明确规则的场景,例如“本地待支付但账单已经扣款”,而且修复逻辑要经过业务确认。
5.4 对账不是重试的替代品
对账的时效性天然比较差,通常按小时或按天执行。它无法解决用户刚支付完就想看到订单状态更新的问题,所以不能把对账当作唯一的恢复手段。
正确的关系是三层防线:
- 第一层:支付网关回调,本质是发送方重试。
- 第二层:商户系统主动查单,本质是接收方补偿查询。
- 第三层:每日对账,本质是系统化纠偏。
每一层都解决不同时间尺度上的问题,彼此不是替代关系。
6. 三种机制放在一起对比,选型就有依据
6.1 一页对比表
| 维度 | 调用方重试 | 接收方补偿查询 | 定时对账 |
|---|---|---|---|
| 发起方 | 调用方 | 接收方 | 任意一方或中台 |
| 时效性 | 秒级 | 分钟级 | 小时/天级 |
| 开发成本 | 低 | 中 | 高 |
| 是否依赖接口幂等 | 是 | 是 | 是 |
| 适合场景 | 瞬时故障、网络抖动 | 上游回调不可达 | 系统化纠偏、数据一致性核对 |
| 典型风险 | 重试风暴、重复写入 | 查询压力、锁竞争 | 时效差、自动修复风险 |
6.2 常见组合与折中
在支付回调场景中,业界比较常见的组合是:
支付网关自身重试 + 商户系统主动查询 + 每日对账。
普通内部接口调用,如果业务允许短暂失败,只需要调用方重试就能解决。涉及资金、库存、积分这类对一致性要求高的数据,至少要有主动查询。如果跨越多个团队维护的系统,还需要增加对账。
6.3 选型前先回答五个问题
不要一上来就把三种方案全部实现,也不要把所有责任都压给重试。选型前先回答下面五个问题:
- 这次失败是瞬时故障,还是业务规则拒绝?
- 调用方是否有可靠存储保存请求现场?
- 被调用方接口是否已经实现幂等?
- 业务允许最长不一致时间是多少?
- 双方的日志和监控是否足够定位问题?
回答完这些问题,方案基本就出来了。允许秒级恢复,就加强重试和回调;允许分钟级恢复,就加补偿查询;只要求最后账目一致,对账就足够。
7. 常见问题与从“订单没更新”倒查的排查链路
7.1 坑1:重试导致重复扣款
现象是调用支付接口超时后重试,结果用户被扣了两次款。
原因通常是缺少幂等。调用方没有携带唯一的请求 ID,或者接收方没有用请求 ID 去重。
推荐做法是在请求中携带requestId,支付网关侧用requestId去重,商户侧使用订单号配合状态条件更新。出现超时时,先查单再决定是否重试,不要对未知状态盲目重试。
7.2 坑2:回调接口在同步长事务里做了太多事,导致网关超时
现象是回调接口程序还在执行,但响应太慢,网关已经判定超时,然后不断重试。
原因是回调里同步做了数据库更新、发送通知、调用第三方接口等操作。
回调接口的处理原则应该是最小动作:先验签,再落库,然后返回 SUCCESS。后续的异步通知、积分发放、消息推送放到消息队列或异步任务里处理。这样回调接口的响应时间能保持在几十毫秒级别。
7.3 坑3:定时任务没有分布式锁,多实例重复查询
现象是主动查询任务和普通请求同时更新同一个订单,出现死锁或版本冲突。
原因是定时任务在每个实例上都会触发,而且没有加锁。查询任务本身是幂等操作,但查询后的更新语句必须受到状态条件保护。
解决方式是使用分布式锁,或者在更新语句中带上“当前状态等于支付中”的条件,利用影响行数判断是否更新成功。
7.4 坑4:重试没有抖动,故障恢复瞬间流量尖峰
现象是下游服务恢复后,积压的重试请求在同一时间全部发出,导致又一次不可用。
原因是固定重试间隔没有随机抖动。
解决方式是指数退避加随机抖动。重试次数必须有限,超过最大次数后要转人工或告警,而不是无限重试下去。
7.5 排查链路:用户说“支付成功了但订单还是待支付”从哪里查起
遇到这类问题,不要一上来就怀疑网络。按下面的顺序检查:
第一步,查订单表。看status、callback_count、last_callback_time有没有变化。
SELECT order_id, status, callback_count, last_callback_time FROM payment_order WHERE order_id = '202501010001';第二步,查回调日志。确认支付网关是否把请求打到了商户系统。
grep "202501010001" /app/logs/payment-order.log | tail -100第三步,查支付网关后台的回调记录。确认回调状态是成功、失败还是超时。
第四步,如果回调失败,看重试次数是否已经耗尽。
第五步,如果回调没有进来,看主动查询任务有没有覆盖这个订单。查询任务的日志里通常会有orderId和处理结果。
第六步,如果以上都正常,跑一次对账,看账单里是否存在该订单,以及比对结果是什么。
大多数情况下,问题出在“回调日志根本没有进来”。这时候排查重点不是代码逻辑,而是回调地址、验签、网关配置和网络白名单。
8. 落地建议与下一步可以做的事
8.1 上线前的可复用检查清单
无论做支付回调、短信通知还是外部接口同步,上线前都可以用这份清单检查:
- 每个写接口是否都带请求 ID,并实现了幂等处理?
- 重试是否只针对可重试错误,是否带退避和抖动?
- 回调接口是否能快速落库并立即返回?
- 补偿查询是否加分布式锁,是否限制了单次处理数量?
- 对账任务是否包含批次号和可重跑机制?
- 关键日志是否包含业务单号和
traceId? - 是否设置了针对重试耗尽、差异单、回调积压的告警?
这份清单看起来基础,但大部分线上故障都和清单中的某一项被跳过有关。
8.2 学习环境与生产环境的区别再强调
学习环境里,可以用一个for循环加Thread.sleep演示重试,也可以手动调用定时任务。生产环境则完全不同。生产环境建议把重试转换成异步任务,让消息队列承接失败消息;主动查询任务交给分布式调度平台;对账任务单独部署,避免和业务接口争抢资源。数据库层要增加合适的索引,接口层要加限流和熔断,日志要能按订单号串联整条链路。
8.3 下一步可以扩展的方向
理解“谁先拨通电话”之后,可以继续往三个方向深入。
第一个方向是消息队列的投递语义。理解 at-least-once、exactly-once 的限制,以及为什么大多数系统只能用 at-least-once 配合幂等来模拟 exactly-once。
第二个方向是分布式事务中的 Saga 模式。当一次操作需要跨多个服务时,失败后的补偿动作会比单笔回调复杂得多,需要维护事务协调状态。
第三个方向是事件驱动的对账平台。把对账从定时任务升级为事件流处理,做到小时级甚至分钟级的数据核对。
无论往哪个方向走,核心原则都一样:状态必须持久化,恢复路径必须可追踪,失败的每一步都要有日志和监控。