MySQL的锁实现原理

83 阅读10分钟

前言

数据库作为一个数据存取的服务器,往往需要对接多个客户端,在实际的数据库系统中,每时每刻都在发生锁定,当某个用户在修改一部分数据时,MySQL会通过锁定 防止其他用户读取同一数据。

在处理并发读或者写时,通过实现一个由两种类型的锁组成的锁系统来解决问题。两种锁通常被称为共享锁(shared lock)和排他锁(exclusive lock),也叫读锁(read lock)和写锁(write lock)。

读锁是共享的,是互相不阻塞的。多个客户端在同一时刻可以同时读取同一个资源,而不互相干扰。写锁则是排他的,也就是说一个写锁会阻塞其他的写锁和读锁,这是出于安全策略的考虑,只有这样才能 确保在给定的时间里,只有一个用户能执行写入,并防止其他用户读取正在写入的同一资源。

image.png

MySQL锁分类

按锁粒度分:

  • 全局锁:锁整Database,由MySQL的SQL layer层实现。
  • 表锁:锁某Table,由MySQL的SQL layer层实现
  • 行锁:锁某Row的索引,也可锁定行索引之间的间隙,由存储引擎实现【InnoDB】
    • 记录锁
    • 间隙锁
    • 临键锁
    • 插入意向锁

image.png

按锁功能分:

  • 共享锁Shared Locks(S锁,也叫读锁):
    • 加了读锁的记录,允许其他事务再加读锁
    • 加锁方式:select…lock in share mode
  • 排他锁Exclusive Locks(X锁,也叫写锁)
    • 加了写锁的记录,不允许其他事务再加读锁或者写锁
    • 加锁方式:select…for update

全局锁

全局锁是对整个数据库实例加锁,加锁后整个实例就处于只读状态,后续的DML的写语句,DDL语句, 已经更新操作的事务提交语句都将被阻塞。其典型的使用场景是做全库的逻辑备份,对所有的表进行锁 定,从而获取一致性视图,保证数据的完整性。

加全局锁的命令为:

flush tables with read lock;

释放锁的命令为:

unlock tables;

或者断开加锁session的连接,自动释放全局锁。

全局锁用于备份这个事情,还是很危险的。因为如果在主库上加全局锁,则整个数据库将不能写 入,备份期间影响业务运行,如果在从库上加全局锁,则会导致不能执行主库同步过来的操作,造成主 从延迟。

对于innodb这种支持事务的引擎,使用mysqldump备份时可以使用--single-transaction参数,利用 mvcc提供一致性视图,而不使用全局锁,不会影响业务的正常运行。而对于有MyISAM这种不支持事务 的表,就只能通过全局锁获得一致性视图,对应的mysqldump参数为--lock-all-tables。

# 提交请求锁定所有数据库中的所有表,以保证数据的一致性,全局读锁【LBCC】
mysqldump -uroot -p --host=localhost --all-databases --lock-all-tables > /root/db.sql

# 一致性视图【MVCC】
mysqldump -uroot -p --host=localhost --all-databases --single-transaction > /root/db.sql

表锁

MySQL的表级锁有四种:

  • 表读锁(Table Read Lock)
lock table t read; # 为表t加写锁

unlock tables; # 释放锁
  • 表写锁(Table Write Lock)
lock table t write; # 为表t加写锁

unlock tables; # 释放锁
  • 元数据锁(meta data lock,MDL) , 当对表结构锁调整时,如alter操作会加元数据表锁。
  • 自增锁(AUTO-INC Locks),AUTO-INC锁是一种特殊的表级锁,发生涉及AUTO_INCREMENT列的事务性插入操作时产生。

行锁

MySQL的行级锁,是由存储引擎来实现的,这里我们默认说的是InnoDB的行级锁。InnoDB行锁是通过给索引上的索引项加锁来实现的,因此InnoDB这种行锁实现特点:只有通过索引条件检索的数据, InnoDB才使用行级锁,否则,InnoDB将使用表锁!

InnoDB的行级锁,按照锁定范围来说,分为四种:

  • 记录锁(Record Locks):锁定索引中一条记录。
  • 间隙锁(Gap Locks):要么锁住索引记录中间的值,要么锁住第一个索引记录前面的值或者 最后一个索引记录后面的值。
  • 临键锁(Next-Key Locks):是索引记录上的记录锁和在索引记录之前的间隙锁的组合(间隙锁 + 记录锁)。
  • 插入意向锁(Insert Intention Locks):做insert操作时添加的对记录id的锁。

InnoDB的行级锁,按照功能来说,分为两种:

  • 读锁:允许一个事务去读一行,阻止其他事务更新目标行数据。同时阻止其他事务加写锁,但不阻止其他事务加读锁。
  • 写锁:允许获得排他锁的事务更新数据,阻止其他事务获取或修改数据。同时阻止其他事务加读锁和写锁。

如何加行级锁?

  • 对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据集加写锁;
  • 对于普通SELECT语句,InnoDB不会加任何锁。
  • 事务可以通过以下语句手动给记录集加共享锁或排他锁:
    • 添加读锁:select * from t1_simple where id = 4 lock in share mode;
    • 添加写锁:select * from t1_simple where id = 4 for update;

主键id索引的行锁区间划分图:

image.png

记录锁

记录锁(Record Locks)仅仅锁住索引记录的一行,在单条索引记录上加锁。记录锁锁住的永远是索引,而非记录本身,即使该表上没有任何显示索引,那么innodb会在后台创建一个隐藏的聚簇索引索引,那么锁住的就是这个隐藏的聚簇索引索引。

-- 加记录读锁 
select * from t1_simple where id = 1 lock in share mode; 

-- 加记录写锁 
select * from t1_simple where id = 1 for update; 

-- 新增,修改,删除加记录写锁 
insert into t1_simple values (1, 22); 
update t1_simple set pubtime=33 where id =1; 
delete from t1_simple where id =1;

间隙锁

  • 间隙锁(Gap Locks),仅仅锁住一个索引区间(开区间,不包括双端端点)。
  • 在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,并不包括该索引记录 本身。
  • 间隙锁可用于防止幻读,保证索引间隙不会被插入数据。
  • 在可重复读(REPEATABLE READ)这个隔离级别下生效。

临键锁

  • 临键锁(Next-Key Locks)相当于记录锁 + 间隙锁【左开右闭区间】。
  • 默认情况下,innodb使用临键锁来锁定记录,但在不同的场景中会退化。
  • 当查询的索引含有唯一属性的时候,临键锁会进行优化,将其降级为记录锁,即仅锁住索引本身,不是范围。
场景退化的锁类型
使用Unique index 精确匹配【=】,且记录存在记录锁
使用Unique index 精确匹配【=】,且记录不存在间隙锁
使用Unique index 范围匹配【<和>】临键锁

插入意向锁

按照之前学习的关于 间隙锁 的知识分析,此时间隙锁的范围是 (11,99),意思是这个范围的 id 都不可以 插入。如果是这样的话数据插入效率太低,锁范围比较大,很容易发生锁冲突怎么办? 插入意向锁就是用来解决这个问题的!

什么是插入意向锁?

  • 插入意向锁(Insert Intention Locks)是一种在INSERT操作之前设置的一种特殊的间隙锁。
  • 插入意向锁表示了一种插入意图,即当多个不同的事务,同时往同一个索引的同一个间隙中插入数据的时候,它们互相之间无需等待,即不会阻塞。
  • 插入意向锁不会阻止插入意向锁,但是插入意向锁会阻止其他间隙写锁(排他锁)、记录锁。

注意, 插入意向锁和InnoDB表意向锁是不同的锁:

InnoDB也实现了表级锁,也就是意向锁【Intention Locks】。意向锁是MySQL内部使用的,不需要用 户干预。意向锁和行锁可以共存,意向锁的主要作用是为了全表更新数据时的提升性能。否则在全表更 新数据时,需要先检索该范是否某些记录上面有行锁。那么将是一件非常繁琐且耗时操作。举例说明:

事务A修改user表的记录r,会给记录r上一把行级的写锁,同时会给user表上一把意向写锁(IX),这时 事务B要给user表上一个表级的写锁就会被阻塞。意向锁通过这种方式实现了行锁和表锁共存,且满足 事务隔离性的要求。

加锁的规则

主键索引

  • 等值条件,命中,加记录锁。
  • 等值条件,未命中,加间隙锁。
  • 范围条件,命中,包含where条件的临键区间,加临键锁。
  • 范围条件,没有命中,加间隙锁。

辅助索引

  • 等值条件,命中,命中记录的辅助索引项 + 主键索引项加记录锁,辅助索引项两侧加间隙锁。
  • 等值条件,未命中,加间隙锁。
  • 范围条件,命中,包含where条件的临键区间加临键锁。命中记录的id索引项加记录锁。
  • 范围条件,没有命中,加间隙锁。

死锁

深入理解MySQL如何加锁,有两个比较重要的作用:

  • 可以根据MySQL的加锁规则,写出不会发生死锁的SQL;
  • 可以根据MySQL的加锁规则,定位出线上产生死锁的原因;
  • 可以根据MySQL的加锁规则,透过现象看本质,理解数据库层面阻塞执行的根本原因。

死锁如何产生的?

举例说明,准备数据:

CREATE TABLE `t1_deadlock` ( 
`id` int(11) NOT NULL, `name` varchar(100) DEFAULT NULL,
`age` int(11) NOT NULL,
`address` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_age` (`age`) USING BTREE,
KEY `idx_name` (`name`) USING BTREE 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 

Insert into t1_deadlock(id,name,age,address) values (1,'刘备',18,'蜀国');
Insert into t1_deadlock(id,name,age,address) values (2,'关羽',17,'蜀国'); 
Insert into t1_deadlock(id,name,age,address) values (3,'张飞',16,'蜀国');
Insert into t1_deadlock(id,name,age,address) values (4,'关羽',16,'蜀国'); 
Insert into t1_deadlock(id,name,age,address) values (5,'诸葛亮',35,'蜀国');
Insert into t1_deadlock(id,name,age,address) values (6,'曹孟德',32,'魏国');

下面,来看看两个死锁的例子 一个是两个Session的两条SQL产生死锁;另一个是两个Session的一条 SQL,产生死锁:

image.png

结论: 死锁的发生与否,并不在于事务中有多少条SQL语句,【死锁的关键在于】:两个(或以上)的Session 【加锁的顺序】不一致。

如何避免死锁?

MySQL默认会主动探知死锁,并回滚某一个影响最小的事务。等另一事务执行完成之后,再重新执行该事务。

  1. 注意程序的逻辑:根本的原因是程序逻辑的顺序交叠,最常见的是交差更新。
  2. 保持事务的轻量:越是轻量的事务,占有越少的锁资源,这样发生死锁的几率就越小。
  3. 提高运行的速度:避免使用子查询,尽量使用主键等等。
  4. 尽量快提交事务,减少持有锁的时间:越早提交事务,锁就越早释放。