并发读写数据一致性保证(二)-MySQL

6,541 阅读10分钟

业务开发过程,其实就是用户业务数据的处理过程,因而开发的核心任务就是维护数据一致不出错。现实场景中,多个用户会并发读写同一份数据(如秒杀),不加控制会翻车、加了控制则降低并发度,影响性能和用户体验。

如何优雅的进行并发数据控制呢?本质上需要解决两个问题:

  • 读-写冲突
  • 写-写冲突

让我们看下,最常见的MySQL InnoDB存储引擎是如何协调上述两个问题的?

并行事务

在本文中,假定所有读写操作,不管是一次操作,还是一串操作,都是在事务中进行的,这就涉及事务的ACID特性(原子性、一致性、隔离性、持久性)

ACID 解释
原子性 序列操作要么全都完成、要么全都失败
一致性 一个事务只有提交后,其操作数据才可被其他事务看见
隔离性 适度破坏一致性、使得事务可以在不同程度上看到其他并行事务的数据,提升性能
持久性 事务提交后持久到磁盘不会丢失

在并发冲突问题下,重点关注并行事务下隔离性与一致性

要数据完全一致,就意味着所有并行事务都得串行执行,对于高并发的数据库应用来说显然无法接受,所以现代数据库应用为了提高性能都会适度破坏一致性,破坏程度的不同对应了数据库标准的不同隔离级别

事务隔离级别

Serializable 序列化

SQL规范

顾名思义,将并行事务的执行顺序串行化

事务A 事务B 事务C
begin begin begin
select/insert/update/delete
commit
select/insert/update/delete
commit
select/insert/update/delete
commit
InnoDB实现

读写都加表级别的排它锁

实际效果

并行事务串行执行,因而不存在冲突,每次操作的数据一定是最新的数据,虽然数据完全一致,但是性能差,毫无并发度可言,一般不用,不过多阐述, 只要记住select也是会隐式加排它锁的就行

Repeatable Read 可重复读(默认)

SQL规范

事务多次读取同样条件下的数据依然可以读到同样的数据,不管其他事务如何操作同样条件的数据

事务A 事务B
begin begin
select a from table where id =1 => a =1 update table set a = 2 where id = 1
commit
select a from table where id =1 => a =1
commit
InnoDB实现

在该级别下,MVCC(多版本并发控制)处理读-写冲突、 2PL(两阶段锁)处理写-写冲突

  • MVCC
SELECT * FROM table WHERE id = 1

上述这种普通的Select语句在Repeatable Read级别下执行的是快照读。MVCC机制给每条数据都额外增加两个字段,一个用于记录当前事务id(新事务自增),另一个用于指向undo log版本链中的上一个版本(事务每次更新记录,mysql都会写一条undo log,方便事务回滚),同时给事务开启一个ReadView,展示当前还在活动的事务id

假设由Insert事务提交产生一条原始记录a,事务101连续更新两次a值,但未提交,这时事务102要查询a值,在事务开始时生成ReadView,记录当前活动事务[101],查找a=4的记录发现事务id为101,位于ReadView中,于是顺着版本链,找到第一条事务id不在ReadView中的a=1,返回

这时即便事务101提交了,由于事务102的ReadView依然是事务开始那一刻的[101],因而后续读到的还是a=1

快照读的存在,使得读取数据不需要加锁,提高了读操作的性能,但这也意味着数据有可能是历史版本的,是弱一致的,如果后续存在Update操作,并且基于本次读出来的数据,那么update写入的数据实际上是错误的(也称为第二类丢失更新,其他事务update的记录也会被这次错误的update覆盖)

对于这类场景,我们可以通过在业务上使用乐观锁,在每条记录增加一个版本号字段,每次更新前比对,也可以手动加锁(悲观锁),将快照读升级为当前读

SELECT * FROM table WHERE id = 1 lock in share mode	//加读锁
SELECT * FROM table WHERE id = 1 for update	//加写锁
  • 2PL

这种操作数据必须读取当前版本数据的,叫做当前读,包括显式加锁的SELECT 和隐式加写锁的INSERT、UPDATE、DELETE。

在2PL的加锁阶段,只允许加锁操作,已有读锁可以加读锁但不能再加写锁,已有写锁则不能再加任何锁,无法获取锁的事务只能阻塞等待,直到持锁事务在解锁阶段(事务提交)释放锁后才能继续竞争锁并执行

与Serializable的表级锁相比,Repeatable Read的锁粒度更细,是行级锁,只会锁住所需要的数据行,支持更大的并发度,不过若查询时所需数据太多或者没有走索引,还是会升级成表级锁(行锁实际上加在索引上,没有索引则锁全表)

实际效果

数据库是读多写少的高并发应用,MVCC的机制牺牲了数据的强一致性,使得大部分只读事务不用阻塞等待,而且由于行级锁的粒度更小,只有对相同条件的数据加锁时才会冲突,极大提高了并发,不过需要注意第二类丢失更新的存在

数据库的操作除了SELECT、UPDATE、DELETE外,还有INSERT,SQL规范中,该级别的INSERT会引起幻读的可能性:由于是行级锁,事务A加行锁update某个范围的数据后,事务B在这个范围内插入一条记录,这时事务A发现多了一条数据,而且没有被自己update

实际上MySQL的实现中,利用了GAP锁(间隙锁)在一定索引区间内加锁,锁住甚至不需要的数据,防止其他事务往其中插入数据,这样行锁+GAP锁组成的Next-Key锁就同时防止别的事务修改、删除和插入,解决了幻读的问题

深入理解

MVCC支撑着Repeatable Read的实现,但是偶然看到MySQL · 源码分析 · InnoDB Repeatable Read隔离级别之大不同发现:

当事务一开始读不到的记录被另外的事务插入时,该事务在update同样条件的记录后,再次读却能读到

在RR级别却出现了不可重读的现象

事务A 事务B
begin begin
select a from table where id = 1 => null
insert into table(id,a) values(1,1)
commit
select a from table where id = 1 => null
update table set a = 2 where id = 1
select a from table where id = 1 => a = 2
commit

原因在于 update是当前读,可以读到最新记录,在更新后,将事务A的事务id赋值到记录上,因而这条记录对于事务A来说是第一条不位于ReadView中的数据,因而可以读到

Read Committed 读已提交

SQL规范

事务可以读到其他事务已经提交的数据

事务A 事务B
begin begin
select a from table where id = 1 => a=1
update table set a = 2 where id = 1
select a from table where id = 1 => a=1 commit
select a from table where id = 1 => a=2
commit
InnoDB实现

与RR级别类似,RC级别也是采用MVCC处理读-写冲突、 2PL处理写-写冲突

  • MVCC

该级别下,普通的Select语句,仍然是快照读,与RR最大的区别在于:RC每次select都会生成ReadView,例如事务103第一次select生成的ReadView为[101,102],当事务101提交后,事务103再次select生成的新的ReadView为[102]了,可以读到101提交后的数据

因此在一次长事务中的多次读取,只要其他事务有更新提交,就会读到,所以是不可重读的

同样,快照读也可能是历史数据,基于历史数据作出的变更再update就会存在丢失更新的现象,解决方案同RR级别的乐观锁与悲观锁

  • 2PL

Update、Insert、Delete操作同样会加写锁,区别是该级别下只有行锁,并没有Gap锁和Next-Key锁,所以操作不当就会引发幻读

实际效果

与MySQL官方默认的RR级别相比,RC级别一致性程度更低,存在很多诸如不可重读、幻读、丢失更新等问题,乍一看没那么受待见,但据说这是阿里内部使用的数据库隔离级别

可以看到,RC级别的加锁很少,减少并行事务因锁冲突阻塞等待的概率,也减少了死锁出现的可能(但还是会有),因而也支持更大的并发

只是需要多花点心思控制下一致性而已

Read Uncommitted 读未提交

SQL规范

事务可以读到其他事务未提交的数据

事务A 事务B
begin begin
select a from table where id = 1 => a=1
update table set a = 2 where id = 1
select a from table where id = 1 => a=2
commit commit
InnoDB实现

读写都不加锁,并不存在冲突,直接读最新的数据,也就不需要MVCC处理读写冲突了

实际效果

读读,读写,写写都可以并行,没有一致性保证,性能也不见得提升太多,假如事务A在读到事务B更新的数据后,事务B回滚,事务A就相当于读到了一条不存在的数据,也就是所谓的脏读,这样实际情况中业务容易出问题,一般也不用,不过多阐述

小结

数据库是高并发应用,场景各异,不可能一套标准走天下,因而提供了四种隔离级别,供开发者权衡,但主要还是RR和RC两种

要保证写的一致性,避免不了加锁,不管是Insert、Update、Delete自带的锁还是select …… for update/in share mode这种在业务控制层面的锁,要降低锁的影响,就需要控制好锁的覆盖范围

关于读的一致性,如果用二八原则来看待的话,可以简单地认为80%的操作都是只读操作,在这种场景下,即使第一次看到的是历史数据,刷新几下,总能看到最新数据的,如果还需要加锁来保证强一致,反而更加影响性能,因此包括MySQL在内的主流关系型数据库,都采用无锁的典型方案MVCC来实现读的弱一致

推荐阅读

Innodb中的事务隔离级别和锁的关系

事务的隔离级别以及Mysql事务的使用

浅入深出』MySQL 中事务的实现

MySQL加锁分析

MySQL事务隔离级别和MVCC

MySQL实战笔记--深入理解RR隔离级别