MySQL 事务
MySQL 事务
事务的定义
把需要保证原子性、一致性、隔离性、持久性的一个或多个数据库操作称为事务
事务的四大特性 - ACID
原子性
一个事务中所有的 SQL 语句的操作,要么全部成功,要么全部失败;
一致性
事务操作前后,数据满足完整性约束,数据库保持一致性状态;
隔离性
数据库允许多个并发事务同时读取或修改数据,隔离性要求多个并发事务之间是相互隔离,每个事务都拥有自己的数据空间,不能相互干扰;
持久性
持久性意味着事务操作后的数据要在磁盘下留下来,即使系统故障也不会丢失;
实现原理
持久性是通过 redo log
(重做日志)实现的;
原子性是通过 undo log
(回滚日志)实现的;
隔离性是通过 MVCC
或 锁机制 来保证的;
一致性是通过 持久性 + 原子性 + 隔离性 实现的;
并行事务会带来什么问题
脏读:一个事务读到了另一个未提交事务修改的数据
不可重复读:一个事务进行两次查询操作,得到的两次数据结果不一致(另一个事务更新数据)
幻读:一个事务进行两次查询操作,得到的两次数据的数量不一致(另一个事务增加或删除数据)
事务的隔离级别
读未提交( read uncommited ):一个事务 未提交 时,它做的变更可以被其他事务看到;
读已提交( read commited ):一个事务 提交 以后,它做的变更可以被其他事务看到;
可重复读( repeatable read ):一个事务执行过程中的数据,与该事务启动时数据库中的数据一致,为 MySQL 的默认隔离级别;
串行化( serializable ):会对读写操作进行加锁,一旦发生读写冲突,后访问的事务必须等待前一个事务执行完毕才能进行访问;
在 读未提交 的情况下会发生:脏读、可重复读、幻读
在 读已提交 的情况下会发生:可重复读、幻读
在 可重复读 的情况下会发生:幻读
在 幻读 的情况下可以保证脏读、可重复读、幻读均不发生
redo log 实现持久性
背景
Buffer Pool:在真正访问页面之前,需要将磁盘中的页面加载到内存的 Buffer Pool 中,之后才可以访问;
如果在内存中更新了,此时宕机了,在磁盘中没有更新数据,造成数据的丢失,违反了持久性
如果在事务提交之前,把数据更新到磁盘上,会存在两个问题:
- 刷新一个完整的页太浪费了,有时我们仅仅是改变了某一个页上的某个字节;
- 随机 I/O 刷新起来很慢,因为一个 SQL 语句可能会修改了多个页面,这些页面可能不相邻;
redo log
记录事务对数据库的修改操作,比如增加,删除,更新等;在事物提交时,只需要把 redo log
持久化到硬盘即可,而不用等待到将缓存 Buffer Pool
中的数据持久化到硬盘中。
一旦发生数据库宕机,可以根据 redo log 中的日志信息恢复数据
- redo log 日志占用的空间非常小;
- redo log 日志是顺序写入磁盘的;
redo log
也有自己的缓冲区 redo log buffer
redo log 刷盘时机
刷盘即把 redo log buffer
中的数据刷新到 磁盘中去
- MySQL 正常关闭时;
redo log buffer
空间不足时;- 事务提交时;
- 后台线程,以固定的频率把 buffer 中的日志刷新到磁盘;
- 做
checkpoint
时; - 将
Buffer Pool
中的脏页刷新到磁盘前;
undo log 实现原子性
背景
当事务执行到一半时,数据库发生宕机或者执行 rollback
指令结束当前事务,我们需要把数据库恢复到事务开启之前的样子;
这就造成了一个假象:这个事务看起来什么也没做,所以符合原子性的要求 [ 所有操作要么全部成功,要么全部失败 ]
undo log
在一个事务中,每当对一条记录进行改动时,都要留一手 —— 把回滚时所需要的东西记录下来
- 当插入一条语句 时,我们至少需要把该记录的 主键 记录下来,这样回滚时删除该主键对应的记录即可;
- 当删除一条语句时,我们需要把原来语句的内容记录下来,便于之后恢复;
- 当更新一条语句时,我们需要把旧值记录下来,便于回滚后恢复该值;
我们把这些为了回滚而记录的日志称为 撤销日志,执行该撤销日志,可以恢复到事务开启之前的状态;
MVCC 保证隔离性
定义
MVCC 通过生成一个 ReadView,然后通过 ReadView 找到符合条件的历史记录版本(历史记录版本由 undo log 来构建)。
事务ID
事务ID 本质上是一个数字
- 服务器会在内存中维护一个全局变量,每当需要为某个事务分配事务 ID 时,就会把变量的值当作事务 ID 分配给该事务,并且把该变量自增 1
- 每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间中页号为 5 的页面中一个名为 Max Trx ID 的属性中,这个属性占用 8 字节的存储空间;
- 当下次启动时,会将 Max Trx ID 属性加载到内存中,将该值加上 256 之后赋值给前面提到的全局变量
- 新开启的事务没有对任何记录进行任何改动,系统并不会给其分配一个唯一的事务 ID,它的默认事务 ID 为 0
基于 undo log 版本链
隐藏列
row_id
:隐藏的主键,当没有定义主键,且没有不允许为 NULL
的 UNIQUE
的字段,就会把该隐藏列当作为主键;
trx_id
:一个事务对某条聚簇索引记录进行改动时,都会把当前事务的事务 id 赋值给 trx_id 隐藏列;
roll_pointer
:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入 undo 日志中,这个隐藏列就相当于一个指针,可以通过它找到该记录修改前的信息;
版本链
每对记录进行一次改动,都会产生一个 undo 日志,每条 undo 日志都有一个 roll_pointer
属性,通过这个属性可以将这些 undo 日志串成一个链表。
版本链的头节点就是当前节点的最新值,我们之后利用这个记录的版本链来控制并发事务访问相同事务的行为,这种机制称为 多版本并发控制(Multi-Version-Concurrency Control,MVCC)
ReadView
ReadView 又称为一致性视图,现在的核心问题是:需要判断版本链中哪个版本是当前事务可见的
对于 Read Uncommited:因为事务修改的内容对其他事务可见,所以直接读取最新版本的 ReadView 即可
对于 Read Conmmited 和 Repeatable Read:都保证读到已经提交的事务修改过的记录,也就是说一个事务修改了但没有提交,其他事务是无法读取到该事务修改的值的;
m_ids
:当前系统中正在活跃的事务;
min_trx_id
:当前活跃事务中事务 ID 的最小值;
max_trx_id
:下一个将要赋值的事务 ID,不是 m_ids 的最大值;
creator_trx_id
:创建该 readView 的事务 ID;
判断规则
行记录中的事务 ID 为 trx_id
- 若
trx_id
等于creator_trx_id
,则表明可以访问当前记录,因为该记录是由当前事务创建的; - 若
trx_id
小于min_trx_id
,则表示当前事务可以访问该记录,表明生成该版本的事务在生成该readView
之前就已经提交了; - 若
trx_id
大于或等于max_trx_id
,则表明当前事务不能访问该记录,表明生成该版本的事务在生成该readView
之后才创建; - 若
trx_id
在m_ids
中,则表明当前事务不能访问该记录,表明生成该版本的事务在活跃中; - 若
trx_id
不在m_ids
中,则说明创建readView
时该事务已经提交,可以访问该记录;
Read Committed 的实现
Read Committed —— 每次 select 时重新生成一个 ReadView
现有 C1 C2 两个事务,id 分别为100,200,存在一条旧记录
insert into user values(1, 1,"马超");
开始时,C1 开启事务
begin;
C2 也开启事务
begin;
C1 插入更改2条数据
update user set name = "张飞" where number = 1;
update user set name = "刘备" where number = 1;
此时版本链为:
此时新开一个 事务C3,其 trx_id = 0,对其进行 select 查询,生成一个 readView
m_ids = 100,200
min_trx_id = 100
max_trx_id = 201
creator_id = 0
- 对于第一条记录,trx_id = 100,在 m_ids 中,不符合要求;
- 对于第二条记录,trx_id = 100,在 m_ids 中,不符合要求;
- 对于第三条记录,trx_id = 80,小于 min_trx_id ,符合判断规则,故作为结果返回;
此时 C1 提交事务
commit;
事务 C2 更新 number 为 1 的记录
update user set name = "曹操" where number = 1;
update user set name = "关羽" where number = 1;
此时版本链为:
C3 再次进行 select 查询,在 Read Committed 的情况下,重新生成一个 ReadView
m_ids = 200
min_trx_id = 200
max_trx_id = 201
creator_id = 0
- 对于第一条记录,trx_id = 200,在 m_ids 中,故不符合条件
- 对于第二条记录,trx_id = 200,在 m_ids 中,故不符合条件
- 对于第三条记录,trx_id = 100,小于 min_trx_id ,故选择该记录
可见,C3 在 ReadCommit 的隔离级别下,两次查询的结果不一致
Repeatable Read 的实现
Repeatable Read —— 创建事务时会生成一个 ReadView,每次 select 都会复用该 ReadView
现有 C1 C2 两个事务,id 分别为100,200,存在一条旧记录
insert into user values(1, 1,"马超");
开始时,C1 开启事务
begin;
C2 也开启事务
begin;
C1 插入更改2条数据
update user set name = "张飞" where number = 1;
update user set name = "刘备" where number = 1;
此时版本链为:
此时新开一个 事务C3,其 trx_id = 0,对其进行 select 查询,生成一个 readView
m_ids = 100,200
min_trx_id = 100
max_trx_id = 201
creator_id = 0
- 对于第一条记录,trx_id = 100,在 m_ids 中,不符合要求;
- 对于第二条记录,trx_id = 100,在 m_ids 中,不符合要求;
- 对于第三条记录,trx_id = 80,小于 min_trx_id ,符合判断规则,故作为结果返回;
此时 C1 提交事务
commit;
事务 C2 更新 number 为 1 的记录
update user set name = "曹操" where number = 1;
update user set name = "关羽" where number = 1;
此时版本链为:
此时 C3 再次进行 select 操作,因为是在 Repeatable Read 的隔离级别下,所以重用开启事务时生成的 readView
m_ids = 100,200
min_trx_id = 100
max_trx_id = 201
creator_id = 0
- 对于第一条记录,trx_id = 200,在 m_ids 中,不符合要求;
- 对于第二条记录,trx_id = 200,在 m_ids 中,不符合要求;
- 对于第三条记录,trx_id = 100,在 m_ids 中,不符合要求;
- 对于第四条记录,trx_id = 100,在 m_ids 中,不符合要求;
- 对于第五条记录,trx_id = 80,小于 min_trx_id ,符合判断规则,故作为结果返回;
可见,在有事务提交的情况,不会产生不可重复的情况,两次读到的数据是一致的;
Repeatable Read 有没有完全解决幻读?
在正常情况下
事务c1,事务ID 为 100 | 事务c2,事务ID 为200 |
---|---|
begin; | |
begin; | |
select * from user;结果:曹操 | |
insert into user(name) values("马超"); | |
select * from user;结果:曹操 |
可见,事务 C2 在插入新的记录 马超后,该事务id为200
此时 c1 的 readView 为
creator_trx = 100
m_ids = 100
trx_min_id = 100
trx_max_id = 101
在查询 马超的记录时,发现其 trx_id 大于 trx_max_id,故不能返回此记录,解决幻读问题;
一致性读
利用 MVCC 进行读取的操作称为 一致性读,也叫快照读, 读取的是 MVCC 中版本链中的数据
所有的 select 语句在 Read Committed,Repeatable Read 的隔离级别下都算是一致性读,上述的正常情况就是通过 MVCC 解决的;
一致性读取的过程是通过 MVCC 保证隔离性的
当前读
MySQL 中除了普通的 select 查询,其他的 insert、delete、update 语句都是当前读,查询最新版本的数据,然后再进行进一步的操作;
假如你要 update 一条数据,而另一个事务已经 delete 了这条数据并提交了事务,这样不是会产生冲突吗?所以 update 的时候一定要知道最新的数据;
select ... for update
也会执行锁定读,查询最新的数据
两种发生幻读的场景
第一个发生幻读的场景
c1事务 | c2 事务 |
---|---|
begin; | begin; |
select name from user where id = 5;结果为空 | |
insert into user(id, name) value(5,"马超") | |
commit; | |
update user set name = "关羽" where id = 5; | |
select name from user where id = 5;结果为关羽 |
- 第一次 c1 进行查询时,结果为空
- 当 c2 插入一条 id = 5 的记录,此时 c1 是看不到的,但 c1 却可以去更新这条数据;
- 更新完毕后,c1 再次进行查询,就可以看到这条数据了;
导致两次查询到的结果的数量不一致
第二个发生幻读的场景
c1事务 | c2 事务 |
---|---|
begin; | begin; |
select name from user where id >= 1;结果为 曹操 关羽 马超 周瑜 刘备 | |
insert into user(id, name) value(7,"张飞") | |
commit; | |
select name from user where id >= 1 for update;结果为 曹操 关羽 马超 周瑜 刘备 张飞 |
- 第一次 c1 进行查询(快照读)时,结果为 曹操 关羽 马超 周瑜 刘备
- c2 插入一条新数据并提交事务
- 第二次 c1 进行查询(当前读)时,结果为 曹操 关羽 马超 周瑜 刘备 张飞
导致两次查询到的结果的数量不一致
转载自:https://juejin.cn/post/7223189435519926331