《Java 服务端通信工程》13.1 故障模型与爆炸半径

故障不是一句「服务挂了」。本节按依赖、资源、流量、代码、配置五类拆开 linkd-v3 链路的失效来源,给出爆炸半径分级表、稳态假设的写法、MTTD/MTTR 的拆解口径,并用一段 Java 代码计算一次故障影响的请求量,最后划清故障注入与生产环境的边界。

本节目标:在写第一行注入代码之前,先回答「可能坏什么、坏了影响谁、多久能发现、多久能恢复」。
适用版本: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 失败、下游 5009094 → 9093 → 8080gRPC 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 熔断后返回 50315.1
libraryd 变慢8080 前加 500 ms 延迟300 ms 单跳 deadline 触发,网关返回 5043.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 注入实现与开关

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收