摘要:找到系统性能瓶颈所在,找出在逻辑上不相干,并且没有先后顺序的业务逻辑,将其放到不同的线程中执行,能够大大提供系统的性能。
本文分享自华为云社区《【高并发】我用多线程优化了亿级流量电商业务下的海量数据校对系统,性能直接提升了200%!!》,作者: 冰 河 。
最近不少运营同事找到我说:咱们的数据校对系统越来越慢了,要过很久才会显示出校对结果,你能不能快速优化一下呢?
优化背景
由于这个数据校对系统最初不是我开发的,我了解了下数据校对系统的业务,整体来说,数据校对系统的业务还是比较简单的。用户通过商城提交订单后,会在订单微服务中生成订单信息,保存在订单数据库中。
订单微服务会调用库存微服务的接口,扣减商品的库存数量,并且会将每笔订单扣减库存的记录保存在库存数据库中。为了防止用户提交订单后没有扣减库存,或者重复扣减库存,数据校对系统每天会校验订单中提交的商品数量与扣减的库存数量是否一致,并且会将校对的结果信息保存到数据校对信息表中。
数据校对系统的总体流程为:先查询订单记录,然后在查询库存的扣减记录,然后对比订单和库存扣减记录,然后将校对的结果信息保存到数据校对信息表中,整体流程如下所示。
为了能够让大家更好的了解数据校对系统对于订单和库存的校对业务,我将代码精简了下,核心业务逻辑代码如下所示。
//检测是否存在未对账订单 checkOrders = checkOrders(); while(checkOrders != null){ //查询未校对的订单信息 hasNoOrders = getHasNoOrders(); //查询未校对的库存记录 hasNoStock = getHasNoStock(); //校对数据并返回结果 checkResult = checkData(hasNoOrders, hasNoStock); //将结果信息保存到数据校对信息表中 saveCheckResult(checkResult); //检测是否存在未对账订单 checkOrders = checkOrders(); }
好了,上述就是系统优化的背景,想必看到这里,很多小伙伴应该知道问题出在哪里了。我们继续往下看。
问题分析
虽然很多小伙伴应该已经知道系统性能低下的问题所在了,这里,我们就一起详细分析下校对系统性能低下的原因。
既然运营的同事说数据校对系统越来越慢了,我们首先要做的就是找到系统的性能瓶颈所在。据了解,目前的数据对账系统,由于订单记录和库存扣减记录数据量巨大,所以查询未校对的订单信息的方法getHasNoOrders()和查询为校对的库存记录的方法getHasNoStock()相对来说比较慢。并且在数据校对系统中,校对订单和库存记录的方法是单线程执行的,我们可以简单画一个时间抽线图,如下所示。
由图可以看出,以单线程的方式getHasNoOrders()方法和getHasNoStock()方法耗费了大量的时间,这两个方法本身在逻辑上就是两个独立的方法,并且这两个方法没有先后的执行的顺序依赖。那这两个方法能不能并行执行呢?很显然是可以的。那我们把getHasNoOrders()方法和getHasNoStock()方法分别放到两个不同的线程中,优化下系统的性能,整体流程如下所示。
优化后,我们将getHasNoOrders()方法放到线程1中执行,getHasNoStock()方法放到线程2中执行,checkData()方法和saveCheckResult()方法发放到线程3中执行,优化后的系统性能相比优化前的系统性能几乎提升了一倍,优化效果相对来说还是比较明显的。
说到这里,大家应该应该知道具体怎么优化了吧?好,我们继续往下看!
解决方案
解决问题的思路有了,接下来,我们看看如何使用代码实现我们上面分析的解决问题的思路。这里,我们可以分别开启两个线程执行getHasNoOrders()方法和getHasNoStock()方法,在主线程中执行checkData()方法和saveCheckResult()方法。这里需要注意的是:主线程需要等待两个子线程执行完毕之后再执行checkData()方法和saveCheckResult()方法。
为了实现这个功能,我们可以使用Thread类中join()方法。这里,具体的逻辑就是在主线程中调用两个子线程的join()方法实现阻塞等待,当两个子线程执行完毕退出时,调用两个子线程join()方法的主线程会被唤醒,从而执行主线程中的checkData()方法和saveCheckResult()方法。大体代码如下所示。
//检测是否存在未对账订单 checkOrders = checkOrders(); while(checkOrders != null){ Thread t1 = new Thread(()->{ //查询未校对的订单信息 hasNoOrders = getHasNoOrders(); }); t1.start(); Thread t2 = new Thread(()->{ //查询未校对的库存记录 hasNoStock = getHasNoStock(); }); t2.start(); //阻塞主线程,等待线程t1和线程t2执行完毕 t1.join(); t2.join(); //校对数据并返回结果 checkResult = checkData(hasNoOrders, hasNoStock); //将结果信息保存到数据校对信息表中 saveCheckResult(checkResult); //检测是否存在未对账订单 checkOrders = checkOrders(); }
至此,我们基本上能够解决问题了。但是,还有没有进一步优化的空间呢?我们进一步往下看。
进一步优化
通过上面对系统优化,基本能够达成我们的优化目标,但是上面的解决方案存在着不足的地方,那就是在while循环里每次都要新建两个线程分别执行getHasNoOrders()方法和getHasNoStock()方法,了解Java多线程的小伙伴们应该都知道,在Java中创建线程可是个非常耗时的操作。所以,最好是能够将创建出来的线程反复使用。这里,估计很多小伙伴都会想到使用线程池,没错,我们可以使用线程池进一步优化上面的代码。
遇到新的问题
不过在使用线程池进一步优化时,我们会遇到一个问题,就是主线程如何等待子线程中的结果数据呢?说直白点就是:主线程如何知道子线程中的getHasNoOrders()方法和getHasNoStock()方法执行完了? 由于在之前的代码中我们是在主线程中调用子线程的join()方法等待子线程执行完毕,获取到子线程执行的结果后,继续执行主线程的逻辑。但是如果使用了线程池的话,线程池中的线程根本不会退出,此时,我们无法使用线程的join()方法等待线程执行完毕。
所以,主线程如何知道子线程中的getHasNoOrders()方法和getHasNoStock()方法执行完了? 这个问题就成了关键的突破点。这里,我们使用线程池进一步优化的代码如下所示。
//检测是否存在未对账订单 checkOrders = checkOrders(); //创建线程池 Executor executor = Executors.newFixedThreadPool(2); while(checkOrders != null){ executor.execute(()->{ //查询未校对的订单信息 hasNoOrders = getHasNoOrders(); }); executor.execute(()->{ //查询未校对的库存记录 hasNoStock = getHasNoStock(); }); /**如何知道子线程中的getHasNoOrders()方法和getHasNoStock()方法执行完了成为关键**/ //校对数据并返回结果 checkResult = checkData(hasNoOrders, hasNoStock); //将结果信息保存到数据校对信息表中 saveCheckResult(checkResult); //检测是否存在未对账订单 checkOrders = checkOrders(); }
那么,如何解决这个问题呢?我们继续往下看。
新的解决方案
相信细心的小伙伴们能够看出,整个业务的场景就是:一个线程需要等待其他两个线程的逻辑执行完毕后再执行。在Java的并发类库中,为我们提供了一个能够在这种场景下使用的类库,那就是CountDownLatch类。
使用CountDownLatch类优化我们程序的具体做法就是:在程序的while()循环中首先创建一个CountDownLatch对象,计数器的值初始化为2。分别在hasNoOrders = getHasNoOrders();代码和hasNoStock = getHasNoStock();代码的后面调用latch.countDown()方法使得计数器的值分别减1。在主线程中调用latch.await()方法,等待计数器的值变为0,继续往下执行。这样,就能够完美解决我们遇到的问题了。优化后的代码如下所示。
//检测是否存在未对账订单 checkOrders = checkOrders(); //创建线程池 Executor executor = Executors.newFixedThreadPool(2); while(checkOrders != null){ CountDownLatch latch = new CountDownLatch(2); executor.execute(()->{ //查询未校对的订单信息 hasNoOrders = getHasNoOrders(); latch.countDown(); }); executor.execute(()->{ //查询未校对的库存记录 hasNoStock = getHasNoStock(); latch.countDown(); }); //等待子线程的逻辑执行完毕 latch.await(); //校对数据并返回结果 checkResult = checkData(hasNoOrders, hasNoStock); //将结果信息保存到数据校对信息表中 saveCheckResult(checkResult); //检测是否存在未对账订单 checkOrders = checkOrders(); }
至此,我们就完成了系统的优化工作。
总结与思考
这次系统性能的优化,主要是将单线程执行的数据校对业务,优化成使用多线程执行。在平时的工作过程中,我们需要认真思考,找到系统性能瓶颈所在,找出在逻辑上不相干,并且没有先后顺序的业务逻辑,将其放到不同的线程中执行,能够大大提供系统的性能。
这次,对于系统的优化,我们最终使用线程池来执行比较耗时的查询订单与查询库存记录的操作,并且在主线程中等待线程池中的线程逻辑执行完毕后再执行主线程的后续业务逻辑。这种场景,使用Java中提供的CountDownLatch类再合适不过了。这里,再强调一下:CountDownLatch主要的使用场景就是一个线程等待多个线程执行完毕后再执行。如下图所示。
这里,也进一步提醒了我们:如果想学好并发编程,熟练的掌握Java中提供的并发类库是我们必须要做到的。