在本系列博客中,我将介绍 InnoDB 如何锁定数据(表和行),从而向客户端营造出查询是依次执行的假象,并探讨近期新版本中对此机制的改进。
正如我们之前所见,服务器所呈现的事务执行顺序(即串行化顺序)与事务获取锁的顺序息息相关。因此,锁的授予顺序会影响事务表现出的执行顺序,进而影响系统性能。
值得明确指出的是,当某项资源未被任何事务锁定时,若有事务请求访问该资源,InnoDB 会立即授予其访问权限。虽然理论上可以采用其他策略(例如基于事务 ID、时间戳或先前已锁定的资源来决定),但 InnoDB 的做法很简单:对于无竞争的资源,直接将其授予第一个提出请求的事务。
然而,当某个事务结束并不再需要某项资源时,我们便有机会将该资源的访问权限授予其他正在等待它的事务。问题在于:该选择哪一个事务呢?这正是服务器在决定串行化顺序和系统性能时所拥有的灵活性所在。
方法 1:FIFO(先进先出)
最简单且效果较好的方法是将访问权限优先授予等待时间最长的事务,这可以通过使用"先进先出"(FIFO)队列来实现。每个资源都对应一个这样的 FIFO 队列。当事务请求访问某个资源时,我们首先检查是否可以立即授予该权限。这一步可以很简单,只需扫描当前持有该资源访问权限的事务列表,检查是否存在冲突即可。然而,为了避免"饥饿"现象(即某些事务长时间无法获得资源),我们也需要检查队列中排在我们之前的事务。正如我们在《InnoDB Data Locking -- Part 2 "Locks"》中所见,检查两个锁请求之间是否存在冲突的规则可能相当复杂,但最终我们能够判断新请求是应立即授予还是必须等待。如果需要等待,我们会将请求追加到该资源对应的队列末尾。
当事务结束时,我们会逐一释放其持有的锁。每释放一个锁,都会检查相应的等待队列,并按照 FIFO 顺序逐一考察等待者,判断是否可以向其授予锁。请注意,可能没有任何等待者符合获得授予条件(例如,如果当前事务持有共享锁,而仍有其他事务持有该访问权限),也可能同时有多个请求获批(例如,如果当前事务持有资源的排他锁,而有多个事务在等待该资源的共享锁)。此外,向队列中某个等待者得请求授予后,可能会导致后续等待者的请求无法被授予(例如,如果队列前部的某个等待者需要排他锁),这正是队列顺序至关重要的原因。
该算法曾多年作为 InnoDB 的默认机制,直到后来通过与学术界的合作,引入了下文所述的改进措施。
感知方差的事务调度【Variance Aware Transaction Scheduling】
密歇根大学的 Jiamin Huang、Barzan Mozafari、Grant Schoenebeck 和 Thomas Wenisch 在其论文《识别事务延迟波动的主要来源:迈向更具可预测性的数据库》("Identifying the Major Sources of Variance in TransactionLatencies: Towards More Predictable Databases")中,提出了一种旨在最小化由调度引起的延迟波动的方法。该方法指出,尽管 FIFO(先进先出)策略表面上看似公平------即新到达的事务不会插队超越队列中已有的事务------但它并未将"旧事务应优先于新事务"这一理念贯彻到底:即队列中的顺序应当由事务的"年龄"(即事务在系统中已停留的时间)来决定。要理解 FIFO 并非真正按"年龄"对事务进行排序,只需考虑这样一个事实:单个事务在其生命周期内可能会请求多种资源,从而进入多个队列;而在每个队列中,即便该事务此前已耗费了大量时间,它仍会被视为"新来者"对待。为了实现更公平的调度,他们在提出的补丁中加入了一项机制:在向等待中的事务授予锁之前,会根据事务的"出生时间"(即进入系统的时刻)重新排列它们的顺序(参见代码第 2723 行)。
据我所知,这一版本的方案并未被纳入 MySQL 的正式发布版本中,因为随后密歇根大学的研究人员又提出了一项新的改进方案。
感知竞争的锁调度【Contention-Aware Lock Scheduling】
在接下来的论文《面向事务数据库的竞争感知锁调度》("Contention-Aware Lock Scheduling for TransactionalDatabases" )中,Boyu Tian、Jiamin Huang、Barzan Mozafari 和 Grant Schoenebeck 提出了一种基于不同标准对等待者进行排序的思路:即考虑有多少(其他)事务因某个事务必须等待而被(传递性地)阻塞。您看,可能发生这样的情况:一个正在等待的事务在开始等待某个资源之前,已经累积了大量资源的请求权限,而现在其他事务必须等待它释放这些资源。如果我们强制让这个事务等待,我们也就间接地强制了所有其他等待它的事务等待,这意味着额外"单位等待时间"的影响会被其身后等待队伍的大小所放大。
从概念上讲,这类似于为每个事务分配一个"权重",该权重正比于等待它的事务的"子树"大小,以及等待那些等待它的事务的事务大小,依此类推。"等待图"(wait-for graph)的概念在《InnoDB 数据锁定 -- 第 3 部分:"死锁"》中有描述,但粗略地说,您可以将等待事务想象为带有指向导致它们等待资源的事务的箭头。总体上,该图是一个有向无环图,而不是树,因此"权重"应该是"子图"的大小,而不是"子树"的大小。
您也可以在我们之前的文章《竞争感知事务调度即将登陆 InnoDB 以提升性能》( "Contention-Aware Transaction Scheduling Arriving in InnoDB to Boost Performance")中了解更多相关信息。
这就是随 MySQL 8.0.3 发布的算法,其缩写名称 CATS(Contention Aware Transaction Scheduling,竞争感知事务调度)更加简洁。
直接以高性能且正确的方式实现论文中的想法遇到了一些困难。起初,在等待图的边出现或消失时,通过增加或减少每个事务的权重来跟踪其权重似乎相当容易------人们很自然地会假设只需将已计算出的一个事务的权重加到另一个事务上,复用已有计算结果即可。然而,仔细思考后您会发现,不仅需要将权重的更新传播到从边的端点可达的所有节点(它们的权重也发生了变化!),而且甚至更新的具体值应该是什么都不清楚!您看,"权重"的定义取决于"从 A 能否到达 B?",而不是"从 A 到 B 有多少条不同的路径?"。对于树来说,这个区别可能无关紧要,但对于更复杂的图,其中一个节点到另一个节点可能存在多条路径,因此,增加或删除一条边是否会改变可达性并不一目了然。例如,很可能出现这样的情况:从权重为 5 的节点添加一条新边到另一个节点,并不会带来 5 个新的追随者,而只带来,比方说,3 个新追随者,因为另外 2 个已经可以到达该目标节点。类似地,删除一条边并不一定意味着两侧节点之间的连通性就丢失了。
人们很容易忽略这种差异(即"能到达我的节点数"与"能到达我的路径数"之间的差异),并认为在实践中这并不重要。出于性能原因,最初的实现确实正是这样做的。是的,我们当时进行的是加减运算,所操作的数字并非论文中定义的精确"权重",但它们似乎仍然与给定事务引起的"竞争"程度(共同)相关。是的,您可以构造出路径数量呈指数增长的等待图,从而可能导致溢出,但这在实践中很少发生,并且可以通过钳制(clamping)数值来修补。
然而,这个简化实现中潜伏着一个更微妙的缺陷:它没有考虑到图本身会随时间变化,并且边出现时您加上的数字,在边消失时不一定能减掉同样的数字,这可能导致令人意外的结果,例如负权重或溢出。这些问题同样通过钳制得以修复,但越来越明显的是,要获得"真正的"值,应该从头重新计算所有值,而不是进行点更新。
然而,从性能角度来看,这样做代价过高:每次边出现或消失时,以拓扑顺序处理整个等待图,并为了"更公平"的调度而暂停整个世界,将是一个糟糕的权衡。幸运的是,我们可以复用快速死锁检测所需的思想和实现,从而促成了我们今天所拥有的解决方案......
Atomic CATS!
正如《 InnoDB Data Locking -- Part 3 "Deadlocks》一文所述,代码中现已包含一个机制,用于对事务间的"等待关系"(waits-for relation)进行精简后的快照捕获。该过程在后台线程中执行,无需暂停整个系统(即无需"stop-the-world")。这意味着我们可以基于此快照以极低的代价估算每个事务的"权重"。虽然计算出的数值与论文作者最初设想的并不完全一致------因为在精简后的图中,每个节点最多只有一条出边------但在实际应用中,这种近似已足够有效。此外,由于该值总是从零开始重新计算,因此即便存在误差,也不会随时间推移而累积(旧实现中曾因此导致数值下溢或上溢)。计算出的值使用 C++ 原子操作(atomically)无锁地(latch-free)存储于事务对象中,并在对等待事务进行排序之前,也是通过同样的方式读取这些值。
这一变更意味着,执行事务的线程不再需要仅仅为了更新图中整个"下游"部分的权重而暂停系统运行;这消除了此前的一个严重瓶颈,并使 CATS 算法具备了实际应用价值。过去,我们曾采用一种启发式策略在 CATS 和 FIFO 之间进行选择,因为在某些场景下 CATS 运行过慢,不值得使用。有了原子 CATS(Atomic CATS),我们终于可以弃用 FIFO 实现,从而在所有情况下都使用 CATS。(你可以通过注释掉 lock_wait_compute_and_publish_weights_except_cycles 和 lock_wait_update_weights_on_cycle 函数,利用 CATS 来"模拟" FIFO 行为,这将导致所有事务拥有相同的权重------不过我们始终未为此提供专门的开关,因为在我们的测试中 CATS 的表现始终更优。)
你可以通过查询 INFORMATION_SCHEMA.INNODB_TRX 中的 TRX_SCHEDULE_WEIGHT 列来查看当前计算出的权重值(注意不要将其与 TRX_WEIGHT 列混淆,后者用于确定死锁发生时权重最小的牺牲者事务)。对等待者(waiters)进行排序时,利用了这样一个事实:权重为 1 是非常普遍的情况(即没有其他事务在等待该事务)。因此,在执行 O(NlgN) 复杂度的排序之前,我们先进行一次 O(N) 复杂度的扫描,将所有权重为 1 的事务筛选出来------这些事务应当在 schedule_weight > 的事务之后处理。但通常这意味着根本没有什么需要排序的了。
但这远非锁系统(Lock System)所有潜在优化的终点。恰恰相反!这仅仅是一个先决条件,它使我们能够着手解决一个更为重大的问题------锁系统的可扩展性。这正是下一部分内容------《InnoDB Data Locking -- Part 5 "Concurrent queues"》------的主题。