并发控制
只要存在并发读写,那么必然需要并发控制,如etcd、mysql、mongodb等都实现了并发控制,而redis等内存型的,通过单线程读写避免了并发问题;
并发控制协议
数据库是面向多用户的,因此事务会是并发执行的,所以事务并发过程中就会出现一些并发问题(脏读、重复读、丢失更新、幻读),而并发控制协议就是 通过并发控制协议 来解决这些问题;
- 基于锁的协议(悲观) ;通过锁并发控制协议来实现并发控制,保证事务的ACID特性;
- 基于时间戳的协议(乐观) ;通过时间戳并发控制协议实现并发控制;
- 基于多版本的协议(乐观) ;
- 基于验证的协议;基于验证的并发控制协议;
基于锁的协议
简单理解就是对事务中的操作加读写锁,复杂点在于什么时间加锁、什么时间释放;
在多种并发控制协议中,基于锁的协议是最常用的,因为事务本质上就是多个读写操作,并发的多组读写操作出现了并发问题,而通过锁可以轻易的保证多个事务的读写问题,保证写的互斥;但难点在于锁的申请和释放时机的选择,因为需要面对的不是一个操作的申请/释放锁,而是一组操作;
锁基础
基于锁的原理
在事务读写过程中,对不同的操作加不同类型的锁,从而达到事务并发执行的效果;
锁类型
- 互斥锁;互斥锁通常用于实现写锁等;
- 共享锁;共享锁通常用于实现锁重入、读锁等;
锁粒度
- 行级锁;并发的请求执行过程中,仅针对当前所请求的目标进行加锁,会大大降低锁粒度,如请求中涉及到2行读写,那么只需要对这两行数据加锁;
- 表级锁;针对当前表加锁,通常此方式会大大降低性能;
死锁
死锁是一个现象,多个事务尝试加锁过程中,如果已经持有了某个对象的锁后又再次尝试持有对方的锁,那么就会发生死锁,而2-PL协议很容易发生死锁;
解决方式:
- 等待超时;一旦出现死锁,那么等待一段时间后超时失败;(程序中经常会出现异常:locked fail)
- 代码避免;在编写事务时,事务之间不能形成循环依赖关系;
饥饿锁
饥饿锁是一个现象,多个并发事务线程竞争一个锁,某一个事务线程一直无法获取到锁;
解决方式:
- 公平竞争;使用队列,先尝试加锁的线程先被唤醒,后续的事务继续排队;
注意:这个现象实际上是也是惊群效应,唤醒了所有线程,导致所有线程都在竞争锁,同时也会占用CPU;
简单锁协议
略;
Pre-Claiming协议
略;
2-Phase-Lock协议
2PL即将加锁分为两个阶段,扩张与缩放,扩张阶段只能加锁,缩放阶段只能释放锁(事务完成前) ;只要事务满足这两个阶段,那就是2-PL协议;
注意2PL和2PC不同,2PC是用于分布式事务的2阶段提交协议,而2PL是用于单机事务的2阶段加锁协议;
缺点:简单的2PL协议会出现在缩放阶段时,但是释放锁的过程比较混乱;
严格2-PL协议
由于简单2-PL的问题,严格2-PL协议将释放互斥锁的过程放在了事务完成后(commit或rollback后),注意:释放共享锁的过程在事务完成前;
通常DBMS使用此协议实现,MySQL也是如此;
非常严格2-PL协议
基于严格2-PL之上,释放共享锁的过程也放到事务完成后;
基于时间戳的协议
乐观并发控制思想(没有乐观锁,乐观锁说法不正确),理论上认为事务不会发生冲突,只要有冲突那么就回滚,然后重新提交;
很少有DBMS使用此协议,乐观并发控制面对高并发的DBMS不合适;
时间戳基础
T-Time
每个事务在创建时都有一个时间戳,回滚后会重新生成一个时间戳;
D-Read-Time
每个数据都有一个读时间戳,保存的是最新读取该数据的 T-Time;
D-Write-Time
每个数据都有一个写时间戳,保存的是最新更新该数据的 T-Time;
时间戳排序协议
事务在执行时,比较T-Time和D-R-Time以及D-W-Time时间戳大大小,如果发生冲突,那么事务回滚,重新设置时间戳并提交;
- 当事务 Write 时:
-
- 如果T-Time小于 D-R-Time 或 D-W-Time ,那么回滚;简单来说,当事务 write 时,发现当前数据已经被其他事务所操作(因为数据的读写时间戳已经比当前事务大了),那么回滚;
- 如果T-Time大于 D-R-Time 且 D-W-Time,那么设置D-W-Time为T-Time,并继续执行;简单来说,当事务write时,发现这个数据还没有当前事务时间戳大,那么就说明当前时间没有其他事务在操作这个数据,然后修改数据时间戳为当前事务时间戳;
- 当事务 Read 时:
-
- 如果T-Time小于 D-W-Time,那么回滚;简单来说,当事务read时,发现当前数据已经被其他事务更新了(时间戳都比自身事务时间戳大了),那么就回滚;(注意:D-R-Time在read时不需要比较,读-读不会冲突)
- 如果T-Time大于 D-W-Time,那么设置D-R-Time为T-Time,并继续执行;
缺点:级联回滚问题,当事务1更新了A=100,此时事务2启动,读取了A=100(由于事务2时间戳大于A-W,因此可以获取成功)。如果事务1在后续发生了冲突或执行失败,进行回滚,那么事务2也必须回滚(因为A已经不再等于100,被回滚了);
注意:多个事务并发比较T-Time和D-Time时,怎么办?CAS吗?
基于验证的协议
略;
基于多版本+其他协议 (MVCC+other)
MVCC 基础
MVCC 是什么
MVCC是多版本并发控制思想,优化了并发读-写加锁的性能,使得读事务+写事务时不需要加锁;
注意:几乎没有任何数据库使用标准的MMVC实现,因为MVCC不是并发控制协议,不能保证写写时的并发安全问题,它只能解决并发读写安全问题;
读事务
事务中包含的都是读操作;
写事务
事务中包含了写操作;
可见性
读取数据时选择当前事务可见的最新版本,因此可见性判断是一个很重要的点,通常来说不同的数据库实现的方式可能不同;
如MySQL通过readview与row的隐藏字段做比较,计算出可见的一些版本;值得注意的是MySQL不是根据当前事务ID进行判断的,而是根据readview来计算可见性,而readview是在select时生成。
版本存储
在实现MVCC时,由于其多版本概念,因此DBMS需要同时存储多个版本。
- 使用一张表存储当前记录和其他版本,每一次的版本追加到表的末尾;
- 使用两张表,一张表是真实的记录,另一张表存储了记录的不同版本;
一些DBMS使用undo log结构存储多版本,通常存储在表空间中,如Oracle、MySQL等。
垃圾回收
MVCC需要实现多个版本,而当版本很多且一些版本过时时,就需要对这些过时的版本进行回收。
- 采用异步线程回收;DBMS实现专门的线程用于跟踪版本,过时的版本直接回收;
- 事务跟踪版本;谁创建的版本,那么由哪一个事务跟踪和回收;
索引管理
如果DBMS使用了一张表管理版本,那么就必须解决重复主键的问题。
MVCC 问题
MVCC 实现
TX-Id
每个事务在开启时DBMS都会为其分配一个ID,ID的值是一个时间戳或自增整数;
元组
每个元组(可简单理解为row)都有额外的四个字段,DBMS使用这四个字段来实现事务的并发控制。
- txn-id:最后 写 该元组的事务ID,通常通过CAS的方式更新;
- begin-ts:开始的事务,即当前元组是被谁创建的(每次写会创建新的版本);
- end-ts:结束的事务,即当前元祖是被谁结束的(创建新的版本时会结束旧的版本);
- pointer:指向旧版本的指针;
元组结构如下图:
基于 MVCC 的原理
每次写数据产生一个版本,而读数据时选择当前事务 可见的最新版本,利用多版本实现无锁;
- 写时:
-
- 如果txn-id 不为空且不等于当前事务ID,那么就阻塞(写-写阻塞) ,等待txn-id事务提交;
- 如果txn-id 为空或等于当前事务ID,那么创建新版本并指向上一个版本;
- 读时:
-
- 根据当前事务ID和row隐藏字段,选择可见的最新版本;(不同的DBMS可见性判断不同,简单实现为:Read 时判断当前事务ID/时间戳是否大于row的txn-id)
-
-
- row的txn-id不存在则直接读取;
- row的txn-id等于事务ID则直接读取;
- 事务ID小则一定不可见;
- 事务ID大则判断row的txn-id是否提交;
-
-
-
-
- 提交则直接读取;
- 未提交则忽略;
-
-
在DBMS上实现MVCC
任何DBMS如果想解决并发读写问题,那么一定需要考虑如何实现自己的并发控制,而采用MVCC思想时也必然要考虑以下4点:
- 并发控制协议;即如何设计基于MVCC的并发控制协议;
- 多版本管理;如何管理写操作而创建的多个版本;
- 垃圾回收;如何回收无效的版本;
- 索引管理;多版本如何与索引结合;
需要注意的是,可见性是MVCC的核心;
并发控制协议
- Timestamp Ordering (MVTO);
- Optimistic Concurrency Control (MVOCC);
- Two-phase Locking (MV2PL);
- Serialization Certifier;
MV2PL 协议(MVCC + 2-PL协议)
读写使用MVCC协议,写冲突使用2-PL解决;如MySQL;
MVTO 协议(MVCC + 时间戳排序协议)
读写使用MVCC协议,写冲突使用TO解决;如PGSQL;