文章

高并发下系统出现大量数据库锁超时报错,该如何处理?

大事务

在数据库和分布式系统中,大事务(Long-Running Transactions)通常指的是一个事务中包含的操作数量非常多、数据处理量非常大、执行时间非常长,或涉及多个数据库或服务的复杂事务。大事务在执行过程中会占用大量资源,可能会对系统的性能和稳定性产生不良影响。以下是一些关于大事务的常见特征和问题:

特征

  1. 操作数量多

    • 事务中包含大量的数据库操作,如插入、更新、删除等。

    • 事务涉及多个表或多个数据库的操作。

  2. 数据处理量大

    • 事务需要处理大量的数据,如批量插入、更新或删除数百万行数据。

    • 事务涉及的数据集非常大,可能会占用大量内存和存储空间。

  3. 执行时间长

    • 由于操作数量多和数据量大,事务执行时间可能非常长,可能需要数秒、数分钟甚至更长时间才能完成。

  4. 跨多个服务或数据库

    • 分布式事务,涉及多个数据库、多个微服务或多个地理位置的操作。

    • 需要通过分布式事务协调器来保证数据一致性。

问题和挑战

  1. 锁定资源

    • 大事务会长时间锁定资源,如数据库表、行或索引,导致其他事务无法访问被锁定的资源,可能引发死锁、资源争用、锁等待、锁超时、主从延迟等问题。

  2. 性能影响

    • 由于长时间占用资源和执行时间长,大事务会影响系统的整体性能,可能导致响应时间增加、系统吞吐量下降。

    • 并发情况下数据库线程池占满

  3. 失败恢复

    • 大事务如果在执行过程中失败,恢复和回滚的成本较高,可能需要长时间才能恢复到一致状态,也就是回滚时间长。

    • 分布式事务的回滚和恢复更加复杂,涉及多个数据库和服务的协调。

  4. 数据一致性

    • 确保数据一致性是一个挑战,尤其是在分布式系统中,网络延迟和分区故障可能导致数据不一致。

解决思路

  1. 拆分事务

    • 将大事务拆分为多个小事务,减少每个事务的操作数量和数据量。

    • 将非事务操作排除在事务之外。

  2. 批量处理

    • 采用批量处理的方式,分批次处理数据,避免单个事务处理过多的数据。

  3. 异步处理

    • 采用异步处理模型,将长时间运行的操作异步化,减少对主事务的影响。

    • 使用消息队列或事件总线来处理异步操作,保证最终一致性。

  4. 优化锁定策略

    • 优化数据库的锁定策略,使用行级锁而不是表级锁,减少锁的粒度。

    • 尽量缩短事务的执行时间,减少锁的持有时间。

  5. 分布式事务管理

    • 使用分布式事务管理器(如 TCC 模型、Saga 模型)来协调分布式事务,确保数据一致性。

    • 在分布式环境中,尽量避免使用全局锁定,采用柔性事务(eventual consistency)模型。

最佳实践

拆分事务

1、使用编程式事务

针对长业务逻辑,少用 @Transactional 注解,粒度太粗,不好控制事务范围,这是出现大事务问题最常见的原因。

可以使用编程式事务,在spring项目中使用TransactionTemplate类的对象,手动执行事务。

部分代码如下:

 @Autowired
 private TransactionTemplate transactionTemplate;
 ​
 public void placeOrder(OrderDTO orderDTO) {
     transactionTemplate.execute((status) -> {
       saveOrder();
       return Boolean.TRUE;
     })
 }

或者使用一个Wrapper封装,可以参考本人Github上的代码片段:

https://gist.github.com/amuguelove/9829bdaacf0c67c7df822f54baa46b18

比较推荐封装一个,这样每个事务的生命周期是独立的生命周期,不相互影响。

2、 查询方法放到事务外

如果出现大事务,可以将查询方法放到事务外,也是比较常用的做法,因为一般情况下这类方法是不需要事务的。但是

比如出现如下代码:

 @Transactional(rollbackFor = Exception.class)
 public void placeOrder(OrderDTO orderDTO) {
      queryData1();
      queryData2();
      addData3();
      updateData4();
 }

可以将queryData1queryData2两个查询方法放在事务外执行,将真正需要事务执行的代码才放到事务中,比如:addData3updateData4方法,这样就能有效的减少事务的粒度。MySQL 的事务隔离级别是可重复读,所以不在事务中的查询,可能出现不可重复读问题,例如:

  1. 事务A开始,并读取某条记录。

  2. 事务B开始,并修改该记录并提交。

  3. 事务A再次读取该记录,但在事务外执行查询,读取到事务B修改后的数据,导致不可重复读。

可以结合实际情况考虑,这种情况一般读取的是已经提交的数据,出现数据不一致的情况在接受的范围内。

如果使用TransactionTemplate编程式事务这里就非常好修改。

 @Autowired
 private TransactionTemplate transactionTemplate;
 ​
 public void placeOrder(OrderDTO orderDTO) {
      queryData1();
      queryData2();
      transactionTemplate.execute((status) -> {
         addData3();
         updateData4();
         return Boolean.TRUE;
      })
 }

但是如果你实在还是想用@Transactional注解,该怎么拆分呢?

 public void placeOrder(OrderDTO orderDTO) {
    queryData1();
    queryData2();
    doSave(orderDTO);
 }
    
 @Transactional(rollbackFor = Exception.class)
 public void doSave(OrderDTO orderDTO) {
    addData3();
    updateData4();
 }

以上是非常经典的错误,这种直接方法调用的做法事务不会生效,关于Spring事务失效的文章可以参考:Spring事务失效和异常回滚问题总结

可以使用 AOPProxy 获取代理对象,实现相同的功能。具体代码如下:

 @Servcie
 publicclass OrderService {
 ​
    public void placeOrder(OrderDTO orderDTO) {
        queryData1();
        queryData2();
        ((OrderService) AopContext.currentProxy()).doSave(orderDTO);
    }
 ​
    @Transactional(rollbackFor=Exception.class)
    public void doSave(OrderDTO orderDTO) {
        addData3();
        updateData4();
     }
  }

3、识别非必要事务

在使用事务之前,我们都应该思考一下,是不是所有的数据库操作都需要在事务中执行?

 @Autowired
 private TransactionTemplate transactionTemplate;
 ​
 public void placeOrder(OrderDTO orderDTO) {
    transactionTemplate.execute((status) -> {
       saveOrder(orderDTO);
       addLog();
       updateStatisticsCount();
       return Boolean.TRUE;
    })
 }

上面的例子中,其实 addLog 增加操作日志方法 和 updateStatisticsCount更新统计数量方法,是可以不在事务中执行的,因为操作日志和统计数量这种业务允许少量数据不一致的情况。

 @Autowired
 private TransactionTemplate transactionTemplate;
 ​
 ...
 ​
 public void placeOrder(OrderDTO orderDTO) {
    transactionTemplate.execute((status) -> {
       saveOrder(orderDTO);           
       return Boolean.TRUE;
    })
    addLog();
    updateStatisticsCount();
 }

大事务中要鉴别出哪些方法可以非事务执行,需要对整个业务梳理一遍,才能找出最合理的方案。

批量处理

事务中避免一次性处理太多数据:

如果一个事务中需要处理的数据太多,也会造成大事务问题。比如为了操作方便,你可能会一次批量更新100000条数据,这样会导致大量数据锁等待,特别在高并发的系统中问题尤为明显。

解决办法是分页处理,100000条数据,分200页,一次只处理500条数据,这样可以大大减少大事务的出现。

异步处理

1、非阻塞主流程操作异步化

不是事务中的所有方法都需要同步执行?方法同步执行需要等待方法返回,如果一个事务中同步执行的方法太多了,会造成等待时间过长,出现大事务问题。

看看下面这个列子:

 @Autowired
 private TransactionTemplate transactionTemplate;
 ​
 public void placeOrder(OrderDTO orderDTO) {
    transactionTemplate.execute((status) -> {
       Order order = saveOrder(orderDTO);
       deliveryOrder(order);
       return Boolean.TRUE;
    })
 }

order方法用于下单,delivery方法用于发货,但是下单后就不需要马上发货,可以走mq异步处理逻辑。

 @Autowired
 private TransactionTemplate transactionTemplate;
 ​
 public void placeOrder(OrderDTO orderDTO) {
     transactionTemplate.execute((status) -> {
       saveOrder(orderDTO);
       return Boolean.TRUE;
     })
     sendMq();
 }

2、远程调用异步化

业务方法中调用其他系统的接口是不能避免的,由于网络不稳定,这种远程调的响应时间可能比较长,如果远程调用的代码放在某个事物中,这个事物就可能是大事务。

 @Transactional(rollbackFor=Exception.class)
 public void placeOrder(OrderDTO orderDTO) {
    callRemoteApi();
    saveOrder(orderDTO);
 }

远程调用的代码可能耗时较长,切记一定要放在事务之外。

 @Autowired
 private TransactionTemplate transactionTemplate;
 ​
 public void placeOrder(OrderDTO orderDTO) {
    callRemoteApi(); 
    transactionTemplate.execute((status) -> {
       saveOrder(orderDTO);
       return Boolean.TRUE;
    })
 }

远程调用的代码不放在事务中如何保证数据一致性呢?这就需要建立:重试+补偿机制,达到数据最终一致性。但是在实际项目并没有这么简单,例如:库存扣减,业务要求一定要先占用后下单,这时远程调用方法必须放在事务事务中。后续会开启一篇专栏讲述如何多系统保障数据一致性。

优化锁定策略

MySQL 提供了多种方式来优化锁定策略,以下是一些关键方法和注意事项:

1、使用适当的存储引擎

MySQL 支持多种存储引擎,每种存储引擎的锁机制不同。InnoDB 是 MySQL 默认的存储引擎,它支持行级锁,是优化锁定策略的理想选择。

2、合理设计索引

索引不仅能提高查询性能,还能优化锁定策略。使用合适的索引可以确保锁定更少的行,减少锁争用。

  • 主键索引:使用主键索引可以确保更新和删除操作只锁定相关的行。

  • 覆盖索引:在查询中使用覆盖索引(即查询的字段都在索引中),可以避免回表操作,减少锁争用。

  • 组合索引:对于经常使用的查询条件,创建组合索引可以提高查询效率并减少锁定的行数。

3、避免全表扫描

全表扫描会导致表级锁或大量行级锁,影响并发性能。可以通过以下方法避免全表扫描:

  • 优化查询条件:使用有效的查询条件,减少扫描的行数。

  • 添加适当的索引:确保查询使用索引,避免全表扫描。

4、使用合适的锁模式

InnoDB 支持多种锁模式,包括共享锁(S锁)和排他锁(X锁)。根据具体的业务需求,选择合适的锁模式:

  • 共享锁(S锁):在需要读取但不需要修改数据时使用,可以允许多个事务并发读取。

  • 排他锁(X锁):在需要修改数据时使用,保证数据的一致性。

5、使用乐观锁和悲观锁

根据业务需求,选择乐观锁或悲观锁来控制并发:

  • 乐观锁:适用于并发冲突较少的场景,通过版本号或时间戳来控制并发。

  • 悲观锁:适用于并发冲突较多的场景,通过加锁机制来控制并发。

分布式事务管理

后续出一个轻量级的多系统数据一致性性方案的问题,这里不再赘述。

举个🌰

假设一个电商系统,用户下单后需要消耗库存、生成订单、发起支付和申请发货。如果所有这些操作都放在一个事务中,将会导致一个大事务。解决方案可以是:

  1. 将订单生成和库存消耗放在一个事务中。

  2. 支付可以异步进行,订单生成后触发生成支付流程。

  3. 支付成功后异步触发发货申请。

通过这种方式,原本的大事务被拆分成多个小事务和异步操作,减少了单个事务的复杂性和执行时间,提高了系统的性能和可靠性。

推荐阅读

Spring事务失效和异常回滚问题总结

License:  CC BY 4.0