Flink 状态 RocksDBListState(写入时的Merge优化)

RocksDBListState<K, N, V>

RocksDBListState 继承自 AbstractRocksDBState<K, N, List<V>>,并实现了 InternalListState<K, N, V> 接口。

  • 继承 AbstractRocksDBState : 这意味着它天然获得了与 RocksDB 交互的底层能力,包括:
    • 持有 backendcolumnFamily 等核心组件的引用。
    • 能够使用 serializeCurrentKeyWithGroupAndNamespace() 等方法构建 RocksDB 的 Key。
    • 能够使用共享的序列化器和 WriteOptions
  • 实现 InternalListState : 这意味着它必须对外提供 ListState 的标准 API,如 get(), add(V), addAll(List<V>), update(List<V>) 等。

它的核心任务就是将 ListState 的操作(如向列表中添加一个元素)高效地翻译成对 RocksDB 的操作。

mergeNamespaces 的作用是什么?

在 Flink 中,namespace(命名空间)通常用来隔离不同窗口的状态。例如,在一个会话窗口中,每个窗口(比如 [00:00, 00:10))都有一个自己的 namespace

当两个会话窗口因为新的数据到来而需要合并时(比如 [00:00, 00:10)[00:15, 00:25) 因为一个 00:12 的事件到来而需要合并成 [00:00, 00:25)),Flink 需要将这两个旧窗口(sources)的状态合并到新的大窗口(target)中。

mergeNamespaces 方法正是执行这个状态合并 操作的。它的核心任务是:将多个源 namespace 下的状态数据,迁移到目标 namespace 下。

java 复制代码
// ... existing code ...
    @Override
    public void mergeNamespaces(N target, Collection<N> sources) {
        if (sources == null || sources.isEmpty()) {
            return;
        }

        try {
            // 1. 准备目标 Key
            // create the target full-binary-key
            setCurrentNamespace(target);
            final byte[] targetKey = serializeCurrentKeyWithGroupAndNamespace();

            // 2. 遍历所有源 Namespace
            // merge the sources to the target
            for (N source : sources) {
                if (source != null) {
                    // 3. 准备源 Key
                    setCurrentNamespace(source);
                    final byte[] sourceKey = serializeCurrentKeyWithGroupAndNamespace();

                    // 4. 读取源 Value
                    byte[] valueBytes = backend.db.get(columnFamily, sourceKey);

                    if (valueBytes != null) {
                        // 5. 删除源 Key-Value 对
                        backend.db.delete(columnFamily, writeOptions, sourceKey);
                        // 6. 将源 Value 追加到目标 Key
                        backend.db.merge(columnFamily, writeOptions, targetKey, valueBytes);
                    }
                }
            }
        } catch (Exception e) {
            throw new FlinkRuntimeException("Error while merging state in RocksDB", e);
        }
    }
// ... existing code ...
  1. 准备目标 Key (targetKey)

    • setCurrentNamespace(target):首先,将 backend 的上下文切换到目标窗口namespace
    • serializeCurrentKeyWithGroupAndNamespace():然后,将当前的 key(例如用户ID)和这个 target namespace 序列化成一个完整的二进制 byte[] key。这个 targetKey 就是未来所有状态要汇集的地方。
  2. 遍历所有源 namespace (sources)

    • 这是一个 for 循环,会迭代所有需要被合并的旧窗口的 namespace
  3. 准备源 Key (sourceKey)

    • 在循环内部,setCurrentNamespace(source)backend 的上下文切换到其中一个源窗口namespace
    • serializeCurrentKeyWithGroupAndNamespace() 再次被调用,生成这个源窗口对应的二进制 byte[] key。
  4. 读取源 Value (valueBytes)

    • backend.db.get(columnFamily, sourceKey):使用源 key 从 RocksDB 中读取出对应的 value。对于 ListState 来说,这个 valueBytes 存储的是整个列表序列化后的字节
  5. 删除源 Key-Value 对

    • backend.db.delete(columnFamily, writeOptions, sourceKey):一旦源窗口的状态被读取出来,它对应的旧条目就需要被删除,以防状态重复。
  6. 将源 Value 追加到目标 Key

    • backend.db.merge(columnFamily, writeOptions, targetKey, valueBytes):这是最核心的一步。它调用了 RocksDB 的 Merge 操作。

利用 Merge 操作符

RocksDBValueState 的简单 put/get 不同,RocksDBListState 的实现非常巧妙,它充分利用了 RocksDB 的一个高级特性------Merge 操作符

在 RocksDB 中,Merge 操作允许你定义一个自定义的合并函数。当你对一个 Key 多次调用 merge() 时,RocksDB 不会立即覆盖旧值,而是会将这些操作暂存起来。在后续的读取或 Compaction(数据合并)过程中,RocksDB 会调用你定义的合并函数,将所有的暂存值(operands)与原始值(existing value)合并成一个最终值。

RocksDBListState 正是利用了这一点来实现高效的 add()addAll()

  • 配置 : RocksDBKeyedStateBackend 在初始化时,会为 ListStateAggregatingState 等需要合并操作的状态所对应的列族(Column Family)设置一个名为 stringappendtestMergeOperator。这个操作符的功能很简单:将新的值(字节数组)追加到旧的值(字节数组)后面,并用一个特殊的分隔符隔开。
  • 优势 : 当你调用 listState.add(element) 时,RocksDBListState 不需要先从 RocksDB get() 出整个列表(可能非常大),在内存中反序列化、添加新元素、再序列化整个列表写回去(即 "Read-Modify-Write" 模式)。它只需要将单个新元素序列化 ,然后调用 RocksDB 的 merge() 操作。这个操作非常轻量,只是将新元素的字节数组追加到 RocksDB 的内部日志中,极大地提升了写入性能。

add(V value) 方法

这是最能体现 Merge 操作优势的地方。

java 复制代码
// ... existing code ...
    @Override
    public void add(V value) throws IOException, RocksDBException {
        Preconditions.checkNotNull(value, "You cannot add null to a ListState.");

        backend.db.merge(
                columnFamily,
                writeOptions,
                serializeCurrentKeyWithGroupAndNamespace(),
                serializeValue(value, elementSerializer));
    }
// ... existing code ...
  1. serializeCurrentKeyWithGroupAndNamespace(): 从父类继承此方法,构建出当前 Key+Namespace 对应的 RocksDB Key。
  2. serializeValue(value, elementSerializer): 注意 ,这里序列化的不是整个 List,而仅仅是新加入的单个元素 value
  3. backend.db.merge(...): 调用 RocksDB 的 merge API。RocksDB 底层会将这个序列化后的单个元素追加到已有值的末尾。

addAll(List<V> values) 的逻辑类似,它会将传入的 values 整个序列化(元素间用分隔符隔开),然后进行一次 merge 操作。

add没有增加逗号分隔,但是 Flink 通过配置 RocksDB 的 StringAppendOperator 并为其指定了 逗号 作为分隔符,才使得多次 merge 操作的结果之间被加上了逗号,此时get可以正确解析。

java 复制代码
public class RocksDBKeyedStateBackend<K> extends AbstractKeyedStateBackend<K> {


    /**
     * The name of the merge operator in RocksDB. Do not change except you know exactly what you do.
     */
    public static final String MERGE_OPERATOR_NAME = "stringappendtest";

    RocksDBOperationUtils::public static ColumnFamilyOptions createColumnFamilyOptions(
            Function<String, ColumnFamilyOptions> columnFamilyOptionsFactory, String stateName) {

        // ensure that we use the right merge operator, because other code relies on this
        return columnFamilyOptionsFactory
                .apply(stateName)
                .setMergeOperatorName(MERGE_OPERATOR_NAME);
    }

get() 方法

get() 方法的实现则相对复杂,它需要处理 merge 操作留下的、由分隔符拼接的字节数组。

java 复制代码
// ... existing code ...
    @Override
    public List<V> getInternal() throws IOException, RocksDBException {
        byte[] key = serializeCurrentKeyWithGroupAndNamespace();
        byte[] valueBytes = backend.db.get(columnFamily, key);
        return listSerializer.deserializeList(valueBytes, elementSerializer);
    }
// ... existing code ...
  1. backend.db.get(columnFamily, key): 从 RocksDB 获取值。此时获取到的 valueBytes 可能是一个经过多次 merge 后、由多个序列化元素和分隔符拼接成的长字节数组。
  2. listSerializer.deserializeList(...): 这里的 listSerializer 是一个 ListDelimitedSerializer 的实例。这个特殊的序列化器知道如何处理这种格式的数据。它会遍历 valueBytes,根据分隔符 DELIMITER (,) 将其切分成一个个独立的元素字节块,然后使用 elementSerializer 逐个反序列化,最终组装成一个 List<V> 返回。

update(List<V> valueToStore) 方法

update 方法的语义是覆盖 整个列表,而不是追加。因此,它不能使用 merge

java 复制代码
// ... existing code ...
    @Override
    public void updateInternal(List<V> values) throws IOException, RocksDBException {
        Preconditions.checkNotNull(values, "List of values to add cannot be null.");

        if (!values.isEmpty()) {
            backend.db.put(
                    columnFamily,
                    writeOptions,
                    serializeCurrentKeyWithGroupAndNamespace(),
                    listSerializer.serializeList(values, elementSerializer));
        } else {
            clear();
        }
    }
// ... existing code ...

它的实现非常直接:

  1. 使用 listSerializer.serializeList(...) 将整个 values 列表序列化成一个完整的字节数组。
  2. 调用 backend.db.put(...),用这个新的字节数组直接覆盖 RocksDB 中该 Key 的旧值。

backend.db.put(...) 的写入格式和参数含义。

这个方法的作用是 用一个新的列表完全覆盖(Update)当前 Key 和 Namespace 下的 ListState

java 复制代码
// ... existing code ...
    @Override
    public void updateInternal(List<V> values) throws IOException, RocksDBException {
        Preconditions.checkNotNull(values, "List of values to add cannot be null.");

        if (!values.isEmpty()) {
            backend.db.put(
                    columnFamily,
                    writeOptions,
                    serializeCurrentKeyWithGroupAndNamespace(),
                    listSerializer.serializeList(values, elementSerializer));
        } else {
// ... existing code ...

backend.db.put() 是对 RocksDB JNI 接口的直接调用,它的作用是将一个键值对(Key-Value Pair)写入数据库。我们来逐个分析它的四个参数,这正是理解其写入格式的关键。

  1. columnFamily
  • 类型 : org.rocksdb.ColumnFamilyHandle
  • 含义 : 列族句柄。RocksDB 允许在一个数据库实例中创建多个"列族",你可以把它想象成关系型数据库中的"表"。每个列族有自己独立的配置和数据文件。
  • 作用 : 这个参数告诉 RocksDB:"请把接下来的键值对写入到这个句柄所代表的列族(表)中"。在 RocksDBListState 的场景下,这个 columnFamily 是在创建该 State 对象时由 RocksDBKeyedStateBackend 分配并传入的。Flink 为每一个 StateDescriptor (即每一个 ListState, ValueState 等)都创建了一个独立的列族,从而实现了不同状态之间的物理隔离。
  1. writeOptions
  • 类型 : org.rocksdb.WriteOptions
  • 含义 : 写入选项。这是一个配置对象,用于控制本次写入操作的行为。
  • 作用 : 它可以定义诸如"是否同步写入到磁盘"、"是否禁用预写日志(WAL)"等底层写入策略。这个 writeOptions 对象通常是在 RocksDBKeyedStateBackend 中创建并共享给所有 State 对象的,以保证整个后端写入行为的一致性。

serializeCurrentKeyWithGroupAndNamespace()

  • 类型 : byte[] (方法返回值)

  • 含义 : 序列化后的 RocksDB Key 。这是写入键值对中的 Key 部分。

  • 作用 : 这个方法(继承自 AbstractRocksDBState)是 Flink 状态寻址的核心。它负责将 Flink 的逻辑地址(当前 Key、当前 Namespace)转换成 RocksDB 能够理解的物理 Key(一个字节数组)。其内部的构建过程大致如下:

    1. 获取当前 Key Group ID(一个整数,由 Flink Key 的 hash 计算得出)。
    2. 获取当前 Flink Key (K) 并用 keySerializer 序列化。
    3. 获取当前 Namespace (N) 并用 namespaceSerializer 序列化。
    4. 将这几部分按固定格式拼接成一个最终的 byte[]

    最终的 Key 格式 : [Key Group ID Bytes] + [Serialized Flink Key Bytes] + [Serialized Namespace Bytes]

    这个精心设计的 Key 结构保证了 Flink 状态的正确寻址和高效的范围扫描(因为相同 Key Group 的数据在物理上是相邻的)。

listSerializer.serializeList(values, elementSerializer)

  • 类型 : byte[] (方法返回值)

  • 含义 : 序列化后的 RocksDB Value 。这是写入键值对中的 Value 部分。

  • 作用 : 这个方法负责将 Flink 的逻辑值(一个 List<V>)转换成 RocksDB 能够存储的物理 Value(一个字节数组)。

    • listSerializer 是一个 ListDelimitedSerializer 的实例。
    • 它会遍历传入的 values 列表。
    • 对列表中的每一个元素 ,调用 elementSerializer 将其序列化成 byte[]
    • 在序列化后的元素之间插入一个特殊的分隔符 DELIMITER (, 的字节表示)。

    最终的 Value 格式 : [Serialized Element 1] + [Delimiter] + [Serialized Element 2] + [Delimiter] + ... + [Serialized Element N]

    这个格式与 get() 方法中的反序列化逻辑是完全对应的。

所以,backend.db.put(...) 这行代码的完整写入格式可以概括为:

columnFamily 指定的表中,根据 writeOptions 定义的策略,写入一条记录。这条记录的 Key 是由 [KeyGroup] + [Flink Key] + [Namespace] 序列化拼接而成,其 Value 是由列表中的每个元素序列化后再用分隔符拼接而成的。

这个 put 操作会覆盖 掉该 Key 下已有的任何旧值,这正是 update 语义的正确实现。如果传入的 values 列表为空,则会执行 clear() 方法,即从 RocksDB 中删除这个 Key。

性能考量与对比

  • add() vs update() : add() 操作非常高效,因为它避免了读操作和对整个列表的序列化。而 update() 则是一个典型的 "Write-Only" 操作(如果忽略 Key 的构建),但它需要序列化整个列表,开销相对较大。
  • get() : get() 操作的开销取决于列表的大小,因为它需要读取并反序列化整个列表。
  • 对比 ValueState<List<V>> : 如果你使用 ValueState<List<V>>,那么每次 add 操作都必须遵循 "Read-Modify-Write" 模式:get() 整个列表 -> 内存中 add -> update() 整个列表。这比 RocksDBListStateadd() 操作要昂贵得多。这就是为什么官方文档和社区都强烈推荐在 RocksDB 后端上使用 ListState 而不是 ValueState<List<V>> 的原因。

总结

RocksDBListState 是 Flink 状态后端优化的一个典范。它没有简单地将 ListState 的 API 直接映射到 RocksDB 的 put/get,而是深入利用了 RocksDB 的 Merge 操作符特性,为最常见的 add 操作提供了极高的性能。

  • 核心优势 : 通过 merge 操作实现了高效的追加写,避免了代价高昂的"读-改-写"模式。
  • 专用序列化器 : 使用 ListDelimitedSerializer 来处理由 merge 产生的、由分隔符连接的特殊数据格式。
  • API 语义分离 : 清晰地区分了 add (追加) 和 update (覆盖) 的语义,并为它们选择了不同的底层 RocksDB 操作 (merge vs put),以达到最佳性能。

ListDelimitedSerializer 如何处理分隔符

调用RocksDBListState.get会调用

java 复制代码
    @Override
    public Iterable<V> get() throws IOException, RocksDBException {
        return getInternal();
    }

    @Override
    public List<V> getInternal() throws IOException, RocksDBException {
        byte[] key = serializeCurrentKeyWithGroupAndNamespace();
        byte[] valueBytes = backend.db.get(columnFamily, key);
        return listSerializer.deserializeList(valueBytes, elementSerializer);
    }

deserializeList 方法本身并不直接处理分隔符,它通过循环调用 deserializeNextElement 方法来实现。真正的分隔符处理逻辑在 serializeListdeserializeNextElement 这两个方法中。

核心逻辑

  1. 序列化时(写入)serializeList 方法在写入列表时,会在每两个元素之间写入一个分隔符。第一个元素前和最后一个元素后都没有分隔符。
  2. 反序列化时(读取)deserializeNextElement 方法的逻辑是,它首先调用元素的 TypeSerializer 来读取一个完整的元素。读取完毕后,如果流中还有数据,它就再读取一个字节并丢弃,这个被丢弃的字节就是预设的分隔符。

我们来看具体的代码实现:

  1. 定义分隔符 在类的顶部,定义了用作分隔符的字节。这里用的是逗号 ,

    ListDelimitedSerializer.java

    java 复制代码
    // ... existing code ...
    public final class ListDelimitedSerializer {
    
        private static final byte DELIMITER = ',';
    // ... existing code ...
  2. 序列化(写入分隔符)serializeList 方法中,它会遍历列表。除了第一个元素外,在序列化每个元素之前 ,都会先写入一个 DELIMITER

    java 复制代码
    // ... existing code ...
    public <T> byte[] serializeList(List<T> valueList, TypeSerializer<T> elementSerializer)
            throws IOException {
    
        dataOutputView.clear();
        boolean first = true;
    
        for (T value : valueList) {
            Preconditions.checkNotNull(value, "You cannot add null to a value list.");
    
            if (first) {
                first = false;
            } else {
                dataOutputView.write(DELIMITER);
            }
            elementSerializer.serialize(value, dataOutputView);
        }
    
        return dataOutputView.getCopyOfBuffer();
    }
    // ... existing code ...
  3. 反序列化(读取并消费分隔符)deserializeNextElement 方法中,处理逻辑如下:

    • elementSerializer.deserialize(in): 首先,使用元素的序列化器读取一个完整的对象。序列化器知道自己需要读多少字节。
    • if (in.available() > 0): 读取完一个元素后,检查是否还有剩余的字节。
    • in.readByte(): 如果有,就读取一个字节。这个字节就是元素之间的分隔符。这一步执行完后,输入流的指针就移动到了下一个元素的起始位置。
    java 复制代码
    // ... existing code ...
    /** Deserializes a single element from a serialized list. */
    public static <T> T deserializeNextElement(
            DataInputDeserializer in, TypeSerializer<T> elementSerializer) throws IOException {
        if (in.available() > 0) {
            T element = elementSerializer.deserialize(in);
            if (in.available() > 0) {
                in.readByte();
            }
            return element;
        }
        return null;
    }
    }

ListDelimitedSerializer 序列化 和 RocksDB MergeOperator重复写入分割符号?

简单来说,答案是:这两个机制服务于完全不同的场景,它们并不冲突,也不是重复工作。

  • RocksDB 的 MergeOperator :用于 运行时(Runtime)ListState增量更新
  • ListDelimitedSerializer :用于 快照/保存点(Snapshot/Savepoint)全量读写

MergeOperator 和它的分隔符是一种底层存储优化 ,它将多次小的写入操作(add)延迟合并为一次大的写入操作,极大地提升了 ListState 在运行时的写入性能。这个过程对 Flink 的上层应用是透明的。

ListDelimitedSerializer 的用途在它的类注释中已经写明:

java 复制代码
// ... existing code ...
/**
 * Encapsulates a logic of serialization and deserialization of a list with a delimiter. Used in the
 * savepoint format.
 */
public final class ListDelimitedSerializer {
// ... existing code ...

它被用于 Savepoint 格式

当 Flink 创建一个快照或保存点时,它需要从 RocksDB 中读取状态的全量数据,并将其写入一个外部的、可移植的文件系统中(如 HDFS, S3)。

流程如下:

  1. Flink 向 RocksDB 请求某个 key 对应的 ListState 的值。
  2. RocksDB 返回这个 key 对应的最终值。这个值可能已经是被 MergeOperator 合并过的、包含内部分隔符的完整字节数组。
  3. Flink 的 RocksDB 后端接收到这个字节数组后,会使用内部分隔符 将其反序列化 成 JVM 中的一个 java.util.List<T> 对象。
  4. 现在,Flink 需要将这个 List<T> 对象写入到 Savepoint 文件中。这时 ListDelimitedSerializer 就登场了。它会遍历这个 List<T>,将每个元素序列化,并在元素之间写入它自己的分隔符,),最终生成一个用于写入 Savepoint 文件的字节数组。

ListDelimitedSerializer 是一个高层格式化工具 ,它的任务是将一个在 JVM 中已经完整存在的 List 对象,序列化成一个符合 Savepoint 规范的、带分隔符的字节数组,以便于存储和恢复。

特性 RocksDB MergeOperator ListDelimitedSerializer
使用场景 运行时 ListState.add() 创建/恢复 Savepoint
操作对象 RocksDB 中的原始字节片段 (operands) JVM 中的 java.util.List<T> 对象
目的 性能优化:实现高效的增量追加 数据持久化:为 Savepoint 创建可移植的二进制格式
上下文 底层存储引擎的内部机制 Flink 的快照/保存点机制

所以,这两种机制在 Flink 状态管理的不同生命周期阶段各司其职,它们的分隔符虽然功能相似,但作用的层次和目标完全不同,因此不存在功能上的重复。

总结

ListDelimitedSerializer 通过一种"约定"来处理分隔符:

  • 写入方 :在元素之间插入一个 , 字节。
  • 读取方 :在读取完一个元素的字节后,主动地、无条件地再读取一个字节(即分隔符 ,)并将其忽略,从而为读取下一个元素做好了准备。

这种方式依赖于 elementSerializer 能够精确地从字节流中反序列化出自身,不多读也不少读。

另外需要注意,正如该类的注释所说,它主要用于 Savepoint 格式,而不是 RocksDB ListState 在运行时的 Merge 操作。RocksDB ListStateMerge 操作是更底层的字节数组拼接,不依赖于这样的分隔符。

设置MergeOp

RocksDBOperationUtils 是设置 MergeOperator 的地方。这个设置是 Flink RocksDB StateBackend 能够正确支持 ListState 的关键。

下面是 RocksDBKeyedStateBackendRocksDBOperationUtils 之间的详细互动过程,这个过程通常在为新状态(State)创建列族(Column Family)时触发:

RocksDBKeyedStateBackend 在需要创建列族时,会委托 RocksDBOperationUtils 来完成。在这个委托过程中,RocksDBOperationUtils 会确保为列族设置正确的 MergeOperator

第 1 步:触发点 - 创建状态信息

RocksDBKeyedStateBackend 启动(例如从快照恢复)或在运行时首次访问某个 StateDescriptor 对应的状态时,它需要为这个状态创建一个在 RocksDB 中对应的 Column Family。这个创建过程被封装在 RocksDBOperationUtils.createStateInfo 方法中。

RocksDBOperationUtils.java

java 复制代码
// ... existing code ...
    public static RocksDBKeyedStateBackend.RocksDbKvStateInfo createStateInfo(
            RegisteredStateMetaInfoBase metaInfoBase,
            RocksDB db,
            Function<String, ColumnFamilyOptions> columnFamilyOptionsFactory,
            @Nullable RocksDbTtlCompactFiltersManager ttlCompactFiltersManager,
            @Nullable Long writeBufferManagerCapacity,
            List<ExportImportFilesMetaData> importFilesMetaData,
            ICloseableRegistry cancelStreamRegistryForRestore) {

        ColumnFamilyDescriptor columnFamilyDescriptor =
                createColumnFamilyDescriptor(
                        metaInfoBase,
                        columnFamilyOptionsFactory,
                        ttlCompactFiltersManager,
                        writeBufferManagerCapacity);

        try {
            ColumnFamilyHandle columnFamilyHandle =
                    createColumnFamily(
                            columnFamilyDescriptor,
                            db,
                            importFilesMetaData,
                            cancelStreamRegistryForRestore);
            return new RocksDBKeyedStateBackend.RocksDbKvStateInfo(
                    columnFamilyHandle, metaInfoBase);
// ... existing code ...

第 2 步:创建列族描述符(ColumnFamilyDescriptor)

createStateInfo 方法内部会调用 createColumnFamilyDescriptor 来准备创建列族所需的描述信息。

java 复制代码
// ... existing code ...
    public static ColumnFamilyDescriptor createColumnFamilyDescriptor(
            RegisteredStateMetaInfoBase metaInfoBase,
            Function<String, ColumnFamilyOptions> columnFamilyOptionsFactory,
            @Nullable RocksDbTtlCompactFiltersManager ttlCompactFiltersManager,
            @Nullable Long writeBufferManagerCapacity) {

        byte[] nameBytes = metaInfoBase.getName().getBytes(ConfigConstants.DEFAULT_CHARSET);
// ... existing code ...
        ColumnFamilyOptions options =
                createColumnFamilyOptions(columnFamilyOptionsFactory, metaInfoBase.getName());

        if (ttlCompactFiltersManager != null) {
// ... existing code ...
        }

// ... existing code ...
        return new ColumnFamilyDescriptor(nameBytes, options);
    }

第 3 步:创建并强制设置列族选项(ColumnFamilyOptions)

这是最核心的一步。createColumnFamilyDescriptor 调用了 createColumnFamilyOptions。这个方法接收一个 columnFamilyOptionsFactory 函数,这个函数通常来自用户的配置(比如通过 EmbeddedRocksDBStateBackend.setPredefinedOptions() 设置)。

createColumnFamilyOptions 首先通过工厂函数获取用户配置的基础 ColumnFamilyOptions,然后强制 调用 .setMergeOperatorName(MERGE_OPERATOR_NAME)

java 复制代码
// ... existing code ...
    public static ColumnFamilyOptions createColumnFamilyOptions(
            Function<String, ColumnFamilyOptions> columnFamilyOptionsFactory, String stateName) {

        // ensure that we use the right merge operator, because other code relies on this
        return columnFamilyOptionsFactory
                .apply(stateName)
                .setMergeOperatorName(MERGE_OPERATOR_NAME);
    }
// ... existing code ...

这里的 MERGE_OPERATOR_NAME 是在 RocksDBKeyedStateBackend 中定义的常量:

java 复制代码
// ... existing code ...
    /**
     * The name of the merge operator in RocksDB. Do not change except you know exactly what you do.
     */
    public static final String MERGE_OPERATOR_NAME = "stringappendtest";

    private static final Map<StateDescriptor.Type, StateCreateFactory> STATE_CREATE_FACTORIES =
// ... existing code ...

总结

这个互动过程的设计非常巧妙:

  1. 尊重用户配置 :通过 columnFamilyOptionsFactory.apply(stateName),首先获取了用户定义的所有 RocksDB 选项。
  2. 保证框架正确性 :通过 .setMergeOperatorName(...),它覆盖或设置了 MergeOperator,确保无论用户如何配置,ListState 所依赖的 merge 操作都能正确工作。ListState.add() 方法内部依赖 db.merge(),而 db.merge() 需要这个名为 "stringappendtest"StringAppendOperator 才能将元素正确地追加到列表后面。

所以,整个流程是一个"配置合并"的过程:RocksDBKeyedStateBackend 将创建列族的任务委托给 RocksDBOperationUtils,而后者在执行任务时,会将用户配置和 Flink 框架的强制要求结合起来,最终生成一个能保证状态后端正常工作的 ColumnFamilyOptions

序列化和反序列化:怎么处理作为value的list

RocksDBListState 将整个列表(List)作为单个 value 存储在 RocksDB 中。但RocksDBListState 并不直接使用标准的 ListSerializer 来序列化整个列表,而是使用一个内部的 ListDelimitedSerializer

RocksDBListState 的构造函数中可以看到,它会从传入的 valueSerializer (一个 ListSerializer<V>) 中提取出元素的序列化器 elementSerializer,并创建一个 ListDelimitedSerializer 实例供自己使用。

java 复制代码
// ... existing code ...
class RocksDBListState<K, N, V> extends AbstractRocksDBState<K, N, List<V>>
        implements InternalListState<K, N, V> {

    /** Serializer for the values. */
    private TypeSerializer<V> elementSerializer;

    private final ListDelimitedSerializer listSerializer;

    /** Separator of StringAppendTestOperator in RocksDB. */
    private static final byte DELIMITER = ',';

    /**
     * Creates a new {@code RocksDBListState}.
// ... existing code ...
     */
    private RocksDBListState(
            ColumnFamilyHandle columnFamily,
            TypeSerializer<N> namespaceSerializer,
            TypeSerializer<List<V>> valueSerializer,
            List<V> defaultValue,
            RocksDBKeyedStateBackend<K> backend) {

        super(columnFamily, namespaceSerializer, valueSerializer, defaultValue, backend);

        ListSerializer<V> castedListSerializer = (ListSerializer<V>) valueSerializer;
        this.elementSerializer = castedListSerializer.getElementSerializer();
        this.listSerializer = new ListDelimitedSerializer();
    }
// ... existing code ...

如何反序列化?

当调用 get() 方法时,RocksDBListState 从 RocksDB 中获取序列化后的 byte[],然后使用 ListDelimitedSerializer 来反序列化。

java 复制代码
// ... existing code ...
    @Override
    public List<V> getInternal() throws IOException, RocksDBException {
        byte[] key = serializeCurrentKeyWithGroupAndNamespace();
        byte[] valueBytes = backend.db.get(columnFamily, key);
        return listSerializer.deserializeList(valueBytes, elementSerializer);
    }
// ... existing code ...

ListDelimitedSerializer 的反序列化逻辑如下:它并不读取列表长度,而是循环地从字节流中反序列化单个元素,直到字节流被完全读取。

  • 类型信息 :通过 elementSerializer (TypeSerializer<V>) 来获知。这个序列化器知道如何从字节流中正确地读取一个 V 类型的元素。
  • 长度信息 :是隐式的。反序列化过程会一直持续到 valueBytes 被完全消费完为止。
java 复制代码
// ... existing code ...
public final class ListDelimitedSerializer {

// ... existing code ...
    public <T> List<T> deserializeList(byte[] valueBytes, TypeSerializer<T> elementSerializer)
            throws IOException {
        if (valueBytes == null) {
            return null;
        }

        dataInputView.setBuffer(valueBytes);

        List<T> result = new ArrayList<>();
        T next;
        while ((next = deserializeNextElement(dataInputView, elementSerializer)) != null) {
            result.add(next);
        }
        return result;
    }
// ... existing code ...
}

这种设计的核心原因是为了充分利用 RocksDB 的 merge 操作符来高效地执行 add(V value)addAll(List<V> values)

  • add(value) : 当你调用 add 时,Flink 只序列化这一个新元素,然后调用 RocksDB 的 merge。RocksDB 会将这个新元素的字节流追加(append)到现有值的末尾,并用一个特殊的分隔符(,)隔开。这比"读取整个列表 -> 添加元素 -> 序列化整个列表 -> 写回"要高效得多。
  • update(list) : 当你调用 update 时,它会序列化整个新列表,并使用 put 操作覆盖掉 RocksDB 中的旧值。

对比:标准的 ListSerializer

作为对比,标准的 ListSerializer 在序列化时会先写入列表的长度(一个整数),然后再依次序列化每个元素。反序列化时则先读取长度,再根据长度读取相应数量的元素。

java 复制代码
// ... existing code ...
    @Override
    public void serialize(List<T> list, DataOutputView target) throws IOException {
        final int size = list.size();
        target.writeInt(size);

        // We iterate here rather than accessing by index, because we cannot be sure that
        // the given list supports RandomAccess.
        // The Iterator should be stack allocated on new JVMs (due to escape analysis)
        for (T element : list) {
            elementSerializer.serialize(element, target);
        }
    }
// ... existing code ...

elementSerializer 是否需要写入长度?

对于可变长度的类型,elementSerializer 自身必须负责写入和读取长度信息。

让我们来详细解释一下这个机制:

TypeSerializer 的设计哲学是 "自包含" 的。也就是说,任何一个 TypeSerializer 的实现,其 serialize 方法写入到 DataOutputView 的字节流,必须能被其对应的 deserialize 方法从 DataInputView 中完整地、不多不少地读取出来,而不需要任何外部的长度信息。

调用者(比如 ListSerializer)在处理一连串元素时,它只是简单地循环调用 elementSerializer.deserialize(source)。它并不知道每个元素具体有多长,它完全信任 elementSerializer 每次都能正确地从流中消费掉一个完整的元素。

我们来看两个例子:

a) 可变长度类型

BinaryRowData 的长度是可变的。因此,它的序列化器在序列化时,必须先把自身的总字节长度写进去,反序列化时再先读出长度,然后按长度读取数据。

BinaryRowDataSerializer.java

java 复制代码
// ... existing code ...
    @Override
    public void serialize(BinaryRowData record, DataOutputView target) throws IOException {
        // 1. 先写入总长度
        target.writeInt(record.getSizeInBytes());
        if (target instanceof MemorySegmentWritable) {
            serializeWithoutLength(record, (MemorySegmentWritable) target);
        } else {
            // 2. 再写入实际数据
            BinarySegmentUtils.copyToView(
                    record.getSegments(), record.getOffset(), record.getSizeInBytes(), target);
        }
    }

    @Override
    public BinaryRowData deserialize(DataInputView source) throws IOException {
        BinaryRowData row = new BinaryRowData(numFields);
        // 1. 先读取总长度
        int length = source.readInt();
        byte[] bytes = new byte[length];
        // 2. 再根据长度读取数据
        source.readFully(bytes);
        row.pointTo(MemorySegmentFactory.wrap(bytes), 0, length);
        return row;
    }
// ... existing code ...

b) 固定长度类型 (例如 LongSerializer)

Long 类型是固定8个字节。它的序列化器就不需要额外写入长度信息,因为 writeLongreadLong 操作本身就隐含了8字节的长度。

LongSerializer.java

java 复制代码
// ... existing code ...
    @Override
    public void serialize(Long record, DataOutputView target) throws IOException {
        target.writeLong(record);
    }

    @Override
    public Long deserialize(DataInputView source) throws IOException {
        return source.readLong();
    }
// ... existing code ...

仅仅做到序列化和反序列化对称是不够的。elementSerializer 必须确保它的 deserialize 方法能够从一个连续的字节流中准确地界定出一个完整对象的边界。对于可变长度类型,最通用的方法就是先写入长度。


Java 原生的 writeObject 序列化机制

Java 原生的 ObjectOutputStream.writeObject() 不需要用户自己去控制长度,那是因为它在底层为你做了更多的事情。

Java 的原生序列化机制会生成一种**自描述(self-describing)**的二进制格式。当你调用 writeObject() 时,它并不仅仅是写入对象的原始数据,还会写入很多元数据(metadata),包括:

  1. 类信息: 对象的完整类名、serialVersionUID
  2. 字段描述: 类的所有需要序列化的字段的名称和类型。
  3. 长度信息: 对于数组、String 等可变长度的类型,它会自动写入其长度。
  4. 对象引用: 如果你在同一个流中多次序列化同一个对象,从第二次开始,它只会写入一个指向第一次序列化结果的引用(一个内部ID),而不是重复写入整个对象,这可以解决循环依赖和共享引用的问题。

所以,是的,Java 原生序列化底层为你补充了所有必要的元信息,包括长度。这也是为什么它使用起来很方便,但通常性能较差、序列化后的体积也更大的原因。Flink 为了极致的性能和对内存的精细化控制,选择了自己设计一套序列化框架,将这些控制权交给了 TypeSerializer 的实现者。

总结

  • RocksDBListState 将整个 List 存成一个 value。
  • 它不记录列表的长度,而是将所有元素的序列化字节拼接在一起。
  • 反序列化时,它依赖 elementSerializer 来逐个解析元素,直到字节流耗尽。
  • 这种设计是为了优化 add 操作,利用了 RocksDB 的 merge 特性,避免了读-修改-写的开销。

RocksDB 的存储架构:LSM-Tree

要理解 merge,必须先理解 RocksDB 的数据是如何组织的。其简化的写入和读取流程如下:

  1. 写入 (Write/Put/Merge/Delete):

    • 写 WAL (Write-Ahead Log) : 首先,操作记录(如 merge 'key1', 'value_part_3')会被顺序写入到 WAL 文件中,用于故障恢复。这一步是纯粹的顺序写,非常快。
    • 写 MemTable : 同时,数据被写入到内存中的一个可写数据结构,称为 MemTable(通常是跳表或类似结构)。在 MemTable 中,数据是按 Key 排序的。
  2. 刷盘 (Flush):

    • MemTable 写满后,它会变为只读状态,并被一个新的空 MemTable 替换。
    • 这个只读的 MemTable 会被后台线程刷盘(Flush) ,生成一个位于磁盘上的、不可变的、有序的 SSTable (Sorted String Table) 文件。这个文件会被放在 Level-0
  3. 后台合并 (Compaction):

    • RocksDB 会在后台不断地进行 Compaction 操作。这个过程会读取低层级(如 Level-0)的多个 SSTable,将它们的键值对进行合并排序,然后生成一个新的、更大的 SSTable 写入到下一层级(如 Level-1)。
    • Compaction 的主要目的有:清理被覆盖或删除的数据、合并 merge 操作、优化读取性能。
  4. 读取 (Get):

    • 当读取一个 Key 时,RocksDB 会从新到旧 地查找:
      1. 先查可写的 MemTable
      2. 再查只读的 MemTable (正在刷盘的)。
      3. 然后从 Level-0Level-N 逐层查找 SSTable。
    • 一旦在某个地方找到了这个 Key 的最新版本(PUTDELETE),查找就会停止。但对于 MERGE 操作,则需要继续往下找。

Merge 操作的实现细节

现在,我们来看 merge 是如何融入这个体系的。

当执行 db.merge(key, value_part) 时:

  1. 写入阶段 : RocksDB 不会去查找 key 的旧值。它只是简单地将一个类型为 MERGE 的操作记录value_part 一起写入 WAL 和 MemTable。这和 PUT 操作一样快,因为它避免了任何读操作。

  2. 逻辑视图 : 经过多次 merge 后,对于同一个 key,在 RocksDB 的物理存储中可能存在一个这样的操作序列(从新到旧): MERGE 'd' -> MERGE 'c' -> PUT ['a', 'b'] -> MERGE 'e' (在更老的SSTable里)

  3. 读取/合并阶段 (Merge-on-Read):

    • 当用户调用 db.get(key) 时,RocksDB 开始查找。
    • 它找到了最新的操作是 MERGE 'd'。因为它是一个 MERGE 操作,所以它知道不能停,必须继续往下找,直到找到一个 PUTDELETE 操作,或者查完所有层级。
    • 它会继续找到 MERGE 'c',然后是 PUT ['a', 'b']
    • 此时,它找到了一个 PUT 作为合并的起点
    • 然后,它会调用一个用户定义的 MergeOperator (在 Flink ListState 的场景下,就是一个能将字节片段拼接起来的 ListMergeOperator),执行如下逻辑: result = merge(merge(put_value, operand_c), operand_d) 即:result = merge(merge(['a', 'b'], 'c'), 'd') -> 最终得到 ['a', 'b', 'c', 'd']
    • 这个合并过程是在读取时动态发生的。
  4. 后台合并阶段 (Compaction):

    • 为了避免每次读取都进行大量动态合并,Compaction 过程也会应用 MergeOperator
    • 当 Compaction 任务合并多个包含 key 的 SSTable 时,它会执行与读取时类似的操作,将一系列 PUTMERGE 操作合并成一个新的、更完整的 PUT 操作,然后写入到更高层级的 SSTable 中。
    • 例如,MERGE 'd', MERGE 'c', PUT ['a', 'b'] 经过 Compaction 后,可能会在新的 SSTable 中变成一个单独的记录:PUT ['a', 'b', 'c', 'd']
    • 这样就大大减少了未来读取该 key 时的合并开销。

总结

RocksDB merge 的底层实现可以概括为:

  • 写时追加 (Append-on-Write) : merge 操作本身非常轻量,只是在 LSM-Tree 中追加一条"合并"指令,避免了昂贵的"读-改-写"模式。
  • 读时合并 (Merge-on-Read) : 当读取数据时,RocksDB 会查找一个 Key 的所有 merge 操作记录,直到找到一个 PUT 作为起点,然后通过 MergeOperator 动态地将它们组合成最终结果。
  • 后台合并 (Compaction) : 为了优化读取性能,后台 Compaction 任务会周期性地将多个 merge 操作和它们的 PUT 基础值进行预合并,生成一个包含完整数据的新 PUT 记录。

这种设计将写入的性能最大化,同时通过后台和读取时的计算来换取最终的数据一致性,是 LSM-Tree 架构处理追加写(Append-only)场景的经典实现。