朱曄的網際網路架構實踐心得S2E2:寫業務程式碼最容易掉的10種坑 | 掘金年度徵文

powerzhuye發表於2019-01-12

我承認,本文的標題有一點標題黨,特別是寫業務程式碼,大家因為沒有足夠重視一些細節最容易調的坑(側重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客戶端往往都有自動重試功能(MaxAutoRetriesMaxAutoRetriesNextServer),考慮到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這種併發異常還好,在沒有異常的情況要定位問題真的很難。許多Web程式設計師其實都沒有意識到自己的專案其實是多執行緒環境這個問題。

鎖範圍和粒度

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

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

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

5、非同步

資料流順序

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

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

非同步非阻塞

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

6、事務

本地事務

現在大多數專案都直接使用了@Transactional註解來開啟事務,但是沒有過多考慮這個註解的實現原理,常見的坑有:

  • 因為配置問題導致壓根註解沒有起作用(特別是沒有使用Spring Boot的情況下)
  • 雖然使用了,但是姿勢不對,導致事務沒有生效,比如入口沒有@Transactional,然後this.method()標記的方法帶有註解,類因為沒代理導致無效
  • 又比如rollbackFor沒有配置,或是方法內部吃了所有異常並不會出現異常導致無法回滾

因為事務問題導致的程式碼Bug相當多,而且一般不出問題不容易發現,很多專案只是裝模作樣使用了@Transactional但是完全沒考慮到註解壓根不能生效的問題

分散式事務

不管是最終實現一致也好,兩階段提(只是思想,不是說一定要用中介軟體)交也好,跨程式的整體事務性需要考慮如何去實現。最難的地方在於要考慮遠端資源的事務性和本地資源的事務性怎麼作為整體事務。

7、引用根

這裡說的是記憶體洩露的問題,Java程式其實如果不使用堆外直接記憶體分配的話不會出現狹義的記憶體洩露問題。坑在於,有的時候大家會使用static來宣告List或Map,來存檔一些資料,但是有的時候會忽略刪除老資料的問題,一個勁往裡面增加資料不刪除,導致資料無限增多,還是有一些程式設計師意識不到引用根的問題的。更隱蔽的是,Spring的Bean預設是單例的,這個時候在Service內宣告使用List之類結構來儲存資料,雖然沒有宣告static,但是就是static的屬性(容易讓人形成物件能夠自己回收的錯覺)。這個問題要求我們能夠明確:

  • 我們資料所歸屬的類是否是單例或static的(生命週期)
  • 我們資料所歸屬的類所歸屬的類的宣告週期(探尋引用根)
  • 我們資料本身是無限擴大的還是隻是有限的集合
  • 當我們的資料放入Map中或Set中,是否新資料會替換老的資料(見下面判等問題)

說白了,程式碼裡見到非方法體內部宣告的List、Map等資料結構(作為類成員欄位)都要小心。

8、判等

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

==的問題

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

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

Map和hashCode()

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

9、中介軟體的使用

在使用中介軟體的時候,我們最好針對使用場景對中介軟體或儲存做一次壓力測試,並且研究各種配置引數做到對基本原理心中有數,否則容易因為沒有按照最佳實踐來使用配置而踩坑。遇到坑可以過去倒沒什麼,最怕的是大面積使用了某個系統比如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不引入其它東西,在有效能問題的時候再考慮其它中介軟體,這種方式最不容易出問題。

10、環境和配置

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

網路環境

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

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

容器環境

現在大家都使用了K8S和Docker,在這種環境下,我們的業務專案不僅僅在網路上從外到內經過多層,而且對於CPU、記憶體、檔案控制程式碼都配置也是層層限制(Pod層面、Docker層面、OS層面)。這個時候特別容易出現某一處配置不匹配導致資源限制的問題。

之前遇到過通過K8S Ingress訪問服務慢的問題,這個時候需要層層排查,畢竟K8S的網路還是挺複雜的,不同的CNI方案可能會有不同的問題,Docker裡訪問慢不慢,通過Service訪問慢不慢,通過Ingress訪問慢不慢來定位問題。

還有,在容器環境下,CPU數量可能會獲取到宿主機的CPU梳理,導致很多框架的執行緒數配置的過大(比如有些宿主機48核+,CPU數量*2的話就是96執行緒),JVM的ParallelGCThreads就是一個例子,此類坑很多,不合理的配置可能會導致效能問題。

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

環境隔離

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

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

總結

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

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

掘金年度徵文 | 2018 與我的技術之路 徵文活動正在進行中......

相關文章