
TiDB 外部存储 Put-and-Verify 事务基于对象存储的分布式锁与并发控制方案解析【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb本文深入解析 TiDB 为外部存储S3、GCS、Azure Blob 等设计的Put-and-Verify 事务协议设计文档见 docs/design/2024-10-11-put-and-verify-transactions-for-external-storages.md该协议在仅依赖对象存储强一致性的 PUT/GET/LIST 能力的前提下实现安全的独占写与远程互斥/读写锁。你将掌握其 intent-file意图文件两阶段提交算法、Verify前置条件的正确性边界以及如何在备份恢复BR、日志备份PITR等真实场景中用TryLockRemote/LockWithRetry系列 API 实现存储迁移、截断保护等并发控制。背景为什么放一个锁文件不够安全在 TiDB 的备份恢复与日志备份体系中经常需要对**同一个备份归档backup archive**做并发访问控制典型场景包括compacting / restoring 期间阻止存储迁移到新版本备份存储迁移到新版本时禁止他人读取truncating 存储时不希望另一个 truncate 操作同时进行正在备份时不希望另一个备份任务使用同一份存储。设计文档明确指出了朴素方案的缺陷仅仅 PUT 一个锁文件并不安全——因为检查到不存在锁文件与写入锁文件之间存在竞态窗口检查之后、写入之前另一个进程可能抢先写入了锁文件。业界现有手段各有限制Object locks对象锁一致性更强但需要额外的配置与权限Conditional write条件写比对象锁轻量但两者都面向实体entity可用条件受限。例如截至 2024 年中你无法表达如果前缀/competitor下没有任何文件则写入/me这类跨前缀谓词。该提案的出发点是在所有对 PUT、GET、LIST API 提供强一致性保证的对象存储上设计一套安全的上锁/解锁流程。相关存储的一致性承诺见于 S3、Google Cloud Storage 的官方一致性文档以及 Azure Blob Storage 的相关讨论设计文档中注明 Azure 暂未找到官方正式文档。在 TiDB 仓库中这一强一致性假设被抽象为一个显式的标记接口StrongConsistency见 pkg/objstore/storeapi/storage.go它要求实现者声明自己的Read、Write、WalkDir三个 API 均具备强一致性。S3 风格存储pkg/objstore/s3like/store.go、GCSpkg/objstore/gcs.go与 Azure Blobpkg/objstore/azblob.go均已实现该方法。协议定义Put-and-Verify 事务核心数据结构一个 put-and-verify 事务由两个核心类型组成type VerifyWriteContext struct { context.Context Target string Storage ExternalStorage TxnID uuid.UUID } type ExclusiveWrite struct { // Target is the target file of this txn. // There shouldnt be other files shares this prefix with this file, or the txn will fail. Target string // Content is the content that needed to be written to that file. Content func(txnID uuid.UUID) []byte // Verify allows you add other preconditions to the write. // This will be called when the write is allowed and about to be performed. // If Verify() returns an error, the write will be aborted. // // With this, you may add extra preconditions of committing in usecases like RWLock. Verify func(ctx VerifyWriteContext) error }字段语义字段语义Target事务的目标文件。不允许有其他文件与该文件共享此前缀否则事务失败。Content需要写入目标文件的内容是一个以txnID为入参的生成函数保证每次提交写入的内容携带唯一事务标识。Verify额外的提交前置条件钩子。在写入被允许且即将执行时被调用一旦返回错误写入即被中止。利用它可实现 RWLock 之类的复合前置条件。提交成功后的不变量invariantsTarget恰好被写入一次exactly once只要Verify函数满足如下性质——一旦它返回过非错误那么在恰好有一个文件拥有该前缀期间它永远返回非错误——那么事务提交后Verify()必然不返回错误。提交算法意图文件 二次检查一个 PutAndVerify 事务按以下步骤提交向Target写入一个带随机后缀的意图文件intention file检查Target前缀下是否已存在其他文件若存在删除自己的意图文件退避或向调用方报告错误若不存在且Verify()返回无错误将Content()无后缀地写入Target随后删除意图文件。设计文档给出了完整提交实现func (w ExclusiveWrite) CommitTo(ctx context.Context, s ExternalStorage) (uuid.UUID, error) { txnID : uuid.New() cx : VerifyWriteContext{ Context: ctx, Target: w.Target, Storage: s, TxnID: txnID, } intentFileName : cx.IntentFileName() // Should be {Target}.INTENT.{UUID} checkConflict : func() error { var err error if w.Verify ! nil { err multierr.Append(err, w.Verify(cx)) } return multierr.Append(err, cx.assertOnlyMyIntent()) } if err : checkConflict(); err ! nil { return uuid.UUID{}, errors.Annotate(err, during initial check) } if err : s.WriteFile(cx, intentFileName, []byte{}); err ! nil { return uuid.UUID{}, errors.Annotate(err, during writing intention file) } defer s.DeleteFile(cx, intentFileName) if err : checkConflict(); err ! nil { return uuid.UUID{}, errors.Annotate(err, during checking whether there are other intentions) } return txnID, s.WriteFile(cx, w.Target, w.Content(txnID)) }关键点在于checkConflict()被执行两次一次在写意图文件之前initial check一次在写意图文件之后此时其它事务的意图文件也会暴露在前缀扫描中。第二次检查是原子性的来源——在强一致性的 LIST 语义下任何并发事务的意图文件都必然在这次 WalkDir 中被观察到从而只有一个事务能通过检查并写入最终文件。源码实现对照该算法在仓库中的真实落地位于 pkg/objstore/locking.go类型名为conditionalPut逻辑与设计文档完全一致并做了三处工程化增强强一致性校验CommitTo开始时用s.(storeapi.StrongConsistency)断言存储类型若不满足则打印警告日志提示该存储未提供强一致性保证请尽量避免并发访问failpoint 注入在写意图文件前后分别注入exclusive-write-commit-to-1与exclusive-write-commit-to-2两个 failpointpkg/objstore/locking.go 与 pkg/objstore/locking.go用于测试中精确制造竞态窗口见下文并发测试意图文件命名IntentFileName()实际格式为fmt.Sprintf(%s.INTENT.%s, cx.Target, hex.EncodeToString(cx.TxnID[:]))即以{Target}.INTENT.{UUID十六进制}命名pkg/objstore/locking.goUUID 被编码为十六进制字符串避免出现非法的对象名分隔符。冲突回滚示例两个事务互相竞争设计文档用 Alice 与 Bob 两个并发事务演示了双方都失败的情况意图文件名已简化Alices TxnBobs TxnintentFile : LOCK_AliceintentFile : LOCK_BobVerify() →OKVerify() →OKWrite(LOCK_Bob, ) →OKWrite(LOCK_Alice, ) →OKVerify() →Failed! LOCK_Alice existsVerify() →Failed! Lock_Bob existsDelete(LOCK_Bob) →OKDelete(LOCK_Alice) →OKABORTABORT随后双方重试提交Alices TxnBobs TxnintentFile : LOCK_AliceintentFile : LOCK_BobVerify() →OKVerify() →OKWrite(LOCK_Bob, ) →OKWrite(LOCK_Alice, ) →OKVerify() →Failed! LOCK_Alice existsDelete(LOCK_Bob) →OKVerify() →OKWrite(LOCK_Alice, Alice owns the lock) →OKCOMMITTEDABORT这次 Alice 幸运地提交成功而 Bob 在发现自己与冲突事务竞争后放弃提交。正确性论证原子 CASAtomic Compare-And-Swap设计文档用TLA 模块对算法进行了形式化建模与验证渲染版 TLA 模块write-and-verify-tla.pdf两个客户端场景下的状态转换图可人工核对write-and-verfiy-tla-states.pdf。TLA 模型验证保证了协议在并发下的安全性无论两个事务如何交错最终都至多只有一个事务能够成功提交符合 CAS 语义。Verify()的不变量正确性论证看似平凡对Verify的最后一次调用保证了前缀下恰好只有一个文件。而根据Verify的定义在文件被移除之前它应当一直返回 nil。两次checkConflict()调用中第二次写意图文件之后的那次恰好处于即将写入最终文件的位置因此只要该次Verify通过最终文件写入时前缀下必然没有其它文件与之冲突。应用示例一用空Verify实现 Mutex在外部存储上实现互斥锁一个空的Verify函数就足够了——协议自身的前缀唯一性检查assertOnlyMyIntent已经保证了排他性func TryLockRemote(ctx context.Context, storage ExternalStorage, path) error { writer : ExclusiveWrite{ Target: path, Content: func(_ uuid.UUID) []byte { return []byte(I got the lock :D) }, } _, err writer.CommitTo(ctx, storage) return err }源码实现对照仓库中的TryLockRemotepkg/objstore/locking.go在此基础上做了增强——它通过MakeLockMetapkg/objstore/locking.go生成带LockedAt加锁时间、LockerHost主机名、LockerPID进程号、OwnerID、LockType、Hint等字段的 JSON 元数据并将txnID写入meta.TxnID随Content一起写入锁文件。这样锁文件本身就是可诊断的任何人读到它都能知道谁、在什么时间、为什么持有这把锁。相应地RemoteLock.Unlockpkg/objstore/locking.go在删除锁文件前会比对锁文件内的TxnID与本地持有的txnID若不一致说明锁已被覆盖或自己覆盖了别人的锁直接返回Txn ID mismatch错误避免误删他人锁UnlockOnCleanUppkg/objstore/locking.go则在 context 已取消时用 30 秒超时的后台 context 兜底清理。注意设计文档与源码都强调这不是 Linuxflock那种严格锁持有者可能被人工删除锁文件强制释放。应用示例二借助Verify实现 RWLock读写锁需要区分两种兼容性读锁之间互不冲突而写锁与任何锁冲突。设计文档先引入一个前缀扫描辅助函数// assertNoOtherOfPrefixExpect asserts that there is no other file with the same prefix than the expect file. func (cx VerifyWriteContext) assertNoOtherOfPrefixExpect(pfx string, expect string) error { fileName : path.Base(pfx) dirName : path.Dir(pfx) return cx.Storage.WalkDir(cx, WalkOption{ SubDir: dirName, ObjPrefix: fileName, }, func(path string, size int64) error { if path ! expect { return fmt.Errorf(there is conflict file %s, path) } return nil }) }写锁检查是否已存在任何类型的锁加写锁时需要验证前缀下不存在任何读锁或写锁。注意锁文件被放在$path.WRIT而非$pathfunc TryLockRemoteWrite(ctx context.Context, storage ExternalStorage, path string) error { target : fmt.Sprintf(%s.WRIT, path) writer : ExclusiveWrite{ Target: target, Content: func(txnID uuid.UUID) []byte { return []byte(Im going to write something down :) }, Verify: func(ctx VerifyWriteContext) error { return ctx.assertNoOtherOfPrefixExpect(path, ctx.IntentFileName()) }, } _, err writer.CommitTo(ctx, storage) return err }这里Verify检查path前缀下除了自己的意图文件外没有其它文件——即没有任何读锁path.READ.*或写锁path.WRIT存在。读锁只检查是否存在写锁加读锁时只需确认不存在写锁。关键在于读锁文件名带随机后缀因此多个读锁之间天然不冲突func TryLockRemoteRead(ctx context.Context, storage ExternalStorage, path string) error readID : rand.Int63() target : fmt.Sprintf(%s.READ.%016x, path, readID) writeLock : fmt.Sprintf(%s.WRIT, target) writer : ExclusiveWrite{ Target: target, Content: func(txnID uuid.UUID) []byte { return []byte(Guess what we will find today D) }, Verify: func(ctx VerifyWriteContext) error { // Make sure that the write lock doesnt exist. return ctx.assertNoOtherOfPrefixExpect(writeLock, ) }, } _, err writer.CommitTo(ctx, storage) return err }源码实现对照仓库中TryLockRemoteWrite与TryLockRemoteRead的实现pkg/objstore/locking.go与设计文档一一对应writeLockName(path)生成{path}.WRITnewReadLockName(path)用rand.Int63()生成{path}.READ.{16位十六进制}后缀pkg/objstore/locking.go冲突检测通过conflictingObjectsOfPrefixExpectpkg/objstore/locking.go实现它额外设置IncludeTombstone: true我们最好能读到已删除的意图文件并把前缀扫描时目录名为.的情况归一化为空字符串规避部分对象存储在 list 前缀时拒绝.目录组件的问题冲突时返回结构化的ErrLockedpkg/objstore/locking.go其中Blockers会携带每个冲突锁文件的路径与其LockMeta为避免错误信息爆炸冲突文件默认最多采样 3 个lockBlockerMetaLimit 3超出部分以omitted_conflict_files N汇总。生产化能力重试、元数据与可观测性设计文档只给出了协议骨架而仓库在 pkg/objstore/locking.go 中将其打磨成了生产可用的完整锁组件这几点值得在生产集成时关注1. 指数退避重试LockWithRetry冲突失败后应当退避重试但绝不能无限重试。LockWithRetrypkg/objstore/locking.go封装了完整的重试语义最大重试次数lockRetryTimes 60pkg/objstore/locking.go防止无限循环退避策略为指数退避ExponentialBackoff从 1 秒起步、上限 60 秒并叠加 2.5~7.5 秒JitterMs 5000抖动区间[2500, 7500)毫秒的随机抖动避免惊群式同时重试每次重试前都会记录结构化日志Encountered lock, will retry包含剩余尝试次数与下次重试等待时间若外层 context 被取消则返回携带多层 cause 的错误lock retry stopped: ...保证可取消性。2. 锁冲突元数据与日志字段LockMetaInput允许调用方在发起加锁时携带OwnerID如操作 ID、LockType如migration-read/migration-write之类的诊断标签和Hint自定义提示如restore_id456它们会被序列化进锁文件的LockMeta中冲突时LockConflictLogFieldspkg/objstore/locking.go会输出local_owner_id、remote_blocker_count、remote_blocker_0_owner_id等结构化字段配合ErrLocked.Error()中的人类可读描述排查谁锁住了我非常直观。3. 真实调用场景日志备份迁移与 PITR在 BRBackup Restore的日志备份存储迁移storage migration逻辑中这套锁 API 承担了关键职责迁移读锁MigrationExt.GetReadLock通过objstore.LockWithRetry(ctx, objstore.TryLockRemoteRead, ...)获取迁移读锁保证迁移过程中不会有其它进程修改备份br/pkg/stream/stream_metas.go追加/写锁日志数据追加时通过TryLockRemoteWrite获取追加锁读锁存在时写锁必然失败br/pkg/stream/stream_metas.go截断保护日志备份的 truncate 场景使用objstore.TryLockRemote直接创建互斥锁文件防止两个截断操作并发执行br/pkg/task/stream.go。测试验证并发正确性如何被保障仓库在 pkg/objstore/locking_test.go 中提供了系统性的测试套件覆盖协议的全部关键性质TestTryLockRemote/TestConflictLockpkg/objstore/locking_test.go验证互斥锁文件创建/释放以及第二个加锁者在锁已被持有时收到conflict file test.lock错误TestRWLockpkg/objstore/locking_test.go验证两个读锁可共存、写锁被读锁阻挡、读锁释放后写锁可获取对应文件test.lock.WRIT的创建与删除TestConcurrentLockpkg/objstore/locking_test.go借助 failpointexclusive-write-commit-to-1与exclusive-write-commit-to-2在两个 goroutine 的意图文件写入阶段精确制造交错最终断言恰好只有一个加锁成功——这正是设计文档 TLA 模型所验证的原子 CAS 性质在单元测试层面的复现TestWriteLockConflictSamplesReadLockBlockerspkg/objstore/locking_test.go验证冲突文件采样上限——5 个读锁阻塞写锁时BlockerCount 5但Blockers仅返回 3 个且错误信息包含omitted_conflict_files 2TestLockMetaOldJSONCompatibilitypkg/objstore/locking_test.go保证旧版本锁文件 JSON无owner_id/lock_type字段仍可被反序列化体现向前兼容性。集成要点与适用边界存储前提协议的安全性完全建立在对象存储 PUT/GET/LIST 的强一致性之上。集成前请确认目标存储实现了StrongConsistency标记接口S3-like、GCS、Azure Blob 已实现否则CommitTo只会打印警告而不会强制拒绝此时应避免并发访问。前缀纪律Target的前缀必须是私有的——任何其它文件与Target共享前缀都会导致事务失败。RwLock 正是利用.WRIT/.READ.random的后缀布局把同前缀与不同前缀编码进对象名从而表达锁的兼容矩阵。锁的软性远程锁不是flock持有者可能在极端情况如进程崩溃后手动清理失败下留下孤儿锁文件配合LockMeta中记录的加锁时间、主机、PID 与 Hint可以判断并人工清理。失败重试并发竞争下事务失败是正常现象请使用LockWithRetry的指数退避 抖动策略重试并注意外层 context 的可取消性。通过本方案TiDB 在不引入任何额外协调服务如 etcd的前提下仅靠对象存储本身的强一致性能力即可为备份归档提供可靠的互斥与读写锁语义——这正是put-and-verify协议在工程上的价值所在。【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考