Spring系列之——事务管理前传数据库事务

文章阅读提示:

本文只是为了讲spring的事务管理而写的前传,详细程度只是定性的原理基本,没有到源码分析,但也不是浅显的基础概念,以下两种人没必要看:
1)想要刨根问底,分析源码。
2)只想了解基础概念,学一下spring事务管理用法。
注:所有讲解都是建立在MYSQL+Inodb的基础上。

什么是事务:

事务就是在数据库操作中一段不可分割的最小操作单元,例如一个银行取款业务,取钱和余额修改就是不可分割的,不能只取钱不修改余额,也不能只修改了余额不取钱,它们两就是一条绳上的蚂蚱,同生共死。
由此可见事务其实是数据库的一个概念,而我们所说的spring事务其实,是spring代管的数据库的事务。

事务的四大特性(ACID):

原子性(Atomicity):事务是一个原子操作,组成事务的一系列操作必须是,同生共死的。
一致性(Consistency):指的是数据库事务在执行前后是从一个正确的状态到另一个正确的状态,其它的三个特性就是为了保证这个特性。(网络上有各种资料,有的说法与原子性无异,个人觉得这种解释更为合理,如有不同见解望大佬指出)
隔离性(Isolation):就是事务之间对相同数据的操作应该是相隔离开来的,不能相互影响,而一个数据库是有性能要求的,我们在抛开性能的情况下对隔离性的极致追求就应该是完全不影响,那么就应该是串行的方式,执行事务,但是要求性能可能不能完全串行,因此就有了隔离级别这一说。
持久性(Durability):一旦事务完成,无论发生什么系统错误,它的结果都不应该受到影响,这样就能从任何系统崩溃中恢复过来。通常情况下,事务的结果被写到持久化存储器中。
事务四大特性的理解
我们思考一下事务的四大特性是为了保证什么?事实上相关性是一切事情的原罪,想象一下,我们只是进行单一的不相关的操作,一个一个的来,无论发生什么情况,失败就失败成功就成功,不会出什么问题,增删改查都是这样,但是很多操作并不是独立的,相关性包含的有协作也有相互影响。
比如转账是对两个用户数据进行操作,所以一次转账操作的成功,就必须是两个操作同时成功或者失败,不然就会出错,我们把这个过程叫事务,而这种合作的同生共死叫原子性;
再看相互影响,那就是并发的锅,一张银行卡,可能被你和你老婆一起用,这时候就是两个事务的影响,我们知道软件很重要的一个思想就是并发,线性意味着低效率,而两个事务并发运行就不能相互影响,这就是隔离性;
而数据库在设计是也是为了提高效率做了节省IO这样一个炒作,所有会有批量处理,而批量处理就需要保证,数据不丢失,在事务完成后我们可以理解成持久性,事务的效果无论发生什么情况都能让他的影响到达持久层并长时间生效。
而一致性前面我们也说了,其实它可以看做三个特性的最终效果,一致性像是一个目的,下面我们通过几个例子来说明。
例如:
破坏原子性:A有200,B有100,A给B转了100元钱,就是A-100,B+100第一步成功了,第二步失败了,正常来说一个回滚事务,因为原子性保证,现在将其破坏,那么这个时候就违反了一致性,A+B应该保持为300才对,但是现在变成了200,当然这种要求是由于具体数据特性引起的与数据库的特性无关,这里的一致性和原子性也无关只是原子性的破坏引起了一致性的破坏而已。
破坏隔离性:现在在上面的基础上增加一个C,C有500元,C给A转300元,这是一个全新的事务,在A-100后,它开始了,正常情况下A的数据会在第一个事务完成前加锁,第二个事务不能进行操作,这时候锁没了我们假设它被破坏了,C给A转300,那么就是在100的基础上加300,A=400,然后C-300=200一切很顺利第二个事务成功提交,而第一个事务在B+100时出现错误,回滚第一个事务A=200,B=100,这个时候A+B+C从原来的800变为了现在的500,这就是隔离性被破坏导致的一致性破坏。
破坏持久性:这里的持久性想必大家是比较懵逼的,持久性不是数据库的一个特性,数据存入到了磁盘当中,这样一来,数据就能持久的得到保存?当然不是这样,我们看到持久性的描述,它是事务完成后其影响是持久的不会因为系统的问题而出现数据丢失,你会觉得对呀,本来就是这样啊,只要储存了就一定是持久的啊,当然这个说法没有问题,但是是储存了,而不是事务完成了,因为一个事务完成了不一定就存入了磁盘有可能数据还在我们的处理日志中还没有刷盘,比如Inodb的存储引擎,它的一个事务完成后只是在redo log文件中保存了commit状态的数据记录还没有刷盘那么这时候如果宕机,系统恢复后我们的系统根据redo log文件将数据进行刷盘即可,这是持久性的保证,但是我们现在破坏持久性,那么就会出现数据丢失,自然也就没有一致性可言了。

隔离级别:

未提交读:
这是最低隔离级别,这个时候我们可以读到别人未提交的数据,这时候的并发效率最高但是出问题的概率也最高,事实上也没有数据库在用这种隔离级别。ps:为什么可以读到别人未提交的数据?在哪里读到的?,为什么并发效率高?先不管下文自会分解,下面三个隔离级都是如此。
已提交读
可以读取到别人以提交的数据,但是不能保证一个事务中的读取一个数据的值是不变的。效率低于前一个,是Oracle的默认隔离级别。
可重复读
保证了,同一个事务中对一个数据重复读的一致,但是不能保证全表数据的增加,只能保证有的不变但是不能保证不增加,MySQL数据库的默认值
可串行化
记住这里叫可串行化,并不是真正的串行,只是在保证像串行一样的效果下,去并行执行事务,这个级别的隔离下并发效率最低。

支持事务的数据库的底层:

对事物有了一个简单的定性了解后我们来看,支持事务的数据库是怎样操作的,这是非常重要的,支持事务的数据库它是怎样完成一个数据操作的,怎样保持数据对后面的理解非常有帮助。当然这里只是一个简单的汇总。(其中内容非常之多,我们只是为了服务于spring事务管理的学习,做一些必要的知识扩展,需要具体了解大家可以自己下去拓展)
先上一张结构图(图片来源网络,偷个懒):
在这里插入图片描述
MTR:
见名知意MTR就是mini事务,其实一个事务在执行时,是被划分成了多个小事务来完成的:一个SQL语句就是一个小事务,那么我们就会产生疑问,说好的原子性呢?我们可以看到这里说的是mini事务,而不是语句,也就是说它变成多个mini事务是有前提的,变了它还是要保证事务的特性,饭要一口一口吃,就像你妈妈给你讲,今天晚上这碗面你要么就不要吃,要么就必须吃完(原子性),但是不是让你一口吞下去吧?你还是一口一口吃完了的。数据库完成事情也是如此,它不可能一口吃完,必须拆分,而拆分后同时保证原子性就是MTR,要保证的,所有不要只看一个拆字,我们来看看它用哪些机制方式保证了拆如不拆。
注:这一切都是在一个事务中的操作,事务之间的问题都是事务级别的,爸爸的事儿子不管。下面三个原则的范围大家也就知道了。
三大原则之——FIX Rules
Fix Rule要求在访问或者修改一个page时,需要持有该page的latch(闭锁)。一般获取latch的操作称之为Fixing the page。当获得latch之后,称这个page已经Fixed(可理解为锁定)。释放page的latch的操作,称为unfixing。为了保证数据在并发情况下的一致性,当修改一个page的时候,需要持有该page的X latch;当访问一个page的时候,需要持有该page的X latch或者S latch。一个page只有当修改完成或者是访问完成之后才释放其持有的latch。这是对一个page的操作,当mini transaction需要修改多个page,那么该mini transaction必须持有多个page的latch,并在操作完成之后,按照获取latch
相反的顺序释放
latch。
当然我们要实现这个FIX Rules我们就要变量去标记它,事实上一个page为针对它的一系列规则、操作提供了变量支持,那就是buf_block_t对象,我们的这个规则只用到了其中两个如下变量:

    rw_block_t lock;
 
    ulint buf_fix_count;

第一个就是read and write锁,也就是让我们知道,有没有人在读、写它,我还有没有机会,但是我们知道对应X锁来说他是互斥锁只能有一个,而S不一样,有多个操作得到了S锁,那么这时候我们要知道它是否有人在对这个page进行操作就需要第二个变量。
第二个变量,是一个引用计数的存在,表示有多少个操作在fix该page,我们对这个页进行fsync,或者是替换页都需要知道还有没有人在操作它,这就要去看buf_fix_count,0就是没得。
三大原则之——Write-Ahead Log
就是要求在一个page操作写入到持久化设备之前,首先必须将其在内存中的日志写入到持久化存储。可能喜欢问为什么的你不理解为什么要这样,不急先不纠结,往下看,它的具体要求实现如下:
1、每个page都有一个LSN(序列号),存储在page头部File Header的FIL_PAGE_LSN位置,每次对page进行修改的时候总是要修改该值。
2、当将一个page持久化到存储设备的时候(fix rules保证的前提下),要求将内存中所有LSN小于该page的LSN的日志都持久化到存储设备,然后才开始将内存中的page持久化到存储设备。
三大原则之——Force-log-at-commit
我们WAL保证的就是一步一步的mini事务,的每一步的数据都有日志为证,且已经持久化,而这个规则就是保证最后,所有mini事务完了之后的提交阶段(可以看做一个临界状态,也就是最后那一哆嗦),对应事务的日志持久化到存储设备。那么,即使数据库发生宕机,也能根据日志恢复数据库中的数据。
*注:*到此MTR的了解就够了,当然远不止此,比如mini Transaction的具体代码实现,并且有一些概念是简化了的。
Inodb_buffer_pool:
这是Inodb的缓存池,这是一个重点,我们知道,数据库作为一个持久性存储,由于它的数据放在磁盘所有持久,但也慢,因为IO是一个非常大的开销,所有我们的数据库它有一个核心思想就是节省IO,办到这个要求,用到的就是我们的缓冲池,用更快的存储区做频繁的操作,在加上一些规则来保证一下持久即可。缓冲池包含多个部分,数据页、undo页、索引页,我们这里只讲数据页,其他的和标题没什么关系。

数据页:
这是我们的数据库的高频数据放到地方,我们知道一般的数据库都是读大于写,对于读而言我们把高频数据放在这里,直接读取就不需要IO操作,系统问题导致数据没有了,从数据库中拿就是了,没什么说的,只是对高频的判断需要一些规则,因为缓存容量肯定没有磁盘大不可能全部缓存,怎样判断高频我们后面说。对于写,因为系统永远都是有爆发的特性,IO较慢,我们先写在这里,然后慢慢地写到持久层,来平均爆发,这里就涉及到很多问题,保证数据的持久性就需要很多的技巧与设计。我们慢慢分析。

①数据页的结构:
单个页:页的大小是固定的,同时从磁盘中读取数据也是一页一页的读,一个也常见的是(4KB),页是存放数据的,但是每个页都对应了一个 buf _block _t对象,它管理了另一个结构buf _page _t(在页当中) ,两个一一对应,不同的是buf _page _t 是一个物理页面在内存中的管理结构,是一个页面状态信息的结合体,其中包括所属表空间、Page ID、最新及最早被修改的 LSN 值,以及形成 Page 链表的指针等逻辑信息。buf _page _t 是逻辑的,而 buf _block _t 包含一部分物理的概念,比如这个页面的首地址指针 frame 等。剩下的就是数据了
整体数据页的管理结构:
用 buf _pool _t 结构体来描述,这个结构体是用来管理 Buffer Pool 实例的一个核心工具,它包括了很多信息,主要有如下四部分。FREE 链表:用来存储这个实例中所有空闲的页面。flush_list 链表:用来存储所有被修改过且需要刷到文件中的页面。mutex:主要用来保护这个 Buffer Pool 实例,因为一个实例只能由一个线程访问。chunks:指向这个 Buffer Pool 实例中第一个真正内存页面的首地址,页面都是连续存储,所以通过这个指针就可以直接访问所有的其他页面。
②Inodb页管理算法:
改进的LUR算法(防止篇幅过大传统LUR算法不讲解),传统的LUR算法有哪些问题:1)预读失败:我们预读的数据可能根本就用不到,因为我们采用的只是附近相关法(访问一个数据它周边的数据大概率会很快用到我们就预读到数据页中),所以我们要让它尽可能短的停留在缓冲池中。2)缓冲池污染:就是说一次操作可能会涉及大量数据,但操作的数据并不是高频数据,但是缓冲池大小是一定的,那么这些数据就把真正的高频数据给挤出去了。
改进了什么:1)分为新生代、老年代,预读数据先到老年代,真的被访问的时候在进入新生代。解决了预读失败的长时间停留问题,2)老年代停留时间制,进入老年代有一个考察期,不管访问与否都要过了时间才能进入新生代,解决了缓冲池污染问题。
③四个相关参数:
1)innodb_buffer_pool_size——配置缓冲池总的大小
2)innodb_old_blocks_pct——老生代占整个LRU链长度的比例,默认是37
3)innodb_old_blocks_time——老生代停留时间窗口,单位是毫秒,默认是1000
4)innodb _buffer _pool _instances——缓冲池实例个数,默认值为1,当参数一的值大于1GB时进行调整,修改为多个 instance,每个 instance 各自管理自己的内存和链表,可以提升效率。
日志缓冲区
包含了redo log、undo log、二进制日志
二进制日志与redo log:
进制日志中也记录了innodb表的很多操作,也能实现重做的功能但是它们之间有很大区别。

二进制日志是在存储引擎的上层产生的,不管是什么存储引擎,对数据库进行了修改都会产生二进制日志。而redo log是innodb层产生的,只记录该存储引擎中表的修改。并且二进制日志先于redo log被记录。
二进制日志记录操作的方法是逻辑性的语句。即便它是基于行格式的记录方式,其本质也还是逻辑的SQL设置,如该行记录的每列的值是多少。而redo log是在物理格式上的日志,它记录的是数据库中每个页的修改。
二进制日志只在每次事务提交的时候一次性写入缓存中的日志"文件"(对于非事务表的操作,则是每次执行语句成功后就直接写入)。而redo log在数据准备修改前写入缓存中的redo log中,然后才对缓存中的数据执行修改操作;而且保证在发出事务提交指令时,先向缓存中的redo log写入日志,写入完成后才执行提交动作。
最终态,比如新插入一行后又删除该行,前后状态没有变化。而二进制日志记录的是所有影响数据的操作,记录的内容较多。例如插入一行记录一次,删除该行又记录一次。
redo log与undo log:
redo log是重做日志,提供前滚操作,保证持久性,undo log是回滚日志,提供回滚操作。redo在意的只是最后的状态,而undo记录了每一步过程,所有undo log不是redo log的逆向过程。redo log通常是物理日志,记录的是数据页的物理修改,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。undo用来回滚行记录到某个版本。undo log一般是逻辑日志,根据每行记录进行记录。
磁盘结构
分为log区和数据区,他们都会从缓冲区在合适的时间刷盘到磁盘区。
三种log的commint:
MySQL支持用户自定义在commit时如何将log buffer中的日志刷log file中,不包含二进制日志。这种控制通过变量 innodb_flush_log_at_trx_commit 的值来决定。该变量有3种值:0、1、2,默认为1。这个变量只是控制commit动作是否刷新log buffer到磁盘,而没有控制data file,直接上图(图片来源于网络):
在这里插入图片描述
值为1时性能最差,但是不会丢失数据,值为0性能最好但是最坏可能丢失1秒的数据。
二进制日志的commint方式参数
sync_binlog,有效值为0 、1、N:
0:默认值。事务提交后,将二进制日志从缓冲写入OS Buffer,但是不进行刷新操作(fsync()),若操作系统宕机则会丢失部分二进制日志。
1:事务提交后,将二进制文件写入磁盘并立即执行刷新操作,相当于是同步写入磁盘,不经过OS Buffer。
N:每写N次OS Buffer就执行一次刷新操作。
刷脏页:
我们开始提到了log文件的刷盘(持久化),下面我们看一看最重要的数据是在什么时候刷盘的。
当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。也就是说我们平常修改过的数据就是脏页,那么它是在什么时候刷盘的呢?
第一种场景是InnoDB 的 redo log 写满了,这时候系统会停止所有更新操作,把 checkpoint 往前推进,redo log 留出空间可以继续写。
第二种场景对应的就是系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。
第三种场景对应的就是 MySQL 认为系统“空闲”的时候。当然,MySQL忙起来可是会很快就能把redo log 记满的,所以要合理地安排时间,即使是忙的时候,也要见缝插针地找时间,只要有机会就刷一点“脏页”。
第四种场景对应的就是 MySQL 正常关闭的情况。这时候,MySQL 会把内存的脏页都 flush 到磁盘上,这样下次 MySQL 启动的时候,就可以直接从磁盘上读数据,启动速度会很快。
一个事务操作的过程
1)将一个事务分解成多个mini事务,按照三大原则去运行。
2)事务完成后按照nnodb_flush_log_at_trx_commit参数的进行相应的log刷盘操作
3)四个条件下对脏页进行刷盘

隔离级别带来的问题:

前面讲到的我们可以看出,其实事务要想达到想要的效果,事务本身来说就是一个原子性就可以了。而对于数据库本身设计来说一定要保证行之有效,也就是我们讲到的持久性,而事务之间的影响才是大有乾坤,因为前两个我们都可以做到最佳效果,但是对于隔离性我们只能根据需求,进行平衡,平衡什么?自然是我们要求的隔离性与效率之间的平衡,那么不同的隔离级别适用的场景,我们可以看不同的隔离性带来的问题,这样一来不需要考虑那种问题的场景就适用那种隔离级别。
脏读
脏数据、脏读、脏页,三个概念是完全不一样的,脏读读的就是脏数据,而脏页完全不一样,我们知道我们对数据的修改是先存储在缓冲池中,这时候磁盘页和缓冲池数据页的值是不一致的,但是这并没有什么问题,因为我们有相应的log file磁盘文件,在四个条件方式时在慢慢刷盘,而数据的访问使用就在缓冲池中调用即可,这就是脏页,脏页是正常现象。清楚了这些我们再来看脏读。
当一个事务A对数据1进行修改时应是怎样的过程:
1)在1的undo log中记录当前状态
2)对1的数据页进行修改(未提交态),并写入redo log的prepare操作日志。
3)写入binlog,并持久化到磁盘,完成各redo log 写入commint日志。
4)提交事务数据页中的1数据变成已提交数据,不会再出现回滚。
这样每一步出现问题都能够恢复数据。
脏读及原因:
在1的数据页有了未提交的数据时,事务B访问1是读到了未提交的数据,结果这时候事务出现问题,回滚,那么事务B读到的就是一个不可用的错误数据,就会出现脏读。只有一个隔离级别会出现这种情况——未提交读。想要避免我们在A对1进行写操作的时候是会对更改的这一行加上X锁,在操作完成前不会有事务能对他访问。
不可重复读
比如事务A有一个对数据1的update操作,事务B对数据1的查询,无论这个操作有没有加锁在事务A完成这个操作前后,事务B对数据1的查询就是不一样的结果了,这就是不可重复读,在两个隔离级别下都会出现这样的情况——未提交读、以提交读。要想避免我们一个让一个修改数据的X行锁在事务结束时才释放而不是mini transaction结束就释放。
幻读
我们知道在一个数据操作时我们只要加上行锁就可以避免脏读,但是我们来看一下这种情况,当事务A对表a中进行一个插入操作时,我们的事务B对表a进行一个范围修改,把年龄为20的数据项改为21,然后进行查询验证,而事务B的修改和验证操作之间刚好有一个事务A插入了一条数据其年龄刚好也是20,这个时候就会发生幻读,就好像出现了幻觉,呃…我刚刚不是把所有的20都改成了21吗?怎么又出现一个20。这就是幻读,在三个隔离级别下都可能产生——未提交读、已提交读、可重复读。这里的问题想要解决最粗暴的方法就是直接加上表锁,并且在事务结束后再释放。
两类数据丢失
第一类:

事务A事务B
查询余额 money=1000查询余额 money=1000
存款100元,money=1100
转账100元,money=900
事务B提交,money=900
事务A异常,回滚,money=1000

第二类:

事务A事务B
查询余额 money=1000查询余额 money=1000
存款100元,money=1100
存款100元,money=1100
事务B提交,money=1100
事务A提交,money=1100

我们可以看到最终结果都是一个事务的数据丢失,原因是因为,另一个事务的同时进行,最后的回滚或者提交覆盖了前一个事务。这种问题与前面不同,想要解决也是加锁但是我们知道对于select而言只有在串行化的情况下才会加锁,所有非串行化的情况下它都不受控制。
总结与问题
上面讲到的问题的对应的解决方式,都是加锁但是我们知道锁和性能是相冲突的,比如我们将隔离级别从以提交读提高到可重复读,会产生怎样的性能损失勒?我们的一个事务A有很多查询和一个修改,原本只是在修改的一条语句执行时不能对这个数据访问,但是隔离级别的提高让这个事务整体时间内都不能有其他事务对其访问。那有没有其他的方法呢?
MVCC
刚刚我们提到了加锁的方式其实对性能是有不小的损耗的,在这个问题上我们很容易想到我们,java线程中的乐观锁与悲观锁,上面我们说到的锁其实就是悲观锁,而乐观锁我们知道有版本控制,和CAS等方式(不知道的话可以自己先扩展之后再来看,后面不会仔细讲但不会又看不懂),其实我们的Inodb中就有一个这样的多版本并发控制机制,来部分取代锁的作用,从而提高效率。
它就是在数据行的后面增加两个数据一个创建版本号,一个删除版本号,其实这个版本号就是事务的编号,每新出现一个事务就事务就进行增加,这样一来我们就没必要添加读写锁,直接进行版本判断,比如事务1、2、3、4、5,他们先后开始但是并发运行,如果2修改了一个数据那么就不是在原数据上去修改而是创建一个新的同id的数据,只是创建版本号是2,那么1查询就不会看到她,之后的3、4、5查询中国数据看到的就是这个版本,当一个数据小于它删除版本号的事务全部结束,这个数据就可以去掉了。
优缺点:
显然这样提高了效率,但是消耗了更多的存储空间,以及一些相应的管理算法,不过计算机的世界里,空间换时间是划得来的。
运用范围:
当然支持事务的数据库并不是只用这个MVCC,而是和锁结合起来用,想象一下,如果一个数据库的表是用户信息,平常几乎不会出现修改只是进行查询验证,这种表就没有必要花空间去提升并发效率。

总结

1)需求:很多操作具有关联性,需要同生共死。同时并发之间相互影响是原罪。
2)达到需求的四个特性要求——原子性(操作关联性的要求)、一致性(最终目标)、隔离性(消除事务之间相互影响的要求)、持久性(数据库本身的要求)。
3)隔离级别最费事,不是yes or no,需要取舍和平衡 ,四个级别——未提交读、已提交读、可重复读、可串行化。
4)取舍平衡就会有问题,脏读、不可重复读和幻读,还有两类数据丢失。
5)底层实现要掌握,日志保驾护航,缓冲池提升效率,锁和MVCC实现隔离。

最后一句话:

看到此处便是缘,请您再听我一言;
新生博主甚是难,还请原谅把你烦;
博文写了没人看,博主这活没法干;
看了不评就走人,他人怎知行不行;
看了评论又推荐,博主心里笑开颜。
ps:就想皮一下,但还是希望你能指出错误,如果觉得可以夸奖一下就更好了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值