Customizing Serializers for Flink State
Custom state serializers can improve compatibility and performance in complex Flink applications.
Translated from Chinese with AI · Read the original
Customizing Flink state serialization is an advanced technique that can improve compatibility and performance in complex scenarios.
What a State Serializer Does
These two lines create an Integer ValueState:
// 创建 Integer 类型的 ValueStateValueStateDescriptor<Integer> valueState = new ValueStateDescriptor<>("value", Types.INT);ValueState<Integer> state = getRuntimeContext().getState(valueState);Passing Types.INT tells Flink that the state holds Integers, for which it creates an IntSerializer. That serializer has two roles:
- During snapshots, serialize Integer state values into binary data for persistent storage.
- During restore, deserialize stored binary data back into Integer objects.
A state serializer therefore defines the state’s binary format. Customizing it can solve common problems such as:
- High overhead for complex objects, such as Kryo writing redundant className information.
- Failed recovery after fields are added to or removed from state objects.
Basic Concepts
A complete state serializer consists of a Serializer and SerializerSnapshot:
- Serializer defines serialization and deserialization for a type.
- SerializerSnapshot defines how serializer metadata is serialized as part of the snapshot.
- TypeSerializerSchemaCompatibility describes compatibility between the user-defined TypeSerializer after restart and the one in the snapshot.
The official Custom Serialization for Managed State documentation explains these concepts in more detail.
Implementing a Serializer
We will examine DecimalDataSerializer, which serializes DecimalData.
First, the DecimalData structure:
public final class DecimalData implements Comparable<DecimalData> { final int precision; // 精度(字段长度) final int scale; // 范围(小数的位数) final long longVal; // 精度较小时,选择用 long 值表示 BigDecimal decimalVal; // 精度较大时,选择用 java.math.BigDecimal 表示
// 判断是否超出最大精度(即是否可以使用 long 值表示) public boolean isCompact() { return precision <= MAX_COMPACT_PRECISION; }
// 转换成 long 值 public long toUnscaledLong() { if (isCompact()) { return longVal; } else { return toBigDecimal().unscaledValue().longValueExact(); } }
// 转换成 bytes public byte[] toUnscaledBytes() { return toBigDecimal().unscaledValue().toByteArray(); }}Its corresponding serializer:
public final class DecimalDataSerializer extends TypeSerializer<DecimalData> { private final int precision; private final int scale;
// 构造函数,初始化 precision 和 scale public DecimalDataSerializer(int precision, int scale) { this.precision = precision; this.scale = scale; }
@Override public void serialize(DecimalData record, DataOutputView target) throws IOException { if (DecimalData.isCompact(precision)) { // 当前精度小,使用 long 值表示,写出 long 值 assert record.isCompact(); target.writeLong(record.toUnscaledLong()); } else { // 当前精度大,使用 BigDecimal 表示 byte[] bytes = record.toUnscaledBytes(); target.writeInt(bytes.length); target.write(bytes); } }
@Override public DecimalData deserialize(DataInputView source) throws IOException { if (DecimalData.isCompact(precision)) { // 当前精度小,读取 long 值,初始化 DecimalData long longVal = source.readLong(); return DecimalData.fromUnscaledLong(longVal, precision, scale); } else { // 当前精度大,读取 bytes,初始化 DecimalData int length = source.readInt(); byte[] bytes = new byte[length]; source.readFully(bytes); return DecimalData.fromUnscaledBytes(bytes, precision, scale); } }}The code shows two core methods:
- serialize: Converts DecimalData into binary data written to DataOutputView.
- deserialize: Reads binary data sequentially from DataInputView and reconstructs DecimalData.
You do not need to decide when these methods run. Whether used for RPC or a local/remote filesystem, the corresponding OutputStream and InputStream are wrapped in DataOutputView and DataInputView.
Supporting Multiple Versions
If precision or scale changes after a job modification, DecimalData.isCompact(precision) may produce a different result, making existing bytes impossible to deserialize. What must change to build a serializer that tolerates such modifications?
Include Version Information
To allow precision and scale to change, modify the serialize and deserialize methods as follows:
int currentVersion = 2;
@Override public void serialize(DecimalData record, DataOutputView target) throws IOException { target.writeInt(currentVersion); // 新增版本信息 // ... 和之前一致 }
@Override public DecimalData deserialize(DataInputView source) throws IOException { int version = source.readInt(); if (version == currentVersion) { return deserializeCurrentVersion(source); } else { return deserializeVersion1(source); } }
// 解析当前版本的数据结构 private DecimalData deserializeCurrentVersion(DataInputView source) { // 使用当前版本的 precision 和 scale 进行解析 }
// 解析 Version 1 版本的数据结构 private DecimalData deserializeVersion1(DataInputView source) { // 使用 Version 1 中的 precision 和 scale 进行解析并转化成当前结构的数据 }Adjust Compatibility Checks
After precision or scale changes, the snapshot’s DecimalDataSerializer no longer matches the newly created one. Flink checks these properties and throws TypeSerializerSchemaCompatibility.incompatible() on a mismatch. With version information included, we can instead return TypeSerializerSchemaCompatibility.compatibleAsIs().
There are four compatibility outcomes:
- COMPATIBLE_AS_IS: Compatible; use the new user-defined serializer going forward.
- COMPATIBLE_AFTER_MIGRATION: Compatible after rewriting state: deserialize with the snapshot’s serializer, then serialize with the new one.
- COMPATIBLE_WITH_RECONFIGURED_SERIALIZER: Compatible; return a reconfigured serializer for future use.
- INCOMPATIBLE: Incompatible; the job throws an exception and exits.
References
Custom serializers are common in platform applications, especially DataStream-based frameworks. Users store business state whose fields frequently change. Flink’s built-in implementations, such as IntSerializer and DecimalDataSerializer, provide useful examples.