0.写在前面
based on CMU 15-445/645 2020fall, Lecture #16 ~ Lecture #17.
1: 事务(transaction)
1.1定义
它是指一些列操作序列(一个或一个以上)当一个事务被提交给了DBMS(数据库管理系统),则DBMS需要确保该事务中的所有操作都成功完成且其结果被永久保存在数据库中,如果事务中有的操作没有成功完成,则事务中的所有操作都需要被回滚,回到事务执行前的状态(要么全执行,要么全都不执行);同时,该事务对数据库或者其他事务的执行无影响,所有的事务都好像在独立的运行。
1.2 ACID
1.原子性(Atomicity)
原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。【all or nothing】
确保原子性的机制:
1.Logging(日志):几乎所有的DBMS都会用。log会记录所有的actions,当 abort 时,可以根据 log 日志撤销操作。每一次在 Log 中新增操作时需同时持久化到硬盘中。
2.Shadow Paging : 当页要被更改的时候,新开一个页,在这个页上改完之后,替换掉原来的页。就是把对原来页的引用全部指向这个shadow page。当该事务 commit 之后,该 page 才对其他事务可见。
2.一致性(Consistency)
事务前后数据的完整性必须保持一致,也就是说一个事务执行之前和执行之后都必须处于一致性状态。举例来说,假设用户A和用户B两者的钱加起来一共是1000,那么不管A和B之间如何转账、转几次账,事务结束后两个用户的钱相加起来应该还得是1000,这就是事务的一致性。
3.隔离性(Isolation)
事务的隔离性是多个用户并发访问数据库时,数据库为每一个用户开启的事务,不能被其他事务的操作数据所干扰,多个并发事务之间要相互隔离。
确保隔离性的方法:
1.并发控制(Pessimistic|Optimistic)
Optimistic:假设事物的冲突是很少发生的,只有当事务 commit 的时候,才会检查是否存在冲突,确实是否需要 abort。适合多读少写的场景。通常基于 MVCC,timestamp 的并发控制是乐观模型。Pessimistic:假设冲突是经常发生,在一个事务开启的时候,就先检查是否存在冲突。适合少读多写的场景。通常基于锁的的并发控制是悲观模型。
4.持久性(Durability)
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响。
1.3 Schedule(调度)
1.3.1 Serial Schedule
指的是多个不同事务按顺序执行。性能很差,相当于数据库是单线程执行的(不过 Redis 好像就是单线程)。数据库并发控制存在的意义就是让并发执行的 schedule 的结果和你 serial schedule 执行的结果一样。
1.3.2 Equivalent Schedule
在任何数据库状态下,如果两个 schedule 执行后均能得到相同的结果,那么称这两个 schedule 为 equivalent schedule。
1.3.3 Serializable Schedule
如果一个 schedule 和一个 serial schedule 互为 equivalent schedule,那么称该 schedule 为 serializable schedule。
1.4 事务执行异常(3种)
Read-Write Conflicts (R-W) - Unrepeatable Reads(不可重复读)
Write-Read Conflicts (W-R) - Reading Uncommitted Data (“Dirty Reads”) - 脏读
Write-Write Conflicts (W-W) - Overwriting Uncommitted Data
DEPENDENCY GRAPHS(依赖图),有点像拓扑排序
1.5 事务隔离的级别
Read uncommitted(未授权读取、读未提交):
如果一个事务已经开始写数据,则另外一个事务则不允许同时进行写操作,但允许其他事务读此行数据。该隔离级别可以通过“排他写锁”实现。这样就避免了更新丢失,却可能出现脏读。也就是说事务B读取到了事务A未提交的数据。
Read committed(授权读取、读提交):
读取数据的事务允许其他事务继续访问该行数据,但是未提交的写事务将会禁止其他事务访问该行。该隔离级别避免了脏读,但是却可能出现不可重复读。事务A事先读取了数据,事务B紧接了更新了数据,并提交了事务,而事务A再次读取该数据时,数据已经发生了改变。
Repeatable read(可重复读取):
可重复读是指在一个事务内,多次读同一数据。在这个事务还没有结束时,另外一个事务也访问该同一数据。那么,在第一个事务中的两次读数据之间,即使第二个事务对数据进行修改,第一个事务两次读到的的数据是一样的。这样就发生了在一个事务内两次读到的数据是一样的,因此称为是可重复读。读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务。这样避免了不可重复读取和脏读,但是有时可能出现幻读。(读取数据的事务)这可以通过“共享读锁”和“排他写锁”实现。
Serializable(序列化):
提供严格的事务隔离。它要求事务序列化执行,事务只能一个接着一个地执行,但不能并发执行。如果仅仅通过“行级锁”是无法实现事务序列化的,必须通过其他机制保证新插入的数据不会被刚执行查询操作的事务访问到。序列化是最高的事务隔离级别,同时代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻读。
隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。对于多数应用程序,可以优先考虑把数据库系统的隔离级别设为Read Committed。它能够避免脏读取,而且具有较好的并发性能。尽管它会导致不可重复读、幻读和第二类丢失更新这些并发问题,在可能出现这类问题的个别场合,可以由应用程序采用悲观锁或乐观锁来控制。大多数数据库的默认级别就是Read committed,比如Sql Server , Oracle。MySQL的默认隔离级别就是Repeatable read。
1.6 悲观锁和乐观锁
虽然数据库的隔离级别可以解决大多数问题,但是灵活度较差,为此又提出了悲观锁和乐观锁的概念。
1、悲观锁
悲观锁,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制。也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统的数据访问层中实现了加锁机制,也无法保证外部系统不会修改数据。
2、乐观锁
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以只会在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回用户错误的信息,让用户决定如何去做。实现乐观锁一般来说有以下2种方式:
1.使用版本号
使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。
2.使用时间戳
乐观锁定的第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。
2: Two-phase Locking(两段锁)
2.1 锁的基本类型(共享锁和排他锁)
2.2 两段锁的定义
1.Growing:txn从lock manager中获得lock。
2.shrinking:这一阶段txn只允许释放之前获得的锁。而不允许加上新的锁。(只减不增)
2.3 两段锁的例子
2.4 两段锁的缺点
会出现2个问题:1)脏读 2)deadlock(死锁)。
事务T1中对A进行写操作-W(A),T1 abort之后,T2 中R(A),读取A的值。这就是dirty read,简单的二段锁无法解决这个冲突,而且会导致T1 abort,T2 也abort,想多米诺骨牌一样,连续下去。
2.4.1 解决脏读问题
例子:T1、T2两个事务
NON-2PL EXAMPLE
2PL EXAMPLE(2PL的核心点是T1提前获得X-Lock(B),因为进入shrink阶段就无法获得锁。也就说获得锁和释放锁是分开的。)
STRONG STRICT 2PL EXAMPLE(strong 2PL核心点是要等T1 commit事务以后T2才能read,write,这就可以避免脏读的情况)
STRICT 2PL 可以解决普通 2PL 会产生脏读和 cascading rollback 的问题。
STRICT 2PL 规定在 Shrinking Phase 阶段,只有到 commit 或 abort 时才会一次性释放全部锁。
2.4.1 解决死锁的问题
根据死锁的八股,解决死锁分为四个手段:
死锁避免,通过破坏死锁产生的4个必要条件,使其永远不会产生死锁。(不现实,那还要锁干啥)
死锁预防,在执行操作的时候检查资源,比如银行家算法,确保这个请求不会产生死锁。
死锁检测与恢复,允许程序进入死锁状态,然后检测是否有死锁(用图判断有没有环即可),然后进行恢复操作。
鸵鸟策略,不管。(不现实)
死锁:死锁就是事务之间形成依赖成环,彼此等待对方释放锁。后台会对lock manager进行死锁检测,wait-for graph(the same as 依赖图),判断环。
1.死锁处理 —> select victim and rollback
根据一定的原则选择一个牺牲者,对其进行 abort 或 restart 。(可以是年龄最小、最大、或者是最近最少使用等等原则)。然后决定回滚多少关于这个事物的改变。
2.死锁预防(可以不需要死锁处理)
例子:T1等待T2中X-LOCK(A)锁的释放。
如果是wait-die模式,优先级高的要等待优先级低的释放。所以T1就会等待。
如果是wound-wait模式,T2就是aborts。
2.5 intention lock(意向锁)
数据库通常采用了树状的锁管理结构。
但是我们要考虑一种情况,假设
T
1
T_1
T1在 Attr 1 上面有 X 锁,此时
T
2
T_2
T2需要遍历整一个数据库,也就是在 Database 上面上 S 锁。那么
T
2
T_2
T2需要先遍历整个树,来判断是否存在和它冲突的锁,这会耗费大量的时间。因此引入 intention lock 来解决这个问题,intention lock 相当于在一个被操作节点的所有父节点上添加了一些额外信息,帮助其他事务不需要遍历整个 lock 树来判断是否存在冲突锁。
每一个节点上都可以加锁(Database,Table 等等均可),且一个节点上只要不冲突可以同时加多个锁。
2.5.1 Intention Lock 种类
Intention-Shared(IS):表示该节点下的子节点存在 S。
Intention-Exclusive(IX):表示该节点下的子节点存在 X。
Shared+Intention-Exclusive(SIX):表示该节点被加了 S,且其下面子节点存在 X。
2.5.2 加锁规则
1)每一个 txn 获得锁的顺序都是从上到下,释放锁的顺序是从下到上。
2)一个节点获得 S 或 IS 时,其所有父节点获得 IS。
3)一个节点获得 X 或 IX 或 SIX 时,其所有父节点获得 IX。
如果没有意向锁则是这样的:
step1:判断表是否已被其他事务用表锁锁表
step2:判断表中的每一行是否已被行锁锁住。
如果没有意向锁的话,则需要遍历所有整个表判断是否有行锁的存在,以免发生冲突
那么如果增加了意向锁之后:
step1:判断表是否已被其他事务用表锁锁表
step2:发现表上有意向共享锁,说明表中有些行被共享行锁锁住了,因此,事务B申请表的写锁会被阻塞。
如果有了意向锁,只需要判断该意向锁与即将添加的表级锁是否兼容即可。因为意向锁的存在代表了,有行级锁的存在或者即将有行级锁的存在。因而无需遍历整个表,即可获取结果。
意向锁的作用:实现表级锁和行级锁共存。
参考:
https://www.cnblogs.com/JayL-zxl/p/14627578.html
https://zhuanlan.zhihu.com/p/464283526