62.5 Flink / Kafka Streams

一句话

Flink 是「集群化的流批一体运行时」,Kafka Streams 是「嵌在应用里的流处理库」;选型先看部署边界、状态规模与是否需要多源/复杂事件处理,而不是看谁的 API 更熟。

两种形态,两种边界

Apache Flink Kafka Streams
形态 独立集群(JobManager + TaskManager) 普通 Java 库,跑在业务应用进程里
数据源 Kafka、JDBC、CDC、文件、MQ、Hive/Iceberg 主要 Kafka(source/sink 都是 topic)
状态 Keyed/Operator State,RocksDB/Heap,checkpoint + savepoint 本地 state store + changelog topic,standby
扩缩容 key group 重分配,可 rescale 受 Kafka 分区数约束,rebalance
Exactly-once checkpoint + 2PC sink / Kafka 事务 Kafka 事务(exactly_once_v2)
SQL Flink SQL / Table API ksqlDB(独立部署)
运维 集群、checkpoint 存储、作业管理 应用 + Kafka + 本地 state 目录
适合 多源、复杂 join/CEP、大状态、批流一体 Kafka 内轻量转换、服务内嵌、团队不想养集群

🔑 最本质的差别是部署边界:Flink 是一个「流处理集群」,作业是提交给集群的;Kafka Streams 是「你的服务里的一个库」,生命周期跟应用走。前者适合平台团队统一运维,后者适合业务团队自己拥有。

并行度从哪来:key group vs partition

Flink 的 Keyed State 按 key group 分配:

key --hash--> keyGroup = hash(key) % maxParallelism
keyGroup --range--> subtask = keyGroup / (maxParallelism / parallelism)

maxParallelism 一旦设定(默认 128,或按状态规模估算),扩容只在 key group 的边界上搬,不会把所有 key 重新哈希。

Kafka Streams 的并行度直接等于 Kafka 分区数:

partition = hash(key) % partitions     // 生产端决定
task = partition                        // 一个分区一个 task

Kafka 分区内的顺序与倾斜见 43.3 顺序与分区。

两条约束:

  1. Flink 的并行度上限是 maxParallelism;Kafka Streams 的上限是主题分区数,分区数不够,加实例也没用。
  2. 两者都不能拆单个热 key:同一个 key 永远落在同一个 key group/partition。

代码:热点 key 在两种模型下怎么分布

package main

import (
	"fmt"
	"hash/fnv"
	"sort"
)

type Key struct {
	Name   string
	Events int
}

// 一个明显的热点 key(K1 占 60%),其余是长尾。
var keys = []Key{
	{"K1", 601}, {"K2", 180}, {"K3", 100}, {"K4", 17},
	{"K5", 17}, {"K6", 17}, {"K7", 17}, {"K8", 17}, {"K9", 17}, {"K10", 17},
}

const maxParallelism = 8

func hash(s string) uint32 {
	h := fnv.New32a()
	_, _ = h.Write([]byte(s))
	return h.Sum32()
}

func flinkTask(hv uint32, p int) int {
	kg := int(hv % maxParallelism)
	return kg / (maxParallelism / p) // key group 连续区间分给 task
}

func kafkaPartition(hv uint32, p int) int { return int(hv % uint32(p)) }

func main() {
	fmt.Printf("总事件 %d;Flink maxParallelism=%d;Kafka 分区数随并行度变化\n\n", total(), maxParallelism)
	fmt.Printf("%-5s %-7s %-12s %-4s %-8s %-8s %-9s %-9s\n",
		"key", "events", "hash", "kg", "flink-p2", "flink-p4", "kafka-p2", "kafka-p4")
	for _, k := range keys {
		hv := hash(k.Name)
		fmt.Printf("%-5s %-7d %-12d %-4d %-8d %-8d %-9d %-9d\n",
			k.Name, k.Events, hv, hv%maxParallelism,
			flinkTask(hv, 2), flinkTask(hv, 4), kafkaPartition(hv, 2), kafkaPartition(hv, 4))
	}

	fmt.Println("\n=== 每个 task 的负载(事件数) ===")
	for _, p := range []int{2, 4} {
		fl := map[int]int{}
		kf := map[int]int{}
		for _, k := range keys {
			hv := hash(k.Name)
			fl[flinkTask(hv, p)] += k.Events
			kf[kafkaPartition(hv, p)] += k.Events
		}
		fmt.Printf("并行度 %d(Flink):", p)
		printLoad(fl, p)
		fmt.Printf("并行度 %d(Kafka):", p)
		printLoad(kf, p)
	}

	fmt.Println("\n=== 从 2 扩到 4:哪些 key 换了 task ===")
	moved := 0
	for _, k := range keys {
		hv := hash(k.Name)
		a, b := flinkTask(hv, 2), flinkTask(hv, 4)
		if a != b {
			moved++
			fmt.Printf("  %s: task%d -> task%d(%d 条)\n", k.Name, a, b, k.Events)
		}
	}
	fmt.Printf("共 %d/%d 个 key 迁移;K1 仍是单 task 热点(%d 条)\n", moved, len(keys), keys[0].Events)

	fmt.Println("\n结论:keyBy/分区决定状态归属;扩容只搬 key group/partition,不拆单个热 key。")
	fmt.Println("      热点 key 的吞吐上限 = 单个 task/partition 的上限,只能靠加盐/两阶段聚合解决。")
}

func total() int {
	s := 0
	for _, k := range keys {
		s += k.Events
	}
	return s
}

func printLoad(m map[int]int, p int) {
	ids := make([]int, 0, len(m))
	for id := range m {
		ids = append(ids, id)
	}
	sort.Ints(ids)
	for _, id := range ids {
		fmt.Printf("task%d=%d  ", id, m[id])
	}
	fmt.Println()
}

本机实测(macOS,Apple M1,Go 1.27.1):

总事件 1000;Flink maxParallelism=8;Kafka 分区数随并行度变化

key   events  hash         kg   flink-p2 flink-p4 kafka-p2  kafka-p4 
K1    601     401512545    1    0        0        1         1        
K2    180     351179688    0    0        0        0         0        
K3    100     367957307    3    0        1        1         3        
K4    17      451845402    2    0        1        0         2        
K5    17      468623021    5    1        2        1         1        
K6    17      418290164    4    1        2        0         0        
K7    17      435067783    7    1        3        1         3        
K8    17      250513974    6    1        3        0         2        
K9    17      267291593    1    0        0        1         1        
K10   17      4254713731   3    0        1        1         3        

=== 每个 task 的负载(事件数) ===
并行度 2(Flink):task0=932  task1=68  
并行度 2(Kafka):task0=231  task1=769  
并行度 4(Flink):task0=798  task1=134  task2=34  task3=34  
并行度 4(Kafka):task0=197  task1=635  task2=34  task3=134  

=== 从 2 扩到 4:哪些 key 换了 task ===
  K3: task0 -> task1(100 条)
  K4: task0 -> task1(17 条)
  K5: task1 -> task2(17 条)
  K6: task1 -> task2(17 条)
  K7: task1 -> task3(17 条)
  K8: task1 -> task3(17 条)
  K10: task0 -> task1(17 条)
共 7/10 个 key 迁移;K1 仍是单 task 热点(601 条)

结论:keyBy/分区决定状态归属;扩容只搬 key group/partition,不拆单个热 key。
      热点 key 的吞吐上限 = 单个 task/partition 的上限,只能靠加盐/两阶段聚合解决。

读数:

现象 数字 含义
Flink P=2 负载 932 / 68 K1、K2 落在同一 task,热点叠加
Flink P=4 负载 798 / 134 / 34 / 34 扩容后 K1 仍在一个 task,无法拆
Kafka P=2 负载 231 / 769 分区哈希把 K2/K3 分到另一侧,热点更集中
从 2 扩到 4 7/10 key 迁移 扩容要搬状态;Flink 按 key group,Kafka 按 partition
热 key K1=601(60.1%) 单 key 的吞吐上限 = 单 task/partition 上限

🔑 扩容只能解决「key 足够多、分布足够散」的倾斜;单个热 key 永远只能落在一个 task。解法是加盐(把 K1 拆成 K1#0..K1#9,两阶段聚合)或把热 key 的结果单独缓存/预聚合,而不是继续加并行度。

状态与 Exactly-once 的实现差异

Flink 的 barrier 对齐与恢复见 62.3 状态管理与容错;2PC 的提交顺序见 62.4 Exactly-once 语义。

Flink Kafka Streams
状态存储 RocksDB/Heap,本地 + checkpoint RocksDB,本地 + changelog topic
故障恢复 从 checkpoint 恢复状态 + 重放 从 changelog 重建 state store
一致性 checkpoint barrier + 2PC sink Kafka 事务 + 读已提交
升级 savepoint 停应用、改拓扑、重启(state 兼容性要测)
扩缩容 key group 迁移,状态随 key group 走 分区重分配,state 从 changelog 重建

⚠️ Kafka Streams 的「状态」不是靠 checkpoint,而是靠 changelog topic:每次 state 更新都写一条 changelog;实例崩溃后,新实例从 changelog 重放重建。changelog 的保留期必须覆盖「最坏恢复时间」,否则状态重建不完整。

延迟从哪来

来源 Flink Kafka Streams
处理 算子链、网络 shuffle、序列化 同样,但通常进程内
提交/检查点 checkpoint 间隔;2PC sink 在 checkpoint 时提交 commit.interval.ms;EOS 下提交更频繁
缓存 状态后端缓存、网络缓冲 statestore.cache.max.bytes / record cache
反压 checkpoint 对齐、慢 sink rebalance、慢处理

💡 我的判断:延迟敏感且只做 Kafka 内转换 → Kafka Streams;需要多源 join、CDC、复杂 CEP、大状态或批流一体 → Flink。 不要因为「Flink 更强大」就把一个 200 行的 Kafka 转换搬进 Flink 集群;也不要因为「Kafka Streams 轻」就让它承担跨 10 个数据源的 join。

生产落地:指名 → 可抄 → 可验 → 可退

指名:Flink(自建或 Ververica / Amazon Managed Flink);Kafka Streams(Confluent 生态、服务内嵌);SQL 化用 ksqlDB 或 Flink SQL。

可抄(Flink SQL 1.17+,未验证:本机未安装 Flink):

CREATE TABLE orders (
  order_id STRING,
  region   STRING,
  amount   DECIMAL(18,2),
  ts       TIMESTAMP(3),
  WATERMARK FOR ts AS ts - INTERVAL '5' SECOND
) WITH (
  'connector' = 'kafka',
  'topic' = 'orders',
  'properties.bootstrap.servers' = 'localhost:9092',
  'format' = 'json'
);

CREATE TABLE gmv_sink (
  window_start TIMESTAMP(3),
  window_end   TIMESTAMP(3),
  region       STRING,
  gmv          DECIMAL(18,2),
  PRIMARY KEY (window_start, region) NOT ENFORCED
) WITH (
  'connector' = 'kafka',
  'topic' = 'gmv',
  'properties.bootstrap.servers' = 'localhost:9092',
  'format' = 'json'
);

INSERT INTO gmv_sink
SELECT window_start, window_end, region, SUM(amount)
FROM TABLE(TUMBLE(TABLE orders, DESCRIPTOR(ts), INTERVAL '1' MINUTE))
GROUP BY window_start, window_end, region;

Kafka Streams(3.x,未验证):

StreamsBuilder b = new StreamsBuilder();
b.stream("orders", Consumed.with(Serdes.String(), orderSerde))
 .groupBy((k, v) -> v.region, Grouped.with(Serdes.String(), orderSerde))
 .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(1)))
 .aggregate(() -> BigDecimal.ZERO,
            (k, v, agg) -> agg.add(v.amount),
            Materialized.as("gmv-store"))
 .toStream()
 .map((k, v) -> KeyValue.pair(k.key(), v))
 .to("gmv", Produced.with(Serdes.String(), Serdes.serdeFrom(...)));
# Kafka Streams 关键配置
processing.guarantee=exactly_once_v2
commit.interval.ms=100
statestore.cache.max.bytes=10485760

可验:本节程序输出每个 key 的 task 归属与负载;Flink 侧看 Web UI 的 numRecordsIn/Out 与 checkpoint 指标;Kafka Streams 侧看 kafka-consumer-groups --describe 的 lag 与 state 目录大小。可退:Flink 用 savepoint 回滚;Kafka Streams 回滚要保证拓扑兼容(state store 名称/结构不变),否则先停写、重建 state;EOS 出问题可临时降级 at-least-once + 幂等 sink,但要记录「允许重复」的窗口。

反模式

反模式 后果
Kafka 分区数不足,靠加实例扩容 实例闲着,分区仍是瓶颈
maxParallelism 设得过大 key group 太多,小状态作业调度开销大
热 key 靠加并行度解决 单 key 永远在一个 task,扩容无效
Kafka Streams 的 changelog 保留期太短 崩溃后状态重建不完整,结果错
Flink 用 Heap 存 TB 级状态 GC/ checkpoint 超时
把 Kafka Streams 当多源 ETL 连接器与状态能力不够,最终自研一堆
只看吞吐不看 checkpoint/commit 延迟 端到端延迟被提交周期拖长
升级不做 state 兼容性测试 恢复失败或状态读错,只能重跑

自检

Q1 Flink 与 Kafka Streams 最本质的区别是什么?

▸ 答案:部署边界。Flink 是独立集群上的运行时,作业提交给集群,由平台团队统一运维;Kafka Streams 是嵌入业务应用的库,生命周期跟应用走,状态与 changelog 都依赖 Kafka。前者适合多源、大状态、复杂处理;后者适合 Kafka 内轻量转换、服务内嵌。

Q2 Flink 的 key group 与 Kafka 的 partition 在扩缩容上有什么不同?

▸ 答案:Flink 把 key 映射到固定数量的 key group(maxParallelism),扩容时按 key group 的连续区间重新分配给 subtask,状态随 key group 迁移;Kafka Streams 的并行度等于分区数,扩容时按 partition 重新分配,状态从 changelog 重建。前者扩容上限是 maxParallelism,后者上限是分区数。

Q3 本节 K1 有 601 条事件,把并行度从 2 加到 4 为什么还是热点?

▸ 答案:同一个 key 经过哈希永远落在同一个 key group/partition,扩容只改变「key group/partition 属于哪个 task」,不拆分单个 key。K1 的 601 条仍然在一个 task 上,单 task 的吞吐上限就是 K1 的上限。解法是加盐拆 key(两阶段聚合)或对热 key 单独预聚合/缓存。

Q4 从 2 扩到 4,7/10 个 key 迁移,意味着什么?

▸ 答案:意味着大部分 key 的状态要换 task。Flink 按 key group 迁移状态,Kafka Streams 按 partition 从 changelog 重建;迁移期间会有状态恢复/重分配开销,可能短暂反压。生产上要避免频繁扩缩容,扩之前先确认状态大小与恢复时间。

Q5 Flink 与 Kafka Streams 的 exactly-once 实现有什么不同?

▸ 答案:Flink 用 checkpoint barrier 保存算子状态,配合 2PC sink(或 Kafka 事务)在 checkpoint 完成之后提交;Kafka Streams 用 Kafka 事务把「消费位点 + 输出 + state changelog」放在同一个事务里(exactly_once_v2),下游用 read_committed 隔离。两者都依赖 sink/下游支持事务或幂等。

Q6 Kafka Streams 的 changelog 保留期为什么必须覆盖最坏恢复时间?

▸ 答案:Kafka Streams 的状态不是 checkpoint,而是本地 state store + changelog topic。实例崩溃后,新实例要从 changelog 最早可用的位置重放重建状态;如果 changelog 被过期清理,早期更新丢失,重建出的状态就不完整,结果会错。保留期要 ≥ 最坏恢复时间(含冷启动、积压、扩缩容)。

Q7 什么时候不该用 Flink,而该用 Kafka Streams 或更轻的方案?

▸ 答案:只做 Kafka 内的 filter/map/聚合、状态不大、团队不想维护 Flink 集群时,用 Kafka Streams;只做 stateless 转发/同步时,用 Kafka Connect 或普通 consumer;只有跨多源 join、CDC、复杂 CEP、大状态、批流一体时才值得上 Flink。选型先看「数据源与状态」,再看「团队运维能力」。

关联