作者:benjamim,腾讯TEG后台开发工程师
背景
在上一篇《浅谈MySQL数据可靠性》的文章中,简单地聊了一下锁在实现数据可靠性中的具体作用,这篇文章我想继续来聊聊mysql的锁是怎么加上的,为啥想聊这个呢?主要是因为业务中我们或多或少都会使用到锁,毕竟锁是保障我们数据安全性的关键法宝。 但是由于不了解原理,往往可能导致我们在”刻意“或者”无意“的使用场景下,带来潜在的性能问题,轻则导致处理能力降低,重则可能会拖垮我们的DB,因此需要对锁的原理以及使用场景有比较全面的了解,才能更好地驾驭,避免给我们带来不必要的业务隐患。我们主要从三个方面来讨论这个问题:
显式锁
两者的区别在于前者加的是排它锁,后者加的是共享锁。加了排他锁之后,后续对该范围数据的写和读操作都将被阻塞,另外一个共享锁不会阻塞读取,而是阻塞写入,但是这往往会带来一些问题,比如电商场景下更新库存时候,我们为了保障数据的一致性更新往往需要先将该商品数据锁住,如果此时两个线程并发更新库存,就可能会导致数据更新出现异常,如图所示:
所以我们在业务上往往会使用select ... for update对数据进行加锁。另外还有些咱们比较不常用的加锁方式,比如
隐式锁是我们需要特别关注的,因为很多的”坑“就是因为隐式锁的存在导致的,无形往往最为致命。
表级锁除了表锁以外,还有元数据锁,
这个会带来的问题就是当我们想给表添加索引或者修改表结构的时候,由于加了MDL写锁,会阻塞我们线上正常的读写请求,这个时候可能会触发上游的失败重试机制,那很可能就会出现请求雪崩导致DB被打挂。
另外的就是与我们日常业务息息相关行锁以及间隙锁,当我们在进行增删改的时候,会根据当前的隔离级别加上行锁或者间隙锁,那么这时候需要注意的是是否会影响正常业务的读写性能,另外带来的风险就是可能出现加锁范围过大阻塞请求,并触发上游重试,导致服务雪崩,DB打挂。
会不会加锁呢?
谈到这里有的同学可能有疑问,你这增删改都加锁了,那我读的时候岂不是性能很差,特别是在读多写多的业务场景下,我的读请求一上来的话,DB不是分分钟被我查挂了?其实这里innodb引擎用到了一个mvcc的技术即多版本并发控制,其原理就是在数据更新的同时在undolog中记录更新的事务id以及相应的数据,并且维护一个readview的活跃事务id,这样当一个事务执行的时候,很容易能知道自己能看见什么数据,不能看见什么数据,这时候读取数据自然也就不会受到锁的影响能够正常的读取啦。
怎么加
这里讨论怎么加其实就是了解加锁的类型以及范围,即用了什么锁且加在哪里了?在讨论这个问题之前我们先来看看事务隔离级别:
为啥要说这个呢?因为隔离级别也影响着咱们的加锁,读已提交解决了脏读的问题,但是未解决幻读问题;可重复读通过引入间隙锁解决了幻读问题,因此意味着不同的隔离级别用到的锁还不一样,但是有一点明确的是,越高隔离级别锁的使用更加严格。可重复读是默认的事务隔离级别,但是线上设置的隔离级别往往都是读已提交,主要是因为这个级别够用并且能够有更好的并发性能。接下来我们讨论的范围也主要是在读已提交(RC)和可重复读(RR)。
这里根据《mysql45讲》总结的规则来具体分析:
另外有两点需要注意的是:
接下来是分别进行讨论,可能有些冗长,需要你耐心看完。
首先是RC级别,这个级别下的加锁规则是比较简单的,因为只涉及到行锁,首先我们先设计一张表
CREATE TABLE `t_db_lock` ( `id` int(11) NOT NULL, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `a` (`a`)) ENGINE=InnoDB;insert into t_db_lock values(0,0,0),(5,5,5),(10,10,10); 主键等值存在
sessionA |
sessionB |
sessionC |
begin;update t_db_lock set id=id+1 where id = 0; |
||
insert into t_db_lock values(1,1,1) [block] |
||
update t_db_lock set id=id+1 where id = 5;[success] |
sessionA |
sessionB |
sessionC |
begin;update t_db_lock set b=b+1 where a = 0; |
||
update t_db_lock set b=b+1 where id = 0; [block] |
||
update t_db_lock set b=b+1 where b = 0;[block] |
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where id = 2 for update; |
||
update t_db_lock set b=b+1 where a = 0; [success] |
||
update t_db_lock set b=b+1 where b = 0;[success] |
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where b=3 for update; |
||
update t_db_lock set b=b+1 where a = 0; [success] |
||
update t_db_lock set b=b+1 where b = 5;[success] |
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where id >= 0 and id <= 5 for update; |
||
update t_db_lock set b=b+1 where a = 0; [block] |
||
insert into t_db_lock values(1,1,1) [success] |
这里可重复读级别下主要是讨论间隙锁的加锁场景,这种加锁情况会比读已提交的隔离级别复杂的多; set session transaction isolation level repeatable read;
主键等值存在
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where id = 5 for update; |
||
insert into t_db_lock values(2,2,2); [success] |
||
insert into t_db_lock values(6,6,6); [success] |
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where a = 5 for update; |
||
insert into t_db_lock values(6,11,6); [block] |
||
insert into t_db_lock values(11,10,6); [success] |
这里可以看到,对于非唯一等值查询的情况下,加锁的范围要比主键等值存在更大,因此我们在对非唯一索引加锁的时候需要注意这个范围。
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where id = 3 for update; |
||
insert into t_db_lock values(2,2,2); [block] |
||
insert into t_db_lock values(6,6,6); [success] |
为啥这里要加的是范围锁呢,其实主要解决的是幻读问题,假设这里如果没有在此范围内加锁,那么T1时刻sessionB执行成功,T2时刻再次执行select * from t_db_lock where id = 3的话,就会发现原先查询不到的结果现在竟然可以查询到了,就像出现幻觉一样;因为为了避免出现这种幻读的情况,需要在此范围内加锁。
非唯一等值不存在
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where a = 3 for update; |
||
insert into t_db_lock values(3,5,5); [block] |
||
insert into t_db_lock values(6,5,5); [success] |
sessionA |
sessionB |
sessionC |
select * from t_db_lock where id >= 5 and id < 6 for update; |
||
insert into t_db_lock values(3,3,3); [success] |
||
insert into t_db_lock values(10,10,10); [block] |
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where a >= 5 and a < 6 for update; |
||
insert into t_db_lock values(3,3,3); [block] |
||
insert into t_db_lock values(10,10,10); [block] |
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where b = 6 for update; |
||
insert into t_db_lock values(3,3,3); [block] |
||
insert into t_db_lock values(10,10,10); [block] |
通过上述的分析我们应该对锁的类型以及语句中加锁的范围有一个大致的了解,可以知道悲观锁是需要我们谨慎使用的,因为很可能简单的sql就会拖垮db的性能,影响线上服务的质量,那么什么时候该加什么时候不该加呢?
我认为对于db的并发场景,我们可以这么去考虑: