4. Streams DSL 基础:KStream、KTable、GlobalKTable 的区别与联系
好,咱们进入 Streams DSL 的核心了。这三个家伙——KStream、KTable、GlobalKTable——是 Kafka Streams 里最基础的数据抽象。说白了,它们决定了你的数据「长什么样」以及「怎么被处理」。
我记得刚接触 Kafka Streams 时,最困惑的就是:明明都是流数据,为什么搞出三种类型?后来踩了几个坑才明白——它们对应的是不同的数据处理场景。今天我就把这三者的区别和联系给你讲透。
4.1 先看一张总览图
在深入细节之前,我们先从整体上把握三者的定位。下面这张图展示了它们在数据模型、存储方式和典型用途上的核心差异。
4.2 KStream:最纯粹的记录流
KStream 是最直观的抽象。你可以把它想象成一条「事件流水线」——每条消息都是一个独立的事件,彼此之间没有状态关联。
核心特征:
- 每条记录都是独立的「事实」
- Key 可以重复,没有去重语义
- 不维护任何状态,纯粹是「流式处理」
- 适合过滤、转换、分支等无状态操作
典型场景: 日志收集、点击流分析、传感器数据上报。这些场景下,每条数据都是独立事件,不需要关心「之前发生过什么」。
// KStream 示例:从输入主题读取并过滤
KStream<String, String> sourceStream = builder.stream("input-topic");
KStream<String, String> filteredStream = sourceStream
.filter((key, value) -> value != null && value.contains("ERROR"));
filteredStream.to("error-topic");
你看,这段代码里没有状态、没有聚合,就是简单的「读-过滤-写」。我刚开始用 Kafka Streams 时,80% 的场景其实只需要 KStream 就够了。
4.3 KTable:带状态的变更日志
KTable 就复杂一些了。它维护的是「每个 Key 的最新状态」。你想想看,如果一条消息的 Key 是 "user_123",又来了一条同 Key 的消息,KTable 会用新值覆盖旧值。
核心特征:
- Key 是唯一的,后到的消息会覆盖先到的
- 内部维护 RocksDB 状态存储
- 支持 UPSERT 语义(插入或更新)
- 适合聚合、Join、查快照等有状态操作
我的经验: 我曾经在一个实时用户画像系统中用 KTable 维护「用户最新行为标签」。每次用户产生新行为,就更新对应 Key 的状态。这样下游服务随时可以查到用户的最新画像——不需要去数据库里查。
// KTable 示例:统计每个单词的出现次数
KTable<String, Long> wordCounts = sourceStream
.flatMapValues(value -> Arrays.asList(value.toLowerCase().split("\\W+")))
.groupBy((key, word) -> word)
.count(Materialized.as("counts-store"));
// 查询某个单词的当前计数
wordCounts.toStream().to("word-count-topic");
注意看,这里用了 groupBy 和 count,这两个操作都会触发状态存储。KTable 的底层就是靠 RocksDB 来维护这些聚合状态的。
避坑指南: 我曾经犯过一个错误——把 KTable 当成「全量数据表」来用。实际上 KTable 是按 Key 分片的,每个实例只持有部分 Key 的数据。如果你需要全局数据,得用 GlobalKTable。
4.4 GlobalKTable:全量广播的状态视图
GlobalKTable 是 KTable 的「加强版」。它把整个主题的数据复制到每个 Streams 实例中。说白了,每个实例都有一份完整的数据副本。
核心特征:
- 每个实例持有全量数据(不是分片)
- 数据模型与 KTable 相同(UPSERT 语义)
- 适合做「广播表」或「全局字典」
- Join 时不需要 Co-partitioning
典型场景: 地区编码表、商品分类字典、配置信息。这些数据量不大(通常几千到几万条),但每个处理节点都需要完整访问。
// GlobalKTable 示例:用地区编码表做流式 Join
GlobalKTable<String, String> regionTable = builder.globalTable("region-code-topic");
KStream<String, String> userStream = builder.stream("user-login-topic");
userStream.join(regionTable,
(leftKey, leftValue) -> leftKey, // 从流记录中提取 Join Key
(userValue, regionValue) -> userValue + " from " + regionValue
).to("enriched-login-topic");
这里有个关键点:GlobalKTable 做 Join 时,不需要流和表按相同方式分区。因为每个实例都有全量数据,所以 Join 可以在本地完成,没有网络开销。
4.5 三者的核心差异对比
我把它们的关键差异整理成了一张表,方便你对照查阅。
| 对比维度 | KStream | KTable | GlobalKTable |
|---|---|---|---|
| 数据模型 | 事件流(每条独立) | 变更日志(Key 唯一) | 变更日志(Key 唯一) |
| 状态存储 | 无 | 本地 RocksDB(分片) | 本地 RocksDB(全量) |
| Key 语义 | 可重复 | 唯一(后到覆盖) | 唯一(后到覆盖) |
| 数据分布 | 按 Key 分区 | 按 Key 分区 | 全量复制到每个实例 |
| Join 要求 | 需 Co-partitioning | 需 Co-partitioning | 无需 Co-partitioning |
| 典型操作 | filter, map, branch | aggregate, reduce, join | 全局 Join, 字典查询 |
| 数据量建议 | 无限制 | 单分区百万级 | 万级(全量副本) |
4.6 它们之间如何转换?
这三种抽象不是孤立的,它们之间可以互相转换。我画个简单的流程给你看:
// KStream → KTable:通过聚合
KTable<String, Long> table = stream.groupByKey().count();
// KTable → KStream:通过 toStream
KStream<String, Long> streamFromTable = table.toStream();
// KStream → GlobalKTable:不能直接转,需要先写回主题再读
stream.to("intermediate-topic");
GlobalKTable<String, String> globalTable = builder.globalTable("intermediate-topic");
我的建议: 实际项目中,我最常用的组合是「KStream 做过滤和转换,KTable 做聚合和状态维护,GlobalKTable 做字典 Join」。三者配合使用,基本能覆盖 90% 的流处理场景。
4.7 选型指南:什么时候用哪个?
我根据实际项目经验,给你一个简单的选型判断流程:
- 数据量极大,且不需要状态? → 用 KStream
- 需要按 Key 聚合或维护最新状态? → 用 KTable
- 数据量小(万级),且每个节点都需要完整数据? → 用 GlobalKTable
- 需要做流表 Join,且两个主题分区数不一致? → 考虑 GlobalKTable
避坑指南: 我曾经在一个项目中,把百万级的商品字典用 GlobalKTable 来存。结果每个实例的内存暴涨,GC 频繁。后来才意识到——GlobalKTable 适合小数据量,大数据量还是用 KTable 分片更靠谱。
4.8 小结
KStream、KTable、GlobalKTable 是 Kafka Streams 的三大基石。记住一句话:
- KStream = 事件流(无状态)
- KTable = 分片状态视图(有状态,按 Key 分片)
- GlobalKTable = 全量状态视图(有状态,全量副本)