学习引用文章:
Database · 理论基础 · B+树数据库加锁历史
学习笔记仅为个人学习参考总结,内容基本来自于原文及其他额外资料的参考,阅读前请先移至原文参读,同时这里十分感谢原作者的文章分享。
目的:并发控制
并发控制是数据库中的重点及难点,简单描述:并行执行的事务可以满足某一个隔离性级别,也就是下面四个隔离级别标准:
- READ UNCOMMITTED:未提交读
- READ COMMITTED:已提交读
- REPEATABLE READ:可重复读
- SERIALIZABLE:串行化
实现的基本方法:对事务的操作进行冲突检测,对有冲突的事务进行延后处理或者丢弃。
根据检测冲突的时机简单分为三类:
- 事务开始前检测-基于Lock的方式;
- 写数据时检测-基于Timestamp的方式;
- 事务提交时检测-基于Validation的方式。
越晚的冲突检测代表对冲突的发生越乐观,同时并发性越高;而不同方式的采用根据不同的使用场景决定。
Lock-加锁策略变迁
前提说明
基于B+Tree
B Tree在结构上有很多变种,这里基于B+Tree进行分析探讨,也就是InnoDB引擎中使用的数据结构。
特性:
- 叶子节点存储元数据信息,非叶子节点存储索引指针信息;
- 除根节点外,每个节点的键值对个数介于M/2和M之间,超过则分裂,不足则合并(可能会向上传导);
- 叶子节点之间相连为有序列表结构。
优点:
- 扫表能力强
- 磁盘读写能力强
- 排序能力强
Lock Mode
Lock通常有不同的Mode,像读锁(shared locks:S)、写锁(exclusive locks:X)、意向共享锁( intention shared lock :IS) 、意向排他锁(intention exclusive lock :IX)等,不同Mode的锁之间会有一定的兼容性。
X | IX | S | IS | |
|---|---|---|---|---|
X | 冲突 | 冲突 | 冲突 | 冲突 |
IX | 冲突 | 兼容的 | 冲突 | 兼容的 |
S | 冲突 | 冲突 | 兼容的 | 兼容的 |
IS | 冲突 | 兼容的 | 兼容的 | 兼容的 |
通常数据库中有调度模块负责控制资源的加锁及释放锁,所有的锁持有及等待信息记录在一个Table中,根据当前Table中的锁状况及Mode的兼容性来决策处理。
两个重要指标:
- 并发度
- 加锁开销
2PL(二阶段)
原理:事务持有从根节点到叶子节点路径上的所有锁,任何插入和删除操作都有可能导致树节点的分裂或合并(Structure Modification Operations, SMO)。
特点:根节点需要加写锁WL,即任何时刻只允许一个insert或delete操作的事务进行。
结果:满足事务并发控制,但是严重影响并发度。
Tree Protocol(树协议)
原理:利用树结构从root节点顺序访问的特性(加锁顺序一致),在二阶段的基础上,允许部分节点提前释放锁。
特点:Lock Coupling(锁联结)
- 先对root加锁
- 对下层节点加锁前必须持有其父节点的锁
- 任何时候可以放锁,但是释放后不能再次加锁
实现难点:对于B+树,一直搜索到叶子节点才可以判断是否发生 SMO 。两种情况:
- 悲观锁:对每个节点先加写锁,直到遇到一个确认不会发生 SMO 的Safe的节点。
- 乐观锁: 认为 SMO 并不是高频发生,只对每个节点加读锁,直到遇到叶子结点发生 SMO ,则把所有读锁升级为写锁(Upgrade Lock)。同时引入Update Lock Mode,只允许Update Lock升级且互相之间不兼容,避免同时持有同一个节点读锁的事务同时升级写锁时发生死锁。
B-link Tree
原理:对所有节点增加两个字段,一个为link pointer向右指针(link右兄弟节点,提供新的节点访问路径);另一个为high key,查询时如果目标值大于该节点的high key,则沿着link pointer继续往右节点查找。
特点:空间换时间,只对当前节点加锁,避免了前面两种方案的问题:为了避免节点 SMO ,必须持有父节点的写锁;降低因全部或部分节点的加锁消耗。同时避免其他线程在加锁子节点修改完成后但父节点修改前访问,导致按原父节点的顺序可能已经查不到目标值,但可以通过子节点的high key判断,进而顺着link pointer寻找到目标值。
结果:通俗的说锁的控制粒度更细了,锁开销降低了,并发性能提高了。
ARIES/KVL :键值锁
前面几个传统加锁策略都是对B+Tree中的节点加锁,通过降低单个事务需要同时持有的锁节点数来提升并发能力。
基于分层事务的解决方案提出:对Record而不是Page加锁。
ARIES/KVL出现:一种基于B Tree索引的多动作事务并发控制的键值锁方法。
前提逻辑:明确区分B+Tree的物理内容(数据结构)和逻辑内容(数据记录),数据记录改变的是逻辑内容,而节点的分裂及合并属于物理内容,所以可以解耦。
实现:对Record(数据记录)进行Lock,对物理内容则抽离出来通过Latch保护多线程下的安全,因为Latch不需要在整个事务的生命周期持有。
Latch:保护物理内容的锁(Page锁),作用对象为线程,意义上就是前面传统的加锁策略,等于将Lock换为Latch,用来避免 SMO 的问题。
Lock:保护逻辑内容的锁(Record锁),真正意义上的事务锁,加锁流程:
- 查询数据并持有Latch锁;
- 持有Lock的读锁或写锁;
- insert或delete操作导致树结构变化(SMO),处理完成后释放Latch。
- 提交事务并释放Lock锁。
Condition Lock加Revalidation的设计
问题:正常情况下在持有Latch锁并处理完成SMO后并释放前,需要获得Record Lock,但是这时候如果未获取到Record Lock,则不能释放Latch,但是一直让Latch等待,则和传统加锁策略并没有区别。
解决:先对Record加Conditional Lock(满足则会立即返回而不是阻塞等待,失败则会释放Latch再对Record添加Unconditional Lock来阻塞等待),但是后续获取到Record Lock后,由于Latch已经先行释放,不能保证SMO的安全处理,这个时候需要Revalidation,判断自己的需要的叶子节点及父节点是否发生变化,这就需要释放Latch之前记录这些节点的版本号,Revalidation的时候直接找到版本号没有变化的问题。
Key Range Locking:区间锁
场景及问题:基于Latch和Lock的方案,对单条Record的并发控制比较适用,但是实际中通常是条件范围内多条记录的并发操作,比如多条记录的insert或delete就会导致的幻读问题。
解决:
- 谓词锁(Predicate Lock):直接对查询条件加锁,判断及处理条件冲突开销很大。
- 区间锁(Key Range Locking):利用key有序的特点,对key及前后key或之间的范围区间加锁,像InnoDB中的间隙锁(Gap Lock,范围是左开右开)、临键锁(Next-Key Lock,范围是左开右闭,常见的区间锁实现,可以解决幻读的问题)
Instant Locking:瞬时锁(insert 优化)
场景及问题:由于Next-Key Lock会锁住区间及next key,所以在高频insert的场景下,整个事务的生命周期下,这种加锁设计也会导致明显的并发瓶颈。
解决方案:Instant Locking,在insert数据的瞬间持有Next Lock,同时获得锁后并不持有,而是直接释放,为了判断锁住的区间内有没有冲突的读操作,由于操作是在持有Latch锁的保护下进行,所以中间不会有其他事务进来。
Ghost Records:标识记录(delete 优化)
场景及问题:Instant Locking提高了insert操作的并发效率,但是delete之后,通常key就会消失,就没有办法继续采用这个方案了来提高了。
解决方案:Ghost Records,在每个记录中增加一个Ghost Bit位来标识是否删除,将delete操作变为update操作,其他事务查询这个key时会检查Ghost Bit,发现删除则跳过,同时回滚时修改Bit位即可。但是这些Record通常最终会通过后台线程异步处理删除的,这里Record的删除会导致左右两个Lock Range合并,所以删除合并过程除了需要当前的Page Latch之外,还需要合并的Range中的Lock的Page Latch保护。
另一种Ghost Record:Fence Key,在Page的末尾添加一个独立的Key值记录这个Page所在子树的分隔Key,实现上可以在Page Split的时候从Parent节点拷贝而来。这种做法最大的好处就是避免加Next Key Lock的时候对后继结点的访问需求。
其他方案简述
- ARIES/IM:将加锁对象从B+Tree的键值变为最终指向的数据,这样无论多少二级索引只需要一把锁。结果:降低锁开销也降低了并发度。
- KRL:将Range Lock中的Key和Gap的同时加锁分开,分别加锁,同时提出了更精确的Lock Mode,包括Intention Update,Intention Insert以及Intention Delete,通过区分操作类型来解决并发可能。结果:提高并发度同时增加了锁开销。
- Hierarchical Locking:在Table和Key之间增加更多的加锁粒度,通过对锁的分级来选择合适的加锁策略。
- Early Lock Release:提出在事务Commit Log落盘前释放锁,但是Commit Log落盘前的Crash会导致事务回滚,所以续事务还能早于之前事务的Commit,同时这种情况下由于Log的有序写入,所以写事务能够保证,但是读事务并不会记录Log,必须增加额外的机制阻止提前Commit,像《Efficient locking techniques for databases on modern hardware》中采用的对Lock加Tag以及《Controlled lock violation》中调度器对Lock Violation的检查。
总结
所有的方案都是围绕着提高并发度和降低锁开销的指标进行的,采用的手段通常有下面几种:
- 选择更细粒度的锁;
- 引入新的Lock Mode;
- 缩短Lock的持有时间。
身未动,心已远。
把一件事做到极致就是天分!