从消息到流:为什么需要流处理

消息队列解决了系统间异步通信的问题,但把消息取回来之后,真正的计算才刚刚开始:统计、聚合、关联、告警,这些逻辑如果都交给下游应用自己写,每个消费方都要重复实现一遍,还容易因为处理速度跟不上消息产生速度而越积越多。流处理把"边收边算"的能力下沉到数据管道里,消息进来就即时加工,结果直接落地,这正是 Kafka Streams 这类流处理库存在的价值。

Kafka Streams 不是独立的消息平台,而是一个内嵌在应用里的 Java 库:它直接消费 Kafka 主题,用声明式的 API 描述计算拓扑,由框架负责并行、容错与状态管理。对于已经用 Kafka 的团队,它不需要额外部署集群,学习曲线和运维成本都低得多。

本文核心知识点
  • 流无界数据的有序序列,边到边处理。
  • 拓扑处理器节点与数据流组成的计算图。
  • 状态存储有状态计算的本地持久化仓库。
  • 窗口把无界流切成有界片段做聚合。
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"));

时间语义决定窗口边界怎么算:处理时间以当前机器时钟为准,简单但结果不稳定;事件时间以消息自带的时间戳为准,能正确处理乱序与延迟,生产环境默认推荐事件时间,同时配合延迟容忍上限处理迟到数据。