MySQL事务,事务日志,锁机制,MVCC

172 阅读12分钟

MySQL事务的ACID特性

  • 原子性:事务是不可分割的,要么全部提交,要么全部失败回滚。原子性由undolog实现。
  • 一致性:事务执行前后,数据从一个一致性状态转换为另一个一致性状态。一致性由原子性+隔离性+持久性实现。
  • 隔离性:并发执行的各个事务之间不会互相影响。隔离性由锁机制和MVCC实现。
  • 持久性:事务一旦提交,对数据的改变就是永久性的。持久性由redolog实现。

四种隔离级别与数据并发问题

  • 读未提交:可以读到未提交事务修改的数据,会发生脏读问题,即事务A读取了事务B未提交的数据,此时事务B可以回滚数据,事务A读到的是脏数据。
  • 读已提交:只能读到已提交事务修改的数据,解决了脏读问题,但是会发生不可重复读问题,即事务A读取了一个字段,事务B更新了此字段并提交,事务A再次读取发现两次值不同。
  • 可重复读:MySQL默认隔离级别,事务执行过程中看到的数据不变,解决了不可重复读问题,但是仍存在幻读问题,即事务A读表,事务B插入新行并提交,事务A再次读表发现多了几行。MySQL部分解决了幻读问题。
  • 串行化:加锁串行执行,不会有数据并发问题。

四种隔离级别实现原理

  • 读未提交:写数据加排他锁,读数据不加锁。
  • 读已提交:写数据加排他锁,读数据使用MVCC。
    • 每次读取数据都会生成一个新的快照和ReadView(快照用于记录当前数据,ReadView用来判断此时哪些数据可以读取),保证读到最新数据。
    • 例如事务A第一次读创建ReadView_1,发现事务B未提交,就不能读事务B修改过的数据;等事务B提交后若事务A第二次读,又创建ReadView_2,发现事务B已经提交,可以读事务B修改的数据了。
  • 可重复读:写数据加排他锁,读数据使用MVCC。在事务开启时创建一个快照,事务过程中始终读取这份快照的数据。可重复读在一定程度上解决了幻读问题:
    • 对于快照读(普通select语句),读取到的是事务开始时生成的快照,避免了幻读问题
    • 对于当前读(select...for update语句或增删改语句),通过记录锁+间隙锁解决了幻读。
  • 串行化:写数据加排他锁,读数据加共享锁

MySQL锁机制

MySQL事务的隔离性是通过锁机制实现的,MySQL的锁机制按照不同的分类方式可分为:读写锁,表锁行锁,乐观锁悲观锁,MVCC等

读锁和写锁

  • 读锁(共享锁):可以多个线程同时读,与其他读锁不互斥
  • 写锁(排他锁):只能有一个线程写,与其他读锁和写锁都互斥

表锁和行锁

  • InnoDb和MyISAM都支持表级锁。表锁粒度大,冲突概率高,并发度低,但是开销小且不会死锁。表级锁包括表级读写锁,元数据锁,意向锁,自增锁。

    • 元数据锁指对表做CRUD时加MDL读锁,当表的结构(元数据)发生变化时加MDL写锁。
    • 自增锁用来保持主键的自增,主键保持自增防止B+树页分裂。
    • 意向锁是为了协调表锁和行锁,加表锁时要判断表中是否有数据行被锁定,没有意向锁就要逐行判断,有了意向锁直接一次判断即可。意向锁只会和表级锁冲突。
  • InnoDB支持行级锁。行锁粒度小,冲突概率低,并发度高,但是开销大且会死锁。行级锁包括行级读写锁,间隙锁,临键锁,插入意向锁。

    • 插入意向锁表示当前事务想要做插入操作但是没拿到锁,处于等待状态,它会设置一个插入意向锁表示要插入。设置插入意向锁的目的是避免出现死锁,故插入意向锁之间不会互斥,但可能会与其他行锁互斥。
    • 间隙锁指锁住一个范围但不包括记录本身
    • 临键锁是行锁(记录锁)+间隙锁,锁住一个范围且锁住记录本身。MySQL使用临键锁解决幻读问题。
  • MySQL如何解决幻读

    • MySQL使用临键锁解决幻读问题。数据在B+树的叶节点按照顺序排列,记录锁锁住了已存在的记录,而间隙锁锁住了记录之间的空白区间,不允许数据插入这个范围。
    • 例如事务A读取id为1到100的数据行,会为这个区间加上间隙锁,事务B要在这个区间插入数据时发现这个区间加了间隙锁,于是生成一个插入意向锁,然后进入等待状态。(但是这可能会带来死锁,见后文详解)
    • 但MySQL并不完全解决幻读,例如事务1执行了快照读得到1条数据,然后事务2插入了一条数据并提交,事务1再执行当前读得到2条数据(这是因为快照读不加锁)

注意:InnoDB基于索引实现行锁,加锁的对象是索引,故如果没有索引,那么只能用表锁。

乐观锁和悲观锁

  • 乐观锁操作数据时假设不会发生并发冲突,所以只在提交时检查,主要通过CAS和版本号机制实现,适合读操作多的场景。
  • 悲观锁操作数据时假设会发生并发冲突,事务开始到结束都加锁,通过数据库本身的锁机制实现,开销较大,适合写操作多的场景。常用的实现如加锁读select for update。

select for update

加锁读,对查询出来的行加排他锁,确保其他事务不能读取或修改这些行,直到当前事务提交或回滚。如果做范围查询,但是没有命中,此时就会使用间隙锁锁住对应区间,如果命中,此时使用临键锁锁住记录和区间。

注意:如果加锁读或增删改语句没有走索引,那么会对全表扫描出来的所有记录加上临键锁,相当于锁住全表。

MVCC

多版本并发控制,是一种读取数据时不用加锁但能够保证数据一致性的机制,用于实现读已提交和可重复读隔离级别。MVCC通过保存数据快照和创建ReadView判断是否读取快照来实现,即根据事务开始时间不同,每个事务对同一张表看到的数据可能不同。MVCC的优势是读的时候不用加锁,读写不冲突,增强了并发性。

MVCC实现原理

  • 首先,MySQL行格式中包含2个隐藏字段:
    • trxId:最后一次修改该记录的事务ID
    • 回滚指针:指向这条记录的上一个版本(undolog中),多个历史版本数据组成一条版本链
  • 然后,读取数据时MySQL会创建ReadView来判断哪些版本的记录是可见的,其中有四个字段:
    • id:创建该RV的事务id
    • min_trx_id:创建该RV时最小活跃事务id
    • m_ids:创建该RV时活跃事务id列表(活跃事务指创建了但还未提交的事务)
    • max_trx_id:创建该RV时最大活跃事务id(实际可能是最大活跃事务id+1)

image.png

  • 最后,MySQL用ReadView去判断版本记录是否可见的规则如下
    1. 记录的trxId = RV的id:说明记录是当前事务更新的,故可见。
    2. 记录的trxId < RV的min_trx_id(最小活跃事务id):说明RV创建时修改记录的事务已经提交了(不活跃了),所以该记录对当前事务可见。
    3. 记录的trxId >= RV的max_trx_id(最大活跃事务id):说明RV创建后修改记录的事务才创建(肯定活跃),所以该记录对当前事务不可见
    4. RV的min_trx_id <= 记录的trxId < RV的max_trx_id:记录的事务Id在RV的最小和最大活跃事务id之间,判断记录的trxId是否在RV的活跃事务列表中,若在说明事务活跃则不可见,否则可见。

死锁

数据库死锁即两个事务都持有对方需要的锁并且等待对方释放,导致两个事务都被阻塞。

  • 死锁的产生条件为:互斥,请求并保持,非抢占,循环等待。
  • 避免死锁/解决死锁:
    • 破坏死锁条件:
      • 破坏请求并保持条件:同一个事务尽可能一次锁定所有资源。
      • 破坏循环等待条件:以相同顺序去访问表或以相同的顺序获取锁。
    • 使用死锁检测机制,一旦检测到有死锁,回滚undo量最小的事务。
      • 资源分配图:图中的节点表示进程和资源,边表示资源的分配或请求。如果图中存在环,则表示存在死锁。
    • 使用MySQL时避免死锁:
      • 使用索引精确定位行,减少死锁概率。
      • 将大事务拆成小事务,小事务锁定更少资源,锁定时间更短,死锁概率更小。
      • 读数据时非必要不加锁,MVCC可以实现不加锁读数据。
      • 如果业务允许,可以降低隔离级别或提高锁粒度(表锁),减少冲突概率。
  • 死锁实例:以下代码在可重复读级别下会发生死锁:
    • 首先事务A执行update语句,在主键索引上插入了间隙锁,范围是20-30。
    • 然后事务B执行update语句,在主键索引上插入了间隙锁,范围也是20-30。
    • 因为间隙锁只是为了防止其他事务插入数据,所以两个间隙锁之间不会冲突。
    • 事务A执行insert语句,因为需要在事务B的间隙锁范围内插入数据,所以需要生成一个插入意向锁,与事务B的间隙锁冲突,于是事务A被事务B阻塞。
    • 事务B执行insert语句,因为需要在事务A的间隙锁范围内插入数据,事务B同样被事务A阻塞,此时形成死锁。

image.png

事务日志

事务日志主要包括redolog和undolog,这两个事务日志是InnoDB特有的。

redolog

  • redolog主要负责掉电恢复,记录了事务对数据页的修改操作,避免还未刷盘的脏数据丢失。事务执行过程中,更新的数据先放入数据缓冲池,同时写入redolog缓冲池,redolog缓冲池以一定频率(或提交事务时)写入磁盘中的redolog文件,数据缓冲池的脏页也以一定频率刷新到磁盘中。事务提交时只将redolog持久化,若事务提交后数据脏页未刷盘而服务器宕机,则内存中的数据丢失,重启后根据redolog恢复数据。

    image.png

  • 为什么数据不直接刷盘:redolog是顺序写的,比随机写数据页更快。

  • 使用redolog恢复数据:数据库崩溃时重启InnoDB会自动读取redolog恢复数据。

  • redolog缓存刷盘:缓存空间不足时,事务提交时,关闭服务器时,触发了checkpoint规则时(即环形缓冲区满)都会刷盘,所以即使事务未提交,redolog也可能刷盘。另外,redolog也可以设置每次/每秒/从不刷盘。

  • redolog缓存写满:redolog缓存是环形缓冲区,写满时会回到开头覆盖前面的记录。环形缓冲区有读指针和写指针,读指针标识读取(持久化)到哪条记录了,写指针标识最新写入缓存的记录。如果两个指针重叠说明redolog缓存满了,此时MySQL不再执行新的更新操作,等redlog缓存刷盘腾出空间后恢复正常。

undolog

  • undolog主要用于回滚,事务更新数据之前要把旧的数据写入undolog以确保能够回滚,undolog中的历史数据供其他并发事务进行快照读。

  • 回滚只需要undolog吗:回滚操作本身也会生成redolog,以确保回滚操作的持久性,如果数据库在回滚过程中崩溃,redolog可以确保回滚操作能够正确完成。

  • 长事务的影响:长事务会导致undolog无法清理,占用大量磁盘空间。

InnoDB更新操作写日志流程

image.png 一个事务执行更新语句后,需要做的日志操作有:

  • 开启事务前记录旧数据到undolog中。
  • 开始更新操作,将新数据写入缓存,然后将记录写入redolog缓存中,此时算更新完成(事务未提交)。
  • 更新完成后还要将操作写入binlog缓存中。
  • 事务提交后,将redolog缓存和binlog缓存都写入磁盘中,执行两阶段提交
    • 两阶段提交指redolog和binlog的持久化是两段不同的逻辑,但是又需要保证他们都成功,故使用分布式事务的两阶段提交(准备阶段和提交阶段)。
    • 两个日志不一致可能导致主从不一致,因为redolog影响主机,binlog影响从机(从机通过binlog做主从同步)。

redolog和binlog的区别

  • redolog是事务日志,只存在于InnoDB存储引擎,用于掉电恢复,用于恢复还未写入磁盘的脏数据,因为redolog是循环写,边写边擦除,不可能存放太多脏数据。
  • binlog是二进制日志,所有存储引擎都可用,以追加方式记录了数据库所有更新操作,可用于全量恢复和主从复制。所以如果数据库被删除了,需要使用binlog进行恢复。
// 使用binlog恢复指定时间段的数据
// 1.解析binlog文件生成SQL语句
mysqlbinlog \
  --start-datetime="2023-10-01 00:00:00" \
  --stop-datetime="2023-10-02 00:00:00" \
  /var/lib/mysql/mysql-bin.000001 > binlog.sql
// 2.将SQL文件导入数据库中执行