全局锁
全局锁是针对数据库实例的直接加锁,MySQL 提供了一个加全局锁的方法, Flush tables with read lock 可以使用锁将整个表的增删改操作都锁上其中包括 ddl 语句,只允许全局读操作。
全局锁的典型使用场景是做全库的逻辑备份。
不过现在使用官方自带工具 mysqldump 使用参数 --single-transaction 的时候,导出数据之前就会启动一个事务。来确保拿到一致性视图。这个应该类似于在可重复读隔离级别下启动一个一致性事务。由于 MVCC 的支持,这个过程中数据可以正常更新。
另外提一点不太容易遇到的, --single-transaction 既然可以不用锁表,为什么还需要使用全局锁?原因是 --single-transaction 的时候需要支持一致性读,但是不支持事务的引擎是不支持一致性读的。这个时候就需要 FTWRL 命令了。
还有另外一种方法用来支持设置数据库为只读状态
set global readonly=true
这里 丁奇 不建议这样设置来设置数据库为只读有两个原因
一是,在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大,我不建议你使用。
二是,在异常处理机制上有差异。如果执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。
表级锁
MySQL 中表级别锁有两种:一种是普通表锁,一种是元数据锁(metadata lock. MDL)
表锁的语法是 lock tables xxx read/write 同样使用 unlock tables 来释放锁。通过加读锁我们可以限制其他语句进行写入,但是重复加读锁不受影响。但是当我们加写锁的时候,既不可以读也不可以写。同样在使用 unlock tables 之后可以解除锁定。
另外一种表级锁是 MDL 锁(metadata lock) MDL 锁不需要显示的使用,在访问一个表的时候自动就被加上了。 MDL 锁是用来保证读写正确性的,当我们对一个表在做 增删改查操作的时候都会被加上 MDL 读锁。当要进行 ddl 的时候需要加 MDL 写锁。
MDL 读锁与读锁之间不互斥,因此我们可以多个线程进程对一个表进行增删改查。
MDL 读写锁之间互斥,用来保证表结构变更的安全性。因此如果有两个线程同时要给同一个表加字段,其中一个要等另外一个执行完成之后再开始执行。
下面我们来看一个比较有代表性的场景 MDL 读锁写锁互斥导致表无法读写被死锁。
session A: 开始一个事务,然后查询 t 表,这会给 t 表加上 MDL 读锁。(注意该事务被打开后就一直没有结束)
session B: 查询一个 t 表。这里应该是 autoocommit 会自动成功。
session C: 修改表 ddl 会加 MDL 写锁,和 session A 的读锁互斥。这个时候就锁住了表。
session D: 由于 session C 造成了写锁阻塞,所以后面所有的请求都会被锁住。
如果该表查询频繁,而且客户端有重试的机制,那么这个数据库的查询线程会很快被打满。
可能在进行 web 开发的同学会经常遇到类似的情况。比如我在 ipython 里面打开了一个数据库某个表的连接,然后我一直没有 commit 。就可能造成该表在加写锁的时候阻塞后面所有的操作。
这种事情非常常见。
那么我们如何安全的给小表加字段,首先我们应该解决长事务或者脚本事务的问题,因为他们会一直挂读锁不结束。在 MySQL 的 information_schema 中的 innodb_trx 中可以查询到执行中的长事务,但是比较麻烦的是这个看不到很短的事务。但是往往进行 sleep 的短事务也可能因为一直没有 commit 而导致上面的情况出现。
这个时候就需要把对应表的 sleep 进程 kill 掉使其恢复正常。
行级锁
先来看个描述两阶段锁的例子:
事务 A 会持有两条记录的行锁,并且只会在 commit 之后才会释放。
在 InnoDB 事务中,行锁是在需要的时候加上,但是并不是不需要就立刻释放,而是等事务结束之后才会释放。这个就是两阶段锁协议。
知道了这个设定我们应该在长事务中把影响并发度的锁尽量往后放。下面的这一段的介绍比较复杂,我觉得 丁奇 讲得还是比较清楚的所以直接引用原文了。
假设你负责实现一个电影票在线交易业务,顾客 A 要在影院 B 购买电影票。我们简化一点,这个业务需要涉及到以下操作:
1. 从顾客 A 账户余额中扣除电影票价;
2. 给影院 B 的账户余额增加这张电影票价;
3. 记录一条交易日志。
也就是说,要完成这个交易,我们需要 update 两条记录,并 insert 一条记录。当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。那么,你会怎样安排这三个语句在事务中的顺序呢?
试想如果同时有另外一个顾客 C 要在影院 B 买票,那么这两个事务冲突的部分就是语句 2 了。因为它们要更新同一个影院账户的余额,需要修改同一行数据。
根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。所以,如果你把语句 2 安排在最后,比如按照 3、1、2 这样的顺序,那么影院账户余额这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。
死锁和死锁检测
如果出现下面的不慎操作就会发生死锁。
事务 A 开启事务,并且拿了 id = 1 的行锁。
事务 B 开启事务,拿到 id = 2 的行锁。
事务 A 试图去拿 id = 2 的行锁被 block。
事务 B 试图去拿 id = 1 的行锁被 block。
解决死锁 MySQL 目前有两种策略,第二种策略的参数我没有在 MySQL 5.6 版本中找到,在 MySQL 5.7 中找到。
1. 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。这个参数默认是 50s。
2. 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
很显然,等待 50 s 失效在现实业务中是不切实际的。肯定会造成高并发的业务大量的阻塞和 500 。所以看上去我们可以依赖第二种办法?
但是第二种办法也有副作用。
死锁检测会对每个新来的被堵住的线程,都判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。
解决这个的方法是
1. 如果我们能确保业务中就是不会存在死锁的逻辑,那么我们可以关闭死锁检测。
2. 我们控制并发度,不让某些业务更新这么快。对客户端的并发控制下来之后,死锁检测的效率是高的,也可以解决这个问题。
Reference:
本读书笔记皆来自发布在极客时间的 林晓斌(丁奇)的 MySQL 实战45讲:
极客时间版权所有: https://time.geekbang.org/ 版权所有:
https://time.geekbang.org/column/article/69862
https://time.geekbang.org/column/article/70215