记一次神奇的 MySQL 死锁

发布于: 2020 年 12 月 14 日
记一次神奇的MySQL死锁



前段时间遇到一个数据库死锁的问题, 报错信息如下图所示:



image.png



折腾了半天, 终于发现, 是另一个方法里使用的方法写的有问题,  他使用DataSourceTransactionManager开启了一个事务 , 但是由于代码写的有问题, 导致在异常情况下, 该事务既没有提交, 也没有回滚. 当时想的是, 该方法和我的方法操作了同一个表, 他的事务没提交, 导致表锁, 所以最后就只能等待, 但是一直等到不到, 就抛出下面的错误.

事情解决了, 但是充满好奇心的我还是想研究一下来龙去脉, 毕竟不求甚解可不是什么好习惯.

于是我又回头看了一遍代码, 我惊讶的发现, 另一个方法里根本没有操作我的表呀, 怎么会锁住呢, 当时的我表情应该是这样的:



image.png



于是我把场景简化写了一个demo, 开始了我的探索之旅, 别说, 不看不知道, 一看吓一跳, 原理这里有这么多神奇的巧合, 导致了我这个神奇的死锁. 话不多说, 我们上路吧.



首先, 我创建了两张实验使用到的数据库表,  结构如下,  test_lock_table_one 用于模拟手动开启事务, 但是因为异常, 事务没有成功提交或回滚 . test_lock_table_two  用于模拟我的事务中的常规更新操作. 看到这有人可能会问, 这两个方法操作的是不同的表, 八竿子打不着, 怎么会死锁? 这也是我最开始的疑问, 别急, 一会就知道什么是城门失火 , 殃及池鱼.

CREATE TABLE `test_lock_table_one` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID自增',
`custom_id` varchar(256) NOT NULL COMMENT '自定义id',
`name` varchar(256) NOT NULL COMMENT '名称',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '0:未删除 1:已删除',
`created_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modified_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_customId`(`custom_id`) USING BTREE COMMENT '用户ID'
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_bin COMMENT = '测试锁表' ROW_FORMAT = Dynamic;
CREATE TABLE `test_lock_table_two` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID自增',
`custom_id` varchar(32) NOT NULL COMMENT '自定义id',
`name` varchar(32) NOT NULL COMMENT '名称',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '0:未删除 1:已删除',
`created_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modified_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_bin COMMENT = '测试index对锁的影响' ROW_FORMAT = Dynamic;



下面是两个方法的大概实现,  我就简单抽象如下:

@Slf4j
@Service
public class TestLockTableOneServiceImpl extends ServiceImpl<TestLockTableOneMapper, TestLockTableOne> implements ITestLockTableOneService {
@Resource
private DataSourceTransactionManager dataSourceTransactionManager;
@Resource
private TransactionDefinition transactionDefinition;
@Resource
private TestLockTableOneMapper lockTableOneMapper;
@Resource
private ITestLockTableTwoService testLockTableTwoService;
@Override
public boolean updateTableOne(String customId, String name) {
boolean result = updateTableOneInternal(customId, name);
return result;
}
// 这个方法在 lockTableOneMapper.updateTableOneInternal(customId, name);抛出异常后, 事务既不会回滚, 也不会提交
// 代码本身就是有问题的, 不要纠结他写的好不好, 本文的重点不在这
private boolean updateTableOneInternal(String customId, String name) {
TransactionStatus transaction = dataSourceTransactionManager.getTransaction(transactionDefinition);
try {
testLockTableTwoService.queryIndexTable(customId);
// 这里故意让数据库操作异常了,
lockTableOneMapper.updateTableOneInternal(customId, name);
dataSourceTransactionManager.commit(transaction);
} catch (BusinessException exception) {
log.error("更新数据异常");
// 这里只有在捕捉到是BusinessException是才会回滚
dataSourceTransactionManager.rollback(transaction);
} catch (Exception ex) {
log.error("更新数据异常: {}", ex.getMessage());
throw new BusinessException(400, "更新数据异常了");
}
return false;
}
}
// XML中的更新语句是这样的:
<update id="updateTableOneInternal">
UPDATE test_lock_table_one
set name = #{name}
WHERE deleted = 0 AND custome_id = #{customId}
</update>



在我的方法中, 使用了Spring的事务注解, 且事务的传播机制使用的是默认的Propagation.REQUIRED,  可能经验丰富的你或者熟悉Spring 事务机制的你此刻已经知道为什么会死锁了, 如果不知道也没关系, 我们一起继续探索.

@Service
public class TestLockTableTwoServiceImpl extends ServiceImpl<TestIndexLockTableMapper, TestLockTableTwo> implements ITestLockTableTwoService {
@Resource
private TestIndexLockTableMapper indexLockTableMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateData(String customId, String name) {
int count = indexLockTableMapper.updateData(customId, name);
return count > 0;
}
}
// XML中的更新语句是这样的:
<update id="updateData">
UPDATE test_lock_table_two
set index_name = #{name}
WHERE deleted = 0 AND custom_id = #{customId}
</update



下面我们就开始还原事情的真相, 使用postman模拟请求,  controller的写法没什么特殊之处, 大家随便写写就好.

首先, 我们发送第一个请求, 模拟事务没有提交也没有回滚的情况 :

image.png

接下来我们就开始模拟第二个更新的方法, 你连续更新几次之后就会发现请求被hang住了, 然后控制台就出现了我们文章开头提到的错误.

image.png



那问题复现了 , 我们怎么分析呢? 这个时候我们就需要用到MySQL提供的跟事务和锁相关的表:

// 表示正在运行的线程
show processlist;
// 当前运行的所有事务
SELECT * FROM information_schema.INNODB_TRX;
//当前出现的锁
SELECT * FROM information_schema.INNODB_LOCKs;
// 锁等待的对应关系
SELECT * FROM information_schema.INNODB_LOCK_WAITS;



当模拟第一个调用之后, 执行上述的SQL查询 结果如下:

show processlist; 执行结果如下所示:



image.png



执行 SELECT * FROM information_schema.INNODB_TRX; 的结果如下所示:



image.png



可以看到 thread_id 为4837中开启一个事务, 正在运行, 但是处于Sleep  状态, 也就是它正在等待客户端向它发送指令.  那我们能否看到这个事务内执行的到底是什么命令吗?

必须可以, 执行下面的SQL:

select now(),(UNIX_TIMESTAMP(now()) - UNIX_TIMESTAMP(a.trx_started)) diff_sec,b.id,b.user,b.host,b.db,d.SQL_TEXT
from information_schema.innodb_trx a
inner join information_schema.PROCESSLIST b on a.TRX_MYSQL_THREAD_ID=b.id and b.command = 'Sleep'
inner join performance_schema.threads c ON b.id = c.PROCESSLIST_ID
inner join performance_schema.events_statements_current d ON d.THREAD_ID = c.THREAD_ID;

从结果中可以看到 id = 4837, 运行的正是我们的第一个方法执行的出错的SQL.



image.png



接下来我们看看在这种情况下, 我们调用第二个方法, 当出现死锁时, 这些事务又会变成什么样子呢?

show processlist; 执行结果如下, 这里可以看到这里新增一个线程id = 4838 , 执行的Sql 正是我们第二个方法内只能的sql.



image.png



 再来看看当前的事务表里的数据: 执行

SELECT * FROM information_schema.INNODB_TRX;

SELECT * FROM information_schema.INNODB_LOCKs;

SELECT * FROM information_schema.INNODB_LOCK_WAITS;

可以看到 确实有两个事务.

且第一个的状态是 LOCK WAIT.  从INNODB_LOCKs 和INNODB_LOCK_WAITS 表中可以看到 存在两个锁, 且216926被216916阻塞了.



image.png



image.png



image.png



不知道大家发现没有, 这两个锁, 锁住的都是test_lock_table_two, 那跟第一个方法操作的test_lock_table_one 有什么关系呢???

这两个锁都是行锁, 意味着操作会产生锁, 可以理解. 但是为什么, 没有第一个方法导致的事务为提交或回滚, 单独操作第二方法, 问题就不出现,而当有了第一个的方法后, 这个锁就必然出现呢??

此刻, 我突然想到我在第二个方法中使用的事务传播机制是Propagation.REQUIRED, 也就是说如果当前存在事务, 会使用当前存在的事务, 为了验证我的猜想, 我又一次执行了查询事务详情的sql, 执行结果如下:



image.png



大家发现没,  id 没有变化 依旧是4837, 但是执行的SQL却变化了, 已经变成了我们第二个方法中执行的更新操作.这就解释了为什么会出现锁. 且锁住的是表: test_lock_table_two.



为了进一步验证我们可以将第二个方法的传播机制改成Propagation.REQUIRES_NEW, 这个问题就不会出现.

所以可以得出结论: 因为我的事务传播机制选择的是默认的 Propagation.REQUIRED,  意味着如果有事务, 我会使用已经存在的事务

又要第一个请求开启了事务, 但是执行出现异常,导致事务没有提交也没有回滚, 我的事务就一直无法执行, 而我的更新存在行锁, 导致我永远得不到锁, 后面的请求就都挂了.



温馨提示: 没事不要自己手动控制事务, 没事不要自己手动控制事务,没事不要自己手动控制事务!





发布于: 2020 年 12 月 14 日阅读数: 17
用户头像

废材姑娘 2018.01.24 加入

大家叫我双儿,梦想着成为韦小宝的老婆 欢迎关注我的个人公众号----废材姑娘,回复“双儿”加我微信,让我们一起探索多彩的世界。

评论

发布
暂无评论
记一次神奇的MySQL死锁