朱曄的互聯網架構實踐心得S2E2:寫業務代碼最容易掉的8種坑

我承認,本文的標題有一點標題黨,特別是寫業務代碼,大家因為沒有足夠重視一些細節最容易調的坑(側重Java,當然,本文說的這些點很多是不限制於語言的)。

1、客戶端的使用

我們在使用Redis、ElasticSearch、RabbitMQ、Mongodb等中間件或存儲的時候肯定都會使用客戶端包來和這些系統通訊,我們也會使用Http的一些客戶端來發Http請求。在使用這些客戶端包的時候,非常容易犯錯的一個地方就是Client的使用方式,比如有一個叫做RedisClient的類,是Redis操作的入口。你應該是每次使用new RedisClient().get(KEY)呢還是注入一個單例的RedisClient呢?

我們知道,這些組件的客戶端往往需要和服務端通過TCP連接進行遠程通訊,考慮到性能,客戶端一般都維護連接池做長鏈接,如果RedisClient或MongoClient或HttpClient之類的Client在類內部維護了連接池,那麼這個Client往往是線程安全的,可以在多線程環境下使用的,並且嚴格禁止每次都新建一個對象出來的(如果框架做的足夠好一般本身就會是單例模式的,不允許實例化)。

你想,如果一個Client每次new的時候它會建立5個TCP鏈接給整個應用程序公用就是考慮到建連的耗時,而因為使用不當每次調用一次Redis都建5個TCP鏈接,那麼QPS可能就會從10000一下子到10。更要命的是,有的時候這些Client不但維護用於TCP鏈接的連接池,還會維護用於任務處理的線程池,線程池的可能還會有比較大的默認核心線程,這個時候再去每次使用new一個Client出來,那就是雙重打擊了。

在使用Netty等框架的時候,本來就是基於Event Loop線程公用來做IO處理的,對於客戶端來說Work Group可能只會有2~4個鏈接就夠了,我們假設4個鏈接好了,如果這個時候Client框架的開發者對於Netty使用不當,對於客戶端連接池再去每次new一個Bootstrap出來,客戶端連接池又搞了所謂的5個,那就相當於每次20個EventLoopGroup(線程),這個時候客戶端的使用者又對於框架使用不當每次再new一個Client出來,相當於做一個請求需要20個線程,這就是三重打擊。

那你可能會說,是不是所有的Client都做單例使用就好了呢?並不是這樣,這取決於Client的實現,很可能Client只是一個入口,那些連接池和線程池維護在另外一個類中,這個入口本身是輕量的,自帶狀態的(比如一些配置),是不允許作為單例的,框架的開發者就是想讓大家通過這個便捷入口來使用API。這個時候如果當做單例來使用說不定會出現串配置的問題。所以Client使用最佳實踐這個問題沒有統一的答案。

這裡我沒有提到數據庫的原因是,大家使用數據庫一般都使用Mybatis、JPA,已經不會和數據源直接打交道了,一般而言不容易犯錯。但是現在中間件太多了,客戶端更是有官方的有社區的,我們在使用的時候一定要根據文檔搞清楚到底應該怎麼去使用客戶端(或者請使用關鍵字XXX threadsafe或XXX singleton多搜索一下Google確認),如果搞不清楚就去看下源碼,看下客戶端在連接池線程池這塊的處理方式,否則可能會造成巨大的性能問題。還不僅僅是性能問題,我見過很多因為對客戶端使用不當導致的內存暴增、TCP鏈接佔滿等等導致的服務最終癱瘓的重大故障。

2、服務調用參數配置

現在大家都在實踐微服務架構,不管是使用什麼微服務框架,是基於HTTP REST還是TCP的RPC,都會設置一些參數,這些參數在設置的時候如果沒有認真考慮的話可能就會有一些坑。

超時配置

客戶端一般最關注的是兩個參數,連接超時(ConnectionTimeout)和讀取超時:(ReadTimeout),指的是建立TCP鏈接的超時和從Socket讀取(需要的)數據的超時,後者往往不僅僅是網絡的耗時,包含了服務端處理任務的耗時。在設置的時候考慮幾個點:

  • 連接超時相對單純,TCP建鏈一般不會耗時很久,設置太大意義不大,看到有設置60秒甚至更長的,如果超過2秒都連不上還不如直接放棄,快速放棄至少還能重試,何必苦等。
  • 讀取超時不僅僅涉及到網絡了,還涉及到遠端服務的處理或執行的時間,大家可以想一下,如果客戶端讀取超時在5秒,遠程服務的執行時間在10秒,那麼客戶端5秒後收到read timed out的錯誤,遠程的服務還在繼續執行,10秒後執行完畢,這個時候如果客戶端重試一次的話服務端就再執行一次。一般而言,建議評估一下服務端執行時間(比如P95在3秒),客戶端的讀取超時參數建議比服務端執行時間設置的略長一點(比如5秒),否則可能遇到重複執行的問題。
  • 之前遇到過一個問題,Job調用服務執行定時任務生成對賬單,定時任務執行一次需要30分鐘(完成後再更新數據狀態為已生成),但是Job客戶端設置的讀取超時是60秒,Job每1分鐘執行一次,相當於Job不斷超時,不斷重試,每1分鐘執行一次超時了接著又執行,這個任務本應該一天處理一次,因為這個問題變為了執行了30次(請求數量放大),因為任務處理極其消耗資源,執行了還沒到30次後服務端就直接掛了。大多數RPC框架在服務端執行都會在線程池中執行業務邏輯,執行本身不會設定超時時間。還是前面那個問題,對於耗時比較長的操作,要考慮一下是否需要做同步的遠程服務。即使要做,也要通過鎖控制好狀態,或者通過限流控制好併發。
  • 大家可能會覺得奇怪,為啥大多框架不關注寫入超時(WriteTimeout)這個配置?其實寫入操作本身就是寫入Socket的緩衝,數據發往遠端的過程是異步的,就寫入操作本身而言往往是很快的,除非緩衝滿了,我們無法知道寫入操作是否成功寫到遠端,如果要知道的話也要等拿到了響應數據的時候才知道,這個時候就是讀取階段了,所以寫入操作本身的超時配置意義不大。

自動重試

無論是Spring Cloud Ribbon還是其它的一些RPC客戶端往往都有自動重試功能(MaxAutoRetries和MaxAutoRetriesNextServer),考慮到Failover,有的框架會默認情況下對於節點A掛的情況下重試一次節點B。我們需要考慮一下這個功能是否是我們需要的,我們的服務端是否支持冪等,框架重試的策略是很對Get請求還是所有請求,弄的不好就會因為自動重試問題踩坑(不是所有的服務端都對冪等問題處理的足夠好,或者換句話說,和之前那個問題相關的是,不是所有服務端能正確處理請求本身還沒執行完成情況下的冪等處理,很多時候服務端考慮的冪等處理是基於自己的操作執行完成後提交了事務更新數據表狀態下的冪等處理)。對於遠程服務調用,客戶端和服務端商量好冪等策略,明確超時時間不一致情況下的處理策略很重要。

3、線程池的使用

線程池配置

阿里Java開發指南中提到:

線程池不允許使用 Executors 去創建,而是通過 ThreadPoolExecutor 的方式,這樣

的處理方式讓寫的同學更加明確線程池的運行規則,規避資源耗盡的風險。

說明:Executors 返回的線程池對象的弊端如下:

1)FixedThreadPool 和 SingleThreadPool:

允許的請求隊列長度為 Integer.MAX_VALUE,可能會堆積大量的請求,從而導致 OOM。

2)CachedThreadPool 和 ScheduledThreadPool:

允許的創建線程數量為 Integer.MAX_VALUE,可能會創建大量的線程,從而導致 OOM。

建議大家熟悉研究一下線程池基本原理,採用手動方式根據實際業務需求來配置線程數、隊列類型長度、拒絕策略等參數。

我們往往會使用一定的隊列來做任務緩衝(線程池也好,MQ也好),出現隊列滿的情況下的拒絕策略也值得一提。我們使用線程池做異步處理,就是考慮到彈性,這些任務會有補償或任務本身丟失並不這麼重要,這個時候如果輕易使用CallerRunsPolicy策略的話可能會遇到大問題,因為隊列滿了後任務會由調用者線程來執行,這種做法往往是調用者最不希望出現的異步轉同步問題。更嚴重的是這種策略配合NIO框架,比如Netty來使用線程池的時候,如果調用者是IO EventLoopGroup線程,那麼這個時候業務線程池滿了後就會直接把IO線程堵死。遇到任務量太大,任務怎麼處理,是記錄後補償還是丟棄,還是調用者執行需要認真考慮。

線程池共享

見過一些業務代碼做了Utils類型在整個項目中的各種操作共享使用一個線程池,也見過一些業務代碼大量Java 8使用parallel stream特性做一些耗時操作但是沒有使用自定義的線程池或是沒有設置更大的線程數(沒有意識到parallel stream的共享ForkJoinPool問題)。共享的問題在於會干擾,如果有一些異步操作的平均耗時是1秒,另外一些是100秒,這些操作放在一起共享一個線程池很可能會出現相互影響甚至餓死的問題。建議根據異步業務類型,合理設置隔離的線程池。

4、線程安全

對象是否單例

在使用Spring容器的時候,因為Bean默認是單例的策略,所以我們特別容易犯錯的地方是讓不應該是單例的類成為了單例。比如類中有一些數據字段時候類是有狀態的。當我們配合Spring和其它框架一起使用的時候更容易煩這個錯,比如框架內部是沒有使用Spring的,會自己通過一些緩存機制或池機制來維護對象的聲明週期,如果我們直接加入容器,用容器來管理框架內部一些類型的創建方式,可能就會遇到很多Bug。對於單例類型的內部數據字段,考慮使用ThreadLocal來封裝,使得類型在多線程情況下內部數據基於線程隔離不至於錯亂。

單例是否線程安全

前面一點我們說的是要辨別清楚,對象是否應該是單例的,這裡我們說的是單例的情況下是否是線程安全的問題。在使用各種框架提供的各種類的時候,(為了性能)我們有的時候會想當然加上static或讓Spring單例注入,在這麼做之前務必需要確認類型是否是線程安全的(比如常見的SimpleDateFormat就不是線程安全的)。我覺得我在開發時候Google搜索的最多的關鍵字就是XXX threadsafe。反過來說,如果你開發框架的話有義務在註釋裡告知使用者類型是否線程安全。線程安全的問題在測試過程中不容易發現,畢竟測試的時候沒有併發,但是到了生產可能就會有千奇百怪的問題,出現這樣的Bug如果爆出了
ConcurrentModificationException這種併發異常還好,在沒有異常的情況要定位問題真的很難。

鎖範圍和粒度

sync(object)這個object到底是什麼,是類實例,還是類型,還是redis的Key(跨進程鎖)值得仔細思考。我們需要確保鎖能鎖住需要的操作,見到過一些代碼因為沒有鎖到正確級別導致鎖失效。

同時也要儘可能減少鎖的粒度,如果什麼操作都方法級別分佈式鎖,那麼這個方法永遠是全局單線程。這個時候加機器就沒意義,系統就無法伸縮。

最後就是要考慮鎖的超時問題,特別是分佈式鎖,如果沒有設置超時那麼很可能因為代碼中斷導致鎖永遠無法釋放,對於Redis鎖不建議造輪子,建議使用官方推薦的紅鎖方案(比如Redisson的實現)。

5、異步

數據流順序

如果數據流是異步處理的話,會遇到數據流順序的問題。比如我們先發請求到其它服務執行異步操作(比如支付),然後再執行本地的數據庫操作(比如創建支付訂單),完成後提交事務可能會遇到外部服務請求處理的很快,先給我們進行了數據回調(支付成功通知),這個時候我們本地的事務都沒提交呢,支付訂單還沒有落庫,導致外部回調來的時候查不到原始數據導致出現問題。更要命的可能是這個時候我們卻返回了外部回調SUCCESS的狀態導致外部回調也不會進行補償了。

在使用MQ的時候也會遇到補償數據重新進入隊列重發的問題,這個時候可能會先收到更晚的消息,後收到更早的消息,這種情況我們的消息消費處理程序是否能應對呢?如果這點沒做好可能會出現邏輯處理錯亂的問題。

異步非阻塞

在使用Spring WebFlux、Netty(特別是前者,Netty的開發者一般會關注這個問題)等非阻塞框架的時候,我們需要意識到我們的業務處理不能過多佔用事件循環的IO線程,否則可能會導致為數不多的IO線程被阻塞的問題。任務是否在IO線程執行也不是絕對的,如果小任務都分到業務線程池執行可能會有線程切換的問題,得不償失,一切還是要以壓力測試的數據說話不能想當然。如果這點沒做好可能會出現性能大幅下降的問題。有的時候NIO框架Reactor模式使用不當,其效率性能還如request-per-thread的線程模型。

6、判等

判等只是代碼實現細節中最容易犯錯的一個點,在這裡還是再次推薦一下阿里的Java開發手冊以及安裝IDE的檢查工具,裡面有很多禁止或強制項,每一個項都是一個坑,推薦大家逐一細細品味這些代碼細節。

==的問題

Java程序員最容易犯的錯,也是導致代碼Bug非常多的一個點,這個通過代碼靜態檢查都可以發現。出現這樣的Bug非常難查,也非常可惜。其實想一下業務代碼中,除了判空,有多少時候我們需要真正對兩個對象的引用進行判斷。

在數據庫Entity中考慮到空指針問題,我們往往會使用包裝類型,外部Http請求入參我們也會考慮到空指針問題用包裝類型,這個時候碰在一起比較使用==就特別容易出問題,尤其需要關注。而且相等或不等處理的往往是分支邏輯,測試容易覆蓋不到,真正出問題的時候就是大問題。

Map和hashCode()

也是阿里Java開發手冊中提到的一點,如果自定義對象可能作為Map的Key,那麼必須重寫hashCode()和equals(),這是業務開發時非常容易忽略的。我也遇到過這個問題,犯錯的原因不是我不知道這點,而是我不知道也意識不到我的類會被某個框架做作為Map的Key(三方框架,並非自己所寫)進行緩存,然後因為這個問題導致自己定義的類的多個實例被框架當做一個實例出現無法預料的Bug。

7、中間件的使用

在使用中間件的時候,我們最好針對使用場景對中間件或存儲做一次壓力測試,並且研究各種配置參數做到對基本原理心中有數,否則容易因為沒有按照最佳實踐來使用配置而踩坑。遇到坑可以過去倒沒什麼,最怕的是大面積使用了某個系統比如MongoDb、ElasticSearch、InfluxDb後又遇到了伸縮性問題性能問題一時半會無法解決,這種坑就大了。

遇到過開發在使用Redis的時候把它當做數據庫而不是Key-Value緩存,去用KEYS命令搜索自己需要的鍵進行批量操作,這種使用方式完全違背Redis的最佳實踐,在巨大的Redis集群裡頻繁使用這樣的操作可能導致Redis卡死。對於Redis的使用也遇到過因為不合理的RDB配置導致的IO性能問題,以及快照期間超量的內存佔用導致的OOM問題。

比如使用InfluxDb,它的Tag是一個不錯的特性,我們可以針對各種Tag來分組靈活建立各種指標,但是Tag是不能所以使用來保存組合範圍過多的數據的,比如Url、Id等否則可能就會因為巨大的索引(high cardinality問題)拖慢整個InfluxDb的性能甚至OOM。

又比如有一個業務因為壓力大選型Mongodb,最後Mongodb沒有配置開啟write-ahead log和複製,在一次斷電後數據庫因為存儲文件損壞無法啟動,研究恢復工具和數據存儲結構來修復數據文件花了幾天時間,整個期間所有歷史數據都無法訪問到。

對於極限追求穩定的項目,建議約簡單約好,哪怕就是依賴MySQL不引入其它東西,在有性能問題的時候再考慮其它中間件,這種方式最不容易出問題。

8、環境和配置

因為環境問題導致的坑太多了,有的時候其實是大家意識不到環境差異問題。這裡隨便說幾個,我相信開發和運維結合的一些環境配置的問題導致的坑或線上的事故和問題太多太多了。而本地往往因為沒有容器環境、K8S環境和複雜的網絡環境,本地的程序部署到生產可能會出現千奇百怪的問題。

網絡環境

遇到過壓測壓的很好,但是到線上還是崩潰的問題,原因在於壓測走的是全部都在內網部署的一套服務,生產很多服務走的是外網(或專線)鏈接,環境其實是不一樣的,網絡的消耗必然帶來請求的延遲,帶來線程的阻塞,帶來更多的資源消耗。也遇到過因為域名錯誤配置(或解析錯誤)問題導致應該走內網的請求走了公網,在測試環境或本地往往都是配置IP不容易出現這種問題。

反過來,也遇到過,本地壓測怎麼都壓不上去的問題,其實是因為本地有一些請求走的是公網連到了服務器上的一些服務,壓根就不是完全的本地壓測,如果意識不到這個問題,這個時候對於性能的優化往往很茫然。所以在壓測的時候我們最好使用類似iftop這樣的工具觀察一下我們的壓測進程對於網絡流量的使用(以及連接的遠端服務的地址)是否在我們的預期。

容器環境

現在大家都使用了K8S和Docker,在這種環境下,我們的業務項目不僅僅在網絡上從外到內經過多層,而且對於CPU、內存、文件句柄都配置也是層層限制(Pod層面、Docker層面、OS層面)。這個時候特別容易出現某一處配置不匹配導致資源限制的問題。

之前遇到過通過K8S Ingress訪問服務慢的問題,這個時候需要層層排查,Docker裡訪問慢不慢,通過Service訪問慢不慢,通過Ingress訪問慢不慢來定位問題。

今天還遇到一位同學說,死活不知道為啥系統參數各種修改後還是無法生效增大文件描述符和進程數的限制,最後發現原來是因為java進程是supervisord啟動的,supervisor本身有限制(minfds和minprocs)。

環境隔離

互聯網公司基本都會有灰度環境或Staging環境做上線前的最後測試,但是很多時候會因為這套環境和生產環境共享一些資源導致出現問題。

之前遇到一個問題是使用了七牛做CDN,灰度環境和生產環境都是使用了同樣的CDN,導致在灰度測試的時候新的靜態資源文件就緩存到了CDN節點上導致外部用戶訪問出錯(訪問到了新的靜態資源)。出這個問題之後要馬上回滾解決還是比較麻煩的,因為CDN已經被汙染了。長期解決的辦法很簡單就是做隔離或每次發佈靜態資源文件名不同。

總結

總結一下,線程、線程同步、池、網絡連接、網絡鏈路、對象實例化、內存等方面的基礎是最容易犯錯的地方,搞清楚框架內部對於這些基礎資源的的使用方式,根據最佳實踐進行合理配置,這是業務開發時需要特別關注的點。有的時候一些代碼在使用三方框架和中間件的時候因為不瞭解細節,不但沒有按照最佳實踐來配置反而配成了最差實踐,造成了很大的問題非常可惜。

由於各種坑五花八門,本文也只是拋磚引玉,希望讀者可以補充自己遇到的神坑,希望大家能在評論區留言。


分享到:


相關文章: