MVCC&Next-Key Lock&索引

360 阅读17分钟

之前已经写过一个mysql原理和MVCC以及锁的文章了,写着一篇是因为觉得之前对这个东西的理解还不够深刻,这里掰扯碎一点儿,一锤子干到底。

事务隔离级别

因为设计到间隙锁,所以还是有必要再重复说一下事务隔离级别的,而且里面也有些东西需要说清楚。

  • 读未提交:顾名思义,就是一个事务可以读取到,其他事务修改中但是未提交的数据,这回造成脏读的问题,别人没提交最后会滚了,那不是傻了。
  • 读已提交:就是事务之间只能读取到其他事务提交后的数据。
  • 可重复读:就是一个事物中,重复读一条数据结果是一样的。

其中读提交和可重复读应用了undolog来实现了回滚的机制。这里面其实有个问题,为什么mysql的默认隔离级别是可重复读,读提交为什么不行?其实这个问题的答案是mysql的历史问题。这个涉及到mysql的binlog备份问题,binlog日志有三种模式

  • statment:记录sql执行的语句;
  • row:记录更改行的数据;
  • mix:混合模式;

因为在mysql5.0之前只支持statement的模式,也就是只记录sql语句,想象一下,如果有一条语句是更新表中k=5的数据,然后这个时候另一个事务insert了一行数据为5的数据,然后第一条语句提交了,这个时候在binlog里面的日志因为是按照最后提交事务的顺序来保存的,这个时候就会先保存插入,在保存更新,这个时候,你如果拿这份日期去备份重放数据库,或者主从同步,他就会把插入的那个数据也给更新了,造成数据失败。这个东西本质上和mysql引入innodb后需要二阶段提交保证binlog和redolog的一致性的出发点是一样的,怎么说呢,感觉就跟平常迭代中,你打个补丁去妥协以前的版本一样。

在mysql中通过增加间隙锁的方式,就避免了这个问题。其实在读已提交中可以通过执行binlog为mix的方式,从而减少增加间隙锁的范围来增加并发度。

undolog与MVCC

一致性视图

之前的文章提到过,MVCC是通过undolog的版本链来实现的,但是没有说透这个undolog的版本链到底是个啥东西。不妨先来回顾一下快照,或者一致性试图(read-view)

一致性视图,其实这个东西就是,当一个事务启动的时候,会在这个当前事务的所属内存或者对象中,创建一个活跃数组,这个玩意儿其实就是mysql查询出来当前的活跃事务,启动了但是没有提交的事务,给你组装成一个数组,那在这个基础之上,你现在启动的事务能看到,哪些修改的数据呢,都知道事务的id是按照启动顺序的一个递增id,那么好了,活跃数组里面的最小的id,在这个最小id之前的事务提交的东西,当前的事务都能看到,这没有异议,然后活跃数组里面的事务,在我这个事务启动以前还没有提交,那么对不起了,你就算在我事务运行过程中,提交了 我也看不到。还有就是在我当前事物启动之前,创建的事务id比我大的事务,因为事务启动分两个状态 一个是创建一个是提交,当前事物启动的时候,有些比我大的事务已经创建甚至已经提交完成了,那么ok,我是可以看到的,但是在这之后创建的事务我是看不到的。那么总结起来就是,当前事务启动那一刻

  • 版本未提交,对不起了老铁对我不可见。
  • 版本已提交,ok我可以看到,
  • 在我启动之后创建的,对不起,拜拜

undolog版本链

其实上面说的这个一致性视图,说明了一个问题,或者说给了我们一个判断方法,read-view是判断数据版本对当前事务是否可见

那么一直让我费解的是,我去哪里,或者说这个方法到底用来判断什么东西。其实就是undolog的版本链。

其实undolog是在每一条数据后面加了三个字段 rowid,trxid,rollptr,这三个东西分别就是:6个字节的行id,6个字节的事务id,7个字节的回滚指针。

undolog开始是存在表空间的,后面单独开辟了一个空间,这句话是什么意思,这句话其实暗含了你可以把这玩意儿看成一张表,那么既然是表,那他就需要用redolog来守护,是的,undolog需要写入redolog,不然如果mysql crash掉了,没有提交的事务怎么回滚回去。下面看看他是怎么根据undolog进行一致性视图的构建的,这也是从别的博客扒来的图。

上面这个图其实已经看的比较清楚了,undolog里面其实记得就是这个单链表根据回滚指针串起来的一个链,看到有的博客里说,在并发情况下,如何记录更改前的数据,比如,多个事务更改同一条数据,需要记录多分?其实这里可以看到更改数据肯定会先加行锁,所以不会有多个事务访问同一个记录,都阻塞在行锁上。

上面也说到了undolog类似于表的结构,其实是segement的结构,它分为两部分一部分是给insert语句用的,因为insert语句是新加入的语句,不会有事务去查询他之前的版本,所以insert语句的事务在提交之后,undolog就可以被删掉了,他是通过临时表的方式去记录insert语句变跟前的状态的。但是update语句就不一样了,需要去根据undolog构造出来的版本链去回溯之前的数据,那么就不能轻易删除了,他其实是通过标记加入一个purge线程的清理队列去实现删除的,那么什么时候才能删除update的undolog?假如事务A正在运行,事务B更改了一条数据,并且提交了,这时候事务A去查询这个数据,怎么知道更改前的数据,因为如果已提交undolog就删除了,那根本找不到之前的数据。我搜了一些文章,没有怎么说的太清楚的文章,都是在讨论undolog结构或者mysql底层函数的。那按我的理解猜测,这个决定能不能清理的过程也是通过一致性试图来实现的,比如在我当前事务一致性试图高水位之上的事务,也就是在当前事务启动后创建的事务,因为我要在你们更改过后的数据上往前追溯,那么你们的undolog就不能删除,因为删除了之后我就没法追溯了,也包括活跃事务组里没提交的事务也不能删除。那么什么可以删除,在当前事物启动之前已经提交的事务,因为你们的更改数据就是我的底线,我的基础,不可能再往下追溯了,那么就可以删除。总结一下就两点:

  • 启动前已提交,可以删除。
  • 启动后已提交,不可删除。

这样mysql在删除undolog数据的时候,就能通过当前所有事务的一致性视图,其实也就是判断低水位之下的事务的undolog,其实是可以删除的。具体参考的文章在这里贴出来。MySQL · 引擎特性 · InnoDB undo log

next-key lock

这个玩意儿实际上也是一个比较诡异的东西,因为这个加锁范围判断其实是比较麻烦的一个事情,这个nextkey lock只在RR可重复读级别下生效。

  1. 加锁的基本单位是next-keylock,是一个前开后闭的区间。

  2. 查找过程中其实是在索引上加锁,也就是列上加锁,所以会出现如果两个条件一个是通过主键更新,一个是通过索引更新,可能会出现死锁的问题。

  3. 唯一索引上的等值查询,next-lock会退化成行锁。

  4. 普通索引上的等值查询会在b+树的叶子层上向右遍历到第一个不满足的条件,会退化成间隙锁。

  5. 唯一索引上的范围查询会访问到不满足条件的第一个值,这个值上也会加互斥锁。

  6. 等值查询间隙锁

先来说一下加锁的过程如果没有索引列的更新,update t set b=2 where c=5;其实c列上没有索引,所以他会直接在主键上加锁,所以这个等值查询如果这个c=5不存在,他会一条一条扫描直到扫描最后一行都不满足条件,但是这个事务没结束,那么就会在最后一条记录上加一个间隙锁,比如最后两条记录是c=3 c=4,那么其实就是会加一个(3,4)的一个间隙锁。

  1. 非唯一索引等值锁

因为其实加锁是锁索引的,所以加锁存在一个顺序,如果一个表中有多个列需要锁索引,假如根据id去更新和根据一个普通索引去更新,那么存在这样一个问题,id的更新需要获取索引的锁,索引的锁需要获取id的锁,就会造成死锁。同时lock in share mode这样加读锁的方式,其实存在一个优化,就是他不认为你需要更新主键,所以它不会去加主键的锁,所以造成虽然你把这个索引锁上了,但是其他事务根据id去更新其他列数据,是一样可以更新的。所以如果需要给行加锁避免更新 那就需要使用for update这样的直接加互斥锁的语句。

  1. 间隙锁造成的死锁

next-key lock 看起来是一步操作,但是其实是两步,先加间隙锁,再加行锁,如果事务A锁住了lock(5,10]这个间隙锁+10这个行锁,这时候如果事务B申请的也是lock(5,10]因为间隙锁是兼容的,所以lock(5,10)这个间隙锁加锁成功,但是10这个行锁会被阻塞,然后如果事务Ayou运行了一个insert语句,这个时候就会被B的lock(5,10)的间隙锁锁住了,就造成了事务A等待事务B的间隙锁释放,事务B等待事务A的行锁释放,造成死锁。

各种锁类型

  1. 数据库表层面

    这个层面其实有两种,一种是读锁写锁,也就是共享锁和排他锁,读读兼容,读写、写写阻塞。但是其实在这个层面还有一个锁,是数据库自己用的,就是意向锁,有分为意向写锁,意向读锁,其实这两个意向锁之间是兼容的,但是,跟表的读写锁是互斥的。因为如果你需要加一个表读锁,这个时候其实可以看表的有没有写锁,但是你还需要看里面的记录有没有行锁,那么就需要看所有的记录是不是都没有行锁,这是一个非常低效的一个操作,所以就会加一个意向写锁,表示 我这里面有行锁,所以加表锁的操作就会被阻塞。这就是意向锁的意义。

  1. 表记录层面

表记录层面分为 间隙锁、行锁、插入意向锁、next-key lock;

其实行锁和间隙锁还有next-key lock比较容易懂了,行锁就是为了避免并发写的操作,其实所有的锁都是为了保证原子操作,比如我要更新id=10的记录,但是另一个事务上来就把这条记录删了,我还更新鬼啊。间隙锁是为了避免数据库binlog同步的一致性加的。next-key lock就是行锁和间隙锁的组合。

这个插入意向锁,其实是为了跟间隙锁去排他,如果有间隙锁,insert操作首先会申请一个插入意向锁,如果这个是时候插入位置没有间隙锁,那么就在这个记录上加一个排他锁,为什么?因为,如果插入的数据是一个id = 5的记录,然后另一个事物是更新id=5 的记录,这样就会被阻塞,从而不会导致binlog不一致。

insert语句经常造成的一个死锁就是,主键冲突的死锁,第一个事务insert成功之后会加一个互斥锁,然后第二个事务插入同样的id之后就会发生索引冲突,然后会报错,但是报错同时还会在这个索引上加上一个读锁,至于为什么加上这个读锁,其实也不太明白,没有找到问上把这个说透的,普遍的理由是说是为了不让这一行被别的事事务删掉,感觉这理由不够硬。如果这个时候有第三个事务也运行了insert发现主键冲突,那么也会加上一个读锁,这时候如果第一个事务提交或者回滚了,当前主键记录上的排他锁也就是写锁就会释放,这个时候 第二第三个事务回去竞争这个写锁,因为要更新记录的话必须要拿到主键索引上的写锁,但是第二第三个事务又都持有这个记录上的同一个非主键索引的读锁,所以就会导致死锁。

  1. MDL锁

这个锁其实相对特殊一点儿,他是为了避免DML和DDL的数据不一致,比如我正在查询数据,但是你突然ddl更改了表的结构删了一个列,那我一条sql前十个记录和后十个记录的的列都不一样了,索引运行DML的时候会默认在表上家上MDL锁,用来排斥DDL语句。但是这个在mysql5.7之后有了变化,因为这个操作你如果在线上加字段的话,会把所有的业务DML都给阻塞,那不是爆炸了,所以它提供了一种onlineddl的操作,其实这个操作是借鉴了copyonwrite的一种思想,就是我更改ddl的时候,把数据表给他在新的表中进行复制操作,然后有一个rowlog用来存在复制过程中增量的记录,修改完成后在加到新的表里。

索引

索引这个东西下面放的参考文章讲的比较细了。

这个图上就是表示的就是一个聚簇索引的B+树的叶子节点,也就是主键的叶子结点,里面包含了一个前后前后页的指针,一个节点就是一页,然后里面存储了数据,一条一条案主键的顺序排列。里面这个也目录其实就是为了查询效率快,然后根据排序又抽出来的一级,其实跟跳表的逻辑有点儿像。然后页之间用前后指针串起来。最后再叶子结点上抽出来这个页目录再次构建一层,就构成了B+树的结构。 这里可以看到聚集索引是数据放在一起的,而普通索引叶子节点上挂的是主键聚集索引的id。

索引页分裂与合并

其实上面说的的页,每次插入数据的时候都有可能会发生分裂或者合并。

  • 页合并:当你删了一条数据的时候只是标记他为删除,并没有真的删除,当页的数据量小于页体积的50%的时候,他就会看前后的页是不是可以合并优化使用空间。

  • 页分裂:当插入数据的时候,如果当前页满了,不够用了,并且相邻的页也不能插入了,那么就会创建一个新页,然后转移数据,到新的页,保持页的大小尽量平衡,因为页之间是用双链表连起来的,其实就可以认为新的页在原来的两个页中间插入了。

在合并和分裂的过程中,会在索引树上加写锁,这样就降低了性能,所以频繁的分裂和合并,会导致锁竞争,所以不能使用uuid作为主键,因为没有顺序会经常发生插入数据需要移动,那么就会发生分裂和合并。InnoDB中的页合并与分裂这片文章介绍的相对详细。

其他

其实在工作中遇到过几次锁或者事务隔离性的问题:

1. 没有更新条件等于锁表

在可重复读的隔离级别下面,加锁的级别是next-key lock,如果以没有索引的字段作为条件去更新那么会直接加在主键索引上,没有条件的话基本上所有的数据的行锁和间隙锁都会加上,不能插入不能修改,就基本上等于锁表了。之前mybatis中条件拼接的时候,因为条件字段为null值,所以没有加任何条件,本身那条语句的本意是给符合条件的的记录,进行操作记录times字段+1的操作,结果把整个表锁了。杯具了就。。。。

2. 可重复读级别获取不到最新的数据

之前在公司的系统中看到代码里进行了查询最新添加数据或者最新更改数据的做法,其实根本获取不到,因为在可重复读级别下,看到的数据只能是事务开始之前提交的,只能在读提交级别下才能进行这个操作。所以是无意义的。

还有一个操作是之前维护一个代金券系统产生的,在应用层中,使用了select一条未发放的代金券的记录,然后在update,如果update失败或者更新条目为0,那么说明其他人的事务已经使用了这张代金券,就循环再去select一张可用的,这个又点儿cas+自旋的味道了,但是这里有两个问题,一个是这样并发情况下其实可能会出现饿死的情况,导致一个超长事务,如果这个操作之前还进行很多操作,那么无疑会保持一个很长的undolog的版本链,无论是失败回滚还是undolog日志大小都会受到影响。还有一个是,当在这个事务还在执行,代金券已经用完了,但是我在用完之前我又补充了一批代金券,但是我在这个事务启动之前已经生成一致性试图了,我只能看到这么几个代金券,这个时候其实这个事务是会失败的,select会变成0,只能给用户报错了。。。。其实这个东西可以使用redis去做。。但是需要处理超卖的情况,保证库存扣减的准确性。




参考文章: