作者简介
奋斗的小蜗牛,开源项目SnailJob作者。专注于系统架构、分布式设计与高性能系统。
Cris Yang,携程后端开发经理,在系统性能、稳定性、高并发等领域有丰富的实战经验,热衷于新技术探索。
团队热招岗位:Java后端开发工程师(高并发)
导读:在微服务架构中,重试机制本是为了提升系统稳定性,但不当使用却可能引发"重试风暴"——一次简单的3次重试可能演变成N的4次方调用量,导致服务雪崩。本文通过携程真实案例,深入分析重试陷阱、链路放大效应等问题,并介绍SnailRetry组件的优雅解决方案,帮助开发者避免重试成为系统灾难。
一、前言
二、如何优雅重试
三、重试的常见解决方案
四、SnailRetry简介
五、系统架构图
六、流量管控的实现
七、客户端功能
八、服务端功能
九、哪些场景适合使用
十、总结
一、前言
在当今微服务架构盛行的背景下,系统被拆分为多个小服务,服务间频繁的RPC调用可能因网络抖动导致失败。通过重试机制,我们可以提升请求的成功率,降低故障影响,从而增强系统的稳定性。此外,也会面临服务拆分带来的事务一致性挑战,大多数情况下我们不会采用分布式事务,因为它复杂且影响性能,通常通过重试补偿来实现最终一致性。因此不得不让我们重新审视重试机制的作用。
1.1 重试的陷阱
可以短暂停留几秒,思考一下遇到重试场景的时候我们应该怎么做? 是不是曾经或者现在正在写过类似下面的代码?
public class RetryKit {
public static <T> T retry(Supplier<T> supplier, int maxAttempts, long delayMs) { Exception lastException = null; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { return supplier.get(); } catch (Exception e) { lastException = e; if (attempt < maxAttempts) { try { Thread.sleep(delayMs); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new RuntimeException("重试被中断", ie); } } } } throw new RuntimeException("重试失败,已尝试 " + maxAttempts + " 次", lastException); }}
public class AddOrderEventMQ {
// MQ的消费者 public void consume() { // 业务逻辑 ...... // 调用第三方发起重试 RetryKit.retry(new Supplier<Void>() { @Override public Void get() { // 请求其他的服务 Result r = call(); if (r.getCode != 200) { throw new RuntimeException("接口异常"); } } }, 3, 1); }}
其实这是一个足够简单的重试案例,当请求遭遇异常,无法返回预期的结果时,则通过循环进行三次重试。但是这样简单的写法存在一个最直观的问题:
重试业务的代码对于正常业务的侵入性太高,导致代码的可读性变差
引发重试风暴的风险,假设MQ也重试3次,那么总共就会重试 3 * 3 = 9次
虽然这个只是重试的一个极其简单的案例,但是足够让我们重新审视一下重试的危害。
1.2 重试风暴
所谓重试风暴,指的是当下游被重试的服务出现故障时,上游的业务无法获取下游成功的状态码,故而无脑重试,由此引发的一系列问题。
我们一起来看重试风暴可能会引发的问题的场景。
1.2.1 重试导致的链路放大效应
假设有四个服务A、B、C、D,他们的调用关系是A调用B,B调用C和D。在微服务中,熔断和限流是非常常见的动作。假如A服务的调用量激增,触发了B服务的限流。这个时候B服务没有办法返回A服务理想的成功状态码,此时A服务就会发起重试,由此将会带来两个重大恶果。
A服务的重试会导致B服务请求调用量激增,由此引发B服务的下游C和D服务量激增,下游也会出现熔断风险。

当触发限流之后,B服务的请求中会充斥着大量的重试请求,由此会导致A服务中正常请求不可用的比例增加,造成服务更大范围的不可用。

同理,假如B服务没有触发限流,而是在高负载状态下导致了短暂的不可用,同样也会导致上图中出现的链路放大效应,可能会诱发出现服务雪崩的风险。
甚至在这样引入重试机制后,当B服务从短暂不可用状态恢复,由于A服务中包含了大量的重试,使得瞬时实际访问量剧增,从而当B服务恢复正常后,整体服务的恢复时间变长。
我们重试治理的关键就是,不能让重试流量反客为主影响用户流量。
1.2.2 重试引发的告警升级
这个很好理解,假设用户实际的瞬时请求量是500,当经过重试的n次放大之后则会将请求数量变成500*n,此时如果系统中存在告警处理则会使得告警的数量增加三倍,引发告警的升级。
当然上述讲的这些都是人为因素,我们接下来再来讨论一个这种场景下可能会引发的技术问题。下面是一个真实的线上事故:
这个业务中Api层交由Nginx来做负载均衡,然后Api层再去调用RPC进行数据读取操作,重试机制做在了Api层,此时出现了一个bug,由于Api层某个参数传递错误,导致RPC服务返回5xx错误。
随后Api层接收到5xx错误后进行重试,导致5xx的问题翻倍。
由于接入了Nginx,此时触发了Nginx的健康检查机制,由于Nginx发现了大量 5xx 错误时,Nginx 可以自动将该节点从负载均衡的池中摘除。
随后导致Api节点被逐个摘除,然后出现了服务大规模的不可用,导致了生产事故。

尽管在这个案例中,重试机制不是直接原因。但是毋庸置疑的是由于引入了这种重试,导致原本可控的问题被放大,演变成了更加严重的问题。
1.2.3 重试的蝴蝶效应

我们假设微服务的调用方式如上图所示,当服务E出现异常时,服务E上报异常,随后触发服务C重试,而服务C调用服务E异常后同样选择抛出异常,这样子又会引发服务B重试,假设每个服务都接入重试机制,每个服务的重试次数为n,这样子就会导致最终的请求变成了n的4次方。
同样也会引发服务的不稳定,甚至可能造成雪崩。
二、如何优雅重试
因此,如何降低重试机制的使用成本,并探索更优雅的重试策略,是一个值得深入研究的问题。
2.1 重试风暴问题
在单点重试的场景中,一个服务不能无限制的对下游发出重试请求,这样会增加下游被打挂的场景。因此需要限制重试次数的上线和重试请求的成功率。
同时由于在微服务体系中,重试可能会在多级链路中引发指数级调用量增长,我们还需要限制重试在微服务的每一层均发生,最为理想的情况是重试仅仅发生在服务的最下一层。
2.2 退避策略
在某些场景下,会出现一些暂时性的错误,如网络抖动、服务重新部署等场景,可能立即发起重试结果依然是失败的,通常等待一段时间后再重试的话成功率会较高,并且也可以分散上游重试的时间,可以避免因为同时都重试而导致的下游瞬间流量高峰。决定等待多久之后再重试的方法叫做退避策略,在重试系统中支持灵活的退避策略是很必要的。
2.3 动态配置
当指定重试策略后,重试策略可能会因为业务场景的变化而变动,在这种情况下,如果我们需要每次通过代码编译部署上线的流程去修改其中的一些重试参数的话,会使得我们的使用非常不便捷。因此如果重试组件中可以支持动态配置,我们就可以通过后台修改配置的方式,根据业务场景的变化而修改策略。
三、重试的常见解决方案
组件名称 | 组件介绍 | 客户端/服务端 | 是否开源 |
Spring Retry | Spring Retry 是 Spring 框架提供的一个模块,其中的 RetryTemplate 类允许你在代码中配置和执行重试操作。你可以定义重试策略、重试次数、重试间隔等。它提供了灵活的方式来处理需要重试的操作。 | 客户端 | 是 |
Guava Retryer | Guava Retryer 是 Google Guava 库提供的一个重试框架。它允许你定义重试策略和条件,并执行重试操作。你可以设置重试次数、重试间隔、异常判断条件等。Guava Retryer 提供了丰富的重试功能和灵活的配置选项。 | 客户端 | 是 |
阿里EasyRetry | 是一种存储介质可扩展的持久化重试方案 | 客户端 | 是 |
字节跳动直播中台团队 | 是一款基于服务端重试具备重试治理功能的重试组件 | 服务端 | 否 |
SnailJob(由本文作者开发) | 基于服务治理的思想开发重试治理的功能,支持动态配置,接入方式基本无需入侵业务代码,并使用多种策略结合的方式在链路层面控制重试放大效应,兼顾易用性、灵活性、安全性,对提高服务本身稳定性有良好的效果。 | 服务端 | 是 |
注: 携程落地实现的重试方案基于SnailJob的分布式重试为基础实现,内部名称SnailRetry。
四、SnailRetry简介
SnailRetry是一款服务治理重试组件,具备操作简便、实时监控、后台配置等优势,支持多种退避策略和告警方式。它提供本地和远程重试模式,并通过管理后台实现重试任务的可视化,方便查看和管理。此外,用户可以在后台配置多种策略,根据不同业务场景进行实时调整。
SnailRetry的核心功能在于流量管控,主要涵盖以下场景:
单机链路管控
限制链路重试
重试流速管控
支持多种退避策略
支持可视化配置
支持动态关闭重试场景
丰富的参数注解(16个)
五、系统架构图

六、流量管控的实现
那么我们来看一下,核心功能点中的流量管控是怎么做到的呢? 主要是通过两种方式:
6.1 单机多注解循环引用问题
重试执行过程中标记了重试入口,触发重试时只从标记的重试入口进入。

6.2 链路重试流量管控
对于重试的请求,我们在请求头中下发一个特殊的标识(easyRetry:boolean), 在 Service A ->Service B ->Service C 的调用链路中,当Service B 收到Service A 的请求时会先读取这个 easyRetry 判断这个请求是不是重试请求, 如果是,那它调用Service C 即使失败也不会重试;否则将触发重试。同时Service B 也会把这个 easyRetry 下传,它发出的请求也会有这个标志,它的下游也不会再对这个请求重试。

6.3 特殊的 status code 限制链路重试
如果每层都配置重试可能导致调用量指数级扩大,这样对底层服务来说压力是非常之大的,通过对流量的标记,用户可以判断是否是重试的流量来判断是否继续处理,我们使用 Google SRE 中提出的内部使用特殊错误码的方式来实现:
1 统一约定一个特殊的 status code ,它表示:调用失败,但别重试。2 任何一级重试失败后,生成该 status code 并返回给上层。3 上层收到该 status code 后停止对这个下游的重试,并将错误码再传给自己的上层。
这种方式理想情况下只有最下一层发生重试,它的上游收到错误码后都不会重试,但是这种策略依赖于业务方传递错误码,对业务代码有一定入侵,而且通常业务方的代码差异很大,调用 RPC 的方式和场景也各不相同,需要业务方配合进行大量改造,很可能因为漏改等原因,导致没有把从下游拿到的错误码传递给上游。
6.4 重试流速管控
耗时:当前调用命令耗时大于N,管控重试流速
成功率:当前调用命令成功率小于N, 管控流速
七、客户端功能
7.1 丰富的注解
参数 | 描述 | 默认值 | 必须指定 |
scene | 场景 | 无 | √ |
include | 包含的异常 | 无 | × |
exclude | 排除的异常 | 无 | × |
retryStrategy | 重试策略 | LOCAL_REMOTE | √ |
retryMethod | 重试处理入口 | RetryAnnotationMethod | √ |
idempotentId | 幂等id生成器 | SimpleIdempotentIdGenerate | √ |
retryCompleteCallback | 服务端重试完成(重试成功、重试到达最大次数)回调客户端 | SimpleRetryCompleteCallback | × |
isThrowException | 本地重试完成后是否抛出异常 | TRUE | × |
bizNo | 标识具有业务特点的值比如订单号、物流编号等,可以根据具体的业务场景生成。 | 无 | × |
localTimes | 本地重试次数 次数必须大于等于1 | 3 | √ |
localInterval | 本地重试间隔时间(s) | 2 | √ |
timeout | 同步(async:false)上报数据需要配置超时时间 | 60 * 1000 | × |
unit | 超时时间单位 | TimeUnit.MILLISECONDS | × |
forceReport | 是否强制上报数据到服务端 | FALSE | × |
async | 是否异步上报数据到服务端 | TRUE | × |
propagation | REQUIRED: 当设置为REQUIRED时,如果当前重试存在,就加入到当前重试中,即外部入口触发重试 如果当前重试不存在,就创建一个新的重试任务。 REQUIRES_NEW:当设置为REQUIRES_NEW时, 无论当前重试任务是否存在,都会一个新的重试任务。 | REQUIRED | √ |
methodResult | 当isThrowException=false时,允许用户返回一个自定义值,注意: 1. 基于异常重试时生效 2. 若自定义返回值类型与方法的返回值不同时返回NULL。 | NULL | × |
retryIfResult | 当方法正常返回结果时,允许用户针对返回结果判断是否需要开启重试。 | FALSE | × |
重试策略
SnailRetry开发了多种重试模式,以满足不同场景下的重试需求。
策略 | 释义 |
ONLY_LOCAL【本地重试】 | 在内存中执行重试动作,如果重试结束后依然异常则抛出异常或是打印错误日志 |
ONLY_REMOTE【远程重试】 | 将异常上报至服务端,由服务端管理重试的动作或是根据服务端的配置进行重试的动作 |
LOCAL_REMOTE【先本地重试,再远程重试】 | 优先在本地基于内存中完成重试动作,如果重试之后依然返回异常结果则将结果上报至服务端 |

可以看到本地重试模式中仅仅是使用内存来完成重试,不涉及到和服务端的交互。
而远程重试模式则是在遭遇异常后构建异常快照,随后进行重试数据上报,上传重试数据到服务端,服务端进行数据持久化后再由调度器提取上报的异常快照,还原重试的上下文后回调客户端进行重试。
先本地重试,再远程重试模式顾名思义则是两者的结合,优先在本地进行重试,如果本地重试后依然返回异常结果,则进行数据上报,将未完成的异常数据上报至服务端,根据服务端指定的策略进行下一步的处理方案。
回调任务
回调任务是SnailRetry引入并应用的一个功能,使客户端能够感知任务的状态和结果。这一功能通过在任务完成后触发回调,使客户端可以实时获取任务执行情况,从而增强了系统的交互性和响应能力。通过这种机制,客户端不仅可以监控任务的进展,还可以根据回调信息进行相应的处理和调整,提高了系统的灵活性水平。

八、服务端功能
数据大盘
SnailRetry数据大盘集成了场景监控、任务管理、异常处理等功能,通过一目了然的监控面板帮助运维人员实时掌握系统健康度,快速定位问题场景,实现重试治理的运维效率提升。

数据大盘(此图来自SnailJob开源版本)
重试场景
精细化配置:支持对单个重试场景进行深度定制,包括间隔时间设置、超时配置、告警阈值调整、回调机制配置等,真正实现了重试策略的动态配置和可视化配置,让复杂配置变得简单高效。
实时监控:通过成功率、运行状态等实时指标,帮助运维人员快速识别问题场景,避免重试风暴的发生。

重试场景(此图来自SnailJob开源版本)
重试记录
任务管理:平台提供完整的重试任务列表,包括任务ID、应用信息、场景名称、触发时间、重试次数、执行状态等关键指标,让运维人员实时掌握每个重试任务的执行情况,快速识别问题任务。
快速检索:支持多维度筛选条件,包括AppId、场景名称、幂等ID、重试状态、业务编号等,结合时间范围查询,帮助运维人员精准定位特定任务,提高问题排查效率。
批量操作:提供批量执行、暂停、恢复、删除等操作功能,支持对大量重试任务进行统一管理。

重试记录(此图来自SnailJob开源版本)
重试列表
每次任务调度都会产生一个日志,日志维护了状态、调度的日志等信息,帮助用户快速定位问题。通过详细的执行记录和状态追踪,平台让运维人员能够快速识别"处理失败"、"客户端执行重试失败"等异常情况,及时处理问题任务。

重试列表(此图来自SnailJob开源版本)
死信任务
重试失败的数据会迁移到死信表中,可以进行回滚保证重试数据不丢失。平台通过记录序号、组名、应用名称、场景名称、幂等ID、业务编号、创建时间、更新时间等关键信息,确保重试失败的数据得到妥善保存。

死信任务(此图来自SnailJob开源版本)
丰富的告警策略
支持自定义告警阈值,包括重试任务总数、失败次数、失败任务总数等关键指标,运维人员可以根据不同业务场景的需求,灵活调整告警策略,实现精准监控。


九、哪些场景适合使用
在某些业务场景下,需要强制保证将通知、消息等数据发送到目标端接口。由于网络的不确定性以及目标系统、应用、服务的不确定性,可能会造成通知消息的发送失败。 此类场景下可以使用 LOCAL_REMOTE 或者 ONLY_REMOTE 模式进行重试。
9.1 异步场景
在核心接口上,我们总是希望不断提高接口性能。提高接口性能的常用方式包括异步、缓存、并行等。这里我们重点讨论异步场景:

下单完成后会有一些非核心流程,主要特点是实时性要求不高、耗时较长的操作。一般会将这些流程进行异步化处理:
1)进程异步化:通过发送 MQ 消息,一旦MQ消费失败,可以使用 ONLY_REMOTE 上报服务。
MQ 的重试机制缺乏精细化的重试治理能力,可控性与可观测性较弱,不仅容易导致消息堆积、重复消费及业务不可追踪等问题,还可能因大量失败重试占用队列资源,造成 MQ 本身的性能下降甚至系统雪崩。
2)线程异步化:开启异步线程处理,但出现异常会导致数据丢失,因此需要重试保证数据一致性
可以使用 LOCAL_REMOTE 先本地重试
如果本地重试未解决,则上报服务端
代码示例
@Retryable(scene = "notifyXxx", retryStrategy = RetryType.LOCAL_REMOTE)public void notify(NotifyDTO notify) { // RPC请求}
9.2 回调场景
这里引用一个使用 SnailRetry 的真实案例:携程机车合销场景履约失败回调流程。
回调场景可以保证业务方具备任务感知能力,通过回调任务可以知道任务的执行情况,若是重试失败做一些兜底逻辑,比如发送告警、通知上游业务失败等, 保证了重试流程完整性。

用户在购买机票时可以同时下单网约车服务,机票侧会通知用车侧下单。用车侧完成下单后就会进行履约流程,若用户发生退订或者系统原因无法履约,会把用车订单进行退赔,若退赔失败会进行重试,若重试失败则需要感知重试的状态,通知机票和用车业务组进行处理。
下面是具体的回调处理逻辑:
public class RestituteCallback implements RetryCompleteCallback { /** * 重试成功后的回调函数 * 参数1-场景名称 * 参数2-执行器名称 * 参数3-入参信息 */ @Override public void doSuccessCallback(String sceneName, String executorName, Object[] objects) { // 重试成功 }
/** * 重试达到最大次数后的回调函数 * 参数1-场景名称 * 参数2-执行器名称 * 参数3-入参信息 */ @Override public void doMaxRetryCallback(String sceneName, String executorName, Object[] objects) { // 发送告警、通知上游业务失败 }}
9.3 同步阻塞场景
若在在传统重试机制下,如果直接进行重试,可能会长时间阻塞其自身资源,导致系统响应变慢,甚至引发"重试风暴"和"链路放大效应"。通过将重试责任转移给SnailRetry,避免了长时间阻塞,提升了系统整体性能和稳定性。

9.4 消息队列场景
消息队列在业务系统中承担着异步、削峰、解耦的重要角色,保障消息的可达性尤为重要。下面以携程大交通业务下单流程为例:

订单中心下单完成后会发送下单成功消息,从而解耦了订单和其他业务系统的耦合关系。其他相关的业务系统只需要监听订单的下单成功消息即可完成自己的业务逻辑。 但是,由于网络不稳定、消息队列故障等原因,可能导致消息未发送出去,这时候就需要增加重试流程来保障消息的强可达性。

接入 SnailRetry后,只需要一个简单的注解,就能保障消息的强可达性:
代码示例
@Retryable(scene = "create-order-success", retryStrategy = RetryType.ONLY_REMOTE)public void sendCreateOrderSuccessMessage(Message message) { // 发送消息 mqProducer.publish("主题", "key", message);}
十、总结
面对微服务架构中重试机制可能引发的"重试风暴"、"链路放大效应"、"蝴蝶效应"等挑战,SnailRetry管理平台通过可视化管理、灵活策略配置、实时监控告警,将重试机制从"系统杀手"转变为"稳定性守护者",真正实现了微服务重试的优雅治理。
这个平台完美支撑了SnailRetry组件的核心设计理念,自5月在携程内部上线以来,SnailRetry已应用于火车票、用车、智行机票等项目,覆盖数百个场景,调度总量已超过1000万次。
【推荐阅读】

“携程技术”公众号
分享,交流,成长
微服务重试治理实践
168万+

被折叠的 条评论
为什么被折叠?



