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

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

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

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

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);

1. Flink 如何“看待”状态与序列化(恢复与迁移全链路)

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

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

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

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

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

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

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

你的 TypeSerializer 必须实现:

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

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

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 这类类定义即等于字节格式的序列化器。
public class IntSerializerSnapshot extends SimpleTypeSerializerSnapshot<Integer> {
  public IntSerializerSnapshot() { super(() -> IntSerializer.INSTANCE); }
  }

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

  • 用于 Map/List/Array/... 这类外层序列化器,自动读写嵌套快照并综合判断兼容性。
  • 你需要实现 3 个方法(至少):快照版本、如何拿到嵌套序列化器,以及如何用嵌套序列化器重建外层序列化器。
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 自定义紧凑编码,支持新增字段时的平滑迁移。

// 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 兜底暴露问题类型:

    pipeline.generic-types: false

7. 迁移指南

7.1 从 Flink 1.7 之前的旧快照 API(TypeSerializerConfigSnapshot)迁移

  1. 新增一个 TypeSerializerSnapshot 子类;
  2. snapshotConfiguration() 返回新快照;
  3. 旧 savepoint恢复 → 再做一次 savepoint(此间保留旧类与 ensureCompatibility 实现);
  4. 新 savepoint 中已写入新快照,此后可移除旧实现与 ensureCompatibility(...)

7.2 从 Flink 1.19 之前的旧方法迁移

  • 旧:resolveSchemaCompatibility(TypeSerializer newSerializer)删除
  • 新:resolveSchemaCompatibility(TypeSerializerSnapshot oldSerializerSnapshot)在新快照实现相同逻辑)。

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

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

9. 结语

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

  • 把格式差异外置到快照(版本化),
  • 在新快照里主导与旧格式的兼容关系,
  • 恢复旧阅读器完成迁移,
  • 对嵌套结构用 Composite 快照做系统化管理。

posted on 2025-11-10 12:57  slgkaifa  阅读(0)  评论(0)    收藏  举报

导航