RocksDBListState<K, N, V>
RocksDBListState
继承自 AbstractRocksDBState<K, N, List<V>>
,并实现了 InternalListState<K, N, V>
接口。
- 继承
AbstractRocksDBState
: 这意味着它天然获得了与 RocksDB 交互的底层能力,包括:- 持有
backend
、columnFamily
等核心组件的引用。 - 能够使用
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 ...
-
准备目标 Key (
targetKey
):setCurrentNamespace(target)
:首先,将backend
的上下文切换到目标窗口 的namespace
。serializeCurrentKeyWithGroupAndNamespace()
:然后,将当前的key
(例如用户ID)和这个target
namespace 序列化成一个完整的二进制byte[]
key。这个targetKey
就是未来所有状态要汇集的地方。
-
遍历所有源
namespace
(sources
):- 这是一个
for
循环,会迭代所有需要被合并的旧窗口的namespace
。
- 这是一个
-
准备源 Key (
sourceKey
):- 在循环内部,
setCurrentNamespace(source)
将backend
的上下文切换到其中一个源窗口 的namespace
。 serializeCurrentKeyWithGroupAndNamespace()
再次被调用,生成这个源窗口对应的二进制byte[]
key。
- 在循环内部,
-
读取源 Value (
valueBytes
):backend.db.get(columnFamily, sourceKey)
:使用源key
从 RocksDB 中读取出对应的value
。对于ListState
来说,这个valueBytes
存储的是整个列表序列化后的字节。
-
删除源 Key-Value 对:
backend.db.delete(columnFamily, writeOptions, sourceKey)
:一旦源窗口的状态被读取出来,它对应的旧条目就需要被删除,以防状态重复。
-
将源 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
在初始化时,会为ListState
和AggregatingState
等需要合并操作的状态所对应的列族(Column Family)设置一个名为stringappendtest
的MergeOperator
。这个操作符的功能很简单:将新的值(字节数组)追加到旧的值(字节数组)后面,并用一个特殊的分隔符隔开。 - 优势 : 当你调用
listState.add(element)
时,RocksDBListState
不需要先从 RocksDBget()
出整个列表(可能非常大),在内存中反序列化、添加新元素、再序列化整个列表写回去(即 "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 ...
serializeCurrentKeyWithGroupAndNamespace()
: 从父类继承此方法,构建出当前 Key+Namespace 对应的 RocksDB Key。serializeValue(value, elementSerializer)
: 注意 ,这里序列化的不是整个List
,而仅仅是新加入的单个元素value
。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 ...
backend.db.get(columnFamily, key)
: 从 RocksDB 获取值。此时获取到的valueBytes
可能是一个经过多次merge
后、由多个序列化元素和分隔符拼接成的长字节数组。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 ...
它的实现非常直接:
- 使用
listSerializer.serializeList(...)
将整个values
列表序列化成一个完整的字节数组。 - 调用
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)写入数据库。我们来逐个分析它的四个参数,这正是理解其写入格式的关键。
columnFamily
- 类型 :
org.rocksdb.ColumnFamilyHandle
- 含义 : 列族句柄。RocksDB 允许在一个数据库实例中创建多个"列族",你可以把它想象成关系型数据库中的"表"。每个列族有自己独立的配置和数据文件。
- 作用 : 这个参数告诉 RocksDB:"请把接下来的键值对写入到这个句柄所代表的列族(表)中"。在
RocksDBListState
的场景下,这个columnFamily
是在创建该 State 对象时由RocksDBKeyedStateBackend
分配并传入的。Flink 为每一个 StateDescriptor (即每一个ListState
,ValueState
等)都创建了一个独立的列族,从而实现了不同状态之间的物理隔离。
writeOptions
- 类型 :
org.rocksdb.WriteOptions
- 含义 : 写入选项。这是一个配置对象,用于控制本次写入操作的行为。
- 作用 : 它可以定义诸如"是否同步写入到磁盘"、"是否禁用预写日志(WAL)"等底层写入策略。这个
writeOptions
对象通常是在RocksDBKeyedStateBackend
中创建并共享给所有 State 对象的,以保证整个后端写入行为的一致性。
serializeCurrentKeyWithGroupAndNamespace()
-
类型 :
byte[]
(方法返回值) -
含义 : 序列化后的 RocksDB Key 。这是写入键值对中的 Key 部分。
-
作用 : 这个方法(继承自
AbstractRocksDBState
)是 Flink 状态寻址的核心。它负责将 Flink 的逻辑地址(当前 Key、当前 Namespace)转换成 RocksDB 能够理解的物理 Key(一个字节数组)。其内部的构建过程大致如下:- 获取当前 Key Group ID(一个整数,由 Flink Key 的 hash 计算得出)。
- 获取当前 Flink Key (
K
) 并用keySerializer
序列化。 - 获取当前 Namespace (
N
) 并用namespaceSerializer
序列化。 - 将这几部分按固定格式拼接成一个最终的
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()
vsupdate()
:add()
操作非常高效,因为它避免了读操作和对整个列表的序列化。而update()
则是一个典型的 "Write-Only" 操作(如果忽略 Key 的构建),但它需要序列化整个列表,开销相对较大。get()
:get()
操作的开销取决于列表的大小,因为它需要读取并反序列化整个列表。- 对比
ValueState<List<V>>
: 如果你使用ValueState<List<V>>
,那么每次add
操作都必须遵循 "Read-Modify-Write" 模式:get()
整个列表 -> 内存中add
->update()
整个列表。这比RocksDBListState
的add()
操作要昂贵得多。这就是为什么官方文档和社区都强烈推荐在 RocksDB 后端上使用ListState
而不是ValueState<List<V>>
的原因。
总结
RocksDBListState
是 Flink 状态后端优化的一个典范。它没有简单地将 ListState
的 API 直接映射到 RocksDB 的 put
/get
,而是深入利用了 RocksDB 的 Merge
操作符特性,为最常见的 add
操作提供了极高的性能。
- 核心优势 : 通过
merge
操作实现了高效的追加写,避免了代价高昂的"读-改-写"模式。 - 专用序列化器 : 使用
ListDelimitedSerializer
来处理由merge
产生的、由分隔符连接的特殊数据格式。 - API 语义分离 : 清晰地区分了
add
(追加) 和update
(覆盖) 的语义,并为它们选择了不同的底层 RocksDB 操作 (merge
vsput
),以达到最佳性能。
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
方法来实现。真正的分隔符处理逻辑在 serializeList
和 deserializeNextElement
这两个方法中。
核心逻辑
- 序列化时(写入) :
serializeList
方法在写入列表时,会在每两个元素之间写入一个分隔符。第一个元素前和最后一个元素后都没有分隔符。 - 反序列化时(读取) :
deserializeNextElement
方法的逻辑是,它首先调用元素的TypeSerializer
来读取一个完整的元素。读取完毕后,如果流中还有数据,它就再读取一个字节并丢弃,这个被丢弃的字节就是预设的分隔符。
我们来看具体的代码实现:
-
定义分隔符 在类的顶部,定义了用作分隔符的字节。这里用的是逗号
,
。ListDelimitedSerializer.java
java// ... existing code ... public final class ListDelimitedSerializer { private static final byte DELIMITER = ','; // ... existing code ...
-
序列化(写入分隔符) 在
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 ...
-
反序列化(读取并消费分隔符) 在
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)。
流程如下:
- Flink 向 RocksDB 请求某个 key 对应的
ListState
的值。 - RocksDB 返回这个 key 对应的最终值。这个值可能已经是被
MergeOperator
合并过的、包含内部分隔符的完整字节数组。 - Flink 的 RocksDB 后端接收到这个字节数组后,会使用内部分隔符 将其反序列化 成 JVM 中的一个
java.util.List<T>
对象。 - 现在,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 ListState
的 Merge
操作是更底层的字节数组拼接,不依赖于这样的分隔符。
设置MergeOp
RocksDBOperationUtils
是设置 MergeOperator
的地方。这个设置是 Flink RocksDB StateBackend 能够正确支持 ListState
的关键。
下面是 RocksDBKeyedStateBackend
和 RocksDBOperationUtils
之间的详细互动过程,这个过程通常在为新状态(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 ...
总结
这个互动过程的设计非常巧妙:
- 尊重用户配置 :通过
columnFamilyOptionsFactory.apply(stateName)
,首先获取了用户定义的所有 RocksDB 选项。 - 保证框架正确性 :通过
.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个字节。它的序列化器就不需要额外写入长度信息,因为 writeLong
和 readLong
操作本身就隐含了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),包括:
- 类信息: 对象的完整类名、
serialVersionUID
。 - 字段描述: 类的所有需要序列化的字段的名称和类型。
- 长度信息: 对于数组、
String
等可变长度的类型,它会自动写入其长度。 - 对象引用: 如果你在同一个流中多次序列化同一个对象,从第二次开始,它只会写入一个指向第一次序列化结果的引用(一个内部ID),而不是重复写入整个对象,这可以解决循环依赖和共享引用的问题。
所以,是的,Java 原生序列化底层为你补充了所有必要的元信息,包括长度。这也是为什么它使用起来很方便,但通常性能较差、序列化后的体积也更大的原因。Flink 为了极致的性能和对内存的精细化控制,选择了自己设计一套序列化框架,将这些控制权交给了 TypeSerializer
的实现者。
总结
RocksDBListState
将整个 List 存成一个 value。- 它不记录列表的长度,而是将所有元素的序列化字节拼接在一起。
- 反序列化时,它依赖
elementSerializer
来逐个解析元素,直到字节流耗尽。 - 这种设计是为了优化
add
操作,利用了 RocksDB 的merge
特性,避免了读-修改-写的开销。
RocksDB 的存储架构:LSM-Tree
要理解 merge
,必须先理解 RocksDB 的数据是如何组织的。其简化的写入和读取流程如下:
-
写入 (Write/Put/Merge/Delete):
- 写 WAL (Write-Ahead Log) : 首先,操作记录(如
merge 'key1', 'value_part_3'
)会被顺序写入到 WAL 文件中,用于故障恢复。这一步是纯粹的顺序写,非常快。 - 写 MemTable : 同时,数据被写入到内存中的一个可写数据结构,称为
MemTable
(通常是跳表或类似结构)。在MemTable
中,数据是按 Key 排序的。
- 写 WAL (Write-Ahead Log) : 首先,操作记录(如
-
刷盘 (Flush):
- 当
MemTable
写满后,它会变为只读状态,并被一个新的空MemTable
替换。 - 这个只读的
MemTable
会被后台线程刷盘(Flush) ,生成一个位于磁盘上的、不可变的、有序的 SSTable (Sorted String Table) 文件。这个文件会被放在Level-0
。
- 当
-
后台合并 (Compaction):
- RocksDB 会在后台不断地进行 Compaction 操作。这个过程会读取低层级(如 Level-0)的多个 SSTable,将它们的键值对进行合并排序,然后生成一个新的、更大的 SSTable 写入到下一层级(如 Level-1)。
- Compaction 的主要目的有:清理被覆盖或删除的数据、合并
merge
操作、优化读取性能。
-
读取 (Get):
- 当读取一个 Key 时,RocksDB 会从新到旧 地查找:
- 先查可写的
MemTable
。 - 再查只读的
MemTable
(正在刷盘的)。 - 然后从
Level-0
到Level-N
逐层查找 SSTable。
- 先查可写的
- 一旦在某个地方找到了这个 Key 的最新版本(
PUT
或DELETE
),查找就会停止。但对于MERGE
操作,则需要继续往下找。
- 当读取一个 Key 时,RocksDB 会从新到旧 地查找:
Merge
操作的实现细节
现在,我们来看 merge
是如何融入这个体系的。
当执行 db.merge(key, value_part)
时:
-
写入阶段 : RocksDB 不会去查找
key
的旧值。它只是简单地将一个类型为MERGE
的操作记录 和value_part
一起写入 WAL 和MemTable
。这和PUT
操作一样快,因为它避免了任何读操作。 -
逻辑视图 : 经过多次
merge
后,对于同一个key
,在 RocksDB 的物理存储中可能存在一个这样的操作序列(从新到旧):MERGE 'd' -> MERGE 'c' -> PUT ['a', 'b'] -> MERGE 'e' (在更老的SSTable里)
-
读取/合并阶段 (Merge-on-Read):
- 当用户调用
db.get(key)
时,RocksDB 开始查找。 - 它找到了最新的操作是
MERGE 'd'
。因为它是一个MERGE
操作,所以它知道不能停,必须继续往下找,直到找到一个PUT
或DELETE
操作,或者查完所有层级。 - 它会继续找到
MERGE 'c'
,然后是PUT ['a', 'b']
。 - 此时,它找到了一个
PUT
作为合并的起点。 - 然后,它会调用一个用户定义的
MergeOperator
(在 FlinkListState
的场景下,就是一个能将字节片段拼接起来的ListMergeOperator
),执行如下逻辑:result = merge(merge(put_value, operand_c), operand_d)
即:result = merge(merge(['a', 'b'], 'c'), 'd')
-> 最终得到['a', 'b', 'c', 'd']
。 - 这个合并过程是在读取时动态发生的。
- 当用户调用
-
后台合并阶段 (Compaction):
- 为了避免每次读取都进行大量动态合并,Compaction 过程也会应用
MergeOperator
。 - 当 Compaction 任务合并多个包含
key
的 SSTable 时,它会执行与读取时类似的操作,将一系列PUT
和MERGE
操作合并成一个新的、更完整的PUT
操作,然后写入到更高层级的 SSTable 中。 - 例如,
MERGE 'd'
,MERGE 'c'
,PUT ['a', 'b']
经过 Compaction 后,可能会在新的 SSTable 中变成一个单独的记录:PUT ['a', 'b', 'c', 'd']
。 - 这样就大大减少了未来读取该
key
时的合并开销。
- 为了避免每次读取都进行大量动态合并,Compaction 过程也会应用
总结
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)场景的经典实现。