發表日期 4/10/2022, 11:47:45 AM
作者 | Hugo Rocha
譯者 | 平川
策劃 | 閆園園
富蘭剋林・羅斯福曾經說過,我們往往過多地考慮瞭早起的鳥兒運氣好,卻不怎麼想早起的蟲子運氣差。我從來不玩彩票。彩票的失敗率大到驚人;實際上,成為聖人或美國總統的可能性都比贏得彩票(例如歐洲的 EuroMillions 或美國的 Powerball)大。
事件驅動型服務的並發常常是一種有保障的反麵的彩票中奬,雖然對於特定的並發問題可能概率很低。然而,一切都歸結於嘗試次數,由於服務所處理的事件量非常大,所以一個不大可能的事件幾乎變成瞭一定會發生的事情。例如,我們曾經遇到一個問題,其發生的概率大約為百萬分之一。該服務每秒處理約一百條信息,這意味著該問題每小時會發生三次左右。根據設計,事件驅動型服務需要應對巨大的規模和吞吐量,使得並發問題特彆容易發生。
並發問題,或稱競態條件,是指當某行代碼並行運行時所産生的意想不到的行為,如果代碼單綫程運行,就不會齣現這種情況。對程序員來說,處理並發問題往往不是自然而然的事情,我們習慣於以單綫程的方式來考慮我們的代碼。檢測並確保代碼並行運行的安全,往往需要一個有豐富經驗、接受過專門訓練的人。而且,並發問題並不明顯,往往隻在生産環境中纔會暴露齣來,因為本地或開發環境與實際環境的吞吐量有很大的差彆。
火星漫遊者
例如,美國國傢航空航天局(NASA)有非常嚴格的編碼準則,以及一個非常詳盡、細緻的質量保證過程。畢竟,調試地球以外的東西與分析大多數生産問題不太一樣(雖然有時會覺得有異樣的事情在發生)。一個短暫齣現的錯誤,很可能會被大多數開發人員所忽略,但卻往往是一個競態條件的癥狀。NASA 可不會放過類似的問題,它甚至可能追蹤到應用程序之外,開發人員甚至可能要深挖到操作係統層纔能找齣根本原因。事實上,那是幾百萬美元的風險。但是,即使有這樣孜孜不倦的過程,競態條件往往還是不可避免。例如,我還記得美國國傢航空航天局(NASA)因競態條件而與火星車失去聯係的那段插麯。
並發問題的不可避免性和事件驅動型服務的高吞吐量,使得製定一個深思熟慮的策略來從根本上解決並發問題的需求變得尤為迫切。事件驅動型服務的一個重要屬性是能夠通過添加同一服務的多個實例來進行橫嚮擴展。這種方法使傳統的並發處理方式失效,因為不同的請求可能會被發送到不同的實例上,所以要做一個內存鎖,如互斥量、鎖或信號量。通常,分布式係統采用外部工具來管理分布式並發,如 Consul 或 Zookeeper。然而,對於事件驅動型服務,可以引入一個本質上完全不同的概念來處理並發。端到端消息路由是一種非常有效並且可擴展的方法,它是通過設計(使用架構解決方案)來處理並發問題,而不是實現(求助於外部工具或在服務實現中)。
多年來,我們藉助 RabbitMQ 和 Kafka,在多個不同的生産用例中嘗試瞭幾種不同的方法。我們最終決定在可能的時候通過設計來處理並發問題,而不是通過實現。以下是我們在生産中全麵使用的一些解決方案,希望可以為你處理並發問題帶來一些靈感。
1
並發問題示例
讓我們用一個例子來說明。想象一下,我們有一個産品在綫銷售平台,用戶可以訂閱“新進 ”和 “熱銷補貨”産品的通知。每當所需産品的庫存增加時,用戶可以通過郵件、短信等方式接收通知。持有産品和庫存信息的服務在每次庫存發生變化時都會發送一個事件。訂閱服務必須知道産品庫存何時從 0 變為 1,並在變化時發送通知。下圖說明瞭這種情況。
訂閱服務處理 ProductStockIn 事件,在産品庫存改變時作齣反應。因為隻有當庫存從 0 變為 1 時,訂閱纔有價值,該服務在內部狀態中保存每個産品的當前庫存。ProductStockIn 事件流包括以下動作:
1. 産品服務發布事件;
2. 訂閱服務處理事件;
3. 獲取本地庫存,檢查庫存是否從 0 變為 1;
4. 獲取當前的訂閱信息;
5. 針對每條訂閱發送通知;
6. 更新本地庫存數據。
在單綫程思維模式下,這種方法講得通,不會産生任何問題。然而,為瞭充分優化服務資源並達到閤理的性能,我們應該給服務添加並行性。如果服務處理兩個或兩個以上的事件會發生什麼?一個競態條件會使服務把同一個訂閱發布兩次。如果服務處理兩個庫存變化事件(例如,庫存從 0 到 1 和從 1 到 2),並同時運行步驟 3 的驗證,那麼它將傳入兩個事件,産生一個競態條件,並因此把相同的通知發送兩次。
要處理這個問題,隻需簡單地用傳統的並發處理方法(如鎖、互斥量、信號量等)鎖定綫程執行。然而,傳統方法隻適用於單實例服務,如下圖所示。
由於內存中的鎖隻被做鎖的實例共享,其他實例仍然能夠同時處理其他事件。同一産品的兩個庫存變化事件可以由不同的實例來處理,即使兩個實例都鎖定瞭它們的執行,也隻在它們各自的實例內有效,沒有什麼可以防止兩個實例之間産生並發問題。由於事件驅動型服務的一個重要屬性是水平擴展的能力,這類傳統的方法在這種情況下可以說相當不充分。
本地鎖的一個替代方法是使用數據庫來防止並發問題。處理貨幣時有一個典型的悲觀方法(下文會介紹更多關於悲觀方法和樂觀方法的內容),就是將操作包裹在一個事務中。然而,通常來說,沒有一種簡單直接的方法可以保證存在外部依賴時的交易一緻性,而又不涉足我們最想也應該避免的分布式交易領域。使用事務性一緻性也受限於支持它的技術,許多 NoSQL 數據庫並不提供與傳統關係型數據庫相同的保證。
2
悲觀方法 vs 樂觀方法
有兩種處理並發的方法:悲觀方法和樂觀方法。
悲觀的並發策略通過阻止對所需資源的並行訪問來防止並發。這類策略假設存在並發,並因此預先限製瞭對資源的訪問。這類策略適用於高並發的用例,即兩個進程很可能同時訪問同一資源。
樂觀並發策略假設不存在並發。這類策略是在並發問題發生時,提供一個策略來處理失敗的操作,拋齣一個錯誤或是重試該操作。樂觀並發在並發幾率較低的環境中最有效。
悲觀並發會影響性能,並且限製瞭解決方案的整體並發性。樂觀並發可以提供很好的性能,因為它不鎖定任何東西,隻是對失敗做齣反應。在低並發環境中,幾乎就像沒有並發處理策略一樣。然而,當並發的可能性很高時,與限製對資源的訪問相比,重試操作的成本通常要高很多。在這些情況下,最好使用悲觀並發。
3
Kafka 主題剖析
Kafka 是一個流行的事件流平台。如果你用它來實現簡單的發布 - 訂閱和事件流用例,並且不太關注它的內部工作原理,那麼你可能會因為使用其事件路由功能而錯過一些強大的功能。
發布的事件被發送到主題。Kafka 主題(類似於隊列,但即使在消費後也會持續保持每個事件,就像分布式事件日誌一樣)被劃分為不同的分區。下圖是對 Kafka 主題的剖析:
當應用程序將一個事件發布到一個特定的主題時,它會被存儲在一個特定的分區。為瞭將事件分配到分區,Kafka 會對鍵做哈希計算齣分區,當沒有鍵時,它就會在分區之間循環。然而請注意,使用鍵,我們可以確保所有鍵相同的事件被路由到相同的分區。我們將會看到,這是一個關鍵屬性。
消費者處理來自主題的事件。通常,事件驅動型服務是可以橫嚮擴展的,我們可以通過增加同一服務的實例來增加其吞吐量。因此,一個服務,例如我們在這個例子中討論的訂閱服務,可以有多個實例同時從同一主題消費,這就容易受到我們之前討論的並發問題的影響。一個分區有且隻有一個服務實例消費。
Kafka 保證每個分區的順序,但不保證主題的順序。也就是說,如果你發布一條消息到一個主題,並不能保證消費者按順序收到這些消息(盡管很可能會按順序收到,除非發生網絡分區或再平衡,而這並不常見)。然而,Kafka 保證單個分區中消息的順序。每個分區都僅被一個消費組中的一個實例所消費。
Kafka 是一個分布式事件流平台,關鍵詞是“分布式”。分區被分配到一台機器上,這意味著一個主題在物理上可以存儲在幾台機器上(連同其容錯副本)。這實現瞭高可擴展性和高可用性。然而,如果你和分布式係統打交道的時間足夠長,很可能就知道在幾台機器上保證順序有多難,因此它隻保證分區內的順序而不是整個主題內的。
不過,也並非全無作為,它提供瞭以下三個特性:
一個分區有且隻有一個服務實例消費。
路由鍵相同的事件被路由到同一個分區。
一個分區中可以保證順序。
上述三個特性為實現真正有用的解決方案奠定瞭基礎。它可以提供工具,按順序消費事件而不發生並發問題,正如我們接下來要看到的。
4
通過設計處理並發
如上所述,我們可以應用悲觀或樂觀的解決方案來處理並發。不過,還有一個完全不同的方法,就是通過設計來處理並發。我們不是應用策略來處理並發,而是將係統設計成根本沒有並發。當然,這是一個非常理想的方法,但在非事件驅動解決方案中往往不可行。利用我們前麵討論的三個特性,事件驅動型服務成為通過設計方法處理並發的主要受益者。
在事件驅動型服務中,通過設計處理並發有一個非常有效的方法是使用將事件路由到特定分區的能力。由於每個分區隻被一個實例所消費,所以我們可以根據路由鍵將每組事件路由到特定的實例。有瞭正確的路由鍵,我們就可以在設計係統時避免在同一實體內發生並發。
舉例來說,我們如何將這個理念應用到我們討論的産品和訂閱服務的例子中?比方說,我們使用産品 ID 作為路由鍵。根據我們剛纔討論的特性,同一産品的所有事件將被路由到同一分區,由於一個分區隻被唯一的實例所消費;該産品的所有事件將隻由一個實例來處理,如下所示:
産品 251 的所有庫存事件保證都由訂閱服務實例 #1 所消費,並且隻由該實例消費。由於沒有其他實例可以處理同一産品的事件,所以我們可以使用傳統的方法來處理並發問題,即使用鎖等進程內並發處理策略。我們將分布式並發問題轉化為進程內並發問題,這樣處理起來就比較簡單瞭。在訂閱服務內部,我們甚至可以使用相同的策略將事件路由到特定的綫程。這種端到端的事件路由可以以一種高度可擴展且可持續的方式消除並發。
由於 Kafka 保證瞭單個分區內的順序,所以事件也是有序的。因此,我們也避免瞭處理失序事件的復雜性。
通過設計解決並發問題,我們將係統設計成完全沒有並發。這樣做性能更高,錯誤更少,因為它不像悲觀方法那樣涉及特定資源鎖定,也不像樂觀方法那樣涉及重試操作。這也有利於新功能的開發,因為開發者無需考慮並發的邊緣情況;我們可以假設並發根本不存在。
5
小結
分布式係統中的並發是一個棘手的問題,悲觀方法和樂觀方法都是一種選項,但它們通常意味著性能損失。雖然在某些用例中很有用,但由於涉及到鎖定或重試,它們會影響到微服務的可擴展性。事件驅動型服務和將事件路由到特定服務實例的能力提供瞭一種優雅的方式來消除解決方案中的並發,即通過設計來解決並發,這為真正做到水平可擴展奠定瞭基礎。
查看英文原文:
https://itnext.io/solving-concurrency-in-event-driven-microservices-79bbc13b597c