微服务中的超时、重试与熔断:别让一次慢调用拖垮整个系统
在单体应用中一个方法调用失败影响范围通常比较明确。但在微服务系统中一次用户请求可能经过多个服务客户端 ↓ API Gateway ↓ 订单服务 ↓ 用户服务 ↓ 库存服务 ↓ 支付服务 ↓ 第三方接口只要调用链中的某个环节变慢延迟就可能沿着链路向上传递。如果系统没有设置合理的超时、重试和熔断机制一个原本局部的故障很容易演变成线程池耗尽 连接池打满 请求大量堆积 服务之间相互重试 网关持续返回 502 或 504 整个系统出现级联故障因此微服务稳定性设计的重点并不是保证所有请求永远成功而是当部分服务变慢或不可用时限制故障影响范围让系统以可预期的方式退化。一、为什么必须设置超时很多 HTTP、RPC 和数据库客户端都有默认超时时间。但“有默认值”不等于“默认值合理”。如果一个下游接口没有设置超时或者超时时间过长上游线程会一直等待。假设订单服务有 200 个工作线程每个请求都要调用库存服务。当库存服务响应变慢后第 1 批请求占用 200 个线程 新的请求进入线程池队列 队列逐渐被填满 后续请求开始被拒绝 订单服务失去响应能力此时订单服务本身可能没有任何代码错误但它已经被下游服务拖垮。因此所有跨网络调用都应该明确设置超时。常见超时包括超时类型说明连接超时与目标服务建立连接的最长等待时间读取超时建立连接后等待响应数据的时间写入超时向服务端发送数据的最长时间总请求超时整个调用允许占用的最长时间业务超时某项业务任务允许执行的总时间二、连接超时和读取超时有什么区别以调用 HTTP 接口为例。连接超时表示客户端在规定时间内能否和服务端建立 TCP 连接读取超时表示连接成功后客户端愿意等待服务端返回数据多久例如使用 OkHttpOkHttpClient client new OkHttpClient.Builder() .connectTimeout(Duration.ofSeconds(2)) .readTimeout(Duration.ofSeconds(5)) .writeTimeout(Duration.ofSeconds(5)) .callTimeout(Duration.ofSeconds(8)) .build();这里的配置表示建立连接最多等待 2 秒 读取响应最多等待 5 秒 发送数据最多等待 5 秒 完整请求最多执行 8 秒如果只设置读取超时没有设置总请求超时那么 DNS 查询、连接、重定向和重试等过程累计后实际耗时仍然可能超出预期。三、超时时间应该怎么设置超时时间不能简单地统一设置成 30 秒。不同接口的业务特性不同。例如读取用户缓存通常应该在几十毫秒内完成 数据库查询可能允许几百毫秒 创建订单可能允许 12 秒 大文件导出应该改成异步任务 AI 总结任务不适合占用同步 HTTP 请求长期等待比较合理的设置方式是参考接口历史延迟。例如某接口监控数据为P5080ms P95300ms P99700ms可以考虑将超时时间设置在略高于 P99 的位置例如 1 秒。如果直接设置为 10 秒接口即使已经明显异常上游仍然会等待很久。需要注意超时时间越长不代表系统越稳定只代表故障暴露得越慢。四、调用链中的超时要逐级递减假设一个请求依次经过网关 → 服务 A → 服务 B → 服务 C不合理的配置可能是网关超时3 秒 服务 A 调用 B5 秒 服务 B 调用 C10 秒这样服务 A 还在等待 B 时网关可能已经向客户端返回超时。后端仍然继续处理最终形成大量“客户端已经放弃、服务端还在运行”的无效任务。更合理的方式是让外层超时大于内层超时网关总超时5 秒 服务 A 调用 B3 秒 服务 B 调用 C1 秒同时还要预留网络传输时间 序列化时间 业务逻辑执行时间 异常处理时间这类设计通常被称为超时预算。五、请求超时后任务真的停止了吗这是很多开发者容易忽略的问题。客户端出现读取超时只代表客户端不再等待结果它不一定意味着服务端的任务已经停止。例如服务端正在执行public void generateReport() { queryLargeData(); buildExcel(); uploadFile(); }客户端在 5 秒后超时但服务端可能还会继续执行数分钟。如果客户端随后重试就可能同时启动多个相同任务。因此对于耗时任务应该考虑异步任务 任务唯一编号 幂等处理 任务状态查询 主动取消机制而不是简单增加 HTTP 超时时间。六、哪些请求适合重试重试可以提高临时故障下的成功率但不是所有失败都应该重试。适合重试的情况通常包括短暂网络抖动 服务实例切换 偶发连接失败 下游返回 502、503 或 504 连接被意外重置不适合重试的情况包括请求参数错误 权限验证失败 资源不存在 余额不足 业务规则不允许 接口明确返回 400、401、403如果一个请求本身就是错误的重试多少次都不会成功。七、写操作不能盲目重试查询接口通常具有幂等性GET /api/order/10001重复调用通常不会改变系统状态。但创建、扣款、发消息等操作不一定幂等POST /api/order/create POST /api/payment/pay POST /api/message/send假设客户端发送创建订单请求后发生超时。此时可能存在两种情况情况一服务端没有收到请求 情况二服务端已经创建成功但响应没有返回客户端无法仅凭“超时”判断具体是哪种情况。如果直接重试可能生成两笔订单。因此写操作要重试必须配合业务唯一编号 Idempotency-Key 数据库唯一索引 状态机 去重记录例如POST /api/order/create Idempotency-Key: 89aa9650-cf68-4bb7-bc17-0e0a79731d32服务端使用该 Key 判断请求是否已经处理过。八、为什么固定间隔重试容易引发重试风暴最简单的重试写法是for (int i 0; i 3; i) { try { return callRemoteService(); } catch (Exception ignored) { Thread.sleep(1000); } }这段代码的问题是所有请求都按照相同时间间隔重试。假设下游服务发生故障同时有 1 万个请求失败第 0 秒1 万个请求失败 第 1 秒1 万个请求同时重试 第 2 秒再次同时重试下游服务即使开始恢复也可能立刻被重试流量再次打垮。这就是重试风暴。九、指数退避与随机抖动更合理的重试方式是指数退避。例如第 1 次重试等待 500ms 第 2 次重试等待 1s 第 3 次重试等待 2s 第 4 次重试等待 4s计算方式可以表示为delay baseDelay × 2^retryCountJava 示例public long calculateDelay(int retryCount) { long baseDelay 500L; long maxDelay 10_000L; return Math.min( baseDelay * (1L retryCount), maxDelay ); }仅使用指数退避还不够。如果大量客户端同时失败它们仍可能在相同时间再次重试。因此还应该加入随机抖动public long calculateDelayWithJitter(int retryCount) { long delay calculateDelay(retryCount); long jitter ThreadLocalRandom.current().nextLong(0, 500); return delay jitter; }这样可以将重试请求分散到不同时间点。十、重试应该在哪一层完成一次请求可能经过多个组件客户端 API 网关 服务 A 服务 B 数据库驱动如果每一层都配置 3 次重试最终调用次数并不是 3 次。可能变成客户端重试 3 次 × 网关重试 3 次 × 服务 A 重试 3 次 最多 27 次下游调用如果服务 B 自己又有重试调用次数还会继续扩大。因此重试策略必须统一设计。通常建议选择最了解业务结果的一层执行重试 避免多个调用层重复重试 明确最大重试次数 记录实际重试次数对于用户端请求可以让服务端统一处理重试。对于服务内部调用可以在调用方设置有限重试但不要同时让网关和 SDK 重复重试。十一、什么是熔断如果某个下游服务持续失败继续请求它已经没有意义。这时可以使用熔断器。熔断器的思路类似电路保险丝当失败率达到一定阈值后 暂时停止向故障服务发送请求 等待一段时间后尝试恢复典型熔断器包含三种状态。1. Closed关闭状态这是正常状态。请求会继续访问下游服务熔断器统计请求总数 失败次数 超时次数 慢调用数量当失败率或慢调用比例超过阈值后进入 Open 状态。2. Open打开状态进入 Open 状态后请求不再真正调用下游服务。系统会快速失败或执行降级逻辑。这样可以避免持续占用线程 继续消耗连接 向故障服务施加额外压力3. Half-Open半开状态等待一段时间后熔断器允许少量试探请求进入。如果试探请求成功说明服务可能已经恢复熔断器重新进入 Closed。如果仍然失败则再次回到 Open。十二、使用 Resilience4j 配置熔断器在 Java 微服务中可以使用 Resilience4j。示例配置resilience4j: circuitbreaker: instances: inventoryService: sliding-window-type: COUNT_BASED sliding-window-size: 20 minimum-number-of-calls: 10 failure-rate-threshold: 50 slow-call-rate-threshold: 50 slow-call-duration-threshold: 1s wait-duration-in-open-state: 10s permitted-number-of-calls-in-half-open-state: 3主要参数含义如下参数说明sliding-window-size统计窗口大小minimum-number-of-calls开始计算失败率所需的最少请求数failure-rate-threshold触发熔断的失败率slow-call-rate-threshold慢调用比例阈值slow-call-duration-threshold超过该时间视为慢调用wait-duration-in-open-state熔断后等待多久进入半开状态permitted-number-of-calls-in-half-open-state半开状态允许的试探请求数业务代码CircuitBreaker( name inventoryService, fallbackMethod inventoryFallback ) public InventoryResult queryInventory(Long productId) { return inventoryClient.query(productId); } public InventoryResult inventoryFallback( Long productId, Throwable throwable ) { return InventoryResult.unavailable(productId); }十三、降级不是统一返回“系统繁忙”熔断后通常要执行降级逻辑。但降级不应该只是所有接口统一返回{ message: 系统繁忙请稍后重试 }不同业务应该采用不同策略。例如业务降级方案商品推荐返回热门商品用户头像返回默认头像库存查询显示暂时无法确认数据统计返回最近一次缓存结果会议总结创建异步任务稍后生成非核心通知暂停发送并进入队列支付明确失败不能伪造成功降级的核心是非核心功能可以牺牲 核心数据不能伪造 高风险操作不能模糊处理十四、超时、重试与熔断如何配合三者不是相互替代而是处理不同问题。超时解决一次请求最多等待多久重试解决遇到临时故障后是否再次尝试熔断解决下游持续故障时是否还要继续调用一个常见调用顺序可以是请求进入 ↓ 熔断器判断是否允许调用 ↓ 设置单次请求超时 ↓ 调用失败 ↓ 判断错误是否可重试 ↓ 指数退避后有限重试 ↓ 多次失败计入熔断统计 ↓ 触发熔断并执行降级十五、实时语音与 AI 服务中的稳定性设计实时语音、翻译和 AI 服务通常会调用多个模型或外部能力音频采集 ↓ 语音识别 ↓ 文本翻译 ↓ 语音合成 ↓ 字幕展示 ↓ 会议总结像同言翻译Transync AI这类实时翻译产品工程链路中可能同时涉及实时字幕、语音翻译和会后内容整理。这类连续流业务如果简单套用普通 HTTP 重试可能产生新的问题音频片段被重复识别 字幕被重复写入 翻译结果顺序错乱 旧片段在重连后集中返回 同一场会议生成多份总结因此实时任务除了设置超时和熔断还需要设计meetingId标识一场会议 segmentId标识一个音频或字幕片段 sequence标识片段顺序 final区分临时结果和最终结果 requestId用于任务去重例如{ meetingId: meeting_10001, segmentId: segment_203, sequence: 203, sourceLanguage: en, targetLanguage: zh, final: true }即使某个模型调用发生重试也能根据segmentId避免重复写入。十六、不要把所有异常都计入熔断熔断器应该统计真正代表下游故障的异常。例如连接超时 读取超时 下游返回 500 连接被重置 服务不可用但下面这些错误不一定代表下游服务异常用户参数错误 权限不足 库存不足 业务状态不允许 数据不存在如果把所有异常都计入失败率可能因为大量正常业务拒绝而错误触发熔断。因此应该明确异常分类。例如recordExceptions: - java.net.ConnectException - java.net.SocketTimeoutException ignoreExceptions: - com.example.BizException - com.example.ValidationException十七、需要监控哪些指标没有监控超时、重试和熔断参数就只能靠猜。建议监控接口请求量 成功率 超时率 平均响应时间 P95、P99 响应时间 每次请求的重试次数 重试成功率 熔断器当前状态 熔断触发次数 半开状态恢复率 降级调用次数 线程池活跃线程数 连接池等待数量其中尤其要关注重试后的总请求量业务流量没有明显增长但下游 QPS 突然翻倍往往意味着大量内部重试正在发生。十八、常见错误设计1. 所有接口统一设置 30 秒超时不同接口的正常耗时差异很大统一配置很难合理。2. 超时后立即重试如果下游已经过载立即重试只会增加压力。3. 所有异常都重试参数错误、权限错误和业务错误不应该重试。4. 写接口没有幂等设计重试可能造成重复订单、重复扣款或重复任务。5. 每一层都配置重试多层重试会指数级放大请求量。6. 熔断后仍然执行高成本降级逻辑降级逻辑本身必须简单、快速、稳定。7. 只按失败率熔断不监控慢调用下游没有报错但响应极慢同样可能拖垮上游。十九、稳定性设计检查清单开发微服务调用时可以检查1. 是否明确设置连接超时和读取超时 2. 是否设置完整请求的总超时 3. 上下游超时预算是否合理 4. 超时后服务端任务是否仍在执行 5. 哪些异常允许重试 6. 写操作是否具备幂等性 7. 重试次数是否有限 8. 是否使用指数退避和随机抖动 9. 是否存在多层重复重试 10. 是否配置熔断器 11. 熔断是否统计慢调用 12. 哪些异常应该忽略 13. 熔断后是否有合理降级方案 14. 是否监控重试次数和熔断状态 15. 实时任务是否有片段去重和顺序控制总结超时、重试和熔断是微服务稳定性设计中的三项基础能力。它们分别控制超时一次调用等待多久 重试失败后是否再次尝试 熔断持续失败后是否停止调用合理的策略应该是设置明确的超时预算 只对可恢复错误进行有限重试 使用指数退避与随机抖动 为写操作设计幂等机制 下游持续故障时及时熔断 熔断后执行低成本降级 通过监控持续调整参数真正危险的通常不是某一次请求失败而是系统在失败后采取了错误的动作。一次超时并不可怕。可怕的是所有请求同时重试、所有线程继续等待、所有服务相互放大故障。稳定性设计的核心就是让系统在异常发生后能够及时停止无效工作把有限资源留给仍然可以正常完成的请求。