《Java 服务端通信工程》13.3 演练设计与复盘

一次演练要有开始时间、结束时间、回滚路径和观察者,否则就会变成事故。本节给出假设→注入→观察→回滚→复盘五步闭环、单次演练记录表、超时与熔断护栏、独立的 kill switch 通道,以及两段 Java 代码实现自动回滚看门狗和演练时间线记录。

本节目标:让一次演练在开始前就能回答「怎么停」,在结束后能回答「假设是否被推翻」。
范围:linkd-v3 的测试租户,单次不超过 5 分钟,只注入 L0–L2 故障。

承接的问题

13.2 给了注入工具,但工具不等于演练。没有设计的注入只是一次随机破坏:没人知道该看哪个指标,没人定义什么时候停,出问题时也不知道该回滚还是继续观察。

演练和事故的区别不在严重程度,而在控制权:

  • 演练有预先写好的结束条件和回滚路径,事故没有。
  • 演练的影响面在开始前已经计算过,事故的影响面在扩大。
  • 演练至少有一名不参与操作的观察者,事故现场所有人都在救火。
  • 演练结束后有记录和复盘,事故结束后通常只有一句「已恢复」。

如果一次演练无法在 30 秒内停下来,它就不该开始。本节先给闭环,再给记录表和护栏,最后用代码把「到点自动回滚」变成默认行为。

五步闭环:假设、注入、观察、回滚、复盘

步骤产出失败的样子
假设3–6 条可测量的稳态断言「系统应该没事」
注入明确的故障类型、比例、持续时间和 seed「随便杀个进程看看」
观察每 10 秒一次指标快照,至少两人确认只看最终错误率
回滚预先验证过的 kill switch 和恢复检查出事时现场找开关
复盘时间线、假设结论、改进项与负责人「演练成功」四个字

假设必须写成「在什么流量下、看什么指标、阈值多少、持续多久」。例如:

基准流量 100 rps,测试租户占 2%,持续 5 分钟。
H1:注入 libraryd 500 ms 延迟后,9094 的 p99 仍 < 800 ms。
H2:gRPC DEADLINE_EXCEEDED 比例 < 1%,因为单跳 deadline 是 300 ms。
H3:熔断器不会打开,因为失败率低于 50%。
H4:9095 的 /healthz 全程 SERVING。

四条假设分别对应延迟、错误率、熔断状态和健康检查,覆盖了「用户可见」「保护机制」「自身存活」三个层面。只写一条「错误率不升高」不够,因为熔断打开后错误率可能反而下降。

注入比例建议从 1% 起步,确认观测链路正常后再升到 5%。一次性注入 100% 流量,容易把「验证保护机制」变成「验证降级页面」。

单次演练记录表

记录表在演练开始前填好前半部分,演练中填时间线,结束后填结论。不要事后补写,时间线一旦靠回忆就会失真。

字段示例证据来源
演练编号drill-20261028-a工单系统
假设H1–H4(见上)本节模板
注入指令x-fault: delay:500,比例 5%请求头、注入器日志
seed20261028启动参数
影响面测试租户 2% × 5 分钟13.1 的估算函数
开始时间2026-10-28T19:41:00+08:00时间线第一条
观察者操作人 A,观察人 B值班表
停止条件p99 > 1 s 或 5xx > 5% 或受影响请求 > 5000仪表盘
回滚动作去掉 -Dlinkd.fault.enabled 并重启运行手册
回滚耗时目标 < 60 s时间线
结束时间计划 5 分钟,实际以时间线为准时间线
假设结论逐条写「成立 / 被推翻 / 证据不足」指标快照
改进项动作、负责人、截止日期、对应章节复盘会

「证据不足」是合法结论。指标没采到、采样间隔太粗、日志被截断,都应该如实写,而不是猜一个「成立」。

安全护栏:超时、熔断与回滚按钮

护栏分三层,任何一层触发都立即停止注入:

层触发条件动作由谁执行
实验超时到达预设结束时间自动关闭注入看门狗
业务阈值p99、错误率、受影响请求数超限自动回滚并告警观察脚本
人工止损观察者判断风险不可接受按 kill switch人

实验超时必须是硬超时,不能依赖操作人记得。业务阈值要留出正常波动空间:如果基线 p99 是 120 ms,阈值定 150 ms 会在第一次抖动时误停;定 1 s 又太钝。一个实用做法是「基线 p99 的 5 倍」或「绝对上限 1 s」,取较小值。

kill switch 要独立于被演练的链路。如果开关放在配置中心,而配置中心恰好是演练目标,那么回滚动作会一起失效。推荐顺序:

  1. 进程内系统属性(最快,但要重启)。
  2. 本地文件开关(进程定时检查,秒级生效)。
  3. 管理端 9095 的只读状态查询(用于确认,不用于控制)。

回滚按钮不是「重启一下」。回滚前要确认三件事:注入指令已停止下发、在途请求已排空、指标回到基线。只做第一步就宣布恢复,往往会在几分钟后看到延迟再次上升。

一次完整演练的时间线

以「libraryd 变慢」为例,演练脚本按下面的顺序执行。所有命令都是执行模板,不是本机输出。

#!/usr/bin/env bash
set -euo pipefail

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
BASE=http://127.0.0.1:9095

# 1. 前置检查:服务在、基线正常、没有别的演练在跑
curl -fsS "$BASE/healthz" | grep -q SERVING
test ! -f /tmp/linkd-drill.active || { echo "another drill is running"; exit 1; }
touch /tmp/linkd-drill.active

# 2. 记录基线
curl -fsS "$BASE/metrics" > /tmp/drill-baseline.txt

# 3. 下发注入:测试租户、5% 比例、延迟 500 ms
curl -fsS -X POST "$BASE/drill/start" \
  -H 'content-type: application/json' \
  -d '{"fault":"delay:500","ratio":5,"tenant":"tenant-drill","seed":20261028}'

# 4. 观察 5 分钟,每 10 秒抓一次指标
for i in $(seq 1 30); do
  sleep 10
  curl -fsS "$BASE/metrics" >> /tmp/drill-during.txt
done

# 5. 无论成功失败都回滚
curl -fsS -X POST "$BASE/drill/stop" || true
rm -f /tmp/linkd-drill.active

脚本里的 trap 比 set -e 更重要。真实版本应在开头加:

trap 'curl -fsS -X POST "$BASE/drill/stop" || true; rm -f /tmp/linkd-drill.active' EXIT

这样即使第 4 步的循环被 Ctrl-C 中断,也会执行回滚。没有 trap 的演练脚本,一次误操作就会把注入留在线上。

管理端接口是演练专用,只监听 127.0.0.1,且必须校验调用方。不要把它暴露到公网,也不要用它替代正式的回滚流程。

在 linkd-v3 里落地:看门狗与时间线

自动回滚用 AutoCloseable 包一层:构造时启动看门狗,close() 时执行回滚,异常路径也不会漏。

package com.plume.linkd.v3.chaos;

import java.time.Duration;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

public final class DrillGuard implements AutoCloseable {

    private final Duration maxDuration;
    private final Runnable rollback;
    private final ScheduledExecutorService watchdog =
            Executors.newSingleThreadScheduledExecutor(r -> {
                Thread thread = new Thread(r, "drill-watchdog");
                thread.setDaemon(true);
                return thread;
            });
    private final AtomicBoolean stopped = new AtomicBoolean();
    private final List<String> events = new CopyOnWriteArrayList<>();

    public DrillGuard(Duration maxDuration, Runnable rollback) {
        this.maxDuration = maxDuration;
        this.rollback = rollback;
        events.add("armed maxDuration=" + maxDuration);
        watchdog.schedule(this::stopSafely,
                maxDuration.toMillis(), TimeUnit.MILLISECONDS);
    }

    public void abort(String reason) {
        events.add("abort reason=" + reason);
        stopSafely();
    }

    private void stopSafely() {
        if (stopped.compareAndSet(false, true)) {
            events.add("rollback start");
            try {
                rollback.run();
                events.add("rollback done");
            } catch (RuntimeException e) {
                events.add("rollback failed: " + e.getClass().getSimpleName());
                throw e;
            }
        }
    }

    @Override
    public void close() {
        stopSafely();
        watchdog.shutdownNow();
    }

    public List<String> events() {
        return List.copyOf(events);
    }
}

使用方式是一个 try-with-resources:

try (DrillGuard guard = new DrillGuard(Duration.ofMinutes(5),
        () -> drillControl.stop("tenant-drill"))) {
    drillControl.start("delay:500", 5, "tenant-drill", 20261028L);
    observeFor(Duration.ofMinutes(5));
} catch (RuntimeException e) {
    // 回滚已经在 close() 或 abort() 里执行,这里只记录
    System.err.println("drill failed: " + e.getMessage());
}

stopSafely 用 compareAndSet 保证回滚只执行一次。看门狗到点和 close() 同时触发时,不会重复调用 rollback;重复停止在有些系统上会报错,反而掩盖真正的失败。

时间线单独记录,方便复盘时按时间排序:

package com.plume.linkd.v3.chaos;

import java.time.Instant;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public final class DrillTimeline {

    public record Event(Instant at, String phase, String detail) {
        @Override
        public String toString() {
            return at + " " + phase + " " + detail;
        }
    }

    private final Instant startedAt = Instant.now();
    private final List<Event> events = new CopyOnWriteArrayList<>();

    public void mark(String phase, String detail) {
        events.add(new Event(Instant.now(), phase, detail));
    }

    public String render() {
        StringBuilder out = new StringBuilder("drill started at ").append(startedAt).append('\n');
        for (Event event : events) {
            out.append(event).append('\n');
        }
        return out.toString();
    }
}

CopyOnWriteArrayList 适合「写少读多、事件数量在几百以内」的场景。它的 add 是 O(n) 复制,不要用它记录每秒上万条的业务指标;演练时间线只有几十条,足够。

常见错误与边界

错误一:没有结束时间。 「看情况停」等于没有停止条件。每次演练都要在记录表里写计划结束时间,并由看门狗强制执行。

错误二:观察者同时是操作者。 一个人既要下发注入又要盯全局,通常只能看到自己关心的那个指标。至少两人,观察者不碰键盘。

错误三:回滚只关开关,不排空。 在途请求仍带着注入标记,延迟还会持续几秒到几十秒。回滚后要观察至少一个完整的请求周期。

错误四:把演练失败当成事故处理。 假设被推翻是演练的正常产出,说明系统没有想象中健壮。只有影响面超出预设、且回滚失效,才升级为事故。

错误五:复盘只写「成功/失败」。 结论要逐条对应假设,写清证据和「证据不足」的项;改进项要有负责人和截止日期。

错误六:在发布窗口做演练。 发布本身已经改变系统状态,再叠加注入,无法区分是哪一个导致的异常。演练前后各留 30 分钟不发版。

边界:演练不能覆盖未建模的故障。 13.1 的故障模型没列出的依赖,不会因为演练通过就变安全。新增依赖时要先补模型,再设计注入。

边界:演练频率不是越高越好。 同一故障每季度一次足够;频繁演练会让团队麻木,也会积累不必要的风险。系统有大改时重新演练,比固定节奏更有意义。

小结

  • 演练和事故的区别是控制权:有结束时间、有回滚路径、有观察者。
  • 五步闭环:可测量的假设、受控的注入、周期观察、预先验证的回滚、逐条结论的复盘。
  • 记录表事前填一半、事中填时间线、事后填结论,不靠回忆补写。
  • 三层护栏:硬超时、业务阈值、人工 kill switch;kill switch 独立于被演练链路。
  • DrillGuard 用 try-with-resources 和 CAS 保证回滚只执行一次,DrillTimeline 记录可排序的事件。
  • 假设被推翻是正常结果;只有影响面失控且回滚失效才升级为事故。

阅读导航:上一节 13.2 注入实现与开关 | 下一节 14.1 容量模型与 Little’s law

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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