Kafka Streams 窗口统计怎么验收:本地跑订单流聚合,用 cpolar 给同事看只读结果页

Kafka Streams 窗口统计怎么验收:本地跑订单流聚合,用 cpolar 给同事看只读结果页

做窗口统计时,我最怕的不是代码报错,而是代码没报错、数字却对不上。尤其是事件时间、窗口边界和迟到数据混在一起后,只看日志很难让测试同事快速确认结果。

这篇直接做一个可复现的小项目:3 条虚构订单进入 Kafka,Kafka Streams 按门店做 5 分钟滚动窗口统计,再由一个只读页面展示窗口起止时间、订单数和金额。Kafka Broker 与本地状态库不对外开放;需要异地验收时,cpolar 只映射这个结果页。

本文固定使用 Apache Kafka 4.1.1、Java 17 和 Maven 3.9。窗口 API 使用 TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5))。示例金额全部用"分"保存,避免浮点金额误差。

1 先看清这次要验收什么

本文的链路很短:

text 复制代码
虚构订单 -> orders Topic -> Kafka Streams 5 分钟窗口
         -> 本地 RocksDB 状态 -> 127.0.0.1:8080 只读 HTML
                                      |
                                      +-> cpolar HTTP 临时入口

滚动窗口(tumbling window)可以理解为首尾相接、互不重叠的固定时间段。本例窗口大小为 5 分钟,10:00:0010:01:0010:02:00 的事件都落进 [10:00, 10:05);恰好发生在 10:05:00 的事件属于下一个窗口。

这里特意选 ofSizeWithNoGrace:窗口结束后不再接收迟到记录,规则直观,验收时不用猜宽限期。生产系统若确实允许迟到数据,应改用 TimeWindows.ofSizeAndGrace(size, grace),并把宽限期写进验收标准。

2 环境准备:只把 Kafka 端口留在本机

新建目录:

bash 复制代码
mkdir kafka-window-review && cd kafka-window-review
mkdir -p src/main/java/demo

创建 compose.yaml

yaml 复制代码
services:
  kafka:
    image: apache/kafka:4.1.1
    container_name: kafka-window-review
    ports:
      - "127.0.0.1:9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1

注意端口左侧明确写了 127.0.0.1。不要改成 9092:9092 后再把主机暴露到公网,更不要为方便验收去穿透 Kafka Broker。

启动并创建输入 Topic:

bash 复制代码
docker compose up -d
docker exec kafka-window-review /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create --if-not-exists --topic orders --partitions 1 --replication-factor 1

检查结果:

bash 复制代码
docker exec kafka-window-review /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 --describe --topic orders

看到 PartitionCount: 1ReplicationFactor: 1 才继续。单分区是为了让这个验收样例的事件顺序稳定,不是生产容量建议。

3 编写 5 分钟窗口聚合项目

3.1 Maven 依赖

创建 pom.xml

xml 复制代码
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>demo</groupId>
  <artifactId>kafka-window-review</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <kafka.version>4.1.1</kafka.version>
    <jackson.version>2.18.3</jackson.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.apache.kafka</groupId><artifactId>kafka-streams</artifactId>
      <version>${kafka.version}</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId>
      <version>${jackson.version}</version>
    </dependency>
    <dependency>
      <groupId>org.slf4j</groupId><artifactId>slf4j-simple</artifactId><version>2.0.17</version>
    </dependency>
  </dependencies>
  <build><plugins>
    <plugin>
      <groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version>
    </plugin>
  </plugins></build>
</project>

3.2 数据对象与 JSON Serde

创建 src/main/java/demo/Order.java

java 复制代码
package demo;
public record Order(String orderId, String shopId, long amountFen) {}

创建 src/main/java/demo/Stat.java

java 复制代码
package demo;
public record Stat(long count, long amountFen) {
    public static Stat empty() { return new Stat(0, 0); }
    public Stat add(Order o) { return new Stat(count + 1, amountFen + o.amountFen()); }
}

创建 src/main/java/demo/JsonSerde.java

java 复制代码
package demo;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.kafka.common.errors.SerializationException;
import org.apache.kafka.common.serialization.*;

public final class JsonSerde<T> implements Serde<T> {
    private static final ObjectMapper JSON = new ObjectMapper();
    private final Class<T> type;
    public JsonSerde(Class<T> type) { this.type = type; }
    public Serializer<T> serializer() {
        return (topic, value) -> {
            try { return value == null ? null : JSON.writeValueAsBytes(value); }
            catch (Exception e) { throw new SerializationException(e); }
        };
    }
    public Deserializer<T> deserializer() {
        return (topic, bytes) -> {
            try { return bytes == null ? null : JSON.readValue(bytes, type); }
            catch (Exception e) { throw new SerializationException(e); }
        };
    }
}

3.3 聚合拓扑与只读页面

创建 src/main/java/demo/App.java

java 复制代码
package demo;

import com.sun.net.httpserver.HttpServer;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.*;
import org.apache.kafka.streams.kstream.*;
import org.apache.kafka.streams.state.*;

import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;

public final class App {
    static final String STORE = "order-stats-store";

    static Topology topology() {
        StreamsBuilder b = new StreamsBuilder();
        b.stream("orders", Consumed.with(Serdes.String(), new JsonSerde<>(Order.class)))
         .selectKey((key, order) -> order.shopId())
         .groupByKey(Grouped.with(Serdes.String(), new JsonSerde<>(Order.class)))
         .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
         .aggregate(Stat::empty, (shop, order, stat) -> stat.add(order),
             Materialized.<String, Stat, WindowStore<org.apache.kafka.common.utils.Bytes, byte[]>>as(STORE)
                 .withKeySerde(Serdes.String()).withValueSerde(new JsonSerde<>(Stat.class)));
        return b.build();
    }

    public static void main(String[] args) throws Exception {
        Properties p = new Properties();
        p.put(StreamsConfig.APPLICATION_ID_CONFIG, "order-window-review-v1");
        p.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        p.put(StreamsConfig.DEFAULT_TIMESTAMP_EXTRACTOR_CLASS_CONFIG,
              "org.apache.kafka.streams.processor.WallclockTimestampExtractor");
        p.put(StreamsConfig.STATE_DIR_CONFIG, "./data/kafka-streams");

        KafkaStreams streams = new KafkaStreams(topology(), p);
        streams.start();

        HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 8080), 0);
        http.createContext("/", exchange -> {
            String html;
            try { html = render(streams); }
            catch (Exception e) { html = "<h1>状态库尚未就绪</h1><p>刷新页面即可。</p>"; }
            byte[] body = html.getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
            exchange.getResponseHeaders().set("Cache-Control", "no-store");
            exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
            exchange.sendResponseHeaders(200, body.length);
            exchange.getResponseBody().write(body);
            exchange.close();
        });
        http.start();
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            http.stop(0); streams.close(Duration.ofSeconds(10));
        }));
        System.out.println("只读结果页:http://127.0.0.1:8080");
    }

    static String render(KafkaStreams streams) {
        ReadOnlyWindowStore<String, Stat> store = streams.store(
            StoreQueryParameters.fromNameAndType(STORE, QueryableStoreTypes.windowStore()));
        Instant from = Instant.now().minus(Duration.ofHours(1));
        Instant to = Instant.now().plus(Duration.ofMinutes(1));
        DateTimeFormatter f = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
            .withZone(ZoneId.systemDefault());
        StringBuilder rows = new StringBuilder();
        try (KeyValueIterator<Windowed<String>, Stat> it = store.fetchAll(from, to)) {
            while (it.hasNext()) {
                var row = it.next(); var w = row.key.window(); var s = row.value;
                rows.append("<tr><td>").append(row.key.key()).append("</td><td>")
                    .append(f.format(w.startTime())).append("</td><td>")
                    .append(f.format(w.endTime())).append("</td><td>")
                    .append(s.count()).append("</td><td>")
                    .append(String.format(Locale.ROOT, "%.2f", s.amountFen()/100.0))
                    .append("</td></tr>");
            }
        }
        return """
          <!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width">
          <title>订单窗口统计验收</title><style>body{font:16px system-ui;max-width:900px;margin:40px auto;padding:0 16px}table{border-collapse:collapse;width:100%%}th,td{border:1px solid #ddd;padding:10px;text-align:left}th{background:#f5f5f5}</style>
          <h1>订单窗口统计验收</h1><p>只读页面:无写入、删除、查询参数和管理入口。</p>
          <table><thead><tr><th>门店</th><th>窗口开始</th><th>窗口结束</th><th>订单数</th><th>金额/元</th></tr></thead><tbody>%s</tbody></table>
          """.formatted(rows);
    }
}

这里别加"提交订单"按钮,也别接受 URL 查询条件。验收页能力越少,临时开放时的攻击面越小。

4 启动应用并写入固定订单

先编译:

bash 复制代码
mvn clean package

启动程序:

bash 复制代码
mvn exec:java -Dexec.mainClass=demo.App

程序打印 只读结果页:http://127.0.0.1:8080 后,新开终端写入 3 条虚构订单:

bash 复制代码
printf '%s\n' \
'{"orderId":"demo-001","shopId":"shop-a","amountFen":12900}' \
'{"orderId":"demo-002","shopId":"shop-a","amountFen":3500}' \
'{"orderId":"demo-003","shopId":"shop-b","amountFen":8800}' \
| docker exec -i kafka-window-review /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server localhost:9092 --topic orders

本例使用 WallclockTimestampExtractor,事件时间取应用处理记录时的系统时间。连续粘贴这 3 条记录,正常会进入同一个 5 分钟窗口;别在窗口最后几秒执行测试,否则记录会跨窗。需要严格复测边界时,应在生产者中给 ProducerRecord 指定固定时间戳,而不是依赖人工粘贴速度。

打开:

text 复制代码
http://127.0.0.1:8080

同窗情况下,表格应有两行核心结果:

门店 订单数 金额/元
shop-a 2 164.00
shop-b 1 88.00

这一步不是为了"看页面能不能开",而是同时核对三件事:分组键是 shopId、同门店订单数累加、金额按分求和后再展示成元。如果 shop-a 出现两行,先看执行时间是否跨过 5 分钟边界;如果页面显示状态库未就绪,等 Streams 状态进入 RUNNING 后刷新。

5 怎样验收窗口边界和迟到记录

5.1 边界规则

TimeWindows.ofSizeWithNoGrace(5 分钟) 创建的是 [start, end) 窗口,左闭右开。验收单要明确记录:

  • 10:04:59.999 属于 [10:00, 10:05)
  • 10:05:00.000 属于 [10:05, 10:10)
  • 分组发生在窗口聚合前,不同 shopId 不会合并;
  • amountFen 使用 long12900 + 3500 = 16400 分。

5.2 迟到数据规则

Kafka Streams 用当前任务观察到的最大事件时间推进 stream time。无宽限期窗口关闭后,再到达且时间戳仍落在已关闭窗口中的记录会被丢弃,并记录 dropped-records 指标。

本文的命令行演示采用处理时钟,不伪造迟到事件。要验证迟到行为,应写自动化拓扑测试:先送入一个时间更晚的记录推进 stream time,再送入旧时间戳记录,断言旧窗口聚合值不变。这比手改系统时间可靠,也不会把"发送晚"误当成"事件时间晚"。

6 用 cpolar 只开放结果页

先确认本地页面能访问:

bash 复制代码
curl -I http://127.0.0.1:8080

再启动临时 HTTP 隧道:

bash 复制代码
cpolar http 8080

终端会显示实际公网地址,把 HTTPS 地址 发给验收同事。这里映射的是 8080,只读页面绑定在 127.0.0.1;9092 Kafka 端口、cpolar 的 9200 管理页、Kafka Streams 状态目录都没有对外开放。

建议同事只检查:

  • 页面标题是否为"订单窗口统计验收";
  • 窗口起止时间是否相差 5 分钟;
  • shop-a 是否为 2 单、164.00 元;
  • 页面是否不存在写入、删除和管理按钮。

如果公网地址打开后报错,先回到本机执行 curl http://127.0.0.1:8080。本地不通就查应用,本地正常再看 cpolar 的在线隧道状态,别一上来反复重建隧道。

7 常见问题与完整收尾

页面一直显示"状态库尚未就绪"

先看应用日志中 Kafka Streams 是否已从 REBALANCING 进入 RUNNING,再确认 orders Topic 已创建、Broker 地址为 localhost:9092。首次启动状态库需要初始化,页面刷新不会触发写操作。

金额出现小数误差

业务层不要用 double 累加金额。本例在流中保存分,只有 HTML 展示时除以 100。

同一门店出现两行

这是窗口结果,不是门店总表。先比较两行的窗口起止时间;若不同,说明写入跨过了 5 分钟边界。

改过代码后仍读到旧结果

停止应用后删除本地演示状态并更换应用 ID,或者完整清理本次实验:

bash 复制代码
rm -rf data/kafka-streams
docker compose down -v

验收结束后,在运行 cpolar http 8080 的终端按 Ctrl+C,先关闭公网入口;再在应用终端按 Ctrl+C,最后关闭 Kafka:

bash 复制代码
docker compose down -v

不要只关浏览器标签页,那不会停止隧道。也不要保留临时公网地址当长期入口;若团队需要长期看板,应补上认证、访问审计、限流和固定部署方案。

8 总结

现在这条验收链路已经闭环:虚构订单进入本机 Kafka,Kafka Streams 按门店完成 5 分钟滚动窗口聚合,本地只读页把窗口边界、订单数和金额摆到同一张表里,异地同事只接触 cpolar 提供的临时 HTTPS 页面。

  • Kafka 只监听本机,窗口 API 明确使用 ofSizeWithNoGrace
  • 验收不只看页面可用,还核对分组键、窗口边界与金额单位;
  • cpolar 只映射 8080 只读页,结束后按顺序关闭隧道、应用和 Broker。

后续要加入迟到事件、宽限期或抑制输出时,先把每条规则变成固定时间戳的拓扑测试,再让页面负责展示结果。这样排错时能分清是窗口语义、数据时间戳还是远程访问链路出了问题。


事实来源

  1. Apache Kafka 4.1.1 TimeWindows 源码与 API 注释:kafka/streams/src/main/java/org/apache/kafka/streams/kstream/TimeWindows.java at 4.1.1 · apache/kafka · GitHub
  2. Apache Kafka 4.1.1 TimeWindowedKStream 聚合与本地 WindowStore API 注释:kafka/streams/src/main/java/org/apache/kafka/streams/kstream/TimeWindowedKStream.java at 4.1.1 · apache/kafka · GitHub
  3. Apache Kafka Streams DSL 窗口与聚合文档:https://kafka.apache.org/documentation/streams/developer-guide/dsl-api/
  4. Apache Kafka 官方 Docker 镜像标签:https://hub.docker.com/r/apache/kafka/tags
  5. cpolar 官方文档:文档 - cpolar 极点云官网
相关推荐
爱读源码的大都督1 小时前
DeepSeek面试官问:生产RAG系统回答不准确,该如何定位和优化?这样回答,能让面试官当场给你Offer!
java·后端·python
杨运交2 小时前
[069][公共模块]Spring Boot 全局异常处理与参数校验实战(下):校验异常精细化处理与 WebFlux 适配
java·spring boot·后端
广州灵眸科技有限公司2 小时前
瑞芯微(EASY EAI)RV1126B 星闪使用
运维·人工智能·科技·docker·容器
努力努力再努力wz2 小时前
【Docker入门系列】:从 Namespace 到 CGroup:一文理解容器资源控制、cgroup v2 与 Memory Controller 实战
运维·docker·容器
Raas1002 小时前
MAI Gateway(魔芋企业级AI网关)对比分析:AI网关和OpenRouter区别?企业级能力差距一览
java·服务器·网络·人工智能·gateway·ai网关·mai gateway
APItesterCris2 小时前
告别人工盯品!借助 Open‑Claw 快速搭建电商商品全自动监控与数据分析系统(完整实操代码)
java·大数据·前端·数据库
青山木2 小时前
Hot 100 --- 跳跃游戏 II
java·数据结构·算法·leetcode·贪心算法
ly76892 小时前
Spring 中的 @Configuration 与 @Component 差异:为何代理时机决定 Bean 生命周期行为
java·后端·spring·注解·代理·bean生命周期
qq_452396232 小时前
第十二篇:《数据采集:Grafana Alloy、Fluent Bit、Vector 的选型与配置》
java·贪心算法·grafana