MySQL中的各种锁
根据加锁的范围,MySQL里面的锁大致可以分成全局锁、表级锁和行锁三类。本节重点讲解一下全局锁以及表锁,InnoDB级别的锁比较特殊,我们单独开一个章节讲解。
全局锁
顾名思义,全局锁就是对整个数据库实例加锁。MySQL提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。
全局锁的使用场景
全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都select出来存成文本。
通过FTWRL确保不会有其他线程对数据库做更新,然后对整个库做备份。注意,在备份过程中整个库完全处于只读状态。
但是让整库都只读,听上去就很危险:
- 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;
- 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。(主库继续执行业务,从库瘫痪,专门用来同步主库信息导致的延迟)。
全局锁的必要性
看来加全局锁不太好。但是细想一下,备份为什么要加锁呢?我们来看一下不加锁会有什么问题。(也就是说备份是,数据库继续用于执行业务)。
现在发起一个逻辑备份。假设备份期间,有一个用户,他购买了一门课程,业务逻辑里就要扣掉他的余额,然后往已购课程里面加上一门课。
如果时间顺序上是先备份账户余额表(u_account),然后用户购买,然后备份用户课程表(u_course),会怎么样呢?你可以看一下这个图:
可以看到,假设主库中的这两次本分是一个原子操作,但由于备份的时间不同步原因,破坏了这个原子性。第一次备份u_account表中数据的时候,用户A是有钱,没有买课的。当同步到u_course表时,此时主库已经把课程买好并且扣钱了。因此备份数据备份u_course表时,会将课程信息成功记录。但是u_account表中的200块此时已经同步过了。所以备份出来的结果就成了,用户一毛钱没花,却拜拜得到了一门课程。
当然,当主库的扣钱与买课顺序颠倒一下的话,用户就有可能变成花了钱,但没买到课的冤大头。
为什么不用MVCC实现备份
如果你对MVCC的可重复读隔离级别有较为深刻的理解的话,那你肯定会奇怪为什么不在做备份时,生成一个当前状态的Read View,这样不就可以保证数据的一致性了?
一致性读是好,但前提是引擎要支持这个隔离级别。比如,对于MyISAM这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用FTWRL命令了。
所以,single-transaction方法只适用于所有的表使用事务引擎的库(比如InnoDB实现的表)。如果有的表使用了不支持事务的引擎,那么备份就只能通过FTWRL方法。这往往是DBA要求业务开发人员使用InnoDB替代MyISAM的原因之一。
业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。不论是哪种方法,一个库被全局锁上以后,你要对里面任何一个表做加字段操作,都是会被锁住的。
表锁
MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。
普通表锁
锁的语法是 lock tables … read/write。与FTWRL类似,可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。
举个例子, 如果在某个线程A中执行lock tables t1 read, t2 write; 这个语句,则其他线程写t1、读写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操作。连写t1都不允许,自然也不能访问其他表。
在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于InnoDB这种支持行锁的引擎,一般不使用lock tables命令来控制并发,毕竟锁住整个表的影响面还是太大。
估计MySQL开发人员最早的时候,绝对一张表能存个几百条数据已经不得了了。但是后面发现一张表中存个几百上千万的数据都不是不可能的。表锁相当于给整个表的所有行加了行锁。
于是,InnoDB的开发人员觉得这个锁的粒度实在太大了。于是,研发了行锁,减小了锁的粒度,提高并发。
MDL(metadata lock)
另一类表级的锁是MDL(metadata lock)。MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。
因此,在MySQL 5.5版本中引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁。
怎么理解MDL读锁和MDL写锁呢。
和并发线程中的读写锁一样,将就读读共享,读写分离这个概念。也就是说,任何增删改查操作之间都是互不影响的。而修改表结构与其他修改表结构,以及对该表的增删改查都是互斥的。
注意:这个锁是默认加的!必须得有。
MySQL事务的公平锁机制
MDL锁机制的产生,让我不禁有一个疑惑。就是只要有读写锁,必然会伴随事务饥饿的问题。所谓事务饥饿:当一个表本身加着MDL读锁,但是不停地有新加入的事务去维持这个读锁,那我MDL写锁岂不是只有永远等着的份儿了?一定要等所有对该表的增删改查操作结束后,才可以获取MDL写锁去修改表结构。
但实际上,这是不存在的。因为MySQL中的事务是以公平锁机制实现的。也就是所有的事务再加MDL锁时都是有序的。我们举下面这个例子来说:
如图,当事务A执行查询时,t表就被加了MDL读锁。事务B也属于增删改查操作,因此不会被阻塞。但是事务C需要加一个MDL写锁,因此只有被阻塞。
按照我原本的思路,新加入的事务D如果是非公平锁机制的话。那么事务D就会立刻获取MDL读锁去执行。然而,实际事务D会被阻塞。这也就证明了MySQL中对MDL加锁的公平性,必须按事务的产生顺序执行锁操作,因此就没有我们所担心的事务饥饿问题。
为什么给一个小表加个字段,导致整个库挂了。
你肯定知道,给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,你肯定会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出问题。我们继续用上面图中的例子,假设表t是一个小表。
假设事务A或者事务B是一个长事务,则就会导致事务C一直等锁。而最严重的后果是,事务C之后的所有对该表的增删改查事务全部被阻塞了!
如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新session再请求的话,这个库的线程很快就会爆满。
你现在应该知道了,事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。
如何安全的给小表加字段?
首先我们要解决长事务,事务不提交,就会一直占着MDL锁。在MySQL的information_schema 库的 innodb_trx 表中,你可以查到当前执行中的事务。如果你要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务。
但考虑一下这个场景。如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而你不得不加个字段,你该怎么做呢?
这时候kill可能未必管用,因为新的请求马上就来了。比较理想的机制是,在alter table语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到MDL写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后开发人员重试命令重复这个过程。
MariaDB已经合并了AliSQL的这个功能,所以这两个开源分支目前都支持DDL NOWAIT/WAIT n这个语法。
ALTER TABLE tbl_name NOWAIT add column ... ALTER TABLE tbl_name WAIT N add column ...
就一直疯狂点,直到MDL写锁成功抢到锁为止!