小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。
前言
系统的性能优化是每一个程序员的必经之路,但也可能是走过的最深的套路。它不仅需要对各种工具的深入了解,有时还需要结合具体的业务场景得出定制化的优化方案。当然,你也可以在代码中悄悄藏上一个Thread.sleep,在需要优化的时候少睡几毫秒(手动狗头)。性能优化这个课题实在是太浩瀚了,以至于目前市面上没有一本优质的书能够全面的总结这个课题。不仅如此,即使是深入到各个细分领域上,性能优化的手段也非常丰富,令人眼花缭乱。
本文也不会涵盖所有的优化套路,仅就最近项目开发过程中遇到的并发调用这一个场景给出自己的通用方案。大家可以直接打包或是复制粘贴到项目中使用。也欢迎大家给出更多的意见还有优化场景。
背景
不知大家在开发过程中是否遇到这样的一个场景,我们会先去调用服务A,然后调用服务B,组装一下数据之后再去调用一下服务C(如果你在微服务系统的开发中没有遇到这样的场景,我想说,要么你们系统的拆分粒度太粗,要么这一个幸运无下游服务依赖的底层系统~)
这条链路的耗时就是 duration(A) + duration(B) + duration(C) + 其它操作。从经验来看,大部分的耗时都来自于下游服务的处理耗时和网络IO,应用内部的CPU操作的耗时相比而言基本可以忽略不计。但是,当我们得知对服务A和B的调用之间是无依赖的时候,是否可以通过同时并发调用A和B来减少同步调用的等待耗时,这样理想情况下链路的耗时就可以优化成 max(duration(A),duration(B)) + duration(C) + 其它操作
再举一个例子,有时我们可能需要批量调用下游服务,比如批量查询用户的信息。下游查询接口出于服务保护往往会对单次可以查询的数量进行约束,比如一次只能查一百条用户的信息。因此我们需要多请求拆分多次进行查询,于是耗时变成了 n*duration(A) + 其它操作。同样,用并发请求的优化方式,理想情况下耗时可以降到 max(duration(A)) + 其它操作
这两种场景的代码实现基本类似,本文将会提供第二种场景的思路和完整实现。
小试牛刀
并发RPC调用的整体实现类图如下:
首先我们需要创建一个线程池用于并发执行。因为程序中通常还有别的使用线程池的场景,而我们希望RPC调用能够使用一个单独的线程池,因此这里用工厂方法进行了封装。
1 | java复制代码@Configuration |
如代码所示,我们声明了两个Spring的线程池AsyncTaskExecutor,分别是默认的线程池和RPC调用的线程池,并将它们装载到map中。调用方可以使用fetchAsyncTaskExecutor方法并传入线程池的名称来指定线程池执行。这里还有一个细节,Rpc线程池的线程数要显著大于另一个线程池,是因为Rpc调用不是CPU密集型逻辑,往往伴随着大量的等待。因此增加线程数量可以有效提高并发效率。
1 | java复制代码@Component |
submit方法封装了获取线程池和提交异步任务的逻辑。这里采用Callable+Future的组合来获取异步线程的执行结果。
线程池准备就绪,接着我们就需要声明一个接口用于提交并发调用服务:
1 | java复制代码public interface BatchOperateService { |
batchOperate方法中传入了function对象,这是需要并发执行的代码逻辑。requests则是所有的请求,并发调用会递归这些请求并提交到异步线程。config对象则可以对这次并发调用做一些配置,比如并发查询的超时时间,以及如果部分调用异常时整个批量查询是否继续执行。
接下来看一看实现类:
1 | java复制代码@Service |
通常我们提交给线程池后直接遍历Future并等待获取结果就好了。但是这里我们用CountDownLatch来做更加统一的超时管理。可以看一下BatchOperateCallable的实现:
1 | java复制代码public class BatchOperateCallable<T, R> implements Callable<R> { |
无论调用时成功还是异常,我们都会在结束后将计数器减一。当计数器被减到0时,则代表所有并发调用执行完成。否则如果在规定时间内计数器没有归零,则代表并发调用超时,此时会抛出异常。
潜在问题
并发调用的一个问题在于我们放大了访问下游接口的流量,极端情况下甚至放大了成百上千倍。如果下游服务并没有做限流等防御性措施,我们极有可能将下游服务打挂(这种原因导致的故障屡见不鲜)。因此需要对整个并发调用做流量控制。流量控制的方法有两种,一种是如果微服务采用mesh的模式,则可以在sidecar中配置RPC调用的QPS,从而做到全局的管控对下游服务的访问(这里选择单机限流还是集群限流取决于sidecar是否支持的模式以及服务的流量大小。通常来说平均流量较小则建议选择单机限流,因为集群限流的波动性往往比单机限流要高,流量过小会造成误判)。如果没有开启mesh,则需要在代码中自己实现限流器,这里推荐Guava的RateLimiter类,但是它只支持单机限流,如果要想实现集群限流,则方案的复杂度还会进一步提升
小结
将项目开发中遇到的场景进行抽象并尽可能的给出通用的解决方案是我们每一个开发者自我的重要方式,也是提高代码复用性和稳定性的利器。并发Rpc调用是一个常见解决思路,希望本文的实现可以对你有帮助。
本文转载自: 掘金