数据库设计的初衷是处理并发问题的,作为多用户共享的资源,当出现并发访问时,数据库需要合理地控制资源的访问规则。而锁就是用来实现这个访问规则的重要数据结构。
根据加锁的范围,MySQL 里面的锁大致可以分为全局锁、表锁、行锁。这篇我们来学习表锁。
MySQL 里表级锁有两种:
- 表锁
- 元数据锁(meta data lock ,MDL).
表锁
语法结构:lock table 表名字1 read(write),表名字2 read(write);
比如有俩表 t1、t2,给 t1 加读锁,给 t2 加写锁
示例: lock table t1 read,t2 write;
手动释放表锁:unlock tables;
与 FTWRL (Flush tables with read lock )类似,可以用 unlock tables 主动释放表,也可以在客户端断开的时候自动释放。另外,lock tables 语法除了会限制别的线程读写外,还会限制本线程接下来的操作。
表锁就是锁一整张表,在表被锁定期间,其他事务不能对该表进行操作,必须等当前表的锁被释放后才能进行操作。表锁响应的是非索引字段,即全表扫描,全表扫描时锁定整张表。索引字段对行锁才起作用。
表级锁优点
- 开销小,加锁快
- 不会出现死锁
- 锁定力度大,发生锁冲突的概率高,并发度小。
不同的存储引擎支持的锁粒度不一样:
- InnoDB 行锁和表锁都支持,MyISAM 只支持表锁。
- InnoDB 只有通过索引条件检索数据才使用行级锁,否则,InnoDB 将使用表锁。InnoDB 的行锁是基于索引的。
表锁的模式
- 表读锁(table read lock)
- 表写锁(table write lock)
在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大。因为 InnoDB 有行锁,一般进行行锁。
元数据锁(MDL)
元数据锁不需要显式的使用,在访问一个表的时候会自动加上。
MDL 的作用是保证读写的正确性。
我们可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。
因此,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。
- 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
- 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
即在表的读锁和写锁的模式下:读读不阻塞,读写阻塞,写写阻塞。
- 读读不阻塞:当前线程在读数据时,其他线程也可读数据,不会加锁,不会发生阻塞。
- 读写阻塞:当前线程在读数据时,其他线程不能修改当前线程读的数据,会加锁,发生阻塞。
- 写写阻塞:当前线程在修改数据时,其他线程不能修改当前线程正在修改的数据,会加锁,发生阻塞。
举个栗子, 如果在某个线程 A 中执行 lock tables t1 read, t2 write; 这个语句,则其他线程写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能访问其他表。
MDL 会直到事务提交才释放,在做表结构变更的时候,我们一定要小心不要导致锁住线上查询和更新。
虽然 MDL 锁是系统默认会加的,但却是你不能忽略的一个机制。比如下面这个例子,经常会有人掉到这个坑里:给一个小表加个字段,导致整个库挂了。
我们知道,给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,我们都会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出问题
下面再看一个栗子:
session 1 | session 2 | session 3 | session 4 |
begin; select * from t limit 1; | |||
select * from t limit 1; | |||
alert table t add f int; (blocked) | |||
select * from t limit 1; (blocked) |
- 我们可以看到 session 1 先启动,这时候会对表 t 加一个 MDL 读锁。由于 session 2 需要的也是 MDL 读锁,因此可以正常执行。
- 之后 session 3 会被 blocked,是因为 session 1 的 MDL 读锁还没有释放,而 session 3 需要 MDL 写锁,因此只能被阻塞。
- 如果只有 session 3 自己被阻塞还没什么关系,但是之后所有要在表 t 上新申请 MDL 读锁的请求也会被 session 3 阻塞。前面我们说了,所有对表的增删改查操作都需要先申请 MDL 读锁,就都被锁住,等于这个表现在完全不可读写了。
- 如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新的 session 请求进来的话,这个库的线程很快就会爆满。
我们现在应该知道了,事务中的 MDL 锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。
如何安全地给小表加字段?
我们现在可以再来看看如何给小表加字段了:
首先我们要解决长事务的问题,事务不提交就会一直占着 MDL 锁。在 MySQL 的 information_schema 库的 innodb_trx 表中,我们可以查到当前执行中的事务,如果有长事务在执行,要考虑先暂停 DDL ,或者 kill 掉这个长事务。
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
这里 trx_id 就是事务的 id, 就能看到长事务是哪个了。
trx_started 表示这个事务开始执行的时间点。
我们再来考虑一下这个场景:
如果你要变更的表是一个热点表,虽然数据量不大,但是过来的请求很频繁,而你的这个表又不得不加字段,这时候我们能怎么做呢?
这时候通过 kill 事务未必管用了,因为新的请求马上就进来了。比较理想的机制是,在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里,能够拿到 MDL 写锁最好,若拿不到也不能阻塞后面的业务,应该先放弃。之后再通过重试命令重复这个过程。
比如:
ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ...
一个线上踩坑的栗子
系统上线后,进入 APP 的几个业务都是需要对用户表进行操作的,结果在执行添加表字段的过程中,大量的接口超时,过一会就提示数据库连接不够。
而最初定位问题的时候以为是服务增加了节点导致的数据库连接不够了(因为本次上线将之前 10 几个服务的节点从 2 个节点增加到 3 个节点),所以把每个节点的数据库连接池配置都改小了,但是问题依旧存在。
后面调整方向从出现问题的接口上找规律,结果找到规律就是出问题的接口都是用户相关的接口,最后关联到当天对用户表增加了新的字段,就把方向定位到数据库,结果 DBA 说数据库有长时间的锁等待,经过排锁就发生在用户信息表的 MDL 锁上。