DBMS/数据库(八)并发控制

199 阅读9分钟

并发控制

只要存在并发读写,那么必然需要并发控制,如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,那么创建新版本指向上一个版本
  • 读时:
    • 根据当前事务IDrow隐藏字段,选择可见的最新版本;(不同的DBMS可见性判断不同,简单实现为:Read 时判断当前事务ID/时间戳是否大于row的txn-id
      1. row的txn-id不存在则直接读取
      2. row的txn-id等于事务ID则直接读取
      3. 事务ID小则一定不可见
      4. 事务ID大则判断row的txn-id是否提交
        1. 提交则直接读取;
        2. 未提交则忽略
在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;