尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

TiDB分布式事务核心:Percolator模型原理与工程实践详解

TiDB分布式事务核心:Percolator模型原理与工程实践详解 1. 从单机到分布式为什么我们需要一个全新的“事务”模型如果你是从传统单机数据库比如 MySQL转向 TiDB 的开发者最让你困惑和头疼的恐怕就是“分布式事务”这个概念了。在单机数据库里事务是数据库的基石ACID原子性、一致性、隔离性、持久性特性由数据库引擎在本地保证你只需要一个BEGIN和COMMIT就能安心地操作数据。但到了分布式世界数据被切分Sharding并存储在不同的物理节点上一个事务可能涉及对多个节点上数据的修改。这时如何保证跨节点的 ACID 特性就变成了一个极其复杂的工程问题。想象一下你在一个电商系统里要完成一个“下单减库存”的操作。订单表Order和库存表Stock可能因为数据量巨大被拆分到了不同的数据库节点上。你的业务逻辑要求这两个操作要么同时成功要么同时失败。在单机数据库里这只是一个本地事务。但在分布式环境下这变成了一个跨网络、跨节点的协同问题。网络可能延迟、节点可能宕机任何一个环节出问题都可能导致数据不一致比如订单创建成功了但库存没扣减或者库存扣减了但订单没生成。这就是分布式事务要解决的核心痛点。TiDB 作为一款分布式 NewSQL 数据库其核心卖点之一就是支持跨节点的分布式事务并且对外提供了与 MySQL 兼容的乐观事务和悲观事务接口。而支撑这一能力的底层基石就是 Google 在 2006 年发表的论文《Large-scale Incremental Processing Using Distributed Transactions and Notifications》中提出的Percolator 模型。很多人一听到“模型”就觉得是艰深的理论但事实上Percolator 是一个极其精巧且务实的工程方案。它不是凭空设计出来的而是为了解决 Google 网页索引系统Google Search中需要对数十亿网页进行增量更新这一特定、超大规模场景而诞生的。TiDB 的工程师们没有闭门造车而是选择站在巨人的肩膀上对 Percolator 模型进行了深入的吸收、改造和工程化使其能够适配通用数据库的事务需求。所以理解 TiDB 的分布式事务本质上就是理解 Percolator 模型在 TiDB 中是如何落地、如何工作、以及工程师们为了解决实际生产问题做了哪些关键的“魔改”。这不仅仅是学习一个算法更是理解一套在超大规模、高并发环境下如何权衡性能、一致性与复杂度最终构建出一个稳定可靠系统的工程哲学。接下来我们就抛开晦涩的论文描述用最直白的语言和场景拆解 Percolator 的核心思想并看看 TiDB 是如何让它“跑起来”的。2. Percolator 模型精讲两阶段提交2PC的“优雅”实现Percolator 模型的核心其实是对经典分布式共识算法——两阶段提交2PC的一次精巧封装和优化。2PC 本身是一个“古老”但基础的协议它通过引入一个“协调者”Coordinator来管理多个“参与者”Participant的提交过程分为“准备阶段”和“提交阶段”。但原生的 2PC 存在几个致命问题协调者单点故障、阻塞时间长、数据在准备阶段被锁定影响性能。Percolator 的聪明之处在于它利用了一个分布式存储系统在 Google 是 Bigtable在 TiDB 是 TiKV的行级多版本和时间戳能力将 2PC 的过程“物化”到数据本身从而巧妙地规避了这些问题。2.1 核心数据结构锁、时间戳与写入意图要理解 Percolator必须先理解它定义的三个核心“标记”它们被作为特殊的数据和用户的业务数据一起存储锁Lock这是一个临时性的记录标记某一行数据正在被一个事务修改。它包含了锁持有者事务的信息。锁是 Percolator 实现隔离性和原子性的关键。在事务修改数据前必须先上锁防止其他并发事务冲突。写入意图Write Intent / Data这是事务准备提交的新数据版本。在事务提交成功前这个版本对其他事务是不可见的。你可以把它理解为事务的“私有草稿”。提交时间戳Commit Timestamp这是一个全局递增的时间戳由 Timestamp Oracle (TSO) 服务颁发。当事务成功提交时这个时间戳会被记录下来并用于将“写入意图”转化为一个对所有人可见的、带时间戳的数据版本。这里有一个关键的设计Percolator 将 2PC 的“协调者”角色给“消灭”了。准确地说是将其职责分散到了数据和参与事务的各个节点中。它没有中心化的协调者进程而是通过一套基于锁和时间戳的协议让参与者们自主地完成提交。2.2 事务流程拆解一次写事务的完整旅程让我们用一个具体的例子走一遍 Percolator 模型下一个写事务比如转账从 A 账户减 100给 B 账户加 100的生命周期。假设 A 和 B 两行数据存储在不同的 TiKV 节点上。阶段一预写Prewrite—— 对应 2PC 的“准备阶段”客户端从 TSO 获取一个开始时间戳start_ts例如 100。客户端选择两行数据中的一行作为Primary Row主行另一行作为 Secondary Row次级行。通常选择第一个被写入的行或一个确定性的行作为 Primary。这里我们选 A 账户行为 Primary。对 Primary Row (A) 执行 Prewrite检查 A 行上是否存在时间戳晚于start_ts的提交记录或锁。如果有说明有更新的事务已经提交本次事务冲突回滚。检查 A 行上是否存在任何锁无论时间戳。如果有说明正被其他事务修改需要等待或回滚取决于事务模型乐观或悲观。如果以上检查通过则在 A 行上写入一个Lock锁锁的内容指向 Primary Row 本身即锁住自己。同时将新的数据值A-100作为Write Intent写入。对 Secondary Row (B) 执行 Prewrite执行类似的检查。写入 Lock但这次锁的内容指向Primary Row (A)的位置信息。这是一个极其重要的设计所有次级行的锁都指向主行。同时写入 B100 的 Write Intent。为什么锁要指向 Primary Row这是 Percolator 实现故障恢复和原子提交的精髓。如果事务协调者客户端在提交过程中崩溃其他事务或后台清理线程在 TiDB 中叫 Lock Resolver会发现这些“孤儿锁”。通过查看锁内容它们会去检查 Primary Row 的状态。如果 Primary Row 的锁还在说明事务未提交可以安全地清理回滚所有次级行的锁和意图如果 Primary Row 的锁已消失且有了提交记录说明事务已提交则可以帮次级行提交提交锁和意图。这避免了需要一个中心化的协调者来记录事务状态。阶段二提交Commit—— 对应 2PC 的“提交阶段”客户端从 TSO 获取一个提交时间戳commit_ts例如 105。提交 Primary Row客户端前往 Primary Row (A) 所在的 TiKV 节点执行提交操作。这个操作是原子的它首先检查 A 行上的锁是否还存在且属于本事务防止重复提交等异常。如果检查通过则执行两个操作1) 写入一条提交记录Commit Record内容为start_ts (100) - commit_ts (105)2)删除该行上的锁。至此Primary Row 的修改对后续读取事务可见它们会读取commit_ts为 105 的版本。并行提交所有 Secondary Rows一旦 Primary Row 提交成功整个事务在逻辑上就已经提交了因为原子性已经由 Primary 的提交保证。客户端可以并行地向所有 Secondary Row (B) 所在的 TiKV 节点发送提交请求。每个节点的提交操作与 Primary 类似检查锁、写入提交记录、删除锁。阶段三清理Cleanup—— 异步提升性能在提交阶段我们只写了提交记录和删了锁但之前 Prewrite 阶段写入的Write Intent数据还处于“意图”状态。Percolator 模型并不要求在提交阶段立即将意图数据转化为正式版本。这个转化可以由后续的读请求“懒加载”完成当一个读请求遇到 Write Intent 时它会根据其提交记录将数据正式写入一个带commit_ts的版本中。TiDB 也有后台线程GC Worker会定期清理过期的旧版本和完成转化的意图数据。这种异步清理的设计将提交的关键路径Critical Path缩到最短只涉及元数据锁和提交记录的修改大大提升了提交阶段的性能和吞吐量。2.3 读操作如何与写事务共存读事务例如查询 A 账户余额同样需要一个开始时间戳read_ts比如 103。它读取数据时的逻辑是从存储中定位到目标行寻找时间戳小于等于read_ts的最新数据版本。如果找到的是一个已提交的数据版本带时间戳直接返回。如果找到的是一个 Write Intent锁读事务需要判断如果这个意图的start_ts大于read_ts说明这个写事务是在本读事务开始之后才启动的对读事务不可见继续向前寻找更早的版本。如果这个意图的start_ts小于read_ts说明这个写事务与读事务在时间上有重叠。此时读事务需要尝试去解析这个锁如果锁对应的写事务已经提交通过检查 Primary Row 的提交记录那么读事务可以等待该意图被提交或者直接读取已提交的数据如果异步清理已完成。如果锁对应的写事务尚未提交且锁已经存在了较长时间超过一定阈值在 TiDB 中由max-txn-time-use等参数控制读事务可能会认为该事务已超时或协调者宕机从而触发锁清理流程。它会根据锁指向的 Primary Row 状态决定是回滚该事务还是帮助其提交。这个过程保证了读操作不会被死锁无限期阻塞。这种基于多版本时间戳的读取天然地提供了快照隔离Snapshot Isolation, SI级别。每个读事务都像是在一个特定时间点read_ts的数据库快照上操作读写互不阻塞写写则通过锁来检测冲突。3. TiDB 的工程化实践当理论遇上生产环境直接将论文中的 Percolator 模型搬过来是无法支撑一个企业级数据库的。TiDB 在工程落地过程中做了大量至关重要、甚至可以说是决定性的改造和优化。这些才是“工程实践”的真正内涵。3.1 全局授时服务Timestamp Oracle (TSO) 的高可用与高性能Percolator 模型严重依赖全局单调递增的时间戳。在 Google 的内部实现中可能使用了原子钟和 GPS 等硬件来提供 TrueTime API。而 TiDB 作为一个软件系统需要自己实现一个高可用、高性能、强一致的 TSO 服务。TiDB 将 TSO 服务内置于 PDPlacement Driver组件中。PD 本身是一个基于 etcd 实现强一致性的集群。TSO 的生成逻辑是PD 集群通过 etcd 的选主机制确保只有一个 Leader 节点负责分配 TSO。TSO 本身是一个 64 位整数高位是物理时间戳毫秒低位是逻辑计数器。客户端TiDB Server会批量地向 PD Leader 请求时间戳以减少 RPC 调用开销。PD Leader 在内存中维护一个最后发放的时间戳每次请求递增逻辑计数器。它会确保时间戳严格单调递增即使发生 Leader 切换新的 Leader 也会从持久化存储中读取最后一个时间戳并确保新分配的时间戳大于旧值。这里有一个关键挑战性能与扩展性。所有事务都需要获取时间戳这使得 TSO 可能成为系统的瓶颈。TiDB 的优化策略包括客户端缓存时间戳一次获取一批、PD 服务本身的无状态化设计方便水平扩展、以及优化 TSO 请求的网络路径。在实际部署中确保 PD 集群与 TiDB Server 之间的网络低延迟至关重要。3.2 锁管理与冲突处理乐观与悲观的抉择原始的 Percolator 论文描述的是一个乐观事务模型先执行在提交前的 Prewrite 阶段检查冲突冲突则回滚。这在冲突率低的场景如后台数据处理下效率很高。但对于高冲突的 OLTP 场景如秒杀扣库存大量事务在最后阶段因冲突而回滚会造成严重的资源浪费“事务冲刺”问题。因此TiDB 很早就引入了悲观事务模式并与 MySQL 语法兼容BEGIN或START TRANSACTION后默认是悲观模式。其核心区别在于乐观模式在Prewrite时才上锁并检测冲突。悲观模式在语句执行过程中如执行UPDATE时就尝试对要修改的行上悲观锁Pessimistic Lock。如果锁被占用则等待。在提交时其 Prewrite 阶段几乎一定会成功因为锁已提前获取从而大大降低了提交阶段的冲突率。TiDB 中悲观锁的实现也是 Percolator 模型的延伸。它同样在数据行上写入一个 Lock 记录但这个锁的生存期更长从事务开始持续到提交。这带来了新的问题长事务持有锁可能阻塞其他事务。TiDB 引入了锁等待超时、死锁检测等机制来应对。死锁检测器会构建事务的等待图Wait-for Graph发现环状依赖后会选择牺牲回滚其中一个代价最小的事务来解开死锁。3.3 大规模运维的基石锁解析器与垃圾回收在生产环境中事务协调者TiDB Server可能因为节点重启、网络分区或客户端错误而崩溃导致其正在处理的事务留下“孤儿锁”。如果不处理这些锁会永远阻塞其他读写操作。这就是Lock Resolver锁解析器发挥作用的地方。Lock Resolver 不是一个独立的服务而是一套内嵌在 TiDB Server 和 TiKV 中的逻辑。其工作流程如下任何读/写操作在遇到锁时如果等待超时由tikv_gc_life_time和事务超时时间决定就会触发锁解析流程。该操作会根据锁中记录的Primary Key信息定位到 Primary Row。检查 Primary Row 的状态如果 Primary Row 的锁已消失且存在一条start_ts - commit_ts的提交记录则判定原事务已提交。那么当前操作会帮助这个“孤儿”的 Secondary Row 完成提交写入提交记录删除锁这个过程称为“解决锁”。如果 Primary Row 的锁还在或者没有任何提交记录则判定原事务已失败。当前操作会帮助清理回滚这个 Secondary Row 的锁和 Write Intent。完成清理后触发操作的事务可以继续执行。垃圾回收GC则是另一个后台生命线。由于 Percolator 的多版本机制每次更新都会产生新的数据版本旧版本需要被清理以释放存储空间。TiDB 的 GC Worker 会定期默认10分钟运行确定一个“安全点”Safe Point即所有活跃事务的开始时间戳的最小值。早于这个时间戳的数据版本理论上可以被清理。分阶段清理首先删除不再需要的旧版本数据然后清理已提交事务的提交记录最后清理锁信息。GC 的速度需要谨慎控制过快可能影响正在运行的长事务过慢则会导致存储膨胀。3.4 性能调优与监控让分布式事务飞起来理解了原理最终要服务于性能。以下是一些关键的工程实践点事务大小限制一个 Percolator 事务涉及的所有键值对KV不能太大或太多。TiDB 默认限制单个事务的容量txn-total-size-limit和键的数量。因为过大的事务会导致 Prewrite 阶段耗时极长锁持有时间久严重影响系统并发度和稳定性。最佳实践是将大事务拆分为多个小事务。重试机制对于乐观事务提交时可能因冲突失败。TiDB 客户端驱动如 go-client提供了自动重试逻辑。但重试次数max-retry-count和退避策略需要合理设置避免雪崩。Region 与事务TiKV 中数据以 Region 为单位分布和调度。一个事务如果涉及多个 Region其 Prewrite 和 Commit 请求就需要发送到多个 TiKV 节点RPC 开销增大。设计表结构时尽量让高频事务的访问模式落在更少的 Region 上例如通过合理的主键设计可以显著提升性能。监控关键指标tikv_gc_secondsGC 的安全点延迟。如果这个值持续很大说明旧版本数据堆积可能存储压力大或有长事务阻塞了 GC。tidb_tikvclient_region_err_totalRegion 错误计数。频繁的 Region 错误如 Not Leader会导致事务重试增加延迟。tidb_session_retry事务重试次数。乐观事务冲突的直观反映。tikv_scheduler_latch_wait_duration和tikv_scheduler_command_durationTiKV 调度器等待和执行命令的耗时反映了 TiKV 的负载和瓶颈。锁相关的监控如tikv_lock_manager_waiter_lifetime_duration锁等待时间、tikv_lock_manager_deadlock_detect_duration死锁检测耗时。4. 避坑指南TiDB 分布式事务的典型陷阱与应对策略纸上得来终觉浅绝知此事要躬行。在实际使用 TiDB 分布式事务时有几个常见的“坑”需要特别注意。4.1 大事务性能与稳定性的头号杀手这是最经典的问题。如前所述一个更新数十万行的事务在 Prewrite 阶段会在所有涉及的行上写锁和 Write Intent。这会导致内存暴涨Prewrite 的数据需要在 TiDB 内存中缓存可能触发 OOM。锁风暴持有大量锁阻塞其他事务导致整个系统吞吐量骤降。提交风险高Primary Row 提交成功后需要并行提交海量的 Secondary Rows任何一个节点失败都可能导致部分数据提交成功部分失败虽然最终锁解析器能修复但过程复杂。GC 压力产生海量的数据版本给 GC 带来巨大压力。应对策略业务拆分这是根本解决方法。将批量操作拆分成多个批次每批次一个独立事务。例如批量更新用户状态可以每 1000 条一个事务。使用非事务性接口对于可以接受最终一致性的海量数据导入或更新考虑使用 TiDB Lightning 或LOAD DATA等工具或者使用INSERT ... ON DUPLICATE KEY UPDATE的“盲写”模式需谨慎评估一致性需求。调整参数在万不得已时可以临时调大txn-total-size-limit和txn-entry-count-limit但必须密切监控系统状态。4.2 热点 Region 与悲观锁等待如果业务中存在一个高频更新的“热点行”比如某个热门商品的库存在悲观事务模式下所有更新该行的事务都会串行化因为要获取行锁。即使每个事务都很快高并发下也会形成严重的排队等待。应对策略热点打散这是治本之策。如果热点是账户余额可以考虑引入“账户分桶”的设计。例如将一个用户的余额拆分成 N 个子账户如balance_0,balance_1更新时随机或按规则选择一个子账户操作将并发压力分散到多行数据上。优化事务逻辑尽量缩短事务持有锁的时间。遵循“锁在最后放锁最早”的原则将非数据库操作如调用外部 API、复杂计算移到事务外。使用乐观事务对于冲突率并非极高的场景可以尝试切回乐观事务模式。虽然可能有一定比例的回滚但整体吞吐量可能更高。这需要通过压测来权衡。4.3 时钟偏移与“过新”的读取TiDB 的 SI 隔离级别依赖于 TSO 的单调递增。如果部署 TiDB 的服务器之间存在较大的时钟偏移Clock Skew可能会引发一个微妙的问题某个 TiDB Server 的系统时钟比 PDTSO 服务快很多。当它执行一个读事务时会用本地时间估算一个read_ts去 PD 获取PD 返回的合法时间戳可能小于这个本地估算值。为了保持单调性PD 返回的时间戳必须大于该服务器之前获取过的所有时间戳。如果时钟偏移太大可能导致 PD 返回一个“未来”的时间戳。后续使用这个时间戳的读事务可能无法读到刚刚提交的数据因为数据版本的commit_ts可能还没达到这个“未来”的read_ts从而产生一种“过新读”的幻觉。应对策略部署 NTP 时间同步服务这是强制要求。确保所有 TiDB、TiKV、PD 节点的系统时钟与可靠的 NTP 服务器同步将时钟偏移控制在毫秒级最好 100ms 以内。监控tidb_server_start_time与 PD 时间的差异通过监控系统观察各节点的时间差。4.4 长事务阻塞 GC 与版本膨胀如果一个事务运行时间非常长例如一个未提交的批量查询或修改由于 GC 的安全点不能超过任何活跃事务的start_ts这个长事务会阻止 GC 清理比它更早的数据版本。导致存储空间中堆积大量历史版本不仅占用磁盘还会影响查询性能因为读操作需要扫描更多的版本。应对策略设置事务超时通过innodb_lock_wait_timeout悲观锁等待超时和max-execution-time语句执行超时来控制事务生命周期。避免交互式长事务不要在程序中使用交互式连接长时间开启一个事务而不提交。监控长事务通过information_schema.cluster_tidb_trx表或监控面板定期排查运行时间过长的活跃事务并及时干预。合理设置tikv_gc_life_time根据业务中长事务的最大可能持续时间来设置 GC 生命周期通常设置为最长事务时间的 2-3 倍以上但不宜过大以免存储压力。理解 TiDB 的分布式事务是一个从理论模型Percolator到工程系统TiDB的完整认知链条。它不仅仅是一个开关或者配置而是贯穿于数据库设计、应用开发、运维监控全流程的核心概念。在实际使用中我个人的体会是与其死记硬背参数不如深入理解其背后的权衡用锁和异步清理换取无中心协调者的简化架构用全局时间戳换取一致的多版本快照用乐观/悲观两种模式来适应不同冲突场景。当你遇到一个分布式事务相关的问题时沿着“锁-时间戳-多版本”这条主线去思考结合监控指标往往能更快地定位到问题的根源。最后永远对“大事务”保持警惕在分布式数据库的世界里“小而美”的事务设计是保证系统稳定高效的黄金法则。
返回列表