先讲一个在故障复盘会上被反复提起的事故链。某个工作日下午,订单库主节点因为磁盘 IO 打满开始抖动,接口平均耗时从 80ms 涨到 1.2s。按设计,这本该是一次靠主从切换就能收敛的故障。但二十分钟后,整个交易链路全部不可用,缓存层 CPU 打满,消息队列堆积了几十万条重复消息,客服系统被用户投诉打爆。
真正把一次"抖动"放大成"事故"的,不是那台主库,而是后面接连发生的二次伤害:客户端 SDK 默认重试 3 次,网关层又重试 2 次,重试请求几乎在同一秒涌入下游;一批缓存 key 的过期时间集中在同一分钟,大量请求同时回源;某个服务在降级分支里顺手跳过了鉴权校验,异常流量直接穿到了数据层。
这就是技术世界里的"趁火打劫"。成语出自《三十六计》,原意是趁着对方混乱取利。放到系统里,"火"是故障本身,"劫"是故障期间被放大的流量、被绕过的约束、被翻倍的损失。火往往只是一根导火索,真正决定损失规模的是我们在混乱期丢掉的那部分工程约束。
这篇文章不讨论历史典故,而是把"趁火打劫"当成一个故障建模的视角来看:系统起火的那段时间里,会被哪几只手趁乱取利,我们该在什么位置提前布防。读完你会拿到一套可以直接落地的防护组合——带抖动的退避重试、熔断与舱壁隔离、单飞回源、幂等落库,以及一组能跑起来的验证代码。
1. 为什么"起火"之后才是真正的考验
大部分团队对"故障"的理解停留在单点:数据库挂了、机器宕了、第三方接口超时了。于是监控告警全部围绕单点可用性建设,一旦单点恢复就认为故障结束。但真实的线上事故里,单点故障的持续时间往往只占整个事故时长的一小部分,剩下的时间都在处理"故障引发的连锁反应"。
这里有一个很容易被忽略的事实:系统的设计假设在故障期间会集体失效。限流组件假设下游能正常返回耗时数据,但下游超时后限流器自己也会排队;重试策略假设失败是低概率事件,但故障期间失败率接近 100%,重试就从"补偿"变成了"放大器";缓存假设回源是低频操作,但大批 key 同时失效时,回源会变成一次针对数据库的定向压测。
我们可以做一个粗略的量级估算,帮助理解放大效应有多强。假设入口 QPS 是 1000,客户端重试 3 次,网关重试 2 次,两层叠加后下游实际收到的请求是 1000 × 3 × 2 = 6000 次/秒。如果下游此时因为故障只能承载 800 次/秒,那么它不是"变慢",而是直接被压垮,并且因为被压垮而无法快速恢复——这就形成了"越重试越挂,越挂越重试"的死循环。
| 环节 | 设计假设 | 故障期间的实际表现 | 放大倍数(最坏估算) |
|---|---|---|---|
| 客户端 SDK 重试 | 失败是偶发事件 | 失败率接近 100%,每次都重试 | 3x |
| 网关层重试 | 后端偶发超时 | 后端全面超时 | 2x |
| 缓存回源 | 回源是低频操作 | 大批 key 同时过期 | 与并发线程数同阶 |
| 消息重投 | 消费失败是少数 | 消费端整体不可用 | 与重投次数同阶 |
这张表想说明的不是"重试不好",而是:任何带放大系数的机制,在正常时期是保护,在故障时期是攻击面。判断一个系统抗不抗揍,不看它在健康状态下跑得多漂亮,而看它在起火的那几分钟里,能不能把放大系数压回 1。
2. "趁火打劫"在系统里的四张面孔
把"趁火打劫"映射到工程语境,可以拆成四类非常具体的现象。它们的共同点是:都由故障触发,但破坏力主要来自我们自己的代码和配置,而不是故障源本身。
2.1 面孔一:重试风暴
重试是最容易被滥用的可靠性手段。很多 SDK 默认开启失败重试,很多团队在接入第三方接口时习惯性加一层 for 循环重试,理由是"多试几次总能成功"。这个逻辑在失败率 1% 的时候成立,在失败率 90% 的时候完全反过来:重试不仅不能提高成功率,还会把仅剩的那点成功率彻底吃掉。
更隐蔽的问题在时间维度上。假设 500 个客户端在同一秒失败,各自的第一次重试都安排在 1 秒后,那么第 2 秒会迎来一次 500 QPS 的尖峰;如果重试间隔又都是固定的 1 秒、2 秒、4 秒,尖峰就会以固定节奏反复出现。下游刚缓过来一口气,马上又被下一波打回去。
2.2 面孔二:缓存击穿与回源踩踏
缓存击穿指的是某个热点 key 失效的瞬间,所有请求同时穿透到后端。它和缓存雪崩的区别在于:雪崩是大批 key 同时失效,击穿是单个热点 key 失效但并发极高。两者的破坏路径一致,都是把"读缓存"变成"打数据库"。
这个问题最讽刺的地方在于,它经常由我们自己的"优化"引发。为了让缓存不过期,有人把过期时间设得很长,结果大量 key 在同一时间点集中失效;为了降低回源频率,有人加了本地缓存,结果本地缓存和分布式缓存的失效时间没有错开,形成了两级同时回源。
2.3 面孔三:降级分支绕过约束
这是四张面孔里最危险的一张,因为它丢的不是性能,而是安全边界。典型的场景是:熔断器打开后,代码走 fallback 分支,而 fallback 里为了"保证接口能返回",把鉴权、参数校验、租户隔离、审计日志统统省掉了。
故障期间恰恰是异常流量最活跃的窗口。攻击者并不需要多高深的手段,只要在你服务不稳定的时间段内高频探测,就能遇到那些"为了可用性而放宽"的分支。一次故障可能只持续十分钟,但它留下的越权窗口足够造成实质损失。
2.4 面孔四:竞态与重复提交
故障会拉长请求的响应时间,而响应时间一长,用户的重复点击、前端的自动重发、消息队列的重投就会同时发生。如果写操作没有幂等保护,同一条业务单据会被创建多次,同一笔扣款会被执行多次。
这类问题的排查成本特别高,因为它在正常压力下几乎不复现,只在故障期间以小概率出现。等到发现时,脏数据已经沉淀了几天,需要人工核对和修复。
3. 核心概念:重试放大系数、幂等边界与降级契约
在进入实操之前,先把三个关键概念讲透,后面所有代码和配置都建立在这三个概念之上。
3.1 重试放大系数
重试放大系数指的是:一次用户请求,在经过 N 层重试机制后,实际打到最底层资源的请求数。它的计算方式是各层重试次数连乘,而不是相加。
很多团队在评估容量时犯的错误,就是把重试次数当成"上限"来算,认为"最坏也就多几次"。但真实情况是连乘:3 层各重试 3 次,放大系数就是 27。这意味着你以为预留了 3 倍容量,实际需要的可能是 27 倍。
控制放大系数的办法有三个,优先级从高到低:一是减少重试层数,只在一层做重试;二是给重试加退避和抖动,把时间上的尖峰摊平;三是给重试设总预算,超过预算直接失败,不再继续。
3.2 幂等边界
幂等的意思是:同一个操作执行一次和执行多次,对系统状态的影响相同。它的关键不是"怎么实现",而是"边界划在哪里"。
一个常见的误区是把幂等做在方法入口,用请求参数做去重。这在单实例部署时没问题,但在多实例场景下就成了本地锁,挡不住跨实例的重复请求。正确做法是把幂等判断下沉到有全局唯一约束的地方,通常是数据库的唯一索引,或者带 CAS 语义的存储操作。
3.3 降级契约
降级契约是对"出问题时系统允许退到什么程度"的书面约定。它必须明确三件事:哪些能力可以退、哪些能力绝对不能退、退化的结果怎么向上游表达。
一个可用的降级契约示例是:推荐位可以退化为空列表,商品价格不能退化为 0,用户身份不能退化为游客,扣款结果不能退化为"成功"。前两项关乎体验,后两项关乎资金和安全。把这条线画清楚之后,fallback 方法里写什么就不再需要开发临时拍脑袋了。
| 概念 | 一句话解释 | 最容易踩的坑 |
|---|---|---|
| 重试放大系数 | 各层重试次数的连乘 | 按相加估算容量 |
| 幂等边界 | 判断重复的位置 | 用本地内存做去重 |
| 降级契约 | 允许退化的范围 | fallback 里丢掉鉴权 |
4. 环境准备与最小复现工程
本文的示例分两部分:Java 部分用来演示重试与熔断,Python 部分用来演示单飞回源,SQL 部分用来演示幂等落库。你可以只挑和自己技术栈匹配的部分跑,不影响理解。
环境要求如下,具体版本请以你实际项目的依赖为准,本文不锁定版本号,重点是演示通用思路。
| 组件 | 用途 | 说明 |
|---|---|---|
| JDK | 运行 Java 示例 | 建议 11 及以上,示例只用到 JDK 标准库 |
| Maven | 构建 Spring Boot 工程 | 用于演示熔断配置 |
| Spring Boot | 承载熔断与舱壁示例 | 2.x 与 3.x 均可,依赖坐标不同 |
| Resilience4j | 熔断、重试、舱壁实现 | 版本与 Spring Boot 大版本要匹配 |
| Python | 运行单飞回源示例 | 3.8 及以上,只用标准库 |
| MySQL | 验证幂等落库 | 用于演示唯一索引去重 |
| curl | 压测与验证 | 系统自带即可 |
工程目录结构建议如下,把不同语言、不同类型的示例分开放,避免混在一个目录里互相干扰:
chaohuo-demo/ ├── pom.xml ├── src/main/java/com/example/chaohuo/ │ ├── retry/ │ │ ├── BackoffRetryer.java │ │ └── RetryerDemo.java │ └── service/ │ ├── OrderQueryService.java │ └── RemoteOrderClient.java ├── src/main/resources/application.yml ├── python/ │ ├── singleflight_cache.py │ └── demo_stampede.py ├── sql/ │ └── idempotent_order.sql └── scripts/ └── load_test.sh有一点需要提前说明:Java 部分的重试工具类完全不依赖任何框架,你可以直接复制到现有项目里用;熔断部分依赖 Spring Boot 与 Resilience4j,需要能正常启动一个 Web 应用才能验证。如果你的项目是纯命令行工具,跳过第 6.2 节即可。
5. 核心流程拆解:从"起火"到"被劫"的五个环节
把一次典型的放大事故拆开看,会发现它有相当稳定的五段式结构。理解这个结构,比记住任何单个组件的用法都重要,因为你只要能在任意一个环节切断传播,事故就停在那里。
5.1 环节一:超时阈值被击穿
故障最初的信号一定是耗时上涨。此时系统还是"可用"的,只是慢。真正的问题是超时阈值设得不对:设得太长,请求在连接池里堆着,把线程和连接耗尽;设得太短,大量本可以成功的请求被判定为失败,触发重试。
这里的关键判断是:超时时间应该基于下游的 P99 耗时来定,而不是基于自己的感觉。更稳妥的做法是给整条调用链设一个总预算,每一跳的超时从总预算里扣减,保证任何一条链路的耗时都不会突破用户可接受的上限。
5.2 环节二:重试把失败放大
一旦超时开始大量出现,所有带重试的层会同时动作。这一环的布防思路是"合并重试":整条链路只在一层做重试,其余层遇到失败直接上抛。通常建议放在最靠近业务逻辑、最了解失败语义的那一层,因为只有那里能判断"这个异常到底该不该重试"。
5.3 环节三:缓存与连接池被打满
放大后的请求会同时冲击缓存和连接池。缓存这边的问题是回源踩踏,连接池那边的问题是排队。这一环的布防是"限并发":不管是单飞回源还是舱壁隔离,本质都是给下游设一个并发上限,超过上限的请求快速失败,而不是无限排队。
5.4 环节四:降级分支丢失约束
系统被打满之后,熔断器会打开,流量进入 fallback。这一环的布防是"契约前置":在写 fallback 之前就把降级契约定下来,明确哪些校验必须保留。一个实用的检查方法是,把所有 fallback 方法单独 review 一遍,逐个问"如果这个方法被高频调用,会不会造成越权或资金损失"。
5.5 环节五:重复请求造成数据不一致
最后落地的是数据层。这一环的布防是"唯一约束兜底":不依赖应用层判断,而是让数据库的唯一索引来拒绝重复写入。应用层判断的作用是减少无效请求,唯一索引的作用是保证正确性,两者不能互相替代。
6. 完整示例代码实现
下面四个示例分别对应上面的环节二、环节三、环节五,你可以按需取用。
6.1 带抖动的指数退避重试
先看一个不依赖任何框架的重试工具类。它实现了指数退避加全抖动,核心思路是让每个重试请求的等待时间随机化,从而把并发重试打散到不同时间点。
// 文件路径:src/main/java/com/example/chaohuo/retry/BackoffRetryer.java package com.example.chaohuo.retry; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Predicate; import java.util.function.Supplier; public final class BackoffRetryer { private final int maxAttempts; private final long baseDelayMillis; private final long maxDelayMillis; public BackoffRetryer(int maxAttempts, long baseDelayMillis, long maxDelayMillis) { if (maxAttempts < 1) { throw new IllegalArgumentException("maxAttempts must be >= 1"); } if (baseDelayMillis < 0 || maxDelayMillis < baseDelayMillis) { throw new IllegalArgumentException("invalid delay range"); } this.maxAttempts = maxAttempts; this.baseDelayMillis = baseDelayMillis; this.maxDelayMillis = maxDelayMillis; } /** * @param action 需要执行的操作 * @param retryable 判断异常是否值得重试 */ public <T> T execute(Supplier<T> action, Predicate<Exception> retryable) { Exception last = null; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { return action.get(); } catch (Exception e) { last = e; if (attempt == maxAttempts || !retryable.test(e)) { break; } sleepQuietly(nextDelayMillis(attempt)); } } throw new IllegalStateException("retry exhausted after " + maxAttempts + " attempts", last); } // 指数退避 + 全抖动:把并发的重试请求打散到不同时间点 long nextDelayMillis(int attempt) { long cap = Math.min(maxDelayMillis, baseDelayMillis * (1L << Math.min(attempt - 1, 20))); return ThreadLocalRandom.current().nextLong(cap + 1); } private void sleepQuietly(long millis) { try { Thread.sleep(millis); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new IllegalStateException("interrupted while backing off", ie); } } }这段代码有三个值得注意的细节。第一,retryable参数把"是否重试"的决定权交给调用方,业务拒绝类的异常不应该重试,只有超时、连接失败这类瞬时异常才重试。第二,nextDelayMillis里用了ThreadLocalRandom而不是Random,避免高并发下的随机数生成器争用。第三,抖动采用的是"全抖动",也就是在[0, cap]区间内均匀取值,这是 AWS 那篇关于指数退避与抖动的经典文章里推荐的策略之一,相比固定间隔能把尖峰压得更平。
写一个 main 方法验证它:
// 文件路径:src/main/java/com/example/chaohuo/retry/RetryerDemo.java package com.example.chaohuo.retry; import java.util.concurrent.atomic.AtomicInteger; public class RetryerDemo { public static void main(String[] args) { AtomicInteger counter = new AtomicInteger(); BackoffRetryer retryer = new BackoffRetryer(5, 100, 2000); String result = retryer.execute(() -> { int n = counter.incrementAndGet(); System.out.println("第 " + n + " 次调用, 时间戳 = " + System.currentTimeMillis()); if (n < 3) { throw new IllegalStateException("模拟下游抖动: " + n); } return "OK"; }, e -> e instanceof IllegalStateException); System.out.println("最终结果 = " + result + ", 总调用次数 = " + counter.get()); } }6.2 熔断与舱壁的配置
如果项目是 Spring Boot,推荐直接用 Resilience4j 的声明式配置。先加依赖,注意 Spring Boot 2.x 用resilience4j-spring-boot2,3.x 用resilience4j-spring-boot3,坐标写错会导致注解完全不生效。
<!-- 文件路径:pom.xml(节选) --> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-boot3</artifactId> <version>${resilience4j.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>spring-boot-starter-aop是必须的,Resilience4j 的注解是通过 AOP 织入的,缺了它注解不会报错,但也不会有任何效果——这是排查"熔断怎么不生效"时最常见的第一个原因。
接着写配置:
# 文件路径:src/main/resources/application.yml resilience4j: circuitbreaker: instances: orderQuery: slidingWindowType: COUNT_BASED slidingWindowSize: 20 minimumNumberOfCalls: 10 failureRateThreshold: 50 slowCallDurationThreshold: 800ms slowCallRateThreshold: 80 waitDurationInOpenState: 15s permittedNumberOfCallsInHalfOpenState: 5 registerHealthIndicator: true retry: instances: orderQuery: maxAttempts: 3 waitDuration: 300ms enableExponentialBackoff: true exponentialBackoffMultiplier: 2 retryExceptions: - java.io.IOException - java.util.concurrent.TimeoutException ignoreExceptions: - com.example.chaohuo.exception.BusinessRejectException bulkhead: instances: orderQuery: maxConcurrentCalls: 20 maxWaitDuration: 10ms几个参数的意思解释一下。minimumNumberOfCalls: 10表示至少要统计 10 次调用才可能触发熔断,避免启动初期几个失败就把熔断器打开。slowCallDurationThreshold把"慢"也计入失败,这一点在故障场景下特别重要,因为很多故障的表现不是报错而是变慢。maxWaitDuration: 10ms是舱壁的排队上限,超过就直接拒绝,宁可快速失败也不让请求在队列里堆积。
ignoreExceptions的配置很容易漏。业务异常(比如"用户不存在")不该被算作熔断依据,否则正常业务失败率一高就会误熔断。
业务代码里这样用:
// 文件路径:src/main/java/com/example/chaohuo/service/OrderQueryService.java package com.example.chaohuo.service; import com.example.chaohuo.exception.BusinessRejectException; import io.github.resilience4j.bulkhead.annotation.Bulkhead; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import io.github.resilience4j.retry.annotation.Retry; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.List; @Service public class OrderQueryService { private final RemoteOrderClient remoteOrderClient; public OrderQueryService(RemoteOrderClient remoteOrderClient) { this.remoteOrderClient = remoteOrderClient; } @Retry(name = "orderQuery", fallbackMethod = "fallback") @CircuitBreaker(name = "orderQuery") @Bulkhead(name = "orderQuery") public List<String> queryByUser(String userId) { return remoteOrderClient.listByUser(userId); } // 降级方法签名需与原方法一致,最后一个参数接收异常 private List<String> fallback(String userId, Throwable t) { // 业务拒绝异常必须原样抛出,不能被降级吞掉 if (t instanceof BusinessRejectException) { throw (BusinessRejectException) t; } // 推荐位类数据允许退化为空,不影响主流程 return Collections.emptyList(); } }这里要强调一个容易踩的坑:多个注解叠加时,切面的执行顺序会直接影响熔断器的统计口径。如果重试在熔断器外层,那么每次重试都会被熔断器计为一次独立调用;如果重试在内层,熔断器只统计一次。Resilience4j 提供了调整切面顺序的配置项,但默认值在不同版本间存在差异,不要凭记忆推断。稳妥的做法是写一个稳定抛异常的方法,跑 20 次,看熔断器记录的调用次数,用实测结果确认顺序。
6.3 单飞回源防止缓存击穿
Python 这边演示一个更常见的场景:热点 key 失效瞬间,100 个并发请求同时回源。单飞(singleflight)的思路是,同一个 key 在同一时刻只允许一个线程真正去加载,其余线程等待并复用结果。
# 文件路径:python/singleflight_cache.py # -*- coding: utf-8 -*- import threading from typing import Any, Callable, Dict, Optional class _Call(object): def __init__(self) -> None: self.done = threading.Event() self.value: Any = None self.error: Optional[BaseException] = None class SingleFlightCache(object): """同一 key 在同一时刻只允许一个线程回源,其余线程等待复用结果。""" def __init__(self) -> None: self._mu = threading.Lock() self._calls: Dict[str, _Call] = {} def get_or_load(self, key: str, loader: Callable[[], Any]) -> Any: with self._mu: call = self._calls.get(key) is_leader = call is None if is_leader: call = _Call() self._calls[key] = call if not is_leader: call.done.wait() if call.error is not None: raise call.error return call.value try: call.value = loader() return call.value except BaseException as exc: call.error = exc raise finally: call.done.set() with self._mu: self._calls.pop(key, None)这段代码的关键在finally里:无论加载成功还是失败,都要先done.set()唤醒等待者,再把 key 从字典里移除。如果没有这一步,等待者会永远阻塞;如果两个步骤顺序颠倒,会出现新的请求进入时读到旧 call 的竞态。
然后写一个对比脚本,量化效果:
# 文件路径:python/demo_stampede.py # -*- coding: utf-8 -*- import threading import time from typing import List from singleflight_cache import SingleFlightCache class SlowDataSource(object): """模拟一次耗时 200ms 的回源查询,并记录回源次数。""" def __init__(self) -> None: self.hits = 0 self._lock = threading.Lock() def load(self, key: str) -> str: with self._lock: self.hits += 1 time.sleep(0.2) return "value-of-" + key def _worker(loader, key, results: List[str], barrier): barrier.wait() # 让所有线程尽可能同时发起请求 results.append(loader(key)) def run(loader, key: str, thread_count: int) -> int: results: List[str] = [] barrier = threading.Barrier(thread_count) threads = [ threading.Thread(target=_worker, args=(loader, key, results, barrier)) for _ in range(thread_count) ] for t in threads: t.start() for t in threads: t.join() assert len(results) == thread_count, "有线程未拿到结果" assert len(set(results)) == 1, "不同线程拿到了不一致的结果" return len(results) if __name__ == "__main__": threads = 100 key = "user:1001:profile" plain = SlowDataSource() run(plain.load, key, threads) print("未加保护: 回源次数 = %d" % plain.hits) protected = SlowDataSource() cache = SingleFlightCache() run(lambda k: cache.get_or_load(k, lambda: protected.load(k)), key, threads) print("单飞保护: 回源次数 = %d" % protected.hits)这里必须提前说明一个预期偏差:单飞保护下的回源次数通常不会严格等于 1,而是在 1 到 3 之间浮动。原因在于单飞只合并"同一时刻重叠"的请求,第一批线程完成后,如果还有后续线程刚好在 key 被移除之后进入,它就会成为新的 leader 再回源一次。这不是 bug,而是单飞的正确语义。真正的防护还要靠缓存本身有值可用,单飞只负责兜住"失效瞬间"那一下。
6.4 幂等落库的唯一索引兜底
最后是数据层。不管前面做了多少防护,最终都要有一个不依赖应用逻辑的正确性保证。
-- 文件路径:sql/idempotent_order.sql CREATE TABLE `t_order_dedup` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号,由上游生成', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-处理中 1-成功 2-失败', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz_no` (`biz_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幂等去重表'; -- 重复请求:靠唯一索引兜底,不会产生第二行 INSERT INTO `t_order_dedup` (`biz_no`, `status`) VALUES ('ORDER202405200001', 0) ON DUPLICATE KEY UPDATE `status` = `status`;这里有一个选择需要说明。很多人喜欢用INSERT IGNORE来实现幂等,代码更短,但它的语义是"忽略一切错误",包括字段长度超限、非空约束失败、数据类型不匹配。用IGNORE等于把所有数据问题都静默吞掉,等到发现时数据已经脏了一大片。ON DUPLICATE KEY UPDATE只处理唯一键冲突,其他错误照常抛出,是更稳妥的选择。
6.5 并发验证脚本
验证防护是否生效,最简单的方式是并发打请求,然后看返回码分布和下游调用次数。
#!/usr/bin/env bash # 文件路径:scripts/load_test.sh set -u TARGET="http://127.0.0.1:8080/api/order/query?userId=u1001" CONCURRENCY=200 rm -f /tmp/chaohuo_codes.txt for i in $(seq 1 "${CONCURRENCY}"); do curl -s -o /dev/null -w "%{http_code}\n" \ -H "X-Request-Id: req-${i}" \ "${TARGET}" >> /tmp/chaohuo_codes.txt & done wait echo "--- 返回码分布 ---" sort /tmp/chaohuo_codes.txt | uniq -c | sort -rn脚本本身很简单,价值在于它把"感觉接口挺稳"变成了可量化的分布。正常状态下应该是绝大多数 200;熔断或限流生效时会出现 429 或 503;如果出现大量 500,说明防护没起到作用,请求已经打穿到业务逻辑了。
7. 运行结果与效果验证
Java 重试部分的运行命令:
mvn -q compile exec:java -Dexec.mainClass=com.example.chaohuo.retry.RetryerDemo预期输出类似下面的结构,重点是三次调用的时间戳间隔在递增,并且每次运行的间隔数值都不一样:
第 1 次调用, 时间戳 = 1716200000123 第 2 次调用, 时间戳 = 1716200000198 第 3 次调用, 时间戳 = 1716200000456 最终结果 = OK, 总调用次数 = 3如果你连续跑十次,发现第二次调用的间隔永远是同一个数字,那说明抖动没有生效,检查一下是不是用了固定间隔的sleep而不是nextDelayMillis。
Python 部分的运行命令:
cd python python demo_stampede.py预期输出:
未加保护: 回源次数 = 100 单飞保护: 回源次数 = 1实际数字会在 1 到 3 之间浮动,未加保护的那一行则稳定接近 100。判断成功的标准不是"单飞那行必须是 1",而是"单飞那行显著小于未加保护那行",通常是一个数量级以上的差距。如果两行数字差不多,说明等待逻辑没有生效,重点检查done.wait()是否真的被调用到了。
熔断部分的验证要稍微绕一点。先用正常流量确认接口能通,然后人为制造下游故障(比如把远程调用指向一个会超时的地址),连续请求 20 次以上,观察熔断器状态。判断熔断打开的标志是:后续请求不再尝试远程调用,而是直接走 fallback 并快速返回,同时健康检查端点里的熔断器状态从CLOSED变为OPEN。
如果验证失败,第一步不要急着改配置,先按这个顺序看:注解所在的方法是不是被同类内部调用(Spring AOP 对this.调用不生效);spring-boot-starter-aop有没有引入;application.yml里的实例名和注解里的name是否一致。这三个原因合起来能覆盖绝大部分"配置了但没生效"的情况。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 熔断注解完全不生效 | 缺少 AOP 依赖或同类内部调用 | 查看依赖树,确认调用方是否为代理对象 | 补spring-boot-starter-aop,改为跨 Bean 调用 |
| 重试次数超过配置值 | 多层重试叠加 | 打印每层调用栈,统计入参层的调用次数 | 只保留一层重试,其余层直接上抛 |
| 熔断打开过于频繁 | 业务异常被计入失败率 | 看熔断失败统计里是否包含业务异常 | 配置ignoreExceptions |
| 故障恢复后流量再次压垮下游 | 没有预热,请求瞬间打满 | 观察恢复后前 30 秒的 QPS 曲线 | 半开状态放少量请求,逐步放量 |
| 单飞场景下等待线程超时 | 加载失败后未唤醒等待者 | 检查finally块是否覆盖异常路径 | 保证done.set()一定执行 |
| 幂等去重没有生效 | 唯一索引建在非业务字段上 | 执行SHOW INDEX FROM 表名 | 索引建在业务单号上 |
| 压测时返回大量 500 | 防护未生效,请求打穿到业务层 | 看服务端异常日志的首条堆栈 | 先补舱壁限并发,再排查下游 |
| 降级后出现越权访问 | fallback 丢失了鉴权校验 | 逐个 review fallback 方法 | 明确降级契约,鉴权不允许退化 |
这张表里最值得单独拿出来说的是最后一行。其他问题影响的是性能和数据,最后一行影响的是安全边界。建议把 fallback 方法当成敏感代码来管理,和鉴权、计价、状态机这些模块走同一套 review 流程,而不是随手写在业务类的底部。
9. 最佳实践与工程建议
9.1 把超时预算当成架构约束
不要给每个下游单独设超时,而是先定一个用户可接受的总时长,再把预算往下拆。比如总预算 800ms,那么主流程 300ms、次流程 200ms、兜底流程 100ms,剩下的留给网络抖动。这样做的最大好处是,任何一个下游变慢都不会单方面拖垮整条链路,因为它最多只能消耗自己那份预算。
9.2 重试的三条硬规则
第一条,只在一层重试,其他层遇到失败直接上抛。第二条,只重试瞬时异常,业务拒绝类异常绝不重试。第三条,重试必须带退避和抖动,固定间隔等于定时压测。这三条规则写进团队的接入规范,比事后复盘有用得多。
9.3 降级契约要提前写
在写第一行业务代码之前,就把降级契约写下来:哪些字段允许为空,哪些字段允许用默认值,哪些操作允许返回"稍后重试"。这份文档的意义在于,故障发生时开发不需要临场决策,照着契约实现就行。契约里必须明确列出"不可降级清单",通常包括身份鉴权、资金计算、库存扣减、审计日志。
9.4 让防护措施可观测
熔断器的状态变化、限流的触发次数、重试的发生次数,都应该有独立的指标暴露出来。很多团队只在故障后才去翻日志,结果发现根本没有记录这些数据。一个实用做法是把熔断器状态接入健康检查端点,让监控系统能直接采集到OPEN、HALF_OPEN、CLOSED的切换事件,并在切换时打一条结构化日志。
9.5 用故障演练验证,而不是靠推演
所有的推演都可能有盲区。更可靠的方式是定期做故障演练:人为让一个下游超时,观察系统是否能按预期降级;人为制造缓存集中失效,观察回源是否被单飞挡住。演练要在测试环境做,要有明确的回滚预案,要有专人观察核心指标。演练的价值不在于证明系统有多稳,而在于提前发现那些"以为配了其实没生效"的配置。
回到开头那个事故链。如果再给它一次机会,正确的布防顺序应该是:先用超时预算把故障限制在单个调用内,再用单层重试避免放大,再用舱壁和单飞把并发压住,最后用唯一索引守住数据正确性。这四步不需要引入任何复杂中间件,全部可以在业务代码和配置里完成。真正难的从来不是技术方案,而是在系统起火的那几分钟里,我们还有没有勇气坚持那些被写进规范的约束。