MySQL事务的隔离性是如何实现的

目录
  • 并发场景
    • 写-写
    • 读-读
    • 读-写 和 写-读
  • mysql中的锁
    • 行级锁
    • 表级锁
  • 隔离级别
    • read committed
    • repeatable read
  • 参考博客

    并发场景

    最近做了一些分布式事务的项目,对事务的隔离性有了更深的认识,后续写文章聊分布式事务。今天就复盘一下单机事务的隔离性是如何实现的?

    隔离的本质就是控制并发,如果sql语句就是串行执行的。那么数据库的四大特性中就不会有隔离性这个概念了,也就不会有脏读,不可重复读,幻读等各种问题了

    对数据库的各种并发操作,只有如下四种,写写,读读,读写和写读

    写-写

    事务a更新一条记录的时候,事务b能同时更新同一条记录吗?

    答案肯定是不能的,不然就会造成脏写问题,那如何避免脏写呢?答案就是加锁

    读-读

    mysql读操作默认情况下不会加锁,所以可以并行的读

    读-写 和 写-读

    基于各种场景对并发操作容忍程度不同,mysql就搞了个隔离性的概念。你自己根据业务场景选择隔离级别。

    √ 为会发生,×为不会发生

    隔离级别 脏读 不可重复读 幻读
    read uncommitted(未提交读)
    read committed(提交读) ×
    repeatable read(可重复读) × ×
    serializable (可串行化) × × ×

    所以你看,mysql是通过锁和隔离级别对mysql进行并发控制的

    mysql中的锁

    行级锁

    innodb存储引擎中有如下两种类型的行级锁

    • 共享锁(shared lock,简称s锁),在事务需要读取一条记录时,需要先获取改记录的s锁
    • 排他锁(exclusive lock,简称x锁),在事务要改动一条记录时,需要先获取该记录的x锁

    如果事务t1获取了一条记录的s锁之后,事务t2也要访问这条记录。如果事务t2想再获取这个记录的s锁,可以成功,这种情况称为锁兼容,如果事务t2想再获取这个记录的x锁,那么此操作会被阻塞,直到事务t1提交之后将s锁释放掉

    如果事务t1获取了一条记录的x锁之后,那么不管事务t2接着想获取该记录的s锁还是x锁都会被阻塞,直到事务1提交,这种情况称为锁不兼容。

    多个事务可以同时读取记录,即共享锁之间不互斥,但共享锁会阻塞排他锁。排他锁之间互斥

    s锁和x锁之间的兼容关系如下

    兼容性 x锁 s锁
    x锁 互斥 互斥
    s锁 互斥 兼容

    update,delete,insert 都会自动给涉及到的数据加上排他锁,select 语句默认不会加任何锁

    那什么情况下会对读操作加锁呢?

    • select … lock in share mode,对读取的记录加s锁
    • select … for update ,对读取的记录加x锁
    • 在事务中读取记录,对读取的记录加s锁
    • 事务隔离级别在 serializable 下,对读取的记录加s锁

    innodb中有如下三种锁

    • record lock:对单个记录加锁
    • gap lock:间隙锁,锁住记录前面的间隙,不允许插入记录
    • next-key lock:同时锁住数据和数据前面的间隙,即数据和数据前面的间隙都不允许插入记录

    写个demo演示一下

    create table `girl` (
      `id` int(11) not null,
      `name` varchar(255),
      `age` int(11),
      primary key (`id`)
    ) engine=innodb default charset=utf8;
    
    insert into girl values
    (1, '西施', 20),
    (5, '王昭君', 23),
    (8, '貂蝉', 25),
    (10, '杨玉环', 26),
    (12, '陈圆圆', 20);
    

    record lock

    对单个记录加锁

    如把id值为8的数据加一个record lock,示意图如下

    record lock也是有s锁和x锁之分的,兼容性和之前描述的一样。

    sql执行加什么样的锁受很多条件的制约,比如事务的隔离级别,执行时使用的索引(如,聚集索引,非聚集索引等),因此就不详细分析了,举几个简单的例子。

    -- read uncommitted/read committed/repeatable read 利用主键进行等值查询
    -- 对id=8的记录加s型record lock
    select * from girl where id = 8 lock in share mode;
    
    -- read uncommitted/read committed/repeatable read 利用主键进行等值查询
    -- 对id=8的记录加x型record lock
    select * from girl where id = 8 for update;
    

    gap lock

    锁住记录前面的间隙,不允许插入记录

    mysql在可重复读隔离级别下可以通过mvcc和加锁来解决幻读问题

    当前读:加锁
    快照读:mvcc

    但是该如何加锁呢?因为第一次执行读取操作的时候,这些幻影记录并不存在,我们没有办法加record lock,此时可以通过加gap lock解决,即对间隙加锁。

    如一个事务对id=8的记录加间隙锁,则意味着不允许别的事务在id=8的记录前面的间隙插入新记录,即id值在(5, 8)这个区间内的记录是不允许立即插入的。直到加间隙锁的事务提交后,id值在(5, 8)这个区间中的记录才可以被提交

    我们来看如下一个sql的加锁过程

    -- repeatable read 利用主键进行等值查询
    -- 但是主键值并不存在
    -- 对id=8的聚集索引记录加gap lock
    select * from girl where id = 7 lock in share mode;
    

    由于id=7的记录不存在,为了禁止幻读现象(避免在同一事务下执行相同的语句得到的结果集中有id=7的记录),所以在当前事务提交前我们要预防别的事务插入id=7的记录,此时在id=8的记录上加一个gap lock即可,即不允许别的事务插入id值在(5, 8)这个区间的新记录

    给大家提一个问题,gap lock只能锁定记录前面的间隙,那么最后一条记录后面的间隙该怎么锁定?

    其实mysql数据是存在页中的,每个页有2个伪记录

    • infimum记录,表示该页面中最小的记录
    • upremum记录,表示该页面中最大的记录

    为了防止其它事务插入id值在(12, +∞)这个区间的记录,我们可以给id=12记录所在页面的supremum记录加上一个gap锁,此时就可以阻止其他事务插入id值在(12, +∞)这个区间的新记录

    next-key lock

    同时锁住数据和数据前面的间隙,即数据和数据前面的间隙都不允许插入记录
    所以你可以这样理解next-key lock=record lock+gap lock

    -- repeatable read 利用主键进行范围查询-- 对id=8的聚集索引记录加s型record lock-- 对id>8的所有聚集索引记录加s型next-key lock(包括supremum伪记录)select * from girl where id >= 8 lock in share mode;

    因为要解决幻读的问题,所以需要禁别的事务插入id>=8的记录,所以

    • 对id=8的聚集索引记录加s型record lock
    • 对id>8的所有聚集索引记录加s型next-key lock(包括supremum伪记录)

    表级锁

    表锁也有s锁和x锁之分

    在对某个表执行select,insert,update,delete语句时,innodb存储引擎是不会为这个表添加表级别的s锁或者x锁。

    在对表执行一些诸如alter table,drop table这类的ddl语句时,会对这个表加x锁,因此其他事务对这个表执行诸如select insert update delete的语句会发生阻塞

    在系统变量autocommit=0,innodb_table_locks = 1时,手动获取innodb存储引擎提供的表t的s锁或者x锁,可以这么写

    对表t加表级别的s锁

    lock tables t read
    

    对表t加表级别的x锁

    lock tables t write

    如果一个事务给表加了s锁,那么

    • 别的事务可以继续获得该表的s锁
    • 别的事务可以继续获得表中某些记录的s锁
    • 别的事务不可以继续获得该表的x锁
    • 别的事务不可以继续获得表中某些记录的x锁

    如果一个事务给表加了x锁,那么

    • 别的事务不可以继续获得该表的s锁
    • 别的事务不可以继续获得表中某些记录的s锁
    • 别的事务不可以继续获得该表的x锁
    • 别的事务不可以继续获得表中某些记录的x锁

    所以修改线上的表时一定要小心,因为会使大量事务阻塞,目前有很多成熟的修改线上表的方法,不再赘述

    隔离级别

    读未提交:每次读取最新的记录,没有做特殊处理
    串行化:事务串行执行,不会产生并发

    所以我们重点关注读已提交可重复读的隔离实现!

    这两种隔离级别是通过mvcc(多版本并发控制)来实现的,本质就是mysql通过undolog存储了多个版本的历史数据,根据规则读取某一历史版本的数据,这样就可以在无锁的情况下实现读写并行,提高数据库性能

    那么undolog是如何存储修改前的记录?

    对于使用innodb存储引擎的表来说,聚集索引记录中都包含下面2个必要的隐藏列

    trx_id:一个事务每次对某条聚集索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列

    roll_pointer:每次对某条聚集索引记录进行改动时,都会把旧的版本写入undo日志中。这个隐藏列就相当于一个指针,通过他找到该记录修改前的信息

    如果一个记录的name从貂蝉被依次改为王昭君,西施,会有如下的记录,多个记录构成了一个版本链

    为了判断版本链中哪个版本对当前事务是可见的,mysql设计出了readview的概念。4个重要的内容如下

    • m_ids:在生成readview时,当前系统中活跃的事务id列表
    • min_trx_id:在生成readview时,当前系统中活跃的最小的事务id,也就是m_ids中的最小值
    • max_trx_id:在生成readview时,系统应该分配给下一个事务的事务id值
    • creator_trx_id:生成该readview的事务的事务id

    当对表中的记录进行改动时,执行insert,delete,update这些语句时,才会为事务分配唯一的事务id,否则一个事务的事务id值默认为0。

    max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比如现在有事务id为1,2,3这三个事务,之后事务id为3的事务提交了,当有一个新的事务生成readview时,m_ids的值就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4

    执行过程如下:

    • 如果被访问版本的trx_id=creator_id,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问
    • 如果被访问版本的trx_id<min_trx_id,表明生成该版本的事务在当前事务生成readview前已经提交,所以该版本可以被当前事务访问
    • 被访问版本的trx_id>=max_trx_id,表明生成该版本的事务在当前事务生成readview后才开启,该版本不可以被当前事务访问
    • 被访问版本的trx_id是否在m_ids列表中
    • 4.1 是,创建readview时,该版本还是活跃的,该版本不可以被访问。顺着版本链找下一个版本的数据,继续执行上面的步骤判断可见性,如果最后一个版本还不可见,意味着记录对当前事务完全不可见
    • 4.2 否,创建readview时,生成该版本的事务已经被提交,该版本可以被访问

    好了,我们知道了版本可见性的获取规则,那么是怎么实现读已提交和可重复读的呢?

    其实很简单,就是生成readview的时机不同

    举个例子,先建立如下表

    create table `girl` (
      `id` int(11) not null,
      `name` varchar(255),
      `age` int(11),
      primary key (`id`)
    ) engine=innodb default charset=utf8;
    

    read committed

    read committed(读已提交),每次读取数据前都生成一个readview

    下面是3个事务执行的过程,一行代表一个时间点

    先分析一下5这个时间点select的执行过程

    • 系统中有两个事务id分别为100,200的事务正在执行
    • 执行select语句时生成一个readview,mids=[100,200],min_trx_id=100,max_trx_id=201,creator_trx_id=0(select这个事务没有执行更改操作,事务id默认为0)
    • 最新版本的name列为西施,该版本trx_id值为100,在mids列表中,不符合可见性要求,根据roll_pointer跳到下一个版本
    • 下一个版本的name列王昭君,该版本的trx_id值为100,也在mids列表内,因此也不符合要求,继续跳到下一个版本
    • 下一个版本的name列为貂蝉,该版本的trx_id值为10,小于min_trx_id,因此最后返回的name值为貂蝉

    再分析一下8这个时间点select的执行过程

    • 系统中有一个事务id为200的事务正在执行(事务id为100的事务已经提交)
    • 执行select语句时生成一个readview,mids=[200],min_trx_id=200,max_trx_id=201,creator_trx_id=0
    • 最新版本的name列为杨玉环,该版本trx_id值为200,在mids列表中,不符合可见性要求,根据roll_pointer跳到下一个版本
    • 下一个版本的name列为西施,该版本的trx_id值为100,小于min_trx_id,因此最后返回的name值为西施

    当事务id为200的事务提交时,查询得到的name列为杨玉环。

    repeatable read

    repeatable read(可重复读),在第一次读取数据时生成一个readview

    可重复读因为只在第一次读取数据的时候生成readview,所以每次读到的是相同的版本,即name值一直为貂蝉,具体的过程上面已经演示了两遍了,我这里就不重复演示了,相信你一定会自己分析了。

    参考博客

    [1]https://souche.yuque.com/bggh1p/8961260/gyzlaf
    [2]https://zhuanlan.zhihu.com/p/35477890

    到此这篇关于mysql事务的隔离性是如何实现的的文章就介绍到这了,更多相关mysql事务的隔离性内容请搜索www.887551.com以前的文章或继续浏览下面的相关文章希望大家以后多多支持www.887551.com!

    (0)
    上一篇 2022年3月21日
    下一篇 2022年3月21日

    相关推荐