马上注册,结交更多数据大咖,获取更多知识干货,轻松玩转大数据
您需要 登录 才可以下载或查看,没有帐号?立即注册
x
前几天,线上发生了一次数据库死锁问题,这一问题前前后后排查了比较久的时间,这个过程中自己也对数据库的锁机制有了更深的理解。本文总结了这次死锁排查的全过程,并分析了导致死锁的原因及解决方案。希望给大家提供一个死锁的排查及解决思路。 <span]前几天,线上发生了一次数据库死锁问题,这一问题前前后后排查了比较久的时间,这个过程中自己也对数据库的锁机制有了更深的理解。本文总结了这次死锁排查的全过程,并分析了导致死锁的原因及解决方案。希望给大家提供一个死锁的排查及解决思路。 <span]本文涉及到MySql执行引擎、数据库隔离级别、Innodb锁机制、索引、数据库事务等多领域知识。前车之鉴,后事之师,希望读者们都可以有所收获。
<span]
<span]1
现象 <span]现象 <span]某天晚上,同事正在发布,突然线上大量报警,很多是关于数据库死锁的,报警提示信息如下:
{"errorCode":"SYSTEM_ERROR","errorMsg":"nested exception is org.apache.ibatis.exceptions.PersistenceException:
Error updating database. Cause: ERR-CODE: [TDDL-4614][ERR_EXECUTE_ON_MYSQL]
Deadlock found when trying to get lock;
The error occurred while setting parameters\n### SQL:
update fund_transfer_stream set gmt_modified=now(),state = ? where fund_transfer_order_no = ? and seller_id = ? and state = 'NEW'
通过报警,我们基本可以定位到发生死锁的数据库以及数据库表。先来介绍下本文案例中涉及到的数据库相关信息。 <span]通过报警,我们基本可以定位到发生死锁的数据库以及数据库表。先来介绍下本文案例中涉及到的数据库相关信息。 <span]
2]2]背景情况 我们使用的数据库是Mysql]我们使用的数据库是Mysql]数据库版本查询方法: select version();
引擎查询方法: <pre] 引擎查询方法: <pre]建表语句中会显示存储引擎信息,形如:ENGINE=InnoDB事务隔离级别查询方法: <pre]事务隔离级别查询方法:<pre]事务隔离级别设置方法(只对当前Session生效): set session transaction isolation level read committed;
PS:注意,如果数据库是分库的,以上几条SQL语句需要在单库上执行,不要在逻辑库执行。 <span]PS:注意,如果数据库是分库的,以上几条SQL语句需要在单库上执行,不要在逻辑库执行。 <span]发生死锁的表结构及索引情况(隐去了部分无关字段和索引): CREATE TABLE `fund_transfer_stream` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`gmt_create` datetime NOT NULL COMMENT '创建时间',
`gmt_modified` datetime NOT NULL COMMENT '修改时间',
`pay_scene_name` varchar(256) NOT NULL COMMENT '支付场景名称',
`pay_scene_version` varchar(256) DEFAULT NULL COMMENT '支付场景版本',
`identifier` varchar(256) NOT NULL COMMENT '唯一性标识',
`seller_id` varchar(64) NOT NULL COMMENT '卖家Id',
`state` varchar(64) DEFAULT NULL COMMENT '状态', `fund_transfer_order_no` varchar(256)
DEFAULT NULL COMMENT '资金平台返回的状态',
PRIMARY KEY (`id`),UNIQUE KEY `uk_scene_identifier`
(KEY `idx_seller` (`seller_id`),
KEY `idx_seller_transNo` (`seller_id`,`fund_transfer_order_no`(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资金流水';
该数据库共有三个索引,1个聚簇索引(主键索引),2个非聚簇索(非主键索引)引。 <span]该数据库共有三个索引,1个聚簇索引(主键索引),2个非聚簇索(非主键索引)引。 <span]聚簇索引: PRIMARY KEY (`id`)
非聚簇索引: <pre]非聚簇索引:<pre]以上两个索引,其实idx_seller_transNo已经覆盖到了idx_seller,由于历史原因,因为该表以seller_id分表,所以是先有的idx_seller,后有的idx_seller_transNo
<span]
<span]3
死锁日志 <span]死锁日志 <span]当数据库发生死锁时,可以通过以下命令获取死锁日志:
show engine innodb status
发生死锁,第一时间查看死锁日志,得到死锁日志内容如下: <pre]发生死锁,第一时间查看死锁日志,得到死锁日志内容如下:<pre]简单解读一下死锁日志,可以得到以下信息:1、导致死锁的两条SQL语句分别是: <pre]1、导致死锁的两条SQL语句分别是:<pre]和 update `fund_transfer_stream_0056`
set `gmt_modified` = NOW(), `state` = 'PROCESSING'
where ((`state` = 'NEW') AND (`seller_id` = '38921111') AND (`fund_transfer_order_no` = '99010015000805619031958363857'))
2、事务1,持有索引idx_seller_transNo的锁,在等待获取PRIMARY的锁。 <span]2、事务1,持有索引idx_seller_transNo的锁,在等待获取PRIMARY的锁。 <span]3、事务2,持有PRIMARY的锁,在等待获取idx_seller_transNo的锁。 4、因事务1和事务2之间发生循环等待,故发生死锁。 <span]4、因事务1和事务2之间发生循环等待,故发生死锁。 <span]5、事务1和事务2当前持有的锁均为:lock_mode X locks rec but not gap 两个事务对记录加的都是X]两个事务对记录加的都是X]X锁:排他锁、又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。 与之对应的是S锁:共享锁,又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。 <span]与之对应的是S锁:共享锁,又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。 <span]Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。 Next-Key]Next-Key]4
问题排查 <span]问题排查 <span]根据我们目前已知的数据库相关信息,以及死锁的日志,我们基本可以做一些简单的判定。
首先,此次死锁一定是和Gap锁以及Next-Key]首先,此次死锁一定是和Gap锁以及Next-Key]然后,就要翻代码了,看看我们的代码中事务到底是怎么做的。核心代码及SQL如下: @Transactional(rollbackFor = Exception.class)
public int doProcessing(String sellerId, Long id, String fundTransferOrderNo) {
fundTreansferStreamDAO.updateFundStreamId(sellerId, id, fundTransferOrderNo);
return fundTreansferStreamDAO.updateStatus(sellerId, fundTransferOrderNo,"PROCESSING");
}
该代码的目的是先后修改同一条记录的两个不同字段,updateFundStreamId]该代码的目的是先后修改同一条记录的两个不同字段,updateFundStreamId]updateStatus SQL: update fund_transfer_stream
set gmt_modified=now(),state = #{state}
where fund_transfer_order_no = #{fundTransferOrderNo} and seller_id = #{sellerId}
and state = 'NEW'
可以看到,我们的同一个事务中执行了两条Update语句,这里分别查看下两条SQL的执行计划: <img]可以看到,我们的同一个事务中执行了两条Update语句,这里分别查看下两条SQL的执行计划:<img]updateFundStreamId执行的时候使用到的是PRIMARY索引。updateStatus执行的时候使用到的是idx_seller_transNo索引。 <span]updateStatus执行的时候使用到的是idx_seller_transNo索引。 <span]通过执行计划,我们发现updateStatus其实是有两个索引可以用的,执行的时候真正使用的是idx_seller_transNo索引。这是因为 MySQL查询优化器是基于代价(cost-based)的查询方式。因此,在查询过程中,最重要的一部分是根据查询的SQL语句,依据多种索引,计算查询需要的代价,从而选择最优的索引方式生成查询计划。 我们查询执行计划是在死锁发生之后做的,事后查询的执行计划和发生死锁那一刻的索引使用情况并不一定相同的。但是,我们结合死锁日志,也可以定位到以上两条SQL语句执行的时候使用到的索引。即]我们查询执行计划是在死锁发生之后做的,事后查询的执行计划和发生死锁那一刻的索引使用情况并不一定相同的。但是,我们结合死锁日志,也可以定位到以上两条SQL语句执行的时候使用到的索引。即]有了以上这些已知信息,我们就可以开始排查死锁原因及其背后的原理了。通过分析死锁日志,再结合我们的代码以及数据库建表语句,我们发现主要问题出在我们的idx_seller_transNo索引上面: KEY `idx_seller_transNo` (`seller_id`,`fund_transfer_order_no`(20))
索引创建语句中,我们使用了前缀索引,为了节约索引空间,提高索引效率,我们只选择了fund_transfer_order_no字段的前20位作为索引值。 <span]索引创建语句中,我们使用了前缀索引,为了节约索引空间,提高索引效率,我们只选择了fund_transfer_order_no字段的前20位作为索引值。 <span]因为fund_transfer_order_no只是普通索引,而非唯一性索引。又因为在一种特殊情况下,会有同一个用户的两个fund_transfer_order_no的前20位相同,这就导致两条不同的记录的索引值一样(因为seller_id 和fund_transfer_order_no(20)都相同 )。 就如本文中的例子,发生死锁的两条记录的fund_transfer_order_no字段的值:99010015000805619031958363857和99010015000805619031957477256]就如本文中的例子,发生死锁的两条记录的fund_transfer_order_no字段的值:99010015000805619031958363857和99010015000805619031957477256]那么为什么fund_transfer_order_no的前20位相同会导致死锁呢? 5]5]加锁原理 我们就拿本次的案例来看一下MySql数据库加锁的原理是怎样的,本文的死锁背后又发生了什么。]我们就拿本次的案例来看一下MySql数据库加锁的原理是怎样的,本文的死锁背后又发生了什么。]我们在数据库上模拟死锁场景,执行顺序如下: <img]<img]我们知道,在MySQL中,行级锁并不是直接锁记录,而是锁索引。索引分为主键索引和非主键索引两种,如果一条sql语句操作了主键索引,MySQL就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引。 主键索引的叶子节点存的是整行数据。在InnoDB中,主键索引也被称为聚簇索引(clustered]主键索引的叶子节点存的是整行数据。在InnoDB中,主键索引也被称为聚簇索引(clustered]非主键索引的叶子节点的内容是主键的值,在InnoDB中,非主键索引也被称为非聚簇索引(secondary index) 所以,本文的示例中涉及到的索引结构(索引是B+树,简化成表格了)如图: <img]所以,本文的示例中涉及到的索引结构(索引是B+树,简化成表格了)如图:<img]死锁的发生与否,并不在于事务中有多少条SQL语句,死锁的关键在于:两个(或以上)的Session加锁的顺序不一致]死锁的发生与否,并不在于事务中有多少条SQL语句,死锁的关键在于:两个(或以上)的Session加锁的顺序不一致]下图是分解图,每一条SQL执行的时候加锁情况: 结合以上两张图,我们发现了导致死锁的原因:]结合以上两张图,我们发现了导致死锁的原因:]事务1执行update1占用PRIMARY = 1的锁 ——> 事务2执行update1 占有PRIMARY = 2的锁; 事务1执行update2占有idx_seller_transNo]事务1执行update2占有idx_seller_transNo]事务2执行update2尝试占有idx_seller_transNo = (3111095611,99010015000805619031)的锁失败(死锁); 事务在以非主键索引为where条件进行Update的时候,会先对该非主键索引加锁,然后再查询该非主键索引对应的主键索引都有哪些,再对这些主键索引进行加锁。) <span]事务在以非主键索引为where条件进行Update的时候,会先对该非主键索引加锁,然后再查询该非主键索引对应的主键索引都有哪些,再对这些主键索引进行加锁。) <span]
6]6]解决方法 至此,我们分析清楚了导致死锁的根本原理以及其背后的原理。那么这个问题解决起来就不难了。]至此,我们分析清楚了导致死锁的根本原理以及其背后的原理。那么这个问题解决起来就不难了。]可以从两方面入手,分别是修改索引和修改代码(包含SQL语句)。 修改索引:只要我们把前缀索引]修改索引:只要我们把前缀索引]但是,改了idx_seller_transNo的前缀长度后,可以解决死锁的前提条件是update语句真正执行的时候,会用到fund_transfer_order_no索引。如果MySQL查询优化器在代价分析之后,决定使用索引 KEY idx_seller(seller_id),那么还是会存在死锁问题。原理和本文类似。 所以,根本解决办法就是改代码: - 所有update都通过主键ID进行。
- 在同一个事务中,避免出现多条update语句修改同一条记录。
<span]所以,根本解决办法就是改代码: - 所有update都通过主键ID进行。
- 在同一个事务中,避免出现多条update语句修改同一条记录。
<span]
7]7]总结与思考 在死锁发生之后的一周内,我几乎每天都会抽空研究一会,问题早早的就定位到了,修改方案也有了,但是其中原理一直没搞清楚。]在死锁发生之后的一周内,我几乎每天都会抽空研究一会,问题早早的就定位到了,修改方案也有了,但是其中原理一直没搞清楚。]前前后后做过很多中种推断及假设,又都被自己一次次推翻。最终还是要靠实践来验证自己的想法。于是我自己在本地安装了数据库,实战的做了些测试,并实时查看数据库锁情况。 show engine innodb status ;可以查看锁情况。最终才搞清楚原理。 简单说几点思考: <span]简单说几点思考: <span]1、遇到问题,不要猜!!!亲手复现下问题,然后再来分析。 2、不要忽略上下文!!!我刚开始就是只关注死锁日志,一直忽略了代码中的事务其实还执行了另外一条SQL语句(updateFundStreamId)。 <span]2、不要忽略上下文!!!我刚开始就是只关注死锁日志,一直忽略了代码中的事务其实还执行了另外一条SQL语句(updateFundStreamId)。 <span]3、理论知识再充足,关键时刻不一定想的起来!!! 4、坑都是自己埋的!!! <span]4、坑都是自己埋的!!! <span]
参考资料:]参考资料:]http://hedengcheng.com/?p=771 https://dev.mysql.com/doc/refman/5.7/en/innodb-transaction-isolation-levels.html <span]https://dev.mysql.com/doc/refman/5.7/en/innodb-transaction-isolation-levels.html <span]《MySql实战45讲》 https://www.hollischuang.com/archives/914 |