《MySQL实战45讲》——学习笔记06-07 “全局锁、表锁、行锁“

根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类;下面这两篇文章不会涉及锁的具体实现细节,主要介绍的是碰到锁时的现象和其背后的原理;

06 | 全局锁和表锁 :给表加个字段怎么有这么多阻碍?

全局锁

全局锁就是对整个数据库实例加锁;此状态下,其他线程的以下语句会被阻塞:数据更新语句(DML)(数据的增删改)、数据定义语句(DDL)(包括建表、修改表结构等)和更新类事务的提交语句;

MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL);当需要让整个库处于只读状态的时候,可以使用这个命令,全局锁的典型使用场景是,做全库逻辑备份,在备份过程中整个库完全处于只读状态;

备份为什么要加锁呢?

考虑一种场景:先备份用户的账户,例如金额为200,然后此时发生购买行为,用户多了一件商品,金额变成100,但是金额已经备份成了200,接下来急需备份用户的商品,新购买的商品被加入备份,导致最终备份结果呈现的逻辑为"用户凭空多了一件商品";

能否不加全局锁?

加表锁导致的问题还是挺大的:如果在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;如果在从库上备份,那么备份期间从库不能执行主库同步过来的 binlog,会导致主从延迟

上述备份过程中逻辑错误的原因:系统备份得到的库不是一个逻辑时间点,这个视图是逻辑不一致的;其实是有一个方法能够拿到一致性视图的——可重复读隔离级别;

官方自带的逻辑备份工具是mysql dump;当mysql dump使用参数–single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图;而由于MVCC的支持,这个过程中数据是可以正常更新的;

有了mysql dump这个备份工具,为什么还需要 FTWRL 呢?

single-transaction 方法只适用于所有的表使用事务引擎的库;如果有的表使用了不支持事务的引擎,那么备份就只能通过 FTWRL 方法;这往往是 DBA 要求业务开发人员使用 InnoDB 替代 MyISAM 的原因之一;

既然要全库只读,为什么不使用 set global readonly=true 的方式呢?

确实 readonly 方式也可以让全库进入只读状态,但还是建议 FTWRL 方式,主要有两个原因:

(1)在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库;因此,修改 global 变量的方式影响面更大,不建议使用;

(2)在异常处理机制上有差异;如果执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态;而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高;

表级锁

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

  1. 表锁

表锁的语法是lock tables…read/write;与FTWRL类似,可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放;在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式;

表锁读写的示例

lock tables语法除了会限制别的线程的读写,也限制当前线程;示例如下:

case1:线程A中执行lock tables t1 read,即在线程A中对t1表上加读锁;

结论为:
(1)对于其他线程,进行读t1时,可以正常读,原因是读锁是共享锁,线程A对表t1加上read lock后,其他线程可以拿到表t1的读锁,因此其他线程可以读表t1;
(2)对于其他线程,进行写t1时,出现了阻塞情况,原因是当前t1表的锁被线程A持有,其他线程拿不到表t1的写锁;
(3)对于当前线程A,进行写t1时,出现了阻塞情况,原因是当前t1表的读锁未被释放,无法进行写操作;
(4)对于当前线程A,进行读其他表时,出现了阻塞情况,原因是线程A对表t1加了读锁,在未解锁前,不能操作其他表;

case2:线程A中执行lock tables t1 write,即在线程A中对t1表上加写锁;

(1)对于其他线程,进行读和写t1时,出现了阻塞情况,原因是由于写锁是排他锁,线程A对mylock表加锁且未释放时,其他线程(会话)拿不到表t1的锁,是不能对表t1进行更新操作的;
(2)对于当前线程A,可以对表t1进行读和写操作;即线程A对表t1加写锁,线程A对对表t1的读写操作都执行正常;
(3)对于当前线程A,进行读其他表时,出现了阻塞情况,原因是线程A中表t1的写锁并未被释放,无法对其他表CRUD,即除了锁这张表t1,还锁了当前线程A;

因此,对于InnoDB这种支持行锁的引擎,一般不使用lock tables命令来控制并发,毕竟表锁的影响面还是太大,既限制其他线程也限制当前线程;

参考:mysql之表锁 - 走看看

尽量避免使用表锁

表锁一般是在数据库引擎不支持行锁的时候才会被用到的;

如果你发现你的应用程序里有lock tables这样的语句,比较可能的情况是:要么是你的系统现在还在用MyISAM这类不支持事务的引擎,那要安排升级换引擎;要么是你的引擎升级了,但是代码还没升级;

最终,需要业务开发把lock tables和unlock tables改成begin和commit语句,问题就解决了;

  2. 元数据锁

另一类表级的锁是MDL(metadata lock);MDL不需要显式使用,在访问一个表的时候会被自动加上;MDL的作用是,保证读写的正确性;

你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的;因此,在MySQL 5.5版本中引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁

(1)读锁之间不互斥,因此可以有多个线程同时对一张表增删改查;

(2)读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性;

因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行;

修改表结构的示例

来看一下下面的操作序列,假设表t是一个小表;备注:这里的实验环境是MySQL5.6;

f433dc24620d40cb987d620182518225.png

事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放;

1. sessionA先启动,这时候会对表t加一个MDL读锁;注意sessionA开启了事务,但是事务还未提交;
2. 由于sessionB需要的也是MDL读锁,因此可以正常执行;
3. 之后sessionC会被blocked,是因为sessionA的MDL读锁还没有释放,而sessionC需要MDL写锁,因此只能被阻塞;
4. 最重要的一点:如果只有sessionC自己被阻塞还没什么关系,但是之后所有要在表t上新申请MDL读锁的请求也会被sessionC阻塞

为什么呢?为什么sessionC后面的命令不能先去获取MDL读锁呢?因为MYSQL有类似维护命令的队列来避免并发下的锁竞争;(个人理解,类似JAVA的AQS框架,避免羊群效应)

052dd5ffdd044f9b8885b7301d8f5596.png

我们知道对表的增删改查操作都需要先申请MDL读锁,但由于sessionC的原因,导致后面的操作都被阻塞,等于这个表现在完全不可读写了;如果某个表上的查询语句频繁,哪怕这个表的数据量不大,而且客户端有重试机制,也就是说超时后会再起一个新session再请求的话,这个库的线程很快就会爆满;

Online DDL?

e87cfb28160d4ffa84261602b5f3a6c9.png

如何安全地给小表加字段?

从两个角度出发:首先让DDL之前的事务能尽快完成,避免长事务,从而降低执行DDL而要获取MDL的等待时间;另一方面就是为执行DDL尝试获取MDL写锁一个合理的超时时间,避免阻塞时间过长,尤其是迸发较高的场景下,拿不到MDL写锁也不要过久的阻塞后面的业务语句,先放弃,再重试;

07 | 行锁功过:怎么减少行锁对性能的影响?

行锁

MySQL的行锁是在引擎层由各个引擎自己实现的;但并不是所有的引擎都支持行锁,比如MyISAM引擎就不支持行锁;不支持行锁意味着并发控制只能使用表锁,这就会影响到业务并发度,如同一张表上任何时刻只能有一个更新在执行;

InnoDB是支持行锁的,这也是MyISAM被InnoDB替代的重要原因之一;

顾名思义,行锁就是针对数据表中行记录的锁;这很好理解,比如事务A更新了一行,而这时候事务B也要更新同一行,则必须等事务A的操作完成后才能进行更新;

  1. 两阶段锁

举个例子说明行锁是何时添加的,又是何时释放的;在下面的操作序列中,事务B的update语句执行时会是什么现象呢?假设字段id是表t的主键;

行锁的实现是通过给索引上的索引项添加锁实现的,故只有当执行的脚本走索引时,innodb才会使用行锁,否则innodb只能使用元数据锁MDL,两种锁均无需显示添加;

1b47c10a76da45c2b498942cfd9539a4.png

结论:实际上事务B的update语句会被阻塞,直到事务A执行commit之后,事务B才能继续执行;

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

这里"在需要的时候才加上"的意思是指:事务中,行锁是在语句执行时才加上的,不是事务开始就加上,但释放是统一在事务结束时才释放;

根据这个特性,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放,让不会引起锁冲突的语句提前执行,从而尽可能的减少锁等待的时间,提高并发性能;

按照上面的建议修改SQL语句顺序后,可能这并不能完全解决你的困扰;因为当并发量起来后,瞬时流量一打进来,你的MySQL可能就挂了;你登上服务器一看,CPU消耗接近100%,但整个数据库每秒就执行不到100个事务;这是什么原因呢?

这里,我就要说到死锁和死锁检测了;

  2. 死锁和死锁检测

用数据库中的行锁举个例子来说明什么是死锁:

c53b097e84c54c4cae8f4ffe29341e8a.png

如图,事务A先占用id=1的行锁,然后事务B占用id=2的行锁;接着事务A在等待事务B释放id=2的行锁,而事务B在等待事务A释放id=1的行锁;事务A和事务B在互相等待对方的资源释放,就是进入了"死锁"状态;

当出现死锁以后,有两种策略:

(1)一种策略是,直接进入等待,直到超时;这个超时时间可以通过参数innodb_lock_wait_timeout来设置;在InnoDB中,innodb_lock_wait_timeout的默认值是50s;

(2)另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行;innodb_deadlock_detect的默认值本身就是on,表示开启这个逻辑;

如果采用第一个策略,innodb_lock_wait_timeout的值不好确定;如果使用默认值50s,意味着第一个被锁住的线程要过50s才会超时退出,然后其他线程才有可能继续执行;对于在线服务来说,这个等待时间往往是无法接受的;但是,我们又不可能直接把这个时间设置成一个很小的值,比如1s;这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待,那就把原本能完成执行的线程给"误杀"了;

所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测;主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的;因为,对每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是O(n^2)的操作,这会要消耗大量的CPU资源;因此,你就会看到上面描述的"CPU利用率很高,但是每秒却执行不了几个事务";

  3. 怎么解决由这种热点行更新导致的性能问题呢?

问题的症结在于——死锁检测要耗费大量的CPU资源,因此有以下两种思路:

(1)既然思索那么耗CPU,如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉

这种操作本身带有一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的;而关掉死锁检测意味着可能会出现大量的超时,这是业务有损的;因此不推荐此方案;

(2)出现问题的原因是热点行的更新,也就是并发太大,那么就去做并发控制;

比如同一行同时最多只有10个线程在更新,那么死锁检测的成本很低,就不会出现CPU占用过高问题;

(3)还有一种思路,类似Redis的热点key解决方案,就是通过冗余的思路,将这一个热点行"拆成"多行数据,降低锁冲突的几率;

还是以影院账户为例,可以考虑放在多条记录上,比如10个记录,影院的账户总额等于这10个记录的值的总和;这样每次要给影院账户加金额的时候,随机选其中一条记录来加;这样每次冲突概率变成原来的1/10,可以减少锁等待个数,也就减少了死锁检测的CPU消耗;这个方案看上去是无损的,但其实这类方案需要根据业务逻辑做详细设计;如果账户余额可能会减少,比如退票逻辑,那么这时候就需要考虑当一部分行记录变成0的时候,代码要有特殊处理;

  题目:如何删数据?

如果你要删除一个表里面的前10000行数据,有以下三种方法可以做到:

  • 第一种,直接执行delete from T limit10000;
  • 第二种,在一个连接中循环执行20次delete from T limit 500;
  • 第三种,在20个连接中同时执行delete from T limit 500;

你会选择哪一种方法呢?为什么呢?

答案:方案二;

方案一,事务相对较长,则占用锁的时间较长,会导致其他客户端等待资源时间较长;

方案二,串行化执行,将相对长的事务分成多次相对短的事务,则每次事务占用锁的时间相对较短,其他客户端在等待相应资源的时间也较短;这样的操作,同时也意味着将资源分片使用(每次执行使用不同片段的资源),可以提高并发性;

方案三,人为自己制造锁竞争,加剧并发量;

  行锁小结

最后对于行锁,做一下小结:

(1)如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁的申请时机尽量往后放;但是,调整语句顺序并不能完全避免死锁;

(2)热点行更新时,死锁检测占用CPU资源较大,减少死锁的主要方,就是控制访问相同资源的并发事务量,无论是通过并发控制或是业务改造将热点行拆分;

下篇文章:《MySQL实战45讲》——学习笔记08 “一致性视图、可重复读实现“

本章参考:

06 | 全局锁和表锁 :给表加个字段怎么有这么多阻碍?-极客时间

07 | 行锁功过:怎么减少行锁对性能的影响?-极客时间

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值