一次生产环境数据库死锁问题排查与解决

2023年 8月 13日 53.5k 0

1 背景

最近线上清算服务RocketMQ消费频繁报SQL死锁异常,虽然最终可以基于事务回滚和消息消费重试保证最终正确性,但频繁回滚和重试是会降低消费端吞吐量的。个人通过分析线上MySQL死锁日志、阅读相关逻辑代码,找到了真正的问题所在,并给出了解决办法。特在此整理出来,互相学习提升,如果文中有错误的地方欢迎指正。

2 环境

生产环境使用的数据库为Mysql 8.0.13版本,InnoDB引擎,事务隔离级别READ-COMMITED

发生死锁的表结构及索引情况(只保留了表关键字段)

create table clearing_settle_org_cost_order
(
    id                    int auto_increment
        primary key,
    org_cost_uid          varchar(64)                                 not null comment '成本唯一id',
    org_id                int                                         not null comment '机构id',
    product_id            int                                         not null comment '产品id',
    back_article          decimal(12, 3) default 0.000                not null comment '回款金额',
    repay_date            varchar(32)                                 not null comment '还款日期:年-月',
    create_time           datetime(3)    default CURRENT_TIMESTAMP(3) not null comment '创建时间',
    update_time           datetime(3)    default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3) comment '更新时间',
    is_delete             int            default 0                    null comment '删除状态 1删除  0未删除',
    constraint org_cost_uid
        unique (org_cost_uid)
)
    comment '清算-机构结算成本单';
​
create index org_id_index
    on clearing_settle_org_cost_order (org_id);
​
create index product_id_index
    on clearing_settle_org_cost_order (product_id);
​

3 知识储备

什么是数据库死锁:通常是因为两个及以上事务发生了死循环锁依赖,此时不得不回滚来释放锁。

死锁的四个必要条件:互斥、占有且等待、不可强占用、循环等待。只要系统发生死锁,这些条件必然成立,但是只要破坏任意一个条件就死锁就不会成立。

在实际业务中,大部分死锁都是行级锁导致的,InnoDB引擎行级锁有三大类:

  • Record Lock:记录锁,也就是仅仅把一条记录锁上;
  • Gap Lock:间隙锁,锁定一个范围,但是不包含记录本身;
  • Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。

这里我们线上数据库使用的隔离级别为RC,只有Record Lock记录锁。

行级锁锁的是什么?

锁的是索引,索引分为聚簇索引(主键索引)、二级索引(辅助索引),如果查询直接走聚簇索引,则锁聚簇索引,如果走二级索引的话,先锁二级索引,再锁聚簇索引(重点)。

关于行级锁在RR、RC隔离级别的详细加锁规则大家可以自行了解,这里就不过多赘述。

4 排查过程

当数据库发生死锁时,执行下面的命令可查看数据库最近的一次死锁日志数据

show engine innodb status;
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-08-09 16:03:24 0x7f6dc21cf700
*** (1) TRANSACTION:
TRANSACTION 4679832935, ACTIVE 0 sec fetching rows
mysql tables in use 3, locked 3
LOCK WAIT 15 lock struct(s), heap size 1136, 5 row lock(s)
MySQL thread id 302348430, OS thread handle 140109382948608, query id 4556452767 172.16.18.77 assets Sending data
SELECT  id,org_cost_uid,settle_order_num,org_id,org_name,product_id,product_name,back_article,service_fee_rate,bonus,fine,service_fee,settle_order_url,generate_settle_order,repay_date,voucher_state,operation_user_id,operation_user_name,is_delete,create_time,update_time  FROM clearing_settle_org_cost_order 
​
 WHERE (org_id = 1561 AND product_id = 45 AND repay_date = '2023-08' AND is_delete = 0) for update
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 7680 page no 374 n bits 152 index PRIMARY of table `assets`.`clearing_settle_org_cost_order` trx id 4679832935 lock_mode X locks rec but not gap
Record lock, heap no 25 PHYSICAL RECORD: n_fields 23; compact format; info bits 0
​
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7680 page no 227 n bits 1112 index product_id_index of table `assets`.`clearing_settle_org_cost_order` trx id 4679832935 lock_mode X locks rec but not gap waiting
Record lock, heap no 117 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 8000002d; asc    -;;
 1: len 4; hex 80001c70; asc    p;;
​
*** (2) TRANSACTION:
TRANSACTION 4679832933, ACTIVE 0 sec fetching rows
mysql tables in use 3, locked 3
178 lock struct(s), heap size 24784, 33 row lock(s)
MySQL thread id 570785034, OS thread handle 140109384840960, query id 4556452765 172.16.18.77 assets Sending data
SELECT  id,org_cost_uid,settle_order_num,org_id,org_name,product_id,product_name,back_article,service_fee_rate,bonus,fine,service_fee,settle_order_url,generate_settle_order,repay_date,voucher_state,operation_user_id,operation_user_name,is_delete,create_time,update_time  FROM clearing_settle_org_cost_order 
​
 WHERE (org_id = 598 AND product_id = 45 AND repay_date = '2023-08' AND is_delete = 0) for update
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 7680 page no 227 n bits 1112 index product_id_index of table `assets`.`clearing_settle_org_cost_order` trx id 4679832933 lock_mode X locks rec but not gap
Record lock, heap no 117 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
​
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7680 page no 374 n bits 152 index PRIMARY of table `assets`.`clearing_settle_org_cost_order` trx id 4679832933 lock_mode X locks rec but not gap waiting
Record lock, heap no 25 PHYSICAL RECORD: n_fields 23; compact format; info bits 0
​
*** WE ROLL BACK TRANSACTION (1)

从上面的死锁日志中我们可以知道,造成死锁的两条sql分别为:

事务一
SELECT  id,org_cost_uid,settle_order_num,org_id,org_name,product_id,product_name,back_article,service_fee_rate,bonus,fine,service_fee,settle_order_url,generate_settle_order,repay_date,voucher_state,operation_user_id,operation_user_name,is_delete,create_time,update_time  FROM clearing_settle_org_cost_order 
 WHERE (org_id = 1561 AND product_id = 45 AND repay_date = '2023-08' AND is_delete = 0) for update
事务二 
 SELECT  id,org_cost_uid,settle_order_num,org_id,org_name,product_id,product_name,back_article,service_fee_rate,bonus,fine,service_fee,settle_order_url,generate_settle_order,repay_date,voucher_state,operation_user_id,operation_user_name,is_delete,create_time,update_time  FROM clearing_settle_org_cost_order 
 WHERE (org_id = 598 AND product_id = 45 AND repay_date = '2023-08' AND is_delete = 0) for update

事务1持有PRIMARY锁,等待获取product_id_index锁,事务2持有product_id_index锁,等待获取PRIMARY锁,两个事务发生循环等待,造成死锁。两个事务都是加的Record Lock记录锁,前面也说到了RC隔离级别只有记录锁。

通过sql定位到对应代码,代码的逻辑是在进行mq消费,一个事务里执行了多次for update当前读,这里涉及到公司机密,就不贴源代码了。

这里我们先分析一下,这两条sql只有查询条件org_id不同,表中有org_id和product_id两条二级索引。

如果MySql优化器选择先走org_id索引:

1.定位到若干条数据后会给二级索引org_id=xx加记录锁,再给这些记录的主键索引加上记录锁;
2.再走product_id索引,会给二级索引product_id=45加上记录锁,前面加的主键记录锁不符合product_id=45的会释放掉。

这里步骤1是不存在资源竞争的,因为org_id并不一样,步骤2中,如果A事务先执行,那么A会拿到product_id=45的记录锁,B事务执行sql的时候会等待,然后A再执行这条sql的时候,并不会发生死锁。

如果MySql优化器选择先走product_id索引:

1.定位到若干条数据后会给二级索引product_id=45加记录锁,再给这些记录的主键索引加上记录锁;
2.再走org_id索引,会给二级索引org_id=xx加上记录锁,前面加的主键记录锁不符合org_id=xx的会释放掉。

这里步骤1会发生资源竞争,如果A事务先执行,拿到product_id=45的记录锁后,B事务执行sql的时候会等待,然后A再次执行这条sql的时候,同样也不会发生死锁。

通过上述分析我们知道,这两种情况都不会发生死锁。难道他们走的索引顺序不一样吗?我们查看一下这两条sql的执行计划。

事务一:

事务二:

执行计划并不一样,我们根据它们各自的执行计划来分析一下加锁过程。

  • 事务二先走product_id索引,定位到若干条数据后会给二级索引product_id=45加上记录锁,再给这些记录的主键索引加上记录锁,再走org_id索引,会给二级索引org_id=598加上记录锁,前面锁定的主键索引不符合org_id=598的会释放掉。最终加锁情况如下图:

  • 然后事务一开始执行,先走org_id索引,定位到若干条数据后会给二级索引org_id=1561加上记录锁,再给这些记录的主键索引加上记录锁,接着走product_id索引,发现product_id=45已被事务二加上了记录锁,事务一进入等待状态,最终加锁情况如下图:

    注意这三条主键上的记录锁PRIMARY(12573、12634、12701)

    我们看一下这三条记录对应的product_id字段值为多少:

  • 接着事务一再次执行上次的sql,和步骤一的逻辑一样,先走product_id索引,定位到若干条数据后会给二级索引product_id=45加上记录锁,再给这些记录的主键索引加上记录锁,这个时候死锁就出现了,事务一可以对product_id=45加记录锁这没问题,因为步骤一已经拿到了这个锁,当给这些记录的主键索引加记录锁的时候,有一条数据PRIMARY=12573被事务二加了记录锁(“事物一进入等待”)。

    事务一持有二级索引product_id=45的记录锁,等待获取主键索引PRIMARY=12573的记录锁;

    事务二持有主键索引PRIMARY=12573的记录锁,等待获取二级索引product_id=45的记录锁(死锁)。

  • 5 解决办法

    通过上面的排查分析,这次死锁发生的原因已经很清晰了,那么该如何解决呢?

    因为清算服务这部分Mq消费的代码逻辑不是我写的,而且代码逻辑比较复杂,直接改代码不太现实,最终决定在这张表上添加联合索引(org_id,product_id,repay_date)。

    如何有效的避免死锁的发生:

  • 设置事务等待锁的超时时间。当一个事务的等待时间超过该值后,就对这个事务进行回滚,于是锁就释放了,另一个事务就可以继续执行了。在 InnoDB 中,参数 innodb_lock_wait_timeout 是用来设置超时时间的,默认值时 50 秒。

  • 开启主动死锁检测。主动死锁检测在发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑,默认就开启。

  • 修改数据库隔离级别为RC,MySql默认级别为RR,RC没有间隙锁Gap Lock和组合锁Next-Key Lock,能一定程度的避免死锁的发生。

  • 尽量少使用当前读for update,数据更新时尽量使用主键。

  • 参考资料:

    • 《MySQL 是怎样运行的?》

    • 《MySQL45讲》

    • 《高性能MySQL》

    • mysql.taobao.org/monthly/202…

    • xiaolincoding.com/mysql/lock/…

    相关文章

    Oracle如何使用授予和撤销权限的语法和示例
    Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
    下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
    社区版oceanbase安装
    Oracle 导出CSV工具-sqluldr2
    ETL数据集成丨快速将MySQL数据迁移至Doris数据库

    发布评论