数据库事务

1 前言

关于事务,说简单点就是一串原子操作,说复杂又牵扯出好多疑问:为啥需要事务;ACID是干啥的,是怎么保证的;隔离级别和并发问题是什么关系,底层是怎么实现…带着这些问题,让我们开始学习事务吧

2 为什么要有事务

现实中存在很多一组操作需要顺序完成的场景,比如转账至少包括A账户减少和B账户增加两步操作,我们希望他们能要么同事执行,要么同时失败,并且操作前后数据库都要处于正确状态。

存在的问题(系统崩溃、并发等)

然而在这个过程中存在很多问题,系统突然崩溃导致只执行了一部分操作,或者并发场景下会出现账户减少的数额不正确等,为了便于解决这些问题,引入了数据库事务的概念,毕竟人对万物的认识和使用都是从命名开始的

终极目的(一致性)

所以事务的终极目的和存在意义就显而易见了,就是为了保证这一串操作前后,特别是并发情况下数据库各相关数据都能保持正确状态,这里也起来一个名字,就是一致性

3 什么是事务

3.1 官方描述

事务提供了一种机制,把一个活动中的所有操作纳入到一个不可分割的单元,只有在所有操作全部正常执行的情况下方才能提交,只要其中有任一操作执行失败就将导致整个事务回滚到事务开始前的状态

3.2 质疑

以上描述更像是在说事务的原子性,而不是其全部含义,或者说事务只是并发控制的一个基本单位,本身并不保证一致性,因为这个官方描述只能保证串行条件下事务的一致性,在并发操作时就会出问题了,那事务还需要哪些特性才能保证其终极目的(一致性)呢,所以这就有了事务的特性

4 事务的特性(ACID)

A:原子性(Atomicity)

事务中的所有操作作为一个整体像原子一样不可分割,要么全部成功,要么全部失败(简直是事务的官方定义啊,但是注意事务的原子性和java多线程的原子性并不是一回事)

为了保证事务操作的原子性,必须实现基于日志的REDO/UNDO机制:将所有对数据的更新操作都写入日志,如果一个事务中的一部分操作已经成功,但以后的操作,由于断电/系统崩溃/其它的软硬件错误而无法继续,则通过回溯日志,将已经执行成功的操作撤销,从而达到“全部操作失败”的目的。

但是,原子性并不能完全保证一致性。在多个事务并行进行的情况下,即使保证了每一个事务的原子性,仍然可能导致数据不一致的结果。

C:一致性(Consistency)

事务的执行结果必须使数据库从一个一致性状态到另一个一致性状态。一致性是最基本的属性,其它的三个属性都为了保证一致性而存在的。一致性有两个参考标准,如下

满足完整性约束:系统的状态满足数据的完整性约束(主码,参照完整性,check约束等) ;
和期望的真实状态一致:系统的状态反应数据库本应描述的现实世界的真实状态,比如转账前后两个账户的金额总和应该保持不变。

I:隔离性(Isolation)

为了保证并发情况下的一致性,引入了隔离性,即保证每一个事务能够看到的数据总是一致的,就好象其它并发事务并不存在一样。

并发执行的事务不会相互影响,其对数据库的影响和它们串行执行时一样。比如多个用户同时往一个账户转账,最后账户的结果应该和他们按先后次序转账的结果一样。但是现实中完全的做到隔离是很浪费性能的,所以有了隔离级别,数据库底层主要是通过MVCC和锁实现的。

D:持久性(Durability)

指的是只要事务成功结束,它对数据库所做的更新就必须永久保存下来。即使发生系统崩溃,重新启动数据库系统后,数据库还能恢复到事务成功结束时的状态。

5 并发事务常见问题

由于串行操作的效率问题,很多情况下必须允许并行,之前说到事务机制的定义并不能保证并发条件下的数据一致性,这就是可能出现的问题(java多线程共享数据时也会出现),这里注意脏数据是指一切不合理,不正确以及其它不符合期望的数据

5.1 脏写

当两个事务同时尝试去更新某一条数据记录时,就肯定会存在一个先一个后。而当事务A更新时,事务A还没提交,事务B就也过来进行更新,覆盖了事务A提交的更新数据,这就是脏写。这个我理解属于写未提交导致的,在加写锁的情况下就不会出现了。

5.2 丢失更新

存在两种丢失更新情况

5.2.1 第一类丢失更新:回滚覆盖

是指两个事务几乎对同一行记录进行操作,A事务撤销时,把已经提交的B事务的更新数据覆盖了(如有事务A和B,事务A对数据的回滚(10-20-10)导致事务A过程中发起的事务B对数据的已提交修改也被回滚了(10-30-10))

SQL92标准没有定义这种现象,所有隔离界别都不允许第一类丢失更新发生。

5.2.2 第二类丢失更新:提交覆盖

当多个事务并发写同一数据时,先执行的事务所写的数据会被后写的覆盖,这也就是更新丢失。前面的脏写情形,就属于会导致更新丢失问题的一种情形。

除了这个,更新丢失主要发生在read-modify-write类型的事务当中:就是要先查询数据,然后计算新的数据,最后写回新的数据。比如数值更新

5.2.2.1 问题本质

更新操作本身没有什么问题,数据变更是很常见的操作,比如数据的状态变更,总不能不让改吧。问题出在丢失上,当旧值会对新值有影响时(比如数值的加减操作),这就变成了一个先读后写的非原子过程,当程序(比如java)遇到这种非原子的并发操作时也会发生类似的数据安全问题,这里则会导致破坏数据库的一致性

5.2.2.2 解决方案

除了串行化外,其他隔离级别都不能解决丢失更新问题,这个需要开发者自己解决,比如

原子写

UPDATE counter SET value = value + 1 WHERE id = ‘whatever’;

显式锁定

BEGIN TRANSACTION;

#为这行数据加锁(排他锁或共享锁,最好是共享锁),提交或回滚后释放
SELECT * FROM users WHERE id = ‘Eddie’ FOR UPDATE / IN SHARE MODE;

#拿到数据后,应用程序做校验,然后…
UPDATE users SET money = ‘99999999’ WHERE id = ‘Eddie’;

COMMIT;

版本号机制(类似CAS)

版本号机制,增加一个版本或者时间戳的列,对于进行修改行时先获取版本号,修改的时候比较一下版本号是否一致,一致再进行修改并将版本号加一更新

5.3 脏读(读未提交)

是指一个事务读取了另一个事务未提交的数据(如有事务A和B,A读取了B未提交的数据)

脏读会导致什么问题呢,在并发和非原子操作的情况下有可能会导致多个数值不一致的情况,或者在存在提前撤销的情况下看到不该存在的数据(被撤销的数据)

5.4 不可重复读(读操作跨越了修改前后)

是指一个事务对同一数据的读取结果前后不一致(如有事务A和B,A负责读取,B负责写入,A连续读的过程中B写入了一次,A前后两次读出来的数据不一样)

脏读和不可重复读的区别:前者读取的是事务未提交的脏数据,后者读取的是事务已经提交的数据,只不过因为数据被其他事务修改过导致前后两次读取的结果不一样

5.4 幻读( 范围查询时跨越了修改前后)

是指事务读取某个范围的数据时,因为其他事务的操作导致前后两次读取的结果不一致(如有事务A和B,A修改表内数据的过程中,B向表内插入了一条数据,A修改完后发现数据并没有被全部修改完)

幻读和不可重复读的区别:不可重复读是针对确定的某一行数据而言,由update语句导致的,而幻读是针对不确定的多行数据,由insert或delete语句导致的,因而幻读通常出现在带有查询条件的范围查询中

6 MVCC

在了解隔离级别及其实现之前有必要了解一下MVCC,即多版本并发控制(Mutil-Version Concurrency Control),是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。

6.1 意义(处理读-写冲突)

MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

6.2 当前读和快照读

在学习MVCC多版本并发控制之前,我们必须先了解一下,什么是MySQL InnoDB下的当前读和快照读

当前读

像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录加锁。

快照读

即不加锁的非阻塞读,之所以出现快照读的情况,是基于提高并发性能的考虑。既然是基于多版本,快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

抽象概念

当前读,快照读和MVCC都是一些抽象概念:

MVCC指的是“维持一个数据的多个版本,使得读写操作没有冲突”这么一个概念。我们需要MySQL提供具体的功能去实现它,而快照读就是MySQL为我们实现MVCC理想模型的其中一个具体非阻塞读功能

快照读本身也是一个抽象概念,再深入研究。MVCC模型在MySQL中的具体实现则是由 3个隐式字段,undo日志 ,Read View 等去完成的。

6.3 MVCC带来的好处

提高并发读写性能

在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能

解决事务并发问题

还可以通过设置事务隔离级别有选择的解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题

6.4 MVCC官方原理

MySQL官方文档给出的原理是方便理解用的(坑人的),如下:

在InnoDB中,给每行增加两个隐藏字段,一个用来记录数据行的创建时间,另一个用来记录行的过期时间(删除时间)。实际存储的并不是时间,而是事务的版本号,每开启一个新事务,事务的版本号就会递增。

于是乎,默认的隔离级别(REPEATABLE READ)下,增删查改变成了这样:
SELECT:读取创建版本小于或等于当前事务版本号,并且删除版本为空或大于当前事务版本号的记录。这样可以保证在读取之前记录是存在的。

INSERT:将当前事务的版本号保存至行的创建版本号

UPDATE:新插入一行,并以当前事务的版本号作为新行的创建版本号,同时将原记录行的删除版本号设置为当前事务版本号

DELETE:将当前事务的版本号保存至行的删除版本号

6.5 MVCC实际原理

MVCC的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3个隐式字段,undo日志 ,Read View 来实现的

6.5.1 隐式字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段。

DB_TRX_ID:6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID。

DB_ROLL_PTR:7byte,回滚指针,用于配合undo日志,指向上一个旧版本。

DB_ROW_ID:6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。

**deleted_bit:**实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了。

6.5.2 undo日志

Undo log分为Insert和Update两种,delete可以看做是一种特殊的update,即在记录上修改删除标记,对MVCC有帮助的实质是update undo log

insert undo log

代表事务在insert新记录时产生的undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃

update undo log

事务在进行update或delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除

purge

为了节省磁盘空间和不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view),专门的purge线程来清理deleted_bit为true的记录。

清除条件:如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。

版本链

undo log实际上就是存在rollback segment中旧记录链

执行流程:会先对该行加排他锁 -> 把该行数据拷贝到undo log中(头插法) -> 设置事务ID和回滚指针(指向上一个旧纪录)-> 事务提交,释放锁

6.5.3 Read View

Read View就是事务进行快照读操作的时候产生的读视图,主要是用来做可见性判断的,MVCC相关属性如下

trx_ids

初始化时当前未提交的事务列表,trx_ids中的事务(除了自身事务)对于本事务是不可见的。就是创建RV时,将当前活跃事务ID记录下来,后续即使他们提交对于本事务也是不可见的。

up_limit_id

记录trx_list列表中事务ID最小的ID,事务号 < up_limit_id ,就是创建Read View视图的时候,之前已经提交的事务对于该事务都是可见的。

low_limit_id

当前最大的事务号 + 1,事务号 >= low_limit_id,对于当前Read View都是不可见的。理解起来就是在创建Read View视图之后创建的事务对于该事务都是不可见的。

可见性算法

Read View遵循一个可见性算法,将Read View的属性与当前事务ID做比较,如不符合可见性,就遍历链表,直到找到满足条件的旧记录,就是当前事务能看见的最新老版本
比较up_limit_id 可见事务上限:首先比较DB_TRX_ID < up_limit_id , 如果小于,则当前事务能看到所在的记录,如果大于等于进入下一个判断

比较low_limit_id 不可见事务下限:接下来判断 大于等于 low_limit_id , 如果大于等于则代表DB_TRX_ID 所在的记录在Read View生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断

是否在trx_ids未提交务中:判断DB_TRX_ID 是否在活跃事务之中,如果在,则代表我Read View生成时这个事务还没有Commit;如果不在,则说明这个事务在Read View生成之前就已经Commit了,当前事务是能看见的

7 事务的隔离级别

理论上来说事务之间的执行不应该相互产生影响, 然而完全的隔离性会导致系统并发性能很低,因而实际上对隔离性的要求会有所放宽。对于并发问题,隔离级别一般只讨论脏读,不可重复读和幻度问题(脏写问题通过写锁就都解决了,所有隔离级别都不存在这个问题,而更新丢失问题需要程序员自行权衡解决)

7.1 读未提交(READ UNCOMMITTED)

能解决第一类丢失更新的问题,但不能解决脏读的问题。

实现原理是读数据时候不加锁,写数据时候加行级别的共享锁,提交时释放锁。行级别的共享锁,不会对读产生影响,但是可以防止两个同时的写操作。

7.2 读已提交(READ COMMITTED)

能解决脏读的问题,但是不能解决不可重复读的问题。

InnoDB在该隔离级别写数据时,使用排它锁, 读取数据不加锁而是使用了MVCC机制(获取当前数据的最新快照)。

MVCC版本的生成时机是每次select时。这就意味着,如果我们在事务A中执行多次的select,在每次select之间有其他事务更新了我们读取的数据并提交了,那就出现了不可重复读

7.3 可重复读(REPEATABLE READ)

一次事务中只在第一次select时生成版本,后续的查询都是在这个版本上进行,从而实现了可重复读。

RR级别,可以防止大部分的幻读(MVCC+Next-Key Lock),但由于写操作不启用MVCC,像读-写(其它insert,自己update)-读的情况,使用不加锁的select依然会幻读。

7.4 串行化(SERIALIZABLE)

会自动将所有普通select转化为select … lock in share mode执行,即针对同一数据的所有读写都变成互斥的了,可靠性大大提高,并发性大大降低。

8 故障修复技术

8.1 分类

数据库运行过程中可能会出现故障,这些故障包括事务故障和系统故障两大类,

事务故障

比如非法输入,系统出现死锁,导致事务无法继续执行,

系统故障

比如由于软件漏洞或硬件错误导致系统崩溃或中止

8.2 为什么需要故障修复技术

这些故障可能会对事务和数据库状态造成破坏,因而必须提供一种技术来对各种故障进行恢复,保证数据库一致性,事务的原子性以及持久性。

数据库通常以日志的方式记录数据库的操作从而在故障时进行恢复,因而可以称之为日志恢复技术。

8.3 可能出现的问题

由于数据库存在立即修改和延迟修改,所以在事务执行过程中可能存在以下情况:

数据未提交但已部分修改写入磁盘(原子性)

在事务提交前出现故障,但是事务对数据库的部分修改已经写入磁盘数据库中。这导致了事务的原子性被破坏。

数据已提交但未写入磁盘(持久性)

在系统崩溃前事务已经提交,但数据还在内存缓冲区中,没有写入磁盘。系统恢复时将丢失此次已提交的修改。这是对事务持久性的破坏。

8.4 日志的格式

系统在对数据库进行修改前会在日志文件末尾追加相应的日志记录,当一个事务的commit日志记录写入到磁盘成功后,称这个事务已提交,但事务所做的修改可能并未写入磁盘

< checkpoint L>:检查点,系统保证在检查点之前已经提交的事务对数据库的修改已经写入磁盘,不需要进行redo。检查点可以加快恢复的过程,L是写入检查点记录时还未提交的事务的集合,如 <checkpoint {T0,T1}>。
L<T,X,V1,V2>:描述一次数据库写操作,T是执行写操作的事务的唯一标识,X是要写的数据项,V1是数据项的旧值,V2是数据项的新值。
<T,X,V1>:对数据库写操作的撤销操作,将事务T的X数据项恢复为旧值V1。在事务恢复阶段插入。
< T start>:事务T开始。
< T commit>:事务T提交。
< T abort>:事务T中止。

8.5 操作流程

事务正常回滚/因事务故障中止将进行redo,系统从崩溃中恢复时将先进行redo再进行undo

8.5.1 两种核心操作

撤销事务undo

将事务更新的所有数据项恢复为日志中的旧值,事务撤销完毕时将插入一条<T# abort>记录。

日志中只包括记录,但既不包括记录也不包括记录.

重做事务redo

将事务更新的所有数据项恢复为日志中的新值。

日志中包括记录,也包括记录或记录。

8.5.2 事务故障终止/正常回滚的恢复流程

倒序扫描

从后往前扫描日志,对于事务T的每个形如<T,X,#V1,V2>的记录,将旧值V1写入数据项X中

遇start终止

一旦发现了日志记录,就停止继续扫描,并往日## 志中写一个日志记录

8.5.3 系统崩溃时的恢复流程(系统故障)

系统崩溃时的恢复过程分为两个阶段:重做阶段和撤销阶段。

重做阶段

从最后检查点正向开始:系统从最后一个检查点开始正向的扫描日志,将要重做的事务的列表undo-list设置为检查点日志记录中的L列表。

重做更新记录和补偿撤销记录:发现<T,X,V1,V2>的更新记录或<T,X,V>的补偿撤销记录,就重做该操作。

先加入undo-list:发现记录,就把T加入到undo-list中。

再从undo-list去除已经结束的事务:发现或记录,就把T从undo-list中去除。

撤销阶段

先将日志记录中所有事务的更新按顺序重做一遍,在针对需要撤销的事务按相反的顺序执行其更新操作的撤销操作。

反向扫描:系统从尾部开始反向扫描日志。

对undo-list中的事务进行撤销:发现属于undo-list中的事务的日志记录,就执行undo操作。

从undo-list去除已经撤销完成的事务:发现undo-list中事务的T的记录,就写入一条记录,并把T从undo-list中去除。

直到清空undo-list:undo-list为空,则撤销阶段结束。

9 事务总结

事务是数据库系统进行并发控制的基本单位,是数据库系统进行故障恢复的基本单位,从而也是保持数据库状态一致性的基本单位。

数据库系统是通过并发控制技术和日志恢复技术来对事务的ACID进行保证的。

数据库事务的概念体系结构图

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值