本节目标:在写第一行注入代码之前,先回答「可能坏什么、坏了影响谁、多久能发现、多久能恢复」。
适用版本:JDK 25 + gRPC-Java 1.76.0;链路为 HTTP 客户端 → linkd-v3 网关 9094 → gRPC 9093 → libraryd 8080。
承接的问题
第 12 章讲完灰度与回滚,团队容易产生一种错觉:只要发布流程足够谨慎,线上就不会出问题。真实情况是,发布只覆盖了「代码变更」这一类故障;依赖超时、连接泄漏、流量突增、配置写错、证书过期,都不需要发布就能让链路失效。
混沌工程要解决的不是「制造事故」,而是把「我们以为会怎样」变成可验证的假设。做这件事之前,必须先有故障模型:哪些故障会发生、通过什么信号被发现、影响范围有多大、现有的超时与熔断能不能兜住。
没有故障模型就做注入,常见结果是三类无效演练:
- 注入了一个链路里根本不存在的依赖,演练「通过」但什么也没证明。
- 注入的故障被上层重试吃掉,观测到的错误率没有变化,结论却是「系统很健壮」。
- 故障范围超出预期,从演练变成事故,团队此后不敢再做第二次。
本节先建立分类和度量口径,13.2 再把模型落成可开关的拦截器,13.3 设计一次完整的演练。
故障分类:五个来源
分类的价值在于「不同类别的发现手段和护栏不同」。把所有故障笼统称为「服务不可用」,会导致监控只盯一个 5xx 比率,而漏掉资源类故障的渐进特征。
| 类别 | 典型触发 | linkd-v3 中的位置 | 首个可观测信号 | 主要护栏 |
|---|---|---|---|---|
| 依赖故障 | libraryd 进程退出、DNS 失败、下游 500 | 9094 → 9093 → 8080 | gRPC UNAVAILABLE、连接被拒 | 超时、熔断、有限重试 |
| 资源故障 | 线程池满、堆外内存上涨、fd 泄漏、磁盘满 | comm-server、comm-gateway | 队列深度、RSS、lsof 计数 | Bulkhead、上限、告警 |
| 流量故障 | 突增、重试风暴、大消息、慢客户端 | 全部入口 | QPS 曲线、RESOURCE_EXHAUSTED | 限流、背压、消息上限 |
| 代码故障 | 空指针、序列化失败、死循环、锁竞争 | comm-core、comm-server | 特定方法的错误率、线程栈 | 单测、契约测试、灰度 |
| 配置故障 | 超时写成 0、证书路径错、租户白名单漏项 | 启动参数、ConfigMap | 启动失败或全线 4xx/5xx | 配置校验、启动自检、回滚 |
五类不是互斥的。一次典型故障往往从配置开始:单跳 deadline 被写成 30 秒,libraryd 变慢时网关线程全部挂住,随后线程池耗尽,表现为流量类故障。归因时要找到最初的那一环,而不是停在最后倒下的组件上。
依赖故障和资源故障的观测位置不同:前者看客户端侧的 gRPC 状态码分布,后者看服务端自身的队列、线程与内存。只在网关看 5xx,会把「libraryd 慢」和「网关线程池满」混成同一个指标。
爆炸半径:从一条连接到一个租户
爆炸半径不是形容词,要能算。给定峰值 QPS、故障持续时间和受影响流量占比,就能估出受影响的请求数;再乘以单请求的业务价值,才是业务影响。
| 级别 | 范围 | 例子 | 默认处置 |
|---|---|---|---|
| L0 | 单连接 / 单请求 | 某条 HTTP/2 stream 被取消 | 客户端重试 |
| L1 | 单实例 | 一个 comm-server 进程 OOM | 摘除实例,流量转移 |
| L2 | 单服务 | 全部 comm-server 不可用 | 网关熔断,降级读缓存 |
| L3 | 单依赖链 | libraryd 不可用,所有图书查询失败 | 限流 + 返回兜底 |
| L4 | 多租户 / 多区域 | 共享数据库故障、证书同时过期 | 切区域、人工止损 |
演练默认只允许 L0–L2。L3 需要单独评审,L4 不在演练范围内,只能通过演练「验证发现与切换流程」,不能真的制造。
把「影响多少请求」写成一个纯函数,比在复盘会上拍脑袋可靠:
package com.plume.linkd.v3.chaos;
import java.time.Duration;
import java.util.List;
public final class FaultCatalog {
public enum Layer { DEPENDENCY, RESOURCE, TRAFFIC, CODE, CONFIG }
public record FaultCase(
String id,
Layer layer,
String trigger,
String symptom,
Duration mttdTarget,
Duration mttrTarget,
List<String> blastRadius) {
}
public record RequestShape(double peakRps, int tenants, double peakFactor) {
public double peak() {
return peakRps * peakFactor;
}
}
/**
* 受影响请求数 = 峰值 QPS × 持续时间 × 受影响流量占比。
* tenantShare 是「受影响租户的流量占比」,不是租户数量占比。
*/
public static double affectedRequests(RequestShape shape,
Duration duration,
double tenantShare) {
double share = Math.min(1.0, Math.max(0.0, tenantShare));
return shape.peak() * duration.toSeconds() * share;
}
public static void main(String[] args) {
RequestShape shape = new RequestShape(200, 40, 1.5);
double affected = affectedRequests(shape, Duration.ofMinutes(5), 0.02);
System.out.printf("peak=%.0f rps, affected=%.0f requests%n", shape.peak(), affected);
// 假设口径:峰值 300 rps、持续 5 分钟、2% 流量 → 1800 个请求
}
}
这段代码只做算术,不依赖任何运行时数据。tenantShare 必须来自真实流量分布,例如「测试租户占 2%」,不能直接用 1 / 租户数。40 个租户平均分配是 2.5%,但真实分布往往是一个大租户占 60%,长尾租户合计不到 5%。
同时要算「放大系数」。一次网关请求在重试 2 次的情况下,最坏会变成 3 次下游调用;如果 200 rps 全部重试,下游看到的是 600 rps。故障模型的输入应该是放大后的流量,否则容量评估会系统性偏低。
三种口径:峰值、均值与最坏情况
同一个故障用不同口径算,会得到差一个数量级的结论。演练报告必须写明用的是哪一种。
| 口径 | 取值方式 | 适用场景 | 容易高估/低估 |
|---|---|---|---|
| 均值 | 一天 QPS 总和 / 86400 | 成本预算、月度容量 | 高估低峰时段的冗余 |
| 峰值 | 过去 30 天 P99 分钟值 | 熔断阈值、限流 burst | 低估营销活动的瞬时尖峰 |
| 最坏情况 | 峰值 × 重试放大 × 全部租户 | 爆炸半径上限、预算封顶 | 容易得出「什么都扛不住」 |
日常容量规划用峰值,故障影响估算用最坏情况,成本核算用均值。三个数不要互相替代:用均值算爆炸半径会显得故障「没什么影响」,用最坏情况算日常成本会逼着团队过度采购。
最坏情况也要有边界。把「全部租户 × 3 次重试 × 峰值」直接相乘,得到的数字通常大到无法指导行动。更实用的写法是分档:P50 流量、P95 流量、P99 流量各算一次,再标注每一档对应的租户范围。演练的准入条件用 P95 档,回滚阈值用 P99 档。
稳态假设:把「正常」写成可证伪的句子
混沌工程的核心不是注入动作,而是稳态假设(steady-state hypothesis)。它必须是一个在注入前后都能测量的断言,而不是「系统应该没问题」。
好的稳态假设具备三个特征:有明确指标、有阈值、有观测窗口。
在 100 rps 的基准流量下,持续 5 分钟:
1. 网关 9094 的 p99 < 150 ms,5xx 比例 < 0.1%
2. gRPC 9093 的 UNAVAILABLE 比例 < 0.1%
3. 网关到 libraryd 的 ESTABLISHED 连接数 ≤ 32
4. 服务端等待队列深度 < 32,拒绝数 = 0
5. 9095 的 /healthz 返回 SERVING
6. 熔断器状态为 CLOSED,限流拒绝率 < 0.5%
反例是「系统保持高可用」。这句话无法测量,也无法在演练后判定失败。另一个反例是「错误率为 0」:正常重试和客户端取消都会产生少量非 2xx,阈值定得太死会让每次演练都「失败」,团队很快学会忽略结果。
稳态假设要覆盖「用户路径」而不只是「进程活着」。/healthz 返回 200 只证明 accept 和一条最短路径可用;图书查询、搜索流、库存订阅是不同代码路径,需要各自的断言。
MTTD 与 MTTR:把发现和恢复拆开
MTTD(平均发现时间)和 MTTR(平均恢复时间)经常被合成一个「故障处理时长」,导致改进项无处落地。拆开之后,每个阶段都有独立的负责人和手段。
| 阶段 | 从什么时刻算起 | 主要手段 | 常见瓶颈 |
|---|---|---|---|
| 检测延迟 | 故障发生 → 指标采集 | 采集间隔、探针频率 | 1 分钟采集 + 5 分钟窗口 |
| 告警延迟 | 采集 → 告警触发 | 阈值、持续时长、抑制规则 | 阈值太钝或告警风暴 |
| 确认延迟 | 告警 → 有人接手 | 值班表、升级策略 | 夜间无人、告警未分派 |
| 诊断时间 | 接手 → 找到根因 | 仪表盘、追踪、变更记录 | 指标维度不足 |
| 止损时间 | 找到根因 → 恢复流量 | 回滚、切流、扩容 | 回滚未演练 |
| 验证时间 | 止损 → 确认恢复 | 稳态假设复测 | 只看错误率不看延迟 |
一个可落地的目标是按级别设定上限,而不是全站一个数:
- L0/L1:MTTD ≤ 2 分钟,MTTR ≤ 10 分钟。
- L2:MTTD ≤ 5 分钟,MTTR ≤ 30 分钟。
- L3:MTTD ≤ 5 分钟,MTTR ≤ 60 分钟,且必须有人工决策点。
MTTD 的改进通常最便宜:把关键指标的采集间隔从 60 秒降到 15 秒,把「持续 5 分钟」改成「持续 1 分钟且伴随错误率上升」,就能把发现时间提前几分钟。代价是告警量和存储成本上升,需要配合抑制规则。
MTTR 的改进更多依赖「回滚是否真的能一键执行」。如果回滚需要手工改三个配置、重启两个服务,那么演练测出来的 MTTR 是理想值,不是真实值。
故障注入与生产环境的边界
演练和生产事故的边界必须写在制度里,不能靠个人判断。
| 维度 | 演练 | 生产事故 |
|---|---|---|
| 结束时间 | 事先确定,到点自动回滚 | 未知 |
| 影响范围 | 预先计算并有上限 | 可能持续扩大 |
| 回滚路径 | 演练前验证过 | 可能不存在 |
| 观察者 | 至少两人,一人只看全局 | 全员救火 |
| 数据 | 测试租户或只读流量 | 真实用户数据 |
生产环境可以做「验证型实验」,但要满足全部条件:只读、可立即取消、影响面 ≤ 1% 流量、单次 ≤ 5 分钟、同一时间只有一个实验、有独立 kill switch。写操作、数据库主库、证书轮换、磁盘打满,这四类不在生产演练范围内。
一个容易被忽略的边界是「注入工具本身也是依赖」。如果注入开关放在配置中心,而配置中心正好是本次演练要验证的依赖,那么开关可能一起失效。kill switch 要走独立通道,例如进程内的系统属性或本地文件,不依赖被演练的链路。
在 linkd-v3 里落地
把五类故障映射到链路上的具体位置,才能决定注入点:
| 故障 | 注入点 | 期望的上层行为 | 对应章节 |
|---|---|---|---|
| libraryd 拒绝连接 | 8080 进程停止 | 9093 返回 UNAVAILABLE,9094 熔断后返回 503 | 15.1 |
| libraryd 变慢 | 8080 前加 500 ms 延迟 | 300 ms 单跳 deadline 触发,网关返回 504 | 3.2 |
| gRPC 服务端过载 | comm-server 并发限流 | 返回 RESOURCE_EXHAUSTED,客户端退避 | 8.2 |
| 网关线程池耗尽 | 注入慢调用占满池 | 队列增长、拒绝策略触发、健康检查仍可用 | 7.3 |
| 配置错误 | 把单跳 deadline 改成 0 | 启动自检失败,进程不进入就绪状态 | 15.3 |
稳态探针也要用真实协议,不要只 ping 端口:
package com.plume.linkd.v3.chaos;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class SteadyStateProbe {
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(200))
.build();
public boolean healthy(String baseUrl, Duration budget) {
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/healthz"))
.timeout(budget)
.header("x-tenant-id", "tenant-drill")
.GET()
.build();
try {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() == 200 && response.body().contains("SERVING");
} catch (Exception e) {
return false;
}
}
public double errorRate(long errors, long total) {
return total == 0 ? 0.0 : (double) errors / total;
}
}
healthy 捕获所有异常并返回 false,因为探针的职责是「回答是否正常」,不是「报告为什么异常」。异常细节应写进日志,由另一个组件聚合。探针本身要有独立的超时,不能继承业务调用的 800 ms,否则依赖变慢时探针也会一起卡住。
注入前先跑一遍探针,记录基线;注入中每 10 秒跑一次;回滚后再跑 5 分钟。三次结果都写进演练记录,才算有证据。
常见错误与边界
错误一:把「进程活着」当成稳态。 kill -STOP 之后端口还在监听,/healthz 可能仍然返回 200,但业务线程已经停止。稳态假设必须包含真实业务路径。
错误二:用租户数量估算爆炸半径。 40 个租户不等于每个 2.5%。按流量占比算,才能真正回答「影响多少请求」。
错误三:忽略重试放大。 200 rps 在 3 次尝试下会变成 600 rps 的下游压力。容量模型和故障模型必须使用同一个放大系数。
错误四:MTTD 和 MTTR 合成一个指标。 合成之后无法判断该加监控还是该练回滚,改进项只能写成「提升稳定性」。
错误五:在生产注入写操作。 只读实验和写实验的风险差一个数量级;写操作只能在隔离环境做,且要有数据清理方案。
边界:故障模型是假设,不是事实。 表里的分类和阈值需要随架构变化更新;新增一个下游依赖,就要新增一行依赖故障和一条稳态断言。
边界:演练通过不等于系统健壮。 它只证明「在给定注入、给定流量、给定阈值下,假设没有被推翻」。未覆盖的故障仍然未知,演练报告要列出未覆盖项。
小结
- 故障按依赖、资源、流量、代码、配置五类拆开,每类有独立的观测信号和护栏。
- 爆炸半径用「峰值 QPS × 持续时间 × 流量占比」估算,重试放大要计入输入。
- 稳态假设必须是可测量、有阈值、有窗口的断言,覆盖真实业务路径而不只是健康检查。
- MTTD 拆成检测、告警、确认;MTTR 拆成诊断、止损、验证,改进项才有落点。
- 演练默认 L0–L2,L3 需评审,L4 只验证发现与切换流程;生产的 kill switch 必须独立于被演练链路。
阅读导航:上一节 12.3 契约测试与回滚 | 下一节 13.2 注入实现与开关
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。