前言
项目上有一个基于消息队列的订单统计功能,每次在支付微服务完成一笔订单,就会通过广播的方式将该笔订单发送到数据中心微服务上,数据中心微服务接收后根据“支付完成时间”、“支付地点”、“项目Guid”进行统计。如果当前不存在该数据,则做新增动作,如果存在,则先读取当前数据的金额,再通过BigDecimal加上当前订单的金额。
这个功能在本地单机执行的时候没有任何问题,但是一到生产环境上就会时常出现统计金额不准确的问题。之前一直以为是因为统计时有部分订单仍卡在消息队列中,于是都通过直接修改生产环境的数据库解决,这次针对这个问题写了定时任务,发现每次执行都会出现不一样的结果。
一、原因分析
回看这部分代码,逻辑没有任何问题,修改的操作可以拆分为以下三个步骤:
(1)读取MySQL数据库中的统计数据;
(2)将数据缓存到JAVA中并计算统计结果;
(3)把结果重新写入MySQL数据库。
可以想到,在分布式系统中,消息队列广播发出后会由不同的机器接收,如此即会出现同一时间“支付完成时间”、“支付地点”以及“项目Guid”相同的多笔订单分配到不同的机器上,出现以下结果:
(1)机器1读取到MySQL数据库中的统计数据;
(2)机器1将数据缓存到JAVA中并计算统计结果;
(3)机器2读取到MySQL数据库中更新前的统计数据;
(4)机器1将结果重新写入MySQL数据库;
(5)机器2将数据缓存到JAVA中并计算统计结果;
(6)机器2将结果重新写入MySQL数据库。
所以每次执行都会出现不一样的结果。
二、解决方式
针对此类问题有以下几种解决方式:
1.线程休眠
1.1 简介
Thread.sleep()方法可以让当前线程暂停执行一段时间,单位是毫秒。
1.2 效果
该方法会使当前线程暂停执行,让出CPU资源给其他线程,但是该线程仍然持有锁。缺点是可能会影响程序的性能和响应性,因为其他线程可能需要等待该线程释放锁才能继续执行。除此之外,还可能会导致线程调度延迟,因为操作系统可能会在一段时间后才重新调度该线程。
try {
Thread.sleep(100);
} catch( Exception error ) {
logger.error(error);
}
在每次查询之前执行线程休眠,该方法治标不治本,只能解决部分问题且对性能影响极大,排除掉。
2.行级锁(ForUpdate)
2.1 简介
行级锁是一种数据库锁定机制,它可以在数据库中对单个数据行进行锁定,以保证并发访问时的数据一致性。当一个事务对某个数据行进行修改时,行级锁会将该数据行锁定,其他事务无法对该数据行进行修改,直到该事务释放锁定。行级锁可以提高并发访问的效率,减少锁定的粒度,从而提高数据库的并发性能。
2.2 效果
for update是一种行级锁。使用带行级锁的SQL语句时,其他机器在查询到同一条数据时会等待该行解锁,不允许两个事务同时对一个表进行更新,真正对表进行更新时,是以独占方式锁表,一直到提交或复原该事务为止。
3.SQL语句
将查询步骤去除,使用SQL在查出数据时直接计算统计结果。
4.单机执行
将计算统计结果程序单独拿出来单机执行,繁琐低效,排除掉。
三、代码展示
本文选用的是行级锁的方式解决,在Example的查询方法中使用。
Example example1 = new Example(StatPayBankDay.class);
criteria1.andEqualTo("guid", payBankDays.get(0).getGuid());
example1.setForUpdate(true);
⚠️ 使用行级锁需要注意以下四点:
(1)锁的范围,避免锁定过多的行导致性能下降,For Update虽然是行级锁,但是并不所有的情况下都只锁行,某些情况下也会将整个表锁住。为避免整张表被锁住导致所有查询都无法执行,查询方法需要对该表的主键进行查询;
(2)事务的提交和回滚,避免出现死锁或长时间占用锁的情况。故该类需要加上事务回滚的注解@Transactional(rollbackFor = Exception.class);
(3)并发性,避免多个事务同时请求锁导致性能下降;
(4)事务隔离级别,确保事务隔离级别为可重复读或更高级别,否则可能会出现锁失效的情况。