《MySQL:MySQL事务特性》
CURD不加控制系统,会有什么问题?
CURD满足什么属性,能解决上述问题?
- 买票过程是原子性的
- 买票互相不能影响
- 买完票之后应该是永久有效的
- 买票前、后要有确定的状态
事务的概念
事务就是一组DML语句组成,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体,以确保数据库的一致性。MySQL提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。
事务简单来说,就是要做的或所做的事情,也就是完成某个业务的多条SQL集合,主要用于处理操作量大,复杂度高的数据。
假设有一种场景:你毕业了,学校的教务系统后台MySQL中,不再需要你的数据,要删除你的所有信息(一般不会,打个比方),那么要删除你的基本信息(姓名,电话,籍贯登)的同时,也删除和你有关的其他信息,比如:你的各科成绩,在校表现,甚至在论坛发过的文章登。这样,就需要多条MySQL语句构成,那么所有这些操作合起来,就构成了一个事务。
为了完成某个业务的一组SQL语句。
一个MySQL数据库,不止一个事务在运行,同一时刻,甚至有大量的请求被包装成事务,在向MySQL服务器发起事务处理请求。而每条事务至少一条SQL,最多很多条SQL。这样,如果多个事务都访问同样的表数据,在不加保护的情况下,就绝对会出问题。甚至,因为事务是由多条SQL构成,那么也会存在执行到一半出错或者不想再继续执行的情况,那么已经执行的怎么办呢?
所以,一个完整的事务,绝对不是简单的SQL集合,还需要满足如下四个属性。
- 原子性:一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间的某个环节。事务在执行过程中如果发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性的完成预订的工作。
- 隔离性:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(Read committed)、可重复读(Repeatable read)和串行化(Serializable)。
- 持久性:事务提交后,对数据的修改就是永久的,数据永久保存在数据库中,即便系统故障,也不会丢失。
这四个属性,可以简称为ACID。
- 原子性(Atomicity,或称不可分割性)
- 一致性(Consistency)
- 隔离性(Isolation,又称独立性)
- 持久性(Durability)
为什么会有事务
事务被MySQL编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。当我们使用事务的时侯,要么提交,要么回滚,不需要去考虑更改一个数据时网络异常了、服务器宕机了怎么办。因此,事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的。
MySQL中的一行信息,称为一条记录。
事务版本支持
在MySQL中只有使用了Innodb数据库引擎的数据库或表才支持事务,MyISAM不支持。
查看数据库引擎
show engines;(或show engines \G)
事务提交方式
事务提交方式常见的有两种:
- 自动提交
- 手动提交
查看事务提交方式:
改变事务提交方式:
禁止自动提交。
开启自动提交。
事务常见操作方式
为了演示,先将事务隔离级别设置为读未提交(隔离级别下面会说)。
mysqld是一个网络服务,可以有多个客户端连接。创建测试表。
create table if not exists account(
id int primary key,
name varchar(50) not null default '',
blance decimal(10,2) not null default 0.0
)ENGINE=InnoDB DEFAULT CHARSET=UTF8;
正常演示一:
- 证明事务的开始与回滚。事务提交方式故意设置为自动提交(方便与下面例子作对比)。
可以手动创建保存点,回滚到保存点。
可以rollback回滚到事务最开始。
回滚一定是在事务运行期间回滚,事务提交之后数据已经被持久化保存在数据库中了,不可回滚。
非正常演示一:
- 证明事务未commit,客户端崩溃,MySQL会自动回滚。(隔离级别设置为读未提交)
commit之前,如果异常,数据可以自动回滚,保证了事务原子性(要么提交成功,要么失败),commit之后,数据自动提交到数据库中,完成持久化。
非正常演示二:
- 对比实验,证明begin操作会自动更改提交方式,不会受MySQL是否自动提交影响
非正常演示三:
- 证明单条SQL与事务的关系
总结
- 只要输入begin/start transaction启动事务,事务必须通过commit提交才会持久化,与是否设置autocommit无关。
- 事务可以手动回滚,同时,当操作异常时,MySQL会自动回滚。
- 对于InnoDB每一条SQL语言都默认封装成事务,自动提交。(select除外,因为MySQL有MVCC)
- 从上面的演示中,可以看到事务本身的原子性(回滚),与持久性(commit)。
事务操作注意事项
- 如果没有设置保存点,也可以使用rollback回滚,只能回滚到事务最开始,前提事务还没有commit。
- 如果一个事务被commit了,不可rollback。
- InnoDB支持事务,MyISAM不支持事务。
- 事务开始可以使用begin或start transaction。
事务隔离级别与隔离性
隔离性
MySQL服务可能会同时被多个客户端进程(线程)访问,访问的方式以事务方式进行。
一个事务可能由多条SQL构成,也就意味着,任何一个事务,都有执行前,执行中,执行后的阶段。而所谓的原子性,其实就是让用户层要么看到执行前,要么看到执行后。执行中出现问题,可以随时回滚。所以单个事务,对用户表现出来的特性,就是原子性。
但是,毕竟所有事务都要有个执行过程,那么在多个事务各自执行多个SQL的时候,就还是有可能会出现互相影响的情况。比如:多个事务同时访问同一张表,甚至用一行数据。
就如同妈妈说:你要么别学,要学就学到最好。至于你怎么学,中间有什么困难,妈妈不关心。那么你的学习,对妈妈来讲,就是原子的。那么你学习过程中,很容易受到别人干扰,此时,就不需要将你的学习隔离开,保证你的学习环境是健康的。
数据库中,为了保证事务执行过程中尽量不受干扰,就有了一个重要特征:隔离性。
数据库中,允许事务受不同程度的干扰,根据受影响程度的不同,就有了一种重要特征:隔离级别。
隔离级别
- 读未提交【Read Uncommitted】:在该隔离级别下,所有的事务都可以看到其他事务没有提交的执行结果(实际生产中不可能使用这种隔离界别)。相当于没有任何隔离性,也会有很多并发问题,如脏肚,幻读,不可重复读等。(上面为了方便作演示,用的就是这个隔离性)
- 读提交【Read Committed】:该隔离级别是大多数数据库的默认隔离级别(不是MySQL默认的)。它满足了隔离的简单定义:一个事务只能看到其他的已经提交的事务所作的改变。这种隔离级别会引起不可重复读,即一个事务执行时,如果多次select,可能得到不同的结果。
- 可重复读【Repeatable Read】:MySQL默认的隔离级别,它确保同一个事务,在执行中,多次读取操作数据时,会看到同样的数据行。但是会有幻读问题。
- 串行化【Serializalbe】:是事务的最高隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决了幻读问题。它在每个读的数据行上面加上共享锁。但是可能会导致超时和锁竞争。(这种隔离级别太极端,实际生产基本不适用)
隔离级别如何实现:隔离,基本都是通过锁来实现的,不同的隔离级别,锁的使用是不同的。常见的有,表锁、行锁、读锁、写锁、间隙锁(GAP)、Next-Key锁(GAP+行锁)等。我们关注上层的使用就可以。
查看与设置隔离级别
查看。
当前会话的隔离级别默认就是全局的隔离级别,MySQL的全局隔离级别默认为可重复读Reatable Read,(这里最开始就更改为了读未提交所以查看到隔离级别为读未提交)。
更改隔离级别。
SET [ SESSION | GLOBAL ] TRANSACTION ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE }
读未提交【Read Uncommitted】
一个事务在执行CURD操作时,还没有commit提交,另一个事务就能读到。所有的事务都可以看到其他事务没有提交的执行结果。
几乎没有加锁,虽然效率高,但是问题太多,不建议采用。
一个正在执行中的事务,读到另一个正在执行中事务的更新(或其他操作),但是未commit的数据,这种现象叫做脏读。
读提交【Read Committed】
更改当前事务的隔离级别。
一个正在执行中的事务,读不到另一个正在执行中未commit的事务的数据,只有该事务commit之后才能被读到。一个事务只能看到其他的已经提交的事务所作的改变。
此时,当前事务还在执行中,并未commit,那么就造成了,在同一个事务内,同样的读取,在不同的时间段(依旧还在事务操作中!),读取到了不同的值,这种现象就叫做不可重复读(non reapeatable read)!
当前事务还没有结束,但是能查看到不同的值,依旧是有问题的。
可重复读【Repeatable Read】
设置隔离级别为repeatable read。
同一个事务,在执行中,多次读取操作数据时,会看到同样的数据行,可重复读。
在另一个正在执行的事务中(未commit),无论在什么时候进行查看,看到的都是同样的数据,这就叫做可重复读,只有commit提交之后,才能看到更改的数据。
注意:一般的数据库在可重复读情况的时候,无法屏蔽其他事务insert的数据,(因为隔离性实现是对数据进行加锁完成的,而insert待插入的数据因为并不存在,那么一般加锁无法屏蔽这类问题)会造成虽然大部分内容是可重复读的,但是insert的数据在可重复读情况被读取出来,导致多次查找时,会多查找出来新的记录,如同出现了“幻觉”一样,这种现象,叫做幻读(phantom read)。MySQL在RR级别的时候,可以解决幻读问题,解决方式是用Next-key锁解决的。
可串行化【serializable】
设置事务隔离级别为serializable。
对所有操作全部加锁,进行串行化,不会有问题,但是只要串行化,效率很低,几乎完全不会被采用。也就是对事务进行串行化,一个事务执行完,另一个事务才能执行。
总结
- 隔离级别越严格,安全性越高,但数据库的并发性能也就越低,往往需要在两者之间找一个平衡点。
- 不可重复读的重点是修改和删除:同样的条件,读取过的数据,再次读取出来发现值不一样了。
- 幻读的重点在于insert新增:同样的条件,第一次读和第二次读出来的记录数不一样。
- MySQL的默认隔离级别是可重复读,一般情况不需要修改隔离级别。
- 从上面的演示中可以看出,事务也有长短事务这样的概念。事务之间相互影响,指的是事务在并发执行的时候,即都没有commit的时候,影响会比较大。
一致性(Consistency)
一致性是事务的最终目标。
事务执行的结果,必须使数据库从一个一致性的状态,变到另一个一致性状态。当数据库只包含事务成功提交的结果时,数据库处于一致性状态。如果系统运行发生中断,某个事务尚未完成而被迫中断,而未完成的事务对数据库所做的修改已被写入数据库,此时数据库就处于一种不正常(不一致)的状态。因此一致性是通过原子性来保证的。
其实一致性和用户的业务逻辑强相关,一般MySQL提供技术支持,但是一致性还是要用户业务逻辑做支撑,也就是一致性是由用户决定的。
而技术上,通过AID保证C。
原子性、隔离性、持久性是实现一致性的手段。
以上内容就已经对MySQL事务有了足够的基本认识,不过下面的内容会进一步加深对事务的理解。
再次理解隔离性
数据库并发的场景有三种:
- 读-读:不存在任何问题,也不需要并发控制。
- 读-写:有线程安全问题,可能造成事务隔离性问题,可能遇到脏读、幻读、不可重复读。
- 写-写:有线程安全问题,可能存在更新丢失问题,比如第一类更新丢失、第二类更新丢失。
目前只关心读-写场景就可以。
多版本并发控制MVCC,是一种用来解决读-写冲突的无锁并发控制。
为事务分配单向增长的事务ID,为每个修改保存一个版本,版本与事务ID关联,读操作只读事务开始前的数据库的快照。所以MVCC可以为数据库解决以下问题:
-
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能。
-
同时还可以解决脏读、幻读、不可重复读等事务隔离问题,但不能解决更新丢失问题。
理解MVCC需要知道三个前提知识:
- 3个记录隐藏字段
- undo日志
- Read View
3个记录隐藏列字段
- DB_TRX_ID:6byte,最近修改(修改/插入)事务ID,记录创建这条记录/最后一次修改该记录的事务ID
- DB_ROLL_PTR:7byte,回滚指针,指向这条记录的上一个版本(简单理解成指向历史版本就行,这些数据一般在undo log中)
- DB_ROW_ID:6byte,隐含的自增ID(隐藏主键),如果数据表中没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。
- 补充:实际还有一个删除flag隐藏字段,既记录被更新或删除并不代表真的删除,而是删除flag变了
假设有如下表结构:
上面描述的意思是:
目前并不知道创建该记录的事务ID,隐式主键,就默认设置成null,1。第一条记录也没有其他版本,回滚指针为null。
事务ID
每个事务都要有自己的事务ID,可以根据事务ID的大小,来决定事务到来的先后顺序。
mysqld可能面临处理多个事务的情况,事务也有自己的生命周期,mysqld要对多个事务进行管理,先描述,再组织。所以,mysqld中一定是对应的一个或者一套结构体对象/类对象,事务也要有自己的结构体。
undo日志
MySQL是以服务进程的方式,在内存中运行的。索引、事务、隔离性、日志等机制都是在内存中完成的,即在MySQL内存的相关缓冲区中,保存相关数据,完成各种判断操作。然后在合适的时候,将相关数据刷新到磁盘当中的。
所以,undo log,简单理解成就是MySQL中的一段内存缓冲区,用来保存日志数据就可以了。
模拟MVCC
现在有一个事务10(仅仅为了好区分),对student表中的记录进行修改(update):将name(张三)改成name(李四)。
-
事务10,因为要修改,所以要先给记录加行锁。
-
修改前,先将该行记录拷贝到undo log中,所以,undo log中就有了一行副本数据。(原理就是写时拷贝)
-
所以现在MySQL中有两行同样的记录。现在修改原始记录中的name,改成“李四”。并且修改原始记录隐藏字段DB_TRX_ID为当前事务10的ID,我们默认从10开始,之后递增。而原始记录的回滚指针DB_ROLL_PTR列,里面写入undo log中副本数据的地址,从而指向副本记录,即表示“我的”上一个版本就是它。
-
事务10提交,释放锁。
此时,最新的记录是“李四”那条记录。
现在,又有一个事务11,对student表中记录进行修改(update):将age(28)改成age(38)。
-
事务11,因为也要修改,所以要先给该记录加行锁(最新记录)。
-
修改前,先将该行记录拷贝到undo log中,所以undo log中就又有了一行副本数据。此时,新的副本,采用头插方式,插入到undo log。
-
现在修改原始记录中的age,改成38。并且修改原始记录的隐藏字段DB_TRX_ID为当前事务11的ID。而原始记录的回滚指针DB_ROLL_PTR列,里面写入刚才头插进undo log中副本数据的地址,从而指向副本记录,即表示“我的”上一个版本就是它。
-
事务11提交,释放锁。
此时,最新的记录为“age为38”的那条记录。
这样,就有了一个基于链表记录的历史版本链。所谓的回滚,无非就是用历史数据,覆盖当前数据。
上面的一个个版本,称之为一个个快照。
思考
- 上面是以更新(update)为主的,如果是delete呢?
一样。删数据不是清空,而是设置flag为删除即可,同样也可以形成版本。
- 如果是insert呢?
插入。也就是之前没有数据,那么insert也就没有历史版本。但是一般为了回滚操作,insert的数据也是要被放入undo log中的,如果当前事务commit了,那么这个unod log的历史insert记录就可以被清空了。
可以理解称,update和delete可以形成版本链,insert暂时不考虑。
- select呢?
select不会对数据做任何修改,所以为select维护多版本没有意义。不过有个问题:
- select读取,是读取最新的版本还是读取历史版本?
当前读:读取最新的记录,就是当前读。增删改,都叫做当前读,select也有可能当前读,比如:select lock in share mode(共享锁),select for update。(好理解)
快照读:读取历史版本(一般而言),就叫做快照读。(下面重点理解)
可以看到,在多个事务同时增删改的时候,都是当前读,是要加锁的。那同时又select过来,如果也要读取最新版本(当前读),那么也就需要加锁,这就是串行化。
但是如果是快照读,读取历史版本的话,是不受加锁限制的。也就是可以并行执行!提高了效率,即MVCC的意义所在!
- 那么是什么决定了,select是当前读,还是快照读?
隔离级别!
- 那么为什么要有隔离级别?
事务都是原子的。无论如何,事务总有先有后。
但是,事务从begin->CURD->commit,是有一个阶段的。也就是事务执行前、执行中、执行后的阶段。但是,不管怎么启动多个事务,也总是有先有后的。
那么多个事务在执行中,CURD操作是会交织在一起的。那么,为了保证事务的“有先有后”,是不是应该让不同的事务看到它该看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。
- 先来的事务,应不应该看到后来的事务所作的修改呢?
- 那么,如何保证,不同的事务,看到不同的内容呢?也就是如何实现隔离级别呢?
Read View
Read View就是事务进行快照读操作的时候产生的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护当前活跃事务的ID(当每个事务开启时,都会被分配一个ID,这个ID时递增的,所以最新的事务,ID值越大)
Read View在MySQL源码中就是一个类,本质是用来进行可见性判断的。即当我们某个事物执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
Read View的结构中有这样几个字段:
m_ids; // 一张列表。用来维护Read View生成时刻,系统正在活跃的事务ID
up_limit_id; // 记录m_ids列表中事务ID最小的ID
low_limit_id; // Read View生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
creator_trx_id; // 创建该ReadView的事务ID
我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的DB_TRX_ID。
那么,我们现在知道,当前快照读的ReadView和版本链中的某一个记录的DB_TRX_ID。
所以现在的问题就是,当前快照读,应不应该读到当前版本记录。看下面这张图就能清楚。
对应的源码策略:
如果查到不应该看到当前版本,接下来就是遍历下一个版本,直到符合条件。ReadView是当你进行select的时候,会自动形成。
整体流程演示
假设当前有记录:
事务操作:
- 事务4:修改name(张三)变成name(李四)
- 当事务2对某行数据执行了快照读,数据库为改行数据生成一个Read View读视图
// 事务2的 Read View
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4+1=5 ReadView生成时刻,系统尚未分配的下一个事务ID
creastor_trx_id; // 2
此时的版本链为:
- 只有事务4修改过该行记录,并在事务2中执行快照读前,就提交了事务
- 事务2在快照读该行记录的时候,就会拿着历史版本链中该行记录的DB_TRX_ID去跟up_limit_id,low_limit_id和活跃事务ID列表进行比较,判断当前事务2能看到该行记录的版本
// 事务2的 Read View
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4+1=5 ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id; // 2
// 事务4提交记录对应的事务ID
DB_TRX_ID=4
// 比较步骤
creator_trx_id(2) == DB_TRX_ID(4)|| DB_TRX_ID(4)< up_limit_id(1)?不等于也不小于,下一步
DB_TRX_ID(4)>= up_limit_id(1)?不大于,下一步
m_ids.contains(DB_TRX_ID)?不包含,说明事务4不在当前的活跃事务中
// 结论
事务4的更改应该能看到。所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度的最新版本。
RR与RC的本质区别
当前读和快照读在RR级别下的区别。
设置隔离性为可重复读。select * from student lock in share mode,以加共享锁方式读取,为当前读。
测试用例-表1
测试用例-表2
-
用例1和用例2:唯一区别仅仅是表1中的事务B在事务A修改age之前快照读过一次age数据
-
而表2的事务B在事务A之前没有进行过快照读
结论:
-
事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读,决定该事务后续快照读结果的能力
-
delete同样如此
RR与RC本质区别
-
正式Read View生成时机的不同,从而造成RC、RR级别下快照读的结果的不同。
-
在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View,将当前系统活跃的其他事务记录起来。此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见。
-
即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见的。
-
而在RC级别下的事务中,每次快照读都会新生成一个快照和Read View,这就是我们在RC级别下的事务中可以看到别的事务提交更新的原因。
-
总之,在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View,之后的快照读获取的都是同一个Read View。
-
正是RC每次快照读都会形成新的Read View,所以,RC才会有不可重复读的问题。