事务:原理篇
锁机制
注:在介绍具体是如何采用锁实现隔离机制之前,先来看看有哪些常见锁
全局锁
全局锁就是对整个数据库上锁,让整个数据库处于只读的状态:处于只读状态听起来就比较危险,如果主库处于只读状态那么就会导致整个系统的业务停摆;如果从库处于只读状态,那么就会加大主备延迟,但是全局锁依然有其用处
最主要的目的就是实现数据库的全量备份,如果在备份各个表时不添加全局锁那么就可能导致数据不一致性
这里的数据不一致性可能不是特别好理解,这里还是通过例子来解释下到底怎么出现不一致性的:
- 现在分为两张表,第一张是记录用户账户余额的表,第二张是记录用户购买的课程的表
- 如果现在正在对余额表进行备份同时有用户正在购买课程
- 那么就会导致用户余额的备份是之前的值,而购买课程的表却新增了课程,相当于一分钱没花就买了课程
补充:可是 MVCC 机制不是能通过一致性读保证备份数据时的一致性吗?
MVCC 确实可以确保事务在备份的过程中看到的数据是不变的,但是并不是所有的存储引擎都支持事务,所以全局锁依然有存在的必要
直接使用 FTWRL (flush table with read lock) 命令就可以对整个数据库上锁
不要直接使用 set global readonly = true 这条命令将数据设置为只读:
只读属性常用于在主从同步中区分主库和从库的角色,轻易改动可能在分发请求时将查询请求也分发到主库去
只读属性只能被手动修改,也就是说某个线程在设置完成后突然断开就会导致其余更新线程全部阻塞
全局锁就可以在线程突然断开之后自动释放锁,确保其余线程可以继续使用数据库而不会阻塞
表级锁
注:之后采用锁机制实现的隔离级别都是使用的行锁或者表锁,不会使用全局锁的
简单来说就是对整个表上锁,并且会细分为表锁和 MDL (meta data lock) 两种锁:
这两种锁都可以细分为读锁和写锁,读锁和读锁之间是不冲突的,读锁和写锁以及写锁和写锁之间肯定是冲突的
全局锁上锁的粒度太大,并发性太差,所以需要粒度更加细的锁
MDL 锁不需要显式使用,只要在修改表的字段的时候就会默认获取 MDL 写锁,对表进行增删改查的操作旧货默认获取 MDL 读锁
因为 MDL 写锁和 MDL 读锁是冲突的,那么就会在修改表的字段的时候其余线程对表的增删改查的操作全部都会被阻塞,这也就是为什么在修改表的字段的时候可能导致整个数据库挂了
表锁是需要显式使用的,不过使用也是比较简单的
添加读锁: lock table t1 read;添加写锁:lock table t2 write
如果给某个表加读锁,那么当前线程就只能够读取该表,不可以读取其他表,但是其他线程依然可以读取该表
如果给某个表加写锁,那么当前线程是可以读写该表的,但是不可以读写其他表,其他线程也是不可以读写该表的
行锁
注:行锁是 InnoDB 存储引擎特有的,其他存储引擎不一定有
简单来说就是对表中的某一行数据加锁:
- 如果是对该行记录进行修改那么就加写锁
- 如果是对该行记录进行读取那么就只加读锁
普通的行锁是隐式使用的,也就是由数据库自行添加的,当前读的行锁可以显式使用也可以隐式使用
select * from table lock in share lock:查询的时候添加读锁
select * from table for update:查询的时候添加写锁
update,insert,delete:这几条语句都会自动添加写锁
这几种方式也被称为当前读,会无视 MVCC 启动的视图而是直接读取当前数据的最新版本,所以称为当前读
注:这里非常重要,和之后的多版本并发机制是存在相关性的
使用行锁的目的,显然可以更加细粒度的控制事务的并发,并发性能会更好
隔离级别
注:在了解锁的相关特性之后,再来看看隔离性是如何采用锁机制的实现的
锁机制实现事务隔离级别的核心就是通过控制写锁 (X 锁) 和读锁 (S 锁)的释放时机来实现的
实现事务隔离级别加的都是行锁,不是表锁
读未提交(Read Uncommitted)
- 事务的写操作加写锁,读操作不加锁,并且写锁会在事务提交之后释放
- 因为读操作不需要获取锁,那么写锁是无法阻塞不需要获取锁的线程,导致可以读取到未提交的事务,出现脏读
- 因为写锁在事务提交之后才会释放,所以是不会导致回滚覆盖问题的
读已提交(Read Committed)
- 事务的写操作和读操作都会加锁,不过读锁在读取之后就会立刻释放,写锁依然要等到事务提交才释放
- 因为现在读操作需要获取锁了,那么读操作就可能被写操作阻塞从而避免读取到未提交的事务,也就避免脏读
可重复读(Repeatable Read)
- 事务的写操作和读操作都会加锁,两个锁都是在事务提交之后才会释放
- 因为读锁要等到事务提交后释放,那么想要修改记录的事务都无法执行,数据也就不会变化,避免了不可重复读
串行化(Serializable)
- 非常简单,就是对整个表加锁从而让事务串行,显然可以解决所有问题
采用锁机制实现的可重复读是无法解决幻读的,之后的 MVCC 可以解决快照读下的幻读,但是当前读的幻读只能通过 next-key lock 来解决
幻读问题指的就是前后两次查询得到的记录数量不一致的情况,那么造成这种问题的主要来源就是 update 和 insert 语句
- update 造成幻读的情况可能是这样的:
- 首先事务 A 第一次执行查询仅得到一条符合条件的记录
- 然后事务 B 修改其他行使其符合事务 A 的查询条件:因为仅对符合条件的行加锁,所以其他行是不会加锁的,可以进行修改
- 最终就会导致事务 A 再次查询的时候得到两个符合条件的记录,造成幻读
| transaction A | transaction B |
|---|---|
| begin; | begin |
| select * from t where age = 24; | |
| update t set age = 24 where id = 2; | |
| select * from t where age = 24; |
- insert 造成幻读的情况就可能是这样的:
- 首先还是事务 A 第一次执行查询得到一条符合条件的记录
- 然后事务 B 插入一条新的符合条件的数据,这里是不会被阻塞的:新插入的数据在表中根本就不存在,所以也就没有办法上锁
- 最终就会导致事务 A 再次查询的时候得到两个符合条件的记录,造成幻读
| transaction A | transaction B |
|---|---|
| begin; | begin |
| select * from t where age = 24; | |
| insert t values into (3, 24); | |
| select * from t where age = 24; |
不过,其实是可以通过修改加锁规则来避免 update 造成的幻读问题:
- 解决 update 幻读: 将 select 扫描到的所有行都上锁而不只是将符合条件的,这样就可以避免 update 语句对数据进行修改
- 解决 insert 幻读:只可以通过间隙锁 (Gap Lock ) 来解决,因为根本没有办法对不存在的数据上锁嘛
update 可以通过行锁解决,insert 可以通过间隙锁解决,所以就将行锁和间隙锁结合起来从而提出 next-key lock 的概念用于解决幻读
传统的锁机制基本无法解决存在的幻读,之后引入的新的 MVCC 机制可以解决快照读下的幻读问题
在引入 MVCC 机制后确实可以解决幻读问题,但是一致性读取这个特性给事务的更新带来了困难,所以又提出了当前读的概念
在引入当前读的概念之后,却发现 MVCC 是没有办法解决当前读下的幻读问题,最终也就不得不引入 next-key lock 的概念来解决
注:如果不能理解这段逻辑的含义,可以先看看什么是多版本并发控制,然后再来理解这段话
多版本并发控制
MySQL 采用锁机制和多版本并发控制(Mutiple Version Control Concurrent MVCC)相结合的方式防止事务并发出现错误
MVCC 仅实现读已提交 (RC),可重复读 (RR) 两个隔离级别,其余两个隔离级别依然是采用锁机制去实现的
MVCC 能够通过 版本链 + 视图 + 可见性算法 三个部分确保解决这两个隔离级别下的 读-写问题(脏读,不可重复度,幻读),是一种无锁的解决方案,能够极大提升读-写操作的并发性能,但是对于写-写问题依然只能够采用锁机制去解决,所以 MySQL 是采用锁机制和 MVCC 相结合来解决事务并发出现的问题的
版本链
事务在执行过程中每次执行更新操作后,都会在回滚日志(undo log)中记录相反的操作,然后采用回滚指针将日志中的每行记录串联起来;如果想要获取该行数据此前的版本,只需要从链表头结点开始依次向后遍历并执行日志中的记录,就可以得到数据此前的版本,这些数据此前的版本也就组成了所谓的版本链
视图
在读未提交的隔离级别下无论其他事务是否提交,版本链中的数据都是可以读取的,所以不存在任何问题;但是在读已提交和可重复读的隔离级别下仅读取已经提交的事务而不读取未提交的事务,那么就存在有些数据版本是不可以被读取的,因为这些数据版本对应的事务还没有提交,所以就提出视图这个概念来解决这个问题
每个事务在启动的时候都会记录相应的视图或者叫快照,这个快照并不是直接记录整个数据库此时的数据,而是记录当前有哪些事务处于稳定状态(已提交),哪些事务处于活跃状态(正在执行),还有哪些事务还没有开始。
具体的实现方式:
- 将处于活跃状态的事务中的最小 ID 作为低水位,然后将当前系统中的事务的最大 ID 作为高水位
- 低水位到高水位之间的事务 ID 就是所有活跃的事务
- 低于低水位的事务 ID,那就是已经提交的
- 高于高水位的事务 ID,那就是还没有开始的
之后就可以利用可见性算法 + 视图判断版本链中的哪些数据版本是可以被读取的
这里需要注意一个问题:
- 如果采用的是 start transaction 启动事务:那么此时并不会立刻记录快照,会在第一个 DQL 执行的时候才记录快照
- 这也就意味着快照启动的时候,当前系统中的事务的最大 ID 不一定就是自己
- 如果采用的是 start transaction with snapshot 启动,那么就会立刻记录快照,当前系统中的事务的最大 ID 就是自己
可见性算法
如果某个事务 ID 比视图维护的低水位还要小,那么就认为该事务已经提交,自然该事务修改的数据版本都是可见的
如果某个事务 ID 比视图维护的高水位还要大,那么就认为该事务还没有开始执行,不可以读取该事务修改的数据版本的
注:这里只是当前事务的快照认为高水位之外的事务没有开始执行,实际有没有开始那不一定
如果某个事务 ID 恰好在低水位和高水位范围内,那么就认为这个事务是活跃的,但是并不代表这个事务修改的数据可见
- 如果这个事务 ID 恰好在视图记录的事务 ID 数组中,那么就认为这个这个事务还在执行,修改的数据就不可见
- 如果这个事务 ID 不在视图记录的事务 ID 数组中,那么就认为这个事务已经提交了,修改的数据就是可见的
这个地方不是很好理解,举个例子:
- 视图启动的时候,记录的事务 ID 在 [1,2,3,5] 范围内,此时 ID = 4 的事务确实在这个范围内,但是并不在视图记录的事务 ID 中
- 所以认为这个事务已经提交,对于当前事务来将就是可见的,也就可以读取这个事务关联的数据版本
- 至于为什么会不在,其实还是因为开启事务的方式造成的
- 如果以 start transaction 开启,在记录快照的时很有可能 ID = 4 的事务已经提交了,所以自然就不在数组中了
隔离级别
事务中的查询语句会先从最新的数据版本开始读取:
- 如果读取到的数据版本对应的事务 ID 大于高水位,那么就认为这个数据版本是不可见的,就继续看下个数据版本
- 如果下个数据版本对应的事务 ID 在视图记录的事务 ID 数据组中或者小于低水位,那么这个版本就是可见,就会直接读取这个版本
- 如果这个版本依然不满足可见性,那就继续读取下个数据版本直到到达版本链的末尾
这就能确保读取到的数据是一致的
每个事务在更新数据前都需要先读取相应的数据版本,此时肯定不可以根据可见性算法来读取,这是因为如果每个事务读取到的数据版本都不同,那么就会导致之后的更新也都是在不同版本上进行的,那么最终提交的时候到底以哪个数据版本的更新为准呢?
最终的解决方案就是引入当前读的概念,允许更新数据时直接读取最新的数据版本,然后直接在这个版本上更新,从而避免数据不一致的问题
事务中执行 select 时是不会加锁的,所以读取到的只能够是视图范围内可见的数据
事务中执行 update,insert,delete 时就会强制读取最新的数据版本并且对其上锁,然后在最新的数据版本中更新
注:因为在更新的过程中需要上锁,所以这里肯定是造成阻塞等问题的
DML 语句可以自动开启当前读,也就是自动添加行锁;DQL 语句就只能够手动开启当前读模式,主要通过下面这两种方式
select from table lock in share mode
select from table for update
读已提交:如果其他事务已经已经提交,那么当前事务就可以读取它们的数据
可重复读:无论其他的事务是否已经提交,当前事务都是不可以读取它们的数据
主要在实现中有两个区别:
- 两者都是采用 MVCC 实现:不过读已提交是每次执行 DQL ( select ) 都会生成相应的视图,也就是读已提交的视图会发生更新,所以可以读取到最新已经提交的数据;但是可重复读仅在第一次执行 DQL ( select ) 时候生成视图,之后所有的语句都在这个视图的基础上进行,所以是无法读取最新提交的数据
- 原本两者在 SQL 规范中都是无法解决幻读问题的:不过可重复读通过 MVCC 可以直接避免快照读下的幻读问题,但是不能解决当前读下幻读问题,对于读已提交来说就是根本无法解决幻读问题
补充:为什么 MVCC 仅能够解决快照读下的幻读问题,而无法解决当前读下的幻读问题?
注:网上有很多说 MVCC 无法解决幻读问题的都是建立在当前读的前提下的
本身 MVCC 其实已经可以避免快照读情况下的幻读问题:因为只要当前事务启动并且生成快照后,那么事务 ID 比自己大的事务对于当前事务来说就是不可见的,毕竟这些事务要么就是未开启的要么就是处于活跃状态,所以当前事务是根本不可能读取到其他事务提交的数据的
可是问题在于事务在执行更新时必须要在最新的数据版本基础上更新,那么就迫使 MySQL 必须区分查询和更新的读取方式,也就是快照读和当前读,对于普通查询来说就是快照读,那么对于使用 lock in share mode 和 for update 的查询来说就是当前读,所以只要在事务中使用当前读那么就是可以获取当前最新的数据的,那么如果其余事务使用 update / insert 就会造成幻读问题
注:其实幻读出现逻辑基本和锁机制出现幻读的逻辑一致,这里就只列出例子,并且具体的解决方式也和锁机制一致
- update 造成幻读问题:
| transaction A | transaction B |
|---|---|
| begin; | begin |
| select * from t where age = 24 for update; | |
| update t set age = 24 where id = 2; | |
| select * from t where age = 24 for update; |
- insert 造成的幻读问题
| transaction A | transaction B |
|---|---|
| begin; | begin |
| select * from t where age = 24 for update; | |
| insert t into valuse (3, 24) | |
| select * from t where age = 24 for update; |
补充:间隙锁 (Gap Lock) 和 临键锁 (Next-Lock Key)
注:update 造成的幻读问题就是通过给 select 扫描到的每行数据都添加行锁来解决,不在间隙锁的解决范围内
简单来说,间隙锁 (Gap Lock ) 就是给扫描到的所有行数据之间的间隙上锁,从而避免其他事务向这些间隙中插入新的数据,最终避免当前读造成的幻读问题
具体来讲就是给 where 条件中使用到的索引之间添加间隙锁,举个简单的例子
- 如果扫描到行数据的索引分别是 (5, 10, 15),那么就会在这三个索引之间添加两个间隙锁,从而避免向这两个间隙中插入数据,
- 但是如果插入数据的索引不在这个间隙的范围内,那么依然可以插入成功
- 此外,间隙锁是不存在独占或者共享的概念的,所有事务都可以同时获取某个间隙中的锁,并不会相互排斥
临键锁 (next-key lock) 其实就是 行锁 + 间隙锁 的结合,也就是说在扫描过程中不仅会在索引间隙添加锁,也会给扫描到的行加锁,所以加锁的区间就变成左开右闭了,也就是锁住的区间是 (5, 10],(10, 15],更加详细来讲 next-key lock 规则是这样的:
next-key lock 基本原则就是将查询过程中扫描到的所有对象都添加 next-key lock,这里的对象指的就是索引列和扫描到的在表中存在的行
next-key lock 还会采用相应的优化策略:
- 如果是在唯一索引上进行等值查询并且能够查找到相应的行,那么 next-key lock 就会直接退化成行锁;如果没有查询到相应的行,那么 next-key lock 就会退化成间隙锁
- 退化成行锁是因为唯一索引只可能查询得到一条符合条件的数据,即使添加或者修改其他数据也不会影响当前查询的结果,毕竟唯一索引是不能冲突的
- 退化成间隙锁是因为最后扫描到的行肯定是不满足条件的,所以也就没有必要给最后一行添加行锁
- 如果是在普通索引上进行等值查询且查询的结果存在,除了前面扫描到的行要添加 next-key lock 那么会额外添加一把间隙锁 next-key lock;如果查询的结果不存在,那么就只有一把 next-key lock 并且会退化为间隙锁
- 额外添加一把间隙锁的原因是因为在使用普通索引扫描的时候,必须遍历到下一个不满足条件的行为止,那么既然已经遍历到这个不满足条件的行了就会相应的添加间隙锁
- 退化的原因也和唯一索引相同
- 如果在查询时使用了覆盖索引,那么就只会在覆盖索引上添加 next-key lock,而不会在主键上添加 next-key lock, 也就是说你可以继续在主键中进行更新和插入;但是如果在查询时添加了 for update 那么就会顺遍在主键给所有满足条件的行添加行锁,与之相对的 lock in share mode 是不会添加的
- 如果没有使用任何索引,那么就会直接全表扫描,会将所有行都添加 next-key lock,所以这就会导致整个业务停滞
- 如果是在唯一索引上进行等值查询并且能够查找到相应的行,那么 next-key lock 就会直接退化成行锁;如果没有查询到相应的行,那么 next-key lock 就会退化成间隙锁
