3

IO密集型服务提升性能的三种方法

 1 year ago
source link: https://zxs.io/article/1932
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

IO密集型服务提升性能的三种方法

2023-08-12 分类:程序猿进阶 阅读(4) 评论(0)
v2-6ff4328bff33b59fb518be52cd739561_1440w.avis?source=172ae18b&biz_tag=Post

  大部分的业务系统其实都是IO密集型的系统,比如像我们面向B端提供摄像头服务,很多的接口其实就是将各种各样的数据汇总起来,展示给用户,我们的数据来源包括Redis、Mysql、Hbase、以及依赖的一些服务方的数据,并不涉及到太多复杂的计算逻辑。在过去的半年中,因为我们数据量和业务复杂性的增长,确实遇到了一些明显的性能问题,分析大部分问题的本质原因就是IO太慢了。 我们系统中最复杂的计算逻辑执行最慢也就微秒级,而调一次数据库最快也得1-2毫秒,有着2-3个数量级的差距。

  然而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百性能提升(简略计算),只要我们无限提升缓存命中率,似乎就能无限提升性能。那命中率又和什么相关呢? 答案就是数据的分布、缓存的大小和数据的淘汰策略三者相关。

1523dba7090f4e80961e734a308b4efd.png

数据分布: 现实世界中,大部分数据的访问都受局部性的影响,用大白话讲就是只有少部分数据会被频繁访问,如果把数据被访问频次曲线画出来,如上图。
缓存大小: 这个很好理解,只要缓存的数据足够多,缓存命中率就越高。
淘汰策略: 淘汰策略是指在缓存容量不足的情况下,如何剔除价值最低的数据,常见的淘汰策略有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密集型系统性能优化时,我们可以通过三种主要的方式来进行:批处理、缓存和多线程。这三种方式各有其优点和适用场景。

  1. 批处理可以通过减少网络IO次数,显著减少网络传输的延迟时间,从而提升系统性能。但是,它需要我们仔细分析和设计我们的数据处理流程,才能找到合适的批处理策略。
  2. 缓存则是通过存储频繁访问的数据,减少了对慢速存储(如磁盘或网络)的访问,从而提升性能。但是,使用缓存时需要考虑数据的一致性问题,以及如何选择合适的缓存淘汰策略。
  3. 多线程则是通过并行处理多个任务,充分利用CPU的计算能力,从而提升性能。但是,使用多线程需要处理并发问题,以及线程管理和调度的开销。

  在实际应用中,这三种方式往往会结合使用,以适应不同的性能需求和系统环境。选择哪种方式,或者如何结合使用,需要根据具体的业务需求、系统环境和性能目标来决定。在进行性能优化时,我们需要深入理解我们的系统,找出性能瓶颈,然后有针对性的进行优化。同时,我们还需要通过性能测试和监控,来验证我们的优化效果,以及及时发现和解决新的性能问题。只有通过这样的方式,我们的系统才能持续提供高效、稳定的服务。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK