一、问题背景
1.工单更新接口调用成功,但是工单的主表字段没有变更。
2.工单的表结构分为主表和扩展表。查看数据库,扩展表更新成功了,但是主表没有更新。
3.主表和扩展表的更新都在一个事务下面,扩展表的更新操作有重试机制。
更新工单代码示例:
扩展表创建语句:
二、问题排查
由于扩展表更新了,但是主表没更新,主表更新的语句在扩展表之前,初步判断是回滚导致的,数据丢失。
1.是否是异常导致的回滚
查询日志发现主表和扩展表都有更新日志,按道理来说两张表都应该更新完成了。继续查看日志,扩展表在更新时有抛出死锁的日志,查看死锁的栈信息,重试机制吃掉了抛出的死锁异常。最后在重试下更新扩展表成功了,因此此处的回滚不可能是事务捕获到异常进行的回滚。
2.是否是mybatis导致的回滚
mybatis在执行异常时,只会释放引用,并不会处理事务,因此不是mybatis导致的回滚,只可能是InnoDB里面发生了回滚。
3.DB发生了回滚
查阅MySQL官方文档https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlock-detection.html发现在发生死锁时,数据库内核会自动回滚较小的事务,来避免死锁的持续。(该处的较小事务的大小由插入更新或者删除决定)
三、解决方案
方案1:使用REQUIRES_NEW,创建新事务后,在此处较小的事务的大小就等于新事务了。
优势:改造简单,成本低
劣势:没从根本上解决死锁问题。
方案2:先更新扩展表,再更新主表。
优势:不引入新事务。
劣势:可能影响其他逻辑。
方案3: 删除区分度低的索引,避免死锁。
优势:从根本上解决死锁
劣势:在没有该索引后,往常从该索引查询的语句效率会变低。因此依靠被删索引的查询语句查扩展表需要走ES,并且需要考虑写库后立即查的情况(es存在主从延迟的问题)。
考虑时间成本和改动范围,最终选择方案一。代码如下:
四、结论
1.死锁的原因:是因为扩展表的“idx_field_id_field_value25”索引区分度低(存在过多与索引相关的相同字段值)。
2.回滚的原因:发现在发生死锁时,数据库内核会自动回滚较小的事务,来避免死锁的持续。
3.最开始使用Propagation.NESTED事务的原因:想创建一个安全点,使得回滚只影响到updateExpansionTable方法,但是Propagation.NESTED在内层只会创建安全点,不会创建新事务,而死锁的回滚会跳过安全点直接回滚事务,导致没达到预期情况。
五、补充
1 死锁
死锁通常发生在多个事务同时访问相同的资源,并且每个事务都在等待另一个事务释放资源的情况下。不存在事务的情况下,不会发生死锁,因为资源的锁定和释放是即时的,不会出现等待的情况,因此不会发生死锁。
一个工单的更新通常包含:一条主表记录的更新和多条扩展表记录的更新(查看扩展表的create语句就能发现一条扩展表记录是对应工单的一个字段,而工单通常不止更新一个字段)。在实际的代码中,就对更新语句涉及的扩展表记录进行了排序,保证每个工单进行更新时,工单的扩展字段的都是有序的,以此来降低死锁发生的可能性。
2.索引区分度
2.1 简介:
索引的区分度低是指索引列中的重复值较多,导致索引不能有效区分不同的记录。区分度低的索引在查询时会导致大量的记录被扫描,从而降低查询效率,并可能引发锁争用和死锁问题。
在一个包含大量记录的表中,如果某个列的值大部分都是相同的,那么以该列为基础创建的索引的区分度就很低。查询时,数据库需要扫描大量的记录才能找到匹配的结果,这不仅影响性能,还可能导致多个事务同时竞争相同的锁,从而引发死锁。例如,此处的idx_field_id_field_value25 的fieldId 为110代表结费方式,field_value的值为1代表上门、为2代表远程,如果每个工单都存在节费方式的属性,那么这个 idx_field_id_field_value25 的索引就极其容易重复。
2.2 索引的区分度低可能会导致数据库中的死锁问题。以下是一些常见的解决方法:
1.优化索引:确保索引的选择性高,避免使用低区分度的列作为索引。
2.减少事务持有锁的时间:尽量缩短事务的执行时间,减少锁的持有时间。
3.合理设计事务顺序:确保多个事务访问资源的顺序一致,避免循环等待。
4.使用合适的隔离级别:根据业务需求选择合适的事务隔离级别,避免不必要的锁争用。
5.分解大事务:将大事务分解为多个小事务,减少锁的竞争。
2.3可以通过以下SQL语句查看索引的区分度:
SELECT COUNT(DISTINCT column_name) / COUNT(*) AS selectivity
FROM table_name;
3事务传播性
3.1事务介绍
3.2此处两事务对比
Propagation.NESTED 和 Propagation.REQUIRES_NEW 是Spring事务管理中的两种传播行为,它们的区别如下:
* Propagation.NESTED:
- 嵌套事务。
- 如果当前存在一个事务,则在嵌套事务中执行。如果当前没有事务,则创建一个新的事务。
- 嵌套事务依赖于外部事务,嵌套事务的提交或回滚不会影响外部事务,但外部事务的回滚会导致嵌套事务也回滚。
* Propagation.REQUIRES_NEW:
- 新建事务。
- 无论当前是否存在事务,都创建一个新的事务。当前事务会被挂起,直到新事务完成。
- 新事务的提交或回滚不会影响挂起的事务,两个事务相互独立。
总结:
- Propagation.NESTED 适用于需要嵌套事务的场景,嵌套事务依赖于外部事务。
- Propagation.REQUIRES_NEW 适用于需要完全独立事务的场景,新事务与外部事务相互独立。