跨服务调用失败后谁先打电话?重试、补偿查询与对账机制解析
2026/9/8 12:16:03 网站建设 项目流程

“和好的电话,谁来拨通?”单看这个问题,它像是在讨论人和人之间的修复关系。但在软件系统里,它同样是一个每天都在发生的工程问题。服务 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 用订单支付回调作为主线

假设有一个非常常见的业务链路:

  1. 用户在商户系统下单。
  2. 商户系统请求支付网关,创建一笔支付单。
  3. 用户进入收银台完成支付。
  4. 支付网关确认支付成功后,回调商户系统的通知接口。
  5. 商户系统更新订单状态为已支付。

在这个链路里,“谁来拨通电话”等于“支付网关回调失败后,如何恢复订单状态”。支付网关会按照自己的规则重试回调,但不会无限重试。商户系统也不能只等回调,否则用户付了钱、订单还停留在待支付。

这个场景非常适合用来理解三种恢复机制:支付网关的回调是发送方重试,商户系统的主动查单是接收方补偿查询,双方账单核对就是定时对账。

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_countlast_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 学习环境和生产环境要补充的东西不同

在本地,单实例、单数据源、手动触发定时任务就够了。到了生产环境,订单量和调用量都会放大,至少要补充以下几个方面:

  • 回调接口必须先落库并快速返回,不能在里面同步执行长事务。
  • 主动查询和对账任务需要分布式锁,避免多实例重复执行。
  • 日志里必须带上orderIdtraceId,否则排查回调问题时找不到线索。
  • 补偿查询不能全表扫描,要基于索引和分批查询。
  • 对账任务要有批次号和重跑机制,避免重复比对造成数据混乱。

这些差异不是小事。很多项目在本地能重试成功,一到生产环境就出现重复更新、任务竞争和日志断链,就是因为只实现了主链路,没有实现恢复链路。

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 选型前先回答五个问题

不要一上来就把三种方案全部实现,也不要把所有责任都压给重试。选型前先回答下面五个问题:

  1. 这次失败是瞬时故障,还是业务规则拒绝?
  2. 调用方是否有可靠存储保存请求现场?
  3. 被调用方接口是否已经实现幂等?
  4. 业务允许最长不一致时间是多少?
  5. 双方的日志和监控是否足够定位问题?

回答完这些问题,方案基本就出来了。允许秒级恢复,就加强重试和回调;允许分钟级恢复,就加补偿查询;只要求最后账目一致,对账就足够。

7. 常见问题与从“订单没更新”倒查的排查链路

7.1 坑1:重试导致重复扣款

现象是调用支付接口超时后重试,结果用户被扣了两次款。

原因通常是缺少幂等。调用方没有携带唯一的请求 ID,或者接收方没有用请求 ID 去重。

推荐做法是在请求中携带requestId,支付网关侧用requestId去重,商户侧使用订单号配合状态条件更新。出现超时时,先查单再决定是否重试,不要对未知状态盲目重试。

7.2 坑2:回调接口在同步长事务里做了太多事,导致网关超时

现象是回调接口程序还在执行,但响应太慢,网关已经判定超时,然后不断重试。

原因是回调里同步做了数据库更新、发送通知、调用第三方接口等操作。

回调接口的处理原则应该是最小动作:先验签,再落库,然后返回 SUCCESS。后续的异步通知、积分发放、消息推送放到消息队列或异步任务里处理。这样回调接口的响应时间能保持在几十毫秒级别。

7.3 坑3:定时任务没有分布式锁,多实例重复查询

现象是主动查询任务和普通请求同时更新同一个订单,出现死锁或版本冲突。

原因是定时任务在每个实例上都会触发,而且没有加锁。查询任务本身是幂等操作,但查询后的更新语句必须受到状态条件保护。

解决方式是使用分布式锁,或者在更新语句中带上“当前状态等于支付中”的条件,利用影响行数判断是否更新成功。

7.4 坑4:重试没有抖动,故障恢复瞬间流量尖峰

现象是下游服务恢复后,积压的重试请求在同一时间全部发出,导致又一次不可用。

原因是固定重试间隔没有随机抖动。

解决方式是指数退避加随机抖动。重试次数必须有限,超过最大次数后要转人工或告警,而不是无限重试下去。

7.5 排查链路:用户说“支付成功了但订单还是待支付”从哪里查起

遇到这类问题,不要一上来就怀疑网络。按下面的顺序检查:

第一步,查订单表。看statuscallback_countlast_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 模式。当一次操作需要跨多个服务时,失败后的补偿动作会比单笔回调复杂得多,需要维护事务协调状态。

第三个方向是事件驱动的对账平台。把对账从定时任务升级为事件流处理,做到小时级甚至分钟级的数据核对。

无论往哪个方向走,核心原则都一样:状态必须持久化,恢复路径必须可追踪,失败的每一步都要有日志和监控。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询