事务概念
事务就是一组DML语句组成,这些语句在逻辑上存在相关性,主要用于处理操作量大,复杂度高的数据;例如:转账等多sql语句的业务,包括查询余额(select),减少转账人余额(update),增加指定人的余额(update)…等多条sql语句。 而这些sql语句只要其中一句没有执行成功,转账这个事务就没有完成。
而mysql中,肯定不止一个的事务在运行,同一时刻,可能会有大量的请求被包装成事务,在向mysql服务器发送事务处理请求。而事务中至少包含一条sql语句,肯定会存在大家都在访问同样的表数据,在不加保护的情况下,就绝对会出现并发常见问题(数据不一致…)。也会存在一个事务执行到一半出错或者是不想继续执行的情况;
所以, 一个完整的事务,决定不仅仅是简单的sql集合,还需要满足如下4个属性:
上面四个属性,可以简称为 ACID 。
原子性(Atomicity,或称不可分割性)
一致性(Consistency)
隔离性(Isolation,又称独立性)
持久性(Durability)。
事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题.可以想一下当我们使用事务时,要么提交,要么回滚,我们不会去考虑网络异常了,服务器宕机了,同时更改一个数据时怎么保证安全;
因此事务本质是为了应用层服务的,而不是伴随着数据库系统天生就有的;
即mysql原本是不需要向我们提供事务这个功能的,但是为了简化客户使用成本,简称就是用得爽一点,不用考虑太多问题;
注: 我们后面把mysql中的一行信息,称为一行记录;
show engines;
说明:
查看提交方式
show variables like 'autocommit' ;
注:ON 代表打开, OFF关闭
设置提交方式的语句
set autocommit = 0/1 ;
例:将提交方式设置为手动提交,即将自动提交关闭
准备工作
我们需要先将msyql的隔离模式设为 读未提交(read uncommitted) ,因为隔离级别越低,越好观察现象;
注:设置完隔离级别之后,需要将服务重启;
set global transaction isolation level read uncommitted;
创建测试表
create table if not exists account1(
id int primary key,
name varchar(50) not null default '',
blance decimal(10,2) not null default 0.0
)ENGINE=InnoDB DEFAULT CHARSET=UTF8;
演示一: 手动回滚操作
为了观察现象方便,我们需要建立俩个连接,连接A用来操作,连接B用来观察现象;
创建测试表
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SAgqVplR-1686568388898)(null)]
俩个连接打开事务(start transaction or begin),连接A向表中插入数据
start transaction ; -- 俩个命令都可以用来打开一个事务
begin;
打开事务,并向account1 插入数据1, 然后创建保存点s1, 然后再插入数据2
这时候我们回滚到s1,然后再使用连接B查看account1里面的数据,而如果直接回滚,就会直接回到事务一开始
演示二: mysql的原子性
查看当前提交方式为自动提交,而后创建俩个事务,当前表中数据为空
连接A向表中插入数据,然后使用连接B查看表中数据
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vlfdplzx-1686568388957)(null)]
不提交事务,直接将连接A退出,使用连接B查看表中数据
演示三: mysql的持久性
依旧是先创建俩个事务
然后使用事务A向表中插入数据,然后使用事务B查看
而后先将事务Acommit后然后再退出mysql,而后再使用事务B查看
演示四: 验证单条sql语句与事务的关系
我们先在提交方式为自动提交下,进行sql操作,然后终止掉连接A,使用事务B查看插入结果
然后我们再将提交方式改为‘手动提交’再重复一遍上面的操作;
结果:
我们会发现当我们把提交方式变成手动提交,然后直接终止mysql服务,我们会发现插入操作被回滚了;
结论:
只要输入begin或者start transaction,事务便必须要通过commit提交,才会持久化,与是否设置set autocommit无关。
事务可以手动回滚,同时,当操作异常(使用ctrl + D 或ctrl + \信号直接终止掉mysql服务的进程),MySQL会自动回滚
对于 InnoDB 每一条 SQL 语言都默认封装成事务,自动提交。(select有特殊情况,因为 MySQL 有MVCC )
事务操作注意事项:
如果没有设置保存点,也可以回滚,只能回滚到事务的开始。直接使用 rollback(前提是事务还没有提交)
如果一个事务被提交了(commit),则不可以回退(rollback)
可以选择回退到哪个保存点
InnoDB 支持事务, MyISAM 不支持事务
开始事务可以使用 start transaction 或者 begin
读未提交【Read Uncommitted】: 在该隔离级别,所有的事务都可以看到其他事务没有提交的执行结果。(实际生产中不可能使用这种隔离级别的),但是相当于没有任何隔离性,也会有很多并发问题,如脏读,幻读,不可重复读等,我们上面为了做实验方便,用的就是这个隔离性。
读提交【Read Committed】 :该隔离级别是大多数数据库的默认的隔离级别(不是 MySQL 默认的)。它满足了隔离的简单定义:一个事务只能看到其他的已经提交的事务所做的改变。这种隔离级别会引起不可重复读,即一个事务执行时,如果多次 select, 可能得到不同的结果。
可重复读【Repeatable Read】: 这是 MySQL 默认的隔离级别,它确保同一个事务,在执行中,多次读取操作数据时,会看到同样的数据行。但是会有幻读问题。
串行化【Serializable】: 这是事务的最高隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决了幻读的问题。它在每个读的数据行上面加上共享锁,。但是可能会导致超时和锁竞争(这种隔离级别太极端,实际生产基本不使用)
注:隔离级别如何实现:隔离,基本都是通过锁实现的,不同的隔离级别,锁的使用是不同的。常见有,表锁,行锁,读锁,写锁,间隙锁(GAP),Next-Key锁(GAP+行锁)等
查看隔离级别
select @@global.tx_isolation ; -- 查看全局隔离级别
select @@session.tx_isolation ; -- 查看当前会话全局隔离级别
select @@tx_isolation ; --查看当前隔离级别 默认和当前会话隔离级别相同
注:
设置隔离级别
set global transaction isolation level 隔离级别; -- 设置全局隔离级别
set session transaction isolation level 隔离级别; -- 设置当前会话级别
设置全局隔离级别
设置会话隔离级别
注:
设置隔离级别为read uncommitted
说明:使用俩个连接 , 连接A进入插入数据,连接B查看数据
使用连接A更新表中数据,我们发现连接A并未将事务commit,而连接B却能看到A事务所做出的修改
设置隔离级别未read committed,而后重启服务
然后使用连接A更新数据,使用连接B查看数据;
我们会发现连接A在未将事务提交时,连接B确实是看不到数据的,而在连接A将事务提交之后,是能看到连接A更新的数据的;
注:
设置全局隔离级别未repeatable read ,重启服务,并查看一下当前数据
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jtLsVm5p-1686568388985)(null)]
而后使用连接A向表中更新数据和插入数据,使用B在连接Acommit前和commit后查看数据
我们会发现无论是连接commit前和commit后,连接B都是查看不到事务A更新的数据,也就是说repeatable read 隔离级别下是解决了‘脏读’和‘不可重复读’这俩个问题的;
但是,一般的数据库在可重复读情况的时候,无法屏蔽其他事务insert的数据(为什么?因为隔离性实现是对数据加锁完成的,而insert待插入的数据因为并不存在,那么一般加锁无法屏蔽这类问题),会造成虽然大部分内容是可重复读的,但是insert的数据在可重复读情况被读取出来,导致多次查找时,会多查找出来新的记录,就如同产生了幻觉。这种现象,叫做幻读(phantom read)
验证:mysql中是否具有幻读问题
依旧是先查看表中的数据,然后使用俩个连接分别启动俩个事务;
而后我们使用连接A向表中插入一条新数据,使用连接B观察现象
在事务B提交之后,就可以看到事务A更新的数据了
很明显,MySQL在RR级别的时候,是解决了幻读问题的(解决的方式是用Next-Key锁(GAP+行锁)解决的
先将全局隔离级别置成Serializable,然后重启服务
查看目前的表中数据,确保隔离级别为串行化
查看表中数据,我们会发现查看数据是不会发生串行化
但是进行插入操作时,我们发现如果事务B没有commit,事务A的插入操作是一直被堵塞的,而且如果在等待时间里事务B还未commit,事务A的插入操作就会超时失败,即事务A就会失败
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AWap2K7g-1686568388927)(null)]
总结:
隔离级别 | 脏读 | 不可重复读 | 幻读 | 是否加锁读 |
---|---|---|---|---|
读未提交(read uncommitted) | Yes | Yes | Yes | 不加锁 |
读已提交(read committed) | No | Yes | Yes | 不加锁 |
可重复读(repeatable read) | No | No | No | 不加锁 |
可串行化 (serializable) | No | No | No | 加锁 |
Yes:会发生该问题 No:不会发生该问题
注:一般的数据库repeatable隔离模式下,都会存在幻读现象,但从上面验证来看mysql中是不存在幻读现象的
事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态。当数据库只包含事务成功提交的结果时,数据库处于一致性状态。
如果系统运行发生中断,某个事务尚未完成而被迫中断,而改未完成的事务对数据库所做的修改已被写入数据库,此时数据库就处于一种不正确(不一致)的状态。因此一致性是通过原子性来保证的。
如果数据库因为出现某种问题,导致数据库中的某些数据丢失了,而客户又以为这些数据依旧存在,此时数据库就会处于一种不正确(不一致)的状态。因此一致性是通过持久性来保证的。 --例子: 某位购买了车票的乘客,因购买记录在数据库中不慎丢失,导致乘客登车时,查找不到购买记录,但是乘客有扣费记录;就会导致很严重的问题
例如多个事务同时操作数据库中的数据,如果不对这些事务加以隔离,就会造成多个事务并行执行时,不同事务看到的数据可能会出现‘脏读’,‘不可重复读’,‘幻读’等现象,会让数据库处于一种不一致的状态。因此一致性是通过隔离性来保证的;
而技术上,通过AID保证C的;
而且要保证事务的一致性,不仅需要AID这三个属性,还需要程序员的sql语句是符合当前事务的一致性逻辑的。-- 例子: 某程序员在银行工作,所写的转账事务,只写了减少转账人的余额的sql,而增加被转账人的余额的sql却没有;
所以说保障事务的一致性,不仅需要原子性,隔离性,持久性来保证,还得需要程序员自身的代码逻辑是没有问题的;即一致性是由mysql和用户一同保证的
数据并发的场景有以下3种:
读-读 :不存在任何问题,也不需要并发控制
读-写 :有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
写-写 :有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失
说明:
多版本并发控制(MVCC)
多版本并发控制(Multi-Version Concurrency Control
)是一种用来解决 读-写冲突 的无锁并发控制
为事务分配单向增长的事务ID,为每个修改保存一个版本,版本与事务ID关联,读操作只读该事务开始前的数据库的快照。
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
DB_TRX_ID :6 byte,最近修改( 修改/插入 )事务ID,记录创建这条记录/最后一次修改该记录的事务ID
DB_TRX_ID : 7 byte,回滚指针,指向这条记录的上一个版本(简单理解成,指向历史版本就行,这些数据一般在 undo log 中)
DB_ROW_ID : 6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以 DB_ROW_ID 产生一
个聚簇索引
补充:实际上还有一个删除flag字段,即记录被更新或者删除并不代表真的删除,而是将flag置1;
例:
创建一个测试表并插入一条数据
create table if not exists student(
name varchar(11) not null,
age int not null
);
insert into student (name,age) values ('张三',28);
而实际上该表结构是这样的:
name | age | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐式主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 28 | null | 1 | null |
说明:
MySQL的三大日志如下:
MySQL会为上述三大日志开辟对应的缓冲区,用于存储日志相关的信息,必要时会将缓冲区中的数据刷新到磁盘。
说明:
详细介绍日志可参考这篇文章
模拟MVCC
现在又有一个事务11,对student表中记录进行修改(update):将age(28)改成age(38)
历史版本链
,而所谓的回滚,实际上就是使用回滚指针指向的数据版本,覆盖掉当前数据快照概念
上面的一个一个版本数据,就称为一个一个快照
select , insert ,delete 是否会具有历史版本链呢?
delete
操作并不是真的将数据删除,而是将flag字段置为1,代表该字段要删除,所以delete
和update
是一样的,会具有历史版本链insert
:因为insert
是插入,也就是之前没有数据,那么insert
也就没有历史版本。但是一般为了回滚操作,insert的数据也是要被放入undo log中(实际上就是将当前数据拷贝一份,然后将flag置1,代表删除,放到undo log),如果当前事务commit了,那么这个undo log 的历史insert记录就可以被清空了select
不会对数据做任何修改,所以,为select
维护多版本,没有意义;但我们现在上面的数据是有很多不多版本的,而我们select的时候读那个版本的数据呢? 读取不同版本就分为了快照读和单纯读当前读和快照读
其中,insert , update ,delete ,select lock in share mode (共享锁),select for update 都是当前读
即只有select操作具有快照读和当前读,其他操作都是当前读
Read View
Read View就是事务进行 快照读 操作的时候生产的 读视图 (Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)
Read View 在 MySQL 源码中,就是一个类(因为mysql是用c++写的),本质是用来进行可见性判断
的。 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。
下面是部分源码:
class ReadView {
// 省略...
private:
/** 高水位,大于等于这个ID的事务均不可见*/
trx_id_t m_low_limit_id
/** 低水位:小于这个ID的事务均可见 */
trx_id_t m_up_limit_id;
/** 创建该 Read View 的事务ID*/
trx_id_t m_creator_trx_id;
/** 创建视图时的活跃事务id列表*/
ids_t m_ids;
/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
/** 标记视图是否被关闭*/
bool m_closed;
// 省略...
};
主要属性:
m_ids; //一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
up_limit_id; //记录m_ids列表中事务ID最小的ID(没有写错)
low_limit_id; //ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1(也没有写错)
creator_trx_id //创建该ReadView的事务ID
说明:
low_limit_id
理解为下一个事务创建时所分配的事务ID;m_ids
: 可以理解为同一时期内创建的事务ID;同一时期:[up_limit_id , low_limit_id -1 ]up_limit_id
: 当前活跃组中事务ID的最小值;如何判断当前快照读,是否能读取到某个版本的数据呢?看下图
我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的 DB_TRX_ID
。
那么,我们现在手里面有的东西就有,当前快照读的 ReadView 和 版本链中的某一个记录的 DB_TRX_ID
注:图中id指定是 DB_TRX_ID
即当前版本数据的最近修改或创建ID
对应的源码策略:
说明:
说明:
DB_TRX_ID
== 当前的creator_trx_id
,表示的是该记录可能是刚刚由本事务修改的,所以当前事务是可以读取到该版本记录的;DB_TRX_ID
> low_limit_id
即表示当前记录版本的ID,是比当前所有活跃的事务都要晚创建的,而我们快照读时只能读历史版本,且该ID不属于同一时期事务的ID,所以当前事务不能读取当前版本的记录整体流程
假设当前有条记录:
name | age | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐式主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 28 | null | 1 | null |
事务操作:
事务1 [id = 1] | 事务2 [id = 2] | 事务3 [id = 3] | 事务4 [id = 4] |
---|---|---|---|
begin | begin | begin | begin |
… | … | … | 修改且已提交 |
进行中 | 快照读 | 进行中 | |
… | … | … |
Read View
读视图事务2的 Read View 读视图
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id // 2
即当前Read View 认为同一时期的事务是[1,4]
此时数据的版本链是这样的
DB_TRX_ID
去跟 up_limit_id
,low_limit_id
和活跃事务ID列表(trx_list)
进行比较,判断当前事务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
//比较步骤
DB_TRX_ID(4)< up_limit_id(1) ? 不小于,下一步
DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中。
//结论
故,事务4的更改,应该看到。
所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
结论:
DB_TRX_ID
去跟up_limit_id
,low_limit_id
活跃事务ID列表(trx_list)
进行比较,判断当前事务能看到该记录的版本,如果不满足,就通过当前记录的回滚指针找到历史版本,重复上述操作,直到找到匹配的记录版本;当前读和快照读在RR级别下的区别
--设置RR模式下测试
mysql> set global transaction isolation level REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)
--重启终端
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
--依旧用之前的表
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;
--插入一条记录,用来测试
mysql> insert into user (id, age, name) values (1, 15,'黄蓉');
Query OK, 1 row affected (0.00 sec)
测试用例1-表1:
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
select * from user | 快照读(无影响查询) | 快照读查询 | select * from user |
update user set age = 18 where id = 1 | 更新age = 18 | - | - |
commit | 提交事务 | - | - |
select 快照读,没有读到age = 18 | select * from user | ||
select lock in share mode 当前读,读到age = 18 | select * from user lock in share mode |
测试用例ku2-表2:
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
select * from user | 快照读,查到age = 18 | ||
uddate user set age = 28 where id =1; | 更新age = 28 | ||
commit | 提交事务 | ||
select 快照读 age =28 |
select * from user | ||
select lock in share mode 当前读 age = 28 |
select * from user lock in share mode |
用例1与用例2:唯一区别仅仅是 表1 的事务B在事务A修改age前 快照读 过一次age数据
而表2的事务B在事务A修改age前没有进行过快照读。
结论:
事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读,决定该事务后续快照读结果的能力;
注: 上速操作虽然是update, 但delete操作数据同样如此
RR 和RC的本质区别
RR : Repeatable Read RC: Read Committed
总结:
不可重复读问题
,使得不同的事务可以高效率的安全的并行访问数据其中解决了幻读问题的方案是next- key锁(gap + 行锁);
感兴趣的可参考下面几篇博客:
照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改记录版本不可见;
总结:
不可重复读问题
,使得不同的事务可以高效率的安全的并行访问数据其中解决了幻读问题的方案是next- key锁(gap + 行锁);
感兴趣的可参考下面几篇博客:
博客1
博客2