验收不是「跑一遍没报错」
本章给出一套可以照着执行的验收方案。它不预填任何 PASS——验收结果必须由执行人在目标环境跑出来,并保留证据。判断一个流处理系统是否验收通过,至少要看五个维度:
| 维度 | 判定标准 | 需要保留的证据 |
|---|---|---|
| 可运行 | 五个组件健康,作业处于 RUNNING | docker compose ps、Flink REST /jobs/overview |
| 正确性 | 确定性输入下,逐用户计数与手算一致 | 输入清单、窗口键值、对比脚本输出 |
| 延迟 | 生产到可查询的 p99 在预算内 | 标记事件时间戳、轮询记录 |
| 容错 | 崩溃恢复后事件集合不丢不重 | 恢复前后 eventId 集合差异 |
| 反压 | 峰值期间不崩溃,停止生产后 lag 回落 | backpressure 指标、lag 曲线 |
验收报告里最没有信息量的一句话是「功能正常」。每个结论都要能指向一个可复查的证据。
验收前先冻结三件事
- 版本与配置:代码 commit、镜像 digest、Kafka topic 分区数、Flink Slot 数、Checkpoint 目录。
- 输入数据:固定随机种子、固定
eventTime基准、每条事件有唯一eventId。否则失败后无法复现。 - 输出契约:Redis 键必须包含聚合维度。例如按用户聚合时用
eventd:metrics:order-count:<windowStart>:<userId>;如果只写<windowStart>,多用户结果会互相覆盖,验收根本无法断言。
第 3 条不是形式主义。9.3
的写入键已经按 metric:windowStart:dimension 组织;如果实现时省掉最后的维度,验收用例会立刻失败——它先于用户发现契约缺陷。
验收环境记录(执行时填写)
| 项目 | 实际值 |
|---|---|
| 机器 / CPU / 内存 | 待填 |
| 操作系统 | 待填 |
| JDK | 待填(Flink 1.20 用 17) |
| Kafka / 分区数 | 待填 |
| Flink / Slot 数 | 待填 |
| Redis | 待填 |
| 代码 commit | 待填 |
本卷不提供某台机器的「实测报告」。吞吐和延迟离开硬件、分区数、负载大小就没有可比性;把别人的数字抄进验收报告,等于没有验收。下面的所有结果都由执行人填写。
前置:可重复的起点
# Compose v2 会等待 healthcheck 通过,比 sleep 30 可靠
docker compose -f eventd-v3/docker-compose.yml up -d --wait
# 组件健康
docker compose -f eventd-v3/docker-compose.yml ps
# Flink 作业状态
curl -s localhost:8081/jobs/overview | jq '.jobs[] | {name, state}'
# 清空上一轮遗留的 topic 数据与 Redis 指标,避免旧窗口干扰
「清空」最好不要靠 FLUSHDB 和删 Topic:FLUSHDB 会连带清掉同一 Redis 上其他环境的数据,Topic 删除又是异步的,刚删完就重建会报 TopicExistsException。更可靠的做法是每次验收用独立的 topic 前缀和独立 Redis DB;必须复用时,删除后先确认 kafka-topics.sh --describe 已经查不到该 Topic,再重建。
测试辅助方法(sendToKafka、awaitEvents、redis()、windowStartOf、sendWatermarkAdvance、awaitWindow、triggerSavepointAndWait 等)放在 eventd-shared 的 test fixture 中,验收代码只表达断言,不重复实现连接逻辑。它们的实现不是本章重点,但每个都要有明确的超时——辅助方法内部 while 等不到条件就返回 null,会把这个用例变成「假通过」。
用例 1:端到端可运行
@Test
void testEndToEndPipeline() throws Exception {
List<Event> events = generateEvents(100, "acceptance-e2e");
events.forEach(e -> sendToKafka(e, "eventd-raw"));
// 轮询到 100 条或超时,不要用固定 sleep 猜处理时间
List<Event> cleaned = awaitEvents("eventd-cleaned", 100, Duration.ofSeconds(30));
assertEquals(100, cleaned.size(), "清洗输出数量不符");
// 窗口 key 只有在 Watermark 越过窗口结束、窗口触发后才存在。
// 这里补一个事件时间更大的哨兵,否则用例 1 会一直等不到 key
// (哨兵同样要覆盖所有活跃分区,原因见用例 2 的第 3 条)
long lastEventTime = events.stream()
.mapToLong(Event::eventTime)
.max()
.orElseThrow();
sendWatermarkAdvance(lastEventTime + Duration.ofMinutes(2).toMillis());
long windowStart = windowStartOf(events.get(0).eventTime());
awaitWindow("eventd:metrics:order-count", windowStart, Duration.ofSeconds(30));
String countKey = "eventd:metrics:order-count:" + windowStart + ":acceptance-e2e";
assertTrue(redis().exists(countKey) > 0, "Redis 缺少验收用户的窗口计数: " + countKey);
// SSE 是无限流,只能用 ofLines 读一帧,绝不能用 ofString
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8083/api/stream/metrics"))
.header("Accept", "text/event-stream")
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<Stream<String>> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofLines());
assertEquals(200, response.statusCode());
try (Stream<String> lines = response.body()) {
String firstData = CompletableFuture
.supplyAsync(() -> lines.filter(l -> l.startsWith("data:")).findFirst().orElse(null))
.get(5, TimeUnit.SECONDS);
assertNotNull(firstData, "5 秒内未收到 SSE data 帧");
}
}
通过标准:30 秒内清洗输出恰好 100 条;验收用户的窗口键存在;5 秒内收到第一帧 SSE 数据。三者缺一不可。
用例 2:指标正确性
构造 10 个用户、每人 5 条 ORDER,共 50 条,全部落在同一个 1 分钟事件时间窗口内:
@Test
void testMetricsAccuracy() throws Exception {
long base = 1_700_000_000_000L;
Map<String, Integer> expected = new LinkedHashMap<>();
for (int u = 0; u < 10; u++) {
String userId = "u" + u;
expected.put(userId, 5);
for (int i = 0; i < 5; i++) {
sendToKafka(Event.builder()
.eventId("acc-" + userId + "-" + i)
.type("ORDER")
.userId(userId)
.amount(100.0 + i)
.symbol("BTC")
.eventTime(base + i * 1000L)
.build(), "eventd-raw");
}
}
// Kafka 是无界源,不会自动发 MAX_WATERMARK;
// 用一条 eventTime 更大的事件把 Watermark 推过窗口结束。
sendWatermarkAdvance(base + Duration.ofMinutes(2).toMillis());
long windowStart = windowStartOf(base);
awaitWindow("eventd:metrics:order-count", windowStart, Duration.ofSeconds(30));
for (Map.Entry<String, Integer> e : expected.entrySet()) {
String key = "eventd:metrics:order-count:" + windowStart + ":" + e.getKey();
String json = redis().get(key);
assertNotNull(json, "缺少用户窗口计数: " + key);
assertEquals(e.getValue().intValue(),
JsonMapper.parse(json).get("count").asInt(),
"用户计数不符: " + key);
}
}
三个容易忽略的前提:
- Watermark 必须推进。事件时间窗口只有在 Watermark 越过窗口结束时间后才触发;Kafka Source 不会因为有 50 条数据就自动推进到未来。
sendWatermarkAdvance发出的哨兵事件同样要走清洗管道,必须带eventId/type且能通过校验,否则会进死信、Watermark 不会推进。 - Watermark 要在过滤之前分配。如果清洗管道先把哨兵事件丢掉,Watermark 也推进不了。实现上有两种选择:在 Source 后立即分配 Watermark,或让测试使用一个有界源。
- 哨兵要覆盖所有活跃分区。Flink 每个 source 分区各自维护 Watermark,算子里取最小值。验收 topic 若是多分区,只往一个分区发哨兵,其他分区仍会把 Watermark 压住;要么把测试 topic 建成单分区,要么让
sendWatermarkAdvance给每个分区都发一条。
通过标准:10 个用户的窗口计数全部等于 5,而不是「Redis 里有个 latest 值」。
用例 3:延迟预算
@Test
void testLatencyBudget() throws Exception {
long sentAt = System.currentTimeMillis();
String userId = "latency-user";
sendToKafka(Event.builder()
.eventId("latency-marker-" + sentAt)
.type("ORDER")
.userId(userId)
.amount(1.0)
.symbol("TEST")
.eventTime(sentAt)
.build(), "eventd-raw");
// 指标是事件时间窗口聚合,单条事件不会自己关闭窗口:
// 必须补一条事件时间更晚的 pumper 把 Watermark 推过窗口结束,否则永远等不到
sendWatermarkAdvance(sentAt + Duration.ofMinutes(2).toMillis());
String key = "eventd:metrics:order-count:" + windowStartOf(sentAt) + ":" + userId;
Long observedAt = null;
for (int i = 0; i < 50 && observedAt == null; i++) {
Thread.sleep(200);
if (redis().exists(key) > 0) {
observedAt = System.currentTimeMillis();
}
}
assertNotNull(observedAt, "10 秒内未观测到标记事件,延迟无法判定");
long latencyMs = observedAt - sentAt;
assertTrue(latencyMs < 10_000, "端到端延迟超预算: " + latencyMs + "ms");
}
这里测的是生产到可查询的延迟。注意窗口聚合的「可查询」本身要等窗口关闭,所以结果里包含了补 pumper 推进 Watermark 的那一跳;要单独测纯处理耗时,得在面板的实时层(非窗口指标)上打点。两者要分开报告:
| 指标 | 计算方式 | 用途 |
|---|---|---|
| 端到端延迟 | 观测时刻 − 生产时刻 | 面板「多久能看到」 |
| 事件时间滞后 | 处理时刻 − eventTime | 重放/积压时的数据年龄 |
如果事件是历史数据重放,now - eventTime 会很大,但它不是系统变慢,而是输入本来就旧。把两者混成一个「延迟」数字,是性能报告最常见的错误。
另外,这个用例只发一条标记事件,只能得到一个样本,不能写成 p99。要报 p99,至少发 1000 条(最好 10000 条)带唯一 eventId 的事件,逐条记录「观测时刻 − 生产时刻」,排序后按最近秩取分位;样本太少时 p99 基本等于最大值。验收记录里的 p99 必须注明样本数和计算方式。
用例 4:故障恢复
@Test
void testFailureRecovery() throws Exception {
List<Event> before = generateEvents(20, "recovery-before");
before.forEach(e -> sendToKafka(e, "eventd-raw"));
awaitEvents("eventd-cleaned", 20, Duration.ofSeconds(30));
// 1. 用 Savepoint 建立显式恢复点。Checkpoint 是否保留取决于
// externalized 配置和清理策略,不能假设 cancel 后一定还有
String savepoint = triggerSavepointAndWait(jobId(), Duration.ofMinutes(2));
cancelJob(jobId());
// 2. 作业停机期间继续生产:这些事件只可能由恢复后的作业处理
List<Event> after = generateEvents(20, "recovery-after");
after.forEach(e -> sendToKafka(e, "eventd-raw"));
// 3. 从 Savepoint 重新提交
submitFromSavepoint(savepoint);
awaitCondition(() -> "RUNNING".equals(jobState(jobId())), Duration.ofMinutes(2));
// 4. 两批共 40 个 eventId,每个都必须恰好处理一次
Set<String> expected = Stream.concat(before.stream(), after.stream())
.map(Event::eventId)
.collect(Collectors.toSet());
Map<String, Long> processed = awaitProcessedCounts(
"eventd:metrics:processed", 40, Duration.ofSeconds(30));
assertEquals(expected, processed.keySet(), "恢复后事件集合不一致(有丢失)");
assertTrue(processed.values().stream().allMatch(c -> c == 1L),
"存在重复处理: " + processed.entrySet().stream()
.filter(e -> e.getValue() > 1L).toList());
}
只发一批、等它处理完再重启,验证不到恢复路径:那批事件的结果早就写进 Redis 了。必须让一部分事件在停机期间进入 Kafka,才能确认恢复后既不漏读、也不重复读。
这个用例要求作业在 Sink 里用 HINCRBY eventd:metrics:processed <eventId> 1 记录每个事件被处理的次数;如果作业根本没写这个 Hash,awaitProcessedCounts 只会超时。用 SADD 写 Set 看起来更自然,但重复值在 Set 里会被合并,永远无法证明「不重」。
不要用 total >= 40 判断「不丢不重」:这个断言在重复处理时也会通过;Set 相等同样只能证明不丢,因为 SADD 时重复已经被合并。要证明不重,下游必须记录「每个 eventId 被处理了几次」(Hash + HINCRBY)并断言全为 1;如果下游只有 at-least-once 语义、也没有计数字段,就诚实地把验收标准写成「不丢,允许重复」,并说明重复窗口。
HINCRBY 本身不是幂等写:它统计的是 Sink 被调用的次数。如果 Sink 写完 Redis、还没到下一个 Checkpoint 就崩溃重放,同一个 eventId 会被再加一次,即使 Kafka 输出因为 EOS 没有重复。这个计数适合回答「Sink 是否被重复调用」;要回答「输出 Topic 是否重复」,还得读输出 Topic 并按 eventId 去重统计。若只想让重复写无害,可以改成 HSET processed:<eventId> 1,但那样就失去了检测重复的能力。
Savepoint 与 Checkpoint 的区别也要写进报告:Checkpoint 是自动的、可能被清理;Savepoint 是手工触发、用于升级和迁移。
用例 5:反压冒烟
@Test
void testBackpressureSmoke() throws Exception {
int total = 10_000;
ExecutorService sender = Executors.newSingleThreadExecutor();
try {
Future<?> producing = sender.submit(() -> {
try (RateLimitedProducer producer = new RateLimitedProducer(1_000)) { // 1000 条/秒
for (int i = 0; i < total; i++) {
producer.send(generateSingleEvent(i), "eventd-raw");
}
}
return null;
});
// 发送进行中就检查:等发完再看「峰值期间」等于没看
awaitCondition(() -> "RUNNING".equals(jobState(jobId())), Duration.ofMinutes(1));
awaitCondition(() -> consecutiveCheckpointFailures(jobId()) == 0, Duration.ofMinutes(1));
producing.get(Duration.ofMinutes(2).toMillis(), TimeUnit.MILLISECONDS);
} finally {
sender.shutdownNow();
}
// 停止生产后,lag 必须回落到阈值以下
awaitCondition(() -> totalLag("eventd-analytics") < 1_000, Duration.ofMinutes(5));
}
这是冒烟测试,只回答「会不会被压垮」,不回答「能扛多少」。容量结论要靠 14.1 的扩展性实验。用无限 submit 打满线程池再 sleep 30 秒,既可能打爆测试 JVM,也无法区分「消费完了」和「还在积压」。
10 秒的发送窗口通常覆盖不到一次 30 秒周期的 Checkpoint,所以「Checkpoint 没连续失败」可能只是因为期间根本没做 Checkpoint。要验证峰值下的 Checkpoint,至少让发送持续跨过 2–3 个 Checkpoint 周期(或把 execution.checkpointing.interval 调小),并在报告里写明实际覆盖了几次 Checkpoint。
验收记录模板
eventd v3 验收记录
==================
日期:
执行人:
代码 commit:
环境: <机器 / JDK / Kafka 分区 / Flink Slot / Redis>
用例 1 端到端可运行: <PASS / FAIL> 证据: <日志或截图路径>
用例 2 指标正确性: <PASS / FAIL> 证据: <输入清单 + 窗口键值>
用例 3 延迟预算: <PASS / FAIL> 证据: <单样本 __ ms 或 p99 = __ ms(样本数 __),预算 __ ms>
用例 4 故障恢复: <PASS / FAIL> 证据: <eventId 集合差异 = 0>
用例 5 反压冒烟: <PASS / FAIL> 证据: <峰值 lag / 恢复时间>
结论: <PASS / FAIL>
未执行或证据缺失的用例,一律记 FAIL,不得记 PASS。
六种「假通过」
| 写法 | 为什么不可信 | 正确做法 |
|---|---|---|
Thread.sleep(20000) 后直接结束 | 机器慢时没处理完也「通过」 | 轮询到条件满足或超时失败 |
只断言 redis.get(...) != null | 键存在不代表值正确 | 断言具体数值或集合 |
long foundTime = 0 | 没找到时延迟为负,恒小于预算 | 未找到直接 fail |
对 SSE 用 BodyHandlers.ofString() | 无限流永远不返回 | ofLines() + 限时读一帧 |
恢复后断言 count >= 40 | 重复处理也能通过 | 比对 eventId 集合 |
用 latest 键验收窗口 | 只看到最后一次覆盖 | 读带窗口起点和维度的键 |
小结
| 验收项 | 通过标准 | 失败时先查 |
|---|---|---|
| 端到端 | 100 条输入、100 条输出、SSE 可读 | Source 消费位点、清洗过滤条件 |
| 正确性 | 逐用户计数与预期一致 | Watermark 是否推进、窗口键是否含维度 |
| 延迟 | p99 在预算内 | 反压、Sink 阻塞、窗口等待 |
| 容错 | eventId 集合不丢不重 | Savepoint 路径、下游幂等键 |
| 反压 | 作业不挂、lag 可回落 | 瓶颈算子、Sink 吞吐、分区数 |
第 12 章结束。eventd v3 实现完成。下一节进入测试专题:13.1 Kafka Streams 测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。