【MySQL进阶】事务和锁

1. 隔离性实现原理

1.1 事务的隔离性

隔离性:MySQL是一个多线程的服务器程序,可以同时跟多个客户端程序建立连接、处理请求,此时不同客户端执行的DML语句被包含在不同的事务当中,就有可能对同一张表的同一个数据行进行修改,此时为了避免多个事务并发执行相互影响,因此需要隔离不同事务,这种性质就是隔离性。

1.2 事务的隔离级别

隔离级别:我们已经知道事务之间存在隔离性,但是如何实现事务之间的隔离,隔离到什么程度?如何保证数据安全的同时兼顾性能,这都是我们需要考虑的问题
在多线程环境中,也存在的线程安全问题,我们通常是使用"锁机制"来解决;同理,多个事务对同一张表的同一个数据行进行修改操作时,如果要实现事务之间的相互隔离也可以通过锁来完成,在MySQL中常见的锁类型有:独占锁、共享锁、行锁、间隙锁、next-key锁等,不同的锁联合多版本并发控制可以实现 事务间不同程度的隔离,称为事务的 隔离级别 ,不同的隔离级别对安全性和性能之间做了取舍,有的隔离级别注重并发性能,有的隔离级别注重安全,在Innodb存储引擎中,提供如下四种隔离级别:

  • 读未提交(Read Uncommited)
  • 读已提交(Read Committed)
  • 可重复读(Repeatable Read)(默认)
  • 串行化(Serializable)

从上到下在性能方面递减,安全性方面递增

1.3 锁

:实现事务的隔离级别中我们使用到了锁机制,所谓锁就是在事务A修改某些数据的时候,对这些数据加一把锁,防止其他事务同时对这些数据进行修改操作;当事务A完成修改操作后,释放当前所持有的锁,以便其他事务进行加锁操作,这里重点介绍InnoDB存储引擎中涉及到的锁:

1.3.1 锁信息

相关锁的一些信息,比如锁的请求状态、持有状态、阻塞状态都被保存在performance_schema库中的data_locks表中,可以通过以下方式进行查看:
image.png
其中我们需要特别注意两个字段:

  • LOCK_TYPE:锁类型,依赖于存储引擎,在InnoDB存储引擎中按照锁的粒度分为表级锁TABLE和行级锁RECORD
    • 行级锁也叫行锁,是对表中的某些具体数据行进行加锁
    • 表级锁也叫表锁,是对整个数据表进行加锁
  • LOCK_MODE:锁模式,用来描述如何申请锁,分为共享锁(S)、独占锁(X)、意向共享锁(IS)、意向独占锁(IX)、记录锁、间隙锁、Next-Key锁、AUTO-INC锁

1.3.2 共享锁和独占锁-Shared and Exclusive Locks

InnoDB实现了标准的行级锁,分为两种分别是共享锁(S)以及独占锁(X),独占锁也被称为排它锁。

  • 共享锁(S锁):允许持有该锁的事务读取表中的一行记录,同时允许其他事务在锁定行上加另一个共享锁以及读取锁定行,但是不能对其进行修改操作
  • 独占锁(X锁):允许持有该锁的事务对数据行进行更新或者删除操作,同时不允许其他事务对锁定行进行读取或修改操作,也不允许加任何锁
# 加共享锁
select * from user where name='张三' for share; 
# 加独占锁
select * from user where name='张三' for update; 
# 使用以下SQL语句查看锁监视器
show engine innodb status\G;
  • 如果事务T1持有行R上的S锁,那么事务T2请求R上的锁时有如下处理:
    • 如果T2请求S锁会立即授予,此时T1、T2都有对行R的共享锁
    • 如果T2请求X锁不会被立即授予,而是阻塞到T1释放持有的锁
  • 如果事务T1持有行R的X锁,那么事务T2无论请求何种类型的锁都不会被立即授予,而是阻塞等待事务T1释放持有的锁

💡 提示:
读锁是共享锁的一种实现方式,写锁是独占锁的一种实现方式!

1.3.3 意向锁-Intension Locks

  • InnoDB存储引擎支持多粒度锁共存,比如行级锁和表级锁共存
  • InnoDB使用意向锁实现多粒度级别的锁,意向锁是表级别的锁,它并不是真正意义上的加锁,只是在data_locks中记录事务以后要对表中的哪一行加哪种类型的锁,意向锁分为两种:
    • 意向共享锁(IS):表示事务打算对表中的单个行设置共享锁
    • 意向排他锁(IX):表示事务打算对表中的单个行设置排他锁
  • 在获取意向锁时有如下协议:
    • 在事务获得表中某一行的共享锁(S锁)之前,它必须首先获取该表上的IS锁或者更强粒度的锁
    • 在事务获得表中某一行的排它锁(X锁)之前,它必须首先获取该表上的IX锁
  • 意向锁可以提高加锁的性能,在真正加锁之前不需要遍历表中的数据行是否加锁,只需要查看表中的意向锁即可
  • 在请求锁的过程中,如果说申请的锁与现有锁兼容则立即授予;如果冲突则不会授予,而是阻塞等待直到冲突的锁被释放,意向锁与行级锁的兼容性如下:

image.png

1.3.4 索引记录锁-Record Locks

索引记录锁:又被称为精准行锁,顾名思义就是指索引记录上的锁,如下SQL锁住的是一行

select * from user where id=1 for update; # id为索引列
  • 索引记录锁总是锁定索引行,在表没有定义索引的情况下,InnoDB会创建一个隐藏的聚集索引,并使用该索引进行记录锁定,当使用索引进行查找时,锁定的只是满足条件的行

1.3.5 间隙锁-Gap Locks

间隙锁:间隙锁锁定的是索引记录之间的间隙,或者第一个索引记录之前,再或者是最后一个索引记录之后的间隙

  • 例如有如下SQL,锁定的是ID(10, 20)之间的间隙,注意不包括10和20的行,目的是防止其他事务将ID为15的列插入到列user表中,因为指定范围值之间的间隙被锁定了
select * from user where id between 10 and 20 for update; # id为索引列
  • 间隙锁可以跨越单个或者多个索引记录
  • 对于使用唯一索引查询到的唯一行,不使用间隙锁,例如如下语句,id列有唯一的索引值,只会对id为100的行使用索引记录锁
# 只使用Record Locks
select * from user where id=100; # id为索引列
  • 如果id没有被索引或者是一个非唯一的索引列,以上SQL语句会锁定记录之前的间隙
  • 不同事务之间的间隙锁可以共存,一个事务的间隙锁不会阻止另一个事务在相同的间隙上使用间隙锁,共享间隙锁和独占间隙所没有区别
  • 当事务隔离级别设置为READ COMMITED时间隙锁会被禁止,扫描索引不会使用间隙锁定

1.3.6 临键锁-Next-Key Locks

临键锁:是索引记录锁和索引记录之前的间隙上间隙锁的组合

  • 假设索引记录包含值10、11、13、20,则这些索引可能的Next-Key锁覆盖以下区间:(-oo,10]、(10,11]、(11,13]、(13,20]、(20, +oo)
  • 默认情况下,默认级别REPEATABLE READ事务隔离级别开启next-key锁进行索引扫描,可以防止部分幻象行,从而解决部分幻读问题

1.3.7 自增锁-AUTO-INC Locks

自增锁:是一个表级锁,服务于配置了AUTO_INCREMENT自增列的表,在插入数据时会在表上加自增锁并生成自增值,同时阻塞其他事务操作,以保证值的唯一性,注意的是如果一个事务执行新增操作生成自增值,但是事务回滚了,申请到的自增值不会回退,这也意味表中可能会出现自增列不连续的情况

1.3.8 死锁

当每个事务持有其他事务所需要的锁时,并且不会主动释放自己持有的锁,并且互相等待对方先释放锁,此时造成事务僵持无法继续执行下去的情况,就被称为死锁

1.3.8.1 示例
  1. 首先打开客户端A,并执行如下操作
# 开启全局变量innodb_print_all_deadlocks方便查看死锁信息
mysql> set global innodb_print_all_deadlocks=ON;
Query OK, 0 rows affected (0.00 sec)
# 创建两张表
mysql> CREATE TABLE animals (name VARCHAR(10) PRIMARY KEY, value INT) ENGINE =InnoDB;
Query OK, 0 rows affected (0.03 sec)
mysql> CREATE TABLE birds (name VARCHAR(10) PRIMARY KEY, value INT) ENGINE =InnoDB;
Query OK, 0 rows affected (0.03 sec)
# 插入数据
mysql>  INSERT INTO animals (name,value) VALUES ("dog",10);
Query OK, 1 row affected (0.01 sec)
mysql>  INSERT INTO birds (name,value) VALUES ("magpie",20);
Query OK, 1 row affected (0.00 sec)
# 开启事务
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
# 给查询行加上共享锁
mysql> SELECT value FROM animals WHERE name='dog' FOR SHARE;
+-------+
| value |
+-------+
|    10 |
+-------+
1 row in set (0.00 sec)
  1. 打开客户端B,并执行以下操作
# 客户端B开启事务
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
# 给查询行加上共享锁
mysql> SELECT value FROM birds WHERE name='magpie' FOR SHARE;
+-------+
| value |
+-------+
|    20 |
+-------+
1 row in set (0.00 sec)
  1. 尝试在客户端B中更新animals表中的行
# 客户端B修改animals表中的行(由于客户端A事务已经加了S锁,因此客户端B阻塞等待)
mysql> update animals set value=30 where name='dog';
  1. 此时客户端A更新birds表中的数据行就会造成死锁
# 客户端A修改birds表中的行(由于客户端B事务已经加了S锁,因此客户端A阻塞等待形成死锁)
mysql> update birds set value=40 where name='magpie';
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
  1. 当死锁发生时,事务A自动回滚(释放锁),此时事务B执行成功!
mysql> update animals set value=30 where name='dog';
Query OK, 1 row affected (26.54 sec)
Rows matched: 1  Changed: 1  Warnings: 0
  1. 可以通过以下方式查看服务器发生死锁的次数:
mysql> SELECT `count` FROM INFORMATION_SCHEMA.INNODB_METRICS
    ->  WHERE NAME="lock_deadlocks";
+-------+
| count |
+-------+
|     1 |
+-------+
1 row in set (0.01 sec)
  1. 我们还可以查看InnoDB监视器查看锁信息:
mysql> show engine innodb status\G;
1.3.8.2 死锁产生的条件
  1. 互斥访问:如果线程1获取到锁A,此时线程2就无法再次获取锁A
  2. 不可抢占:如果线程1获取到锁A,那么除非线程1自己释放,否则其余线程无法从线程1中抢夺锁
  3. 请求并保持:当线程已经获取锁A的时候,还要在此基础上获取锁B,并且不会释放锁A
  4. 循环等待:线程1等待线程2释放锁,线程2也在等待线程1释放锁,死锁发生时系统中一定存在两个或两个以上的线程构成环路,环路上的线程都在等待别的线程释放锁

以上是死锁产生的四个必要条件,比如同时满足才会发生死锁,并且只要破坏任意一个条件就可以打破死锁,通常最简单的就是破坏循环等待条件!

1.4 MVCC

MVCC:上一小节介绍了如何使用锁机制来实现不同的隔离级别,但是频繁加锁解锁对性能会产生比较大的影响,因此InnoDB为了提高性能,同时采用了另一种实现不同隔离级别的机制:MVCC,即"Multi-Versioned Concurrency Control"多版本并发控制,用来解决脏读、不可重复读等问题,MVCC在某些场景中代替了低效的锁,在保证隔离性的同时提升并发性能。

1.4.1 实现原理

1.4.1.1 Undo Log 链

MVCC的实现原理是基于Undo Log版本链和ReadView来实现的,Undo Log我们知道作为事务回滚的基础,会在每一次执行update或者delete操作的时候,将操作的上一版本记录在Undo Log中,每一条Undo Log都记录一个roll_pointer的引用信息,通过该引用信息就可以将某条数据对应的Undo Log组织成Undo链,同时在数据行的头部通过roll_pointer就可以与Undo Log中的第一条日志进行关联,构成完整的数据版本链

1.4.1.2 ReadView

每条数据的版本链都构造好后,在查询的时候具体选择哪个版本呢?这里就需要借助ReadView数据结构实现了,所谓的ReadView本质上就是一个内存数据结构,在事务中每次使用 select 查询都会构造出一个ReadView记录该版本链中的一些统计信息,后续就无需再次遍历所有版本链,这些统计值包括:

  1. m_ids:当前活跃事务id列表
  2. m_low_limit_id:当前活跃事务id列表中最小事务id
  3. m_up_limit_id:下一个将被分配的事务id,也就是版本链头事务id+1
  4. m_creator_tx_id:创建当前readview的事务id

当构造好readview的时候就需要按照一定的查询规则来找到唯一可用版本,比如说在m_creator_tx_id=201的事务中执行select操作就会构造一个readview数据结构

  • m_ids:假设当前活跃事务id列表为[90, 100, 200]
  • m_low_limit_id:当前活跃事务最小id为90
  • m_up_limit_id:下一个将要分配的事务id就是201+1=202
  • m_creator_tx_id:创建readview的事务id为201

接下来找到版本链头,从头开始遍历所有版本,根据四步查找原则:

  1. 第一步:判断该版本是否由当前事务创建,即若 m_creator_tx_id等于该版本事务id,意味着读取自己修改的数据,可以直接访问,否则下一步
  2. 第二步:若该版本事务id < m_low_limit_id则意味该版本在构建readview之前已经提交,可以直接访问,否则下一步
  3. 第三步:若该版本事务id >= m_up_limit_id,则意味着该版本在readview创建完毕之后才创建,所以无需第四步判断,直接遍历下一个版本
  4. 第四步:若在事务在 m_low_limit_idm_up_limit_id 之间并且没有在活跃事务id列表中则意味着已经提交,可以直接访问,否则遍历下一个版本

这样从版本链头遍历判断到版本链尾,找到⾸个符合要求的版本即可,就可以实现查询到的结果都 是已经提交事务的数据,解决了脏读问题

1.4.1.3 其他问题

关于MVCC是否能解决不可重复读、幻读问题?

  • 首先MVCC无法解决幻读问题
  • MVCC可以在REPEATABLE_READ隔离级别下可以解决不可重复读问题,但是在READ_COMMITED隔离级别下无法解决
    1. REPEATABLE_READ隔离级别下,我们只会在第一次使用select查询时生成readview,后续查询都使用该readview保证数据一致性
    2. READ_COMMITED隔离级别下,对于每一次select操作都会生成一个readview,就会出现不可重复读问题

2. 查看并设置隔离级别

  • 事务的隔离级别分为全局作用域和会话作用域,查看不同的隔离级别可以使用如下方式:

    # 查看全局作用域
    select @@global.transaction_isolation;
    # 查看会话作用域
    select @@session.transaction_isolation;
    

可以看到默认的隔离级别都是REPEATABLE_READ可重复读

  • 设置不同的隔离级别可以使用如下几种方式:
    1. 通过set命令设置

      # 设置全局作用域
      set global transaction isolation level serializable;
      # 设置会话作用域
      set session transaction isolation level serializable;
      
    2. 通过选项文件进行配置

      [mysqld]
      transaction-isolation = REPEATABLE-READ # 隔离级别为可重复读
      transaction-read-only = OFF # 关闭只读意味着访问模式为读写
      
    3. 通过set设置系统变量设置

      # 方式一
      # 设置全局作用域
      set global transaction_isolation='REPEATABLE-READ';
      # 设置会话作用域
      set session transaction_isolation='REPEATABLE-READ';
      # 方式二
      # 设置全局作用域
      set @@global.transaction_isolation='REPEATABLE-READ';
      # 设置会话作用域
      set @@session.transaction_isolation='REPEATABLE-READ';
      
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值