高并发下系统出现大量数据库锁超时报错,该如何处理?
大事务
在数据库和分布式系统中,大事务(Long-Running Transactions)通常指的是一个事务中包含的操作数量非常多、数据处理量非常大、执行时间非常长,或涉及多个数据库或服务的复杂事务。大事务在执行过程中会占用大量资源,可能会对系统的性能和稳定性产生不良影响。以下是一些关于大事务的常见特征和问题:
特征
操作数量多:
事务中包含大量的数据库操作,如插入、更新、删除等。
事务涉及多个表或多个数据库的操作。
数据处理量大:
事务需要处理大量的数据,如批量插入、更新或删除数百万行数据。
事务涉及的数据集非常大,可能会占用大量内存和存储空间。
执行时间长:
由于操作数量多和数据量大,事务执行时间可能非常长,可能需要数秒、数分钟甚至更长时间才能完成。
跨多个服务或数据库:
分布式事务,涉及多个数据库、多个微服务或多个地理位置的操作。
需要通过分布式事务协调器来保证数据一致性。
问题和挑战
锁定资源:
大事务会长时间锁定资源,如数据库表、行或索引,导致其他事务无法访问被锁定的资源,可能引发死锁、资源争用、锁等待、锁超时、主从延迟等问题。
性能影响:
由于长时间占用资源和执行时间长,大事务会影响系统的整体性能,可能导致响应时间增加、系统吞吐量下降。
并发情况下数据库线程池占满
失败恢复:
大事务如果在执行过程中失败,恢复和回滚的成本较高,可能需要长时间才能恢复到一致状态,也就是回滚时间长。
分布式事务的回滚和恢复更加复杂,涉及多个数据库和服务的协调。
数据一致性:
确保数据一致性是一个挑战,尤其是在分布式系统中,网络延迟和分区故障可能导致数据不一致。
解决思路
拆分事务:
将大事务拆分为多个小事务,减少每个事务的操作数量和数据量。
将非事务操作排除在事务之外。
批量处理:
采用批量处理的方式,分批次处理数据,避免单个事务处理过多的数据。
异步处理:
采用异步处理模型,将长时间运行的操作异步化,减少对主事务的影响。
使用消息队列或事件总线来处理异步操作,保证最终一致性。
优化锁定策略:
优化数据库的锁定策略,使用行级锁而不是表级锁,减少锁的粒度。
尽量缩短事务的执行时间,减少锁的持有时间。
分布式事务管理:
使用分布式事务管理器(如 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();
}
可以将queryData1
和queryData2
两个查询方法放在事务外执行,将真正需要事务执行的代码才放到事务中,比如:addData3
和updateData4
方法,这样就能有效的减少事务的粒度。MySQL 的事务隔离级别是可重复读,所以不在事务中的查询,可能出现不可重复读问题,例如:
事务A开始,并读取某条记录。
事务B开始,并修改该记录并提交。
事务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、使用乐观锁和悲观锁
根据业务需求,选择乐观锁或悲观锁来控制并发:
乐观锁:适用于并发冲突较少的场景,通过版本号或时间戳来控制并发。
悲观锁:适用于并发冲突较多的场景,通过加锁机制来控制并发。
分布式事务管理
后续出一个轻量级的多系统数据一致性性方案的问题,这里不再赘述。
举个🌰
假设一个电商系统,用户下单后需要消耗库存、生成订单、发起支付和申请发货。如果所有这些操作都放在一个事务中,将会导致一个大事务。解决方案可以是:
将订单生成和库存消耗放在一个事务中。
支付可以异步进行,订单生成后触发生成支付流程。
支付成功后异步触发发货申请。
通过这种方式,原本的大事务被拆分成多个小事务和异步操作,减少了单个事务的复杂性和执行时间,提高了系统的性能和可靠性。