半步多 玄玉的博客

研读MySQL02之事务原理与锁机制

2022-01-22
玄玉

事务原理

事务特性

  • A(Atomicity原子性):全部成功或全部失败
  • I(Isolation隔离性):并行事务之间,互不干扰
  • D(Durability持久性):事务提交后,永久生效
  • C(Consistency一致性):通过 AID 保证

隔离级别

  • Read Uncommitted(读未提交):最低隔离级别,会读取到其他事务未提交的数据
    即其他事务update操作commit之前,它就能读到update之后的结果,若最后update回滚了,那它又读到之前的结果
  • Read Committed(读已提交):事务过程中可以读取到其他事务已提交的数据
    这是Oracle默认隔离级别,即只要其他事务未commit,那读到的都是之前的结果,只有commit后读到的才是新结果
  • Repeatable Read(可重复读):每次读取相同结果集,不管其他事务是否提交
    这是MySQL默认隔离级别,是依赖MVCC(Multi-Version Concurrent Control,多版本并发控制)实现的快照读
  • Serializable(可串行化):事务排队,隔离级别最高,性能最差,也是最严格的隔离级别

并发问题

不同的隔离级别,会引发不同的并发问题,下面是常见的三种:

  • 脏读(Drity Read):读取到未提交的数据
  • 不可重复读(Non-repeatable read):两次读取结果不同
  • 幻读(Phantom Read):读到的结果所表征的数据状态无法支撑后续的业务操作

通过隔离级别的特性,可以知道:

  1. RC解决了脏读,但是,它会引起不可重复读和幻读(Oracle)
  2. RR解决了脏读和不可重复读,但是,它会引起幻读(MySQL)

幻读通常是针对 insert 来说的
比如 select 某记录发现不存在,接着插入该记录,但插入时发现记录又存在了,插入失败,这就是发生了幻读
而 mysql 为了解决幻读,提出了 LBCC(解决当前读下的幻读)和 MVCC(解决快照读下的幻读)的两种方案

当前读和快照读

我们通过 select 读数据库的时候,实际上有两种读:当前读 和 快照读

  • 当前读:读取的是数据的最新版本,并对读取的记录加锁(所以会阻塞其他事务同时改动该记录)
        这两个语句都是当前读:select…lock in share mode / select…for update
        另外,对于数据修改的操作(insert/update/delete),也是采用的当前读
        比如说,我们在 update 的时候,首先会执行当前读,然后把返回的数据加锁,接着才是执行 update
  • 快照读:单纯的 select 操作,不包括上述的 select…lock in share mode/select…for update/insert/update/delete
        其读取的是数据的可见版本(可能是过期的数据),且不会加锁

而当前读和快照读的具体读法,以及快照读又是如何在众多快照中读到数据的,就涉及到了 MVCC 理论

MVCC

正常来讲,既有读又有写操作的时候,是要加锁的

而现在很多数据库都支持 MVCC(Multi-Version Concurrent Control,多版本并发控制)理论

这样就不用加锁了,变成你写你的版本,我读老的版本,彼此不碍事(类似于 Java Concurrent 包中的 CopyOnWrite)

所以说 MVCC 不光解决了隔离级别的问题,实际上它也解决了事务并发的问题(即读写不冲突)

MySQL 同样支持 MVCC 理论,它是在每条记录上都添加隐藏列的方式实现的

并借助 undo log 和 redo log 实现了事务控制(binlog 是数据库层面的同步和恢复用的)

比如修改了一条记录,该记录中会有版本号和回滚指针两个隐藏列,回滚指针指向 undo log 中的上一次修改的记录

而上一次的记录中,可能又有回滚指针指向再上一次的记录,故无论修改多少次,都可以从 undo log 中读到数据

如下图所示:

select for update 是一个典型的当前读,它始终读取最新版本的数据

其中 DB_TRX_ID 和 DB_ROLL_PTR 是两个隐藏列

一个代表事务ID(mysql 的每个事务都会分配一个全局唯一且递增的ID),相当于是标识这条数据的版本号

另一个就相当于这条数据的一个指针,它指向这条数据的前一个版本

这样,数据的多版本和控制就有了

而作为快照读的普通 select,怎么决定具体读哪个版本呢,这就涉及到 ReadView 机制

ReadView

InnoDB 会为每个事务都构造一个数组,用来保存该事务启动的瞬间,当前正在 【活跃】 的所有事务ID

活跃事务指的是启动了但还没提交的事务,另外还有 低水位高水位 的概念:

  1. 低水位:该数组中的最小ID
  2. 高水位:创建数组时,系统尚未分配的下一个事务ID,也即目前已创建过的事务ID的最大值 + 1(不是数组中的)

而这个数组加上高水位,就组成了当前事务的一致性视图,即 ReadView

所以说 ReadView 就是一个保存了事务ID的列表

与其相关的,有 4 个比较重要的定义:

  • m_ids:生成ReadView时当前系统中活跃的读写事务的事务ID列表
  • min_trx_id:低水位
  • max_trx_id:高水位
  • creator_trx_id:生成该ReadView的事务的事务ID

按照 RR 的定义:一个事务启动时,能看到所有已提交的事务结果,但该事务执行期间,其他事务的更新对它不可见

换言之,一个事务只需要在启动的时候说,以我启动的时刻为准,如果一个数据的版本是在我启动之前生成的,我就认

若在我启动之后才生成的,我就不认,这时我必须找到它的上一个版本,如果 “上一个版本” 也不可见,则继续往前找

还有,如果是这个事务自己更新的数据,它自己还是要认的(也就是说:我能读到比我先的,不能读到比我后的)

而这,正是通过 ReadView 来做数据可见性判断的思路

如下图所示:

实际上,在访问某条记录时,会按照下面的规则,从该记录的最新版本开始遍历,逐个判断某个版本是否可见

  1. 被访问版本的 trx_id 等于 creator_trx_id,表示当前事务在访问自己修改的记录,可见,返回
  2. 被访问版本的 trx_id 小于 min_trx_id,表明该版本在生成ReadView时,已经提交,可见,返回
  3. 被访问版本的 trx_id 大于 max_trx_id,表明该版本在生成ReadView时,还未开启,不可见,继续遍历
  4. 被访问版本的 trx_id 在 min_trx_id 和 max_trx_id 之间,那么则判断其是否在 m_ids 里面
    在则说明生成ReadView时该版本事务未提交,该版本不可见,反之则可见,返回

RC 与 RR 这俩隔离级别的一个不同就是:生成 ReadView 的时机不同
RC 会在每一次普通 SELECT(快照读)前,都生成一个 ReadView
RR 只在第一次普通 SELECT(快照读)前,生成一个 ReadView,其作用于整个事务的生命过程

因此,对于 RC 而言,由于每次查询前都生成新的 ReadView,这样读到的都是最新版本的 ReadView 下可见的数据

所以,当在一个事务中出现其他事务对某一数据行操作,那么该事务中,两次读到结果就可能不一致

所以才会说:RC 会引起不可重复读

而对于 RR 来讲,由于只在第一次生成 ReadView,在事务的整个过程中都不会再生成了,而是重复使用 ReadView

这样,即使在该事务中的两次读之间,做了其它的操作,那么第二次读时,仍然读到的是第一次读到的数据

所以才会说:RR 解决了不可重复读

undo log

快照读,之所以能读到数据,就是因为数据的每个版本都被写在了 undo log 里面

undo log 用于记录回滚日志(实现了数据的多版本),保证事务原子性,并且它一般是逻辑日志

比如 delete 一条记录时,undo log 中会记录一条对应的 insert 记录,反之亦然

而当 update 一条记录时,它会记录一条对应相反的 update 记录,这样回滚时就有迹可寻

另外,undo log 也会产生 redo log,因为 undo log 也要实现持久性保护(redo log 是物理日志,会写入到文件中)

它主要分两类:

  • insert undo log:insert 时产生的,由于 insert 的记录只对当前事务可见,因此该 log 会在事务提交后直接删除
  • update undo log:update 和 delete 时产生的,由于需要实现快照读,故该 log 不能在事务提交时就进行删除

redo log

它实现了事务持久性,主要记录数据的修改, 用于异常恢复

实际上是写入到文件中的

它会记录写入位置与刷盘位置,循环写,循环刷,至于写的策略 / 刷盘的策略,它会有不一样的时机

不一样的刷盘策略会有不一样的数据一致性保障,实际上就跟 MQ 一样,直接刷盘 / 定时刷盘 / 写完刷盘等等

具体写入流程如下:

这是 MySQL 执行一次数据更新的流程

客户端发起一个 update 到 MySQL 服务端,服务端会有一个Server层(处理SQL解析等等各种设置)

它会调用存储引擎的API,到达存储引擎这一层(这时一个 update 就会转化成存储引擎的操作命令,去修改数据)

接下记录 undo log(即记录它的历史版本),再更新内存,再记 redo log(此时,更新的内存数据就和 redo log 匹配上了)

然后告诉Server层说数据已经更新完毕,等待提交

最后Server层提交事务,存储引擎层就会将事务记录为commit状态

此时,内存里和 redo log 里面都有数据

如果接下来内存里的数据丢了(比如重启了),redo log 还在,就可以根据 redo log 恢复数据,保证了数据的持久性

锁机制

按类型划分

  • 共享锁:读锁,可以同时被多个事务获取,阻止其它事务对记录的修改
  • 排它锁:写锁,只能被一个事务获取,允许获得锁的事务修改数据(所有当前读都是加的排它锁)

按粒度划分

  • 行级锁
  • 间隙锁
  • 表级锁

行级锁

行锁是作用在索引上的(即它是通过索引来锁住记录的,这样就不会造成写冲突)

  1. 针对聚簇索引,毫无疑问,就直接锁那条记录
  2. 针对二级索引,则会把二级索引自身及其回表后的主键索引都锁住(无论是 RC 还是 RR 隔离级别)

如下图所示:(而对于非唯一索引,锁的东西会更多,详见下方间隙锁)

间隙锁

先说下幻读的场景:

比如你在读到数据后,有人修改了这条数据,这时你再来操作这条数据,数据库就会告诉你失败了

因为你在操作时,数据库实际的数据已经不是你手里读到的那个数据了

而你的 “操作” 通常是 insert/update/delete 操作,这些操作又都是当前读,也就是拿数据库最新数据来操作

所以你会失败,这就是幻读

解决这个问题的办法也简单:只要让你没办法失败就行了,即只要让你没办法去 “操作” 这条数据,就行了

InnoDB 的间隙锁(GAP Lock)正是这么做的:它保证了两次当前读之间,其它的事务不会插入新的满足条件的记录

因此,其特点如下:

  • 间隙锁解决了可重复读下的幻读问题
  • 间隙锁能够保证两次当前读返回一致的记录
  • 间隙锁不是加在记录上的,它锁的是两条记录之间的区间

严格来讲,是临键锁(Next-key Lock:是由 行锁 加 间隙锁 构成的)解决了幻读

如上图所示,一共有 1、2、3 三个间隙

也就是说,如果这三个间隙里面插入数据,那么可能就会影响到 delete 语句,所以就把这三个区间给锁住

此时若插入 phone=132/133/134 的数据就会失败

因此,间隙锁的开销还是比较大的(整个区间都不能往里面插入数据了)

而唯一索引之所以没有这个问题,是因为它本身已经保证唯一了,因此就不会有新的命中条件的数据了
所以就只有在非唯一索引的情况下,才有这个问题

表级锁

它会锁整个表,一般 DBA 才会干这个事

而在实际的业务开发中,也会有一种情况把整个表都锁住,如下图所示

phone 字段没有建索引,然后删除它,这时就会发生全表扫描,所有记录都被锁住了

实际过程就是引擎层把所有记录返回给Server层,Server层会去过滤,没命中的再释放掉

而对于 RR 有间隙锁的情况,除了锁记录,额外还要去锁 GAP,这时的开销就非常大了,所以很耗性能

死锁的分析

先来看一下通常的加锁过程,如下图所示:

Server层收到 update 指令,就会当前读到引擎层,这时可能会命中很多条记录

引擎层就会一条一条的把记录加锁返回给Server层,Server层做更新后再返回给引擎层

接着,引擎层再把下一条记录加锁返回给Server层,重复相同的过程,最终所有记录都加上锁都更新了,然后等待提交

现在举个实际例子,来描述下死锁的产生,如下图所示:

T1 根据 name 更新数据,T2 根据 age 做当前读(也可以是 update,主要强调的是当前读)

而且 name 和 age 各自都有索引,当 T1 与 T2 并发执行时:

  1. 首先,T1 找到 uid=120 的主键记录,锁成功了
  2. 然后,T2 找到 uid=130 的主键记录,锁成功了
  3. 这个时候,T1 才找到 uid=130 的主键记录,也去锁,却发现被别的事务锁住了
  4. 紧接着呢,T2 又找到 uid=120 的主键记录,也去锁,也发现被别的事务锁住了
  5. 于是,T1 和 T2 互相等待对方释放锁,也就死锁了

换句话说:两个语句并发执行时,对主键索引记录的加锁顺序不一样,就有可能会造成死锁


相关文章

Content