Kafka很強大,可是一步出錯就可能致使系統數據損壞!

前言

Apache Kafka 已成爲跨微服務異步通訊的主流平臺。它有不少強大的特性,讓咱們可以構建健壯、有彈性的異步架構。面試

同時,咱們在使用它的過程當中也須要當心不少潛在的陷阱。若是未能提早發現可能發生(換句話說就是早晚會發生)的問題,咱們就要面對一個容易出錯和損壞數據的系統了,Java中間件面試真題 +學習筆記算法

今天小編會將重點介紹其中的一個陷阱:嘗試處理消息時遭遇失敗。首先,咱們須要意識到消息消費可能會,並且早晚會遭遇失敗。其次,咱們須要確保在處理此類故障時不會引入更多問題。數據庫

Kafka 簡介

網上也有一些介紹 Kafka 及其使用方法的深度文章。話雖如此,咱們這裏仍是先簡要回顧一下對咱們的討論很重要的一些概念。設計模式

事件日誌、發佈者和消費者

Kafka 是用來處理數據流的系統。從概念上講,咱們能夠認爲 Kafka 包含三個基本組件:架構

  • 一個事件日誌(Event Log),消息會發布到它這裏
  • 發佈者(Publisher),將消息發佈到事件日誌
  • 消費者(Consumer),消費(也就是使用)事件日誌中的消息

與 RabbitMQ 之類的傳統消息隊列不一樣,Kafka 由消費者來決定什麼時候讀取消息(也就是說,Kafka 採用了拉取而非推送模式)。每條消息都有一個偏移量(offset),每一個消費者都跟蹤(或提交)其最近消費消息的偏移量。這樣,消費者就能夠經過這條消息的偏移量請求下一條消息。異步

主題

事件日誌分爲幾個主題(topic),每一個主題都定義了要發佈給它的消息類型。定義主題是咱們這些工程師的責任,因此咱們應該記住一些經驗法則:分佈式

  • 每一個主題都應描述一個其餘服務可能須要瞭解的事件。
  • 每一個主題都應定義每條消息都將遵循的一個惟一模式(schema)。

分區和分區鍵

主題被進一步細分爲多個分區(partition)。分區使消息能夠被並行消費。Kafka 容許經過一個**分區鍵(partition key)**來肯定性地將消息分配給各個分區。分區鍵是一段數據(一般是消息自己的某些屬性,例如 ID),其上會應用一個算法以肯定分區。微服務

這裏,咱們將消息的 UUID 字段分配爲分區鍵。生產者應用一種算法(例如按照分區數修改每一個 UUID 值)來將每條消息分配給一個分區。學習

以這種方式使用分區鍵,使咱們可以確保與給定 ID 關聯的每條消息都會發布到單個分區上。測試

還須要注意的是,能夠將一個消費者的多個實例部署爲一個消費者組。Kafka 將確保給定分區中的任何消息將始終由組中的同一消費者實例讀取。

在微服務中使用 Kafka

Kafka 很是強大。因此它可用於多種環境中,涵蓋衆多用例。在這裏,咱們將重點介紹微服務架構中最多見的用法。

跨有界上下文傳遞消息

當咱們剛開始構建微服務時,咱們許多人一開始採用的是某種中心化模式。每條數據都有一個駐留的單一微服務(即單一真實來源)。若是其餘任何微服務須要訪問這份數據,它將發起一個同步調用以檢索它。

這種方法致使了許多問題,包括同步調用鏈較長、單點故障、團隊自主權降低等。

最後咱們找到了更好的辦法。在今天的成熟架構中,咱們將通訊分爲命令處理和事件處理。

命令處理一般在單個有界上下文中執行,而且每每仍是會包含同步通訊。

另外一方面,事件一般由一個有界上下文中的服務發出,並異步發佈到 Kafka,以供其餘有界上下文中的服務消費。

左側是咱們之前設計微服務通訊的方式:一個有界上下文(由虛線框表示)中的服務從其餘有界上下文中的服務接收同步調用。右邊是咱們現在的作法:一個有界上下文中的服務發佈事件,其餘有界上下文中的服務在本身空閒時消費它們。

例如,以一個 User 有界上下文爲例。咱們的 User 團隊會構建負責啓用新用戶、更新現有用戶賬戶等任務的應用程序和服務。

建立或修改用戶賬戶後,UserAccount 服務會將一個相應的事件發佈到 Kafka。其餘感興趣的有界上下文能夠消費該事件,將其存儲在本地,使用其餘數據加強它,等等。例如,咱們的 Login 有界上下文可能想知道用戶的當前名稱,以便在登陸時向他們致意。

咱們將這種用例稱爲跨邊界事件發佈。

在執行跨邊界事件發佈時,咱們應該發佈聚合(Aggregate)。聚合是自包含的實體組,每一個實體都被視爲一個單獨的原子實體。每一個聚合都有一個「根」實體,以及一些提供附加數據的從屬實體。

當管理聚合的服務發佈一條消息時,該消息的負載將是一個聚合的某種表示形式(例如 JSON 或 Avro)。重要的是,該服務將指定聚合的惟一標識符做爲分區鍵。這將確保對任何給定聚合實體的更改都將發佈到同一分區。

出問題的時候怎麼辦?

儘管 Kafka 的跨邊界事件發佈機制顯得至關優雅,但畢竟這是一個分佈式系統,所以系統可能會有不少錯誤。咱們將關注也許是最多見的惱人問題:消費者可能沒法成功處理其消費的消息。

圖片

咱們如今該怎麼辦?

肯定這是一個問題

團隊作錯的第一件事就是根本沒有意識到這是一個潛在的問題。消息失敗時有發生,咱們須要制定一種策略來處理它……要未雨綢繆,而非亡羊補牢。

所以,瞭解這是一種早晚會發生的問題並設計針對性的解決方案是咱們要作的第一步。若是咱們作到了這一點,就應該向本身表示一點祝賀。如今最大的問題仍然存在:咱們該如何處理這種狀況?

咱們不能一直重試那條消息嗎?

默認狀況下,若是消費者沒有成功消費一條消息(也就是說消費者沒法提交當前偏移量),它將重試同一條消息。那麼,難道咱們不能簡單地讓這種默認行爲接管一切,而後重試消息直到成功嗎?

問題是這條消息可能永遠不會成功。至少,沒有某種形式的手動干預它是不會成功的。因而乎,消費者就永遠不會繼續處理後續的任何消息,而且咱們的消息處理將陷入困境。

好吧,咱們不能簡單地跳過那條消息嗎?

咱們一般容許同步請求失敗。例如,對咱們的 UserAccount 服務所作的一個「create-user」POST 可能包含錯誤或丟失的數據。在這種狀況下,咱們能夠簡單地返回一個錯誤代碼(例如 HTTP 400),而後要求調用方重試。

雖然這種辦法並不不理想,但這不會對咱們的數據完整性形成任何長期問題。那個 POST 表明一條命令,是尚未發生的事情。即便咱們讓它失敗,咱們的數據也將保持一致狀態。

當咱們丟棄消息時狀況並不是如此。消息表示已經發生的事件。任何忽略這些事件的消費者都將與生成事件的上游服務再也不同步。

全部這些都代表,咱們不想丟棄消息。

那麼咱們如何解決這個問題呢?

對咱們來講這不是什麼容易解決的問題。所以,一旦咱們認識到它須要解決,就能夠向互聯網諮詢解決方案。但這引出了咱們的第二個問題:網上有一些咱們可能不該該遵循的建議。

重試主題:流行的解決方案

你會發現最受歡迎的一種解決方案就是重試主題(retry topics)的概念。具體細節因實現而異,但整體概念是這樣的:

  • 消費者嘗試消費主要主題中的一條消息。
  • 若是未能正確消費該消息,則消費者將消息發佈到第一個重試主題,而後提交消息的偏移量,以便繼續處理下一條消息。
  • 訂閱重試主題的是重試消費者,它包含與主消費者相同的邏輯。該消費者在消息消費嘗試之間引入了短暫的延遲。若是這個消費者也沒法消費該消息,則會將該消息發佈到另外一個重試主題,並提交該消息的偏移量。
  • 這一過程繼續,並增長了一些重試主題和重試消費者,每一個重試的延遲愈來愈多(用做退避策略)。最後,在最終重試消費者沒法處理某條消息後,該消息將發佈到一個死信隊列(Dead Letter Queue,DLQ)中,工程團隊將在該隊列中對其進行手動分類。

概念上講,重試主題模式定義了失敗的消息將被分流到的多個主題。若是主要主題的消費者消費了它沒法處理的消息,它會將該消息發佈到重試主題 1 並提交當前偏移量,從而將自身釋放給下一條消息。重試主題的消費者將是主消費者的副本,但若是它沒法處理該消息,它將發佈到一個新的重試主題。最終,若是最後一個重試消費者也沒法處理該消息,它將把該消息發佈到一個死信隊列(DLQ)。

問題出在哪裏?

看起來這種方法彷佛很合理。實際上,它在許多用例中都能正常工做。問題在於它不能充當一種通用解決方案。現實中存在一些特殊用例(例如咱們的跨邊界事件發佈),對於這些用例來講,這種方法其實是危險的。

它忽略了不一樣類型的錯誤

第一個問題是,它沒有考慮到致使事件消費失敗的兩大緣由:可恢復錯誤和不可恢復錯誤。

可恢復錯誤指的是,若是咱們屢次重試,這些錯誤最終將得以解決。一個簡單的示例是將數據保存到數據庫的消費者。若是數據庫暫時不可用,那麼當下一條消息經過時,消費者將失敗。一旦數據庫再次變得可用,消費者就可以再次處理該消息。

從另外一個角度來看:可恢復錯誤指的是那些根源在消息和消費者外部的錯誤。解決這種錯誤後,咱們的消費者將繼續前進,好像無事發生同樣。(不少人在這裏被弄糊塗了。「可恢復」一詞並不意味着應用程序自己——在咱們的示例中爲消費者——能夠恢復。相反,它指的是某些外部資源——在此示例中爲數據庫——會失敗並最終恢復。)

關於可恢復錯誤須要注意的是,它們將困擾主題中的幾乎每一條消息。回想一下,主題中的全部消息都應遵循相同的架構,並表明相同類型的數據。一樣,咱們的消費者將針對該主題的每一個事件執行相同的操做。所以,若是消息 A 因爲數據庫中斷而失敗,那麼消息 B、消息 C 等也將失敗。

不可恢復錯誤指的是不管咱們重試多少次都將失敗的錯誤。例如,消息中缺乏字段可能會致使一個 NullPointerException,或者包含特殊字符的字段可能會使消息沒法解析。

與可恢復錯誤不一樣,不可恢復錯誤一般會影響單個孤立消息。例如,若是隻有消息 A 包含不可解析的特殊字符,則消息 B 將成功,消息 C 等也將成功。

與可恢復錯誤不一樣,解決不可恢復錯誤意味着咱們必須修復消費者自己(永遠不要「修復」消息自己——它們是不可變的記錄!)例如,咱們可能會修復消費者以便正確處理空值,而後從新部署它。

那麼,這與重試主題解決方案有什麼關係?

對於初學者來講,它對可恢復錯誤不是特別有用。請記住,在解決外部問題以前,可恢復錯誤將影響每一條消息,而不只僅是當前的一條消息。所以能夠確定的是,將失敗的消息分流到重試主題將爲下一條消息清理出通道。但接下來的消息也將失敗,下一條以及再下一條也將失敗。咱們最好仍是讓消費者本身重試,直到問題解決爲止。

不可恢復的錯誤呢?重試隊列能夠在這些狀況下提供幫助。若是一條麻煩的消息阻止了全部後續消息的消費,那麼毫無疑問,分流該消息確定會爲咱們的用戶消費清除障礙(固然,多個重試主題是不必的)。

可是,雖然重試隊列能夠幫助受不可恢復錯誤困擾的消息消費者繼續前進,但它也可能帶來更多隱患。下面咱們就進一步分析背後的緣由。

它會忽略排序

咱們簡要回顧一下跨邊界事件發佈的一些重要環節。在有界上下文中處理一條命令後,咱們會將一個對應的事件發佈到一個 Kafka 主題。重要的是,咱們會將聚合的 ID 指定爲分區鍵。

爲何這很重要?它確保的是對任何給定聚合的更改都會發布到同一分區。

好吧,那這一點爲何會那麼重要呢?當事件發佈到同一分區時,能夠保證各個事件按照它們發生的順序進行處理。若是對同一聚合進行連續更改,而且所產生的事件發佈到不一樣的分區,就可能發生爭用情況,也就是消費者在消費第一個更改以前就消費了第二個更改。這會致使數據不一致。

咱們舉個簡單的例子。咱們的 User 有界上下文提供了一個容許用戶更改其名稱的應用程序。一位用戶將他的名字從 Zoey 更改成 Zoë,而後當即又更改成 Zoiee。若是咱們無論排序,則某個下游消費者(例如 Login 有界上下文)可能會先處理對 Zoiee 的更改,而後不久用 Zoë覆蓋它。

如今,登陸數據與咱們的用戶數據已經不一樣步了。更麻煩的是,每當 Zoiee 登陸咱們的網站時都會看到「歡迎光臨,Zoë!」的登陸提示。

這纔是重試主題真正出問題的地方。它們讓咱們的消費者容易打亂處理事件的順序。若是一個消費者在處理 Zoë更改時受到某個臨時的數據庫中斷的影響,它會把這個消息分流到一個重試主題,稍後再嘗試。若是在 Zoiee 更改到達時數據庫中斷已獲得糾正,則這條消息將先被成功處理,而後再由 Zoë更改覆蓋。

爲了說明問題,這裏用了 Zoiee/Zoë這樣一個簡單的示例。實際上,亂序處理事件可能致使會各類各樣的數據損壞問題。更糟糕的是,這些問題不多會在一開始就被注意到。相反,它們所致使的數據損壞每每在一段時間內都不會引發注意,但損壞程度會隨着時間的推移而增加。通常來講,當咱們意識到發生了什麼事情時,已經有大量數據受到影響了。

重試主題何時可行?

須要明確的是,重試主題並不是一直都是錯誤的模式。固然,它也存在一些合適的用例。具體來講,當消費者的工做是收集不可修改的記錄時,這種模式就很不錯。這樣的例子可能包括:

  • 處理網站活動流以生成報告的消費者
  • 將交易添加到分類帳的消費者(只要這些交易用不着按特定順序跟蹤)
  • 正在從另外一個數據源 ETL 數據的消費者

這類消費者可能會從重試主題模式中受益,同時沒有數據損壞的風險。

不過,請注意

即便存在這種用例,咱們仍應謹慎行事。構建這樣的解決方案既複雜又耗時。所以,做爲一個組織,咱們不想爲每一個新的消費者編寫一個新的解決方案。相反,咱們要建立一個統一的解決方案,好比一個庫或一個容器等,能夠在各類服務之間重複使用。

還存在另外一個問題。咱們可能會爲相關消費者構建一個重試主題的解決方案。不幸的是,不久以後,這個解決方案就會進入跨邊界事件發佈消費者的領域了。擁有這些消費者的團隊可能沒有意識到風險的存在。正如咱們前面所討論的那樣,在發生重大數據損壞以前,他們可能不會意識到任何問題。

所以,在實現重試主題解決方案以前,咱們應 100%肯定:

  • 咱們的業務中永遠不會有消費者來更新現有數據,或者
  • 咱們擁有嚴格的控制措施,以確保咱們的重試主題解決方案不會在此類消費者中實現

咱們如何改善這種模式?

鑑於重試主題模式可能不是跨邊界事件發佈消費者的可接受解決方案,咱們是否能夠對其作一些調整來改善它呢?

一開始,本文想要提供一種完整的解決方案。但以後我意識到,並不存在什麼萬能的路徑。所以,咱們將只討論一些在制定合適解決方案時須要考慮的事項。

消除錯誤類型

若是咱們可以在可恢復錯誤和不可恢復錯誤之間消除歧義,生活就會變得輕鬆許多。例如,若是咱們的消費者開始遇到可恢復錯誤,那麼重試主題就變得多餘了。

所以,咱們能夠嘗試肯定所遇到的錯誤類型:

void processMessage(KafkaMessage km) {
  try {
    Message m = km.getMessage();
    transformAndSave(m);
  } catch (Throwable t) {
    if (isRecoverable(t)) {
      // ...
    } else {
      // ...
    }
  }
}

在上面的 Java 僞代碼示例中,isRecoverable()將採用一種白名單方法來肯定 t 是否表示可恢復錯誤。換句話說,它檢查 t 以肯定它是否與任何已知的可恢復錯誤(例如 SQL 鏈接錯誤或 ReST 客戶端超時)相匹配,若是匹配則返回 true,不然返回 false。這樣就能防止咱們的消費者被不可恢復錯誤一直阻塞下去。

誠然,要在可恢復錯誤和不可恢復錯誤之間消除歧義可能很困難。例如,一個 SQLException 可能指的是一次數據庫故障(可恢復)或一次約束違反情況(不可恢復)。若有疑問,咱們可能應該假設錯誤是不可恢復的——爲此要冒的風險是將其餘好的消息發送給隱藏主題,從而延遲它們的處理……但這也能避免咱們無心間陷入泥潭,無休止地嘗試處理不可恢復錯誤。

在消費者內重試可恢復錯誤

正如咱們所討論的那樣,存在可恢復錯誤時,將消息發佈到重試主題毫無心義。咱們只會爲下一條消息的失敗掃清道路。相反,消費者能夠簡單地重試,直到條件恢復。

固然,出現可恢復錯誤意味着外部資源存在問題。咱們不斷對這塊資源發送請求是無濟於事的。所以,咱們但願對重試應用一個退避策略。咱們的僞 Java 代碼如今可能看起來像這樣:

void processMessage(KafkaMessage km) {
  try {
    Message m = km.getMessage();
    transformAndSave(m);
  } catch (Throwable t) {
    if (isRecoverable(t)) {
      doWithRetry(m, Backoff.EXPONENTIAL, this::transformAndSave);
    } else {
      // ...
    }
  }
}

(注意:咱們使用的任何退避機制都應配置爲在達到某個閾值時向咱們發出警報,並通知咱們潛在的嚴重錯誤)

遇到不可恢復錯誤時,將消息直接發送到最後一個主題

另外一方面,當咱們的消費者遇到不可恢復錯誤時,咱們可能但願當即隱藏(stash)該消息,以釋放後續消息。但在這裏使用多個重試主題會有用嗎?答案是否認的。在轉到 DLQ 以前,咱們的消息只會經歷 n 次消費失敗而已。那麼,爲何不從一開始就將消息粘貼在那裏呢?

與重試主題同樣,這個主題(在這裏,咱們將其稱爲隱藏主題)將擁有本身的消費者,其與主消費者保持一致。但就像 DLQ 同樣,這個消費者並不老是在消費消息;它只有在咱們明確須要時纔會這麼作。

考慮排序

來看看排序的狀況。咱們在這裏重用以前的「用戶/登陸」示例。嘗試處理 Zoë名稱中的ë字符時,Login 消費者可能會遇到錯誤。消費者將其識別爲一個不可恢復錯誤,將消息放在一邊,而後繼續處理後續消息。不久以後,消費者將得到 Zoiee 消息併成功處理它。

Zoë消息已隱藏,而且 Zoiee 消息如今已成功處理完畢。目前,兩個有界上下文之間的數據是一致的。

晚些時候,咱們的團隊會修復消費者,以便其能夠正確處理特殊字符並從新部署它。而後,咱們將 Zoë消息從新發布給消費者,消費者如今能夠正確處理該消息了。

當更新的消費者隨後處理隱藏的 Zoë消息後,兩個有界上下文之間的數據將變得不一致。所以,當 User 有界上下文將用戶視爲 Zoiee 時,Login 有界上下文會將她稱爲 Zoë。

顯然,咱們沒有保持排序;Zoë是在 Zoiee 以前由 Login 消費者處理的,但正確的順序是倒過來的。隱藏一條消息後,咱們能夠開始隱藏全部消息,但在那種狀況下咱們實際上會陷入困境。幸運的是,咱們不須要保持全部消息的順序,只需考慮與單個聚合相關聯的消息便可。所以,若是咱們的消費者能夠跟蹤已隱藏的特定聚合,它就能夠確保屬於同一聚合的後續消息也被隱藏。

收到隱藏主題中消息的警報後,咱們能夠取消部署消費者並修復其代碼(請注意:切勿修改消息自己;消息表明不可變的事件!)在修復並測試了咱們的消費者以後,咱們能夠從新部署它。固然,在繼續使用主要主題以前,咱們將須要特別注意先處理隱藏主題中的全部記錄。這樣,咱們將繼續保持正確的排序狀態。出於這個緣由,咱們將首先部署隱藏消費者,而且只有在其完成時(這意味着消費者組中的全部實例都完成,若是咱們使用了多個消費者),咱們纔會取消部署它並部署主消費者。

咱們還應該考慮如下事實:固定的消費者處理了隱藏消息後,它仍可能會遇到其餘錯誤。在這種狀況下,其錯誤處理行爲應像咱們以前描述的那樣:

  • 若是錯誤是可恢復的,則使用退避策略重試;
  • 若是錯誤是不可恢復的,它將隱藏消息並繼續下一條消息。

爲此,咱們能夠考慮使用第二個隱藏主題。

能夠接受一些數據不一致?

這樣的系統構建起來可能會變得至關複雜。它們可能很難構建、測試和維護。所以,某些組織可能會想要肯定出數據不一致的可能性,並判斷他們是否能夠承受這種風險。

在許多狀況下,這些組織可能會採用數據協調機制,以使他們的數據最終(是相對較長的「最終」)變得一致。爲此也存在許多策略(超出了本文的範圍)。

總結

處理重試彷佛很複雜,那是由於它就是這麼麻煩——和一切正常時 Kafka 相對優雅的風格相比之下尤爲明顯。咱們構建的任何合適的解決方案(不管是重試主題、隱藏主題仍是其餘解決方案)都將比咱們想要的更復雜。

不幸的是,若是咱們但願在微服務之間創建彈性的異步通訊流,那麼咱們就不能忽略它。

本文介紹了一種流行的解決方案、它的缺點以及在設計替代解決方案時應考慮的一些事項。到最後,想要構建正確的解決方案,咱們就應該牢記一些事情,例如:

  • 瞭解 Kafka 經過主題、分區和分區鍵提供的功能。
  • 考慮到可恢復錯誤與不可恢復錯誤之間的差別。
  • Java中間件面試真題 +學習筆記
  • 設計模式的用法,例若有界上下文和聚合。
  • 不管如今仍是未來,都要搞清楚咱們組織的用例特性。咱們只是在移動獨立的記錄嗎?……在這種狀況下,咱們可能不關心排序;仍是說咱們正在傳播表示數據更改的事件?……在這種狀況下,排序相當重要。
  • 仔細考慮咱們是否願意承受任何水平的數據不一致。
相關文章
相關標籤/搜索