数据库锁设计的初衷是处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。
全局锁
对整个数据库实例加锁,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。
Flush tables with read lock (FTWRL)
全局锁的使用场景:做全库逻辑备份。
让整库都只读,听上去就很危险:
- 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;
- 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的 binlog,会导致主从延迟。
备份为什么加锁:
- 若时间顺序上是先备份账户余额表 (u_account),然后用户购买
用户 A 的数据状态是“账户余额没扣,但是用户课程表里面已经多了一门课。
- 如果备份表的顺序反过来,先备份用户课程表再备份账户余额表
用户 A 的数据状态是账户余额扣了,但没有多课程。
不加锁的话,备份系统备份的得到的库不是一个逻辑时间点,视图是逻辑不一致的。
所以,可以是可重复读隔离级别下开启一个事务拿到一致性视图的。
官方自带的逻辑备份工具是 mysqldump。当 mysqldump 使用参数–single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。
single-transaction 方法只适用于所有的表使用事务引擎的库。如果有的表使用了不支持事务的引擎,那么备份就只能通过 FTWRL 方法。
既然要全库只读,为什么不使用 set global readonly=true 的方式呢?但建议用 FTWRL 方式,
主要有两个原因:
- 一是,在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大。
- 二是,在异常处理机制上有差异。如果执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。
业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。
表级锁
表锁
表锁一般是在数据库引擎不支持行锁的时候才会被用到的。
表锁的语法是 lock tables … read/write。
如果在某个线程 A 中执行 lock tables t1 read, t2 write;
则其他线程写 t1、读写 t2 的语句都会被阻塞,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能访问其他表。
对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大。
元数据锁(meta data lock,MDL)
MDL 不需要显式使用,在访问一个表的时候会被自动加上,MDL 会直到事务提交才释放。
当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。
- 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
- 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
下面这个例子,给一个小表加个字段,导致整个库挂了。
- session A 先启动,这时候会对表 t 加一个 MDL 读锁。
- 由session B 需要的也是 MDL 读锁,因此可以正常执行。
- session C 会被 blocked,因为 session A 的 MDL 读锁还没有释放,而 session C 需要 MDL 写锁,因此只能被阻塞。
- 如果只有 session C 自己被阻塞还ok,但是之后所有要在表 t 上新申请 MDL 读锁的请求也会被 session C 阻塞。因为所有对表的增删改查操作都需要先申请 MDL 读锁,等于这个表现在完全不可读写。
事务中的 MDL 锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。
如何安全地给小表加字段?
- 首先解决长事务,事务不提交,就会一直占着 MDL 锁。
在 MySQL 的 information_schema 库的 innodb_trx 表中,可以查到当前执行中的事务。
如果做 DDL 变更的表刚好有长事务在执行,要考虑先暂停 DDL,或者 kill 掉这个长事务。
- 但考虑一下这个场景。如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而你不得不加个字段,你该怎么做呢?
此时kill 可能未必管用,因为新的请求马上就来了。
比较理想的机制是,在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。
问:
备份一般都会在备库上执行,你在用–single-transaction 方法做逻辑备份的过程中,如果从主库的 binlog 传来一个 DDL 语句会怎么样?,比如给一个表上加了一列。这时候,从备库上会看到什么现象呢?
答:
Q1:SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Q2:START TRANSACTION WITH CONSISTENT SNAPSHOT;
/* other tables */
Q3:SAVEPOINT sp;
/* 时刻 1 */
Q4:show create table `t1`;
/* 时刻 2 */
Q5:SELECT * FROM `t1`;
/* 时刻 3 */
Q6:ROLLBACK TO SAVEPOINT sp;
/* 时刻 4 */
/* other tables */
Q1 设置当前会话的事务隔离级别为“可重复读”
Q2 使用一致性快照启动一个事务。
Q3 创建一个保存点(savepoint)命名为 sp
。
Q4 是获取最新的表结构信息的
Q5 从表 t1
中检索所有数据。在使用了一致性快照的情况下,将看到事务启动时的数据库状态。
Q6 将事务回滚到保存点 sp
,在这里的作用是释放 t1 的 MDL 锁
DDL 从主库传过来的时间按照效果不同:
- 如果在 Q4 语句执行之前到达,现象:没有影响,备份拿到的是 DDL 后的表结构。
- 如果在“时刻 2”到达,则表结构被改过,Q5 执行的时候,报 Table definition has changed, please retry transaction,现象:mysqldump 终止;
- 如果在“时刻 2”和“时刻 3”之间到达,mysqldump 占着 t1 的 MDL 读锁,binlog 被阻塞,现象:主从延迟,直到 Q6 执行完成。
- 从“时刻 4”开始,mysqldump 释放了 MDL 读锁,现象:没有影响,备份拿到的是 DDL 前的表结构。
行锁
MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁。
行锁:针对数据表中行记录的锁。比如事务 A 更新了一行,而这时候事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。
两阶段锁
在下面的操作序列中,事务 B 的 update 语句执行时会是什么现象呢?假设字段 id 是表 t 的主键。
事务 A 持有的两个记录的行锁,都是在 commit 的时候才释放的。事务 B 的 update 语句会被阻塞,直到事务 A 执行 commit 之后,事务 B 才能继续执行。
两阶段锁协议:在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。
如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。
有一业务需要涉及到以下操作:
- 从顾客 A 账户余额中扣除电影票价;
- 给影院 B 的账户余额增加这张电影票价;
- 记录一条交易日志。
要完成这个交易,需要 update 两条记录,并 insert 一条记录。为了保证交易的原子性,要把这三个操作放在一个事务中。那么如何安排三个语句在事务的顺序呢?
试想如果同时有另外一个顾客 C 要在影院 B 买票,那么这两个事务冲突的部分就是语句 2 了。因为它们要更新同一个影院账户的余额,需要修改同一行数据。
所以,如果把语句 2 安排在最后,比如按照 3、1、2 这样的顺序,那么影院账户余额这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。
死锁和死锁检测
调整语句顺序并不能完全避免死锁,所以引入了死锁和死锁检测的概念。
死锁:当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态。
事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。
事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略:
- 直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
- 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
死锁检测要耗费大量的 CPU 资源。
假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。
虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,会看到 CPU 利用率很高,但是每秒却执行不了几个事务。
怎么解决由热点行更新导致的性能问题呢?
-
一种头痛医头的方法,就是如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。
-
控制并发度:
并发控制要做在数据库服务端。如果你有中间件,可以考虑在中间件实现;如果你的团队有能修改 MySQL 源码的人,也可以做在 MySQL 里面。
基本思路:对于相同行的更新,在进入引擎之前排队,则在 InnoDB 内部就不会有大量的死锁检测工作了。
问:如果你要删除一个表里面的前 10000 行数据,有以下三种方法可以做到:
- 第一种,直接执行 delete from T limit 10000;
- 第二种,在一个连接中循环执行 20 次 delete from T limit 500;
- 第三种,在 20 个连接中同时执行 delete from T limit 500。你会选择哪一种方法呢?为什么呢?
答:
第二种方式是相对较好的。
-
第一种方式(即:直接执行 delete from T limit 10000)里面,单个语句占用时间长,锁的时间也比较长;
而且大事务还会导致主从延迟。
-
第三种方式,会人为造成锁冲突。