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

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
密钥管理服务KMS,1000个密钥,100个凭据,1个月
简介: 本文介绍了优化IO密集型系统性能的三种主要策略——批处理、缓存和多线程,其工作原理和适用场景。

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

数据分布: 现实世界中,大部分数据的访问都受局部性的影响,用大白话讲就是只有少部分数据会被频繁访问,如果把数据被访问频次曲线画出来,如上图。

缓存大小: 这个很好理解,只要缓存的数据足够多,缓存命中率就越高。

淘汰策略: 淘汰策略是指在缓存容量不足的情况下,如何剔除价值最低的数据,常见的淘汰策略有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的计算能力,从而提升性能。但是,使用多线程需要处理并发问题,以及线程管理和调度的开销。

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

目录
相关文章
|
3月前
|
存储 缓存 算法
如何优化阻塞IO的性能?
【10月更文挑战第6天】如何优化阻塞IO的性能?
67 5
|
2月前
|
存储 弹性计算 固态存储
阿里云服务器ESSD Entry系统盘测评IOPS、IO读写和时延性能参数
ESSD Entry云盘是阿里云推出的新一代云盘,具备高IOPS、低延迟和企业级数据保护能力。适用于开发与测试场景,支持按量付费和包年包月计费模式。99元和199元的ECS经济型e实例和通用算力型u1实例均采用ESSD Entry系统盘,性价比高。详细性能参数和价格请参考阿里云官方页面。
115 0
|
5月前
|
存储 Java 数据库连接
BIO阻塞IO流与数据存储大揭秘:性能与资源消耗,一文让你彻底解锁!
【8月更文挑战第25天】本文探讨了Java中BIO阻塞IO流与数据存储的概念及其实现。BIO作为一种传统IO模型,在处理每个客户端请求时需创建新线程并等待响应,这在并发量大时会导致性能下降和高资源消耗。示例代码展示了如何利用`ServerSocket`实现基于BIO的简单服务器。此外,文章还介绍了数据存储的基本方法,例如通过`BufferedWriter`向文件写入数据。两者对比显示,BIO适合连接数稳定的场景,而数据存储则适用于需要持久化保存信息的情况。通过这些分析和实例,希望能帮助读者更好地掌握这两种技术的应用场景及其优缺点。
60 0
|
5月前
|
C# 开发者 设计模式
WPF开发者必读:命令模式应用秘籍,轻松简化UI与业务逻辑交互,让你的代码更上一层楼!
【8月更文挑战第31天】在WPF应用开发中,命令模式是简化UI与业务逻辑交互的关键技术,通过将请求封装为对象,实现UI操作与业务逻辑分离,便于代码维护与扩展。本文介绍命令模式的概念及实现方法,包括使用`ICommand`接口、`RelayCommand`类及自定义命令等方式,并提供示例代码展示如何在项目中应用命令模式。
65 0
|
6月前
|
并行计算 监控 数据处理
构建高效Python应用:并发与异步编程的实战秘籍,IO与CPU密集型任务一网打尽!
【7月更文挑战第16天】Python并发异步提升性能:使用`asyncio`处理IO密集型任务,如网络请求,借助事件循环实现非阻塞;`multiprocessing`模块用于CPU密集型任务,绕过GIL进行并行计算。通过任务类型识别、任务分割、避免共享状态、利用现代库和性能调优,实现高效编程。示例代码展示异步HTTP请求和多进程数据处理。
74 8
|
6月前
|
并行计算 数据处理 Python
Python并发编程迷雾:IO密集型为何偏爱异步?CPU密集型又该如何应对?
【7月更文挑战第17天】Python并发编程中,异步编程(如`asyncio`)在IO密集型任务中提高效率,利用等待时间执行其他任务。但对CPU密集型任务,由于GIL限制,多线程效率不高,此时应选用`multiprocessing`进行多进程并行计算以突破限制。选择合适的并发策略是关键:异步适合IO,多进程适合CPU。理解这些能帮助构建高效并发程序。
137 6
|
6月前
|
算法 Java 程序员
解锁Python高效之道:并发与异步在IO与CPU密集型任务中的精准打击策略!
【7月更文挑战第17天】在数据驱动时代,Python凭借其优雅语法和强大库支持成为并发处理大规模数据的首选。并发与异步编程是关键,包括多线程、多进程和异步IO。对于IO密集型任务,如网络请求,可使用`concurrent.futures`和`asyncio`;CPU密集型任务则推荐多进程,如`multiprocessing`;`asyncio`适用于混合任务,实现等待IO时执行CPU任务。通过这些工具,开发者能有效优化资源,提升系统性能。
123 4
|
6月前
|
并行计算 Java 大数据
深度探索:Python异步编程如何优雅征服IO密集型任务,让CPU密集型任务也臣服!
【7月更文挑战第17天】Python的异步编程借助`asyncio`库提升IO密集型任务效率,如并发下载网页,通过`async def`定义协程,`asyncio.gather`并发执行。在CPU密集型任务中,结合`ThreadPoolExecutor`实现并行计算,利用多核优势。`asyncio.run`简化事件循环管理,使Python在高负载场景下表现更佳。
80 4
|
6月前
|
分布式计算 并行计算 Java
Python并发风暴来袭!IO密集型与CPU密集型任务并发策略大比拼,你站哪队?
【7月更文挑战第17天】Python并发处理IO密集型(如网络请求)与CPU密集型(如数学计算)任务。IO密集型适合多线程和异步IO,如`ThreadPoolExecutor`进行网页下载;CPU密集型推荐多进程,如`multiprocessing`模块进行并行计算。选择取决于任务类型,理解任务特性是关键,以实现最佳效率。
112 4
|
6月前
|
开发框架 并行计算 .NET
脑洞大开!Python并发与异步编程的哲学思考:IO密集型与CPU密集型任务的智慧选择!
【7月更文挑战第18天】在Python中,异步编程(如`asyncio`)适合处理IO密集型任务,通过非阻塞操作提高响应性,例如使用`aiohttp`进行异步HTTP请求。而对于CPU密集型任务,由于GIL的存在,多进程(`multiprocessing`)能实现并行计算,如使用进程池进行大量计算。明智选择并发模型是性能优化的关键,体现了对任务特性和编程哲学的深刻理解。
61 2