mysql重学(三)事务--隔离性
写在开头
本文是记录对林晓斌老师的《mysql45讲》的阅读,并整理成自己能更通俗理解的阅读笔记
前文
1.事务的特性
用4个单词概括就是ACID:
- 原子性atomicty。要么都完成要么都失败,从前文的更新语句执行流程我们知道原子性是通过innodb的redolog和binlog的二阶段提交实现的
- 一致性Consistency。如果事务是并发的也必须如同串行事务一样执行
- 隔离性Isolation。如果有两个事务,运行在相同的时间内,执行相同的功能,事务的隔离性将确保每一事务在系统中认为只有该事务在使用系统
- 持久性Durability。在事务完成以后,该事务对数据库所作的更改便持久的保存在数据库之中,并不会被回滚
本文主要研究隔离性,其他三个都是靠日志实现的
2隔离等级
mysql执行以下命令可以查看当前环境隔离等级
SELECT @@tx_isolation;
show variables like 'transaction_isolation';
2.1读未提交(read-uncommitted)
set global transaction_isolation=read-commit;
这里我使用的是林晓斌老师MYSQL实战45讲里的实验流程,表结构体为
CREATE TABLE IF NOT EXISTS `t` (
id int primary key auto_increment,
c int default 0
)
INSERT INTO `t` (`c`) values (100);
image.png
go实验代码
dns := "user:psd@tcp(ip:3306)/goitem"
db, err := gorm.Open(mysql.Open(dns), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
db.Save(&T{ID: 1, C: 100})
wg.Add(2)
go func() {
defer wg.Done()
tx := db.Begin()
var res T
//启动事务,查询得到值1
tx.First(&res)
t.Log("事务A,值1:", res.C)
time.Sleep(time.Second)
tx.First(&res)
t.Log("事务A,V1:", res.C)
time.Sleep(time.Second * 2)
tx.First(&res)
t.Log("事务A,V2:", res.C)
tx.Commit()
db.First(&res)
t.Log("事务A,V3:", res.C)
}()
go func() {
defer wg.Done()
tx := db.Begin()
var res T
time.Sleep(time.Second)
tx.First(&res)
t.Log("事务B,值1:", res.C)
res.C = 200
tx.Save(&res)
time.Sleep(time.Second * 2)
tx.Commit()
}()
wg.Wait()
实验发现事务B中所做的改动(不论是否已经提交)竟然能完全被事务A所看到。
image.png
这就引发了一系列的问题:
- 脏读。读到了其他事务未提交的数据,如上V1=200
- 不可重复读。读到了其他事务修改后并提交的数据,如上V2=200
- 幻读。事务A前后两次读取同一范围的数据,因为事务B的原因导致A读取到了前一次未读取的数据。幻读和不可重复读类似,只是指的是范围读取,上面例子是读取单条未体现,但是读未提交也存在幻读问题,本次暂时不讨论幻读的问题。
综上,读未提交是隔离性最差的等级
2.2读已提交
set global transaction_isolation='read-committed';
还是执行上面代码(注意go_test会缓存上次结果,记得随便修改下测试用例的数据就行)
image.png
read committed,顾名思义,即读到了其他事务已提交的数据V2=200 (这里我们就想到了GO关于锁学习的二次检查double-check机制的重要性),脏读是解决了,但是不可重复读还在
2.3可重复读
set global transaction_isolation='repeatable-read'
可重复读要求事务执行期间看到数据前后一致
image.png
只有v3=200
脏读和不可重复读问题都不存在
2.3串行化
set global transaction_isolation='serializable'
它其实是更像go的RWMUTX读写锁,当读写锁冲突时,后访问的事务必须等前一个事务执行完成才能继续执行,也就是1->2的时候被锁住,直到A提交后B才能继续执行,这里需要修改事务A协程的部分代码,在查询V3前需要加上B的休眠时间
time.Sleep(time.Second*5)
db.First(&res)
结果就不用说了 v1=v2=100,v3=200
综上,串行化是隔离性最强的,也是性能最差的。
3.隔离的原理
事务的隔离性是依靠视图来实现的。关于视图,MySQL有两个概念:
- view,即查询语句过程中定义的虚拟表
- 就是用于上面的,相当于redis和虚拟机的快照,给数据备份一份,因此也叫数据版本
3.1视图
当一个记录k=1被多个事务连续更新时,视图(这里指数据版本)的改变如下:
- 首先,事务是innodb引擎独有的,每个事务有唯一的ID,trx_id,按顺序严格递增。
- 每次事务更新都会记录回滚日志,生成一个新的数据版本
- 旧的数据版本要保留,并在新的版本中能拿到()
通过链表的方式
总结就是更新数据的事务才会生成数据版本,版本中包含本次事务数据的值、事务trx_id、还有一个指向上一个数据版本的引用。同时表中的一行记录可能有多个版本,每个版本都有自己的事务id
image.png
图中的三个虚拟箭头U3->U2->u1 就是undolog
我们在进行数据更新操作的时候,不仅会记录redo log,还会记录undo log,如果因为某些原因导致事务回滚,那么这个时候MySQL就要执行回滚(rollback)操作,利用undo log将数据恢复到事务开始之前的状态。
3.2如何实现隔离级别-可重复读
隔离级别的实现实际上就是规定如何看数据版本。
思路分析:
- 事务有唯一的trx_id,且严格递增。
- 数据的数据版本包含事务的trx_id,更新数据会在此数据版本上生成链表结构
- 可重读的特点是看不到其他事务的更新,也就是只认trx_id小于本次事务id的数据版本(数据版本可以往回找),当然自己本次事务生成的数据版本也是要认的
也就是说read-reapetable是在事务开启时生成一致性视图(下文会提到),之后的事务的操作都是共用这一个视图。那举一反三,读未提交就是啥版本的数据都认(不需要生成一致性视图),读已提交就是只认链表末端的数据版本(每一个语句执行都会创建一致性视图),是不是这个道理?
实际上代码层面的实现:
- 事务启动时构造一个数组,数组保存的是当前“活跃”的事务,即启动了但未提交
- 数组中事务TRX_ID的最小值标记为低水位--最先启动未提交的事务,那么小于低水位的就是已经提交的事务
- 最大事务TRX_ID+1为高水位,+1的高水位就意味着这以上都是还没开始的事务
-
这个数组就代表了当前事务的一致性视图read-view(下图的黄色区域就是该数组)
image.png - 下面情况都是基于可重复读讨论的,按照它的定义,如果一个数据版本的trx_id:
- 在绿色区域一定是可见的(低水位代表已启动未提交的最小值,那小于低水位还未提交的事务存在吗?不存在,否则定义就矛盾了,因此可以反证得到小于低水位一定是已提交的事务或者是自己当前事务生成的);
- 红色区域一定是不可见的(高水位的事务对于当前事务都是还没开始的,即在该事务之后启动的);
- 落在黄色区域,但是trx_id在数组中,那还是看不到(数组保存的是启动未提交的事务);不在数组中就看得见(该事务已经提交,因此不保存在数组中)
3.3一致性视图的高水位为啥不一定是事务本身的id
- 一致性视图(上面的数组)是在执行第一个快照“读语句”时创建的
- 事务开始时就会有trx_id,判断事务开启条件的一种是begin并且执行第一个操作。所以当begin后第一句只执行非select语句会生成id(举例本次id=88),但不会产生一致性视图,因此在后面执行select语句时,其他事务可能已经创建,即存在着比当前事务还大且未提交的事务ID[72,88,90,91]
begin
update A -----产生事务id88
update B ---- .....中间过程有许多事务提交和产生
select C ---产生一致性视图[72,88,90,91]
commit
- 像上面的更新数据update A都是先读后写,而这个读是当前都--只能读当前数据版本的数据值,否则本次更新会被丢失
3.4当前读
我们使用该命令开启事务(立马生成read-view),这个语句只在可重复读级别以上才有意义
start transaction with consistent snapshot
image.png
- 一条记录是(id=1,k=1),事务ID=90;事务B的trx_id = 101,read-view=[99,100,101];事务C的id=102,read-view=[99,100,101,102];事务C在事务B之后开启执行K+1操作
- 该记录的事务ID=102,值k=2,之后提交
- 事务B执行更新操作k+1,因为是当前读,id=102版本的数据虽然对于B不可见但还剩读到了,所以K=2+1=3
当前读是基于锁实现的(当前事务加锁读,若遇到其他事务持有该数据写锁,则等待,最终得到其他事务修改后提交的最新值),除了更新语句select加锁也是当前读
select * from T lock in share mode;
image.png
也就是说如果事务C不提交,事务B的k+1会被卡住,提交后,记录的版本变成了id=101,get k =3,因此出现了可重复读实现读已提交的效果。
A的get K = 1,因为不是当前读,只能读小于100的90版本的数据版本(前提是使用start tansaction with xxxxxxx语句开启事务,否则 k =2,因为read-view也变了)
3.5小结
隔离级别是依赖数据版本和一致性视图实现的,每个数据版本依赖事务ID:
- 对于可重复读,查询只承认在事务开始时(begin启动就是第一次读语句)创建的一致性视图
- 对于读已提交,查询只承认每句查询时创建的一致性视图
当前读依赖锁实现每次都能读取到最新数据版本的数据,更新语句会触发当前读