第一次混沌演练把生产打挂 23 分钟:稳态定错、半径失控,4 个换来的规矩
title: 第一次混沌演练把生产打挂 23 分钟:稳态定错、半径失控,4 个换来的规矩
tags: [混沌工程, 故障注入, ChaosBlade, Chaos Mesh, 稳定性]
category: 后端
那天的演练计划写得很漂亮:给订单服务调用库存服务的链路注入 500ms 延迟,观察熔断是否按预期触发,预计影响面 5% 流量,持续 3 分钟。
实际结果是,注入开始后第 40 秒,订单服务的 Tomcat 线程池打满,第 70 秒网关开始返回 504,第 110 秒连不在演练范围内的用户中心也开始超时。我们在第 3 分钟按计划停止注入,但系统没有自己恢复——数据库连接池里堆积的等待线程、MQ 里积压的重试消息、Redis 里被写坏的降级标记,三样东西互相拖着,直到 23 分钟后手动清理才彻底恢复。
复盘会上有人说"这不就是演练该发现的问题吗"。话是没错,但代价是真实的:那 23 分钟里下单成功率从 99.6% 掉到 71%,客诉 380 多条。混沌工程的价值在于用可控的代价换取认知,一旦代价失控,它就变成了一次自制的故障。
这篇写的是我们后来把演练流程重建了一遍的过程,以及四条现在写进规范里的硬规矩。
教训一:稳态指标定成了"服务是否存活",而不是"业务是否正常"
原始方案里,我们定义的稳态是"订单服务 HTTP 健康检查返回 200"。这个指标在整个事故过程中一直是绿的——因为健康检查端点不查数据库、不调下游,它只证明进程还活着。
真正该看的是业务指标。我们后来把稳态假设改成了四条,全部要有明确的数值和采样窗口:
- 下单接口成功率 ≥ 99.0%(1 分钟滚动窗口)
- 下单接口 P99 ≤ 800ms(1 分钟滚动窗口)
- 支付回调处理延迟 ≤ 5 秒(队列积压量 < 200)
- 订单表写入 TPS 偏离基线不超过 ±30%
这四条里任何一条连续 30 秒不满足,演练必须立即中止。这个"自动熔断"是代码写死的,不依赖值班同学的判断——事故当天我们其实在第 70 秒就有人喊停了,但从决策到真正执行停止注入的命令,中间过了 100 多秒。
/** * 演练守护器:独立进程运行,与被注入的服务不在同一台机器上。 * 这一点很重要——如果守护器和被测服务同生共死,故障一来它自己也挂了, * 就没人执行中止动作了。我们第一版就是这么写的。 */ @Component public class ChaosGuardian { private final MetricsQueryClient metrics; private final ChaosController chaos; // 连续违反次数计数,单次抖动不算,连续 3 次(每次 10 秒采样)才中止 private final Map<String, AtomicInteger> violationCount = new ConcurrentHashMap<>(); @Scheduled(fixedRate = 10_000) public void check() { ChaosExperiment running = chaos.getRunningExperiment(); if (running == null) { violationCount.clear(); return; } for (SteadyStateHypothesis h : running.getHypotheses()) { double actual = metrics.query(h.getPromql(), Duration.ofMinutes(1)); boolean ok = h.getComparator().test(actual, h.getThreshold()); AtomicInteger cnt = violationCount.computeIfAbsent( h.getName(), k -> new AtomicInteger()); if (ok) { cnt.set(0); continue; } int times = cnt.incrementAndGet(); log.warn("steady state violated: {} actual={} threshold={} times={}", h.getName(), actual, h.getThreshold(), times); if (times >= 3) { // 中止动作必须幂等且尽最大努力: // 先停注入,再触发回滚剧本,最后才通知人 chaos.abort(running.getId(), "steady state violated: " + h.getName()); rollbackPlaybook.execute(running); alerting.pageOnCall(running, h, actual); return; } } } }metrics.query走的是 Prometheus 的 range query,PromQL 长这样:
sum(rate(http_server_requests_seconds_count{uri="/api/order/create",status=~"2.."}[1m])) / sum(rate(http_server_requests_seconds_count{uri="/api/order/create"}[1m]))有个细节:这个查询要排除演练自己造的流量。我们用一个特殊的 headerX-Chaos-Traffic: 1标记演练流量,在指标上打 label 区分,否则演练流量本身会污染稳态判断。
教训二:爆炸半径控制在"流量比例"上,是不够的
方案里写的是"影响 5% 流量",实现方式是在注入规则里加一个 percent=5。听起来很安全,实际上完全没起到隔离作用。
原因是资源是共享的。订单服务那 8 个实例,每个实例的 Tomcat 线程池是 200,数据库连接池是 50。5% 的请求被延迟 500ms,意味着这 5% 的请求会长时间占住线程和连接。当 QPS 是 3000 时,5% 就是 150 QPS,每个请求多占 500ms,稳态下就是 75 个线程被长期占用。这 75 个线程是从 200 里扣的,剩下的 125 个要扛 95% 的正常流量——线程池打满只是时间问题。
真正有效的爆炸半径控制是资源维度的隔离,不是流量比例。我们后来改了三处:
第一,演练只在专门的实例组上做。用 K8s 的 label 把 8 个实例分成chaos-target(1 个)和normal(7 个),注入规则只匹配chaos-target,网关按 header 把演练流量路由过去。
# Chaos Mesh 的 NetworkChaos,只作用于打了 chaos-target 标签的 Pod apiVersion: chaos-mesh.org/v1alpha1 kind: NetworkChaos metadata: name: order-to-inventory-delay namespace: prod spec: action: delay mode: all selector: namespaces: [prod] labelSelectors: app: order-service chaos-group: chaos-target # 关键:只选中被隔离出来的那 1 个实例 direction: to target: mode: all selector: namespaces: [prod] labelSelectors: app: inventory-service delay: latency: "500ms" correlation: "0" jitter: "50ms" duration: "3m"第二,给演练实例单独收紧线程池和超时。演练实例的 Tomcat 线程池调到 50,Feign 调用超时从 3 秒改到 1 秒。这样即使被打满,影响也被限制在这一个实例内,网关的健康检查会把它摘掉。
第三,注入前先做"预演"。在预发环境跑一遍完全相同的实验,把稳态指标的基线和阈值校准好再上生产。这一步我们最初跳过了,理由是"预发流量太小不真实"。现在的做法是承认预发不真实,但它的目的不是验证系统韧性,而是验证演练脚本本身没写错——事故当天我们的回滚脚本里有个 kubectl 命名空间参数写错了,执行时报错,这本来在预发就能发现。
教训三:只注入故障,没准备恢复剧本
停止注入不等于系统恢复。事故当天注入停了之后,还有三样东西在自我维持:
Tomcat 线程池里的堆积请求。这些请求已经进来了,还在等下游返回。虽然下游延迟消失了,但队列里排了 4000 多个请求,按正常处理速度也要几分钟才能消化,而这期间新请求还在进来。
MQ 的重试消息。订单创建失败后会发一条补偿消息,RocketMQ 默认 16 次重试,间隔从 1 秒递增到 2 小时。事故期间积压了 11 万条重试消息,恢复后这些消息集体重投,又把库存服务打了一波。
Redis 里的降级标记。我们的降级开关是"连续失败 N 次就打开,TTL 10 分钟"。注入停止后,这个标记还有 8 分钟才过期,期间所有下单都走降级逻辑(跳过库存校验直接下单),导致超卖 47 单。
后来我们要求每个实验必须配套一份回滚剧本,且剧本本身要在预发验证过:
public class RollbackPlaybook { public void execute(ChaosExperiment exp) { // 顺序很重要:先切流,再清状态,最后放开 List<RollbackStep> steps = List.of( // 1. 把演练实例从网关摘掉,停止新流量进入 new RollbackStep("drain-target", () -> gatewayClient.removeUpstream(exp.getTargetInstances()), Duration.ofSeconds(10)), // 2. 主动清空降级标记,不等 TTL 自然过期。 // 这一步必须在放开流量之前做,否则新进来的请求还会走降级路径 new RollbackStep("clear-degrade-flag", () -> redisTemplate.delete(exp.getDegradeFlagKeys()), Duration.ofSeconds(5)), // 3. 暂停重试队列的消费,避免积压消息在恢复瞬间集中冲击。 // RocketMQ 用 suspend,Kafka 用 pause,之后按 200 TPS 限速慢慢放 new RollbackStep("throttle-retry-queue", () -> mqAdmin.suspendConsumer(exp.getRetryTopic()), Duration.ofSeconds(5)), // 4. 等线程池排空,最长等 60 秒。等不到就强制重启实例—— // 这个决定我们纠结过,但堆积请求靠自然消化有时要 10 分钟以上 new RollbackStep("wait-thread-pool-drain", () -> waitUntilIdle(exp.getTargetInstances(), Duration.ofSeconds(60)), Duration.ofSeconds(65)), // 5. 重新挂回网关,逐步放量:10% → 30% → 100%,每档观察 30 秒 new RollbackStep("restore-traffic", () -> gatewayClient.rampUp(exp.getTargetInstances(), List.of(10, 30, 100), Duration.ofSeconds(30)), Duration.ofMinutes(2)) ); for (RollbackStep step : steps) { try { step.runWithTimeout(); log.info("rollback step done: {}", step.getName()); } catch (Exception e) { // 单步失败不中断整个剧本,记录并继续。 // 恢复过程中卡在某一步不动,比继续往下走危险得多 log.error("rollback step failed: {}, continue anyway", step.getName(), e); alerting.notify("rollback step failed: " + step.getName()); } } } }第 4 步"等不到就重启"这个决定,团队里争论了很久。反对意见是重启会丢失正在处理的请求,支持意见是那些请求本来也已经超时了。我们最后的折中是:等待 60 秒,超时后先看堆积量趋势,如果在下降就继续等,如果持平或上升就重启。这个判断逻辑写在waitUntilIdle里,不靠人工。
教训四:应用层的故障注入,比基础设施层更可控
Chaos Mesh 这类工具在网络层做注入(tc netem 加延迟、iptables 丢包),优点是不侵入代码,缺点是粒度太粗——它只能按 Pod、按端口、按 IP 段来切,没法做到"只让查询库存的这个方法慢,其他调用正常"。
对于精细化的场景,我们在应用层加了一个注入切面。它平时完全无开销(开关关闭时直接返回),只在演练期间由配置中心下发规则激活。
@Aspect @Component public class ChaosInjectionAspect { // volatile + 整体替换,避免读写锁开销。配置中心变更时整个 Map 换掉 private volatile Map<String, InjectionRule> rules = Collections.emptyMap(); @Around("@annotation(com.example.chaos.ChaosPoint)") public Object inject(ProceedingJoinPoint pjp) throws Throwable { // 快路径:没有任何规则时直接放行,这个判断的开销约 2ns Map<String, InjectionRule> snapshot = rules; if (snapshot.isEmpty()) { return pjp.proceed(); } String point = pjp.getSignature().toShortString(); InjectionRule rule = snapshot.get(point); if (rule == null || !rule.hit()) { // hit() 内部做百分比采样 return pjp.proceed(); } // 只对带演练标记的流量注入,防止误伤真实用户。 // 这个检查绝对不能少——我们第二次演练时忘了加, // 结果 3% 的真实用户请求也被注入了延迟 if (!ChaosContext.isChaosTraffic()) { return pjp.proceed(); } switch (rule.getType()) { case DELAY: // 用 parkNanos 而不是 Thread.sleep,前者不吃中断状态 LockSupport.parkNanos(rule.getDelayMillis() * 1_000_000L); return pjp.proceed(); case EXCEPTION: // 抛的异常类型要和真实故障一致, // 抛 RuntimeException 测不出针对 TimeoutException 写的 catch 分支 throw rule.newException(); case EMPTY_RESULT: // 返回空结果,用来测试上游对"查不到数据"的处理 return rule.emptyValueFor(((MethodSignature) pjp.getSignature()).getReturnType()); default: return pjp.proceed(); } } }ChaosContext.isChaosTraffic()读的是从网关透传下来的 header,通过 ThreadLocal 存储,在异步调用时用 TransmittableThreadLocal 传递。这一层保护是第二次演练之后加的——那次我们在规则里只写了 percent=3,没加流量标记判断,结果 3% 的真实用户请求被注入了 500ms 延迟,虽然没造成事故,但性质上已经是"拿用户做实验"了。
三类注入工具的适用范围
| 工具 / 层次 | 注入能力 | 粒度 | 侵入性 | 适合场景 |
|---|---|---|---|---|
| Chaos Mesh(K8s) | 网络延迟/丢包/分区、Pod kill、IO 故障、时钟偏移 | Pod / 容器 | 无侵入,需 K8s 权限 | 验证基础设施韧性、多副本切换 |
| ChaosBlade | 上述 + JVM 层(方法延迟、抛异常)、CPU/内存打满 | 进程 / 方法 | 需挂 agent | 单机资源故障、JVM 方法级注入 |
| 应用层切面 | 方法延迟、返回空、抛指定异常、返回脏数据 | 方法 + 参数条件 | 需改代码埋点 | 精细业务分支验证 |
| 依赖侧 Mock(如 WireMock) | 下游返回慢、返回错误码、返回畸形报文 | 单个 HTTP 依赖 | 需改路由 | 第三方接口异常验证 |
第三行的"返回脏数据"是我最推荐但最少人做的一类。系统对"下游超时"通常都有处理,但对"下游返回了格式正确、内容错误的数据"往往毫无防备。我们注入过一次库存服务返回负数库存,结果订单服务算出了负数金额,一路写进了数据库。这个 bug 在正常测试和网络层混沌演练里都不会暴露。
我的几个判断
没有完善监控之前不要做混沌工程。这不是保守,是逻辑问题:混沌工程的产出是"发现系统的未知弱点",而发现依赖于观测。监控不到位时做演练,最可能的结果是把系统搞挂了但不知道为什么,收益为负。判断标准很简单——如果你不能在 1 分钟内说出"现在下单成功率是多少",就先别演练。
不建议一上来就在生产环境做。业界推崇"生产环境才有真实性",这话对,但有前提:你的演练流程本身要先成熟。我们的顺序是预发跑 5 次验证脚本,生产影子流量跑 3 次验证观测,最后才对真实流量的隔离实例做。跳过前面两步,代价就是开头那 23 分钟。
演练的目标不是"证明系统很稳",而是"找到它在哪不稳"。如果连续几次演练都平安无事,第一反应应该是怀疑注入的强度不够或者稳态指标定得太松,而不是庆祝。我们现在的规矩是:连续 3 次演练零发现,就要提升故障强度或换新的故障场景。
复盘:几个数字
- 事故:注入 500ms 延迟,40 秒线程池打满,110 秒波及无关服务,恢复耗时 23 分钟,下单成功率最低 71%,客诉 380 条,超卖 47 单。
- 改造后的第一次生产演练:同样场景,影响面控制在 1 个实例,下单成功率最低 99.2%,守护器在第 34 秒自动中止(当时阈值定得偏紧),恢复耗时 41 秒。
- 应用层注入切面的性能开销:规则为空时 JMH 测出 1.8ns/次调用,规则激活但未命中时 34ns/次,可以常驻生产。
- 半年内累计演练 41 次,发现有效缺陷 19 个,其中 6 个是 P1 级(会导致资损或大面积不可用),包括那个"下游返回负库存"的问题。
- 演练平均耗时从最初的 2 小时(含人工准备和恢复)压到 22 分钟,主要收益来自回滚剧本自动化。
留个问题
有个场景我们至今没找到好办法:怎么演练"数据库主从延迟 30 秒"?
用 Chaos Mesh 给从库注入网络延迟,会导致复制直接中断而不是延迟;用pt-slave-delay那类工具,又只能整体延迟不能按业务隔离。而这个场景在真实生产里出现过至少四次,每次都是读写分离的读请求读到旧数据,业务表现千奇百怪。
你们是怎么覆盖这类"数据一致性延迟"故障的?