写点什么

MySQL 事务浅析|由浅入深

用户头像
云流
关注
发布于: 2021 年 02 月 20 日

很多人都在讲事务,事务是个啥,我感觉我没开事物也没什么事情啊,学事务有必要吗?



今天照旧,本文在一开始将讲解一些入门适合理解的知识,在后面逐层加深,如果对事务有了解,希望知道细节,可以在目录跳一下


不会吧不会吧,不开事务你们系统还好吗?


事务是个啥?

相信在做的各位,大部分都是为了吃饭、或者为了远大崇高的理想而奋斗(摸鱼


那么,我们用钱。。。买回来的手办来举例,相信各位的体验将更加深刻,






For Example1

现有以下场景,你在 bilibili(无意打扰,勿杀)中花费了 1w 元,买下了自己的老婆(不是


注意这个细节:买下,而不是买回


那么分析这个流程,其中有四个模块




有的杠精就要跳出来,我付款不是一个流程吗?为啥是个扣款加收到?


这个问题建议你复习一下什么是微信钱包嗷,以及正视 b 站和 tx 不是一家公司的问题。


好的,凡是总是怕万一嘛,快递还可能被炸毁嘞。


如果你在扣款成功后,啪,很快啊,微信或者 bilibili 的服务器挂了,


你重新审视这张简单无比的图片,发现一个严重的问题:


如果没有其他手段保证的话,bilibili 没收到钱,老婆没了。


那么请问这个问题,他严重吗,答案肯定是严重的。


同时上述的问题是事务持久性的体现。对数据的修改是永久的,即使故障也不会丢失。


下面我将继续举出几个例子,建议看一下,别觉得简单就跳了,对后面的理解有很大的帮助。


例子 2 脏写

又是你,作为当代光荣程序员,你完成了 leader 的任务,写下了五百行代码,踏上了回家与老婆鼓掌的出租车


但是很不幸,有个憨批张三,


他把你刚刚交上去的代码改成了一个字符画猪^(* ̄(oo) ̄)^,


你的五百行代码不翼而飞,还被换成了猪。


那么请问,如何生吃张三更快人心?




一个事务修改了另一个 未提交的事务 修改过的数据,称为脏写


例子 3 脏读

恭喜你,你终于买了 1w 元的老婆!(高兴的拍起肚皮


但是,生活再次对你下手,你买回来的,是个半成品,没上色啊喂!




一个事物读取到了 另一个未提交的事务修改过的数据,称为脏读


例子 4 不可重复读

一番波折,你终于带回了自己的老婆(不是,女朋友大大别打我


你看看她的样子


select * from home where id =1;




很不巧,领居家的熊孩子来家里玩,




邻居走了以后,你又看了看她的样子


select * from home where id =1;


发现老婆,她,变成了这样




一个事务内两次读到的数据不一样,称为不可重复读


换句话说,一个事务提交修改,会影响其他未完成的事务内的数据


又或者说,如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值


例子 5 幻读

你今个又来看自己的老婆


select * from home where id =1;


ans: 初音(韶华)


觉得她很棒♂,于是你放心的去了一趟扯硕。


回来你又来看老婆


select * from home where id =1;


ans: 初音,2233


但是发生了奇怪的事情,老婆多了一个?!


这不是好事情吗,滑稽?

但是《正 直》的你不这么认为,于是给 2233 摆正,微笑入睡


读到了其他事务 增加的记录,称为幻读。(可能这就是幻术吧


并发编程带来的数据库隐患

我们使用 SQL 时,看起来似乎永远都是单线程操作,而实际上数据库几乎是并发的一个高峰点,有无数的线程同时在这里进行操作,如果对数据的读写不能加以限制,那么你将再次痛失老婆(并不


大佬们把数据库的隐患,归结到了四种,脏写,脏读,不可重复读与幻读


这四个概念在上文给大家讲了例子,下面简要总结一下


  1. 脏写:一个事物写了另一个未提交事物修改的数据。

  2. 脏读:一个事物读到了另一个未提交事物所修改的数据。

  3. 不可重复读:由于其他事务的操作,一个事务内两次读某条数据的结果不同。

  4. 幻读:由于其他事务的操作,一个事务第二次查询到了多的数据。


同时把我们的一组逻辑操作称为事务(比如去银行取钱,买手办等等


通过对事务的分析,得到了四个特点 ACID

  • Atomicity(原子性):一个事务(transaction)中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。即,事务不可分割、不可约简。

  • Consistency(一致性):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。

  • Isolation(隔离性):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。

  • Durability(持久性):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。


以上来自维基百科,下面是人话版本


  • Atomicity(原子性):这个简单,你一组操作肯定是要绑在一起的嘛,没什么说的,要死一起死。

  • Consistency(一致性):真实世界中,数据是有格式的,比如小数位啊,没有负数啊等等,一致性就是为了保证数据处理前后,都符合特定的要求(主键,外键,其他约束等等)。

  • Isolation(隔离性):学过 JUC 的老铁应该懂,当多个线程一起操作一个临界区的时候,没限制多半要出事。隔离性是为了保证多个事务在执行的时候,能像单线程一样顺利。

  • Durability(持久性):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。(没什么说的,懂的都懂)


MySQL 如何保证事务完好

一致性我们就不多说了,用触发器,约束这些是很基础的数据库操作。


我们的主要关注点放在持久性、原子性和隔离性上。


持久性的保证

持久性:对数据的修改是永久的,即使故障也不会丢失。


MySQL 里面有个叫 redo 的玩意儿


MySQL 执行每条更新数据的操作,都会产生一个 redo 日志(即时的),然后再依次执行 redo 日志。


为啥?不直接把结果 io 到库里,非要搞个 redo,装杯吗这是?


这里大家需要考虑一个问题:


直接 io 结果会涉及随机读写,使用 redo 日志是顺序读写


MySQL 中以典型的 innodb 为例,使用的是聚簇索引(不懂的可以去搜索一下




图源:从根上理解 MySQL


这里的索引部分是一页,下面的数据又是不同的页,如果一个操作将更新多个数据,可能将涉及大量的随机读写,我们不可能等这么长时间完成(等一半就暴毙了怎么办)所以使用了 redo 日志,后面慢慢写 redo 日志就行了嘛,这里的 redo 日志可能大家没听过,不知道 binlog 这个名字大家熟悉吗,哈哈哈


数据的修改操作将记录在 binlog,然后将有一个线程慢慢写 binlog。(修改会暂时保存在缓存中)


原子性的保证

原子性:同生共死,要么都成功,要么都失败。那么我们需要关注的是,执行到一半,后悔了,我要恢复,怎么办?


MySQL 的方式是使用 undo 日志


undo 日志的原理其实不难,为了知道每一步我们都干了点啥,我们每修改一次数据,就做一次记录顺序读写,很快的),要是有问题,就用这些记录把数据恢复了就行。


下面我们从设计 undo 日志的角度出发,来理解 undo 日志


为了记录一个数据的修改,同时达到顺序读写的效果,链表可能是我们理想的数据结构




日志肯定要分开存储,不然回滚还要筛选找到日志,很麻烦,那我们直接把 undo 日志挂在记录上应该就可以了。




现在我们还有一个因素要考虑:并发,多事务执行。


对于并发问题:我们必须意识到:不能允许多个事务同时修改一个数,这属于脏写,所以我们将使用来保证。


那么我们已经使用锁来保证当前数据只能被一个事务修改,我们下面需要考虑的是修改数据也有很多种类的,删除,增加,修改,怎么处理?


加一个标志位标识种类,不同种类有不同的内容,完美


综合我们可以设计出这样的结构


因为 mysql 为了提高读取速度,在存储,读取数据时以页面(16kb)为单位


同时页面有多种类型,undo 日志和数据页就是两种不同的类型页。






图片来源:掘金小册《MySQL 是怎样运行的:从根儿上理解 MySQL》


当我们发生情况,要回滚的时候,按照这个日志回滚回去就行了。


但是由于 MySQL 中,delete 行为的不同:


delete 并不会删除数据,而是在当前事务内,标记这个点被删除了(行里面有一个 Header 标志位),然后在事务结束后放入垃圾链表,垃圾链表可以快速的被回收利用或在未来释放空间


如下图所示


下面是一个正常的表,左边是正常的记录,右边是该表的垃圾链表,记录删除的空间。




事务收到请求,把标志位 delete_mask 置为 1




在事务结束后,将该节点加入垃圾链表




标志这一步骤看似多此一举,实际上保证了 MVCC,标志记录是很快的,当其他事务读取到时,可以感知到该条记录是否被删除,而不是等到结束才感知到。


同时,这些操作在事务内是单向的,日志也是使用链表记录,这样就构成了版本链




关于具体的存储方式啊,回滚段啊就不再赘述了,这部分的内容不是很容易理解


还是很推荐这本书 MySQL 是怎样运行的:从根儿上理解 MySQL,也有纸质书。


隔离性的保证|MVCC

说到隔离性,必须要提一下大家小学三年级就知道的四个隔离级别


一般是大写的,为了大家英文看的舒服,写成小写容易认出来单词


  1. Read Uncommitted 读未提交

  2. Read Committed 读提交

  3. Repeatable Read 可重复读

  4. Serializable 可串行化


Read Uncommitted 读未提交,有的地方叫未提交读,本人觉得读未提交更符合他描述的情况。因为他描述的是可以读到未提交的信息。


这四种级别的隔离程度逐级增加,解决了脏写,脏读,不可重复读,幻读的问题。


其中脏写是非常恐怖的,所以 MySQL 默认是必须解决脏写的


既然写到了隔离级别,我们就把他讲完,然后说一下脏写的解决。



MVCC

首先,预备一下,在 MySQL 中,一个表除了我们设定的几个字段,还存在一些隐含字段


这里强调一下,Innodb 是支持事务的,MyISAM 是不支持事务的。

所以 MVCC 是跟 innodb 关联的。


MySQL 默认是使用 COMPACT 行格式的,当然无论什么格式,都会存在隐含列以及 Header 标志


我们用 COMPACT 行格式来举例讲解




暂时不关心奇奇怪怪的设置了,之前所提到的 delete_mask 就在记录头中


记录的真实数据部分包含了三个隐含列


的数据以外,MySQL 会为每个记录默认的添加一些列(也称为隐藏列),具体的列如下:



实际上这几个列的真正名称其实是:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR,我们为了美观才写成了 row_id、transaction_id 和 roll_pointer。


其中,row_id 是为了在没有指定主键,且没有唯一列时作为该条数据主键的(主键的必要的,否则无法区分数据),若指定了主键,他就是空的。


roll_pointer 就是上文我们所提到的记录指向 undo 日志的指针。


transaction_id 是最后一个修改该行的事务 id。


事务 id:为了区分不同的事务,事务也要有个类似主键的东西嘛,就是 id 号,

MySQL 内部会维护一个递增的 id 号。


MVCC 听起来很高大上,实际上并不复杂,大家先不要关注什么 MySQL 的具体实现,下面先简单说一下 MVCC 是啥,怎么用的。


首先,我们需要明确我们的需求:


不同的事务能看到不同的数据(写是不允许混用的,用上锁保证了)


为了达到这个目的,我们可能需要这样做:给每个线程一个缓存空间(类似 JUC,Java 并发中的 JMM)


那么问题来了:数据库很可能是一个系统中并发量最高的地方,大张旗鼓的新开空间,可能造成困难。


如果可以利用一下之前的东西?


没错,有个东西叫 Undo 日志


undo 日志里面包含了修改数据的内容对吧,而且为了回滚,都会记录在哪里,而且还保证了查询速度。


那么很简单,既然每个事务都会记录这个 undo 日志,我在查询的时候,只需要看看最后占用这个数据的事务 id,是不是比自己小?


事务 id 是递增的


如果比自己小,说明这个数据是安全的,可以读取,否则就在 undo 日志里面找个合适的出来。


如下图:


依次从大到小,找到合适的版本就行了(trx_id)



​MVCC 的基础原理就是这样,不是很复杂吧。


但是,我们都知道,SQL 是规定了几个隔离级别的,观察上面的实现,我们发现这个方法是第三级Repeatable Read 可重复读


在一个事务内,查询时总跟自己的事务 id 进行比较


那么这四级隔离都是如何具体实现的?

首先要明确的是,mysql 的事务 id 肯定不是记录自己的 id 这么简单


MySQL 是如何记录当前活跃的事务的?


答:使用 ReadView


什么是 ReadView?

我们还是按照软件设计的正常思路:需求-》设计


需求是什么


我们需要在一个合适的时间点,查看自己的事务 id 与当前所有的事务;因为事务可能很多,最好给一个快速判断是否为过去事务的方法。


实现


我们设计一个独特的记录结构赋予每一个事务


首先需要记录当前所有的事务


  • m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。


为了能够快速的查看目标版本是不是安全的,我们只需要看目标的版本是不是在当前数据库内的事务区间内,要是不在里面,就直接返回安全,否则我们就验证一下是否真的不安全就行了(当前事务内是否包含)


  • min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id,也就是 m_ids 中的最小值。

  • max_trx_id:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。小贴士: 注意 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。


最后,记录当前的事务 id


  • creator_trx_id:表示生成该 ReadView 的事务的事务 id。小贴士: 我们前边说过,只有在对表中的记录做改动时(执行 INSERT、DELETE、UPDATE 这些语句时)才会为事务分配事务 id,否则在一个只读事务中的事务 id 值都默认为 0。


我们设定每次的查询都根据 ReadView 来获取结果(这是为了解耦,大家可以思考一下,如果只跟 ReadView 交互,将会很大程度解耦)


被访问的记录上有 header 记录了最后修改的事务 id(trx_id)


在访问某条记录时,按照下边的步骤判断记录的某个版本是否可见:


  • 如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

  • 如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。

  • 如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。

  • 如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。




有点蒙圈


Read View 其实实质是当前的事务 id


为了快速的判断,给出了记录与范围


试想,目标记录有个事务 id 100,


能访问的条件:


  • 记录的事务 id 是不在当前活跃的事务 id 中

  • 记录的事务 id 已经不活跃小于当前事务 id。 如果命中了,就不能访问。为了加速,我们直接获取最大与最小的范围,在范围之外我们可以迅速判断。


修改 ReadView 就能获得不同的隔离策略:

  • Read uncommitted:直接读取最新的记录

  • Read committed: **每次查询都生成一个新的 ReadView,**这样,我们的权限范围就始终是最新的,就能查到最新的数据

  • Repeatable read: 在第一次读取数据时生成一个 ReadView,之后使用这个 id,这就保证了我们每次读取,状态都是相同的。

  • Serializable:使用锁保证一个事务访问。


防止脏写 | MySQL 读写锁

Java 程序中,为了保证临界区的安全,我们经常会提到一个概念,叫锁。

我们可以这样理解,东阳市大学有个厕所,经常出现一个人进了一个有人的厕所的尴尬情况。

为了保证男孩子和女孩子们的安全,出现了一个叫信号量的东西。

厕所的门口放了一张卡,只有拥有这张卡,才能进入,使用完后要及时归还,下个人才能使用。

这就是锁,不难吧。

回到 MySQL 的世界,我们的数据在并发状态一般就这几种情况:(AA BB AB|BA)

多个事务一起读多个事务一起写一个事务写,另一个事务读

其中存在写的地方需要我们上锁来保护数据(懂的人已经发现了这是读写锁)


一致性读与锁定读

MySQL 读取的数据有两种方式:一致性读与锁定读,他们分别对应着无锁、与显式上锁


一致性读(Consistent Reads)


一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动,一致性读使用的方式是 MVCC


锁定读


锁定读是类似 Java 日常开发中对临界区的保护,必须先上锁才能访问数据。


MySQL 为了保证多个事务能够一起读的同时,写操作还能锁死数据,设计出了两把锁


共享锁与独占锁

  • 读操作请求一个共享锁

  • 写操作请求一个独占锁


当有独占锁出现后,共享锁也将被锁定


若有共享锁存在,需要等待解锁才能申请独占锁


我们可以从打扫厕所的角度来理解:(抱歉今个有点重口味


当清洁工打扫时,会等里面的同学出来,然后在门口放上警示(独占锁


当无人打扫时,xdm 可以一起幸♂福的冲冲冲


这就是共享锁与读写锁,实质上并不难,他们的原理还是信号量




但是啊,这个时候东阳市大学又整了新操作,他们要修整宿舍楼,修整宿舍楼的时候,也是要限制滴


上述情况中,宿舍楼对应了一张表,厕所对应了一行记录


于是 MySQL 就出现了不同粒度的锁:表锁


表锁

MySQL 中也有修改表的语句,这些语句修改的级别是表而不是上文的一行数据,于是表锁出现了


给表加的锁也可以分为共享锁(共享锁)和独占锁(独占锁):


  • 给表加共享锁:如果一个事务给表加了共享锁,那么:别的事务可以继续获得该表的共享锁别的事务可以继续获得该表中的某些记录的共享锁别的事务不可以继续获得该表的独占锁别的事务不可以继续获得该表中的某些记录的独占锁

  • 给表加独占锁:如果一个事务给表加了独占锁(意味着该事务要独占这个表),那么:别的事务不可以继续获得该表的共享锁别的事务不可以继续获得该表中的某些记录的共享锁别的事务不可以继续获得该表的独占锁别的事务不可以继续获得该表中的某些记录的独占锁


综合来讲与行级锁几乎一致,只是层级高了。


但是这其中又有些不一样的地方:表锁需要考虑这个表里面有没有行锁,表的上锁需要等待行锁


但是一个库可能有几百万行,不可能一个一个查吧


为了提高表锁的效率,我们提出一种意向锁,使用意向锁来包裹一次原来的行级锁,以此表示是否有正在进行的锁任务。


当我们准备获取锁的时候,先用意向锁上锁,此时其他的事务发现这个表被上了意向锁,表示这个表已经锁定了,需要等待一下。


用锁来表示当前是否有人访问,免去了遍历查询的痛哭,好!


当然,既然是包裹一层,自然意向锁也有两种,共享与独占。



MySQL 的锁

MySQL 中 MyISAM、MEMORY、MERGE 这些存储引擎只支持表级锁。只有 Innodb 才支持事务、行锁


MySQL 对自增 id 的保护

自增关键字 AUTO_INCREMENT 也会使用锁来保证唯一与递增


他主要有两种实现方式:重量级别的表锁与轻量级的锁,他们对应了目标数量是否确实的两种情况


  • 如果目标的数量不确定,那就有必要先把整个表锁起来,然后再慢慢增加

  • 如果目标数量是确定,我们直接用轻量级的锁快速赋值。


作者:可乐可乐可

链接:https://juejin.cn/post/6930617816261492749

来源:掘金


用户头像

云流

关注

还未添加个人签名 2020.09.02 加入

还未添加个人简介

评论 (1 条评论)

发布
用户头像
通俗易懂!
2021 年 02 月 27 日 22:44
回复
没有更多了
MySQL事务浅析|由浅入深