MySQL - 锁
MySQL的锁
读锁和写锁
读锁还可以称为共享锁 ,写锁还可以称为排他锁
读锁和写锁是系统层面上的锁,也是最基础的锁。读锁和写锁还是锁的一种性质,比如行锁里,有行写锁和行读锁。MDL 锁里也有 MDL 写锁和 MDL 读锁。
读锁和写锁加锁关系如下:
读锁 | 写锁 | |
---|---|---|
读锁 | 共存 | 互斥 |
写锁 | 互斥 | 互斥 |
一个请求占用了读锁,其他请求也可以过来加读锁,但是不能加写锁。这种情况下会出现一个问题,如果一直有请求过来加读锁,那么来了一个加写锁的请求,会一直因为有读锁的存在而阻塞。这样写锁就会被饿死了,为了避免这种情况的发生,数据库做了优化,当有写锁阻塞时,后面的读锁也会阻塞,这样就避免了写锁饿死的现象。
对于 InnoDB 引擎而言,采用的是B+树索引,假设需要将整个表锁住 ,那么需要在整个B+树的每个节点上都加锁,这种方式显然是很低效的。
因此MySQL提出了意向锁的概念,如果要在一个节点上加锁,就必须要在其所有的祖先节点上加意向锁。
表锁和行锁
表锁和行锁是两种不同粒度的锁,除了这两种锁还有一个更大粒度的锁:全局锁
-
全局锁
-
全局锁会锁住整个数据库;
-
MySQL使用
flush tables with read lock
命令来加全局锁,使用unlock tables
解锁; -
当加上全局锁以后,除了当前线程以外,其他线程的更新操作都会被阻塞,包括增删改数据表中的数据、建表、修改表结构等;
-
全局锁的典型使用场景是全库的逻辑备份。
-
-
表锁
- 表锁会锁住一张表;
- MySQL 使用
lock tables read/write
命令给表加上读锁或写锁,通过unlock tables
命令释放表锁- 通过
lock tables t read
给表 t 加上读锁后,当前线程只能访问表 t,不能访问数据库中的其他表,对表 t 也只有读权限,不能进行修改操作。 - 通过
lock tables t write
给表 t 加上写锁后,当前线程只能访问表 t,不能访问数据库中的其他表,对表 t 有读写权限。
- 通过
-
行锁
-
行锁会锁锁住表中的某一行或者多行
-
MySQL 使用
lock in share mode
命令给行加读锁,用for update
命令给行加写锁,行锁不需要显示释放,当事务被提交时,该事务中加的行锁就会被释放。通过
select k from t where k = 1 for update
命令可以锁住 k 为 1 的所有行 -
当使用 update 命令更新表数据时,会自动给命中的行加上行锁
-
MySQL 加行锁时并不是一次性把所有的行都加上锁,执行一个 update 命令之后,server 层将命令发送给 InnoDB 引擎,InnoDB 引擎找到第一条满足条件的数据,并加锁后返回给 server 层,server 层更新这条数据然后传给 InnoDB 引擎。完成这条数据的更新后,server 层再取下一条数据
-
事务A | 事务B | 事务C | 说明 |
---|---|---|---|
begin | 事务A开启事务 | ||
select * from t where id = 3 for update; | 事务A通过 for update将id=3的行锁住 | ||
update t set c = 0 where id = c; | 事务B执行update命令(将所有的c都更新为0)(执行到id=3的行时会阻塞) | ||
set session transaction isolation level READ UNCOMMITTED; | 开启事务 C,并且将事务 C 的隔离级别修改为未提交读; | ||
select * from t; | 事务C查询表信息,发现前两行的c都被更新为0,但是id=3的c并没有被事务B修改为0,说明事务B被阻塞了 | ||
commit | 提交事务A(事务A加的锁释放了,事务B可以继续加锁了,继续更新) | ||
select * from t; | 查询表的信息,发现都事务B已经更新完成 |
(可以看到事务B执行到id=3的行就被阻塞了,后面的数据都没有更新)
(太久没操作搞超时了,,,又重新按流程来了一遍,通过观察事务C的查询结果,可以发现事务A提交后事务B才继续执行了)
乐观锁和悲观锁
-
乐观锁
乐观锁总数假设不会发生冲突,因此读取资源不加锁,只有在更新的时候判断整个事务期间是否有其他事务更新了这个数据。如果没有其他事务更新这个数据,那么本次更新成功,否则更新失败。
-
悲观锁
悲观锁总是假设会发生冲突,因此读取数据的时候加锁,这样保证同时只能有一个线程能修改数据。(前面的表锁,行锁都是悲观锁)
乐观锁和悲观锁是两种不同的加锁策略。乐观锁假设的场景是冲突少,因此适合读多写少的场景。悲观锁则正好相反,合适写多读少的场景。
乐观锁无需像悲观锁那样维护锁资源,做加锁阻塞等操作,因此更加轻量化。
乐观锁的实现
版本号方式
-
给每条数据都加上一个 version 字段,表示版本号
-
开启事务后,先读取数据,并保存数据里的版本号 version1,然后做其他处理
-
最后更新的时候比较 version1 和数据库里当前的版本号是否相同。
用 SQL 语句表示就是
update t set version = version + 1 where version = version1
。update 操作时会进行当前读*,因此即使是在可重复读的隔离级别下,也会取到到最新的版本号。
如果没有其他事务更新过这条数据,那么 version 等于 version1,于是更新成功。如果有其他事务更新过这条数据,那么 version 字段的值会被增加,那么 version 不等于 version1,于是更新没有生效。
当前读,读取的是最新版本,并且对读取的记录加锁,阻塞其他事务同时改动相同记录,避免出现安全问题。
CAS方式
CAS (compare and swap),懂的都懂,看过多线程的应该都知道,这里就不介绍了。
CAS 同时会带来ABA问题,可以通过添加时间戳来解决。
MDL锁和Gap锁
MDL锁
MDL 锁也是一种表级锁,MDL 锁不需要显示使用。
MDL 锁是用来避免数据操作与表结构变更的冲突,试想当你执行一条查询语句时,这个时候另一个线程在删除表中的一个字段,那么两者就发生冲突了,因此 MySQL 在5.5版本以后加上了 MDL 锁。
当对一个表做增删查改时会加 MDL 读锁,当对一个表做结构变更时会加 MDL 写锁。读锁相互兼容,读锁与写锁不能兼容。
MDL 需要注意的就是避免 MDL 写锁阻塞 MDL 读锁。
事务A | 事务B | 事务C | 事务D | 说明 |
---|---|---|---|---|
select * from t | 事务 A 执行 select 后给表 t 加 MDL 读锁 | |||
select * from t | 事务 B 执行 select 后给表再次加上 MDL 读锁(读锁和读锁可以兼容) | |||
alter table t add c int | 事务 C 执行 alter 命令时会阻塞,需要对表 t 加 MDL 写锁 事务 C 被阻塞问题并不大,但是会导致后面所有的事务都被阻塞,比如事务 D |
|||
select * from t | 事务D被阻塞 |
这是为了避免写锁饿死的情况发生,MySQL 对加锁所做的优化,当有写锁在等待的时候,新的读锁都需要等待。如果事务 C 长时间拿不到锁,或者事务 C 执行的时间很长都会导致数据库的操作被阻塞。
为了避免这种事情发生有以下几点优化思路:
- 避免长事务。事务 A 和事务 B 如果是长事务就可能导致事务 C 阻塞在 MDL 写锁的时间比较长。
- 对于大表,修改表结构的语句可以拆分成多个小的事务,这样每次修改表结构时占用 MDL 写锁的时间会缩短。
- 给 alter 命令加等待超时时间
Gap锁
Gap 锁是 InnoDB 引擎为了避免幻读而引入的。
InnoDB 引擎在可重复读隔离级别下可以避免幻读。间隙锁就是锁住数据行之间的间隙,避免新的数据插入进来。
只有在进行当前读的时候才会加 gap 锁。
其他
实践:MySQL 加锁处理分析