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);
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
必须实现:
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. 实现注意事项与最佳实践
-
快照类可被"类名 + 无参构造"反射
- 不要用匿名/内部类;
- 提供
public
无参构造; - 快照类不要在不同序列化器间复用(一类一快照,关注点分离)。
-
快照内容不要用 Java 序列化
- 写简单原语 或类名字符串,读取时自己解析/加载;
- 避免类实现变更后导致二进制不兼容而不可读。
-
把"版本差异"放在快照层解决
getCurrentVersion()
明确快照格式;resolveSchemaCompatibility()
明确旧→新的关系(AsIs / AfterMigration / Incompatible);restoreSerializer()
能恢复旧阅读器。
-
嵌套序列化器用 Composite 快照
- 让框架替你读写"嵌套快照 + 兼容性聚合";
- 外层若有配置(如数组组件类),记得读写该配置 并提升外层快照版本。
6. 与 Key/Schema 演进相关的雷区
-
Key 不支持 Schema 演进 :会破坏分区/定位,导致非确定性;有变更需求请重算而非演进。
-
Kryo 不支持演进:一旦状态链路中某节点走 Kryo,你无法验证兼容性;建议关闭 Kryo 兜底暴露问题类型:
yamlpipeline.generic-types: false
7. 迁移指南
7.1 从 Flink 1.7 之前的旧快照 API(TypeSerializerConfigSnapshot
)迁移
- 新增一个
TypeSerializerSnapshot
子类; - 在
snapshotConfiguration()
返回新快照; - 从旧 savepoint 恢复 → 再做一次 savepoint (此间保留旧类与
ensureCompatibility
实现); - 新 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 快照做系统化管理。