然而IO又是业务系统中不可能干掉的操作,但频繁或者错误的使用IO会给系统带来非常明显的性能问题,轻则拖慢接口影响用户体验,重则OOM直接宕机。 针对IO问题带来性能问题,这里我总结了三种方式 批处理、缓存和多线程,虽然看起来是很简单的操作,但还是得在合适的地方正确使用才能发挥出这三种方法的价值。
首先是批处理,这里先说一个真实的案例, 在2021年我们在做服务上云过程中,有个接口上云后,时延从原本的50ms左右涨到了150ms,后来排查发现,之前是串行化去调用KMS,这个服务上云后和KMS的服务端出现了跨机房调用,单次KMS的调用时长增长了近0.5ms。 单看这0.5ms确实不算多,但也架不住几十次的串行调用累计到一起,最终出现了100ms的总延时增长。这种接口时延增长大到原来的三倍,用户是很容易感受到的,可能他们的感受就是这应用真卡!
上面这个问题复现起来很简单,其实就一个for循环,串行去调用kms解密数据量。
for (String str : strList) {
decodedStr = kmsClient.decrypt(str); // 单次调用需要0.5-1ms,串行100次需要50-100ms
}
上述代码整体的主要的耗时其实并不是kms对数据解密的过程上(仅需要微秒级),而是请求发送和接收结果数据时数据在网络上传输的耗时,这就取决于双方服务之间的物理距离了,我们大部分服务都是在北京部署,但仍会出现跨机房调用的情况,这个时候网络延时也会增长0.5-1ms。批处理提升IO性能的原理,其实就是用单次网络IO替代掉原有的多次网络IO,IO时长越长,优化效果越显著。 用一个生活中的例子大家更容易理解些,假设你要给家里准备一份晚餐,其中很重要的一步就是去菜市场买菜,你是一样一样买?还是一次性全买齐了? 这就是单次处理和批处理的区别。
这个性能问题看似简单,其实在实际编程过程中经常犯,稍不留神就大批量串行IO调用,比如在for循环中查库(你是不是已经在脑海中想到自己写的问题代码了)。 如何避免自己在日常编程中出现类似的问题,我总结了一条编程指导经验,那就是 在任何循环中尽量不要产生IO调用,除非你知道自己在做什么。
当然也不是所有的IO都会产生问题,有些IO非常快,而且你串行的频次也不是很高,贸然将代码改成批处理的逻辑会显著增加代码复杂度,增加维护成本反而得不偿失,所以建议还是根据具体的IO类型和具体需求,评估具体是否要做批处理。以下我给出一些具体的IO类型和单次IO耗时参考值,大家写代码的时候可以关注下。
IO类型 | 耗时 | 备注 |
---|---|---|
SSD固态磁盘随机访问 | 0.1ms | 目前大部分服务器在使用SSD了,小文件读写的耗时几乎可以不关注,但如果文件非常大时,这里各方的带宽就是瓶颈,耗时也容易快速增长,重点关注大文件。 |
Redis访问 | 0.1ms | 简单Redis查询,主要还是在网络上,Redis服务自身处理请求仅几十us,只要不出大key,基本没问题。 |
mysql查询 | 1-10ms | 简单查询可以在10ms下,但涉及到复杂查询或者大量数据无索引的情况下,耗时会显著增长。mysql的异常查询是很多业务系统的性能问题主要来源。 |
HDD机械磁盘随机访问 | 10ms | 主要磁盘寻道时间,取决于磁盘转速,如果你恰好用了HDD又想读写文件,无论文件大小这部分耗时是一定不能忽略的。 |
调用第三方服务 | 1-100ms | 取决于依赖方的接口性能,不同接口延时的方差非常大,调用第三方接口,性能和容量都需要非常仔细的评估。 |
同城跨机房RTT | 0.5ms | - |
物理距离每增加50-100公里 | rtt +1ms | 延时主要来源于光在光纤中的传播耗时+交换机和路由器的处理耗时,比如从广州到北京,一个RTT就需要50ms,对接外部服务接口,如果关注性能,物理距离一定要考虑进去。 |
高IO的应用有个特点,就是大量的数据其实是被重复加载的,这也是”局部性“的一个体现,局部性告诉我们,只有少量的数据会被大量的加载。 利用局部性,我们只要将重要的小部分数据缓存起来,就可以减少大量的IO,从而提升我们系统的性能。如果我们用平均延时来评估性能,我们可以用一个平均延迟计算公式来描述加缓存后的性能:
avgLatency = hitRate * cacheLatency + (1 - hitRate) * originalLatency
其中avgLatency代指加了缓存后的平均延迟,hitRate表示缓存的命中率,cacheLatency指的是访问一次缓存所需要的耗时,在实际使用中,如果我们使用了本地缓存,我们可以简单粗暴认为cacheLatency是0,以上公式就可以简化为avgLatency = (1 - hitRate) * originalLatency 。 从简化后的公式可以看出加缓存后的效果仅跟缓存的命中率有关系,如果cache命中率是90%,就会有10倍的性能提升,如果是99%就会有100百性能提升(简略计算),只要我们无限提升缓存命中率,似乎就能无限提升性能。那命中率又和什么相关呢? 答案就是数据的分布、缓存的大小和数据的淘汰策略三者相关。
数据分布: 现实世界中,大部分数据的访问都受局部性的影响,用大白话讲就是只有少部分数据会被频繁访问,如果把数据被访问频次曲线画出来,如上图。
缓存大小: 这个很好理解,只要缓存的数据足够多,缓存命中率就越高。
淘汰策略: 淘汰策略是指在缓存容量不足的情况下,如何剔除价值最低的数据,常见的淘汰策略有LRU、LFU、FIFO,我们实际情况中用的最多的就是LRU。
正确考虑到以上三点后,我们大部分情况下是可以将少量高频被访问的数据缓存起来,从而提升系统性能。使用Cache有个额外需要注意的一项就是数据一致性,在cache的使用过程中缓存命中率和数据一致性几乎就是相悖的,很难做到两全其美,就比如我在上篇文章《从CPU的视角看 多线程代码为什么那么难写!》中写道的CPU Cache,其实就是硬件层面使用Cache优化IO性能的一个典型案例,但CPU为保证数据一致性却给当代程序员留下一堆"坑"。
在实际工作中,关于Cache实现我们有很多选择,常用的比如Guava中的LoadingCache、caffiene、ehcache、redis,spring中也有spring-cache 高级封装,这些如果你都不想用的话,你都可以用Map自己撸一个…… 这里先打个广告,后续关于cache的配置、使用及注意事项会再出一篇详细的文章, 我这里就先不展开了。
以上两种方式的本质,其实是通过优化非必要的IO次数来提升性能,但现实情况中并不是所有的IO都可以被优化掉,针对这种情况,其实也就只多线程一条路可选了。这个思路也很好理解,用大白话来说,如果活太多干不完就多招两个人来干。 在IO密集型系统中,多线程的优势在于它能充分利用CPU的计算能力。当一个线程在等待IO操作(如网络请求或磁盘读写)完成时,CPU可以切换到其他线程去执行其他任务,而不是闲置不用。这样,我们就可以充分利用CPU资源,提高系统的响应速度。
但是,使用多线程并非没有代价。首先,需要注意的是线程切换的开销。如果线程数量过多,线程切换的开销可能会消耗大量的CPU资源。其次,使用多线程会显著增加代码的复杂度,需要考虑到很多并发相关的问题,如:线程间的同步、死锁、资源竞争等,这些都需要在编程时仔细考虑和处理,稍有不慎就会引入很难排查的Bug。
在Java中,我们可以通过使用ExecutorService、CompletableFuture等工具来创建并管理线程。当然,我们也可以直接使用Thread类来创建线程,但线程需要自行管理,不是很推荐。同时,Java提供了许多同步和并发工具,如synchronized关键字、ReentrantLock、Semaphore等,以帮助我们处理并发问题。
在多线程优化中,线程池的使用是非常常见的。线程池可以有效地管理和复用线程,避免了频繁地创建和销毁线程所带来的开销。在Java中,我们可以使用ExecutorService来创建一个线程池,然后将任务提交给线程池来执行。在Java8及以上的版本中,我们也可用使用parallelStream()很方便的将代码改造成多线程,但需注意parallelStream底层是使用同一个ForkJoinPool,大量使用可能会出现相互干扰的情况。
另一个常见的多线程优化方式是使用异步编程。异步编程可以让程序在等待IO操作完成的时候,不必阻塞当前线程,而是可以切换到其他任务进行处理。在Java中,我们可以使用Future、CompletableFuture等工具来进行异步编程。
总的来说,多线程可以是一个强大的工具,可以显著提高IO密集型系统的性能。但是,使用多线程也需要谨慎,需要处理好并发问题,才能确保程序的正确性和稳定性。
在面对IO密集型系统性能优化时,我们可以通过三种主要的方式来进行:批处理、缓存和多线程。这三种方式各有其优点和适用场景。
在实际应用中,这三种方式往往会结合使用,以适应不同的性能需求和系统环境。选择哪种方式,或者如何结合使用,需要根据具体的业务需求、系统环境和性能目标来决定。在进行性能优化时,我们需要深入理解我们的系统,找出性能瓶颈,然后有针对性的进行优化。同时,我们还需要通过性能测试和监控,来验证我们的优化效果,以及及时发现和解决新的性能问题。只有通过这样的方式,我们的系统才能持续提供高效、稳定的服务。