数据库锁设计的初衷是为了处理并发问题,根据加锁的范围,MySQL 里的锁大致分为 全局锁、表锁、行锁 三类。
全局锁
对整个数据库实例加锁。
当你需要让整个库处于只读状态的时候,可以用下面的命令:
flush tables with read lock
DFTWRL 之后,其他线程的以下语句会被阻塞:数据更新语句(增删改,DML)、数据定义语句(建表、修改表结构等,DDL)、更新类事务的提交语句。
全局锁的典型使用场景是 做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。
在备份过程中,整个库完全处于只读状态。加锁会出现以下问题:
- 如果在主库上备份,那备份期间不能执行更新操作,业务基本上就得停摆。
- 如果在从库上备份,那备份期间从库不能执行主库同步过来的 binlog,导致主从延迟。
那不加锁的话,问题就更严重了,会导致备份数据逻辑不一致。
我们知道在可重复读隔离级别下,启动一个事务,是可以拿到一致性视图的。官方自带的逻辑备份工具是 mysqldump,使用 --single-transaction 参数,导数据前会启动一个事务,确保拿到一致性视图,由于 MVCC 的支持,整个过程中数据是可以正常更新的。
那有了上述功能,为什么还需要 DFTWRL 呢?因为上述功能的前提是:引擎要支持事务,还得支持可重复读隔离级别,只是用与所有表都使用事务引擎的库。
表级锁
表级锁包括 表锁 和 元数据锁(meta data lock,MDL)
表锁
表锁一般是在数据库引擎不支持行锁的时候才会被用到。
表锁的语法是:lock tables ... read / write。可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。但是 lock tables 语法除了会限制别的线程读写外,也限定了本线程接下来的操作对象。
比如,如果在线程 A 中执行 lock tables t1 read, t2 write; 这个语句,则别的线程读 t1 和 读写 t2 的语句都会被阻塞,线程 A 在执行 unlock tables 之前也只能读 t1 和 读写 t2。
元数据锁 MDL
MDL 不需要显示的调用,在访问一个表的时候会自动加上。
作用是保证读写的正确性。
可以想象一下,如果一个查询正在遍历表中数据,执行期间有另一个线程对表结构做了更改,删除了一列,那么查询线程的数据跟表结构对不上,肯定是不行的。
所以在 MySQL 5.5 版本引入了 MDL,当对一个表进行增删改查的时候,加 MDL 读锁,当对表结构做更改的时候,加 MDL 写锁。
- 读锁之间不互斥,因此可以有多个线程同时进行增删改查。
- 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。如果有两个线程要同时给一个表加字段,那其中一个就得等另一个执行完成,才能开始执行。
为什么给一个小表加字段,却导致整个库挂了?
给一个表加字段、修改字段、加索引,都会扫描全表数据。在对大表进行操作的时候,你肯定会特别小心,以免对线上服务造成影响。实际上即使是小表,操作不慎也会出问题。比如下面的操作序列,假设表 t 是一个小表:
session A 先启动,在执行 select 的时候,会对表 t 加 MDL 读锁。
session B 需要 MDL 读锁,可以正常执行。
但是 session C 需要 MDL 写锁,因此被阻塞。如果只是 session C 被阻塞还好,但是之后所有在表 t 上需要 MDL 读锁的请求都会被 session C 阻塞,也就是表 t 暂时无法读写!
事务中的 MDL 锁,在语句执行开始时申请,在语句执行结束后不会马上释放,只有等事务提交后才会释放。所以只有在 session A 的事务提交后,session C 的语句才会开始执行。
如果这个表上查询语句频繁,而且客户端有重试机制,就是超时后会再起一个新 session 请求的话,这个库的线程很快就会爆满。
如何安全的给小表加字段?
- 解决长事务,事务不提交,就会一直占着 MDL 锁。可以在 information_schema 库的 innodb_trx 表中,查找当前执行中的事务。如果要做 DDL 的表刚好有长事务在执行,可以考虑等会执行 DDL,或先 kill 掉长事务。
- 如果是个热点表,数据量不大,但是上述请求很频繁,又不得不添加一个字段的时候,最好是在 alter table 语句中添加等待时间,如果能在指定的等待时间里拿到 MDL 写锁最好,拿不到也不要阻塞后面的语句,先放弃,然后多次重试。
MySQL 5.6 版本的 Online DDL 过程
- 拿 MDL 写锁
- 退化成 MDL 读锁
- 做 DDL
- 升级为 MDL 写锁
- 释放 MDL 锁
1、2、4、5 如果没有锁冲突,执行时间非常短。第 3 步占用了 DDL 绝大部分时间,期间这个表可以正常读写数据,所以叫 Online DDL。
行锁
针对数据表中行记录的锁。比如事务 A 更新了一行,事务 B 也要更新同一行,就得等事务 A 提交后才能进行更新。
两阶段锁
在 InnoDB 中,行锁是在需要的时候才加上的,但并不是不需要了就立即释放,而是要等到事务结束时才释放。
所以事务中需要锁多个行时,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。
死锁和死锁检测
当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。
如上,事务 A 在等待事务 B 释放 id=2 的行锁,事务 B 在等待事务 A 释放 id=1 的行锁,造成死锁。出现死锁后,有两种策略:
- 直接进入等待,直到超时。超时时间可以通过 innodb_lock_wait_timeout 来设置(默认 50s)。
- 发起死锁检测,发现死锁后,主动回滚链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑(默认就是 on)。
死锁检测过程:每当一个事务被锁住的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待(死锁)。
假设有1000个并发线程都要更新同一行数据,那么死锁检测操作就是 100万 这个数量级的(1000 * 1000)。虽然最后检测的结果不是死锁,但这个过程要消耗大量的 CPU 资源。因此就会看到 CPU 利用率很高,但是每秒执行不了几个事务。
如何解决由热点行更新导致的性能问题呢?
- 一种是如果能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。而关掉死锁检测意味着可能会出现大量的超时,所以不建议这样。
- 另一种思路是控制并发度。比如对于同一行的更新,在进入引擎之前排队,这样在 InnoDB 内部就不会有大量的死锁检测工作了。
- 还有就是可以将一行改成逻辑上的多行来减少锁冲突。
减少死锁的主要方向,就是控制访问相同资源的并发事务量。