从消息到流:为什么需要流处理
消息队列解决了系统间异步通信的问题,但把消息取回来之后,真正的计算才刚刚开始:统计、聚合、关联、告警,这些逻辑如果都交给下游应用自己写,每个消费方都要重复实现一遍,还容易因为处理速度跟不上消息产生速度而越积越多。流处理把"边收边算"的能力下沉到数据管道里,消息进来就即时加工,结果直接落地,这正是 Kafka Streams 这类流处理库存在的价值。
Kafka Streams 不是独立的消息平台,而是一个内嵌在应用里的 Java 库:它直接消费 Kafka 主题,用声明式的 API 描述计算拓扑,由框架负责并行、容错与状态管理。对于已经用 Kafka 的团队,它不需要额外部署集群,学习曲线和运维成本都低得多。
- 流无界数据的有序序列,边到边处理。
- 拓扑处理器节点与数据流组成的计算图。
- 状态存储有状态计算的本地持久化仓库。
- 窗口把无界流切成有界片段做聚合。
Kafka Streams 的核心概念
理解 Kafka Streams,先抓住四个词:KStream 是记录流,每条消息独立流过;KTable 是键值表,同一键的后续消息会覆盖旧值;GlobalKTable 是全量副本表,适合做只读关联;StateStore 则是挂在处理器上的本地状态,用来保存聚合中间结果。
流的源头是主题,流的终点也是主题。你写代码时只需要定义从哪个主题读、经过哪些变换、写到哪个主题,框架把分区、拉取、提交这些细节都包起来。因为计算状态和 Kafka 日志绑在一起,任务挂了可以从未提交的偏移量处恢复,状态也能从变更日志主题重建。
StreamsBuilder builder = new StreamsBuilder();
KStream<String, String> source = builder.stream("user-action");
source
.mapValues(value -> normalize(value))
.filter((key, value) -> value != null)
.to("user-action-clean");
第一个流处理拓扑
拓扑由源处理器、流操作和汇处理器组成。最简单的拓扑就是上面那段代码:读主题、清洗、写回另一个主题。生产环境的拓扑会复杂得多,常见操作包括 map 变换字段、filter 过滤脏数据、groupBy 按键分组、aggregate 聚合累计,以及 join 关联两条流。
// 按用户统计每分钟操作次数
source
.groupBy((key, value) -> extractUserId(value))
.windowedBy(TimeWindows.of(Duration.ofMinutes(1)))
.count()
.toStream()
.to("user-action-count");
拓扑写好后,用 KafkaStreams 实例启动,框架会根据主题分区数自动分配任务。一个分区对应一个任务,任务内部的处理是顺序的,保证了同一键的数据不会被并行乱序处理,这是流处理正确性的根基。
状态存储与有状态计算
没有状态的流处理只能做逐条变换,像计数、去重、滑动统计这类需求必须有地方记住"之前发生了什么"。Kafka Streams 的状态存储默认是 RocksDB,本地落盘,每个任务持有自己分区的状态;同时状态的变化会异步写入一个变更日志主题,任务重启后从日志回放恢复。
- 放哪本机 RocksDB,访问快、不占堆内存。
- 怎么恢复变更日志主题回放,自动重建。
- 怎么共享通过交互式查询暴露只读快照。
// 去重:记录每个键最近一次出现时间
KStream<String, String> deduped = source
.groupByKey()
.reduce((oldV, newV) -> newV,
Materialized.as("dedup-store"));
状态存储占用的磁盘要规划好:任务数乘单任务状态上限就是集群需要的总容量。状态变更日志主题的保留时间要覆盖恢复窗口,否则日志被清理后就无法完整重建状态。
窗口聚合与时间语义
窗口把无界的数据流切成有界的片段,让聚合有了"周期"的概念。常用窗口有三种:滚动窗口固定长度不重叠,适合每分钟计数;滑动窗口有步长和长度,适合做滚动平均;会话窗口按不活动间隔切分,适合用户行为分段。窗口聚合的典型产物是时间窗 + 键 + 聚合值,下游按窗口时间消费即可。
// 滚动窗口:每 5 分钟统计一次订单金额
orders
.groupBy((k, v) -> v.getRegion())
.windowedBy(TimeWindows.of(Duration.ofMinutes(5)))
.aggregate(() -> 0.0,
(key, order, total) -> total + order.getAmount(),
Materialized.as("region-total"));
时间语义决定窗口边界怎么算:处理时间以当前机器时钟为准,简单但结果不稳定;事件时间以消息自带的时间戳为准,能正确处理乱序与延迟,生产环境默认推荐事件时间,同时配合延迟容忍上限处理迟到数据。
流表连接与拓扑调优
流与流连接需要按键对齐,通常要先共用一个 repartition 主题;流与表连接则是给每条流记录去查一张当前快照表,比如订单流关联用户维表补全用户信息。GlobalKTable 全量加载到每个实例,查询不依赖分区亲和,适合小维表;大维表则用 KTable 按分区分布。
// 订单流关联用户表
KStream<String, Order> enriched = orders.join(
usersTable,
(orderId, order) -> order.getUserId(),
(order, user) -> order.withUser(user));
调优从资源与参数两头入手:任务线程数 num.stream.threads 通常与 CPU 核数匹配;消息较大时调大 fetch 与 buffer 参数;拓扑里出现频繁重分区时,检查键是否稳定,减少不必要的 repartition 主题。聚合计算密集的场景,可以先用 mapValues 精简字段,只保留聚合真正需要的列,减轻序列化开销。
落地清单
- 明确处理语义:能接受延迟与重复时用至少一次,否则做幂等输出。
- 统一使用事件时间并设置延迟容忍,正确处理乱序消息。
- 状态存储按任务数规划磁盘,变更日志保留时间要覆盖恢复窗口。
- 键设计稳定,避免频繁重分区带来的额外主题与开销。
- 输出主题设置合理分区数,与下游消费能力匹配。
- 监控任务延迟、活跃任务数与状态存储增长,及时扩容。