天天看點

解決事件驅動型微服務中的并發問題

作者 | 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

繼續閱讀