MySQL 原理(三):事务的隔离性

说到事务这个词,相信大家应该都不陌生,ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)几乎都能脱口而出。但是说到事务的实现以及其背后所使用的技术,可能就有些茫然。

 今天我们就从事务的隔离性出发,聊一聊 MySQL 的事务原理。

一致性视图(快照)

我们知道,事务一共有 4 个隔离级别,分别是读未提交、读提交、可重复读和串行化。它们的含义如下:

  1. 读未提交(read uncommitted):一个事务还没提交时,它做的变更就能被别的事务看到。
  2. 读提交(read committed):一个事务提交之后,它做的变更才会被其他事务看到。
  3. 可重复读(repeatable read):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  4. 串行化(serializable ):对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

通过上面的定义不难发现,隔离级别描述的就是事务所做变更的可见性。在实现上,每开启一个事务,MySQL 就会创建一个一致性视图(快照),事务内访问到的数据以视图的逻辑结果为准。

  1. “读未提交” 隔离级别下,直接返回记录上的最新值,没有视图概念。
  2. “读提交” 隔离级别下,视图是在每个查询 SQL 语句开始执行时创建的。
  3. “可重复读” 隔离级别下,视图是在事务启动时创建的,整个事务执行期间都用这个视图。
  4. “串行化” 隔离级别下,直接用加锁的方式来避免并行访问,不创建视图。

视图与一致性视图

需要注意的是,在 MySQL 里有两个“视图”的概念。

一个是 view (视图),它是用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。

另一个是 InnoDB 在实现 MVCC(Multi-Version Concurrency Control) 时用到的 consistent read view(一致性读视图),用于支持 “读提交” 和 “可重复读” 隔离级别的实现。它没有物理结构,只用来定义事务执行期间所做变更的可见性。

一般情况下,我们用快照来形容一致性视图。对于一个快照而言,它能够读到数据如下:

  1. 当前事务内的变更,可以读到。
  2. 其他未提交事务内的变更,不能读到。
  3. 快照创建其他事务内提交的变更,不能读到。
  4. 快照创建前其他事务内提交的变更,可以读到。

RR 隔离级别下快照的创建时机

通过上文我们知道,RC 隔离级别下,快照是在每个查询 SQL 语句执行时创建的;RR 隔离级别下,则是在事务启动时创建的。也就是说 RR 隔离级别下,在执行 begin/start transaction 命令后就会立刻创建快照。

然而事实却和我们想象的有些出入。RR 隔离级别下,执行 begin/start transaction 命令并不会立刻创建快照,而是在执行到它们之后的第一个快照读语句,快照才会真正创建。如果想在事务启动时就创建快照,可以使用 start transaction with consistent snapshot 命令。

简而言之,在 RR 隔离级别下:

  • begin/start transaction:快照在执行第一个快照读语句时创建。
  • start transaction with consistent snapshot:快照在执行该指令时创建。

快照读、当前读

通过快照,不同事务可以读到自己该看到的记录(快照读)。快照读可以并发操作,这并不会影响记录的正确性,但是不同的事务对相同记录的更新操作却不能并发操作。

如果不同事务在同一时间操作相同的记录,那么记录的更新就会相互覆盖,乱了套了。此时,需要引入锁机制来保证同一时间同一记录只能有一个事务修改(当前读)。

在 MVCC 并发控制中,读操作可以分成两类:快照读与当前读。

  • 快照读(snapshot read):读取的是记录的可见版本 (可能是历史版本),不用加锁。
  • 当前读(current read):读取的是记录的最新版本。当前读返回的记录都会加上锁,从而保证其他事务不能并发修改这些记录。 

在 InnoDB 实现的 MVCC 中,快照读和当前读分别包含以下操作:

  • 快照读:简单的 select 操作,不用加锁。
    • select * from table where ?; 
  • 当前读:特殊的读操作,以及插入/更新/删除操作,需要加锁。
    • select * from table where ? lock in share mode; 
    • select * from table where ? for update;
    • insert into table values (…);
    • update table set ? where ?;
    • delete from table where ?;

读锁(共享锁)与写锁(排他锁)

读锁又称共享锁(shared lock),因此也叫S锁。共享锁指多个事务对于同一记录可以共享一把锁。多个事务都能访问到记录,但是只能读不能修改。

写锁也叫排它锁(exclusive lock),简称X锁。排他锁意味着不能与其他锁并存,若一个事务获取了某一记录的排他锁,其他事务就不能再获取该记录的其他锁(包括共享锁和排他锁)。获取排他锁的事务可以读取和修改记录。

关于读写锁,上文有两处提及:

  1. 串行化隔离级别下,对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。
  2. 当前读操作中的 select ... lock in share mode,该操作会为返回的记录加上“读锁”。

除此之外,其他的当前读操作都会自动给涉及到的记录加上“排他锁”。

我们知道加过排他锁的记录,无法通过当前读操作查询或修改,但可以直接通过 select ... from ... 的方式查询数据,因为普通查询没有任何锁机制。

幻读与间隙锁

凡事都是有利有弊,事务的隔离也一样。下面是不同隔离级别带来的副作用:

事务隔离级别脏读不可重复读幻读
读未提交(read uncommitted)
读提交(read committed)
可重复读(repeatable read)
串行化(serializable)

下面对三种副作用做一个简单的解释:

  • 脏读:事务A读取了事务B未提交的数据,但是事务B回滚了,导致事务A读取了脏数据。
  • 不可重复读:事务A多次读取某记录,在两次读取之间,事务B修改/删除该记录并提交,导致事务A前后两次读取的数据不一致。
  • 幻读:幻读是比较难理解的一种,这里举例两种场景。
    • 事务A多次读取某批记录的数量,在两次读取之间,事务B在这批记录中新增/删除记录并提交,导致事务A前后两次读取的记录数量不一致。
    • 事务A查询某记录不存在(使用唯一索引),然后插入该记录,在读取之后写入之前,事务B插入该记录并提交,导致事务A由于索引冲突异常回滚。

从上表可以看到,可重复读和串行化之间只差了一个幻读,所以如果能解决可重复读的幻读问题,那么就可以放心的使用可重复读而不必使用性能消耗严重的串行化。

为此 InnoDB 通过间隙锁解决 RR 隔离级别下的幻读问题,也就是说使用 InnoDB 引擎在 RR 隔离级别下不会出现幻读问题

间隙锁(Gap Lock)

InnoDB 支持三种行锁定方式:行锁、间隙锁和 Next-Key 锁。

  1. 行锁(Record Lock):锁直接加在索引记录上面。
  2. 间隙锁(Gap Lock):锁定索引记录间隙,确保索引记录的间隙不变。间隙锁是针对事务隔离级别为可重复读或以上级别的。
  3. 临键锁(Next-Key Lock):是行锁和间隙锁的组合。

在 RR 隔离级别下,InnoDB 以 Next-Key Lock 的方式对记录行进行加锁。Next-Key Lock是行锁和间隙锁的组合,当InnoDB扫描索引记录的时候,首先会对索引记录加上行锁(Record Lock),再对索引记录两边的间隙加上间隙锁(Gap Lock)。加上间隙锁之后,其他事务就不能在这个间隙修改/插入/删除记录。

RR 隔离级别下,加锁有以下规则:

  • 快照读不加锁,当前读才会加锁。若当前读操作没有使用索引,会给全表加排它锁。
  • 加锁的基本单位是(Next-Key Lock),间隙锁加锁遵循前开后闭原则。
  • 索引上的等值查询 —— 先给索引记录加上行锁,然后在索引记录两边加上间隙锁。若给唯一索引加锁并且记录存在,Next-Key Lock 会升级为行锁,取消间隙锁。
  • 索引上的等值查询 —— 向右遍历时最后一个值不满足查询需求时,间隙锁后闭变为后开。
  • 范围查询会访问到不满足条件的第一个值为止。

由于当前读操作不使用索引会给全表加排它锁,这非常不友好。针对这种情况,在适当的时候可以使用 limit 关键字来减小间隙的范围。

InnoDB 将间隙锁作为一种可配置选项,可以通过下面的指令,查看间隙锁是否启用:

show variables like 'innodb_locks_unsafe_for_binlog';

查询结果:

innodb_locks_unsafe_for_binlog:默认值为OFF,即启用间隙锁。

两阶段锁(2PL)

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这就是两阶段锁协议(two-phase locking)。

因此如果我们事务中需要锁多个行,就要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

死锁和死锁检测

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。

InnoDB 的两阶段锁也会引发死锁的问题,以行锁为例:

这时候,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略:

  • 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout(默认值 50s)来设置。
  • 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行((内部代码的处理逻辑之一是比较 undo log 的数量,回滚 undo log 数量少的事务))。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

正常情况下我们一般采用第二种策略:主动死锁检测,而且 innodb_deadlock_detect 的默认值本身就是 on。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的。

例如,同时出现很多事务要更新到同一行。每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。

针对死锁检测要耗费大量的 CPU 资源,有两种思路来解决。

一种思路是你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。但是这种操作本身带有一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的。而关掉死锁检测意味着可能会出现大量的超时,这是业务有损的。

另一个思路是控制并发度。如果并发能够控制住,比如同一行同时最多只有 10 个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。一个直接的想法就是,在客户端做并发控制。但是这个方法不太可行,因为客户端会很多。假如一个应用有 600 个客户端,这样即使每个客户端控制到只有 5 个并发线程,汇总到数据库服务端以后,峰值并发数也可能要达到 3000。

因此,这个并发控制要做在数据库服务端。如果有中间件,可以考虑在中间件实现;如果团队有能修改 MySQL 源码的人,也可以做在 MySQL 里面。基本思路就是,对于相同行的更新,在进入引擎之前排队。这样在 InnoDB 内部就不会有大量的死锁检测工作了。

死锁检测注意点:

1、快照读不会加锁,不需要做死锁检测。

2、并不是每次死锁检测都都要扫所有事务。比如某个时刻,事务等待状态如下:

  • B 在等 A,D 在等 C;
  • 现在来了一个 E,InnoDB 发现 E 需要等 D,那么 E 就判断跟 D、C 是否会形成死锁,这个检测不用管 B 和 A。

修改事务隔离级别(建议使用 session 级)

MySQL 的四种隔离级别参数分别为 'READ-UNCOMMITTED','READ-COMMITTED','REPEATABLE-READ' 和 'SERIALIZABLE'。

查询隔离级别指令:

  • 当前 session:SELECT @@session.tx_isolation;
  • 全局:SELECT @@global.tx_isolation;          

修改隔离级别指令:

  • 当前 session:set @@session.tx_isolation = 'REPEATABLE-READ';
  • 全局:set @@global.tx_isolation = 'REPEATABLE-READ';

测试 MySQL 隔离性时,建议在 session 级别的修改隔离级别进行测试。

参考:

数据库隔离级别剖析 | 伴鱼技术团队

MYSQL(04)-间隙锁详解

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值