相关背景
事务并发面临的问题
目前的数据库事务的并发面临着4个问题:丢失修改、读脏数据、不可重复读、幻影读。
现在假设有两个并发事务T1和T2
- 丢失修改:T1修改了某一条记录,然后T2也修改了这条记录,T1对数据的修改丢失。
- 读脏数据:T1修改了某一条记录但未提交,此时T2读取到了T1未提交的数据。
- 不可重复读:T2读取某一条记录,T1修改这条记录,T2再次读取这一条记录,发现在同一事务下数据不一致。
- 幻影读:T2查找某一条记录,发现没有,T1添加了T2要找的记录,T2再次查找,发现见鬼了,无缘无故多出了这条记录。
幻影读类似于不可重复读,但不可重复读强调修改,幻影读强调插入/删除。
事务隔离级别
为了解决以上问题,SQL标准定义了以下事务隔离级别:
- 未提交读:T1未提交时T2就可以查看到T1修改的数据。
- 已提交读:T1提交后T2才能看到T1修改的数据。
- 可重复读:T1提交后T2也不能查看到T1修改的数据。
- 串行化:T1、T2必须严格按照先后顺序执行,即T1的所有操作在T2所有操作之前完成。
它们所能即解决的问题如下所示:
隔离级别 | 丢失修改 | 读脏数据 | 不可重复读 | 幻影读 |
---|---|---|---|---|
未提交读 | √ | × | × | × |
已提交读 | √ | √ | × | × |
可重复读 | √ | √ | √ | × |
串行化 | √ | √ | √ | √ |
未提交读也已经解决了丢失修改的问题,如果事务T1要修改某条数据,那么T2若要修改这一条数据,则会被阻塞。阻塞方法涉及到读写锁。
在MySQL的InnoDB引擎中,默认的事务隔离级别是可重复读,但它也解决了幻影读的问题。
实验
在MySQL5.7版本,使用默认InnoDB引擎的情况下,可以分别通过以下指令来查看、修改当前会话事务隔离级别:
查看事务隔离级别:
1 | select @@transaction_isolation; |
修改事务隔离级别:
1 | set session transaction isolation level __________; |
横线上要填的是以上事务隔离级别的英文,分别为READ UNCOMMITTED
、READ COMMITTED
、REPEATABLE READ
、SERIALIZABLE
,不区分大小写。
可以通过以下语句分别开启、回滚、提交一个事务:
1 | start transaction; |
接下来,暂不讨论串行化隔离级别,把事务隔离级别设置为以下,观察两个并发事务的表现。
未提交读
前面说到,未提交读只解决了丢失修改的问题,所以来试试
在T1和T2同时修改数据时,这个数据被锁住了,T1先修改,T2在T1提交之前就无法修改。此时T2发生阻塞,等待T1提交。
接下来测试以下读脏数据问题
T2提交修改的数据之前,T1就读取到了T2修改的数据,当T1发现异常需要回滚,那么T2此时拥有的就是一个错误的数据。
已提交读
T1先读取数据,T2修改,T1再读,发现数据没有被改变。但T2提交后,T1再去读,数据还是变了,存在不可重复读问题。
可重复读
这里的测试流程和上面相似,唯一的变化就是解决了不可重复读问题,这也是MySQL5.7的默认事务隔离级别。
同样地,它也避免了幻影读的问题
一些思考
还记得在学习关于事务的并发锁时,有读写锁这样一个概念。读锁在读取数据时给数据加上,写锁在对数据进行修改时加上。经过以上的实验,T2修改一个数据之后,确实会给数据加上一个写锁,T1要写只能等T2提交,这就解决了上述的丢失修改问题。
引起我思考的是以下这张表,当一个数据被加读锁或是写锁后,是否还能被加锁的情况:
T1已加的锁/T2可加的锁 | 读锁 | 写锁 |
---|---|---|
读锁 | √ | × |
写锁 | × | × |
读锁又叫共享锁,因为读锁可以同时被多个并发事务加上;而写锁是独占锁,只能被一个事务独占。
显然,以上实验已经验证了写锁是互斥的,但是通过以上实验会发现,写并没有阻塞读,读也并没有阻塞写。
这是什么原因呢?我查阅了相关资料,了解到了MVCC这样一种技术,它被广泛应用于已提交读和可重复读的事务隔离级别中。
MVCC
MVCC的全称是Multi-Version Concurrency Control(多版本并发控制),从表面上看上去,它主要在做一件事情:快照。所谓快照,意思是开启一个事务之前,先把需要修改或删除的数据备份一遍,如果要修改数据那就看这份备份就好了。
这样的好处就是:在读数据的时候不需要给数据加上读锁,看看上面对应的表格,去除掉读锁的行列,会发生阻塞的情况只有写-写阻塞,而读-读操作、读-写操作、写-读操作都不会发生阻塞,这提高了并发度
那么它是怎么实现的呢?
事实上,它为每一条记录加了两个隐藏字段:创建版本号和删除版本号。版本号指的是对记录做出修改的事务版本号。
在进行增删查改操作时:记录的创建版本号和删除版本号会发生如下变化:
- insert:将新记录的创建版本号修改为当前事务的版本号,删除版本号置空。
- delete:将对应记录的删除版本号置为当前事务版本号。
- update:将对应记录的删除版本号置为当前事务版本号,并新增一条记录,将新记录的创建版本号修改为当前事务版本号,删除版本号置空。相当于先删除再创建。
查询操作是读操作,因此它不会更改版本号,但是针对不同的事务隔离级别会做出不同的反应,分类讨论一下:
- 已提交读级别:查询这条记录中具有最新创建版本号的第一条记录。
- 可重复读级别:查询这条记录中创建版本号不大于当前事务版本号的第一条记录。
这里有出现了一个问题:T1事务对版本号进行的修改,不会影响并发事务T2对数据的读取吗?
为了解决这个问题,引入了undo-log这样的技术。undo-log存储了这条记录未提交的状态不同的数据版本,也可以用于回滚。它是一个线性表,采用链表实现,这里不多赘述。
所以,并发事务在对事务进行读取时,读取的应该是已提交记录,并且满足上述条件的第一条记录!
同样,我们的实验也证明过如果并发事务同时对同一条数据进行写操作,会发现版本号的冲突而产生阻塞,T1对数据的修改要等到并发事务T2完成并释放锁以后才能操作。
什么时候加读锁?
如果想要强制加上读锁,可以对select操作加上for update的后缀
1 | select ... for update; |
MVCC的设计哲学
在我看来,MVCC的设计采用了以下思想:
- 空间换时间:新增两个字段来维护这个设计,提高并发度。
- 乐观锁:假设没有冲突,所以先不加锁,出了冲突再说。(它们的关系依然比较暧昧)
- Copy On Write:快照的实现并不是直接复制,而是说在进行写操作时才在undo-log中创建一个副本,减少了不必要的数据拷贝时间。