關于IO密集型服務提升性能的三種方式
IO密集型服務提升性能的三種方式
大部分的業(yè)務系統(tǒng)其實都是IO密集型的系統(tǒng),比如像我們面向B端提供攝像頭服務,很多的接口其實就是將各種各樣的數據匯總起來,展示給用戶,我們的數據來源包括Redis、Mysql、Hbase、以及依賴的一些服務方的數據,并不涉及到太多復雜的計算邏輯。
在過去的半年中,因為我們數據量和業(yè)務復雜性的增長,確實遇到了一些明顯的性能問題,分析大部分問題的本質原因就是IO太慢了。
我們系統(tǒng)中最復雜的計算邏輯執(zhí)行最慢也就微秒級,而調一次數據庫最快也得1-2毫秒,有著2-3個數量級的差距。
然而IO又是業(yè)務系統(tǒng)中不可能干掉的操作,但頻繁或者錯誤的使用IO會給系統(tǒng)帶來非常明顯的性能問題,輕則拖慢接口影響用戶體驗,重則OOM直接宕機。
針對IO問題帶來性能問題,這里我總結了三種方式 批處理、緩存和多線程,雖然看起來是很簡單的操作,但還是得在合適的地方正確使用才能發(fā)揮出這三種方法的價值。
批處理
首先是批處理,這里先說一個真實的案例, 在2021年我們在做服務上云過程中,有個接口上云后,時延從原本的50ms左右漲到了150ms,后來排查發(fā)現(xiàn),之前是串行化去調用KMS,這個服務上云后和KMS的服務端出現(xiàn)了跨機房調用,單次KMS的調用時長增長了近0.5ms。 單看這0.5ms確實不算多,但也架不住幾十次的串行調用累計到一起,最終出現(xiàn)了100ms的總延時增長。這種接口時延增長大到原來的三倍,用戶是很容易感受到的,可能他們的感受就是這應用真卡!
上面這個問題復現(xiàn)起來很簡單,其實就一個for循環(huán),串行去調用kms解密數據量。
for (String str : strList) { decodedStr = kmsClient.decrypt(str); // 單次調用需要0.5-1ms,串行100次需要50-100ms }
上述代碼整體的主要的耗時其實并不是kms對數據解密的過程上(僅需要微秒級),而是請求發(fā)送和接收結果數據時數據在網絡上傳輸的耗時,這就取決于雙方服務之間的物理距離了,我們大部分服務都是在北京部署,但仍會出現(xiàn)跨機房調用的情況,這個時候網絡延時也會增長0.5-1ms。批處理提升IO性能的原理,其實就是用單次網絡IO替代掉原有的多次網絡IO,IO時長越長,優(yōu)化效果越顯著。 用一個生活中的例子大家更容易理解些,假設你要給家里準備一份晚餐,其中很重要的一步就是去菜市場買菜,你是一樣一樣買?還是一次性全買齊了? 這就是單次處理和批處理的區(qū)別。
這個性能問題看似簡單,其實在實際編程過程中經常犯,稍不留神就大批量串行IO調用,比如在for循環(huán)中查庫(你是不是已經在腦海中想到自己寫的問題代碼了)。 如何避免自己在日常編程中出現(xiàn)類似的問題,我總結了一條編程指導經驗,那就是 在任何循環(huán)中盡量不要產生IO調用,除非你知道自己在做什么。
當然也不是所有的IO都會產生問題,有些IO非??欤夷愦械念l次也不是很高,貿然將代碼改成批處理的邏輯會顯著增加代碼復雜度,增加維護成本反而得不償失,所以建議還是根據具體的IO類型和具體需求,評估具體是否要做批處理。
以下我給出一些具體的IO類型和單次IO耗時參考值,大家寫代碼的時候可以關注下。
IO類型 | 耗時 | 備注 |
---|---|---|
SSD固態(tài)磁盤隨機訪問 | 0.1ms | 目前大部分服務器在使用SSD了,小文件讀寫的耗時幾乎可以不關注,但如果文件非常大時,這里各方的帶寬就是瓶頸,耗時也容易快速增長,重點關注大文件。 |
Redis訪問 | 0.1ms | 簡單Redis查詢,主要還是在網絡上,Redis服務自身處理請求僅幾十us,只要不出大key,基本沒問題。 |
mysql查詢 | 1-10ms | 簡單查詢可以在10ms下,但涉及到復雜查詢或者大量數據無索引的情況下,耗時會顯著增長。mysql的異常查詢是很多業(yè)務系統(tǒng)的性能問題主要來源。 |
HDD機械磁盤隨機訪問 | 10ms | 主要磁盤尋道時間,取決于磁盤轉速,如果你恰好用了HDD又想讀寫文件,無論文件大小這部分耗時是一定不能忽略的。 |
調用第三方服務 | 1-100ms | 取決于依賴方的接口性能,不同接口延時的方差非常大,調用第三方接口,性能和容量都需要非常仔細的評估。 |
同城跨機房RTT | 0.5ms | - |
物理距離每增加50-100公里 | rtt +1ms | 延時主要來源于光在光纖中的傳播耗時+交換機和路由器的處理耗時,比如從廣州到北京,一個RTT就需要50ms,對接外部服務接口,如果關注性能,物理距離一定要考慮進去。 |
緩存
高IO的應用有個特點,就是大量的數據其實是被重復加載的,這也是”局部性“的一個體現(xiàn),局部性告訴我們,只有少量的數據會被大量的加載。
利用局部性,我們只要將重要的小部分數據緩存起來,就可以減少大量的IO,從而提升我們系統(tǒng)的性能。
如果我們用平均延時來評估性能,我們可以用一個平均延遲計算公式來描述加緩存后的性能:
avgLatency = hitRate * cacheLatency + (1 - hitRate) * originalLatency
其中avgLatency代指加了緩存后的平均延遲,hitRate表示緩存的命中率,cacheLatency指的是訪問一次緩存所需要的耗時,在實際使用中,如果我們使用了本地緩存,我們可以簡單粗暴認為cacheLatency是0,以上公式就可以簡化為avgLatency = (1 - hitRate) * originalLatency 。
從簡化后的公式可以看出加緩存后的效果僅跟緩存的命中率有關系,如果cache命中率是90%,就會有10倍的性能提升,如果是99%就會有100百性能提升(簡略計算),只要我們無限提升緩存命中率,似乎就能無限提升性能。
那命中率又和什么相關呢?
答案就是數據的分布、緩存的大小和數據的淘汰策略三者相關。
- 數據分布: 現(xiàn)實世界中,大部分數據的訪問都受局部性的影響,用大白話講就是只有少部分數據會被頻繁訪問,如果把數據被訪問頻次曲線畫出來,如上圖。
- 緩存大小: 這個很好理解,只要緩存的數據足夠多,緩存命中率就越高。
- 淘汰策略: 淘汰策略是指在緩存容量不足的情況下,如何剔除價值最低的數據,常見的淘汰策略有LRU、LFU、FIFO,我們實際情況中用的最多的就是LRU。
正確考慮到以上三點后,我們大部分情況下是可以將少量高頻被訪問的數據緩存起來,從而提升系統(tǒng)性能。
使用Cache有個額外需要注意的一項就是數據一致性,在cache的使用過程中緩存命中率和數據一致性幾乎就是相悖的,很難做到兩全其美,就比如我之前有篇文章說過CPU Cache,其實就是硬件層面使用Cache優(yōu)化IO性能的一個典型案例,但CPU為保證數據一致性卻給當代程序員留下一堆"坑"。
在實際工作中,關于Cache實現(xiàn)我們有很多選擇,常用的比如Guava中的LoadingCache、caffiene、ehcache、redis,spring中也有spring-cache 高級封裝,這些如果你都不想用的話,你都可以用Map自己擼一個……
多線程
以上兩種方式的本質,其實是通過優(yōu)化非必要的IO次數來提升性能,但現(xiàn)實情況中并不是所有的IO都可以被優(yōu)化掉,針對這種情況,其實也就只多線程一條路可選了。
這個思路也很好理解,用大白話來說,如果活太多干不完就多招兩個人來干。 在IO密集型系統(tǒng)中,多線程的優(yōu)勢在于它能充分利用CPU的計算能力。
當一個線程在等待IO操作(如網絡請求或磁盤讀寫)完成時,CPU可以切換到其他線程去執(zhí)行其他任務,而不是閑置不用。
這樣,我們就可以充分利用CPU資源,提高系統(tǒng)的響應速度。
但是,使用多線程并非沒有代價。首先,需要注意的是線程切換的開銷。如果線程數量過多,線程切換的開銷可能會消耗大量的CPU資源。其次,使用多線程會顯著增加代碼的復雜度,需要考慮到很多并發(fā)相關的問題,如:線程間的同步、死鎖、資源競爭等,這些都需要在編程時仔細考慮和處理,稍有不慎就會引入很難排查的Bug。
在Java中,我們可以通過使用ExecutorService、CompletableFuture等工具來創(chuàng)建并管理線程。當然,我們也可以直接使用Thread類來創(chuàng)建線程,但線程需要自行管理,不是很推薦。同時,Java提供了許多同步和并發(fā)工具,如synchronized關鍵字、ReentrantLock、Semaphore等,以幫助我們處理并發(fā)問題。
在多線程優(yōu)化中,線程池的使用是非常常見的。線程池可以有效地管理和復用線程,避免了頻繁地創(chuàng)建和銷毀線程所帶來的開銷。在Java中,我們可以使用ExecutorService來創(chuàng)建一個線程池,然后將任務提交給線程池來執(zhí)行。在Java8及以上的版本中,我們也可用使用parallelStream()很方便的將代碼改造成多線程,但需注意parallelStream底層是使用同一個ForkJoinPool,大量使用可能會出現(xiàn)相互干擾的情況。
另一個常見的多線程優(yōu)化方式是使用異步編程。異步編程可以讓程序在等待IO操作完成的時候,不必阻塞當前線程,而是可以切換到其他任務進行處理。
在Java中,我們可以使用Future、CompletableFuture等工具來進行異步編程。
總的來說,多線程可以是一個強大的工具,可以顯著提高IO密集型系統(tǒng)的性能。但是,使用多線程也需要謹慎,需要處理好并發(fā)問題,才能確保程序的正確性和穩(wěn)定性。
總結
在面對IO密集型系統(tǒng)性能優(yōu)化時,我們可以通過三種主要的方式來進行:批處理、緩存和多線程。這三種方式各有其優(yōu)點和適用場景。
- 批處理可以通過減少網絡IO次數,顯著減少網絡傳輸的延遲時間,從而提升系統(tǒng)性能。但是,它需要我們仔細分析和設計我們的數據處理流程,才能找到合適的批處理策略。
- 緩存則是通過存儲頻繁訪問的數據,減少了對慢速存儲(如磁盤或網絡)的訪問,從而提升性能。但是,使用緩存時需要考慮數據的一致性問題,以及如何選擇合適的緩存淘汰策略。
- 多線程則是通過并行處理多個任務,充分利用CPU的計算能力,從而提升性能。但是,使用多線程需要處理并發(fā)問題,以及線程管理和調度的開銷。
在實際應用中,這三種方式往往會結合使用,以適應不同的性能需求和系統(tǒng)環(huán)境。
選擇哪種方式,或者如何結合使用,需要根據具體的業(yè)務需求、系統(tǒng)環(huán)境和性能目標來決定。
在進行性能優(yōu)化時,我們需要深入理解我們的系統(tǒng),找出性能瓶頸,然后有針對性的進行優(yōu)化。
同時,我們還需要通過性能測試和監(jiān)控,來驗證我們的優(yōu)化效果,以及及時發(fā)現(xiàn)和解決新的性能問題。
只有通過這樣的方式,我們的系統(tǒng)才能持續(xù)提供高效、穩(wěn)定的服務。
以上為個人經驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關文章
Spring Boot整合消息隊列RabbitMQ的實現(xiàn)示例
本文主要介紹了Spring Boot整合消息隊列RabbitMQ的實現(xiàn)示例,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2025-03-03mybatis 如何判斷l(xiāng)ist集合是否包含指定數據
這篇文章主要介紹了mybatis 判斷l(xiāng)ist集合是否包含指定數據的操作,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-06-06簡單了解JAVA內存泄漏和溢出區(qū)別及聯(lián)系
這篇文章主要介紹了簡單了解JAVA內存泄漏和溢出區(qū)別及聯(lián)系,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-03-03