Flink 受管状态的自定义序列化原理、实践与可演进设计

0. 什么时候要自定义序列化器?

  • 你不想使用 Flink 推断的默认序列化器(或 Kryo/Avro),而是完全掌控字节格式与兼容策略。
  • 状态对象很大访问频繁 ,通用序列化性能不足,需要定制的紧凑编码(例如稀疏结构、位压缩、字典编码)。
  • 你需要长期演进 状态 Schema(增删字段、参数配置变化),并且希望平滑迁移历史状态。

方式:在注册状态时,直接把你的 TypeSerializer 交给 StateDescriptor

java 复制代码
public class CustomTypeSerializer extends TypeSerializer<Tuple2<String, Integer>> { ... }

ListStateDescriptor<Tuple2<String,Integer>> desc =
    new ListStateDescriptor<>("state-name", new CustomTypeSerializer());
ListState<Tuple2<String,Integer>> state = getRuntimeContext().getListState(desc);

无论是堆外(RocksDB)还是堆内(HashMap)后端,savepoint 中都会写入"序列化器快照 + 状态字节"。恢复时:

  • 作业用新的序列化器 (来自你当前代码的 StateDescriptor)去访问旧状态。

  • Flink 把旧序列化器的快照 交给新序列化器的快照 ,由新快照 判断兼容性

    • compatibleAsIs:新老 Schema 一致,可直接读取;
    • compatibleAfterMigration :Schema 变了,但可迁移:旧序列化器读新序列化器写
    • incompatible:无法迁移,恢复失败(抛异常)。

1.1 RocksDB(堆外)与 HashMap(堆内)的差异

  • RocksDB 后端 :恢复时不反序列化 旧字节;需要迁移时,才会批量把状态A → B(旧读新写)。
  • 堆内后端 :恢复时先反序列化成对象 ;之后如果判定需要迁移,无需额外动作 (因为已经在堆内对象形态),等到下一次 savepoint落盘即是新 Schema B。

2. TypeSerializerSnapshot:可演进设计的核心

你的 TypeSerializer 必须实现:

java 复制代码
public abstract class TypeSerializer<T> {
    public abstract TypeSerializerSnapshot<T> snapshotConfiguration();
    // ... 读写对象的核心逻辑 ...
}

你的快照类需要回答 5 个问题:

java 复制代码
public interface TypeSerializerSnapshot<T> {
    int getCurrentVersion(); // 快照自身格式的版本
    void writeSnapshot(DataOutputView out); // 写出"旧序列化器"所需的全部信息
    void readSnapshot(int readVersion, DataInputView in, ClassLoader cl); // 读取快照
    TypeSerializerSchemaCompatibility<T> resolveSchemaCompatibility(
        TypeSerializerSnapshot<T> oldSnapshot); // 判断新旧 Schema 关系
    TypeSerializer<T> restoreSerializer(); // 恢复"旧序列化器"实例(用来读旧字节)
}

关键点:快照是"单一可信源" ------它不仅描述了写入时的 Schema,还要能恢复旧序列化器(为迁移做准备)。

3. 两个预制快照基类:90% 场景拿来即用

3.1 SimpleTypeSerializerSnapshot无状态/无配置的序列化器)

  • 兼容结果只有两种:compatibleAsIs (类相同)或 incompatible(类不同)。
  • 适用于如 IntSerializer 这类类定义即等于字节格式的序列化器。
java 复制代码
public class IntSerializerSnapshot extends SimpleTypeSerializerSnapshot<Integer> {
    public IntSerializerSnapshot() { super(() -> IntSerializer.INSTANCE); }
}

3.2 CompositeTypeSerializerSnapshot有嵌套序列化器的"外层"序列化器)

  • 用于 Map/List/Array/... 这类外层序列化器,自动读写嵌套快照并综合判断兼容性。
  • 你需要实现 3 个方法(至少):快照版本、如何拿到嵌套序列化器,以及如何用嵌套序列化器重建外层序列化器。
java 复制代码
public class MapSerializerSnapshot<K,V>
  extends CompositeTypeSerializerSnapshot<Map<K,V>, MapSerializer> {

  private static final int CURRENT_VER = 1;
  public MapSerializerSnapshot() { super(MapSerializer.class); }
  public MapSerializerSnapshot(MapSerializer<K,V> s) { super(s); }

  @Override public int getCurrentOuterSnapshotVersion() { return CURRENT_VER; }

  @Override protected TypeSerializer<?>[] getNestedSerializers(MapSerializer s) {
    return new TypeSerializer<?>[]{ s.getKeySerializer(), s.getValueSerializer() };
  }

  @Override protected MapSerializer createOuterSerializerWithNestedSerializers(
      TypeSerializer<?>[] nested) {
    return new MapSerializer<>((TypeSerializer<K>) nested[0],
                               (TypeSerializer<V>) nested[1]);
  }
}

若外层还有额外静态配置 (如数组组件类型),你还需覆写 writeOuterSnapshot / readOuterSnapshot / resolveOuterSchemaCompatibility,并在配置格式变化递增 getCurrentOuterSnapshotVersion()

4. 实战骨架:一个可演进的自定义序列化器

需求:为 UserEvent 自定义紧凑编码,支持新增字段时的平滑迁移。

java 复制代码
// 1) 业务类型
public class UserEvent {
  public long userId;
  public int action;      // v1: 0/1/2
  public long eventTime;
  // v2 新增字段
  public int source = 0;  // 新增字段,默认 0
}

// 2) 序列化器(省略 equals/hashCode/copy 等细节)
public class UserEventSerializer extends TypeSerializer<UserEvent> {

  @Override public void serialize(UserEvent e, DataOutputView out) throws IOException {
    out.writeLong(e.userId);
    out.writeInt(e.action);
    out.writeLong(e.eventTime);
    out.writeInt(e.source); // v2 开始写入
  }

  @Override public UserEvent deserialize(DataInputView in) throws IOException {
    UserEvent e = new UserEvent();
    e.userId = in.readLong();
    e.action = in.readInt();
    e.eventTime = in.readLong();
    e.source = in.readInt(); // v1→v2 迁移时需要兼容(见快照)
    return e;
  }

  @Override public TypeSerializerSnapshot<UserEvent> snapshotConfiguration() {
    return new UserEventSerializerSnapshot(this);
  }

  // ... 其他必要实现(isImmutableType, copy, getLength 等) ...
}

// 3) 快照:写出"写入时的格式版本"
public class UserEventSerializerSnapshot
    implements TypeSerializerSnapshot<UserEvent> {

  private static final int CUR_VERSION = 2; // v2: 多了 source
  private int writtenVersion;               // 旧快照里的格式版本

  public UserEventSerializerSnapshot() {}   // 必须:公共无参构造
  public UserEventSerializerSnapshot(UserEventSerializer s) { this.writtenVersion = CUR_VERSION; }

  @Override public int getCurrentVersion() { return CUR_VERSION; }

  @Override public void writeSnapshot(DataOutputView out) throws IOException {
    out.writeInt(CUR_VERSION);              // 仅写版本号(不使用 Java 序列化)
  }

  @Override public void readSnapshot(int readVersion, DataInputView in, ClassLoader cl) throws IOException {
    this.writtenVersion = in.readInt();
  }

  @Override public TypeSerializer<UserEvent> restoreSerializer() {
    // 恢复"能读旧格式"的序列化器;如需要可返回一个"v1 兼容反序列化"的实现
    return new UserEventSerializer();
  }

  @Override
  public TypeSerializerSchemaCompatibility<UserEvent> resolveSchemaCompatibility(
      TypeSerializerSnapshot<UserEvent> oldSnap) {

    UserEventSerializerSnapshot old = (UserEventSerializerSnapshot) oldSnap;

    if (old.writtenVersion == CUR_VERSION) {
      return TypeSerializerSchemaCompatibility.compatibleAsIs();
    }
    // v1 -> v2:新增字段(source)
    if (old.writtenVersion == 1 && CUR_VERSION == 2) {
      return TypeSerializerSchemaCompatibility.compatibleAfterMigration();
    }
    return TypeSerializerSchemaCompatibility.incompatible();
  }
}

迁移时机:

  • RocksDB:触发访问 → 判断"需要迁移" → 旧读新写,批量把该状态从 A→B;
  • 堆内 :恢复即已反序列化为对象,不需要额外动作;下一次 savepoint 会以 B 写出。

5. 实现注意事项与最佳实践

  1. 快照类可被"类名 + 无参构造"反射

    • 不要用匿名/内部类;
    • 提供 public 无参构造;
    • 快照类不要在不同序列化器间复用(一类一快照,关注点分离)。
  2. 快照内容不要用 Java 序列化

    • 简单原语类名字符串,读取时自己解析/加载;
    • 避免类实现变更后导致二进制不兼容而不可读
  3. 把"版本差异"放在快照层解决

    • getCurrentVersion() 明确快照格式;
    • resolveSchemaCompatibility() 明确旧→新的关系(AsIs / AfterMigration / Incompatible);
    • restoreSerializer() 能恢复旧阅读器
  4. 嵌套序列化器用 Composite 快照

    • 让框架替你读写"嵌套快照 + 兼容性聚合";
    • 外层若有配置(如数组组件类),记得读写该配置提升外层快照版本

6. 与 Key/Schema 演进相关的雷区

  • Key 不支持 Schema 演进 :会破坏分区/定位,导致非确定性;有变更需求请重算而非演进。

  • Kryo 不支持演进:一旦状态链路中某节点走 Kryo,你无法验证兼容性;建议关闭 Kryo 兜底暴露问题类型:

    yaml 复制代码
    pipeline.generic-types: false

7. 迁移指南

  1. 新增一个 TypeSerializerSnapshot 子类;
  2. snapshotConfiguration() 返回新快照;
  3. 旧 savepoint 恢复 → 再做一次 savepoint (此间保留旧类与 ensureCompatibility 实现);
  4. 新 savepoint 中已写入新快照 ,此后可移除旧实现与 ensureCompatibility(...)
  • 旧:resolveSchemaCompatibility(TypeSerializer newSerializer)删除
  • 新:resolveSchemaCompatibility(TypeSerializerSnapshot oldSerializerSnapshot)在新快照实现相同逻辑)。

8. 自检清单(上线前最后 5 分钟)

  • 快照类独立公共无参构造不使用 Java 序列化写内容;
  • resolveSchemaCompatibility() 覆盖所有升级路径(AsIs / AfterMigration / Incompatible);
  • RocksDB/堆内两种后端在你的场景下都验证过恢复/迁移;
  • 回滚 savepoint,迁移失败可即时回退;
  • 指标/日志已接入:失败 CK、迁移耗时、反序列化错误、后端 IO/CPU。

9. 结语

自定义序列化器的难点不在读写本身 ,而在可演进的快照设计

  • 把格式差异外置到快照(版本化),
  • 在新快照里主导与旧格式的兼容关系,
  • 恢复旧阅读器完成迁移,
  • 对嵌套结构用 Composite 快照做系统化管理。
相关推荐
Han.miracle16 小时前
数据结构——二叉树的从前序与中序遍历序列构造二叉树
java·数据结构·学习·算法·leetcode
黄沐阳17 小时前
stp,rstp,mstp的区别
服务器·网络·php
Le1Yu17 小时前
分布式事务以及Seata(XA、AT模式)
java
寒山李白18 小时前
关于Java项目构建/配置工具方式(Gradle-Groovy、Gradle-Kotlin、Maven)的区别于选择
java·kotlin·gradle·maven
无妄无望18 小时前
docker学习(4)容器的生命周期与资源控制
java·学习·docker
MC丶科19 小时前
【SpringBoot 快速上手实战系列】5 分钟用 Spring Boot 搭建一个用户管理系统(含前后端分离)!新手也能一次跑通!
java·vue.js·spring boot·后端
千码君201619 小时前
React Native:从react的解构看编程众多语言中的解构
java·javascript·python·react native·react.js·解包·解构
夜白宋20 小时前
【word多文档docx合并】
java·word
小楊不秃头20 小时前
网路原理:UDP协议
网络·网络协议·udp
@yanyu66620 小时前
idea中配置tomcat
java·mysql·tomcat