Code前端首页关于Code前端联系我们

选择MySQL作为生产环境更新死锁问题

terry 2年前 (2023-09-26) 阅读数 51 #数据库

生产环境出现MySQL死锁异常,MySQL版本5.6,隔离级别RC。

[CommandConsumer-pool-thread-1] Process error : 
org.springframework.dao.DeadlockLoserDataAccessException: 
### Error querying database.  Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
### The error may exist in class path resource [mybatis/mapper/sequence.xml]
### The error may involve defaultParameterMap
### The error occurred while setting parameters
### SQL: SELECT current_seq FROM sequence WHERE type = ? AND `date` = ? FOR UPDATE
### Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
复制代码

代码分析

根据日志记录,导致死锁的关键代码如下

    /**
     * 根据传入参数,生成一个序列号。
     *
     * @param type 序列号类型
     * @param date 时间
     * @return 一个新的序列号,第一次调用返回1,后续根据调用次数递增。
     */
    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
    public int getSequence(String type, LocalDate date) {

        // select * from sequence where type = #{type} and date = #{date} for update
        Sequence seq = mapper.selectForUpdate(type, date);

        // seq 还未初始化,select for update 就没锁住
        if (seq == null) {
            // insert ignore into sequence(type, date, current_seq) values(#{type}, #{date}, #{currentSeq})
            if (mapper.insertIgnore(type, date, 1)) {
                return 1;
            }
            // insert ignore 竞争失败,重试
            return getSequence(type, date);
        }

        // update sequence set current_seq = current_seq + 1 where id = #{id}
        mapper.forwardSeq(seq.getId(), 1);

        return seq.getCurrentSeq() + 1;
    }

   CREATE TABLE `sequence` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
      `type` varchar(32) NOT NULL COMMENT '类型',
      `date` date NOT NULL COMMENT '时间',
      `current_seq` int(11) NOT NULL COMMENT '当前最大序号',
      PRIMARY KEY (`id`),
      UNIQUE KEY `uk_seq` (`date`,`type`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='序列号'
复制代码

功能简述该代码主要实现了获取序列号的功能,常用于创建文档编号。例如:我们需要为每个支付订单生成一个支付订单号。格式为:A-20200101,代表A公司在20200101日的付款订单。但是,A公司每天的付款订单不止一张。为了保证支付订单号的唯一性,我们还必须添加一个自增的序列号。例如:A-20200101-1表示A的第一笔付款订单2020-01-01等。第二、第三个支付订单号分别为A-20200101-2、A-20200101-3...

代码申请为确保序列号在同时环境下不会重复,必须输入代码第一的。通过唯一索引锁定给定的数据行更新,然后更新数据行值 current_seq = current_seq + 1。返回 current_seq。

但是,有一个边界条件需要特殊处理,即第一次调用该函数时,还没有数据。 select update唯一索引返回null,必须插入序号为1的原始数据。返回null并且不加锁避免update会导致多次插入。代码中使用了忽略。如果输入忽略失败,则再次(递归)调用 getSequence 以获取下一个序列号。

看完代码,没有发现明显的异常。我们将尝试在本地重现死锁。

本地重放死锁:

手动重放:

  • 准备条件
    • MySQL 5.6
    • 事务隔离级别A和事务隔离级别RC♶前置条件通过监控SQL日志,经过多次测试,发现,以下两个操作可以重现死锁
    • 操作
      • A开始的第1步;插入(忽略)xxx;执行失败,因为 xxx 已存在。
      • B开始;选择xxx进行更新;被阻止,因为输入已经锁定
      • A 选择要更新的 xxx;成功
      • B 阻塞结束,导致死锁
    • 操作步骤 2
      • A 开始;选择xxx进行更新;成功完成持有独占锁
      • B开始;选择xxx进行更新;阻塞等待A释放排它锁
      • A输入(忽略)xxx;成功执行
      • B阻塞结束,诱发死锁
    • 触发死锁操作的共同特点
      • 就是有些数据已经存在,通过insert锁定事务,然后选择更新,可以通过锁定选择更新,然后插入。 ,它会导致等待锁定的更新事务发生死锁。
    • 死锁的原理
      • 还不清楚(如果有路人知道请告诉我)

    从单位重现:

        @Autowired
        private ISequenceService sequenceService;
    
        @Test
        public void test() throws InterruptedException {
            ExecutorService executorService = Executors.newFixedThreadPool(10);
    
            List<Runnable> runnableList = Lists.newLinkedList();
    
            for (int i = 0; i < 100; i++) {
                runnableList.add(() -> sequenceService.getSequence("TX", LocalDate.now()));
            }
    
            runnableList.forEach(executorService::execute);
    
            executorService.shutdown();
            executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
        }
    复制代码

    解决方案

    • 通过本地手动重现发现了一个死点如果死锁是在忽略输入失败后才发生的,并且如果在事务中触发了update select,只需避免这两个操作发生在同一个事务中即可。
    • 更改的代码
        /**
         * 根据传入参数,生成一个序列号。
         *
         * @param type 序列号类型
         * @param date 时间
         * @return 一个新的序列号,第一次调用返回1,后续根据调用次数递增。
         */
        @Override
        @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
        public int getSequence(String type, LocalDate date) {
    
            // select * from sequence where type = #{type} and date = #{date} for update
            Sequence seq = mapper.selectForUpdate(type, date);
    
            // seq 还未初始化,select for update 就没锁住
            if (seq == null) {
                // insert ignore into sequence(type, date, current_seq) values(#{type}, #{date}, #{currentSeq})
                if (mapper.insertIgnore(type, date, 1)) {
                    return 1;
                }
                // insert ignore 竞争失败,在一个新事务中重试,从而避免死锁
                return applicationContext.getBean(ISequenceService.class).getSequence(type, date);
            }
    
            // update sequence set current_seq = current_seq + 1 where id = #{id}
            mapper.forwardSeq(seq.getId(), 1);
    
            return seq.getCurrentSeq() + 1;
        }
    复制代码
    • 已通过单元测试验证,成功解决了死锁问题。

    总结

    • 如果在多锁的方法中使用递归的话,要特别注意,这样很容易造成多锁添加顺序不均匀的问题,造成死锁(与此无关) ,这个例子纯粹是一个反思)。
    • 事务选择update后锁定某行数据,然后插入(忽略)该行数据,出现死锁。这是可以理解的,因为通常没有逻辑来找出数据然后重新输入它。但是我们不小心写了代码,比如忽略输入失败时选择更新,和选择更新再输入基本是一样的,这就造成了死锁,从加锁原理来看,所以要注意写代码时要注意这一点。日常生活。 。

    作者:东正
    链接:https://juejin.im/post/5e86b8e6e51d4546e716ca55
    来源:掘金。版权所有 商业转载请联系作者授权。非商业转载请注明出处。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门