MySQL-事务&锁

121 阅读15分钟

事务

事务的ACID原则

A/Atomicity:原子性

要么全部执行成功,要么全部执行失败

C/Consistency:一致性

数据只允许从一个一致性状态变化为另一个一致性状态

I/Isolation:独立性/隔离性

事务之间都是独立的

D/Durability:持久性

事务一旦被提交,它会保持永久性

MySQL的事务机制综述

MySQL默认将一条SQL视为单个事务,同时默认开启自动提交事务,SQL执行完了就会自动提交

手动管理事务

  • start transaction | begin | begin work:开启一个事务
  • commit:提交一个事务
  • rollback:回滚一个事务

MySQL事务的隔离机制

默认为第三级别:Repeatable read可重复读

隔离级别

  • Read uncommitted/RU:读未提交
  • Read committed/RC:读已提交
  • Repeatable read/RR:可重复读
  • Serializable:序列化/串行化

脏读、幻读、不可重复读问题

  • 脏读
    • 一个事务读到了其他事务还未提交的数据
    • 当前事务读到的数据,由于还未提交,因此有可能会回滚
  • 不可重复读
    • 一个事务中,多次读取同一数据,先后读取到的数据不一致
  • 幻读
    • 一个事务内多次查询返回的结果集不一样

事务的四大隔离级别

  • 解决的问题
    • 读未提交:处于该隔离级别的数据库,脏读、不可重复读、幻读问题都有可能发生。
    • 读已提交:处于该隔离级别的数据库,解决了脏读问题,不可重复读、幻读问题依旧存在。
    • 可重复读:处于该隔离级别的数据库,解决了脏读、不可重复读问题,幻读问题依旧存在。
    • 序列化/串行化:处于该隔离级别的数据库,解决了脏读、不可重复读、幻读问题都不存在。
  • 读未提交
    • 基于「写互斥锁」实现,写同一数据时会互斥,但读操作却并不是互斥的
    • 也就是当一个事务在写某个数据时,就算没有提交事务,其他事务来读取该数据时,也可以读到未提交的数据,因此就会导致脏读、不可重复读、幻读一系列问题出现
    • 读操作不加锁,写操作加排他锁。
  • 读已提交
    • 使用「写互斥锁」,两个事务操作同一数据时,会出现排他性
    • 读操作,MVCC机制不会让另一个事务读取正在修改的数据,而是读取上一次提交的数据(也就是读原本的老数据)
    • 当A正在更新数据但还未提交时,事务B开始读取数据,此时MVCC机制则会基于表数据的快照创建一个ReadView,然后读取原本表中上一次提交的老数据。然后等事务A提交之后,事务B再次读取数据,此时MVCC机制又会创建一个新的ReadView,然后读取到最新的已提交的数据,此时事务B中两次读到的数据并不一致,因此出现了不可重复读问题
    • 读操作使用MVCC机制,每次SELECT生成快照,写操作加排他锁。
  • 可重复读
  • 和【读已提交】稍微有些不同MVCC机制会
  • 不会每次查询时都创建新的ReadView,而是在一个事务中,只有第一次执行查询会创建一个ReadView,在这个事务的生命周期内,所有的查询都会从这一个ReadView中读取数据,从而确保了一个事务中多次读取相同数据是一致的,也就是解决了不可重复读问题。
  • 读操作使用MVCC机制,首次SELECT生成快照,写操作加临键锁。
  • 序列化/串行化
  • 将所有的事务按序排队后串行化处理
  • 操作同一张表的事务只能一个一个执行,事务在执行前需要先获取表级别的锁资源,拿到锁资源的事务才能执行,其余事务则陷入阻塞,等待当前事务释放锁
  • 读操作加共享锁,写操作加临键锁。

MySQL的事务实现原理

事务机制是基于日志实现的

两个个日志

  • undo-log:主要记录SQL的撤销日志,比如目前是insert语句,就记录一条delete日志。
  • redo-log:记录当前SQL归属事务的状态,以及记录修改内容和修改页的位置。

多条SQL的事务机制

  • ①当MySQL执行时,碰到start transaction;的命令时,会将后续所有写操作全部先关闭自动提交机制,也就是后续的所有写操作,不管有没有成功都不会将日志记录修改为commit状态。
  • ②先在redo-log中为第一条SQL语句,记录一条prepare状态的日志,然后再生成对应的撤销日志并记录到undo-log中,然后执行SQL,将要写入的数据先更新到缓冲区。
  • ③再对第二条SQL语句做相同处理,如果有更多条SQL则逐条依次做相同处理.....,
  • ④直到碰到了rollback、commit命令时,再对前面的所有写SQL做相应处理
    • 如果是commit提交事务的命令,则先将当前事务中,所有的SQL的redo-log日志改为commit状态,然后由MySQL后台线程做刷盘,将缓冲区中的数据落入磁盘存储
    • 如果是rollback回滚事务的命令,则在undo-log日志中找到对应的撤销SQL执行,将缓冲区内更新过的数据全部还原,由于缓冲区的数据被还原了,因此后台线程在刷盘时,依旧不会改变磁盘文件中存储的数据。
  • 事务的恢复机制
    • MySQL重启时,依旧可以通过redo-log日志重新恢复未落盘的数据,从而确保数据的持久化特性

MVCC机制综述

  • MVCC机制的全称为Multi-Version Concurrency Control,即多版本并发控制技术
  • 采用更好的方式处理了读-写并发冲突,做到即使有读写冲突时,也可以不加锁解决,从而确保了任何时刻的读操作都是非阻塞的
  • MySQL-MVCC多版本并发控制
    • 仅在RC读已提交级别、RR可重复读级别才会使用MVCC机制
    • RU读未提交级别,既然都允许存在脏读问题,因此无需MVCC介入
    • Serializable串行化级别,所有的并发事务串行化处理,不存在多线程并发问题,因此无需MVCC介入

MVCC机制实现原理剖析

MVCC三剑客实现的

隐藏字段、Undo-log日志、ReadView

InnoDB表的隐藏字段

MySQL除开会构建你显式声明的字段外,通常还会构建一些InnoDB引擎的隐藏字段,在InnoDB引擎中主要有DB_ROW_ID、DB_Deleted_Bit、DB_TRX_ID、DB_ROLL_PTR这四个隐藏字段

  • 隐藏主键 - ROW_ID(6Bytes)
    • 主键、具备唯一非空属性的字段
    • 当两者都不存在时,InnoDB就会隐式定义一个顺序递增的列ROW_ID来作为聚簇索引列
  • 删除标识 - Deleted_Bit(1Bytes)
    • 执行delete语句时,只会改变将隐藏字段中的删除标识改为1/true,如果后续事务出现回滚动作,直接将其标识再改回0/false即可,这样就避免了索引树的结构调整
    • purger线程会自动清理Deleted_Bit=1/true的行数据。
  • 最近更新的事务ID - TRX_ID(6Bytes)
    • MySQL对于所有包含写入SQL的事务,会为其分配一个顺序递增的事务ID,但如果是一条select查询语句,则分配的事务ID=0
    • 不过对于手动开启的事务,MySQL都会为其分配事务ID,就算这个手动开启的事务中仅有select操作
  • 回滚指针 - ROLL_PTR(7Bytes)
    • 当一个事务对一条数据做了改动后,都会将旧版本的数据放到Undo-log日志中,而rollback_pointer就是一个地址指针,指向Undo-log日志中旧版本的数据,当需要回滚事务时,就可以通过这个隐藏列,来找到改动之前的旧版本数据

InnoDB引擎的Undo-log日志

  • 存储旧版本的数据
  • 版本链

MVCC核心 - ReadView

一个事务在尝试读取一条数据时,MVCC基于当前MySQL的运行状态生成的快照,也被称之为读视图,即ReadView,在这个快照中记录着当前所有活跃事务的ID(活跃事务是指还在执行的事务,即未结束(提交/回滚)的事务)

  • 一个事务与一个ReadView属于一对一的关系
  • 核心内容
    • creator_trx_id:代表创建当前这个ReadView的事务ID。
    • trx_ids:表示在生成当前ReadView时,系统内活跃的事务ID列表。
    • up_limit_id:活跃的事务列表中,最小的事务ID。
    • low_limit_id:表示在生成当前ReadView时,系统中要给下一个事务分配的ID值。

MVCC机制实现原理

  • 当一个事务尝试改动某条数据时,会将原本表中的旧数据放入Undo-log日志中。
  • 当一个事务尝试查询某条数据时,MVCC会生成一个ReadView快照。
  • Undo-log主要实现数据的多版本,ReadView则主要实现多版本的并发控制

RC、RR不同级别下的MVCC机制

  • RC级别下,MVCC机制是会在每次select语句执行前,都会生成一个ReadView
  • RR级别中,一个事务只会在首次执行select语句时生成快照,后续所有的select操作都会基于这个ReadView来判断

锁的分类

总的来说其实就共享锁、排他锁两种,只是加的方式不同,加的地方不同,因此就演化出了这么多锁的称呼

以锁粒度的维度划分:

①表锁:

  • 全局锁:加上全局锁之后,整个数据库只能允许读,不允许做任何写操作。
  • 元数据锁 / MDL锁:基于表的元数据加锁,加锁后整张表不允许其他事务操作。
  • 意向锁:这个是InnoDB中为了支持多粒度的锁,为了兼容行锁、表锁而设计的。
  • 自增锁 / AUTO-INC锁:这个是为了提升自增ID的并发插入性能而设计的。

②页面锁

③行锁:

  • 记录锁 / Record锁:也就是行锁,一条记录和一行数据是同一个意思。
  • 间隙锁 / Gap锁:InnoDB中解决幻读问题的一种锁机制。
  • 临建锁 / Next-Key锁:间隙锁的升级版,同时具备记录锁+间隙锁的功能。

以互斥性的维度划分:

  • 共享锁 / S锁:不同事务之间不会相互排斥、可以同时获取的锁。
  • 排他锁 / X锁:不同事务之间会相互排斥、同时只能允许一个事务获取的锁。
  • 共享排他锁 / SX锁:MySQL5.7版本中新引入的锁,主要是解决SMO带来的问题。

以操作类型的维度划分:

  • 读锁:查询数据时使用的锁。
  • 写锁:执行插入、删除、修改、DDL语句时使用的锁。

以加锁方式的维度划分:

  • 显示锁:编写SQL语句时,手动指定加锁的粒度。
  • 隐式锁:执行SQL语句时,根据隔离级别自动为SQL操作加锁。

以思想的维度划分:

  • 乐观锁:每次执行前认为自己会成功,因此先尝试执行,失败时再获取锁。
  • 悲观锁:每次执行前都认为自己无法成功,因此会先获取锁,然后再执行。

共享锁与排他锁

共享锁

  • 不同事务之间不会排斥,可以同时获取锁并执行
  • 这里所谓的不会排斥,仅仅只是指不会排斥其他事务来读数据,但其他事务尝试写数据时,就会出现排斥性

排他锁

当一个线程获取到独占锁后,会排斥其他线程,如若其他线程也想对共享资源/同一数据进行操作,必须等到当前线程释放锁并竞争到锁资源才行

锁的释放

  • MySQL中释放锁的动作都是隐式的(MySQL自己来干)
  • 对于锁的释放时机,在不同的隔离级别中也并不相同,比如在“读未提交”级别中,是SQL执行完成后就立马释放锁,而在“可重复读”级别中,是在事务结束后才会释放

页面锁、乐观锁与悲观锁

页面锁

  • 以页为粒度,锁住的是一页数据
  • 了解即可

乐观锁

  • 无锁思想,仅是一种锁的思想
  • 每次执行都认为只会有自身一条线程操作,因此无需拿锁直接执行
  • 乐观锁机制,一般都是基于CAS思想实现的
  • 乐观锁更加适用于读大于写的业务场景,频繁写库的业务则并不适合加乐观锁

悲观锁

  • 仅是一种锁的思想
  • 每次执行都认为会有其他线程一起来操作,因此每次都需要先拿锁再执行
  • 想要使用悲观锁,对一个事务加排他锁for update即可

行锁

记录锁(Record Lock)

也就是行锁,一条记录和一行数据是同一个意思。

间隙锁(Gap Lock)

  • InnoDB中解决幻读问题的一种锁机制
  • ID字段之间从4跳到了9,那么4~9两者之间的范围则被称为“间隙”,而间隙锁则主要锁定的是这块范围

临键锁(Next-Key Lock)

  • 记录锁+间隙锁组成的锁
  • 锁定左开右闭的区间

插入意向锁(Insert Intention Lock)

  • 插入意向锁是一种间隙锁,这种锁是一种隐式锁,也就是咱们无法手动的获取这种锁
  • 知道即可

行锁的粒度粗化

  • 在内存中专门分配了一块空间存储锁对象,当该区域满了后,就会将行锁粗化为表锁。
  • 当做范围性写操作时,由于要加的行锁较多,此时行锁开销会较大,也会粗化成表锁。

表锁

将锁加在表上,一张表只能存在一个同一类型的表锁

不同引擎的表锁的实现也不同

InnoDB

  • 支持多粒度锁的存储引擎,它的锁机制是基于聚簇索引实现的
  • 当SQL执行时,如果能在聚簇索引命中数据,则加的是行锁,如无法命中聚簇索引的数据则加的是表锁

MyISAM

  • 使用表锁,需要使用额外的语法
    • MyISAM引擎中获取读锁(具备读-读可共享特性)LOCK TABLES table_name READ;
    • MyISAM引擎中获取写锁(具备写-读、写-写排他特性)LOCK TABLES table_name WRITE;
    • 查看目前库中创建过的表锁(in_use>0表示目前正在使用的表锁)SHOW OPEN TABLES WHERE in_use > 0;
    • 释放已获取到的锁UNLOCK TABLES;

元数据锁(Meta Data Lock)

  • 简称MDL锁
  • MDL锁是基于.frm文件中的元数据加锁的
  • 主要是用于,更改表结构时使用

意向锁(Intention Lock)

意向锁实际上也是一种特殊的表锁,意向锁其实是一种“挂牌告知”的思想,好比日常生活中的出租车,一般都会有一个牌子,表示它目前是“空车”还是“载客”状态,而意向锁也是这个思想

自增锁(AUTO-INC Lock)

  • 专门为了提升自增ID的并发插入性能而设计的
  • 模式
    • innodb_autoinc_lock_mode = 0:传统模式。同时只允许一条线程执行,这种形式显然性能较低。
    • innodb_autoinc_lock_mode = 1:连续模式(MySQL8.0以前的默认模式)。
    • innodb_autoinc_lock_mode = 2:交错模式(MySQL8.0之后的默认模式)。

全局锁

  • 全局锁其实是一种尤为特殊的表锁,其实将它称之为库锁也许更合适
  • 全局锁是基于整个数据库来加锁的,加上全局锁之后,整个数据库只能允许读,不允许做任何写操作
  • 一般全局锁是在对整库做数据备份时使用

共享排他锁

  • 主要是解决SMO带来的问题
    • 更新操作触发B+Tree叶子节点分裂,那么就会对整棵B+Tree加排它锁,这不但阻塞了后续这张表上的所有的更新操作,同时也阻止了所有试图在B+Tree上的读操作,也就是会导致所有的读写操作都被阻塞,其影响巨大

MySQL中的死锁现象

死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待、相互僵持的现象

MySQL中死锁如何解决呢?

  • 锁超时机制:事务/线程在等待锁时,超出一定时间后自动放弃等待并返回。
  • 死锁检测算法 - wait-for graph
  • 外力介入打破僵局:第三者介入,将死锁情况中的某个事务/线程强制结束,让其他事务继续执行。
  • 如何避免死锁产生?
    • 缩短事务持有锁的时间、减小锁的粒度以及锁的数量
    • 业务允许的情况下,将隔离级别调低,因为级别越低,锁的限制会越小
    • 尽量不要手动在事务中获取排他锁,否则会造成一些不必要的锁出现,增大产生死锁的几率
    • 调整业务SQL的逻辑顺序,较大、耗时较长的事务尽量放在特定时间去执行(如凌晨对账...)

锁机制的底层实现原理

锁的内存结构

  • 锁的事务信息
  • 索引的信息
  • 锁粒度信息
  • 锁类型信息
  • 其他信息
  • 锁的比特位

参考文章:juejin.cn/column/7140…