为何开发人员必需要了解数据库锁?

1.锁?

1.1何为锁

锁在现实中的意义为:封闭的器物,以钥匙或暗码开启。在计算机中的锁通常用来管理对共享资源的并发访问,好比咱们java同窗熟悉的Lock,synchronized等都是咱们常见的锁。固然在咱们的数据库中也有锁用来控制资源的并发访问,这也是数据库和文件系统的区别之一。java

1.2为何要懂数据库锁?

一般来讲对于通常的开发人员,在使用数据库的时候通常懂点DQL(select),DML(insert,update,delete)就够了。mysql

小明是一个刚刚毕业在互联网公司工做的Java开发工程师,日常的工做就是完成PM的需求,固然在完成需求的同时确定逃脱不了spring,springmvc,mybatis的那一套框架,因此通常来讲sql仍是本身手写,遇到比较复杂的sql会从网上去百度一下。对于一些比较重要操做,好比交易啊这些,小明会用spring的事务来对数据库的事务进行管理,因为数据量比较小目前还涉及不了分布式事务。git

前几个月小明过得都还风调雨顺,知道有一天,小明接了一个需求,商家有个配置项,叫优惠配置项,能够配置买一送一,买一送二等等规则,固然这些配置是批量传输给后端的,这样就有个问题每一个规则都得去匹配他究竟是删除仍是添加仍是修改,这样后端逻辑就比较麻烦,聪明的小明想到了一个办法,直接删除这个商家的配置,而后所有添加进去。小明立刻开发完毕,成功上线。github

开始上线没什么毛病,可是日志常常会出现一些mysql-insert-deadlock异常。因为小明经验比较浅,对于这类型的问题第一次碰见,因而去问了他们组的老司机-大红,大红一看见这个问题,而后看了他的代码以后,输出了几个命令看了几个日志,立刻定位了问题,告诉了小明:这是由于delete的时候会加间隙锁,可是间隙锁之间却能够兼容,可是插入新的数据的时候就会由于插入意向锁会被间隙锁阻塞,致使双方被资源被互占,致使死锁。小明听了以后似懂非懂,因为大红的事情比较多,不方便一直麻烦大红,因此决定本身下来本身想。下班事后,小明回想大红说的话,什么是间隙锁,什么是插入意向锁,看来做为开发者对数据库不该该只会写SQL啊,否则遇到一些疑难杂症彻底无法解决啊。想完,因而小明就踏上了学习Mysql锁这条不归之路。算法

2.InnoDB

2.1mysql体系架构

小明没有着急去了解锁这方面的知识,他首先先了解了下Mysql体系架构:

能够发现Mysql由链接池组件、管理服务和工具组件、sql接口组件、查询分析器组件、优化器组件、 缓冲组件、插件式存储引擎、物理文件组成。spring

小明发如今mysql中存储引擎是以插件的方式提供的,在Mysql中有多种存储引擎,每一个存储引擎都有本身的特色。随后小明在命令行中打出了:sql

show engines \G;

一看原来有这么多种引擎。数据库

又打出了下面的命令,查看当前数据库默认的引擎:后端

show variables like '%storage_engine%';


小明恍然大悟:原来本身的数据库是使用的InnoDB,依稀记得本身在上学的时候好像据说过有个引擎叫MyIsAM,小明想这两个有啥不一样呢?立刻查找了一下资料:缓存

对比项 InnoDB MyIsAM
事务 支持 不支持
支持MVCC行锁 表锁
外键 支持 不支持
存储空间 存储空间因为须要高速缓存,较大 可压缩
适用场景 有必定量的update和Insert 大量的select

小明大概了解了一下InnoDB和MyIsAM的区别,因为使用的是InnoDB,小明就没有过多的纠结这一块。

2.2事务的隔离性

小明在研究锁以前,又回想到以前上学的时候教过的数据库事务隔离性,其实锁在数据库中其功能之一也是用来实现事务隔离性。而事务的隔离性实际上是用来解决,脏读,不可重复读,幻读几类问题。

2.2.1 脏读

一个事务读取到另外一个事务未提交的更新数据。
什么意思呢?

时间点 事务A 事务B
1 begin;
2 select * from user where id = 1; begin;
3 update user set namm = 'test' where id = 1;
4 select * from user where id = 1;
5 commit; commit;

在事务A,B中,事务A在时间点2,4分别对user表中id=1的数据进行了查询了,可是事务B在时间点3进行了修改,致使了事务A在4中的查询出的结果实际上是事务B修改后的。破坏了数据库中的隔离性。

2.2.2 不可重复读

在同一个事务中,屡次读取同一数据返回的结果不一样,和脏读不一样的是这里读取的是已经提交事后的。

时间点 事务A 事务B
1 begin;
2 select * from user where id = 1; begin;
3 update user set namm = 'test' where id = 1;
4 commit;
5 select * from user where id = 1;
6 commit;

在事务B中提交的操做在事务A第二次查询以前,可是依然读到了事务B的更新结果,也破坏了事务的隔离性。

2.2.3 幻读

一个事务读到另外一个事务已提交的insert数据。

时间点 事务A 事务B
1 begin;
2 select * from user where id > 1; begin;
3 insert user select 2;
4 commit;
5 select * from user where id > 1;
6 commit;

在事务A中查询了两次id大于1的,在第一次id大于1查询结果中没有数据,可是因为事务B插入了一条Id=2的数据,致使事务A第二次查询时能查到事务B中插入的数据。

事务中的隔离性:

隔离级别 脏读 不可重复读 幻读
未提交读(RUC) NO NO NO
已提交读(RC) YES NO NO
可重复读(RR) YES YES NO
可串行化 YES YES YES

小明注意到在收集资料的过程当中,有资料写到InnoDB和其余数据库有点不一样,InnoDB的可重复读其实就能解决幻读了,小明心想:这InnoDB还挺牛逼的,我得好好看看究竟是怎么个原理。

2.3 InnoDB锁类型

小明首先了解一下Mysql中常见的锁类型有哪些:

2.3.1 S or X

在InnoDb中实现了两个标准的行级锁,能够简单的看为两个读写锁:

  • S-共享锁:又叫读锁,其余事务能够继续加共享锁,可是不能继续加排他锁。
  • X-排他锁: 又叫写锁,一旦加了写锁以后,其余事务就不能加锁了。
兼容性:是指事务A得到一个某行某种锁以后,事务B一样的在这个行上尝试获取某种锁,若是能当即获取,则称锁兼容,反之叫冲突。

纵轴是表明已有的锁,横轴是表明尝试获取的锁。

. X S
X 冲突 冲突
S 冲突 兼容

2.3.2 意向锁

意向锁在InnoDB中是表级锁,和他的名字同样他是用来表达一个事务想要获取什么。意向锁分为:

  • 意向共享锁:表达一个事务想要获取一张表中某几行的共享锁。
  • 意向排他锁:表达一个事务想要获取一张表中某几行的排他锁。

这个锁有什么用呢?为何须要这个锁呢?
首先说一下若是没有这个锁,若是要给这个表加上表锁,通常的作法是去遍历每一行看看他是否有行锁,这样的话效率过低,而咱们有意向锁,只须要判断是否有意向锁便可,不须要再去一行行的去扫描。

在InnoDB中因为支持的是行级的锁,所以InnboDB锁的兼容性能够扩展以下:

. IX IS X S
IX 兼容 兼容 冲突 冲突
IS 兼容 兼容 冲突 兼容
X 冲突 冲突 冲突 冲突
S 冲突 兼容 冲突 兼容

2.3.3 自增加锁

自增加锁是一种特殊的表锁机制,提高并发插入性能。对于这个锁有几个特色:

  • 在sql执行完就释放锁,并非事务执行完。
  • 对于Insert...select大数据量插入会影响插入性能,由于会阻塞另一个事务执行。
  • 自增算法能够配置。

在MySQL5.1.2版本以后,有了不少优化,能够根据不一样的模式来进行调整自增长锁的方式。小明看到了这里打开了本身的MySQL发现是5.7以后,因而便输入了下面的语句,获取到当前锁的模式:

mysql> show variables like 'innodb_autoinc_lock_mode';
+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_autoinc_lock_mode | 2     |
+--------------------------+-------+
1 row in set (0.01 sec)

在MySQL中innodb_autoinc_lock_mode有3种配置模式:0、一、2,分别对应”传统模式”, “连续模式”, “交错模式”。

  1. 传统模式:也就是咱们最上面的使用表锁。
  2. 连续模式:对于插入的时候能够肯定行数的使用互斥量,对于不能肯定行数的使用表锁的模式。
  3. 交错模式:全部的都使用互斥量,为何叫交错模式呢,有可能在批量插入时自增值不是连续的,固然通常来讲若是不看重自增值连续通常选择这个模式,性能是最好的。

2.4InnoDB锁算法

小明已经了解到了在InnoDB中有哪些锁类型,可是如何去使用这些锁,仍是得靠锁算法。

2.4.1 记录锁(Record-Lock)

记录锁是锁住记录的,这里要说明的是这里锁住的是索引记录,而不是咱们真正的数据记录。

  • 若是锁的是非主键索引,会在本身的索引上面加锁以后而后再去主键上面加锁锁住.
  • 若是没有表上没有索引(包括没有主键),则会使用隐藏的主键索引进行加锁。
  • 若是要锁的列没有索引,则会进行全表记录加锁。

2.4.2 间隙锁

间隙锁顾名思义锁间隙,不锁记录。锁间隙的意思就是锁定某一个范围,间隙锁又叫gap锁,其不会阻塞其余的gap锁,可是会阻塞插入间隙锁,这也是用来防止幻读的关键。

2.4.3 next-key锁

这个锁本质是记录锁加上gap锁。在RR隔离级别下(InnoDB默认),Innodb对于行的扫描锁定都是使用此算法,可是若是查询扫描中有惟一索引会退化成只使用记录锁。为何呢?
由于惟一索引能肯定行数,而其余索引不能肯定行数,有可能在其余事务中会再次添加这个索引的数据会形成幻读。

这里也说明了为何Mysql能够在RR级别下解决幻读。

2.4.4 插入意向锁

插入意向锁Mysql官方对其的解释:

An insert intention lock is a type of gap lock set by INSERT operations prior to row insertion. This lock signals the intent to insert in such a way that multiple transactions inserting into the same index gap need not wait for each other if they are not inserting at the same position within the gap. Suppose that there are index records with values of 4 and 7. Separate transactions that attempt to insert values of 5 and 6, respectively, each lock the gap between 4 and 7 with insert intention locks prior to obtaining the exclusive lock on the inserted row, but do not block each other because the rows are nonconflicting.

能够看出插入意向锁是在插入的时候产生的,在多个事务同时写入不一样数据至同一索引间隙的时候,并不须要等待其余事务完成,不会发生锁等待。假设有一个记录索引包含键值4和7,不一样的事务分别插入5和6,每一个事务都会产生一个加在4-7之间的插入意向锁,获取在插入行上的排它锁,可是不会被互相锁住,由于数据行并不冲突。

这里要说明的是若是有间隙锁了,插入意向锁会被阻塞。

2.5 MVCC

MVCC,多版本并发控制技术。在InnoDB中,在每一行记录的后面增长两个隐藏列,记录建立版本号和删除版本号。经过版本号和行锁,从而提升数据库系统并发性能。

在MVCC中,对于读操做能够分为两种读:

  • 快照读:读取的历史数据,简单的select语句,不加锁,MVCC实现可重复读,使用的是MVCC机制读取undo中的已经提交的数据。因此它的读取是非阻塞的。
  • 当前读:须要加锁的语句,update,insert,delete,select...for update等等都是当前读。

在RR隔离级别下的快照读,不是以begin事务开始的时间点做为snapshot创建时间点,而是以第一条select语句的时间点做为snapshot创建的时间点。之后的select都会读取当前时间点的快照值。

在RC隔离级别下每次快照读均会建立新的快照。

具体的原理是经过每行会有两个隐藏的字段一个是用来记录当前事务,一个是用来记录回滚的指向Undolog。利用undolog就能够读取到以前的快照,不须要单独开辟空间记录。

3.加锁分析

小明到这里,已经学习不少mysql锁有关的基础知识,因此决定本身建立一个表搞下实验。首先建立了一个简单的用户表:

CREATE TABLE `user` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(11) CHARACTER SET utf8mb4 DEFAULT NULL,
  `comment` varchar(11) CHARACTER SET utf8 DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `index_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

而后插入了几条实验数据:

insert user select 20,333,333;
insert user select 25,555,555;
insert user select 20,999,999;

数据库事务隔离选择了RR

3.1 实验1

小明开启了两个事务,进行实验1.

时间点 事务A 事务B
1 begin;
2 select * from user where name = '555' for update; begin;
3 insert user select 31,'556','556';
4 ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

小明开启了两个事务并输入了上面的语句,发现事务B竟然出现了超时,小明看了一下本身明明是对name = 555这一行进行的加锁,为何我想插入name=556给我阻塞了。因而小明打开命令行输入:

select * from information_schema.INNODB_LOCKS

发如今事务A中给555加了Next-key锁,事务B插入的时候会首先进行插入意向锁的插入,因而得出下面结论:

能够看见事务B因为间隙锁和插入意向锁的冲突,致使了阻塞。

3.2 实验2

小明发现上面查询条件用的是普通的非惟一索引,因而小明就试了一下主键索引:

时间点 事务A 事务B
1 begin;
2 select * from user where id = 25 for update; begin;
3 insert user select 26,'666','666';
4 Query OK, 1 row affected (0.00 sec)

Records: 1 Duplicates: 0 Warnings: 0

竟然发现事务B并无发生阻塞,哎这个是咋回事呢,小明有点疑惑,按照实验1的套路应该会被阻塞啊,由于25-30之间会有间隙锁。因而小明又祭出了命令行,发现只加了X记录锁。原来是由于惟一索引会降级记录锁,这么作的理由是:非惟一索引加next-key锁因为不能肯定明确的行数有可能其余事务在你查询的过程当中,再次添加这个索引的数据,致使隔离性遭到破坏,也就是幻读。惟一索引因为明确了惟一的数据行,因此不须要添加间隙锁解决幻读。

3.3 实验3

上面测试了主键索引,非惟一索引,这里还有个字段是没有索引,若是对其加锁会出现什么呢?

时间点 事务A 事务B
1 begin;
2 select * from user where comment = '555' for update; begin;
3 insert user select 26,'666','666';
4 ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
5 insert user select 31,'3131','3131';
6 ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
7 insert user select 10,'100','100';
8 ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

小明一看哎哟我去,这个咋回事呢,咋无论是用实验1非间隙锁范围的数据,仍是用间隙锁里面的数据都不行,难道是加了表锁吗?

的确,若是用没有索引的数据,其会对全部聚簇索引上都加上next-key锁。

因此你们日常开发的时候若是对查询条件没有索引的,必定进行一致性读,也就是加锁读,会致使全表加上索引,会致使其余事务所有阻塞,数据库基本会处于不可用状态。

4.回到事故

4.1 死锁

小明作完实验以后总算是了解清楚了加锁的一些基本套路,可是以前线上出现的死锁又是什么东西呢?

死锁:是指两个或两个以上的事务在执行过程当中,因争夺资源而形成的一种互相等待的现象。说明有等待才会有死锁,解决死锁能够经过去掉等待,好比回滚事务。

解决死锁的两个办法:

  1. 等待超时:当某一个事务等待超时以后回滚该事务,另一个事务就能够执行了,可是这样作效率较低,会出现等待时间,还有个问题是若是这个事务所占的权重较大,已经更新了不少数据了,可是被回滚了,就会致使资源浪费。
  2. 等待图(wait-for-graph): 等待图用来描述事务之间的等待关系,当这个图若是出现回路以下:


就出现回滚,一般来讲InnoDB会选择回滚权重较小的事务,也就是undo较小的事务。

4.2 线上问题

小明到这里,基本须要的基本功都有了,因而在本身的本地表中开始复现这个问题:

时间点 事务A 事务B
1 begin; begin;
2 delete from user where name = '777'; delete from user where name = '666';
3 insert user select 27,'777','777'; insert user select 26,'666','666';
4 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction Query OK, 1 row affected (14.32 sec) Records: 1 Duplicates: 0 Warnings: 0

能够看见事务A出现被回滚了,而事务B成功执行。
具体每一个时间点发生了什么呢?

时间点2:事务A删除name = '777'的数据,须要对777这个索引加上next-Key锁,可是其不存在,因此只对555-999之间加间隙锁,同理事务B也对555-999之间加间隙锁。间隙锁之间是兼容的。

时间点3:事务A,执行Insert操做,首先插入意向锁,可是555-999之间有间隙锁,因为插入意向锁和间隙锁冲突,事务A阻塞,等待事务B释放间隙锁。事务B同理,等待事务A释放间隙锁。因而出现了A->B,B->A回路等待。

时间点4:事务管理器选择回滚事务A,事务B插入操做执行成功。

4.3 修复BUG

这个问题总算是被小明找到了,就是由于间隙锁,如今须要解决这个问题,这个问题的缘由是出现了间隙锁,那就来去掉他吧:

  • 方案一:隔离级别降级为RC,在RC级别下不会加入间隙锁,因此就不会出现毛病了,可是在RC级别下会出现幻读,可提交读都破坏隔离性的毛病,因此这个方案不行。
  • 方案二:隔离级别升级为可序列化,小明通过测试后发现不会出现这个问题,可是在可序列化级别下,性能会较低,会出现较多的锁等待,一样的也不考虑。
  • 方案三:修改代码逻辑,不要直接删,改为每一个数据由业务逻辑去判断哪些是更新,哪些是删除,那些是添加,这个工做量稍大,小明写这个直接删除的逻辑就是为了避免作这些复杂的事的,因此这个方案先不考虑。
  • 方案四:较少的修改代码逻辑,在删除以前,能够经过快照查询(不加锁),若是查询没有结果,则直接插入,若是有经过主键进行删除,在以前第三节实验2中,经过惟一索引会降级为记录锁,因此不存在间隙锁。

通过考虑小明选择了第四种,立刻进行了修复,而后上线观察验证,发现如今已经不会出现这个Bug了,这下小明总算能睡个安稳觉了。

4.4 如何防止死锁

小明经过基础的学习和日常的经验总结了以下几点:

  • 以固定的顺序访问表和行。交叉访问更容易形成事务等待回路。
  • 尽可能避免大事务,占有的资源锁越多,越容易出现死锁。建议拆成小事务。
  • 下降隔离级别。若是业务容许(上面4.3也分析了,某些业务并不能容许),将隔离级别调低也是较好的选择,好比将隔离级别从RR调整为RC,能够避免掉不少由于gap锁形成的死锁。
  • 为表添加合理的索引。防止没有索引出现表锁,出现的死锁的几率会突增。

最后

因为篇幅有限不少东西并不能介绍全若是感兴趣的同窗能够阅读《Mysql技术内幕-InnoDB引擎》第6章 以及 何大师的MySQL 加锁处理分析。做者本人水平有限,若是有什么错误,还请指正。

最后这篇文章被我收录于JGrowing,一个全面,优秀,由社区一块儿共建的Java学习路线,若是您想参与开源项目的维护,能够一块儿共建,github地址为:https://github.com/javagrowin...
麻烦给个小星星哟。

若是你以为这篇文章对你有文章,能够关注个人技术公众号,你的关注和转发是对我最大的支持,O(∩_∩)O

相关文章
相关标签/搜索