12.3 v3 验收

eventd v3 全链路验收方案:端到端可运行、指标正确性、延迟预算、故障恢复与反压五个用例的可执行检查,配套环境记录表、结果记录模板,以及固定 sleep、只断言 key 存在、延迟初值为 0 等六种「假通过」陷阱的识别方法,并说明每个用例应保留的证据。

验收不是「跑一遍没报错」

本章给出一套可以照着执行的验收方案。它不预填任何 PASS——验收结果必须由执行人在目标环境跑出来,并保留证据。判断一个流处理系统是否验收通过,至少要看五个维度:

维度判定标准需要保留的证据
可运行五个组件健康,作业处于 RUNNINGdocker compose ps、Flink REST /jobs/overview
正确性确定性输入下,逐用户计数与手算一致输入清单、窗口键值、对比脚本输出
延迟生产到可查询的 p99 在预算内标记事件时间戳、轮询记录
容错崩溃恢复后事件集合不丢不重恢复前后 eventId 集合差异
反压峰值期间不崩溃,停止生产后 lag 回落backpressure 指标、lag 曲线

验收报告里最没有信息量的一句话是「功能正常」。每个结论都要能指向一个可复查的证据。

验收前先冻结三件事

  1. 版本与配置:代码 commit、镜像 digest、Kafka topic 分区数、Flink Slot 数、Checkpoint 目录。
  2. 输入数据:固定随机种子、固定 eventTime 基准、每条事件有唯一 eventId。否则失败后无法复现。
  3. 输出契约: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 测试 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南