MySQL锁技术详解

Mysql锁技术详解
本文只讨论InoDB存储引擎下的锁。

前言
在分布式并发的场景下,对于共享资源的操作是非原子性的,这会造成操作和预期的结果并不一致。

原子性操作 :指在一次CPU的调度时间类完成的一系列操作,顺序不可打乱,也不可只执行一部分。

任何可能能被CPU打断的操作都不是原子操作,所以真正的原子操作需要硬件支持,但是硬件大多数只支持系统的核心方法的原子操作,所以如果想要在自己开发的程序里做原子操作,需要引入锁。在线程A操作共享资源时需要拿到锁,这样即便线程A的操作中途CPU切换到了线程B,线程B想要读取共享资源时缺拿不到锁,所以线程B无法操作共享资源,这样就模拟出了原子操作,保证了线程A的逻辑是完整正确的。

MySQL的事务具有原子性,所以MySQL肯定实现了许多锁来保证原子操作。以InoDB为例,来看看MySQL实现了哪些锁,这些锁又分别有什么作用。

根据锁的范围来分类,大致分为了三类锁:

全局锁
表锁
行锁

全局锁
顾名思义,全局锁就是对整个数据库实例都加上锁,命令行是 Flush tables with read lock,一旦数据库加上此锁,除了当前操作线程之外,其他的线程对数据库的增删改操作都会被阻塞,包括建表,修改表结构事务更新等操作。

全局锁一般用于数据备份,为了保证备份的一致性,加上全局锁确保备份时间类数据库里的数据不会有更新。

备份时如果不加锁会出现数据不一致,这种不一致不仅仅是增量的,假设表中有两个字段用户余额amount,用户购买的产品product:

id    amount    product
1    100    商品A
假设此时我们在做全库备份,并且我们没有给数据库加上全局锁。此时用户花了50元购买了商品B,那么我们需要将amout修改为50,product中加入商品A,那么这就会有问题了。

加入备份线程已经备份了amount为100,然后CPU切换了线程,执行了用户购买商品B的操作,那么原库的数据现在是这样的:

id    amount    product
1    50    商品A,商品B
而备份的数据是这样的:

id    amount    product
1    100    尚未备份
接下来CPU切换回了备份线程,备份了product字段,那么备份完成之后,备份表的字段:

id    amount    product
1    100    商品A,商品B
显然,备份数据与原库的数据不一致了,这就是备份时不加全局锁问题所在。所以全局锁虽然会影响业务,但是如果备份时不加,可能造成数据不一致,根据业务需求可以自己判断是否添加。

表级锁
表级锁就是加在一整张表上的锁,在MySQL中,表级锁分为了两种

表锁
元数据锁(MDL)

表锁
表锁需要显示的声明,加锁的语句是:

lock tables tablename read/write
表锁分为了读写两种,例如执行了 lock table t1 read,表示将t1表的权限变为只读,其他线程对t1的写入都会阻塞。同理 lock table t1 write 会让t1表变为只可写,不可读。

释放表锁语句:

unlock tables

元数据锁(MateDataLock)
与表锁不同,元数据锁不需要我们显示的声明,当有某一个sql语句访问到了表时会自动被加上。MDL的作用是为了保证读写的正确性,该锁在Mysql5.5+才有。

MDL也分为了读锁和写锁,读锁和读锁之间不互斥,但是写锁和读写锁之间都互斥,换言之,可以有多个线程对该表进行读操作,但是不能同时进行读写。

事务A    事务B    事务C
select * from t1        
update a = 1 where id = 1    
select * from t1
上面这种操作在事务A读t1表的时候就会给t1加上MDL读锁,假如事务A还未提交,事务B执行了更新表t1的操作,由于更新需要写锁,写锁和读锁是互斥的,此时事务B会被阻塞。而事务C虽然需求读锁,与表上的读锁不冲突,但是由于中间有事务B在等待锁释放,所以事务C也会被阻塞。

疑问 :为什么此时表上是读锁,而事务C也需求的也是读锁却无法执行呢。
因为MySQL的设计为了防止更新语句无限等待写锁而“饿死”。假设事务B之后还有许多个select * from t1这样需求读锁的事务,如果不阻塞事务B之后的事务,因为可能不停的有需求读锁的查询语句在执行, 那么表上可能一直存在读锁。这样所有的更新语句都永远获取不到写锁,统统会被“饿死”。所以一旦出现写锁等待,那么之后的事务全部会被阻塞。

根据上面的知识,为了避免数据库阻塞时间太长,提高数据库的性能,业务中尽量不要使用长事务!

行级锁
行锁并非由MySQL实现的,而是存储引擎实现的锁,例如MyIsam就没有实现行锁,所以并不支持事务。而InoDB实现了行锁,所以InoDB可以支持事务。

MySQL提供了对外的数据读写接口,任何实现了接口的存储引擎都可以接入MySQL,行锁是存储引擎层实现的,MySQL的Server层并没有实现,也就是说,MySQL并非一定有行锁。

InoBD的行锁分为两种:

行锁
间隙锁(Gap Lock)

行锁
在InoDB的事务中,行锁是在执行sql的时候才会加上的。

事务A    事务B    事务C
update t1 set a++ where id = 1        
update t1 set a++ where id = 2        
update t1 set a = 1where id = 1    
update t1 set a = 1where id = 2
commit;        
如上图,假如有三个事务需要对t1表锁id=1和id=2的记录做更新操作,事务A在执行两条update语句的时候就已经给这两行记录加上了行锁,事务B和事务C执行的时候会阻塞住,知道事务Acommit之后释放了这两行记录的行锁,事务B和事务C才可以继续执行,这就是事务的两阶段锁协议。

两阶段锁协议:事务的加锁和释放锁分为两个阶段,
扩展阶段:加锁的阶段。
收缩阶段:释放锁的阶段。
任意两个阶段的操作都不会穿插,意味着,扩展阶段只存在加锁操作,收缩阶段只存储释放锁的操作。在事务执行的时候,只会加上行锁,不会存在释放锁的操作。只有当事务commit之后,进入收缩阶段,才会开始释放锁,同样的在这个阶段不存在任何的加锁操作了。这就是两阶段锁协议。

了解了两阶段锁协议,就可以对事务进行一些优化。首先,事务中如果有许多sql语句,那么更新的语句如果可以往后放,那么尽量放在最后,也就是越靠近事务commit越好,这样行锁阻塞的时间会越短,数据库性能越好。

即便这样每次只能减少毫秒/微秒级的阻塞时间,虽然对人来讲几乎是一瞬间的时间,但是对于数据库来说也是不错的提升。

除此之外,熟悉两阶段锁的定义,知道事务何时加锁何时释放,在业务逻辑中将不同的update语句处理的顺序不同,也可以很大的提升数据库的性能,这个理解两阶段锁,根据实际业务情况自己分析。

间隙锁(Gap Lock)
间隙锁的存在是为了解决幻读,它与行锁一同存在,并称为next-key lock。

首先来看一下,为什么会产生幻读。

事务A    事务B    事务C
select * from t1 where a = 1        
update t1 set a = 1 where a = 2    
select * from t1 where a = 1        
insert into t1 (a) value(1)
select * from t1 where a = 1        
首先,对MySQL事务了解应该知道,只有在隔离级别是可重复度(repeatable read)以下的时候,才会出现幻读, 因为可以读取到其他事务修改的数据。
那我们来看一下,上门这三个事务执行的时候,事务A三次执行select * fromt1 where a = 1 都得到了什么结果。
第一次: 查询出来a = 1 这一行的数据。
第二次: 事务B将a=2这一行也修改为了a=1,此时事务A查询出来了两行数据。
第三次: 事务C插入了一行a=1的数据,所以此时事务A查询出来了三行数据。

这种情况下,事务A出现了幻读

幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行

那么幻读除了事务A的数据读取的不一致之外,还有个很严重的问题就是,破坏了MySQL对于锁的定义。这会导致从bin-log日志备份出来的数据库数据和原库的数据并不一样。因为,加入事务A执行了一个update语句对所有的a=1的数据加上了锁,但是事务B insert了一条a=1的数据,那么就会有一个问题,刚刚insert的这条数据并没有加上行锁,其他的事务还是可以操作这一条数据。但是使用bin-log日志恢复数据库时,并不会看出来,也就是说,默认事务A对a=1的行的操作,也包含了刚刚事务B插入的这条数据,这明显会造成数据不一致。

知道了幻读有很多问题,那么MySQL是如何解决的呢。要知道MySQL的默认事务隔离级别是可重复度,在这个隔离级别下是不会出现幻读的。

为了解决这个问题,MySQL就引入了间隙锁。假如现在初始化了一个数据库,里面有两条记录。

id    a
0    1
1    5
你可以看到a有两个值1和5。此时,除了有两个行锁之外,还会存在三个间隙,如下。

id    a
间隙 (0, 1]
0    1
间隙 (1, 5]
1    5
间隙 (5, supernum]
supernum是MySQL内部定义的一个int型的最大值

还是上面的三个事务,在事务A执行select * from t1 where a = 1时,除了加上一个行锁之外,还会在上图的间隙(1, 5]除加上一个间隙锁,此时,所有对1<a<=5的update操作都会阻塞,也就是说,事务B和事务C的update和insert语句由于拿不到间隙锁,所以会阻塞,知道事务A提交之后,才会继续执行。

间隙锁的引入虽然解决了幻读的问题,但是当表内的数据变大的时候,间隙锁会非常多,而且每次加锁都让其他update的事务阻塞,所以对性能肯定会有影响。所以是否需要间隙锁,如何设定事务的隔离级别,也需要根据业务的实际情况来确定。

锁的特殊状态–死锁
死锁并非是一种锁,而是一种状态,产生的原因是由于两个事务的锁之间互相依赖,导致两个事务互相等待对方释放锁,进入一个死循环。

如下的事务:

事务A    事务B
update t1 set a = 1 where id = 1    
update t1 set a = 2 where id = 2
update t1 set a = where id = 2    
update t1 set a = 2 where id = 1
commit    commit
如上图,事务A在开始时给id=1加上了行锁,事务B给id=2这一行加上了行锁。
接着事务A想修改id=2这一行,但是这一行的行锁在事务B上,所以事务A等待事务B释放锁,而事务B想要修改id=1这一行,这一行的行锁在事务A上,事务B又会等待事务A释放id=1的行锁,这样就形成了互相依赖,进入了死循环。也就是死锁。

死锁要如何解决呢?
InoDB存储引擎中有这样一个参数 innodb_deadlock_detect 这个参数默认值是 on 表示开启死锁检测。 InoDB会不断的检测进程中可能存在的死锁,如果确认是死锁状态,那么InoDB会主动中断其中一个事务的进程,让程序可以继续运作下去。

innodb_lock_wait_timeout 参数表示判断死锁的进程等待时间,默认50s,意味着如果该进程等待了超过50s,会被认为是产生了死锁,进程会被InoDB直接杀死。

但是死锁的检测意味着额外的时间消耗,毫无疑问会降低MySQL的性能。如果能确保业务逻辑不会存在死锁,那么可以将 innodb_deadlock_detect 设置为false。但是正常情况下不建议这么做。

在绝大多数并发场景下,死锁必定会产生,想要尽量少的产生死锁,就必须了解自己的业务逻辑中,哪些地方可能会产生死锁。通常情况下这是可以分析出来的,然后对表结构和事务进行优化,降低产生死锁的可能,保证MySQL系统的性能。
 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值