数据库锁设计的初衷是处理并发问题,作为多用户共享的资源,当出现并发访问的时候,数据库要合理地控制资源的访问规则,而锁就是用来实现这些访问规则的重要数据结构。
根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类。
全局锁就是对整个数据库实例加锁,MySQL 提供了一个加全局读锁的方法,命令是Flush tables with read lock(FTWRL)。当你需要将整个库处于只读状态的时候,可以使用这个命令,之后其他线程的一下语句就会被阻塞,数据更新语句(数据的增删改),数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。
首先来看这样一种场景,就是要给数据库做逻辑备份,假设在备份的期间,有一个用户,他购买了“实战课程”,业务逻辑就要扣除他的余额,并在已购课程里面添加这门课程。如果时间顺序上是先备份账户余额表(user_accout),然后用户购买课程,然后备份用户课程表(user_accout)会怎么样呢 ?可以看下图:
上图可以看出最终备份情况,用户的账户余额没扣,反而多了一门课程。然后拿着这个备份来恢复数据,用户发现自己没花钱,反倒拥有一门课。或者说如果备份的表反过来,就是用户余额扣了,反倒没有拥有这门课程,总之这两情况发生都是不对的。我们可以在备份期间给数据库加全局锁,那么用户的购买操作就会被阻塞,就不会发现上面出现的两种问题了。全局锁的典型使用场景是做全库逻辑备份,也就是把整个库的每个表都查出来存成文本。
官方自带的 mysqldump,当 mysqldump 使用参数 -single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图,而由于 MVCC 的支持,这个过程中数据是可以正常更新的。但前提是数据库的引擎要支持事务,如果不支持的话,就要使用上面我们提到的 FTWAL 命令了。
除此之外,set global readonly = true 也可以让全库进入只读状态,但还是建议适用 FTWRL 方式,主要有以下两个原因:
在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此修改 global 变量的方式影响面更大,不建议适用
在异常处理机制上有差异,如执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。
业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。不论是哪种方法,一个库被全局锁上之后,你要对里面任何一个表做加字段操作,都会被锁住的。即使没有被全局锁住,加字段也不是就能一帆风顺的,因为你还会碰到表级锁。
遗留问题:让整库处于只读,听起来就很危险:
如果你在主库上备份,那么备份期间都不能执行更新,业务基本就得停摆
如果你在从库上备份,那么备份期间从库不能执行主库同步过来的 binlog,会导致主从延迟
MySQL 里面表级别的锁有两种:一种是表锁,另一种是元数据锁(meta data lock,MDL)。
表锁的语法是 lock tables。。。read/write,与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。
在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式,而对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是很大。
另一种表级的锁是 MDL,MDL 不需要显示使用,在访问一个表的时候就会被自动加上。MDL 的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对应不上,肯定是不行的。
因此在 MySQL 5.5 之后加入了MDL,党对一个表做增删改查操作的时候,加 MDL 读锁,当对表做结构变更操作的时候,加了 MDL 写锁。
读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查
读写之间、写写之间是互斥的,用来保证变更结构操作的安全性。因此如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
虽然 MDL 锁是系统默认加的,但是却不能忽略一个机制,比如下面一个例子:可能会给一个小表加字段,导致整个库都挂了。
你肯定知道,给一个表加字段、或者修改字段、或者加索引,需要扫描全表的数据。对大表操作的时候,你肯定会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出现问题。
比如sessionA、sessionB、sessionC、sessionD 依次执行,一个 sessionA 先启动,这个时候会对表 t 加一个 MDL 读锁,由于 sessionB 需要的也是 MDL 读锁,读锁之间不互斥,因此可以正常执行。之后 sessionC 会被阻塞,因为事务中的 MDL 锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而是等到事务提交之后再释放。所以 sessionA 读锁还没有释放而 sessionC需要一个写锁,因此只能被阻塞。
如果说 sessionC 自己阻塞了没有关系,但是之后所有要在表 t 上心申请 MDL 读锁的请求也会被 sessionC 阻塞,就等于这个表完全不可读写了。如果某个表上的查询语句频繁,而且客户端有重试机制,超时之后再重启一个 session,这个库的线程很快就会爆满。
基于上面的分析,我们来讨论一个问题,如何安全地给小表加字段 ?
首先要解决长事务,事务不提交,就会一直占着 MDL 锁, 在 MySQL 的 information_schema 库的 innodb_trx 表中,你可以查看当前执行中的事务。如果你要做 DDL 变更的表刚好有长事务在执行,要考虑先暂停 DDL,或者 kill 掉这个长事务。
但考虑一下这个场景,如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,你不得不加字段,你该怎么做呢?这个时候 kill 可能也未必管用,因为新的请求马上就来了。比较理想的机制是,在 alter table 语句里设定等待时间,如果在这个指定时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃,之后开发人员或者 DBA 再通过重试命令重复这个过程。