微服務越來越火。很多互聯網公司,甚至一些傳統行業的系統都采用了微服務架構。體會到微服務帶來好處的同時,很多公司也明顯感受到微服務化帶來的一系列讓人頭疼的問題。本文是筆者對自己多年微服務化經歷的總結。如果你正準備做微服務轉型,或者在微服務化過程中遇到了困難。此文很可能會幫到你!
寫在前面
正文開始前,為了讓各位讀友更好的理解本文內容,先花兩分鐘了解一下微服務的優缺點。
聊起微服務,很多朋友都了解微服務帶來的好處,羅列幾點:
模塊化,降低耦合。將單體應用按業務模塊拆分成多個服務,如果某個功能需要改動,大多數情況,我們只需要弄清楚并改動對應的服務即可。只改動一小部分就能滿足要求,降低了其他業務模塊受影響的可能性。從而降低了業務模塊間的耦合性。
屏蔽與自身業務無關技術細節。例如,很多業務需要查詢用戶信息,在單體應用的情況下,所有業務場景都通過DAO去查詢用戶信息,隨著業務發展,并發量增加,用戶信息需要加緩存,這樣所有業務場景都需要關注緩存,微服務化之后,緩存由各自服務維護,其他服務調用相關服務即可,不需要關注類似的緩存問題
數據隔離,避免不同業務模塊間的數據耦合。不同的服務對應不同數據庫表,服務之間通過服務調用的方式來獲取數據
業務邊界清晰,代碼邊界清晰。單體架構中不同的業務,代碼耦合嚴重,隨著業務量增長,業務復雜后,一個小功能點的修改就可能影響到其他業務點,開發質量不可控,測試需要回歸,成本持續提高
顯著減少代碼沖突。在單體應用中,很多人在同一個工程上開發,會有大量的代碼沖突問題。微服務化后,按業務模塊拆分成多個服務,每個服務由專人負責,有效減少代碼沖突問題
可復用,顯著減少代碼拷貝現象
微服務確實帶來不少好處,那么微服務有沒有什么問題呢?答案是肯定的!例如:
微服務系統穩定性問題。微服務化后服務數量大幅增加,一個服務故障就可能引發大面積系統故障。比如服務雪崩,連鎖故障。當一個服務故障后,依賴他的服務受到牽連也發生故障。
服務調用關系錯綜復雜,鏈路過長,問題難定位。微服務化后,服務數量劇增,大量的服務管理起來會變的更加復雜。由于調用鏈路變長,定位問題也會更加困難。
數據一致性問題。微服務化后單體系統被拆分成多個服務,各服務訪問自己的數據庫。而我們的一次請求操作很可能要跨多個服務,同時要操作多個數據庫的數據,我們發現以前用的數據庫事務不好用了。跨服務的數據一致性和數據完整性問題也就隨之而來了。
微服務化過程中,用戶無感知數據庫拆分、數據遷移的挑戰
如何保障微服務系統穩定性?
互聯網系統為大量的C端用戶提供服務,如果隔三差五的出問題宕機,會嚴重影響用戶體驗,甚至導致用戶流失。所以穩定性對互聯網系統非常重要!接下來筆者根據自己的實際經驗來聊聊基于微服務的互聯網系統的穩定性。
雪崩效應產生原因,如何避免?
微服務化后,服務變多,調用鏈路變長,如果一個調用鏈上某個服務節點出問題,很可能引發整個調用鏈路崩潰,也就是所謂的雪崩效應。
舉個例子,詳細理解一下雪崩。如上圖,現在有A,B,C三個服務,A調B,B調C。假如C發生故障,B方法1調用C方法1的請求不能及時返回,B的線程會發生阻塞等待。B會在一定時間后因為線程阻塞耗盡線程池所有線程,這時B就會無法響應A的請求。A調用B的請求不能及時返回,A的線程池線程資源也會逐漸被耗盡,最終A也無法對外提供服務。這樣就引發了連鎖故障,發生了雪崩。縱向:C故障引發B故障,B故障引發A故障,最終發生連鎖故障。橫向:方法1出問題,導致線程阻塞,進而線程池線程資源耗盡,最終服務內所有方法都無法訪問,這就是“線程池污染”
為了避免雪崩效應,我們可以從兩個方面考慮:
- 在服務間加熔斷。解決服務間縱向連鎖故障問題。比如在A服務加熔斷,當B故障時,開啟熔斷,A調用B的請求不再發送到B,直接快速返回。這樣就避免了線程等待的問題。當然快速返回什么,fallback方案是什么,也需要根據具體場景,比如返回默認值或者調用其他備用服務接口。如果你的場景適合異步通信,可以采用消息隊列,這樣也可以有效避免同步調用的線程等待問題。
- 服務內(JVM內)線程隔離。解決橫向線程池污染的問題。為了避免因為一個方法出問題導致線程等待最終引發線程資源耗盡的問題,我們可以對tomcat,dubbo等的線程池分成多個小線程組,每個線程組服務于不同的類或方法。一個方法出問題,只影響自己不影響其他方法和類。
常用開源熔斷隔離組件:Hystrix,Resilience4j
如何應對突發流量對服務的巨大壓力?
促銷活動或秒殺時,訪問量往往會猛增數倍。技術團隊在活動開始前一般都會根據預估訪問量適當增加節點,但是假如流量預估少了(實際訪問量遠大于預估的訪問量),系統就可能會被壓垮。所以我們可以在網關層(Zuul,Gateway,Nginx等)做限流,如果訪問量超出系統承載能力,就按照一定策略拋棄超出閾值的訪問請求(也要注意用戶體驗,可以給用戶返回一個友好的頁面提示)。
可以從全局,IP,userID等多維度做限流。限流的兩個主要目的:1,應對突發流量,避免系統被壓垮(全局限流和IP限流)2,防刷,防止機器人腳本等頻繁調用服務(userID限流和IP限流)
數據冗余
在核心鏈路上,服務可以冗余它依賴的服務的數據,依賴的服務故障時,服務盡量做到自保。比如訂單服務依賴庫存服務。我們可以在訂單服務冗余庫存數據(注意控制合理的安全庫存,防超賣)。下單減庫存時,如果庫存服務掛了,我們可以直接從訂單服務取庫存。可以結合熔斷一起使用,作為熔斷的Fallback(后備)方案。
服務降級
可能很多人都聽過服務降級,但是又不知道降級是怎么回事。實際上,上面說的熔斷,限流,數據冗余,都屬于服務降級的范疇。還有手動降級的例子,比如大促期間我們會關掉第三方物流接口,頁面上也關掉物流查詢功能,避免拖垮自己的服務。這種降級的例子很多。不管什么降級方式,目的都是讓系統可用性更高,容錯能力更強,更穩定。關于服務降級詳見本文后面的內容。
緩存要注意什么?
緩存穿透。對于數據庫中根本不存在的值,請求緩存時要在緩存記錄一個空值,避免每次請求都打到數據庫
緩存雪崩。在某一時間緩存數據集中失效,導致大量請求穿透到數據庫,將數據庫壓垮。可以在初始化數據時,差異化各個key的緩存失效時間,失效時間 = 一個較大的固定值 + 較小的隨機值
緩存熱點。有些熱點數據訪問量會特別大,單個緩存節點(例如Redis)無法支撐這么大的訪問量。如果是讀請求訪問量大,可以考慮讀寫分離,一主多從的方案,用從節點分攤讀流量;如果是寫請求訪問量大,可以采用集群分片方案,用分片分攤寫流量。以秒殺扣減庫存為例,假如秒殺庫存是100,可以分成5片,每片存20個庫存。
關于隔離的考慮
部署隔離:我們經常會遇到秒殺業務和日常業務依賴同一個服務,以及C端服務和內部運營系統依賴同一個服務的情況,比如說都依賴訂單服務。而秒殺系統的瞬間訪問量很高,可能會對服務帶來巨大的壓力,甚至壓垮服務。內部運營系統也經常有批量數據導出的操作,同樣會給服務帶來一定的壓力。這些都是不穩定因素。所以我們可以將這些共同依賴的服務分組部署,不同的分組服務于不同的業務,避免相互干擾。
數據隔離:極端情況下還需要緩存隔離,數據庫隔離。以秒殺為例,庫存和訂單的緩存(Redis)和數據庫需要單獨部署!數據隔離后,秒殺訂單和日常訂單不在相同的數據庫,之后的訂單查詢怎么展示?可以采用相應的數據同步策略。比如,在創建秒殺訂單后發消息到消息隊列,日常訂單服務收到消息后將訂單寫入日常訂單庫。注意,要考慮數據的一致性,可以使用事務型消息。
-
業務隔離:還是以秒殺為例。從業務上把秒殺和日常的售賣區分開來,把秒殺做為營銷活動,要參與秒殺的商品需要提前報名參加活動,這樣我們就能提前知道哪些商家哪些商品要參與秒殺,可以根據提報的商品提前生成商品詳情靜態頁面并上傳到CDN預熱,提報的商品庫存也需要提前預熱,可以將商品庫存在活動開始前預熱到Redis,避免秒殺開始后大量訪問穿透到數據庫。
圖片
CI測試&性能測試
CI測試,持續集成測試,在我們每次提交代碼到發布分支前自動構建項目并執行所有測試用例,如果有測試用例執行失敗,拒絕將代碼合并到發布分支,本次集成失敗。CI測試可以保證上線質量,適用于用例不會經常變化的穩定業務。
性能測試,為了保證上線性能,所有用戶側功能需要進行性能測試。上線前要保證性能測試通過。而且要定期做全鏈路壓測,有性能問題可以及時發現。
監控
我們需要一套完善的監控系統,系統出問題時能夠快速告警,最好是系統出問題前能提前預警。包括系統監控(CPU,內存,網絡IO,帶寬等監控),數據庫監控(QPS,TPS,慢查詢,大結果集等監控),緩存中間件監控(如Redis),JVM監控(堆內存,GC,線程等監控),全鏈路監控(pinpoint,skywaking,cat等),各種接口監控(QPS,TPS等)
CDN
可以充分利用CDN。除了提高用戶訪問速度之外,頁面靜態化之后存放到CDN,用CDN扛流量,可以大幅減少系統(源站)的訪問壓力。同時也減少了網站帶寬壓力。對系統穩定性非常有好處。
避免單點問題
除了服務要多點部署外,網關,數據庫,緩存也要避免單點問題,至少要有一個Backup,而且要可以自動發現上線節點和自動摘除下線和故障節點。
網絡帶寬
避免帶寬成為瓶頸,促銷和秒殺開始前提前申請帶寬。不光要考慮外網帶寬,還要考慮內網帶寬,有些舊服務器網口是千兆網口,訪問量高時很可能會打滿。
此外,一套完善的灰度發布系統,可以讓上線更加平滑,避免上線大面積故障。DevOps工具,CI,CD對系統穩定性也有很大意義。
關于服務降級
提起服務降級,估計很多人都聽說過,但是又因為親身經歷不多,所以可能不是很理解。下面結合具體實例從多方面詳細闡述服務降級。
互聯網分布式系統中,經常會有一些異常狀況導致服務器壓力劇增,比如促銷活動時訪問量會暴增,為了保證系統核心功能的穩定性和可用性,我們需要一些應對策略。這些應對策略也就是所謂的服務降級。下面根據筆者的實際經歷,跟大家聊聊服務降級那些事兒。希望對大家有所啟發!
關閉次要功能
在服務壓力過大時,關閉非核心功能,避免核心功能被拖垮。
例如,電商平臺基本都支持物流查詢功能,而物流查詢往往要依賴第三方物流公司的系統接口。物流公司的系統性能往往不會太好。所以我們經常會在雙11這種大型促銷活動期間把物流接口屏蔽掉,在頁面上也關掉物流查詢功能。這樣就避免了我們自己的服務被拖垮,也保證了重要功能的正常運行。
降低一致性之讀降級
對于讀一致性要求不高的場景。在服務和數據庫壓力過大時,可以不讀數據庫,降級為只讀緩存數據。以這種方式來減小數據庫壓力,提高服務的吞吐量。
例如,我們會把商品評論評價信息緩存在Redis中。在服務和數據庫壓力過大時,只讀緩存中的評論評價數據,不在緩存中的數據不展示給用戶。當然評論評價這種不是很重要的數據可以考慮用NOSQL數據庫存儲,不過我們曾經確實用Mysql數據庫存儲過評論評價數據。
降低一致性之寫入降級
在服務壓力過大時,可以將同步調用改為異步消息隊列方式(注意這里必須使用rocketmq隊列,別的隊列不能保證一致性),來減小服務壓力并提高吞吐量。既然把同步改成了異步也就意味著降低了數據一致性,保證數據最終一致即可。
例如,秒殺場景瞬間生成訂單量很高。我們可以采取異步批量寫數據庫的方式,來減少數據庫訪問頻次,進而降低數據庫的寫入壓力。詳細步驟:后端服務接到下單請求,直接放進消息隊列,消費端服務取出訂單消息后,先將訂單信息寫入Redis,每隔100ms或者積攢100條訂單,批量寫入數據庫一次。前端頁面下單后定時向后端拉取訂單信息,獲取到訂單信息后跳轉到支付頁面。用這種異步批量寫入數據庫的方式大幅減少了數據庫寫入頻次,從而明顯降低了訂單數據庫寫入壓力,同時增加系統吞吐量。不過,因為訂單是異步寫入數據庫的,就會存在數據庫訂單和相應庫存數據暫時不一致的情況,以及用戶下單后不能及時查到訂單的情況。因為是降級方案,可以適當降低用戶體驗,所以我們保證數據最終一致即可。流程如下圖:
屏蔽寫入
很多高并發場景下,查詢請求都會走緩存,這時數據庫的壓力主要是寫入壓力。所以對于某些不重要的服務,在服務和數據庫壓力過大時,可以關閉寫入功能,只保留查詢功能。這樣可以明顯減小數據庫壓力。
例如,商品的評論評價功能。為了減小壓力,大促前可以關閉評論評價功能,關閉寫接口,用戶只能查看評論評價。而大部分查詢請求走查詢緩存,從而大幅減小數據庫和服務的訪問壓力。
數據冗余
服務調用者可以冗余它所依賴服務的數據。當依賴的服務故障時,服務調用者可以直接使用冗余數據。
例如,我之前在某家自營電商公司。當時的商品服務依賴于價格服務,獲取商品信息時,商品服務要調用價格服務獲取商品價格。因為是自營電商,商品和SKU數量都不太多,一兩萬的樣子。所以我們在商品服務冗余了價格數據。當價格服務故障后,商品服務還可以從自己冗余的數據中取到價格。當然這樣做價格有可能不是最新的,但畢竟這是降級方案,犧牲一些數據準確性,換來系統的可用性還是很有意義的!(注:由于一個商品會有多個價格,比如普通價,會員價,促銷直降價,促銷滿減價,所以我們把價格做成了單獨的服務)
數據冗余可以結合熔斷一起使用,實現自動降級。下面的熔斷部分會詳細說明。
熔斷和Fallback
熔斷是一種自動降級手段。當服務不可用時,用來避免連鎖故障,雪崩效應。發生在服務調用的時候,在調用方做熔斷處理。熔斷的意義在于,調用方快速失敗(Fail Fast),避免請求大量阻塞。并且保護被調用方。
詳細解釋一下,假設A服務調用B服務,B發生故障后,A開啟熔斷:
對于調用方A:請求在A直接快速返回,快速失敗,不再發送到B。 避免因為B故障,導致A的請求線程持續等待,進而導致線程池線程和CPU資源耗盡,最終導致A無法響應,甚至整條調用鏈故障。
對于被調用方B:熔斷后,請求被A攔截,不再發送到B,B壓力得到緩解,避免了仍舊存活的B被壓垮,B得到了保護。
還是以電商的商品和價格服務為例。獲取商品信息時,商品服務要調用價格服務獲取商品價格。為了提高系統穩定性,我們要求各個服務要盡量自保。所以我們在商品服務加了熔斷,當價格服務故障時,商品服務請求能夠快速失敗返回,保證商品服務不被拖垮,進而避免連鎖故障。
看到這,可能有讀者會問,快速失敗后價格怎么返回呢?因為是自營電商,商品和SKU數量都不太多,一兩萬的樣子。所以我們做了數據冗余,在商品服務冗余了價格數據。這樣我們在熔斷后獲取價格的fallback方案就變成了從商品服務冗余的數據去取價格。下圖為商品服務熔斷關閉和開啟的對比圖。
開源熔斷組件:Hystrix,Resilience4j等
限流
說起服務降級,就不可避免的要聊到限流。我們先考慮一個場景,例如電商平臺要搞促銷活動,我們按照預估的峰值訪問量,準備了30臺機器。但是活動開始后,實際參加的人數比預估的人數翻了5倍,這就遠遠超出了我們的服務處理能力,給后端服務、緩存、數據庫等帶來巨大的壓力。隨著訪問請求的不斷涌入,最終很可能造成平臺系統崩潰。對于這種突發流量,我們可以通過限流來保護后端服務。因為促銷活動流量來自于用戶,用戶的請求會先經過網關層再到后端服務,所以網關層是最合適的限流位置,如下圖。
另外,考慮到用戶體驗問題,我們還需要相應的限流頁面。當某些用戶的請求被限流攔截后,把限流頁面返回給用戶。頁面如下圖。
另外一個場景,假如有一個核心服務,有幾十個服務都調用他。如果其中一個服務調用者出了Bug,頻繁調用這個核心服務,可能給這個核心服務造成非常大的壓力,甚至導致這個核心服務無法響應。同時也會影響到調用他的幾十個服務。所以每個服務也要根據自己的處理能力對調用者進行限制。
對于服務層的限流,我們一般可以利用spring AOP,以攔截器的方式做限流處理。這種做法雖然可以解決問題,但是問題也比較多。比如一個服務中有100個接口需要限流,我們就要寫100個攔截器。而且限流閾值經常需要調整,又涉及到動態修改的問題。為了應對這些問題,很多公司會有專門的限流平臺,新增限流接口和閾值變動可以直接在限流平臺上配置。
關于限流,還有很多細節需要考慮,比如限流算法、毛刺現象等。篇幅原因,這些問題就不在本文討論了。
開源網關組件:Nginx,Zuul,Gateway,阿里Sentinel等
服務降級總結和思考
上面我們結合具體案例解釋了多種降級方式。實際上,關于服務降級的方式和策略,并沒有什么定式,也沒有標準可言。上面的降級方式也沒有涵蓋所有的情況。不同公司不同平臺的做法也不完全一樣。不過,所有的降級方案都要以滿足業務需求為前提,都是為了提高系統的可用性,保證核心功能正常運行。
降級分類
一般我們可以把服務降級分為手動和自動兩類。手動降級應用較多,可以通過開關的方式開啟或關閉降級。自動降級,比如熔斷和限流等屬于自動降級的范疇。大多手動降級也可以做成自動的方式,可以根據各種系統指標設定合理閾值,在相應指標達到閾值上限自動開啟降級。在很多場景下,由于業務過于復雜,需要參考的指標太多,自動降級實現起來難度會比較大,而且也很容易出錯。所以在考慮做自動降級之前一定要充分做好評估,相應的自動降級方案也要考慮周全。
大規模分布式系統如何降級?
在大規模分布式系統中,經常會有成百上千的服務。在大促前往往會根據業務的重要程度和業務間的關系批量降級。這就需要技術和產品提前對業務和系統進行梳理,根據梳理結果確定哪些服務可以降級,哪些服務不可以降級,降級策略是什么,降級順序怎么樣。大型互聯網公司基本都會有自己的降級平臺,大部分降級都在平臺上操作,比如手動降級開關,批量降級順序管理,熔斷閾值動態設置,限流閾值動態設置等等。
本節的主要目的是通過具體實例,讓大家了解服務降級,并提供一些降級的思路。具體的降級方式和方案還是要取決于實際的業務場景和系統狀況。
微服務架構下數據一致性問題
服務化后單體系統被拆分成多個服務,各服務訪問自己的數據庫。而我們的一次請求操作很可能要跨多個服務,同時要操作多個數據庫的數據,我們發現以前用的數據庫事務不好用了。那么基于微服務的架構如何保證數據一致性呢?
好,咱們這次就盤一盤分布式事務,最終一致,補償機制,事務型消息!
提起這些,大家可能會想到兩階段提交,XA,TCC,Saga,還有最近阿里開源的Seata(Fescar),這些概念網上一大堆文章,不過都太泛泛,不接地氣,讓人看了云里霧里。
我們以TCC分布式事務和RocketMQ事務型消息為例,做詳細分享!這個弄明白了,也就清楚分布式事務,最終一致,補償機制這些概念啦!
TCC分布式事務
TCC(Try-Confirm-Cancel)是分布式事務的一種模式,可以保證不同服務的數據最終一致。目前有不少TCC開源框架,比如Hmily,ByteTCC,TCC-Transaction (我們之前用過Hmily和公司架構組自研組件)
下面以電商下單流程為例對TCC做詳細闡述
流程圖如下:
基本步驟如下:
1,修改訂單狀態為“已支付”
2,扣減庫存
3,扣減優惠券
4,通知WMS(倉儲管理系統)撿貨出庫(異步消息)
我們先看扣減庫存,更新訂單狀態和扣減優惠券這三步同步調用,通知WMS的異步消息會在后面的“基于消息的最終一致”部分詳細闡述!
下面是偽代碼。不同公司的產品邏輯會有差異,相關代碼邏輯也可能會有不同,大家不用糾結代碼邏輯正確性。
public void makePayment() {
orderService.updateStatus(OrderStatus.Payed); //訂單服務更新訂單為已支付狀態
inventoryService.decrStock(); //庫存服務扣減庫存
couponService.updateStatus(couponStatus.Used); //卡券服務更新優惠券為已使用狀態
}
看完這段代碼,大家可能覺得很簡單!那么有什么問題嗎?
答案是肯定的。沒法保證數據一致性,也就是說不能保證這幾步操作全部成功或者全部失敗!因為這幾步操作是在分布式環境下進行的,每個操作分布在不同的服務中,不同的服務又對應不同的數據庫,本地事務已經用不上了!假如第一步更新訂單為“已支付”成功了,第二步扣減庫存時,庫存服務掛了或者網絡出問題了,導致扣減庫存失敗。你告訴用戶支付成功了,但是庫存沒扣減。這怎么能行!
TCC
接下來,我們來看看TCC是如何幫我們解決這個問題并保證數據最終一致的。
TCC分為兩個階段:
階段一:Try(預留凍結相關業務資源,設置臨時狀態,為下個階段做準備)
階段二:Confirm 或者 Cancel(Confirm:對資源進行最終操作,Cancel:取消資源)
第一階段:Try
更新訂單狀態:此時因為還沒真正完成整個流程,訂單狀態不能直接改成已支付狀態。可以加一個臨時狀態Paying,表明訂單正在支付中,支付結果暫時還不清楚!
凍結庫存:假設現在可銷售庫存stock是10,這單扣減1個庫存,別直接把庫存減掉,而是在表中加一個凍結字段locked_stock,locked_stock 加 1,再給stock減1,這樣就相當于凍結了1個庫存。兩個操作放在一個本地事務里。
更新優惠券狀態:優惠券加一個臨時狀態Inuse,表明優惠券正在使用中,具體有沒有正常被使用暫時還不清楚!
第二階段:Confirm
假如第一階段幾個try操作都成功了!既然第一階段已經預留了庫存,而且訂單狀態和優惠券狀態也設置了臨時狀態,第二階段的確認提交從業務上來說應該沒什么問題了。
Confirm 階段我們需要做下面三件事:
先將訂單狀態從Paying改為已支付Payed,訂單狀態也完成了。
再將凍結的庫存恢復locked_stock減1,stock第一階段已經減掉1是9了,到此扣減庫存就真正完成了。
再將優惠券狀態從Inuse改為Used,表明優惠券已經被正常使用。
第二階段:Cancel
假如第一階段失敗了,
先將訂單狀態從Paying恢復為待支付UnPayed。
再將凍結的庫存還回到可銷售庫存中,stock加1恢復成10, locked_stock減1,可以放在一個本地事務完成。
再將優惠券狀態從Inuse恢復為未使用Unused。
基于Hmily框架的代碼:
//訂單服務
public class OrderService{
//tcc接口
@Hmily(confirmMethod = "confirmOrderStatus", cancelMethod = "cancelOrderStatus")
public void makePayment() {
更新訂單狀態為支付中
凍結庫存,rpc調用
優惠券狀態改為使用中,rpc調用
}
public void confirmOrderStatus() {
更新訂單狀態為已支付
}
public void cancelOrderStatus() {
恢復訂單狀態為待支付
}
//庫存服務
public class InventoryService {
//tcc接口
@Hmily(confirmMethod = "confirmDecr", cancelMethod = "cancelDecr")
public void lockStock() {
//防懸掛處理(下面有說明)
if (分支事務記錄表沒有二階段執行記錄)
凍結庫存
else
return;
}
public void confirmDecr() {
確認扣減庫存
}
public void cancelDecr() {
釋放凍結的庫存
}
}
//卡券服務
public class CouponService {
//tcc接口
@Hmily(confirmMethod = "confirm", cancelMethod = "cancel")
public void handleCoupon() {
//防懸掛處理(下面有說明)
if (分支事務記錄表沒有二階段執行記錄)
優惠券狀態更新為臨時狀態Inuse
else
return;
}
public void confirm() {
優惠券狀態改為Used
}
public void cancel() {
優惠券狀態恢復為Unused
}
}
疑問?
問題1:有些朋友可能會問了!這些關于流程的邏輯也要手動編碼嗎?這也太麻煩了吧!
實際上TCC分布式事務框架幫我們把這些事都干了。比如我們前面提到的Hmily,ByteTCC,TCC-transaction 這些框架。因為try,confirm,cancel這些操作都在TCC分布式事務框架控制范圍之內,所以try的各個步驟成功了或者失敗了,框架本身都知道,try成功了框架就會自動執行各個服務的confirm,try失敗了框架就會執行各個服務的cancel(各個服務內部的TCC分布式事務框架會互相通信)。所以我們不用關心流程,只需要關注業務代碼就可以啦!
問題2:仔細想想,好像還有問題!假如confirm階段更新訂單狀態成功了,但是扣減庫存失敗了怎么辦呢?
比如網絡出問題了或者庫存服務(宕機,重啟)出問題了。當然,分布式事務框架也會考慮這些場景,框架會記錄操作日志,假如confirm階段扣減庫存失敗了,框架會不斷重試調用庫存服務直到成功(考慮性能問題,重試次數應該有限制)。cancel過程也是一樣的道理。注意,既然需要重試,我們就要保證接口的冪等性。什么?不太懂冪等性。簡單說:一個操作不管請求多少次,結果都要保證一樣。這里就不詳細介紹啦!
再考慮一個場景,try階段凍結庫存的時候,因為是rpc遠程調用,在網絡擁塞等情況下,是有可能超時的。假如凍結庫存時發生超時,tcc框架會回滾(cancel)已經執行的分布式事務操作,也就是下單操作。過了一段時間網絡通暢了,之前的凍結庫存請求才到達參與者(庫存服務)并執行了凍結庫存操作,由于已經執行了cancle,所以此時被凍結的庫存后續沒辦法處理(恢復)了。這種情況稱之為“懸掛”,也就是說預留的資源后續無法處理。解決方案:第二階段已經執行,第一階段就不再執行了,可以加一個“分支事務記錄表”,如果表里有相關第二階段的執行記錄,就不再執行try(上面代碼有防懸掛處理)。有人可能注意到還有些小紕漏,對,加鎖,分布式環境下,我們可以考慮對第二階段執行記錄的查詢和插入加上分布式鎖,確保萬無一失。
rocketmq
基于消息的最終一致
還是以上面的電商下單流程為例
上圖,下單流程最后一步,通知WMS撿貨出庫,是異步消息走消息隊列。
public void makePayment() {
orderService.updateStatus(OrderStatus.Payed); //訂單服務更新訂單為已支付狀態
inventoryService.decrStock(); //庫存服務扣減庫存
couponService.updateStatus(couponStatus.Used); //卡券服務更新優惠券為已使用狀態
發送MQ消息撿貨出庫; //發送消息通知WMS撿貨出庫
}
按上面代碼,大家不難發現問題!如果發送撿貨出庫消息失敗,數據就會不一致!有人說我可以在代碼上加上重試邏輯和回退邏輯,發消息失敗就重發,多次重試失敗所有操作都回退。這樣一來邏輯就會特別復雜,回退失敗要考慮,而且還有可能消息已經發送成功了,但是由于網絡等問題發送方沒得到MQ的響應,這些問題都要考慮進來!
幸好,有些消息隊列幫我們解決了這些問題。比如阿里開源的RocketMQ(目前已經是Apache開源項目),4.3.0版本開始支持事務型消息(實際上早在貢獻給Apache之前曾經支持過事務消息,后來被閹割了,4.3.0版本重新開始支持事務型消息)。
先看看RocketMQ發送事務型消息的流程:
概念介紹
- 事務消息:消息隊列RocketMQ版提供類似X或Open XA的分布式事務功能,通過消息隊列RocketMQ版事務消息能達到分布式事務的最終一致。
- 半事務消息:暫不能投遞的消息,發送方已經成功地將消息發送到了消息隊列RocketMQ版服務端,但是服務端未收到生產者對該消息的二次確認,此時該消息被標記成“暫不能投遞”狀態,處于該種狀態下的消息即半事務消息。
- 消息回查:由于網絡閃斷、生產者應用重啟等原因,導致某條事務消息的二次確認丟失,消息隊列RocketMQ版服務端通過掃描發現某條消息長期處于“半事務消息”時,需要主動向消息生產者詢問該消息的最終狀態(Commit或是Rollback),該詢問過程即消息回查。
事務消息發送步驟如下:
- 發送方將半事務消息發送至消息隊列RocketMQ版服務端。
- 消息隊列RocketMQ版服務端將消息持久化成功之后,向發送方返回Ack確認消息已經發送成功,此時消息為半事務消息。
- 發送方開始執行本地事務邏輯。
- 發送方根據本地事務執行結果向服務端提交二次確認(Commit或是Rollback),服務端收到Commit狀態則將半事務消息標記為可投遞,訂閱方最終將收到該消息;服務端收到Rollback狀態則刪除半事務消息,訂閱方將不會接受該消息。
事務消息回查步驟如下:
- 在斷網或者是應用重啟的特殊情況下,上述步驟4提交的二次確認最終未到達服務端,經過固定時間后服務端將對該消息發起消息回查。
- 發送方收到消息回查后,需要檢查對應消息的本地事務執行的最終結果。
- 發送方根據檢查得到的本地事務的最終狀態再次提交二次確認,服務端仍按照步驟4對半事務消息進行操作。
注意事項
- 事務消息的Group ID不能與其他類型消息的Group ID共用。與其他類型的消息不同,事務消息有回查機制,回查時消息隊列RocketMQ版服務端會根據Group ID去查詢客戶端。
- 通過ONSFactory.createTransactionProducer創建事務消息的Producer時必須指定LocalTransactionChecker的實現類,處理異常情況下事務消息的回查。
- 事務消息發送完成本地事務后,可在execute方法中返回以下三種狀態:
- TransactionStatus.CommitTransaction:提交事務,允許訂閱方消費該消息。
- TransactionStatus.RollbackTransaction:回滾事務,消息將被丟棄不允許消費。
- TransactionStatus.Unknow:暫時無法判斷狀態,等待固定時間以后消息隊列RocketMQ版服務端向發送方進行消息回查。
- 可通過以下方式給每條消息設定第一次消息回查的最快時間:
Message message = new Message();
// 在消息屬性中添加第一次消息回查的最快時間,單位秒。例如,以下設置實際第一次回查時間為120秒~125秒之間message.putUserProperties(PropertyKeyConst.CheckImmunityTimeInSeconds,"120");
// 以上方式只確定事務消息的第一次回查的最快時間,實際回查時間向后浮動0秒~5秒;如第一次回查后事務仍未提交,后續每隔5秒回查一次
看完事務型消息發送流程,有些讀者可能沒有完全理解,不要緊,我們來分析一下!
疑問?
問題1:假如發送方發送半消息失敗怎么辦?
半消息(待確認消息)是消息發送方發送的,發送后mq服務端會返回ack確認消息,發送方可根據返回的ack消息并可以做相應處理。
問題2:假如發送方執行完本地事務后,發送確認消息通知MQ提交或回滾消息時失敗了(網絡問題,發送方重啟等情況),怎么辦?
流程圖的第5步回查就是解決這個問題的,當MQ發現一個消息長時間處于半消息(待確認消息)的狀態,MQ會以定時任務的方式主動回查發送方并獲取發送方執行結果。這樣即便出現網絡問題或者發送方本身的問題(重啟,宕機等),MQ通過定時任務主動回查發送方基本都能確認消息最終要提交還是回滾(拋棄)。當然出于性能和半消息堆積方面的考慮,MQ本身也會有回查次數的限制。
問題3:如何保證消費一定成功呢?
RocketMQ本身有ack機制,來保證消息能夠被正常消費。如果消費失敗(消息訂閱方出錯,宕機等原因),RocketMQ會把消息重發回Broker,在某個延遲時間點后(默認10秒后)重新投遞消息,如果一直嘗試失敗,最后只能人為補償。
結合上面幾個同步調用hmily完整代碼如下:
//TransactionListener是rocketmq接口用于回調執行本地事務和狀態回查
public class TransactionListenerImpl implements TransactionListener {
//執行本地事務
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try{
DB::beginTransaction();
//執行本地本地事務
//記錄orderID,消息狀態鍵值對到共享map中,以備MQ回查消息狀態使用;
DB::commit();
return LocalTransactionState.COMMIT_MESSAGE;
}catch (\Exception $exception){
//記錄orderID,消息狀態鍵值對到共享map中,以備MQ回查消息狀態使用;
Log::error('transaction',['error'=>$exception->getMessage()]);
throw new Exception($exception->getMessage());
DB::rollBack();
return LocalTransactionState. RollbackTransaction;
}
}
//回查發送者狀態
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
String status = 從共享map中取出orderID對應的消息狀態;
if("commit".equals(status))
return LocalTransactionState.COMMIT_MESSAGE;
else if("rollback".equals(status))
return LocalTransactionState.ROLLBACK_MESSAGE;
else
return LocalTransactionState.UNKNOW;
}
}
//訂單服務
public class OrderService{
//tcc接口
@Hmily(confirmMethod = "confirmOrderStatus", cancelMethod = "cancelOrderStatus")
public void makePayment() {
1,更新訂單狀態為支付中
2,凍結庫存,rpc調用
3,優惠券狀態改為使用中,rpc調用
4,發送半消息(待確認消息)通知WMS撿貨出庫 //創建producer時這冊TransactionListenerImpl
}
public void confirmOrderStatus() {
更新訂單狀態為已支付
}
public void cancelOrderStatus() {
恢復訂單狀態為待支付
}
}
//庫存服務
public class InventoryService {
//tcc接口
@Hmily(confirmMethod = "confirmDecr", cancelMethod = "cancelDecr")
public void lockStock() {
//防懸掛處理
if (分支事務記錄表沒有二階段執行記錄)
凍結庫存
else
return;
}
public void confirmDecr() {
確認扣減庫存
}
public void cancelDecr() {
釋放凍結的庫存
}
}
//卡券服務
public class CouponService {
//tcc接口
@Hmily(confirmMethod = "confirm", cancelMethod = "cancel")
public void handleCoupon() {
//防懸掛處理
if (分支事務記錄表沒有二階段執行記錄)
優惠券狀態更新為臨時狀態Inuse
else
return;
}
public void confirm() {
優惠券狀態改為Used
}
public void cancel() {
優惠券狀態恢復為Unused
}
}
如果執行到TransactionListenerImpl.executeLocalTransaction方法,說明半消息已經發送成功了,也就是執行到流程中的第三步了,然后根據本地事務的執行情況返回結果,如果返回TransactionStatus.CommitTransaction,則消息訂閱費將開始投遞消息到mq,同時執行tcc到了confirm階段。如果返回TransactionStatus.RollbackTransaction,則消息訂閱費丟棄消息,同時執行tcc到了cancle階段。如果本地事務沒有執行第4步,可能情況是節點宕機,也就是沒有給mq服務端回復消息,此時就會回查,所以我們需要在executeLocalTransaction方法內記錄一個map,方便在回查的時候判斷后續執行操作。
微服務化過程,無感知數據遷移
微服務化,其中一個重要意義在于數據隔離,即不同的服務對應各自的數據庫表,避免不同業務模塊間數據的耦合。這也就意味著微服務化過程要拆分現有數據庫,把單體數據庫根據業務模塊拆分成多個,進而涉及到數據遷移。
其次是數據庫單體容量過大時,需要分表或者分片時,也需要將原來的數據重新的數據庫內,進而也涉及到了數據遷移。
數據遷移過程我們要注意哪些關鍵點呢?第一,保證遷移后數據準確不丟失,即每條記錄準確而且不丟失記錄;第二,不影響用戶體驗(尤其是訪問量高的C端業務需要不停機平滑遷移);第三,保證遷移后的性能和穩定性。
針對以上倆種場景,我們提出下面倆種方法:
掛從庫
這種方式適合數據結構不變,而且空閑時間段流量很低,允許停機遷移的場景。在空閑時段,幾分鐘的停機時間,對用戶影響很小,業務方是可以接受的。所以我們可以采用停機遷移的方案。步驟如下:
1,新建從庫(新數據庫),數據開始從主庫向從庫同步。
2,數據同步完成后,找一個空閑時間段。為了保證主從數據庫數據一致,需要先停掉服務,然后再把從庫升級為主庫。如果訪問數據庫用的是域名,直接解析域名到新數據庫(從庫升級成的主庫),如果訪問數據庫用的是IP,將IP改成新數據庫IP。
- 針對微服務場景,新庫就是一個單獨的數據庫,只要留下本服務需要的表即可,其他表即可刪除。針對分片場景,新庫就是一個已經具備分片功能的數據庫,數據寫入該庫后會按照分片規則自動倒入分片節點內。
4,最后啟動服務,整個遷移過程完成。
這種遷移方案的優勢是遷移成本低,遷移周期短。缺點是,切換數據庫過程需要停止服務。
雙寫
老庫和新庫同時寫入,然后將老數據批量遷移到新庫,最后流量切換到新庫并關閉老庫讀寫。
這種方式適合數據結構發生變化,不允許停機遷移的場景。一般發生在系統重構時,數據結構會發生變化,如表結構改變或者分庫分表等場景。有些大型互聯網系統,平常并發量很高,即便是空閑時段也有相當的訪問量。幾分鐘的停機時間,對用戶也會有明顯的影響,甚至導致一定的用戶流失,這對業務方來說是無法接受的。所以我們需要考慮一種用戶無感知的不停機遷移方案,不停機遷移不需要保證老數據的正確遷移同時也要保證新數據的寫入。
以筆者之前經歷的用戶系統重構為例,聊一下具體方案。當時的場景是這樣的,用戶表記錄數達到3000萬時,系統性能和可維護性變差,于是我們將用戶中心從單體工程中拆分出來并做了重構,重新設計了表結構,而且業務方要求不停機上線!就需要注意下面是我們當時的方案,步驟如下:
代碼準備。在服務層對用戶表進行增刪改的地方,要同時操作新庫和老庫,需要修改相應的代碼(同時寫新庫和老庫)。準備遷移程序腳本,用于做老數據遷移。準備校驗程序腳本,用于校驗新庫和老庫的數據是否一致。
對于第一種微服務場景,新庫可以是一個單獨的數據庫,對于第二種場景新庫就是一個已經搭建好具備分片功能的數據庫。
開啟雙寫,老庫和新庫同時寫入。注意:任何對數據庫的增刪改都要雙寫;對于更新操作,如果新庫沒有相關記錄,需要先從老庫查出記錄,將更新后的記錄寫入新庫,如果新庫有記錄則新庫老庫一起更新;對于刪除操作,如果新庫沒有數據則刪除老庫數據即可,如果新庫有數據則新庫老庫一起刪除。對于新增操作則保證新庫和老庫都新增成功。
為了保證寫入性能,老庫寫完后,可以采用消息隊列異步寫入新庫。
注意:雙寫的操作,需要保證數據的id(主鍵也要相同),比如老庫新增老一條id為100的數據,那么在新庫中這條數據的id(主鍵)也是100。利用腳本程序,將某一時間戳之前的老數據遷移到新庫。注意:1,時間戳一定要選擇開啟雙寫后的時間點(比如開啟雙寫后10分鐘的時間點),避免部分老數據被漏掉;2,遷移過程遇到記錄沖突直接忽略,產生沖突的原因可能是遷移遷移之前老庫的數據已經存在于新庫了(存在主鍵沖突的時間段就是雙寫開始時間到遷移老數據開始時間,這段時間新庫已經存在了老庫的一些數據),當遷移操作開始后在往新庫插入相同數據將會報主鍵沖突等問題;3,遷移過程一定要記錄日志,尤其是錯誤日志,如果有雙寫失敗的情況,我們可以通過日志恢復數據,以此來保證新老庫的數據一致。4.老表遷移到新表的數據的主鍵Id也一定要一致,比如老表的數據Id為100,那么這條數據遷移到新表的id也應該為100
第3步完成后,我們還需要通過腳本程序檢驗數據,看新庫數據是否準確以及有沒有漏掉的數據
對比辦法:讀取所有字段,根據字段名稱+字段值進行拼接,拼接MD5是否相同,相同則不追究,不相同用原表進行覆蓋
public function run()
{
$oldData = oldSheet::findRows(['*'],[]);
$newData = newSheet::findRows(['*'],[]);
//主鍵=>list
$id2New = array_column($newData,null,'id');
foreach ($oldData as $list){
$ret = $this->isMatch($id2New[$list['id']],$list);
if($ret == 1){
//可能發生主鍵沖突,忽略
newSheet::insert($list,true);
}
if($ret == 3){
newSheet::deleteAll([$list['id']]);
newSheet::insert($list,true);
}
}
}
public function isMatch(array $newRow, array $oldRow)
{
$newStr = $oldStr = '';
if(empty($newRow)){
//表示新表漏掉了數據,新表不存在
return 1;
}
foreach ($oldRow as $field => $value) {
$oldStr .= $field . $value;
$newStr .= $field . $newRow[$field];
}
if(md5($newStr) != md5($oldStr)){
//表示新表和老表數據一致
return 2;
}else{
//表示新表和老表數據不一致
return 3;
}
}
數據校驗沒問題后,開啟雙讀,起初給新庫放少部分流量,新庫和老庫同時讀取。由于延時問題,新庫和老庫可能會有少量數據記錄不一致的情況,所以新庫讀不到時需要再讀一遍老庫。逐步將讀流量切到新庫,相當于灰度上線的過程。遇到問題可以及時把流量切回老庫
讀流量全部切到新庫后,關閉老庫寫入(可以在代碼里加上熱配置開關),只寫新庫
-
遷移完成,后續可以去掉雙寫雙讀相關無用代碼。
圖片 切回單寫及數據表rename
當切換的新表和老表屬于同一個庫時,這里最好進行rename操作,也就是新表rename成老表。
好處:如果老表的數據來自上游寫入,那么我只需要將新表rename為老表即可,上游表對應不需要更新了。
壞處:這個操作需要dba進行,且rename期間服務不可用
目前各云服務平臺也提供數據遷移解決方案,大家有興趣也可以了解一下!
全鏈路APM監控
在體會到微服務帶來好處的同時,很多公司也會明顯感受到微服務化后那些讓人頭疼的問題。比如,服務化之后調用鏈路變長,排查性能問題可能要跨多個服務,定位問題更加困難;服務變多,服務間調用關系錯綜復雜,以至于很多工程師不清楚服務間的依賴和調用關系,之后的系統維護過程也會更加艱巨。諸如此類的問題還很多!
這時就迫切需要一個工具幫我們解決這些問題,于是APM全鏈路監控工具就應運而生了。有開源的Pinpoint、Skywalking等,也有收費的Saas服務聽云、OneAPM等。有些實力雄厚的公司也會自研APM。
下面我們介紹一下如何利用開源APM工具Pinpoin
t應對上述問題。
拓撲圖
微服務化后,服務數量變多,服務間調用關系也變得更復雜,以至于很多工程師不清楚服務間的依賴和調用關系,給系統維護帶來很多困難。通過拓撲圖我們可以清晰地看到服務與服務,服務與數據庫,服務與緩存中間件的調用和依賴關系。對服務關系了如指掌之后,也可以避免服務間循依賴、循環調用的問題。
請求調用棧(Call Stack)監控
微服務化后,服務變多,調用鏈路變長,跨多個服務排查問題會更加困難。上圖是一個請求的調用棧,我們可以清晰看到一次請求調用了哪些服務和方法、各個環節的耗時以及發生在哪個服節點。上圖的請求耗時過長,根據監控(紅框部分)我們可以看到時間主要消耗在數據庫SQL語句上。點擊數據庫圖表還可以看詳細sql語句,如下圖:
如果發生錯誤,會顯示為紅色,錯誤原因也會直接顯示出來。如下圖:
類似性能問題和錯誤的線上排查。我們如果通過查日志的傳統辦法,可能會耗費大量的時間。但是通過APM工具分分鐘就可以搞定了!
請求Server Map
Server Map是Pinpoint另一個比較重要的功能。如上圖,我們不但能清晰地看到一個請求的訪問鏈路,而且還能看到每個節點的訪問次數,為系統優化提供了有力的依據。如果一次請求訪問了多次數據庫,說明代碼邏輯可能有必要優化了!
JVM監控
此外,Pinpoint還支持堆內存,活躍線程,CPU,文件描述符等監控。