Mysql 的锁机制

Mysql 的锁机制

MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类

全局锁

全局锁用来对整个数据库实例加锁,由于 Myisam 引擎不支持事务,因此想要进行数据全局备份或库存清点时,就使用该锁让整库处于只读的状态。

mysql 复制代码
Flush tables with read lock
mysql 复制代码
UNLOCK TABLES

在 InnoDB 引擎下 Mysql 官方自带备份工具 mysqldump。由于 MVCC 的支持, mysqldump 在导数据时会启动一个事务来拿到一致性视图,此时数据仍可以正常更新。

全库只读也可以 set global read_only = 0 设置,但是此类参数有可能控制其它的功能,且需要在执行完成后手动恢复,而 Flush tables with read lock(FLWRL)在客户端异常断开时会自动释放全局锁。

表级锁

表级锁一般分为两种:表锁和元数据锁(meta data lock,MDL),有的也包含意向锁和自增主键锁。

表锁

mysql 复制代码
lock tables ... read/write
mysql 复制代码
UNLOCK TABLES

例如:lock tables t1 read, t2 write,则其它写 t1 和读写 t2 的线程都会被阻塞,同时在该线程 unlock 前,也只能执行读 t1,读写 t2 的操作,也不允许访问其它表。

MDL 锁

在 Mysql5.5 版本中引入,当对一个表做增删改查操作的时候,加 MDL 读锁;当对表做结构变更(DDL)的时候,加 MDL 写锁(为了避免一个线程正在查询而另一个线程在执行表结构变更语句的情况)。

  • MDL 读锁之间是不互斥的,因此可以有多个线程同时对一个表做增删改查
  • MDL 读锁与写锁之间,以及写锁与写锁之间是互斥的,用来保证变更表结构操作的安全性。

但是请注意,为了避免请求写锁的线程饿死,在写锁后到来的线程也会阻塞等待,如下图:

sessionD 由于 sessionC 在阻塞,因此也会被阻塞。

基于此,总结下关于如何安全的给小表加字段:

  • 解决长事务,事务不提交就会一直占用 MDL 读锁,可以通过 select * from information_schema.INNODB_TRX 查询当前正在执行的事务,如果 DDL 变更时有长事务在执行,要考虑暂停 DDL 或 kill 掉这个事务。
  • 如果这个表的请求非常频繁,比较理想的机制是在 alter table 语句后设置等待时间,在等待时间内拿不到 MDL 写锁就放弃,不要阻塞后面的业务语句。

意向锁

MySQL 的意向锁(Intention Lock)由 InnoDB 引擎自动管理的 ,本身不锁定任何具体的数据行。它的核心作用是一个"信号"或"意向声明",用来协调不同粒度的锁(表锁与行锁)之间可能发生的冲突。

意向锁的出现主要是为了解决性能问题。想象一下,当一个事务想锁定整张表时(如LOCK TABLES),如果没有意向锁,数据库就必须遍历表中的每一行去检查是否存在行锁,这对于大表来说是灾难性的。

有了意向锁,过程就变得高效:当一个事务要给某几行加行锁(如SELECT ... FOR UPDATE)时,它会先在表上加上一个意向排他锁。之后,当另一个事务想锁整张表时,只需检查表上是否存在意向锁,就能立刻知道内部有行锁,从而避免了全表扫描。

行锁

由于 Myisam 不支持行锁,所以并发操作只能由表锁控制,这也是被 InnoDB 逐渐替换的一个原因。

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

同时需要注意的一点是:行锁是加在索引上的。

记录锁(Record Lock)

记录锁有两种实现:共享锁(S 锁)和排他锁(X 锁)

  • 共享锁:
    • 需要手动添加。
      • MySQL 8.0+ 推荐:SELECT ... FOR SHARE;
      • 旧版语法(仍可用):SELECT ... LOCK IN SHARE MODE;
  • 排他锁:
    • 自动加锁 :执行 INSERT, UPDATE, DELETE 操作时,InnoDB 会自动为涉及的行加上X锁。
    • 手动加锁 :执行 SELECT ... FOR UPDATE;

由于行锁是加在索引上的,例如有一个表结构(id、name、score)

mysql 复制代码
update students set score = 100 where name = 'Tom';

在执行上述的操作语句时会根据 name 索引锁定 'Tom' 对应的记录,由于是非主键索引,还会根据 name 索引对应的主键ID去锁定主键索引上的记录。

如果 name 索引上名为 'Tom' 的记录很多,MySQL Server 会根据 WHERE 条件读取第一条满足条件的记录,然后 InnoDB 引擎会将第一条记录返回并加锁(current read),待 MySQL Server 收到这条加锁的记录之后,会再发起一个 UPDATE 请求,更新这条记录。一条记录操作完成,再读取下一条记录,直至没有满足条件的记录为止。

因此,MySQL 在操作多条记录时 InnoDB 与 MySQL Server 的交互是一条一条进行的,加锁也是一条一条依次进行的,先对一条满足条件的记录加锁,返回给 MySQL Server,做一些 DML 操作,然后在读取下一条加锁,直至读取完毕。

如果 **SQL 语句无法使用索引时会走主索引实现全表扫描,这个时候 MySQL 会给整张表的所有数据行加记录锁。**如果一个 WHERE 条件无法通过索引快速过滤,存储引擎层面就会将所有记录加锁后返回,再由 MySQL Server 层进行过滤。不过在实际使用过程中,MySQL 做了一些改进,在 MySQL Server 层进行过滤的时候,如果发现不满足,会调用 unlock_row 方法,把不满足条件的记录释放锁(显然这违背了二段锁协议)。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。可见在没有索引时,不仅会消耗大量的锁资源,增加数据库的开销,而且极大的降低了数据库的并发性能,所以说,更新操作一定要记得走索引。

间隙锁(Gap Lock)

mysql 复制代码
UPDATE students SET score = 100 WHERE id = 5;

如果 id 为 5 的记录不存在,那么上述 sql 会加锁吗?

思考一个场景,线程 A 开启了事务,查询 id=5 的数据;线程 B 开启了事务,插入了一条 id 为 5 的数据,随后提交事务;线程 A 再次查询 id=5 的数据。如果线程 A 能查询到数据,其实就是出现了"幻读","幻读"与"不可重复读"的主要区别就是表现在记录数量出现了增加或减少,而非记录的值发生了变化。但 Mysql 在可重复读隔离级别下解决了"幻读",靠的就是间隙锁。

间隙锁 是一种加在两个索引之间的锁,或者加在第一个索引之前,或最后一个索引之后的间隙。有时候又称为范围锁(Range Locks),这个范围可以跨一个索引记录,多个索引记录,甚至是空的。

在上述的 sql 中,Mysql 就会在 id=5 的前后两个索引间加上间隙锁,不过间隙锁一般是针对非唯一索引讲的。

临键锁(Next-Key Locks)

Next-key 锁 是记录锁和间隙锁的组合,它指的是加在某条记录以及这条记录前面间隙上的锁。

mysql 复制代码
create table test(id int,v1 int,v2 int,primary key(id),key `idx_v1`(`v1`)) Engine=InnoDB 
mysql 复制代码
INSERT INTO `test` (`id`, `v1`, `v2`) VALUES (1, 1, 0);
INSERT INTO `test` (`id`, `v1`, `v2`) VALUES (2, 1, 1);
INSERT INTO `test` (`id`, `v1`, `v2`) VALUES (3, 1, 2);
INSERT INTO `test` (`id`, `v1`, `v2`) VALUES (5, 5, 3);
INSERT INTO `test` (`id`, `v1`, `v2`) VALUES (7, 10, 4);
INSERT INTO `test` (`id`, `v1`, `v2`) VALUES (10, 10, 5);
INSERT INTO `test` (`id`, `v1`, `v2`) VALUES (11, 10, 10);
sql 复制代码
# 要先设置 autocommit = 0
begin
select * from test where v1 = 5 for UPDATE

由于 v1 不是主键,而是二级非唯一索引,那么这个 sql 在 RR 隔离级别下就会加上如下的锁:

  • (a,5]
  • (5,b)

其中 a 是 5 前的上一个记录对应的 v1,b 是 5 后的下一个记录对应的 v1,所以此时锁定的位置是(1,5] 和 (5,10)

mysql 复制代码
# 开一个新窗口执行
begin;
insert into test (id,v1,v2) VALUES (8,3,10)

由于 v1 的值 3 在锁的范围内,所以无法插入。

但是如果插入记录的 v1 值为 1 或 10 就一定不阻塞吗

mysql 复制代码
# 开一个新窗口执行
begin;
insert into test (id,v1,v2) VALUES (4,1,10)
sql 复制代码
# 开一个新窗口执行
begin;
insert into test (id,v1,v2) VALUES (6,10,10)

如果你进行实验的话,会发现上述两个 sql 都会被阻塞,关于间隙锁端点是否被锁其实取决于插入记录主键的值

对原始数据分析,锁定的 v1(1,10) 的记录中,1 的记录对应的 id 是 3,10 的记录对应的 id 是 7,由于插入的 (4,1,10) 和 (6,10,10) 会插入到对应锁定记录的中间,所以插入失败,但是如果换成下面的 sql 就可以了

sql 复制代码
# 开一个新窗口执行
begin;
insert into test (id,v1,v2) VALUES (0,1,10)
insert into test (id,v1,v2) VALUES (8,10,10)

能成功的原因如下图:

综上所述,如何减少行锁的影响以及避免死锁呢

举个例子:有一个购物系统,购物时需要扣除用户余额,增加平台余额,退货时候需要增加用户余额,扣除平台余额。如果事务1先购物先增加平台余额,再扣除用户余额;事务二先增加用户余额,扣除平台余额(事务1和2操作的是不同的用户)。假如此时事务A操作的用户账户被锁定,由于事务1持有平台记录的写锁,所以事务2无法继续执行,陷入死锁。事务2陷入死锁的原因是事务1先操作了平台记录,在购物系统中,平台的账户可能被多个事务操作,如购物,退款,提现等,事务1把操作这种容易被多处操作的记录放在前,在阻塞时可能导致多个事务也无法向前推进。因此在事务操作中,先执行 insert 操作,把 update 这样的操作放在后面,减少事务之间的锁等待

InnoDB 中 innodb_lock_wait_timeout 参数设置了事务超时时间,默认 50s,如果超时则回退;参数 innodb_deadlock_detect 默认开启,在检测发生死锁的时候,会主动回退其中的一个事务,但是对性能有影响,例如百万并发更新同一条记录,死锁检测时间复杂度 O(N),检测期间要消耗大量的 cpu 资源,对外表现为 cpu 使用率很高,只是执行不了几个事务。

还有一个思路是通过业务设计来解决,例如购物系统的例子,如果平台不只有1个账户,而是 n 个,n 个的和是账户总余额,那么单个事务锁等待的概率就降低了。

参考

【1】Mysql实战45讲

【2】解决死锁之路 - 了解常见的锁类型

【3】MySQL InnoDB锁机制之Gap Lock、Next-Key Lock、Record Lock解析

相关推荐
AI绘画小3310 小时前
Web 安全核心真相:别太相信任何人!40 个漏洞挖掘实战清单,直接套用!
前端·数据库·测试工具·安全·web安全·网络安全·黑客
I***261511 小时前
数据库操作与数据管理——Rust 与 SQLite 的集成
数据库·rust·sqlite
百***480711 小时前
redis连接服务
数据库·redis·bootstrap
C***115011 小时前
对基因列表中批量的基因进行GO和KEGG注释
开发语言·数据库·golang
小蒜学长12 小时前
基于spring boot的汽车4s店管理系统(代码+数据库+LW)
java·数据库·spring boot·后端·汽车
一 乐12 小时前
餐厅管理智能点餐系统|基于java+ Springboot的餐厅管理系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端
gAlAxy...12 小时前
SpringMVC 响应数据和结果视图:从环境搭建到实战全解析
大数据·数据库·mysql
likuolei12 小时前
XQuery 完整语法速查表(2025 最新版,XQuery 3.1)
xml·java·数据库
b***462413 小时前
从 SQL 语句到数据库操作
数据库·sql·oracle
Q***f63513 小时前
后端数据库性能优化的8个工具推荐
数据库·性能优化