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:00、10:01:00 和 10: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: 1 和 ReplicationFactor: 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使用long,12900 + 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。
后续要加入迟到事件、宽限期或抑制输出时,先把每条规则变成固定时间戳的拓扑测试,再让页面负责展示结果。这样排错时能分清是窗口语义、数据时间戳还是远程访问链路出了问题。
事实来源
- 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 - Apache Kafka 4.1.1
TimeWindowedKStream聚合与本地WindowStoreAPI 注释:kafka/streams/src/main/java/org/apache/kafka/streams/kstream/TimeWindowedKStream.java at 4.1.1 · apache/kafka · GitHub - Apache Kafka Streams DSL 窗口与聚合文档:https://kafka.apache.org/documentation/streams/developer-guide/dsl-api/
- Apache Kafka 官方 Docker 镜像标签:https://hub.docker.com/r/apache/kafka/tags
- cpolar 官方文档:文档 - cpolar 极点云官网