本节目标:让一次演练在开始前就能回答「怎么停」,在结束后能回答「假设是否被推翻」。
范围: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% | 请求头、注入器日志 |
| seed | 20261028 | 启动参数 |
| 影响面 | 测试租户 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 要独立于被演练的链路。如果开关放在配置中心,而配置中心恰好是演练目标,那么回滚动作会一起失效。推荐顺序:
- 进程内系统属性(最快,但要重启)。
- 本地文件开关(进程定时检查,秒级生效)。
- 管理端 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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。