Flink State 与 Join 基础

好,咱们正式开始第二讲。上一章我聊了聊风控场景对低延迟的执念,那这一章就得把地基打牢——Flink 的 State 和 Join 到底是怎么玩的?

说实话,我见过不少同学,代码写得飞起,但一问到 State 的底层存储结构就含糊了。结果呢?线上 OOM 了都不知道怎么死的。所以这一章,咱们把 KeyedState 的几个主力类型掰开揉碎,再把 Flink 那三个 Join 算子理清楚。

Flink KeyedState 简介

先问个问题:为什么风控场景里,State 这么重要?

你想想看,风控规则往往是跨事件的。比如「5 分钟内同一设备登录超过 3 次就告警」。这个「5 分钟内」和「同一设备」的信息,必须有个地方存着。Flink 的 State 就是干这个的。

KeyedState,顾名思义,是按 key 分片的 State。每个 key 都有自己的状态视图。在风控里,key 通常是 deviceId、userId、ip 这些维度。

核心要点:KeyedState 只存在于 KeyedStream 上。你得先 .keyBy(),才能用 State。

我个人习惯把 KeyedState 理解成一个「分布式 HashMap」。每个并行子任务管理自己那部分 key 的状态。Flink 负责把状态做 checkpoint,保证故障恢复。

ValueState / MapState / ListState

这三种是最常用的。咱们一个一个说。

ValueState

最简单的状态类型。存一个值,更新一个值。适合计数器、标记位、最近一次事件时间。

// 风控场景:记录用户最近一次登录时间
ValueState<Long> lastLoginTime = getRuntimeContext()
    .getState(new ValueStateDescriptor<>("last-login", Types.LONG));

// 更新
lastLoginTime.update(System.currentTimeMillis());

// 读取
Long lastTime = lastLoginTime.value();

我在项目中遇到过一个问题:ValueState 默认的 TTL 没设置,结果状态越积越多,checkpoint 越来越大。后来统一加了 StateTtlConfig,才控制住。

避坑指南:ValueState 的 value() 返回 null 表示从未写入过。别用 0 或 -1 做默认值,直接用 null 判断更安全。

MapState

存 key-value 对。适合需要按子维度聚合的场景。比如「每个 IP 关联的设备列表」。

MapState<String, Integer> deviceCountMap = getRuntimeContext()
    .getMapState(new MapStateDescriptor<>("device-count", Types.STRING, Types.INT));

// 累加设备出现次数
Integer count = deviceCountMap.get(deviceId);
if (count == null) {
    deviceCountMap.put(deviceId, 1);
} else {
    deviceCountMap.put(deviceId, count + 1);
}

MapState 比 ValueState 灵活,但要注意:遍历所有条目时,数据量大会有性能问题。我曾经在线上看到一个任务,MapState 里存了上百万个 key,每次遍历都卡好几秒。后来改成了按需查询,问题解决。

ListState

存一个有序列表。适合保存事件序列,比如「最近 10 次登录记录」。

ListState<LoginEvent> recentLogins = getRuntimeContext()
    .getListState(new ListStateDescriptor<>("recent-logins", LoginEvent.class));

// 添加事件
recentLogins.add(event);

// 遍历
for (LoginEvent e : recentLogins.get()) {
    // 处理
}

ListState 有个坑:它不保证元素去重,也不保证顺序(除非你自己控制)。我建议只在需要「追加」语义时用它,否则优先考虑 MapState。

状态类型 适用场景 注意事项
ValueState 计数器、标记位、单值缓存 注意 TTL 设置,避免状态膨胀
MapState 维度聚合、字典映射 避免全量遍历,按 key 查询
ListState 事件序列、日志缓存 不保证去重和顺序

Flink Join 算子概览

风控里最头疼的就是 Join。两个流要关联,延迟还不能高。Flink 给了三个主力算子,各有各的脾气。

Window Join

最直观的 Join 方式。把两个流放在同一个时间窗口里,按 key 做关联。

stream1.join(stream2)
    .where(e1 -> e1.getKey())
    .equalTo(e2 -> e2.getKey())
    .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    .apply((e1, e2) -> { /* 合并逻辑 */ });

Window Join 的优点是简单。缺点也很明显:必须等窗口结束才能输出结果。在风控场景里,5 秒的延迟有时候都嫌长。

注意:Window Join 默认是 inner join。如果某个流在窗口内没数据,就不会输出。风控里经常需要 left join 或 anti join,Window Join 做不到。

Interval Join

这个算子就灵活多了。它允许两个流的事件在「时间区间」内关联,不用等窗口结束。

stream1.keyBy(e -> e.getKey())
    .intervalJoin(stream2.keyBy(e -> e.getKey()))
    .between(Time.seconds(-5), Time.seconds(5))
    .process(new ProcessJoinFunction<>() {
        @Override
        public void processElement(Object left, Object right, Context ctx, Collector<Object> out) {
            // 实时输出
        }
    });

Interval Join 的延迟更低——事件一到,只要匹配到另一条流的数据,立刻输出。我在做实时反欺诈时,用的就是 Interval Join 来关联登录事件和交易事件。

但要注意:Interval Join 只支持 event time。如果你用 processing time,时间边界会乱掉。

Temporal Join

这个算子比较特殊。它用于「流」关联「版本化表」。比如实时关联用户画像、设备指纹这类变化缓慢的数据。

// 假设 userProfile 是一个 TemporalTable
stream.keyBy(e -> e.getUserId())
    .join(userProfile)
    .where(e -> e.getUserId())
    .equalTo(p -> p.getUserId())
    .window(TumblingEventTimeWindows.of(Time.seconds(1)))
    .apply((e, p) -> { /* 合并 */ });

Temporal Join 的好处是:能拿到事件发生时,那个时刻的版本数据。而不是当前的最新数据。这在风控审计里特别重要——你得知道当时用户画像是什么,而不是现在的。

我的建议:如果关联的数据变化频率低(小时级),用 Temporal Join。如果变化快(秒级),用 Interval Join 更合适。

Flink Join 算子选型对比 Window Join Interval Join Temporal Join 特点 窗口结束才输出 默认 inner join 延迟 = 窗口大小 特点 事件到即输出 支持时间偏移 仅 event time 特点 关联版本化数据 获取历史快照 适合慢变维度 风控推荐:Interval Join > Temporal Join > Window Join

嗯,这张图把三个算子的核心差异都标出来了。风控场景里,我首选 Interval Join,因为它延迟最低。Temporal Join 适合关联静态数据。Window Join 嘛……除非你对延迟不敏感,否则慎用。