事务、锁 小结

缘起

天下大乱久矣!!!百姓望廓清轮宇久矣!!!

遂花了25块钱买了【1】的mysql小册仔细阅读(非安利,这真是一本好书,至少让只会连接mysql进行CRUD的java程序员对mysql的理解上一个台阶,不夸张的~), 阅读完毕,感觉已经到了廓清轮宇的时候了. 于是今天来写这篇文章, 来彻底明晰一下mysql(只以mysql为例,博主在电商,用的就是mysql,本文所有的讨论都是基于mysql5.7.23)的事务和锁中的相关概念,原先其实看过很多文章,但是比较凌乱,而且不知道这些概念是否有交叉,也不知道所谓的事务和锁在底层到底是个什么东西. 所以一直迷迷糊糊的~ 个人非常、极度讨厌这种不在掌控中的东西!!!

而且这里我吐槽一下——极度厌恶以及鄙视网上那种为了应付面试写的什么”sql索引优化军规36条”这种没营养的文章. 妈的,说难听点不就是死记硬背么~ 不明白底层原理, 有卵用? 闲话不多说,进入正题.

分析

本文的目的是为进一步弄清楚spring事务框架打基础.

首先,程序是现实世界的映射. 所以一定要明白事务在生活中的情景是啥——了解过事务的程序员估计都熟知的那个两个银行账户之间转账的问题吧~ 就是那个例子引入了事务的ACID特性.

那么,我们想问,事务在计算机中到底是啥? 或者这么问——事务在计算机中到底是怎么实现的?

不卖关子了, 事务本质上就是一组undo日志. undo日志由回滚段进行管理(mysql系统表空间的5号页面).

说白了就是做之前记账,记什么账? 你小时候下过象棋吗? 悔过棋吗? 悔棋的前提是你得记得原先你的子儿在哪儿啊? 所以你脑海里面就必须要有一份——“原先棋局长什么样子”的备份. 如果没有,没法悔棋的.

同样的道理,mysql怎么保证原子性和一致性呢? 就是靠的这组undo日志.

事务中的crud的sql执行的顺序是,先写入缓存页(mysql中叫做Buffer Pool,是将磁盘上的页缓存到内存中提高访问速度的),

不急着写入表空间的文件中(这才是ACID中的D,但是毕竟磁盘,尤其是机械盘的IO贼慢),但这样就有一个问题了——缓存中有脏页(和磁盘数据库文件不同步的页),万一掉电的话,我这边即使事务提交成功,数据库磁盘文件中也没有了. 违背了ACID中的D. 怎么解决呢?

解决办法就是在事务提交前写记录下你干了啥, 做了哪些修改. 这种东西就叫做redo日志. 这样的好处是显然的——即便掉电, Buffer Pool中的脏页数据全部丢失, 但是我们还有redo日志啊~ 就可以使用redo日志恢复已经做到的事情. 所以redo日志你可以理解为——说过的话一定要做到! 而且除此之外,写redo日志,而不是直接刷脏页到磁盘数据库文件的好处还有——redo日志是顺序IO(redo日志是文件,写redo日志是顺序IO),而直接刷脏页是随机IO,高下立判. 其次,如果我所谓的脏页只是修改了一个字节,为此你就要给我随机IO刷16KB的脏页到磁盘,是不是有点太壕放了点?

而且redo日志也不是直接写的,mysql也精心设计了redo日志缓冲区. redo日志刷进文件的时机有该日志缓冲区空间不足、事务提交、后台守护线程、正常关闭服务、checkpoint(防止redo日志空间不足,循环覆盖). 详细参见【5】.

值得注意的一点是——undo日志也是往数据库页面写,对它也是要记录redo日志的. 所以我们基本可以认为,undo日志是可靠的. 所以既然undo有了, 那么ACID的ACD都有了保障. 那么I呢? 即所谓事务的隔离性怎么保证?

隔离性就这么谈,比较抽象,我们从并发的角度来谈事务的隔离性. 首先,我们再问一遍,事务底层是什么? 其实不就是一个写undo日志的进程了嘛~ 所谓事务并发指的就是N多写undo日志的进程之间的并发嘛~ 那么也就不难理解什么叫做web站点的事务吞吐率了. 其实就是看你这个网站的事务并发能力如何.

那么,两个事务对同一条记录做修改的时候,出于安全角度,你会怎么做? 参考线程并发,自然是加锁让其顺序执行,而不是并发导致不可预知的结果啦. 但是这样做的缺点是明显的——降低了事务的吞吐量. 怎么办呢? 那就自然不能让事务顺序执行,所以我们反问自己——真的有必要让事务顺序执行么? 因此就自然引出了事务的隔离级别——即你想达到多好的隔离性,隔离级别越高,顺序程序也越高.

SQL 组织(非mysql官方)规定了隔离级别升序排列

1
read uncommitted < read commited (下简称RC) < repeatable read(下简称RR)< serilizable

也就是——我们必须要在事务的吞吐量和事务的顺序程度之间做一个平衡. 因此才有了事务隔离级别的概念. 这里不会详述这些隔离级别到底啥个意思(即它们到底意味着什么样的事务隔离性,即ACID中的I),详见【6】.

RC和RR是最常用的两个隔离级别, 其中RR是mysql默认的隔离级别. 那么RC和RR到底是怎么实现的呢? 有两种解决方案.

  1. MVCC机制,或者叫一致性读、快照读. 这是基于历史undo版本构建的无锁读. 它是用于解决读写高性能并发问题的. MVCC机制提供了一套算法来决定每次select查询每条记录到底要不要展示,如果要展示,展示哪个版本的历史数据. 其实,数据长下面的样子

    所以记住过去(undo日志)的好处还可以为MVCC服务. 所以两个事务对一条数据的读写其实是在不同版本的数据上进行的. 并不会诱发竞争, 正是这个原因, MVCC的并发性能很高. 以至于mysql对于任何普通的select语句采用的都是MVCC机制. 所谓普通的select指的是不带 for update(拿X锁),与 lock in share mode(拿S锁)的select语句. 至于锁,稍安勿躁,后续登场. 为什么MVCC可以实现RR、RC隔离效果呢? 因为MVCC对于普通select引入了ReadView机制,里面有一个重要概念就是m_ids,即创建select事务开始的时候,当前数据库活跃的事务id集合们. RC和RR重要的区别在于ReadView创建的时机, 对于RR,只在事务中创建一次,而对于RC, 事务中每次select都创建一个. 所以RR不能读到其他事务已提交的修改,但是RC可以. 这就是MVCC实现隔离性的机制.

  2. 锁机制. 一般指的是for update获取 X锁. 那么为什么需要这种机制呢? MVCC不好吗? MVCC机制的一个特点是——你可能在你的事务读到的数据全部是历史版本的数据! 这就是MVCC机制无锁实现读写高并发的代价!!! 但是有的时候你是不能读取到历史数据来做业务的. 举个简单的例子. 你分两笔转账给你女朋友. 第一次转了5块钱(事务T1),第二次又转了5块钱(事务T2, 原谅我,就是这么抠). 现实世界中,这两次是顺序无并发的, A是我的账户,伊始有11块钱,B是我女朋友账户,伊始有2块钱

但很不幸,mysql中并不会这样,mysql中很可能会变成下面这个样子

最后却变成了我仅仅扣了5块钱,女朋友却增加了10块钱,四大行也经不起你这样折腾啊!

错误的根本原因在于T2读取A读到的是历史版本的数据,要想正确一定要是最新版的数据,所以T2读取A一定要在事务T1结束之后,也就是对于二次转账这种事务并发问题就应该让事务顺序无并发,也就是serilizable隔离级别才行. 解决方案就是加X锁(即写锁). 类似的情景在【4】中也有. 我觉得那里才叫真正的幻读!!!(否则mysql的RR下的MVCC机制已经能够解决幻读了,也就不需要serilizable级别来解决幻读了. ),解决的办法和这里是一模一样的. 都是给select读加X锁(就不是普通的select,MVCC机制就不再适用了). 使得别的事务无法进入修改最新数据,所以能保证当前事务一直读取的是最新数据. 上述二次转账问题就迎刃而解了. 实际上,这里锁的感觉就是实现顺序性, 而且一旦实现了顺序性,读写、读读、写写并发都没问题了(MVCC机制仅仅处理读写并发,但是优势是可以做到无锁,高效),但是一大诟病就是需要加X锁导致吞吐量低, 性能不佳. 所以【4】也提到了为什么mysql会选择RR作为默认的隔离级别? 因为serilizable级别就是在你所有的select语句给你加上X锁!!! 但其实幻读的出现频率并不高(符合二八定律),所以不需要牺牲那么多性能为代价——就为了防止少量发生的幻读. 而如果RR需要防止【4】中提到的幻读或者事务必须顺序的情况下,可以人为加for update拿X锁嘛~

好了,说了这么多锁的话题. 问一句:锁是什么? 换句话问:锁在mysql中的底层实现是什么呢? 和事务是什么关系? 怎么关联起来的? 锁的分类是什么? 问了这么一大通. 下面一一解答.

其实事务只是修改undo日志的进程而已,锁只是人为加上去的,和事务没有半毛钱关系. 事务的进行完全可以不加锁,或者说事务的并发过程中没有锁参与(MVCC机制不就是这样吗?)。那么锁是什么呢? 其实就是一块内存结构(你可以把它想成C语言中的结构体). 它上面保存了事务的信息, 也就是是哪个事务挂上的这把锁 以及此锁是否加成功了(is_waiting属性,false表示不在等待,即加锁成功,true表示在等待,表示加锁失败,正在等待前面的事务提交释放锁). 当一个事务想对一条记录做改动时,首先会看看内存中有没有与这条记录关联的锁,当没有的时候就会在内存中生成一个锁结构与之关联。比方说事务T1要对这条记录做改动,就需要生成一个锁结构与之关联. 而且如果之前没有任何事务对此记录进行锁定的话,则称T1加锁成功. 如果在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先去看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构is_waiting属性值为true,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,或者说没有成功的获取到锁。

锁的分类

有2种分类

  1. 行锁和表锁.

  2. X锁和S锁.

其中1和2并无互斥情况, 也就是说存在行X锁,表X锁,行S锁,表S锁四种,只是用的多寡的区别而已.

表锁用的很少(除了自增主键的AUTO_INC锁),但是唯一要提及的一点是innodb进行select、update的时候,如果索引没有命中(即没有使用索引),则行锁膨胀为表锁, 就可能导致其他查询超时.

mysql 中用的较多的是行锁的如下分类

  1. 记录锁 (Record Locks )
  2. 间隙锁 (Gap Locks )
  3. 临键锁(Next-Key Locks )

1,2,3都有可能是X锁或者S锁,但是因为他们功能明确,所以其实平时我们经常淡化他们到底是X锁还是S锁.

记录锁一般由例如

1
2
# 这里需要用 X锁, 用 LOCK IN SHARE MODE 拿到 S锁 后我们没办法做 写操作
SELECT `id` FROM `users` WHERE `id` = 1 FOR UPDATE;

的方式产生,注意,id 列必须为 唯一索引列 或 主键列 ,否则上述语句加的锁就会变成 临键锁 。
同时查询语句必须为 精准匹配 ( = ),不能为 > 、 < 、 like 等,否则也会退化成 临键锁 。这些在【3】中提及到了.

间隙锁的缘起就是为了通过加锁的方式防止幻读. 通常产生间隙锁的sql如下

1
2
3
4
# 这里需要用 X锁, 用 LOCK IN SHARE MODE 拿到 S锁 后我们没办法做 写操作,这里id=1的记录不存在
SELECT `id` FROM `users` WHERE `id` = 1 FOR UPDATE;
或者
SELECT * FROM table WHERE id BETWEN 1 AND 10 FOR UPDATE;

即id=1的记录不存在,则无法锁定索引,但是别慌,间隙锁来也,依旧可以锁定导致其他事务无法insert. 这样就防止了幻读,【4】中就是这么干的. 而且间隙锁唯一的目的就是为了防止幻读

临键锁的缘起是你既想锁定本记录又想锁定前置索引范围,则就用临键锁.

产生临键锁的sql一般长下面的样子

事务A

1
2
3
4
-- 根据非唯一索引列 UPDATE 某条记录 age是非唯一索引
UPDATE table SET name = Vladimir WHERE age = 24;
-- 或根据非唯一索引列 锁住某条记录
SELECT * FROM table WHERE age = 24 FOR UPDATE;

不管执行了上述 SQL 中的哪一句,之后如果在后续事务B中执行以下命令,则该命令会被阻塞:

1
INSERT INTO table VALUES(100, 26, 'Ezreal');

很明显,事务 A 在对 age 为 24 的列进行 UPDATE 操作的同时,也获取了 (24, 32] 这个区间内的临键锁。

不仅如此,在执行以下 SQL 时,也会陷入阻塞等待:

1
INSERT INTO table VALUES(100, 30, 'Ezreal');

那最终我们就可以得知,在根据非唯一索引 对记录行进行 UPDATE \ FOR UPDATE \ LOCK IN SHARE MODE 操作时,InnoDB 会获取该记录行的 临键锁 ,并同时获取该记录行下一个区间的间隙锁

临键锁详见【3】.

但是用的最多的是间隙锁防止幻读.

最后给出innodb存储引擎中事务用到的锁的内存结构如下:

关于这个锁结构的详细解读参见【2】,但是要知道的一点是,N多个记录可以公用一个锁结构的. 即不必为每条记录都生成一个锁结构. 根本原因是上图中的那一堆比特位.

注意上图中左边第二行写的是”索引信息”,所以其实innodb的锁其实锁定的是索引而不是记录本身.

参考

【1】https://juejin.im/book/5bffcbc9f265da614b11b731/section/5c923cfcf265da60f00ecaa9

【2】https://juejin.im/book/5bffcbc9f265da614b11b731/section/5c42cf94e51d45524861122d

【3】https://juejin.im/post/5b8577c26fb9a01a143fe04e

【4】https://segmentfault.com/a/1190000016566788

【5】https://juejin.im/book/5bffcbc9f265da614b11b731/section/5c7522daf265da2de165acc3

【6】https://juejin.im/book/5bffcbc9f265da614b11b731/section/5c237a18f265da6143131d04