01 为什么 99% 的窗口作业都用 Keyed Window
上一篇讲了 Global Window——把所有元素放进同一个窗口,触发时机完全自定义。但 Global Window 有个致命问题:所有数据都在一个窗口里,并行度只能是 1,数据量稍大就成了作业瓶颈。
实际生产中,99% 的窗口作业用的都是 Keyed Window——先 keyBy 再 window。比如统计每个用户的 PV、每个商品的销售额、每个地区的 TopN,这些场景都有一个共同特点:按某个维度分组统计,每个维度独立计算。
Keyed Window 的核心优势是并行计算:每个 key 的窗口独立维护状态、独立触发计算,不同 key 分布在不同的 TaskManager 上,真正实现分布式并行处理。
但 Keyed Window 也不是银弹——key 选不好会导致数据倾斜,个别热点 key 拖垮整个作业;状态管理不当会 OOM;滑动窗口的状态倍数容易被忽略。
这篇文章把 Keyed Window 从内部并行计算原理、KeyGroup 机制、状态管理,到三个完整实战案例、数据倾斜识别与优化,一次讲透。
02 Keyed Window 内部机制与并行计算
元素处理全链路
一个元素从进入 Keyed Window 到输出结果,走 4 步:
-
数据源 Source:无界流输入,每个元素包含 key(如 userId)和 value(如事件数据)。此时数据还没有分区,随机分布在各个并行实例上。
-
keyBy 按 key Hash 分区:keyBy 不是物理转换,而是逻辑分区。它根据 KeySelector 提取 key,计算 hashCode,对最大并行度取模,分配到对应的 KeyGroup。同一个 key 的数据一定会被路由到同一个 KeyGroup。
-
window() 每个 key 独立创建窗口:Keyed Window 下,每个 key 独立维护自己的窗口状态。user1 的 5 分钟窗口和 user2 的 5 分钟窗口完全独立,互不影响。不同 KeyGroup 分布在不同的 TaskManager 上,真正并行计算。
-
输出:窗口触发后,每个 key 的 WindowFunction 独立计算并输出结果。输出流中每条数据都带有 key 信息。
Keyed Window vs Non-Keyed Window
| 对比项 | Keyed Window(推荐) | Non-Keyed Window(windowAll) |
|---|---|---|
| API | keyBy(...).window(...) | stream.windowAll(...) |
| 并行度 | 可以设置任意并行度 | 强制为 1 |
| 状态 | 每个 key 独立维护,分散存储 | 所有数据集中存储 |
| 性能 | 高,分布式并行处理 | 低,成为作业瓶颈 |
| 适用 | 99% 的生产场景,按维度统计 | 小数据量全局统计,或必须全局排序 |
| 风险 | key 选择不当导致数据倾斜 | 数据量大会 OOM |
生产建议:除非必须全局统计(如全局 PV),否则都用 Keyed Window。windowAll 的并行度强制为 1,数据量稍大就会成为作业瓶颈,甚至 OOM。
03 KeyGroup 与最大并行度:Keyed Window 并行计算的核心
Keyed Window 能并行计算的核心是 KeyGroup(键组)机制。理解了 KeyGroup,就理解了 Flink 的状态管理和扩缩容原理。
KeyGroup 是什么
Flink 的 keyBy 不是直接把 key 映射到并行实例,而是先映射到 KeyGroup,KeyGroup 再分配到并行实例。KeyGroup 的数量由**最大并行度(maxParallelism)**决定,默认是 128。
计算公式:
// 第一步:key 映射到 KeyGroup
int keyGroupId = MathUtils.murmurHash(key.hashCode()) % maxParallelism;
// 第二步:KeyGroup 映射到并行实例
int parallelInstanceId = keyGroupId * parallelism / maxParallelism;
为什么要多一层 KeyGroup
直接把 key 映射到并行实例不行吗?为什么要多一层 KeyGroup?
答案是为了支持动态扩缩容。如果直接把 key 映射到并行实例,改变并行度时所有 key 的分布都会变,状态无法恢复。有了 KeyGroup,改变并行度只是 KeyGroup 在实例间重新分配,每个 key 的 KeyGroup 不变,状态可以平滑迁移。
举个例子:maxParallelism=128,当前并行度=4,每个并行实例负责 32 个 KeyGroup。如果扩容到并行度=8,每个并行实例负责 16 个 KeyGroup,只是把原来的 KeyGroup 重新分配,每个 key 属于哪个 KeyGroup 没变,状态可以平滑迁移。
最大并行度的注意事项
- 一旦设置就不能改:maxParallelism 决定了 KeyGroup 的数量,改了之后 key 到 KeyGroup 的映射就变了,状态无法恢复。所以设置前要考虑清楚未来的扩容需求。
- 默认 128 够用:大部分场景默认 128 就够了,支持最大 128 并行度。如果预计未来要扩到超过 128 并行度,需要提前设置更大的值(如 4096)。
- 设置方式:
env.setMaxParallelism(4096)全局设置,或keyBy(...).window(...).setMaxParallelism(4096)单算子设置。
04 Keyed Window 状态管理与分区
状态四层命名空间
Keyed Window 的状态不是简单的一个 Map,而是有四层命名空间,从大到小:
-
第一层:Operator State(算子状态):每个算子实例独立的状态,与 key 无关。Source 偏移量、缓冲区大小等。Keyed Window 不直接使用这一层。
-
第二层:Keyed State(按键状态):每个 key 独立的状态命名空间。keyBy 后所有状态都是 Keyed State,按 key 隔离存储。user1 的状态和 user2 的状态完全独立。
-
第三层:Window Namespace(窗口命名空间):每个窗口独立的状态命名空间。同一个 key 的不同窗口(如 0-5分钟和5-10分钟)状态隔离。窗口销毁时清理该命名空间下的所有状态。
-
第四层:State Descriptor(状态描述符):具体的状态变量,如 ListState(窗口元素列表)、ValueState(聚合结果)、MapState(映射表)。每个状态有独立的名称和类型。
这四层命名空间的意义是:状态按 key 和窗口隔离,每个 key 每个窗口的状态独立存储、独立清理,不会互相干扰。
Window State vs Keyed State
Keyed Window 涉及两种状态,容易搞混:
| 对比项 | Window State(窗口状态) | Keyed State(用户自定义状态) |
|---|---|---|
| 存储内容 | 窗口内的所有元素(ListState),或增量聚合的中间结果(ValueState) | 用户在 RichFunction 中通过 RuntimeContext 创建的状态,如去重集合、累计值 |
| 生命周期 | 窗口创建时创建,窗口触发并 PURGE 后销毁(或 allowedLateness 超时后销毁) | 由用户控制,默认永久存在 |
| 访问方式 | WindowFunction 通过 Context 访问,或 Trigger 通过 TriggerContext 访问 | 在 open() 中通过 getRuntimeContext().getState(desc) 获取 |
| 自动清理 | 窗口销毁时自动清理,不需要手动管理 | 默认不清理,必须配置 StateTtlConfig,否则状态无限增长 |
| 典型大小 | 取决于窗口大小和数据量 | 取决于业务逻辑,可能很大 |
关键区别:Window State 是 Flink 自动管理的,窗口销毁时自动清理;Keyed State 是用户自己创建的,默认永久存在,必须配置 TTL 自动清理,否则状态会无限增长最终 OOM。
05 三种状态后端对比
Keyed Window 的状态存储在状态后端(StateBackend)中,Flink 提供三种状态后端,各有优劣:
| 对比项 | HashMapStateBackend(堆内存) | FileSystemStateBackend(文件系统) | EmbeddedRocksDBStateBackend(RocksDB) |
|---|---|---|---|
| 运行时存储 | TaskManager JVM 堆内存 | TaskManager JVM 堆内存 | TaskManager 本地磁盘(RocksDB 实例) |
| 读写速度 | 最快,直接内存访问 | 快,运行时直接内存访问 | 较慢,需要序列化/反序列化 + 磁盘 IO |
| 容量限制 | 受限于堆内存大小 | 运行时受堆内存限制,Checkpoint 受磁盘限制 | 受限于本地磁盘大小,支持 TB 级状态 |
| Checkpoint 方式 | 全量快照,同步到 JobManager | 异步快照,写入文件系统 | 增量快照,只上传变化的 SST 文件 |
| 适用场景 | 小状态、测试、本地调试 | 中等状态、大 Checkpoint | 大状态、生产环境推荐 |
| 注意事项 | 状态大了会 OOM,生产不推荐 | 运行时状态仍在堆内存,大状态会 OOM | 读写比堆内存慢 5-10 倍,但支持超大状态 |
生产建议:
- 状态小于 1GB:用 HashMapStateBackend,速度最快
- 状态 1GB-10GB:用 FileSystemStateBackend,兼顾速度和 Checkpoint 大小
- 状态大于 10GB:必须用 EmbeddedRocksDBStateBackend,支持 TB 级状态和增量 Checkpoint
设置方式:
// 全局设置
env.setStateBackend(new EmbeddedRocksDBStateBackend());
env.getCheckpointConfig().setCheckpointStorage("hdfs:///flink/checkpoints");
// 或单算子设置(Flink 1.13+ 不推荐,建议全局设置)
06 案例一:按用户统计每分钟 PV
最基础的 Keyed Window 用法——按 userId 分区,统计每个用户每分钟的页面访问量。
完整代码
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.AggregateFunction;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import java.time.Duration;
public class UserPvExample {
public static class PvEvent {
private String userId;
private String page;
private long timestamp;
public PvEvent(String userId, String page, long timestamp) {
this.userId = userId; this.page = page; this.timestamp = timestamp;
}
public String getUserId() { return userId; }
public long getTimestamp() { return timestamp; }
}
public static class PvResult {
private String userId;
private long windowStart;
private long windowEnd;
private long pv;
public PvResult(String userId, long ws, long we, long pv) {
this.userId = userId; this.windowStart = ws; this.windowEnd = we; this.pv = pv;
}
@Override
public String toString() {
return String.format("用户=%s, 窗口[%d~%d), PV=%d", userId, windowStart, windowEnd, pv);
}
}
// 增量聚合:计数
public static class PvAggregator implements AggregateFunction<PvEvent, Long, Long> {
@Override
public Long createAccumulator() { return 0L; }
@Override
public Long add(PvEvent event, Long acc) { return acc + 1; }
@Override
public Long getResult(Long acc) { return acc; }
@Override
public Long merge(Long a, Long b) { return a + b; }
}
// 全量窗口函数:包装增量结果,输出带窗口信息
public static class PvWindowFunction extends ProcessWindowFunction<Long, PvResult, String, TimeWindow> {
@Override
public void process(String userId, Context context, Iterable<Long> elements,
Collector<PvResult> out) {
long pv = elements.iterator().next(); // 增量聚合只有一个结果
out.collect(new PvResult(userId, context.window().getStart(),
context.window().getEnd(), pv));
}
}
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<PvEvent> source = env.fromElements(
new PvEvent("user1", "home", 1000L),
new PvEvent("user1", "product", 2000L),
new PvEvent("user2", "home", 3000L),
new PvEvent("user1", "cart", 61000L),
new PvEvent("user2", "product", 62000L)
);
// 分配时间戳和 Watermark
DataStream<PvEvent> withTimestamps = source.assignTimestampsAndWatermarks(
WatermarkStrategy.<PvEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, rt) -> event.getTimestamp())
);
// Keyed Window:按 userId 分区,1 分钟滚动窗口
DataStream<PvResult> result = withTimestamps
.keyBy(PvEvent::getUserId)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.aggregate(new PvAggregator(), new PvWindowFunction());
result.print();
env.execute("User PV Example");
}
}
关键点说明
keyBy(PvEvent::getUserId):按 userId 分区,每个用户独立计算窗口TumblingEventTimeWindows.of(Time.minutes(1)):1 分钟滚动事件时间窗口aggregate(new PvAggregator(), new PvWindowFunction()):增量聚合 + 全量窗口函数组合,既省内存又能拿到窗口信息- 用户 ID 基数大(几十万到几百万),分布均匀,并行度高,每个 key 的窗口状态小
- 注意:用户 ID 要做哈希均匀分布,避免用自增 ID 或有规律的 ID 导致热点
07 案例二:按商品统计每小时销售额
常见的电商场景——按 productId 分区,统计每个商品每小时的销售额。
完整代码
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.ReduceFunction;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import java.time.Duration;
public class ProductSalesExample {
public static class OrderEvent {
private String productId;
private String productName;
private double amount;
private long timestamp;
public OrderEvent(String productId, String productName, double amount, long timestamp) {
this.productId = productId; this.productName = productName;
this.amount = amount; this.timestamp = timestamp;
}
public String getProductId() { return productId; }
public String getProductName() { return productName; }
public double getAmount() { return amount; }
public long getTimestamp() { return timestamp; }
}
public static class SalesResult {
private String productId;
private String productName;
private long windowStart;
private long windowEnd;
private double totalAmount;
private long orderCount;
public SalesResult(String productId, String productName, long ws, long we,
double totalAmount, long orderCount) {
this.productId = productId; this.productName = productName;
this.windowStart = ws; this.windowEnd = we;
this.totalAmount = totalAmount; this.orderCount = orderCount;
}
@Override
public String toString() {
return String.format("商品=%s(%s), 窗口[%d~%d), 销售额=%.2f, 订单数=%d",
productId, productName, windowStart, windowEnd, totalAmount, orderCount);
}
}
// 增量聚合:累加销售额和订单数
public static class SalesAccumulator {
double totalAmount = 0;
long orderCount = 0;
String productName = "";
}
public static class SalesAggregator implements AggregateFunction<OrderEvent, SalesAccumulator, SalesResult> {
@Override
public SalesAccumulator createAccumulator() { return new SalesAccumulator(); }
@Override
public SalesAccumulator add(OrderEvent event, SalesAccumulator acc) {
acc.totalAmount += event.getAmount();
acc.orderCount++;
acc.productName = event.getProductName();
return acc;
}
@Override
public SalesResult getResult(SalesAccumulator acc) {
return new SalesResult(null, acc.productName, 0, 0, acc.totalAmount, acc.orderCount);
}
@Override
public SalesAccumulator merge(SalesAccumulator a, SalesAccumulator b) {
a.totalAmount += b.totalAmount;
a.orderCount += b.orderCount;
return a;
}
}
public static class SalesWindowFunction extends ProcessWindowFunction<SalesResult, SalesResult, String, TimeWindow> {
@Override
public void process(String productId, Context context, Iterable<SalesResult> elements,
Collector<SalesResult> out) {
SalesResult result = elements.iterator().next();
out.collect(new SalesResult(productId, result.productName,
context.window().getStart(), context.window().getEnd(),
result.totalAmount, result.orderCount));
}
}
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<OrderEvent> source = env.fromElements(
new OrderEvent("P001", "iPhone 15", 6999.0, 1000L),
new OrderEvent("P001", "iPhone 15", 6999.0, 2000L),
new OrderEvent("P002", "MacBook Pro", 14999.0, 3000L),
new OrderEvent("P001", "iPhone 15", 6999.0, 3601000L),
new OrderEvent("P002", "MacBook Pro", 14999.0, 3602000L)
);
DataStream<OrderEvent> withTimestamps = source.assignTimestampsAndWatermarks(
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withTimestampAssigner((event, rt) -> event.getTimestamp())
);
// Keyed Window:按 productId 分区,1 小时滚动窗口
DataStream<SalesResult> result = withTimestamps
.keyBy(OrderEvent::getProductId)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.aggregate(new SalesAggregator(), new SalesWindowFunction());
result.print();
env.execute("Product Sales Example");
}
}
关键点说明
keyBy(OrderEvent::getProductId):按 productId 分区,每个商品独立计算销售额- 商品数量可能不多(几千到几万),热门商品(如爆款)数据量大,可能导致数据倾斜
- 用
AggregateFunction增量聚合,自定义累加器同时保存销售额和订单数 - 数据倾斜风险:如果某个商品是爆款(如 iPhone 首发),它的数据量可能是其他商品的几十倍,导致那个 key 的窗口状态很大、计算很慢,拖慢整个作业。需要用加盐打散等方式优化(见第 09 节)
08 案例三:按地区统计 TopN 热门商品
进阶场景——按 region 分区,统计每个地区每小时的 Top10 热门商品。
完整代码
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.state.ListState;
import org.apache.flink.api.common.state.ListStateDescriptor;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class RegionTopNExample {
public static class ProductEvent {
private String region;
private String productId;
private String productName;
private long timestamp;
public ProductEvent(String region, String productId, String productName, long timestamp) {
this.region = region; this.productId = productId;
this.productName = productName; this.timestamp = timestamp;
}
public String getRegion() { return region; }
public String getProductId() { return productId; }
public String getProductName() { return productName; }
public long getTimestamp() { return timestamp; }
}
public static class TopNResult {
private String region;
private long windowStart;
private long windowEnd;
private List<ProductRank> topN;
public TopNResult(String region, long ws, long we, List<ProductRank> topN) {
this.region = region; this.windowStart = ws; this.windowEnd = we; this.topN = topN;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(String.format("地区=%s, 窗口[%d~%d), TopN:\n", region, windowStart, windowEnd));
for (int i = 0; i < topN.size(); i++) {
ProductRank p = topN.get(i);
sb.append(String.format(" 第%d名: %s(%s), 销量=%d\n", i+1, p.productName, p.productId, p.count));
}
return sb.toString();
}
}
public static class ProductRank {
String productId;
String productName;
long count;
public ProductRank(String productId, String productName, long count) {
this.productId = productId; this.productName = productName; this.count = count;
}
}
// 全量窗口函数:统计每个商品的销量,取 TopN
public static class TopNProcessFunction extends ProcessWindowFunction<ProductEvent, TopNResult, String, TimeWindow> {
private final int topN;
public TopNProcessFunction(int topN) {
this.topN = topN;
}
@Override
public void process(String region, Context context, Iterable<ProductEvent> elements,
Collector<TopNResult> out) {
// 统计每个商品的销量
Map<String, ProductRank> countMap = new HashMap<>();
for (ProductEvent event : elements) {
String key = event.getProductId();
ProductRank rank = countMap.get(key);
if (rank == null) {
rank = new ProductRank(event.getProductId(), event.getProductName(), 0);
countMap.put(key, rank);
}
rank.count++;
}
// 按销量排序,取 TopN
List<ProductRank> sorted = new ArrayList<>(countMap.values());
sorted.sort(Comparator.comparingLong((ProductRank p) -> p.count).reversed());
List<ProductRank> topNList = sorted.subList(0, Math.min(topN, sorted.size()));
out.collect(new TopNResult(region, context.window().getStart(),
context.window().getEnd(), topNList));
}
}
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<ProductEvent> source = env.fromElements(
new ProductEvent("华北", "P001", "iPhone 15", 1000L),
new ProductEvent("华北", "P002", "MacBook Pro", 2000L),
new ProductEvent("华北", "P001", "iPhone 15", 3000L),
new ProductEvent("华南", "P001", "iPhone 15", 4000L),
new ProductEvent("华南", "P003", "AirPods Pro", 5000L),
new ProductEvent("华北", "P001", "iPhone 15", 6000L)
);
DataStream<ProductEvent> withTimestamps = source.assignTimestampsAndWatermarks(
WatermarkStrategy.<ProductEvent>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withTimestampAssigner((event, rt) -> event.getTimestamp())
);
// Keyed Window:按 region 分区,1 小时滚动窗口,全量计算 TopN
DataStream<TopNResult> result = withTimestamps
.keyBy(ProductEvent::getRegion)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.process(new TopNProcessFunction(10)); // Top10
result.print();
env.execute("Region TopN Example");
}
}
关键点说明
keyBy(ProductEvent::getRegion):按 region 分区,每个地区独立计算 TopN- 地区数量少(几十个),导致并行度低(最多几十个并行实例)
- 每个 key 的窗口状态大(该地区所有商品的所有事件),必须用全量 ProcessWindowFunction 计算
- 性能瓶颈:地区数量少 → 并行度低 → 每个并行实例处理的数据量大 → 窗口状态大 → 计算慢。如果数据量大,需要考虑增大 key 粒度(如按"地区+城市"分区),或用两阶段聚合优化
09 Keyed Window 实战案例与数据倾斜
四大实战案例对比
| 案例 | Key 选择 | 窗口类型 | key 基数 | 状态大小 | 数据倾斜风险 |
|---|---|---|---|---|---|
| 按用户统计 PV | userId | 滚动 1 分钟 | 大(几十万+) | 小 | 低(用户分布均匀) |
| 按商品统计销售额 | productId | 滚动 1 小时 | 中(几千-几万) | 中 | 中(热门商品可能倾斜) |
| 按地区 TopN | region | 滚动 1 小时 | 小(几十个) | 大 | 高(key 少并行度低) |
| 按设备连续告警 | deviceId | 滑动 5 分钟/1 分钟 | 大(几十万+) | 大(5 倍状态) | 低 |
数据倾斜:Keyed Window 最大的敌人
数据倾斜是 Keyed Window 最常见也最头疼的问题。当某个 key 的数据量远大于其他 key 时,那个 key 的窗口状态很大、计算很慢,拖慢整个作业——其他并行实例都算完了,就等那一个热点 key。
如何识别数据倾斜:
- 看 Flink Web UI 各子任务的 Records Received,如果某个子任务明显高于其他,就是数据倾斜
- 看各子任务的 State Size,如果某个子任务的状态明显大于其他,就是数据倾斜
- 看各子任务的 Busy Time,如果某个子任务一直 100%,其他很闲,就是数据倾斜
三种优化方法
方法一:加盐打散(两阶段聚合)
最常用也最有效的方法。给热点 key 加随机前缀(如 key3-0、key3-1、...、key3-N),先局部聚合再全局聚合。
// 第一阶段:加盐,局部聚合
DataStream<Result> firstStage = stream
.keyBy(event -> event.getKey() + "_" + ThreadLocalRandom.current().nextInt(10)) // 加 0-9 随机前缀
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new LocalAggregator()); // 局部聚合
// 第二阶段:去盐,全局聚合
DataStream<Result> secondStage = firstStage
.keyBy(result -> result.getKey().split("_")[0]) // 去掉前缀,恢复原始 key
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new GlobalAggregator()); // 全局聚合
原理:把一个热点 key 拆成 10 个虚拟 key,分散到 10 个并行实例上局部聚合,每个实例的状态只有原来的 1/10。然后再去掉前缀,按原始 key 全局聚合,此时数据量已经很小了(每个虚拟 key 只有一条聚合结果)。
注意:两阶段聚合的窗口要对齐,第一阶段和第二阶段的窗口大小要一样,否则聚合结果不对。
方法二:拆分热点 key
识别出热点 key(如爆款商品),单独处理,其他 key 正常处理。
// 分流:热点 key 和普通 key 分开处理
SingleOutputStreamOperator<Event> hotStream = stream
.filter(event -> isHotKey(event.getKey())) // 热点 key 流
.keyBy(Event::getKey)
.window(...)
.aggregate(new HotAggregator()); // 热点 key 单独处理(可以用更激进的优化)
SingleOutputStreamOperator<Event> normalStream = stream
.filter(event -> !isHotKey(event.getKey())) // 普通 key 流
.keyBy(Event::getKey)
.window(...)
.aggregate(new NormalAggregator()); // 普通 key 正常处理
// 合并结果
DataStream<Result> result = hotStream.union(normalStream);
适用场景:热点 key 明确且数量少(如几个爆款商品),可以单独优化(如用更细的加盐、用 RocksDB、增大并行度)。
缺点:需要先统计 key 分布识别热点,热点可能随时间变化(如商品热度变化),需要动态更新热点列表。
方法三:增大 key 粒度
把大粒度 key 拆成小粒度,增加 key 数量,分散负载。
// 原来:按地区分区(几十个 key,并行度低)
.keyBy(event -> event.getRegion())
// 优化:按地区+城市分区(几百上千个 key,并行度提高)
.keyBy(event -> event.getRegion() + "_" + event.getCity())
适用场景:key 基数小导致并行度低的场景(如按地区、按性别、按年龄段分区)。
缺点:key 粒度变了,业务含义也变了(原来统计地区 TopN,现在统计城市 TopN),需要下游再聚合一次才能得到地区级别的结果。
10 生产环境最佳实践与常见坑
最佳实践
-
Key 选择要均匀:选择基数大、分布均匀的字段作为 key(如 userId、deviceId、orderId),避免用基数小的字段(如性别、地区、状态)。如果必须用小基数字段,考虑增大粒度或加盐。
-
优先用增量聚合:ReduceFunction/AggregateFunction 内存占用小(只存中间结果),大窗口+高吞吐必须用增量。全量 ProcessWindowFunction 只用于必须全量数据的场景(如 TopN、去重、排序)。
-
滑动窗口注意状态倍数:滑动窗口的状态大小 = 窗口大小 / 滑动步长。如 5 分钟窗口 1 分钟滑动,状态是滚动窗口的 5 倍。需要评估内存,必要时用 RocksDB 后端。
-
大状态用 RocksDB 后端:窗口状态超过堆内存(一般 1-2GB 以上)就用 EmbeddedRocksDBStateBackend,支持 TB 级状态和增量 Checkpoint。RocksDB 读写比堆内存慢,但能撑住大状态。
-
监控数据倾斜:看 Web UI 各子任务的 Records Received、State Size、Busy Time,如果某个子任务明显高于其他,就是数据倾斜。发现后及时优化 key,不要等作业崩溃。
-
合理设置最大并行度:maxParallelism 默认 128,预计未来要扩到更大并行度时提前设置(如 4096)。设置后不能改,改了状态无法恢复。
常见坑
-
坑一:用了 windowAll 还想并行
- 现象:设置了并行度 10,但 windowAll 算子的并行度还是 1,成为瓶颈
- 原因:windowAll 的并行度强制为 1,不管你设置多少
- 解决:改用 keyBy + window,如果必须全局统计,考虑用 keyBy 常量 key + 加盐两阶段聚合
-
坑二:Keyed State 没配 TTL
- 现象:作业跑了几天后状态持续增长,最终 OOM
- 原因:用户在 RichFunction 中创建的 Keyed State 默认永久存在,不会自动清理
- 解决:配置 StateTtlConfig,设置合理的过期时间(如 24 小时)
-
坑三:滑动窗口状态爆炸
- 现象:滑动窗口作业的状态大小是滚动窗口的好几倍,内存不够用
- 原因:滑动窗口状态 = 窗口大小 / 滑动步长,如 1 小时窗口 1 分钟滑动就是 60 倍状态
- 解决:评估滑动比例,必要时增大滑动步长、减小窗口大小,或用 RocksDB 后端
-
坑四:数据倾斜导致作业慢
- 现象:其他子任务都很闲,就一个子任务 100% Busy,作业整体吞吐上不去
- 原因:某个热点 key 的数据量远大于其他 key
- 解决:用加盐打散两阶段聚合、拆分热点 key、增大 key 粒度等方法优化
-
坑五:改了 maxParallelism 状态无法恢复
- 现象:修改最大并行度后,从 Checkpoint 恢复作业失败,状态对不上
- 原因:maxParallelism 决定 KeyGroup 数量,改了之后 key 到 KeyGroup 的映射变了
- 解决:maxParallelism 一旦设置就不要改,设置前考虑清楚未来的扩容需求
11 总结
Keyed Window 是 Flink 窗口中最常用、最实用的类型,99% 的生产场景都用它。核心要点:
- 并行计算原理:keyBy 按 key Hash 分区到 KeyGroup,每个 key 独立窗口,不同 KeyGroup 并行计算。KeyGroup 机制支持动态扩缩容。
- 状态管理:四层命名空间(Operator→Keyed→Window→State),Window State 自动清理,Keyed State 必须配 TTL。大状态用 RocksDB 后端。
- 三大实战案例:按用户 PV(key 均匀,最基础)、按商品销售额(热门商品可能倾斜)、按地区 TopN(key 少并行度低,全量计算)。
- 数据倾斜优化:加盐打散两阶段聚合(最常用)、拆分热点 key、增大 key 粒度。监控 Web UI 各子任务指标,及时发现和处理倾斜。
- 生产最佳实践:key 选择均匀、优先增量聚合、滑动窗口注意状态倍数、大状态用 RocksDB、监控数据倾斜、合理设置 maxParallelism。
Keyed Window 的本质是"按维度分组并行计算"——把大问题拆成小问题,每个小问题独立计算,最后汇总。这是分布式计算的核心思想,也是 Flink 能处理海量数据的关键。