2. 状态类型详解(上):Keyed State 之 ValueState、ListState、MapState 原理与使用场景

好,咱们进入状态类型的正题。Flink 的状态分两大类:Keyed State 和 Operator State。这一章我们先啃 Keyed State 里最常用的三个:ValueState、ListState、MapState。

说白了,Keyed State 就是「每个 key 一份状态」。你想想看,在分布式系统里,数据按 key 分区后,每个并行子任务只处理一部分 key。那每个 key 自己的状态,自然得独立管理。Flink 帮我们封装好了,你只管用。

核心要点:Keyed State 必须用在 KeyedStream 上,也就是先 .keyBy() 再处理。每个 key 的状态是隔离的,不同 key 之间不会互相干扰。

Keyed State 核心架构图 Keyed State 核心架构 数据流 keyBy() KeyedStream 状态后端 ValueState 单个值,如计数器 ListState 元素列表,如历史记录 MapState 键值映射,如配置表 每个 key 的状态独立存储,通过 KeyedStateStore 接口访问 状态后端负责序列化、持久化,对开发者透明

2.1 ValueState:最简单的状态,最常用的场景

ValueState 就是存一个值。可以是任何类型——Long、String、Pojo,甚至你自定义的对象。每次只存一个,更新就覆盖。

使用场景:

  • 计数器:统计每个用户的点击次数
  • 最后一条记录:记录每个传感器的最新读数
  • 聚合中间结果:累加器、最大值、最小值

我在项目中遇到过这样一个需求:统计每个广告位的曝光次数,然后每满 100 次触发一次回调。用 ValueState 存一个 Long 计数器,每次来事件就 update,到了阈值就清空。代码非常干净。

// 定义 ValueState
private transient ValueState<Long> countState;

// 在 open() 中初始化
@Override
public void open(Configuration parameters) {
    ValueStateDescriptor<Long> descriptor = 
        new ValueStateDescriptor<>("clickCount", Types.LONG);
    countState = getRuntimeContext().getState(descriptor);
}

// 处理逻辑
@Override
public void processElement(String value, Context ctx, Collector<String> out) {
    Long currentCount = countState.value();  // 读取
    if (currentCount == null) {
        currentCount = 0L;
    }
    currentCount += 1;
    countState.update(currentCount);         // 更新
    if (currentCount >= 100) {
        out.collect("触发回调:" + value);
        countState.clear();                  // 清空
    }
}

小技巧:ValueState.value() 返回 null 表示该 key 还没有状态。初始化时记得判空。我个人习惯用 Optional.ofNullable 包装一下,代码更优雅。

2.2 ListState:有序列表,适合存「历史」

ListState 存的是一个元素列表。每个 key 对应一个 List,你可以往里面追加元素,也可以遍历所有元素。

使用场景:

  • 最近 N 条记录:比如每个用户最近 10 次登录 IP
  • 事件序列:检测模式匹配时,缓存一段时间内的事件
  • 批量输出:攒够一批数据再发送

嗯,这里要注意:ListState 不是 java.util.List,它有自己的 API。你不能直接 addAll 一个集合,得用 Iterable 遍历。

// 定义 ListState
private transient ListState<String> loginHistoryState;

@Override
public void open(Configuration parameters) {
    ListStateDescriptor<String> descriptor = 
        new ListStateDescriptor<>("loginHistory", Types.STRING);
    loginHistoryState = getRuntimeContext().getListState(descriptor);
}

@Override
public void processElement(String userId, Context ctx, Collector<String> out) {
    // 追加当前登录 IP
    loginHistoryState.add("192.168.1." + System.currentTimeMillis() % 255);
    
    // 遍历所有历史记录
    Iterable<String> history = loginHistoryState.get();
    StringBuilder sb = new StringBuilder("登录历史: ");
    for (String ip : history) {
        sb.append(ip).append(" -> ");
    }
    out.collect(sb.toString());
}

避坑指南:我曾经在项目里用 ListState 存了上百万条记录,结果状态越来越大,Checkpoint 超时。ListState 适合存「有限」的历史。如果数据量不可控,建议加一个上限,比如只保留最近 100 条,或者改用 MapState 按时间戳索引。

2.3 MapState:键值映射,灵活的数据字典

MapState 就是一个 key-value 映射表。每个 key 对应一个 Map,你可以按子 key 存取。说白了,就是「状态的二维结构」。

使用场景:

  • 配置表:每个用户的自定义配置项
  • 去重:用 MapState 存已经处理过的 ID,value 可以存时间戳
  • 多维度聚合:按不同维度分别统计

MapState 的 API 很丰富,支持 containsKey、get、put、remove、entries、keys、values 等操作。比 ListState 灵活得多。

// 定义 MapState
private transient MapState<String, Double> configState;

@Override
public void open(Configuration parameters) {
    MapStateDescriptor<String, Double> descriptor = 
        new MapStateDescriptor<>("userConfig", Types.STRING, Types.DOUBLE);
    configState = getRuntimeContext().getMapState(descriptor);
}

@Override
public void processElement(String userId, Context ctx, Collector<String> out) {
    // 检查是否有配置
    if (configState.contains("threshold")) {
        Double threshold = configState.get("threshold");
        // 使用配置值
    }
    // 更新配置
    configState.put("threshold", 0.8);
    // 遍历所有配置
    for (Map.Entry<String, Double> entry : configState.entries()) {
        // 处理每个配置项
    }
}

性能提示:MapState 的 entries() 和 keys() 返回的是 Iterable,不是一次性加载到内存的集合。遍历大数据量时不会 OOM,但要注意遍历时间。如果 Map 特别大,建议用 .keys() 配合 .get() 按需读取。

2.4 三种状态的对比与选型

特性 ValueState ListState MapState
数据结构 单个值 有序列表 键值映射
读写方式 value() / update() add() / get() / update() put() / get() / contains()
遍历支持 不支持 Iterable keys() / values() / entries()
典型场景 计数器、最后一条记录 历史记录、事件序列 配置表、去重、多维度聚合
状态大小 小(单值) 中(列表) 大(映射表)
序列化开销 低 中 高(取决于条目数)

选型其实不难:

  • 只需要存一个值?用 ValueState。
  • 需要按顺序存多个值?用 ListState。
  • 需要按子 key 索引?用 MapState。

我个人习惯是:能用 ValueState 解决的,绝不用 ListState。因为 ValueState 的序列化开销最小,性能最好。MapState 虽然灵活,但状态后端对它的优化不如 ValueState 成熟,尤其是 RocksDB 后端下,MapState 的每次 put/get 都可能涉及序列化。

避坑指南:我曾经在一个项目里用 MapState 存了每个用户的「标签-权重」对,大概每个用户 50 个标签。一开始用着挺好,后来用户量涨到 1 亿,RocksDB 的读写延迟飙升。后来改成 ValueState 存一个序列化的 Map 对象,性能反而更好。所以 MapState 适合「条目数少、key 数量多」的场景,不适合「每个 key 的 Map 特别大」的场景。

2.5 状态 TTL:别让状态「永远活着」

不管用哪种状态,都要考虑 TTL(Time-To-Live)。默认情况下,状态是永久存在的。你想想看,如果 key 不再来了,状态还占着内存或磁盘,这不是浪费吗?

// 配置 TTL
ValueStateDescriptor<Long> descriptor = 
    new ValueStateDescriptor<>("clickCount", Types.LONG);

StateTtlConfig ttlConfig = StateTtlConfig
    .newBuilder(Time.days(1))           // 存活 1 天
    .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)  // 写入时更新 TTL
    .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)  // 过期不返回
    .build();

descriptor.enableTimeToLive(ttlConfig);

TTL 的配置项不少,我常用的就三个:

  • 存活时间:多久没更新就过期
  • 更新策略:OnCreateAndWrite(写入时续期)还是 OnReadAndWrite(读取也续期)
  • 可见性:过期后是否还能读到(一般选 NeverReturnExpired)

注意:TTL 不是精确的。Flink 在后台 lazily 清理过期状态,不会在过期瞬间就删除。如果你的业务对「精确过期」有要求,比如超过 1 小时必须失效,那 TTL 可能不够,需要自己加逻辑判断。

好了,ValueState、ListState、MapState 的原理和用法就这些。下一类状态——ReducingState 和 AggregatingState,以及 Operator State,我们后面再聊。记住一句话:选对状态类型,比优化代码更重要。