MySQL事务浅析|由浅入深

MySQL事务浅析|由浅入深MySQL事务浅析|由浅入深很多人都在讲事务,事务是个啥,我感觉我没开事物也没什么事情啊,学事务有必要吗?今天照旧,本文在一开始将讲解一些入门适合理解的知识,在后面逐层加深,如果对事务有了解,希望知道细节,可以在下面的目录跳一下文章目录MySQL事务浅析|由浅入深事务是个啥?ForExample1例子2脏写例子3脏读例子4不可重复读例子5幻读并发编程带来的数据库隐患通过对事务的分析,得到了四个特点ACIDMySQL如何保证事务完好持久性的保证原子性的保证隔离性的保证|MVCCMVCC没错

关注可乐可乐可,查看更多有趣文章
MySQL事务浅析|由浅入深

MySQL事务浅析|由浅入深

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

“白嫖表情包”的图片搜索结果

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

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

事务是个啥?

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

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

“初音手办”的图片搜索结果

img

For Example1

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

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

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

image-20210209205827067

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

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

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

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

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

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

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

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

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

例子2 脏写

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

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

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

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

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

image-20210209211514088

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

例子3 脏读

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

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

“半成品手办”的图片搜索结果

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

例子4 不可重复读

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

你看看她的样子

select * from home where id =1;

image-20210209212958774

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

“邻居家的熊孩子”的图片搜索结果

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

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为例,使用的是聚簇索引(不懂的可以去搜索一下

img

图源:从根上理解MySQL

这里的索引部分是一页,下面的数据又是不同的页,如果一个操作将更新多个数据,可能将涉及大量的随机读写,我们不可能等这么长时间完成(等一半就暴毙了怎么办)
MySQL的解决方案是:

  1. 将数据写在缓存中(Buffer pool),同时记录一份redo日志(在事务提交时写到磁盘),若发生了崩溃,就可以读redo日志。
  2. 数据的修改操作将写在MySQL的缓存池中,将有一个专用的线程将脏数据写回。
  3. 同时数据的修改操作将记录在binlog。(binlog记录的是逻辑操作,redolog记录的是物理操作)

原子性的保证

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

MySQL的方式是使用undo日志

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

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

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

image-20210209230513906

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

image-20210209230732652

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

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

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

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

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

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

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

image_1d65h98l3qve1ekb13epv4f37685.png-70.6kB

image_1d658eq7rokf19jffpt20010b63t.png-52.6kB

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

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

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

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

如下图所示

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

image-20210210112520491

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

image-20210210112436732

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

image-20210210112453879

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

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

img

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

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

隔离性的保证|MVCC

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

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

  1. Read Uncommitted 读未提交
  2. Read Committed 读提交
  3. Repeatable Read 可重复读
  4. Serializable 可串行化

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

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

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

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

隔离级别 脏写 脏读 不可重复读 幻读
READ UNCOMMITTED Not Possible Possible Possible Possible
READ COMMITTED Not Possible Not Possible Possible Possible
REPEATABLE READ Not Possible Not Possible Not Possible Possible
SERIALIZABLE Not Possible Not Possible Not Possible Not Possible

MVCC

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

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

所以MVCC是跟innodb关联的。

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

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

image_1c9g4t114n0j1gkro2r1h8h1d1t16.png-42.4kB

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

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

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

列名 是否必须 占用空间 描述
row_id 6字节 行ID,唯一标识一条记录
transaction_id 6字节 事务ID
roll_pointer 7字节 回滚指针

实际上这几个列的真正名称其实是: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)

image_1d8po6kgkejilj2g4t3t81evm20.png-81.7kB

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

未命名绘图

有点蒙圈

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

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

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

能访问的条件:

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

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

我们来复习一下,四种隔离级别:

隔离级别 脏写 脏读 不可重复读 幻读
READ UNCOMMITTED Not Possible Possible Possible Possible
READ COMMITTED Not Possible Not Possible Possible Possible
REPEATABLE READ Not Possible Not Possible Not Possible Possible
SERIALIZABLE Not Possible Not Possible Not Possible Not Possible

四种隔离级别是对能读取的数据范围进行一层层的限制,分为几个层级

  1. 能看到未提交的 | 都能看见
  2. 能看到提交后的 | 看不见未提交的
  3. 能看到自己的操作情况 | 看不见他人对这份数据的修改
  4. 串行

在已有规定ReadView的情况下,我们可以操作ReadView的生成时机即可,已知ReadView保证了只能读取在生成ReadView时的系统内事务

也就是说
相当于Mysql是某某大厦的工卡管理中心,每次发工卡的时候,都会注册大厦办公楼层的访问权限。
但公司是在持续发展的,在你获取了5-6层的权限时,你的土豪公司买了7-9层,那么仅仅用老工卡的你是没办法进入7-9层的

好戏上演,MySQL如何使用ReadView?

  • Read uncommitted:直接读取最新的记录
  • Read committed: 每次查询都生成一个新的ReadView,这样,我们的权限范围就始终是最新的,就能查到最新的数据
  • Repeatable read: 在第一次读取数据时生成一个ReadView,之后使用这个id,这就保证了我们每次读取,状态都是相同的。
  • Serializable:使用锁保证一个事务访问。

防止脏写 | MySQL读写锁

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

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

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

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

这就是锁,不难吧。

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

  1. 多个事务一起读
  2. 多个事务一起写
  3. 一个事务写,另一个事务读

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

一致性读与锁定读

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

一致性读(Consistent Reads)

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

锁定读

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

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

共享锁与独占锁

  • 读操作请求一个共享锁
  • 写操作请求一个独占锁

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

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

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

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

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

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

image_1d9jvmt0n5cl4b71ahh1ki4pjner.png-77.1kB

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

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

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

表锁

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

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

  • 给表加共享锁

    如果一个事务给表加了共享锁,那么:

    • 别的事务可以继续获得该表的共享锁
    • 别的事务可以继续获得该表中的某些记录的共享锁
    • 别的事务不可以继续获得该表的独占锁
    • 别的事务不可以继续获得该表中的某些记录的独占锁
  • 给表加独占锁

    如果一个事务给表加了独占锁(意味着该事务要独占这个表),那么:

    • 别的事务不可以继续获得该表的共享锁
    • 别的事务不可以继续获得该表中的某些记录的共享锁
    • 别的事务不可以继续获得该表的独占锁
    • 别的事务不可以继续获得该表中的某些记录的独占锁

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

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

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

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

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

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

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

兼容性 独占 意向独占 共享 意向共享
独占 不兼容 不兼容 不兼容 不兼容
意向独占 不兼容 兼容 不兼容 兼容
共享 不兼容 不兼容 兼容 兼容
意向共享 不兼容 兼容 兼容 兼容

MySQL的锁

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

MySQL对自增id的保护

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

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

  • 如果目标的数量不确定,那就有必要先把整个表锁起来,然后再慢慢增加
  • 如果目标数量是确定,我们直接用轻量级的锁快速赋值。

写到这里,已经7k字了,猪猪男孩决定不再继续深入了,关于锁的具体实现,大家可以看看

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

觉得有用,还请一键三连,送弟弟上岸。

在这里插入图片描述

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
转载请注明出处: https://daima100.com/11600.html

(0)
上一篇 2023-04-01
下一篇 2023-04-02

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注