Paimon 原子提交实现
快照包含什么?
可以把它们理解为一个层级关系:Snapshot -> Manifest List -> Manifest -> Data File。
1. 快照 (Snapshot)
- 是什么:快照是 Paimon 表在某个时间点的一个完整、一致的视图。它是一个非常小的 JSON 文件,存储在表的
snapshot
目录下。 - 包含什么:
- 快照 ID(通常是提交时的时间戳)。
- 指向一个 Manifest List 文件的路径。
- 本次提交的类型(追加、覆盖、合并等)。
- 表的 Schema ID。
- 其他一些提交相关的元数据。
- 作用:快照是所有数据读取的入口。查询时,Paimon 首先会找到一个最新的或指定的快照文件,然后顺着它找到所有需要读取的数据文件。
2. 清单列表 (Manifest List)
- 是什么:一个清单列表文件,同样存储在表的
manifest
目录下。一个快照文件只指向一个清单列表文件。 - 包含什么:它包含了一个或多个 Manifest 文件的文件名列表。
- 作用:作为快照和清单文件之间的中间层,它可以帮助 Paimon 在不同快照之间复用未发生变化的 Manifest 文件,从而提高提交效率并节省存储空间。
3. 清单 (Manifest)
- 是什么:一个清单文件,存储在表的
manifest
目录下。 - 包含什么:它包含了一系列数据文件(Data File)的详细元数据信息,我们称之为
DataFileMeta
。每个条目都描述了一个数据文件,例如:- 文件名。
- 文件所在的层级(Level)。
- 文件大小。
- 行数。
- 主键的统计信息(最小值、最大值)。
- 序列号范围等。
- 作用:具体记录了构成表数据的所有数据文件的元信息。
MergeTreeWriter
产生的CommitIncrement
中的文件信息最终就是被用来更新或创建新的 Manifest 文件的。
FileStoreCommitImpl
FileStoreCommitImpl
是整个提交过程的“心脏”,负责将 TableWriteImpl
准备好的文件变更清单(ManifestCommittable
)转化为一个持久化的、原子性的、对用户可见的快照。
FileStoreCommitImpl
的核心职责可以概括为:接收文件变更,处理元数据,执行原子提交,最终生成一个新的快照。
它处理三种主要的提交类型:
- APPEND: 追加数据。这是最常见的写入类型,由流式或批量作业产生。
- COMPACT: 文件合并。后台的合并作业完成后,会发起一个
COMPACT
类型的提交。 - OVERWRITE: 分区覆盖。当用户执行
INSERT OVERWRITE
时触发。
commit
我们以最常见的 commit
方法为例,来剖析其内部的快照生成流程。
// ... existing code ...@Overridepublic void commit(ManifestCommittable committable,Map<String, String> properties,boolean checkAppendFiles) {
// ... existing code ...// 1. 将 CommitMessage 分解为不同类型的文件变更列表collectChanges(committable.fileCommittables(),appendTableFiles,appendChangelog,compactTableFiles,compactChangelog,appendHashIndexFiles,compactDvIndexFiles);try {// 2. 处理 APPEND 类型的变更if (!ignoreEmptyCommit|| !appendTableFiles.isEmpty()|| !appendChangelog.isEmpty()|| !appendHashIndexFiles.isEmpty()) {
// ... existing code ...// 2a. 尝试提交 APPEND 变更,这会生成一个快照attempts +=tryCommit(appendTableFiles,appendChangelog,appendHashIndexFiles,committable.identifier(),committable.watermark(),committable.logOffsets(),Snapshot.CommitKind.APPEND,noConflictCheck(),null);generatedSnapshot += 1;}// 3. 处理 COMPACT 类型的变更if (!compactTableFiles.isEmpty()|| !compactChangelog.isEmpty()|| !compactDvIndexFiles.isEmpty()) {
// ... existing code ...// 3a. 尝试提交 COMPACT 变更,这会生成另一个快照attempts +=tryCommit(compactTableFiles,compactChangelog,compactDvIndexFiles,committable.identifier(),committable.watermark(),committable.logOffsets(),Snapshot.CommitKind.COMPACT,hasConflictChecked(safeLatestSnapshotId),null);generatedSnapshot += 1;}} finally {
// ... existing code ...}}
// ... existing code ...
从这段代码可以看出,一个 commit
调用可能会产生两个快照:一个 APPEND
快照和一个 COMPACT
快照。这是因为 Paimon 将数据追加和文件合并视为两个独立的原子操作。
其核心逻辑都汇聚在 tryCommit
方法中,我们进一步深入。
tryCommit
和 tryCommitOnce
:原子提交的实现
tryCommit
方法内部是一个循环,它会不断调用 tryCommitOnce
直到提交成功或者达到最大重试次数。
tryCommitOnce
是真正执行单次提交尝试的地方。它的关键步骤如下:
- 获取最新快照:
latestSnapshot = snapshotManager.latestSnapshot()
,这是为了在最新的状态基础上进行提交,并用于后续的冲突检测。 - 冲突检测:检查本次提交要修改的文件是否与
latestSnapshot
之后发生的其他提交有冲突。这是保证数据一致性的关键。 - 写 Manifest 文件:
- 将本次提交的文件变更(
deltaFiles
)写入一个新的 Manifest 文件。Manifest 文件像是一个清单,记录了哪些文件被添加、哪些被删除。 manifestFile.write(deltaFiles)
- 将本次提交的文件变更(
- 写 Manifest List 文件:
- 将新生成的 Manifest 文件名,连同之前快照的 Manifest 文件列表,一起写入一个新的 Manifest List 文件。Manifest List 是 Manifest 文件的索引。
manifestList.write(newManifests)
- 生成快照对象:创建一个
Snapshot
对象,它包含了本次提交的所有元数据信息,如:- 快照 ID (
newSnapshotId
) - Schema ID
- 新生成的 Manifest List 文件名
- 提交类型 (
commitKind
) - 提交时间、提交用户等
- 快照 ID (
- 原子提交快照:这是最关键的原子操作。
snapshotCommit
.commitSnapshot(newSnapshot)
。- 这一步会在
snapshot
目录下生成一个新的、以快照ID命名的 JSON 文件(例如snapshot-5
)。这个操作通常依赖于文件系统的rename
操作的原子性来保证。一旦这个文件成功创建,整个提交就宣告成功,新的数据版本就对外部可见了。
- 清理旧元数据:提交成功后,会清理掉过期的快照和不再需要的 Manifest 文件。
collectChanges
这个方法是 Paimon 提交过程中的一个关键数据转换和分类环节。它的核心作用是将来自上游(TableWriteImpl
)的、结构化的 CommitMessage
对象,解构、翻译并分类成后续元数据处理步骤所需要的、更原子化的 ManifestEntry
和 IndexManifestEntry
列表。
输入 (Input)
List<CommitMessage> commitMessages
: 这是从ManifestCommittable
中获取的、本次事务中所有待提交的变更信息的集合。每个CommitMessage
都代表了一个分桶(Partition + Bucket)在一个 Checkpoint 周期内的所有文件变更。
输出 (Output)
这个方法没有返回值,它通过修改传入的 List
参数来输出结果。这些列表被清晰地分成了几类,对应后续不同的处理逻辑:
List<ManifestEntry> appendTableFiles
: 用于存放数据追加操作产生的文件变更。这包括:ADD
类型:新写入的数据文件。DELETE
类型:对于主键表,在INSERT
模式下,如果发生数据更新(先DELETE
旧记录,再ADD
新记录),这里会包含被删除的旧文件中的记录(通过 Deletion Vector 实现)。
List<ManifestEntry> appendChangelog
: 用于存放数据追加操作产生的 Changelog 文件。这些文件主要用于流读场景。List<ManifestEntry> compactTableFiles
: 用于存放文件合并操作产生的文件变更。这包括:DELETE
类型:被合并掉的旧数据文件。ADD
类型:合并后生成的新数据文件。
List<ManifestEntry> compactChangelog
: 用于存放文件合并操作产生的 Changelog 文件。List<IndexManifestEntry> appendHashIndexFiles
: 用于存放数据追加操作产生的哈希索引文件(HASH_INDEX
)。List<IndexManifestEntry> compactDvIndexFiles
: 用于存放文件合并操作产生的删除向量索引文件(DELETION_VECTORS_INDEX
)的变更。
方法内部是一个 for
循环,遍历每一个 CommitMessage
,然后像剥洋葱一样,一层层地解析其内部结构,并将解析出的文件元数据(DataFileMeta
或 IndexFileMeta
)放入对应的输出列表中。
// ... existing code ...private void collectChanges(List<CommitMessage> commitMessages,List<ManifestEntry> appendTableFiles,List<ManifestEntry> appendChangelog,List<ManifestEntry> compactTableFiles,List<ManifestEntry> compactChangelog,List<IndexManifestEntry> appendHashIndexFiles,List<IndexManifestEntry> compactDvIndexFiles) {for (CommitMessage message : commitMessages) {CommitMessageImpl commitMessage = (CommitMessageImpl) message;// --- 1. 处理数据追加(Append)相关的变更 ---// 从 newFilesIncrement() 获取commitMessage.newFilesIncrement().newFiles() // 获取新写入的数据文件.forEach(m -> appendTableFiles.add(makeEntry(FileKind.ADD, commitMessage, m)));commitMessage.newFilesIncrement().deletedFiles() // 获取因更新而标记为删除的文件.forEach(m ->appendTableFiles.add(makeEntry(FileKind.DELETE, commitMessage, m)));commitMessage.newFilesIncrement().changelogFiles() // 获取新写入的Changelog文件.forEach(m -> appendChangelog.add(makeEntry(FileKind.ADD, commitMessage, m)));// --- 2. 处理文件合并(Compact)相关的变更 ---// 从 compactIncrement() 获取commitMessage.compactIncrement().compactBefore() // 获取被合并的旧文件.forEach(m ->compactTableFiles.add(makeEntry(FileKind.DELETE, commitMessage, m)));commitMessage.compactIncrement().compactAfter() // 获取合并后生成的新文件.forEach(m -> compactTableFiles.add(makeEntry(FileKind.ADD, commitMessage, m)));commitMessage.compactIncrement().changelogFiles() // 获取合并产生的Changelog文件.forEach(m -> compactChangelog.add(makeEntry(FileKind.ADD, commitMessage, m)));// --- 3. 处理索引(Index)相关的变更 ---// 从 indexIncrement() 获取commitMessage.indexIncrement().newIndexFiles() // 获取新增的索引文件.forEach(f -> {// 根据索引类型分发到不同的列表switch (f.indexType()) {case HASH_INDEX:appendHashIndexFiles.add(new IndexManifestEntry(FileKind.ADD,commitMessage.partition(),commitMessage.bucket(),f));break;case DELETION_VECTORS_INDEX:compactDvIndexFiles.add(new IndexManifestEntry(FileKind.ADD,commitMessage.partition(),commitMessage.bucket(),f));break;default:throw new RuntimeException("Unknown index type: " + f.indexType());}});commitMessage.indexIncrement().deletedIndexFiles() // 获取被删除的索引文件.forEach(f -> {// 目前只支持删除 DELETION_VECTORS_INDEXif (f.indexType().equals(DELETION_VECTORS_INDEX)) {compactDvIndexFiles.add(new IndexManifestEntry(FileKind.DELETE,commitMessage.partition(),commitMessage.bucket(),f));} else {throw new RuntimeException("This index type is not supported to delete: "+ f.indexType());}});}}
// ... existing code ...
关键点解析:
- 结构清晰的
CommitMessage
:CommitMessageImpl
内部通过DataIncrement
,CompactIncrement
,IndexIncrement
这三个子对象来组织不同来源的文件变更,使得这里的解析逻辑非常清晰。 makeEntry
工厂方法: 这个辅助方法负责将DataFileMeta
和CommitMessage
中的上下文信息(分区、桶)打包成一个标准的ManifestEntry
对象,并标记其变更类型(ADD
或DELETE
)。- 分类存储: 最核心的设计思想就是分类。它将不同目的的变更(数据追加 vs. 文件合并)和不同类型的文件(数据文件 vs. Changelog文件 vs. 索引文件)分门别类地放入不同的
List
中。 - 为后续步骤服务: 这种分类非常有意义。在
collectChanges
执行完毕后,FileStoreCommitImpl
会根据这些分类好的列表执行不同的提交逻辑。例如:appendTableFiles
和appendChangelog
会一起构成一个APPEND
类型的快照。compactTableFiles
和compactChangelog
会一起构成一个COMPACT
类型的快照。- 在
OVERWRITE
模式下,appendChangelog
和compactChangelog
可能会被忽略,并打印警告日志。
总结
FileStoreCommitImpl
无疑是负责产生快照的执行者。它通过一个严谨的、带重试和冲突检测的流程,将底层的物理文件变更,转化为逻辑上原子性的、可追溯的快照。
整个过程可以概括为: CommitMessage
-> ManifestEntry
-> Manifest File
-> Manifest List
-> Snapshot File
每成功生成一个 Snapshot
文件,就代表表的一个新版本诞生了。这个机制是 Paimon 实现 ACID 事务和时间旅行(Time Travel)功能的基石。
CommitMessage 如何变为快照
来详细梳理一下 Paimon 中从一个写入意图(CommitMessage
)到最终形成一个持久化快照(Snapshot File
)的完整文件生成链路。这个过程是 Paimon 事务和元数据管理的核心,理解它有助于我们理解 Paimon 的工作原理。
这个链路可以清晰地分为五个步骤:CommitMessage
-> ManifestEntry
-> Manifest File
-> Manifest List
-> Snapshot File
。
CommitMessage
: 写入操作的“施工报告”
- 产生者:
RecordWriter
(在FileStoreWrite
内部)。当一个RecordWriter
(负责写入单个分桶的数据)被要求“准备提交”(prepareCommit
)时,它会完成内存数据的刷盘,并生成一个CommitIncrement
对象。这个对象包含了本次写入中新增的数据文件、因合并而删除的文件、合并后产生的新文件等信息。 - 聚合者:
TableWriteImpl
。它收集所有RecordWriter
产生的CommitIncrement
,并将它们封装成CommitMessage
对象。 - 最终形态:
ManifestCommittable
。在 Flink/Spark 的 Sink 算子中,来自不同并行实例的CommitMessage
会被序列化并发送给一个单并发的“提交者”(Committer)。提交者会将收到的所有CommitMessage
聚合到一个ManifestCommittable
对象中。
ManifestCommittable
本质上是一个容器,它代表了一个完整的、待提交的事务,里面包含了这个事务涉及的所有文件变更信息。
// ... existing code ...
public class ManifestCommittable {private final long identifier;@Nullable private final Long watermark;private final Map<Integer, Long> logOffsets;private final List<CommitMessage> commitMessages;public ManifestCommittable(long identifier, @Nullable Long watermark) {this.identifier = identifier;this.watermark = watermark;this.logOffsets = new HashMap<>();this.commitMessages = new ArrayList<>();}
// ... existing code ...public void addFileCommittable(CommitMessage commitMessage) {commitMessages.add(commitMessage);}
// ... existing code ...
}
ManifestEntry
: 标准化的文件变更记录
- 产生者:
FileStoreCommitImpl
的collectChanges
方法。 - 过程:
FileStoreCommitImpl
接收到ManifestCommittable
后,会遍历其中的每一个CommitMessage
。它将CommitMessage
中描述的各种文件(新文件、删除的文件、Changelog 文件等)转换成统一格式的ManifestEntry
对象。
ManifestEntry
是一个标准化的数据结构,它清晰地描述了一个文件的一次变更。
public class ManifestEntry {// ...private final FileKind kind; // ADD 或 DELETEprivate final BinaryRow partition; // 文件所属分区private final int bucket; // 文件所属桶private final int totalBuckets; // 表的总桶数private final DataFileMeta file; // 文件元数据 (文件名, 大小, 行数等)// ...
}
file
(文件元数据)是一个 DataFileMeta
对象,包含了:
fileName
: 它记录了实际存储用户数据的物理文件名(例如data-b601f8d3-2463-4553-a784-734901ed52a9-0.orc
)。rowCount
: 文件的总行数。minKey
/maxKey
: 文件中主键的最小值和最大值。fileSize
: 文件的物理大小。- 以及其他用于查询优化和统计的元数据。
所以,ManifestEntry
的本质是:“对一个物理数据文件(通过 fileName
引用)的一次状态声明(ADD
或 DELETE
),并附带了它的位置(partition
, bucket
)和摘要信息(rowCount
等)”。
想象一下,如果 ManifestEntry
包含实际数据,那么每次提交或查询时,为了读取元数据,就需要加载海量的数据文件,这将是无法想象的灾难。而现在,元数据文件(Manifest File)非常小,可以被极快地读取和处理,从而快速规划出需要读取哪些真正的数据文件。
- 原子性与不可变性: Paimon 中的数据文件(如 Parquet/ORC 文件)一旦写成就是不可变的。当你要“更新”或“删除”数据时,Paimon 并不是去修改老的数据文件。而是:
- 生成一个
DELETE
状态的ManifestEntry
指向包含旧数据的文件。 - 生成一个包含新数据的新数据文件,并创建一个
ADD
状态的ManifestEntry
指向它。 通过这种只追加元数据的方式,轻松实现了 ACID 事务和时间旅行(Time Travel)功能。
- 生成一个
ManifestEntry
如何组成快照?
现在我们把整个流程串起来,把这个过程想象成记账:
单笔记账 (
ManifestEntry
): 你写入了一批数据,Paimon 生成了一个新的数据文件data-file-1.orc
。于是它记录一笔账:{kind: ADD, partition: '2023-01-01', bucket: 0, file: {fileName: 'data-file-1.orc', rowCount: 1000, ...}}
。这就是一个ManifestEntry
。账本的一页 (
Manifest File
): 一次 Flink 的 Checkpoint 可能产生了多个数据文件。Paimon 会把这次 Checkpoint 产生的所有ManifestEntry
(所有账目)写到一个清单文件里,这就是Manifest File
。它相当于账本的一页,记录了本次提交的所有文件变更。账本目录 (
Manifest List
): 一个完整的表状态,可能由历史上多个Manifest File
共同描述。Manifest List
文件就像是账本的目录,它记录了要组成当前完整视图,需要读取哪些Manifest File
(哪些账本页面)。最终盖章确认 (
Snapshot
): 当一次提交所有元数据都准备好后,Paimon 会生成一个最终的Snapshot
文件(例如snapshot-5
)。这个文件非常小,它里面最重要的信息就是指向刚刚生成的Manifest List
文件的路径。这个Snapshot
文件的生成是一个原子操作。一旦它成功出现,就代表一个新版本的、一致性的数据快照诞生了。
总结一下查询时的工作流程:
查询引擎
-> 读取最新的 Snapshot 文件
-> 找到 Manifest List 文件
-> 读取 Manifest List 找到所有相关的 Manifest File
-> 读取所有 Manifest File 拿到全部 ManifestEntry 列表
-> 根据 ADD/DELETE 状态计算出最终有效的数据文件列表(拿到所有 fileName)
-> 去读取这些数据文件中的实际数据。
所以,ManifestEntry
虽然只是“统计信息”和“指针”,但正是这些轻量的指针和状态,通过层层组织,最终构成了对海量物理数据文件的一次完整、一致、可查询的视图——即快照。
Manifest File
: 记录单次提交所有变更的清单文件
- 产生者:
ManifestFile
类的write
方法。 - 过程:
FileStoreCommitImpl
将上一步生成的所有ManifestEntry
对象,传递给ManifestFile.write()
。这个方法会创建一个新的物理文件(例如manifest/manifest-xxxx-0
),并将这些ManifestEntry
序列化后写入其中。
一个 Manifest File
就是一个 Avro 文件,它像一个不可变的日志,忠实地记录了某一次提交(或合并)所涉及的所有文件层面的增删操作。
// ... existing code .../*** Write several {@link ManifestEntry}s into manifest files.** <p>NOTE: This method is atomic.*/public List<ManifestFileMeta> write(List<ManifestEntry> entries) {RollingFileWriter<ManifestEntry, ManifestFileMeta> writer = createRollingWriter();try {writer.write(entries);writer.close();} catch (Exception e) {throw new RuntimeException(e);}return writer.result();}
// ... existing code ...
Manifest List
: Manifest 文件的索引
- 产生者:
ManifestList
类的write
方法。 - 过程: 一个快照可能由多个
Manifest File
组成(例如,一个来自上一个快照的“基础”清单,加上一个本次提交的“增量”清单)。Manifest List
文件就是用来管理这些Manifest File
的。FileStoreCommitImpl
会收集本次提交需要关联的所有Manifest File
的元数据(ManifestFileMeta
,包含文件名、大小、统计信息等),然后调用ManifestList.write()
将这些元数据写入一个新的物理文件(例如manifest/manifest-list-yyyy-0
)。
一个 Manifest List
文件,其内容就是一个 Manifest File
的列表。它本身也是一个 Avro 文件。通过读取一个 Manifest List
,Paimon 就能知道要加载哪些 Manifest File
来构建出完整的表文件视图。
Snapshot File
: 最终的原子提交凭证
- 产生者:
SnapshotManager
的commitSnapshot
方法,由FileStoreCommitImpl
调用。 - 过程: 这是整个流程的最后一步,也是实现原子性的关键。
FileStoreCommitImpl
在成功创建了Manifest List
文件后,会构建一个Snapshot
对象。这个对象包含了:- 一个自增的快照 ID。
- 本次提交的类型(APPEND, COMPACT, OVERWRITE 等)。
- 指向
Manifest List
文件的指针(文件名)。 - 提交用户、提交时间等元数据。
然后,它调用 snapshotManager.commitSnapshot(newSnapshot)
,该方法会将这个 Snapshot
对象序列化成 JSON 格式,并以原子方式(通常是 rename
)在 snapshot
目录下创建一个名为 snapshot-<ID>
的文件。
// ... existing code ...// 1. 创建 Manifest List 文件Pair<String, Long> baseManifestList = manifestList.write(mergeAfterManifests);Pair<String, Long> deltaManifestList = manifestList.write(emptyList());// 2. 准备 Snapshot 对象Snapshot newSnapshot =new Snapshot(latestSnapshot.id() + 1,latestSnapshot.schemaId(),baseManifestList.getLeft(), // 指向 Manifest List// ... 其他元数据);// 3. 提交 Snapshot (内部会生成 snapshot-xxx 文件)snapshotManager.commitSnapshot(newSnapshot);
// ... existing code ...
一旦 snapshot-xxx
文件成功出现在文件系统中,这次提交就被认为是成功的、完整的、对所有查询可见的。任何查询任务,都会从最新的 Snapshot
文件开始,反向追溯 Manifest List
和 Manifest File
,最终确定表中有哪些有效的数据文件。
这个分层、解耦的设计,使得 Paimon 的元数据管理既强大又清晰,为 ACID 事务、时间旅行等高级功能提供了坚实的基础。
RenamingSnapshotCommit
RenamingSnapshotCommit
是 Paimon 中实现快照提交(Snapshot Commit)的核心机制之一,特别是对于基于文件系统的 Catalog(如 FileSystemCatalog
, HiveCatalog
)而言。它的命名已经揭示了其核心思想:通过文件重命名(rename)操作来完成快照的原子提交。
我们分部分来解析这个类:
RenamingSnapshotCommit
实现了 SnapshotCommit
接口,其核心职责是执行 commit
方法,将一个内存中的 Snapshot
对象持久化为物理文件,从而正式发布一个新版本的数据快照。这个操作必须是原子的,即要么完全成功,要么完全失败,不能出现中间状态。
// ... existing code ...
public class RenamingSnapshotCommit implements SnapshotCommit {private final SnapshotManager snapshotManager;private final FileIO fileIO;private final Lock lock;public RenamingSnapshotCommit(SnapshotManager snapshotManager, Lock lock) {this.snapshotManager = snapshotManager;this.fileIO = snapshotManager.fileIO();this.lock = lock;}
// ... existing code ...
snapshotManager
: 快照管理器,负责提供快照文件的路径信息(如snapshotPath()
)和更新LATEST
hint 文件等。fileIO
: 文件系统 I/O 抽象接口,负责执行实际的文件操作,如tryToWriteAtomic
(核心的原子写操作)、exists
等。lock
: 并发控制的核心。这是一个锁接口,用于在提交操作前后加锁和解锁,以保证并发场景下的正确性。
commit
方法详解
这是整个类的灵魂所在,我们来逐行分析其逻辑:
// ... existing code ...@Overridepublic boolean commit(Snapshot snapshot, String branch, List<PartitionStatistics> statistics)throws Exception {// 1. 确定最终的快照文件路径Path newSnapshotPath =snapshotManager.branch().equals(branch)? snapshotManager.snapshotPath(snapshot.id()): snapshotManager.copyWithBranch(branch).snapshotPath(snapshot.id());// 2. 定义核心的提交逻辑为一个 CallableCallable<Boolean> callable =() -> {// 2a. 调用 FileIO 的原子写方法boolean committed = fileIO.tryToWriteAtomic(newSnapshotPath, snapshot.toJson());if (committed) {// 2b. 如果成功,更新 LATEST hint 文件,加速后续的快照发现snapshotManager.commitLatestHint(snapshot.id());}return committed;};// 3. 在锁的保护下执行提交逻辑return lock.runWithLock(() ->// 3a. 前置检查:目标快照文件不能已存在// 3b. 调用 Callable 执行真正的提交!fileIO.exists(newSnapshotPath) && callable.call());}
// ... existing code ...
逻辑步骤拆解:
确定路径: 首先,根据快照 ID 和分支信息,从
SnapshotManager
获取新快照文件应有的最终路径,例如/path/to/table/snapshot/snapshot-5
。封装核心操作: 将真正的文件写入操作封装在一个
Callable
对象中。fileIO.tryToWriteAtomic()
: 这是实现原子性的关键。它通常会先将snapshot
的 JSON 内容写入一个临时文件,然后通过一次rename
操作将临时文件重命名为最终的newSnapshotPath
。因为在 HDFS 等文件系统中rename
是原子操作,所以能保证提交的原子性。snapshotManager.commitLatestHint()
: 提交成功后,会更新LATEST
这个 hint 文件,内容为当前快照的 ID。这样下次读取时能快速定位到最新的快照,避免扫描所有 snapshot 文件。
加锁执行: 这是保证并发安全的关键。
lock.runWithLock(...)
: 整个提交逻辑都在这个方法的回调中执行。这意味着在执行前会获取锁,执行后会释放锁。!fileIO.exists(newSnapshotPath)
: 这是一个重要的前置检查。在获取锁之后、执行重命名之前,先检查目标文件是否已存在。这是一种双重检查,防止在某些文件系统rename
行为不符合预期(例如,目标已存在时覆盖而不是失败)或非原子性的情况下出现问题。如果文件已存在,说明有另一个并发的提交已经成功,当前提交直接失败即可。
RenamingSnapshotCommit
, Lock
, CatalogLock
的关系
这三者构成了一个清晰的 分层委托(Layered Delegation) 关系,用于实现灵活且可靠的并发控制。
CatalogLock
(最底层,最具体的锁实现)- 定义: 这是一个面向 Catalog 的锁接口,定义了
runWithLock(database, table, callable)
方法。它的实现通常与具体的元数据服务绑定,例如:- HiveCatalogLock: 通过 Hive Metastore 的锁机制实现。
- JdbcCatalogLock: 通过数据库的行锁或表锁实现。
- 角色: 提供最底层的、与外部系统(如 Hive, JDBC)集成的分布式锁能力。
- 定义: 这是一个面向 Catalog 的锁接口,定义了
Lock
(中间层,抽象的锁接口)- 定义: 这是一个更通用的锁接口,只定义了
runWithLock(callable)
方法,不关心database
和table
的概念。 - 角色: 解耦。
RenamingSnapshotCommit
直接依赖这个抽象的Lock
接口,而不是具体的CatalogLock
。这使得RenamingSnapshotCommit
的逻辑更通用,不与任何特定的锁实现绑定。 - 实现:
Lock.EmptyLock
: 一个空实现,什么也不做,用于不需要锁的场景(如本地文件系统,单并发作业)。Lock.CatalogLockImpl
: 这是一个适配器(Adapter)。它内部持有一个CatalogLock
和一个Identifier
(表标识)。当它的runWithLock
方法被调用时,它会调用内部catalogLock
的runWithLock
方法,并传入表信息。
- 定义: 这是一个更通用的锁接口,只定义了
RenamingSnapshotCommit
(最高层,锁的使用者)- 角色: 它不关心锁是怎么实现的,只管调用
lock.runWithLock()
来保护其提交逻辑。 - 如何获取
Lock
:RenamingSnapshotCommit
是通过其内部的Factory
创建的。这个工厂会根据 Catalog 的配置决定创建哪种Lock
。- 如果 Catalog 配置了锁(比如 Hive Catalog 开启了锁),工厂就会创建一个
CatalogLock
,然后用Lock.fromCatalog()
把它包装成一个Lock
实例,传给RenamingSnapshotCommit
。 - 如果 Catalog 未配置锁,工厂就会创建一个
Lock.empty()
实例。
- 如果 Catalog 配置了锁(比如 Hive Catalog 开启了锁),工厂就会创建一个
- 角色: 它不关心锁是怎么实现的,只管调用
关系图谱总结
+--------------------------+
| RenamingSnapshotCommit |
| (锁的使用者) |
|--------------------------|
| - lock: Lock |
| - commit() { |
| lock.runWithLock(...) |
| } |
+--------------------------+|| 依赖 (Depends on)▼
+--------------------------+ +--------------------------------+
| Lock (接口) | | Lock.CatalogLockImpl |
| (通用锁抽象) |◀-----| (适配器,实现了 Lock 接口) |
|--------------------------| |--------------------------------|
| + runWithLock(callable) | | - catalogLock: CatalogLock |
+--------------------------+ | - identifier: Identifier || - runWithLock(callable) { || catalogLock.runWithLock(...) || } |+--------------------------------+|| 委托 (Delegates to)▼+--------------------------------+| CatalogLock (接口) || (具体锁实现,如 Hive/JDBC) ||--------------------------------|| + runWithLock(db, tbl, callable) |+--------------------------------+
RenamingSnapshotCommit
通过依赖抽象的 Lock
接口,将并发控制的复杂性解耦出去。Lock
接口则通过适配器模式(CatalogLockImpl
)将通用的锁请求委托给具体的 CatalogLock
实现。这种设计使得 Paimon 的核心提交流程既保持了逻辑的清晰和通用性,又能灵活地对接不同的外部系统来实现强大的分布式锁功能,以应对对象存储等环境中 rename
操作非原子的挑战。
HiveCatalogLock
HiveCatalogLock
是 Paimon 与 Hive 生态集成时,实现分布式锁的关键组件。当 Paimon 使用 Hive Metastore (HMS) 作为元数据中心时,可以利用 HMS 内置的锁服务来保证多个 Paimon 作业在并发操作同一张表时的元数据一致性。这在对象存储(如 S3、OSS)上尤为重要,因为对象存储的 rename
操作通常不是原子的。
HiveCatalogLock
实现了 org.apache.paimon.catalog.CatalogLock
接口。它的核心职责是:利用 Hive Metastore 提供的锁服务,为 Paimon 的 Catalog 操作提供一个跨 JVM 的分布式锁。
它主要被用在 RenamingSnapshotCommit
过程中,在执行原子提交(即重命名 snapshot 文件)的关键阶段加锁,以防止并发冲突。
核心成员变量
// ... existing code ...
public class HiveCatalogLock implements CatalogLock {static final String LOCK_IDENTIFIER = "hive";private final ClientPool<IMetaStoreClient, TException> clients;private final long checkMaxSleep;private final long acquireTimeout;
// ... existing code ...
clients
: 这是一个IMetaStoreClient
的客户端池。与 HMS 的所有交互(加锁、解锁、检查锁状态)都是通过这个客户端池来完成的。使用池化技术可以复用客户端连接,提高性能并减少资源消耗。checkMaxSleep
: 在尝试获取锁但处于等待状态时,两次检查之间的最大休眠时间(毫秒)。acquireTimeout
: 获取锁的总超时时间(毫秒)。如果超过这个时间仍未获取到锁,则加锁失败。
核心方法 runWithLock
这是 CatalogLock
接口定义的方法,也是该类的入口。它的逻辑非常经典和清晰,采用了 try-finally
模式来确保锁的正确释放。
// ... existing code ...@Overridepublic <T> T runWithLock(String database, String table, Callable<T> callable) throws Exception {// 1. 调用 lock 方法获取锁,拿到锁的 IDlong lockId = lock(database, table);try {// 2. 执行受锁保护的业务逻辑return callable.call();} finally {// 3. 在 finally 块中确保锁一定被释放unlock(lockId);}}
// ... existing code ...
加锁逻辑 lock
方法详解
这是整个类最复杂、最核心的部分。它实现了**带重试和超时的自旋锁(Spin Lock)**逻辑。
// ... existing code ...private long lock(String database, String table)throws UnknownHostException, TException, InterruptedException {// 1. 构建锁请求对象final LockComponent lockComponent =new LockComponent(LockType.EXCLUSIVE, LockLevel.TABLE, database);lockComponent.setTablename(table);lockComponent.unsetOperationType();final LockRequest lockRequest =new LockRequest(Collections.singletonList(lockComponent),System.getProperty("user.name"),InetAddress.getLocalHost().getHostName());// 2. 首次尝试加锁LockResponse lockResponse = clients.run(client -> client.lock(lockRequest));// 3. 如果未能立即获取锁,则进入自旋等待long nextSleep = 50;long startRetry = System.currentTimeMillis();while (lockResponse.getState() == LockState.WAITING) {// 3a. 指数退避策略,避免活锁nextSleep *= 2;if (nextSleep > checkMaxSleep) {nextSleep = checkMaxSleep;}Thread.sleep(nextSleep);// 3b. 检查锁状态final LockResponse tempLockResponse = lockResponse;lockResponse = clients.run(client -> client.checkLock(tempLockResponse.getLockid()));// 3c. 检查是否超时if (System.currentTimeMillis() - startRetry > acquireTimeout) {break;}}long retryDuration = System.currentTimeMillis() - startRetry;// 4. 判断最终加锁结果if (lockResponse.getState() != LockState.ACQUIRED) {// 如果超时后仍在等待,需要主动释放,防止死锁if (lockResponse.getState() == LockState.WAITING) {final LockResponse tempLockResponse = lockResponse;clients.execute(client -> client.unlock(tempLockResponse.getLockid()));}throw new RuntimeException("Acquire lock failed with time: " + Duration.ofMillis(retryDuration));}// 5. 返回获取到的锁 IDreturn lockResponse.getLockid();}
// ... existing code ...
逻辑步骤拆解:
构建请求: 创建一个
LockRequest
对象。这里指定了:LockType.EXCLUSIVE
: 请求一个排他锁,保证同一时间只有一个客户端能持有该锁。LockLevel.TABLE
: 锁的粒度是表级别。database
和table
: 明确要锁定的具体表。- 还包含了用户名和主机名,用于在 HMS 中追踪锁的持有者。
首次尝试: 调用
client.lock(lockRequest)
向 HMS 发起加锁请求。HMS 的响应LockResponse
会有三种状态:ACQUIRED
(已获取),WAITING
(等待中),NOT_ACQUIRED
(未获取,通常是死锁等异常情况)。自旋等待: 如果状态是
WAITING
,说明有其他客户端正持有该锁。此时进入while
循环:- 指数退避 (Exponential Backoff):
nextSleep
从 50ms 开始,每次循环翻倍,直到达到checkMaxSleep
上限。这是一种经典的重试策略,可以有效避免多个等待者在同一时刻密集地检查锁状态(惊群效应),减少 HMS 的压力。 - 检查状态: 调用
client.checkLock(lockId)
轮询锁的最新状态。 - 超时检查: 检查总等待时间是否超过了
acquireTimeout
。如果超时,则跳出循环。
- 指数退避 (Exponential Backoff):
结果判断: 循环结束后,再次检查锁的状态。
- 如果不是
ACQUIRED
,说明加锁失败。特别地,如果是因为超时而跳出循环且状态仍是WAITING
,需要主动调用unlock
来取消这次锁请求,否则这个请求可能会在 HMS 中一直存在,导致其他客户端也无法获取锁。最后抛出异常。
- 如果不是
成功返回: 如果状态是
ACQUIRED
,则返回从 HMS 获取到的lockId
,这个 ID 将用于后续的解锁操作。
解锁逻辑 unlock
解锁逻辑相对简单,直接调用 client.unlock(lockId)
并传入加锁时获取的 lockId
即可。
// ... existing code ...private void unlock(long lockId) throws TException, InterruptedException {clients.execute(client -> client.unlock(lockId));}
// ... existing code ...
工厂模式和创建过程
HiveCatalogLock
自身并不直接被用户创建,而是通过工厂模式 HiveCatalogLockFactory
来实例化。
HiveCatalog
在初始化时,会创建一个HiveCatalogLockContext
,其中包含了创建锁所需的所有信息(如HiveConf
)。- 当需要锁时,会通过
HiveCatalogLockFactory
的createLock
方法,传入HiveCatalogLockContext
。 HiveCatalogLockFactory
解析Context
,创建IMetaStoreClient
客户端池,并最终new HiveCatalogLock(...)
返回一个实例。
这个过程保证了锁的创建和配置是集中管理的,与 HiveCatalog
的生命周期绑定。
总结
HiveCatalogLock
是一个设计精良的分布式锁实现。它巧妙地利用了 Hive Metastore 这一现成的、被广泛使用的中心化服务来提供锁能力,从而解决了 Paimon 在并发场景下,尤其是在使用非原子 rename
的对象存储时,进行元数据操作的一致性问题。其内部实现的带超时和指数退避的自旋等待机制,是分布式系统中处理锁竞争的经典范式,兼顾了可靠性和性能。
JdbcCatalogLock
JdbcCatalogLock
是 Paimon 在使用 JdbcCatalog
时提供的另一种分布式锁实现。与 HiveCatalogLock
依赖 Hive Metastore 不同,它直接利用后端关系型数据库(如 MySQL, PostgreSQL)的特性来保证并发操作的原子性和一致性。
JdbcCatalogLock
的核心思想是:利用数据库中表的唯一主键约束(UNIQUE/PRIMARY KEY)来实现一个互斥锁。
基本逻辑如下:
- 在数据库中创建一张专门用于锁的表,比如
paimon_distributed_locks
。 - 这张表有一个
lock_id
字段,并且该字段是主键。 - 当一个客户端想要获取锁时,它会尝试向这张表中插入一条以特定
lock_id
为主键的记录。 - 由于主键的唯一性约束,只有一个客户端能够成功插入。第一个成功插入的客户端就获得了锁。
- 其他试图插入相同
lock_id
的客户端会因为主键冲突而插入失败,从而进入等待或获取锁失败的状态。 - 当持有锁的客户端完成操作后,它会从表中删除这条记录,从而释放锁。
这是一种非常经典和常见的基于数据库实现分布式锁的方法。
我们来看一下 JdbcCatalogLock.java
的代码实现。
成员变量和构造函数
// ... existing code ...
public class JdbcCatalogLock implements CatalogLock {private final JdbcClientPool connections;private final long checkMaxSleep;private final long acquireTimeout;private final String catalogKey;public JdbcCatalogLock(JdbcClientPool connections,String catalogKey,long checkMaxSleep,long acquireTimeout) {this.connections = connections;this.checkMaxSleep = checkMaxSleep;this.acquireTimeout = acquireTimeout;this.catalogKey = catalogKey;}
// ... existing code ...
connections
: JDBC 连接池,用于与数据库交互。checkMaxSleep
/acquireTimeout
: 与HiveCatalogLock
类似,用于控制自旋等待的超时和休眠时间。catalogKey
: Catalog 的唯一标识。它会成为锁 ID 的一部分,以区分不同 Paimon Catalog 实例下的同名表。
runWithLock
方法
这个方法的结构与 HiveCatalogLock
完全一致,都是标准的 try-finally
模式,确保锁的释放。
// ... existing code ...@Overridepublic <T> T runWithLock(String database, String table, Callable<T> callable) throws Exception {// 1. 构造唯一的锁 IDString lockUniqueName = String.format("%s.%s.%s", catalogKey, database, table);// 2. 加锁lock(lockUniqueName);try {// 3. 执行业务逻辑return callable.call();} finally {// 4. 释放锁JdbcUtils.release(connections, lockUniqueName);}}
// ... existing code ...
关键在于 lock(lockUniqueName)
和 JdbcUtils.release(...)
这两个调用。
lock
方法:自旋等待
lock
方法的逻辑也和 HiveCatalogLock
非常相似,都是一个带超时和指数退避的自旋等待循环。
// ... existing code ...private void lock(String lockUniqueName) throws SQLException, InterruptedException {// 首次尝试获取锁boolean lock = JdbcUtils.acquire(connections, lockUniqueName, acquireTimeout);long nextSleep = 50;long startRetry = System.currentTimeMillis();// 如果失败,进入自旋while (!lock) {// 指数退避nextSleep *= 2;if (nextSleep > checkMaxSleep) {nextSleep = checkMaxSleep;}Thread.sleep(nextSleep);// 再次尝试获取锁lock = JdbcUtils.acquire(connections, lockUniqueName, acquireTimeout);// 检查超时if (System.currentTimeMillis() - startRetry > acquireTimeout) {break;}}long retryDuration = System.currentTimeMillis() - startRetry;if (!lock) {throw new RuntimeException("Acquire lock failed with time: " + Duration.ofMillis(retryDuration));}}
// ... existing code ...
这里的核心是 JdbcUtils.acquire()
方法,它封装了真正的数据库操作。
JdbcUtils
和数据库方言 (Dialect
) 的作用
JdbcCatalogLock
本身不包含任何 SQL 语句。它将所有数据库相关的操作都委托给了 JdbcUtils
,而 JdbcUtils
又进一步将与具体数据库语法相关的部分委托给了 JdbcDistributedLockDialect
的不同实现(如 MysqlDistributedLockDialect
, PostgresqlDistributedLockDialect
)。这是一种典型的策略模式,使得锁的逻辑可以适配不同的数据库。
加锁 JdbcUtils.acquire()
// ... existing code ...public static boolean acquire(JdbcClientPool connections, String lockId, long timeoutMillSeconds)throws SQLException, InterruptedException {// 1. 根据 JDBC URL 的协议头(如 "jdbc:mysql")获取对应的方言实现JdbcDistributedLockDialect distributedLockDialect =DistributedLockDialectFactory.create(connections.getProtocol());// 2. (重要) 尝试清理可能已过期的锁int affectedRows = distributedLockDialect.tryReleaseTimedOutLock(connections, lockId);if (affectedRows > 0) {LOG.debug("Successfully cleared " + affectedRows + " lock records");}// 3. 调用方言的加锁方法return distributedLockDialect.lockAcquire(connections, lockId, timeoutMillSeconds);}
// ... existing code ...
这里的 tryReleaseTimedOutLock
是一个容错机制。如果一个客户端获取锁后崩溃了,没有机会释放锁,这个锁记录就会永远留在数据库中,导致死锁。通过设置一个过期时间,其他客户端在尝试获取锁之前,可以先清理掉那些已经超时的死锁。
解锁 JdbcUtils.release()
// ... existing code ...public static void release(JdbcClientPool connections, String lockId)throws SQLException, InterruptedException {// 同样通过方言工厂获取实现,并调用其解锁方法DistributedLockDialectFactory.create(connections.getProtocol()).releaseLock(connections, lockId);}
// ... existing code ...
数据库方言 (Dialect
) 的具体实现
以 MySQL 为例,MysqlDistributedLockDialect
会提供具体的 SQL 语句:
- 创建锁表:
CREATE TABLE paimon_distributed_locks (lock_id VARCHAR(255) NOT NULL,acquired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,expire_time_seconds BIGINT DEFAULT 0 NOT NULL,PRIMARY KEY (lock_id) )
- 获取锁 (Acquire):
-- 尝试插入一条记录,如果 lock_id 已存在,会因为主键冲突而失败 INSERT INTO paimon_distributed_locks(lock_id, expire_time_seconds) VALUES(?, ?)
try-catch
块中,插入成功返回true
,捕获到SQLException
(主键冲突) 则返回false
。 - 释放锁 (Release):
DELETE FROM paimon_distributed_locks WHERE lock_id = ?
JDBC 和关系型数据库暴露了什么能力?
JdbcCatalogLock
的实现充分利用了关系型数据库和 JDBC 提供的以下核心能力:
- 事务性 (Transaction): 虽然这里的单条
INSERT
或DELETE
语句很简单,但它们本身是在数据库的事务保证下执行的,是原子的。 - 约束 (Constraints): 主键的唯一性约束是这个锁实现能够成立的基石。数据库保证了任何时候表中都不会有两条
lock_id
相同的记录,从而天然地实现了互斥。 - 连接和语句执行 (Connection & Statement Execution): JDBC API 提供了标准的连接数据库、创建
PreparedStatement
、执行 SQL 并处理结果的接口。Paimon 在此基础上封装了连接池 (JdbcClientPool
) 以提高效率。 - 跨平台兼容性: 通过 JDBC API 和方言(Dialect)的抽象,同一套
JdbcCatalogLock
逻辑可以运行在多种不同的关系型数据库之上,只需要提供对应的方言实现即可。
MysqlDistributedLockDialect
MysqlDistributedLockDialect
是 Paimon 中 JdbcCatalogLock
机制针对 MySQL 数据库的具体实现。它继承自 AbstractDistributedLockDialect
,通过提供特定于 MySQL 语法的 SQL 语句,将通用的锁逻辑(如创建表、获取锁、释放锁)转化为可在 MySQL 上执行的操作。
MysqlDistributedLockDialect
这个类非常简洁,其核心就是覆盖父类的抽象方法,返回四条核心的 SQL 语句字符串。
/** Licensed to the Apache Software Foundation (ASF) under one* or more contributor license agreements. See the NOTICE file* distributed with this work for additional information* regarding copyright ownership. The ASF licenses this file* to you under the Apache License, Version 2.0 (the* "License"); you may not use this file except in compliance* with the License. You may obtain a copy of the License at** http://www.apache.org/licenses/LICENSE-2.0** Unless required by applicable law or agreed to in writing, software* distributed under the License is distributed on an "AS IS" BASIS,* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.* See the License for the specific language governing permissions and* limitations under the License.*/package org.apache.paimon.jdbc;/** Distributed lock implementation based on mysql table. */
public class MysqlDistributedLockDialect extends AbstractDistributedLockDialect {@Overridepublic String getCreateTableSql() {return "CREATE TABLE "+ JdbcUtils.DISTRIBUTED_LOCKS_TABLE_NAME+ "("+ JdbcUtils.LOCK_ID+ " VARCHAR(%s) NOT NULL,"+ JdbcUtils.ACQUIRED_AT+ " TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,"+ JdbcUtils.EXPIRE_TIME+ " BIGINT DEFAULT 0 NOT NULL,"+ "PRIMARY KEY ("+ JdbcUtils.LOCK_ID+ ")"+ ")";}@Overridepublic String getLockAcquireSql() {return "INSERT INTO "+ JdbcUtils.DISTRIBUTED_LOCKS_TABLE_NAME+ " ("+ JdbcUtils.LOCK_ID+ ","+ JdbcUtils.EXPIRE_TIME+ ") VALUES (?,?)";}@Overridepublic String getReleaseLockSql() {return "DELETE FROM "+ JdbcUtils.DISTRIBUTED_LOCKS_TABLE_NAME+ " WHERE "+ JdbcUtils.LOCK_ID+ " = ?";}@Overridepublic String getTryReleaseTimedOutLock() {return "DELETE FROM "+ JdbcUtils.DISTRIBUTED_LOCKS_TABLE_NAME+ " WHERE TIMESTAMPDIFF(SECOND, "+ JdbcUtils.ACQUIRED_AT+ ", NOW()) >"+ JdbcUtils.EXPIRE_TIME+ " and "+ JdbcUtils.LOCK_ID+ " = ?";}
}
getCreateTableSql()
:- 作用: 提供创建锁表的 SQL。
- 表结构:
lock_id
: 锁的唯一标识,是主键,保证了互斥性。其类型为VARCHAR(%s)
,%s
会在运行时被替换为用户配置的lock-key-max-length
,默认为 255。acquired_at
: 锁被获取的时间戳,默认为当前时间。expire_time
: 锁的超时时间,单位是秒。这是一个由客户端在获取锁时设置的业务层超时时间,用于后续的死锁清理。
- 分析: 这是整个机制的基础,利用了 MySQL 的主键约束。
getLockAcquireSql()
:- 作用: 提供获取锁的 SQL。
- SQL:
INSERT INTO ... VALUES (?, ?)
- 分析: 这是一个简单的
INSERT
语句。在AbstractDistributedLockDialect
中,这个 SQL 会被执行,如果成功(返回影响行数 > 0),则表示获取锁成功。如果因为主键冲突而抛出SQLException
,则会被捕获并返回false
,表示获取锁失败。
getReleaseLockSql()
:- 作用: 提供释放锁的 SQL。
- SQL:
DELETE FROM ... WHERE lock_id = ?
- 分析: 非常直接,通过
lock_id
删除对应的锁记录,从而让其他客户端可以获取该锁。
getTryReleaseTimedOutLock()
:- 作用: 提供清理超时死锁的 SQL。这是非常关键的容错机制。
- SQL:
DELETE FROM ... WHERE TIMESTAMPDIFF(SECOND, acquired_at, NOW()) > expire_time AND lock_id = ?
- 分析:
TIMESTAMPDIFF(SECOND, acquired_at, NOW())
: 这是 MySQL 特有的函数,用于计算acquired_at
时间戳和当前时间NOW()
之间相差的秒数。> expire_time
: 如果这个时间差大于了当初设置的超时时间,就意味着这个锁已经过期了。AND lock_id = ?
: 这里的lock_id
是当前客户端正准备去获取的那个锁。这意味着,一个客户端在尝试获取锁之前,会先检查这个锁是不是一个已经被持有了很久的“死锁”,如果是,就先把它清理掉,然后再尝试自己去获取。这避免了因为某个客户端崩溃而导致的永久死锁。
结合之前的讨论,这里有一些更精确的补充和修正:
死锁清理机制的触发时机(补充):
- 之前的分析提到了死锁清理机制,但这里可以更明确:
getTryReleaseTimedOutLock()
返回的 SQL 不是由一个后台守护进程定期执行的。 - 它是在每次有客户端调用
JdbcUtils.acquire()
尝试获取锁时被触发的。也就是说,锁的清理是被动触发的,依赖于后续的锁请求。这是一种懒汉式(Lazy)的清理策略,实现简单且高效。
- 之前的分析提到了死锁清理机制,但这里可以更明确:
与 PostgreSQL 的对比(补充):
MysqlDistributedLockDialect
使用了TIMESTAMPDIFF
函数,这是 MySQL 特有的。- 如果我们去看
PostgresqlDistributedLockDialect
,会发现它使用了EXTRACT(EPOCH FROM AGE(NOW(), acquired_at))
来实现相同的功能。 - 这完美地体现了 Dialect(方言)模式的价值:将与特定数据库语法相关的部分隔离在具体的方言实现中,而上层的锁逻辑保持不变。
expire_time
的真正含义(修正/细化):- 之前提到
expire_time
是超时时间,这没错。但更精确地说,它是由客户端在调用lockAcquire
时传入的timeoutMillSeconds
参数转换而来的(除以1000得到秒)。 - 这个超时时间通常就是
CatalogOptions.LOCK_ACQUIRE_TIMEOUT
的值。所以,一个锁记录是否被视为“超时”,取决于另一个客户端在尝试获取它时,所配置的LOCK_ACQUIRE_TIMEOUT
。这隐含了一个假设,即集群中所有客户端的这个配置应该是相似的。
- 之前提到
非阻塞特性(补充):
INSERT
操作本身是非阻塞的。它要么立即成功,要么立即因主键冲突而失败。JdbcCatalogLock
的阻塞和等待行为,完全是在Java 客户端代码中通过while
循环、Thread.sleep()
和重试逻辑实现的,而不是依赖于数据库层面的行锁等待(比如SELECT ... FOR UPDATE
)。这种设计将等待逻辑放在了应用层,减轻了数据库的锁竞争压力和连接持有时间。
MysqlDistributedLockDialect
是一个非常好的例子,展示了如何利用特定数据库(MySQL)的函数(TIMESTAMPDIFF
)和通用特性(主键约束)来实现一个具体的分布式锁策略。它与 AbstractDistributedLockDialect
和 JdbcCatalogLock
共同构成了一个清晰、分层、可扩展的分布式锁实现方案。
总结
JdbcCatalogLock
是一个基于关系型数据库特性实现的、非常健壮的分布式锁。它与 HiveCatalogLock
的设计思想有共通之处(如自旋等待、超时控制),但其底层依赖的是数据库的主键唯一性约束,而不是像 Hive 那样的特定锁服务 API。通过 JdbcUtils
和 Dialect
的分层抽象,它将通用的锁逻辑与具体的 SQL 实现解耦,展示了优秀的可扩展性和软件设计模式。
使用HDFS作为外存
当 Paimon 的底层文件系统是 HDFS 时,它不需要像 HiveCatalogLock
或 JdbcCatalogLock
那样的外部分布式锁。在这种情况下,Paimon 会使用一个“空”的锁实现,即 Lock.empty()
。
这个选择逻辑位于 RenamingSnapshotCommit
的内部工厂类 Factory
中:
// ... existing code .../** Factory to create {@link RenamingSnapshotCommit}. */public static class Factory implements SnapshotCommit.Factory {// ... existing code ...@Overridepublic RenamingSnapshotCommit create(Identifier identifier, SnapshotManager snapshotManager) {Lock lock =Optional.ofNullable(lockFactory).map(factory -> factory.createLock(lockContext)).map(l -> Lock.fromCatalog(l, identifier)).orElseGet(Lock::empty); // 如果没有配置 lockFactory,则返回 Lock.empty()return new RenamingSnapshotCommit(snapshotManager, lock);}
// ... existing code ...}
}
逻辑解释:
- 通常,当 Paimon 与 HDFS 一起使用时,会配置
filesystem
类型的 Catalog。 FileSystemCatalog
默认不提供CatalogLockFactory
。- 因此,在创建
RenamingSnapshotCommit
时,上述代码中的lockFactory
为null
,Optional...orElseGet()
逻辑会执行,最终返回Lock.empty()
。 Lock.empty()
是一个无操作的锁,它的runWithLock(callable)
方法会直接执行callable.call()
,不会进行任何加锁或解锁操作。
那么问题来了,如果没有真正的锁,Paimon 如何保证并发写入的安全性呢?答案就在于 HDFS 自身提供的能力。
HDFS 提供的核心能力:原子重命名 (Atomic Rename)
Paimon 实现快照提交的核心机制是“先写临时文件,再重命名”。这个过程依赖于文件系统 rename
操作的原子性。
HDFS 恰好就提供了这一关键能力。
在 HDFS 的架构中,所有的文件元数据操作(包括创建、删除、移动、重命名文件)都由一个中心化的组件 NameNode 负责。当一个客户端请求 rename
一个文件时:
- 请求被发送到 NameNode。
- NameNode 会获取元数据锁,在内存中修改文件系统的目录树结构,将文件或目录从源路径移动到目标路径。
- 这个修改过程对于整个 HDFS 来说是原子的。它要么完全成功,要么完全失败,绝不会出现文件部分移动或数据损坏的中间状态。
- 由于 NameNode 是单点处理元数据操作,它天然地保证了即使有多个客户端同时尝试对同一个文件或路径进行
rename
,这些操作也会被序列化,最终只有一个能成功。
RenamingSnapshotCommit
的 commit
方法完美地利用了这一特性:
// ... existing code ...@Overridepublic boolean commit(Snapshot snapshot, String branch, List<PartitionStatistics> statistics)throws Exception {
// ... existing code ...Callable<Boolean> callable =() -> {// 1. 先将快照内容写入临时文件,然后原子地重命名为最终文件boolean committed = fileIO.tryToWriteAtomic(newSnapshotPath, snapshot.toJson());if (committed) {snapshotManager.commitLatestHint(snapshot.id());}return committed;};return lock.runWithLock( // 在 HDFS 场景下,lock 是 Lock.empty()() ->// 2. 先检查目标文件是否存在,然后执行 callable!fileIO.exists(newSnapshotPath) && callable.call());}
// ... existing code ...
提交流程拆解:
fileIO.tryToWriteAtomic()
会先将新的快照(比如snapshot-100
)内容写入一个临时文件,例如hdfs:///path/to/table/snapshot/.snapshot-100.tmp
。- 写完临时文件后,它会执行一次
rename
操作,将.snapshot-100.tmp
重命名为snapshot-100
。 - 并发场景:假设有两个作业同时完成了计算,都想提交
snapshot-100
。它们都会先各自生成临时文件,然后几乎同时向 HDFS NameNode 发起rename
请求,目标都是snapshot-100
。 - HDFS 的保证:HDFS NameNode 会仲裁这两个请求,只允许其中一个成功。另一个
rename
请求会失败(因为目标文件已经存在)。 - 结果:成功
rename
的作业完成了提交。失败的作业则认为提交冲突,它的提交操作返回false
,从而保证了快照的线性、一致性。
正如代码注释和官方文档 (docs/content/concepts/concurrency-control.md
) 所述,正是因为 HDFS 保证了 rename
的原子性,所以它不需要外部锁。
总结
- 当 Paimon 使用 HDFS 作为远端存储时,它依赖 HDFS 自身提供的
rename
操作的原子性 来保证并发提交的正确性,因此使用的是一个无操作的Lock.empty()
。 - HDFS 通过其中心化的 NameNode 来管理所有元数据操作,确保了
rename
是一个原子性的、事务性的操作。这成为了 Paimon 在 HDFS 上实现无锁并发控制的基石。 - 这与对象存储(如 S3, OSS)形成鲜明对比。对象存储的
rename
通常是“先复制再删除”,并非原子操作,因此在对象存储上进行并发写入时,必须配置外部锁(HiveCatalogLock
或JdbcCatalogLock
)来弥补文件系统能力的不足。