对码当歌,猿生几何?

(10)数据库的各种锁:全局锁和表锁和行锁

根据加锁的范围, MySQL 里面的锁大致可以分成全局锁、表级锁和行锁、间隙锁四类,这篇文章主要介绍全局锁和表级锁、行锁

全局锁

全局锁就是对整个数据库实例加锁。 MySQL 提供了一个加全局读锁的方法,命令是Flush tables with read lock (FTWRL) 。这样就让整个库处于只读状态,之后其他线程以下语句会被阻塞:

  • 数据定义语言DDL(Data Definition Language),包括建表,定义修改表结构等

  • 数据操纵语言DML(Data Manipulation Language)  包括数据的增删改、插入等

全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。但是加全局锁有两个弊端:

  • 在从库上备份,那么从库不能执行主库同步过来的 binlog,导致主从延迟

  • 如果你在主库上备份,那么在备份期间都不能执行更新

为了克服加全局锁的弊端。官方自带的逻辑备份工具是 mysqldump使用参数 –single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。然而一致性读是好,但前提是引擎要支持这个隔离级别。所以, single-transaction 方法只适用于所有的表使用事务引擎的库

比如,对于 MyISAM 这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用 FTWRL 命令了。

表级锁

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁( meta data lock , MDL) 。

表锁的语法是 lock tables … read/write 。 可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。

 lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

举个例子 ,  如果在某个线程 A 中执行 lock tables t1 read, t2 write;  这个语句,则其他线程写 t1 、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1 、读写 t2 的操作。连写 t1 都不允许,自然也不能访问其他表。

所以而对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大。

那么在 MySQL 5.5 版本中引入了 另外一类的表级锁,是InnoDB使用的。另一类表级的锁是 MDL 元数据锁( metadata lock) 。 MDL 不需要显式使用,在访问一个表的时候会被自动加上,是系统默认加上的

当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

MDL读锁和写锁的关系如下所示

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。

  • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

而经常有例子是这样的:给一个小表加个字段,导致整个库挂了。

 session C 需要MDL写锁,会被 blocked ,是因为 session A 的 MDL 读锁还没有释放。但是之后所有要在表 t 上新申请 MDL 读锁的请求也会被 session C 阻塞,因为所有对表的增删改查操作都需要先申请 MDL 读锁,就都被锁住,等于这个表现在完全不可读写了。

解决这个问题的理想机制是,在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。

也就是说,事务中的 MDL 锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。在做表结构变更的时候,你一定要小心不要导致锁住线上查询和更新。

表锁一般是在数据库引擎不支持行锁的时候才会被用到的。如果你发现你的应用程序里有 locktables 这样的语句,你需要追查一下,比较可能的情况是:

  • 要么是你的系统现在还在用 MyISAM 这类不支持事务的引擎,那要安排升级换引擎;

  • 要么是你的引擎升级了,但是代码还没升级。我见过这样的情况,最后业务开发就是把 locktables  和 unlock tables  改成 begin  和 commit ,问题就解决了。

查询不返回:等表锁

有一个表有两个字段 id 和 c ,并且在里面插入了 10 万行记录。在表 t 执行下面的 SQL 语句:

 select * from t where id=1;

查询结果长时间不返回。一般碰到这种情况的话,大概率是表 t 被锁住了。首先执行一下 show processlist 命令, 处于Waiting for table metadata lock 状态

出现 这个状态表示的是,现在有一个线程正在表 t 上请求或者持有 MDL 写锁,把 select 语句堵住了。例如:session A  通过 lock table 命令持有表 t 的 MDL 写锁,而 session B 的查询需要获取 MDL 读锁。所以, session B 进入等待状态。

这类问题的处理方式,就是找到谁持有 MDL 写锁,然后把它 kill 掉。

通过查询 sys.schema_table_lock_waits 这张表,我们就可以直接找出造成阻塞的 process id ,把这个连接用 kill  命令断开即可。

 

行锁的两阶段锁协议

 

在上一篇文章中,介绍了 MySQL 的全局锁和表级锁。同时我们也提到,不是所有的引擎都支持行锁,比如MyISAM 引擎就不支持行锁。

不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,会影响到业务并发度。 InnoDB 是支持行锁的,
这也是 MyISAM 被 InnoDB 替代的重要原因之一。

与MDL类似,行锁也分两种:读锁和写锁

注意经常读锁也被叫做共享锁(S),写锁被叫为排它锁(x)

也就是说,跟行锁有冲突关系的是 “ 另外一个行锁 ” 。

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

也就是说,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放

例如我们维护一个电影院购票业务:

1.  从顾客 A 账户余额中扣除电影票价;
2.  给影院 B 的账户余额增加这张电影票价;
3.  记录一条交易日志。

假如另外一个顾客 C 要在影院 B 买票,那么这两个事务冲突的部分就是语句 2 了,因为它们要更新同一个影院账户的余额,需要修改同一行数据。

按照 3 、 1 、 2 这样的顺序,那么影院账户余额这一行的锁时间就最少,减少了事务之前的等待

再看一个两阶段锁协议与事务隔离结合的例子,使用的仍然是上个例子的图,只不过事务C不是马上提交的,而是变成了下面的事务 C’ 

 

虽然事务 C’ 还没提交,但是 (1,2) 这个版本也已经生成了,并且是当前的最新版本。但是(1,2)这个版本上的写锁还没释放。

而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务 C’ 释放这个锁,才能继续它的当前读。

还有一种显式加行锁的方法:

  • 共享锁或读锁(S):SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE

  • 排他锁或写锁(X) :SELECT * FROM table_name WHERE ... FOR UPDATE