Skip to main content

溝通當下的版本選擇:Kafka 如何在通訊當下挑 wire protocol 版本

· 23 min read

幾萬個節點之上的版本控制 · 第三場 | 長壽的 client、滾動升級的 broker,如何協商出共同版本

本篇專注在溝通當下到底選哪個版本:一條連線怎麼決定要講第幾版的 wire protocol、metadata.version(MV)在這裡扮演什麼角色、以及協商不出版本時會看到什麼錯誤。

整個主題拆成系列,相鄰兩場各自成篇;本篇只在需要時做前情提要、不重講:

  • 《Kafka 叢集版本定義與 KIP-1170》:release version(上限與規則)vs metadata.version(叢集當前啟用範圍)、MV 如何決定 record schema、feature gate 與啟動檢查。
  • 《運行時的版本升降》(明彥那場):kafka-features 的 finalize 驗證、feature↔MV 依賴與降級規則、fenced broker 擋升級等「升降當下」的行為。

正文聚焦「機制」;對應的 source code 片段與 file:line 收在文末 附錄 A:原始碼對照,想深入的人再翻。commit 基準 trunk b7b1c0a8


Part 1 — 版本為何對不齊,又是怎麼決定的

1. 動機:為什麼版本天生對不齊,只能連線當下協商

大多數人談「Kafka 版本」時,腦中只有一個數字(例如 3.6、4.1),並隱含一個假設:client 與 broker 同版、一起升級——「一個版本打天下」。這個假設在單機或測試環境成立,但在生產叢集會被兩個現實打破。

第一,client 很長壽。broker 由平台團隊維護、會跟著升級;但 client 是嵌在各個應用程式裡的函式庫——某個多年前的 batch job、某個沒人敢動的 legacy service,可能到今天還抱著很舊的 kafka-client 在跑。要全公司應用同一天升 client,基本不可能。這不是隨口說的:Apache Kafka 從 0.8.0(2013)到 3.x,整整九年保留了「每一個」protocol API 版本,就因為總有舊 client 還在連;直到 4.0(KIP-896)才把 baseline 提到約 2.1(2018)。

第二,broker 逐台滾動升級,過程必然新舊並存。升 broker 是一台一台來:關一台、換 binary、起來、再下一台。過程中叢集必然「新舊 broker 混在一起」,client 也同時連到新的和舊的 broker。要不停機,就不能要求全叢集同版。

因果鏈收攏成一條:任一時刻,不同節點的 binary 能力必然不同 → 要不停機就不能鎖全叢集同一版 → 版本無法事先對齊,只能在「連線當下」由雙方決定。這就是本場主題的由來。後面會介紹三種通訊角色(client↔broker、broker↔controller、broker↔broker),先說清楚:那是「版本選擇發生的地方」,不是版本對不齊的原因——對不齊的原因就是上面兩個現實。

先預告這個選擇的帳單:把版本拆到每支 API 各自一組 [min, max] 區間,協定面積、相容性測試、非 Java client 的實作負擔,全都按 API 數放大——MongoDB 與 PostgreSQL 面對同一個問題,選的是只維護一個全域協定版本號,把演進粒度讓給實作簡單。Kafka 買到的是單支 API 獨立演進、不必整包升版;這個成本後來大到需要 KIP-482 的 flexible versions 來止血——一旦某支 API 進入 flexible version,後續的 optional tagged fields 就不必再 bump 版本。評價:對一個 client 由眾多第三方各自實作、API 數十支的生態,這筆帳划算;但它只在這種生態下划算,不是通用解。

2. 術語:講「版本」時,指的是哪一層?

進入機制前先釐清術語。日常說的「版本」其實混了三種 scope 不同、變動時機也不同的「版本」:

release version        我這台裝了哪版 binary       per-node(ops 換 binary,逐台滾動)
metadata.version 叢集 finalized 的 feature level cluster-wide(admin 手動 finalize,刻意跟換 binary 脫鉤)
wire protocol API ver 這條連線實際講第幾版 per-connection(runtime 每條連線各自決定)
  • release version:這台節點裝了哪一版 binary。per-node,由維運換 binary、逐台滾動。
  • metadata.version:叢集 finalized 的 feature level。所謂 finalize,指管理員手動宣告全叢集一致採用的 feature level;怎麼宣告是第一場《版本定義》的主題,本場只需要知道它是叢集共識的一個值。cluster-wide,刻意跟換 binary 脫鉤——升了 binary 不代表 MV 就跟著升。
  • wire protocol API version:這條連線實際講第幾版。per-connection,runtime 每條連線各自決定。

這三層是三個獨立的軸,不會自動一起變:可以升了 binary(release version)卻還沒 finalize metadata.version;也可以兩個 broker binary 同版,卻因連線當下選出不同 wire protocol version 而行為不同。本場的主角是第三層——wire protocol API version。

3. 架構 (a):三種通訊角色;要協商,先送 ApiVersionsRequest

wire 版本的資訊來源是一次查詢:連線建立後,發起端先送一支 ApiVersionsRequest,查詢對方「你每支 API 支援哪個版本區間?」,對方回覆自己每支 API 的 [min, max] 範圍。這個機制由 KIP-35 引入,是後續一切版本選擇的前提。

這個查詢有代價:每條新連線多付一次 round trip——NetworkClient 會把新連線排進 ApiVersionsRequest 流程、收到回覆後才視為 ready(NetworkClient.java:1106 / :1122)。換到的是之後每支 request 都不必再猜版本:一次性成本,長期攤提。

Kafka 叢集裡有三種通訊角色(各列的 RPC 僅代表性、非窮舉)。絕大多數路徑連線後都先做這個查詢;唯一不查詢的是 partition replication(follower→leader,元件是 replica fetcher)那條流程——原因見下一節:

client ↔ broker       讀寫資料、查 metadata        Produce、Fetch、Metadata…
broker ↔ controller 註冊、心跳、轉發 admin 請求 BrokerRegistration、BrokerHeartbeat…
broker ↔ broker partition replication / txn markers Fetch、ListOffsets / WriteTxnMarkers…

注意 broker↔broker 不是鐵板一塊:partition replication(follower→leader)不查詢,但同是 broker↔broker 的 transaction markers(WriteTxnMarkers)仍照 KIP-35 協商discoverBrokerVersions=true)。所以「不查詢」修飾的是 partition replication 這條特定流程,不是整個 broker↔broker 角色。

查詢只解決「知道對方會講什麼」;拿到區間之後,最終版本怎麼定,各路徑的答案並不相同——這是下一節的主題。

4. 架構 (b):查詢之後,最終版本誰說了算?

先看各路徑各自的現象:

  • client ↔ broker:協商。 一般 producer / consumer / admin API,取雙方區間交集的最高版本(NodeApiVersions.latestUsableVersion)。MetadataVersion 的 javadoc 也寫明:「when communicating with clients, the client decides on the API version.」
  • broker ↔ controller:也是協商。 broker 對 controller 送 BrokerHeartbeatBrokerRegistration 等 request 時,扮演的是 client 角色,同樣取交集最高版,不由 MV 決定。
  • partition replication(follower→leader):不協商。 這條由 replica fetcher 走的流程,發起端不查詢對方版本(discoverBrokerVersions=false),版本改由 finalized MV 事先決定。這條連線上依序發三支不協商 RPC——OffsetsForLeaderEpoch(對齊)、ListOffsets(定位)、Fetch(抓資料);代表性的 Fetch 版本由 fetchRequestVersion(MV) 直接決定,leader 不支援即失敗,沒有退讓空間。(同是 broker↔broker 的 transaction markers 則仍協商。)

三種現象收斂到同一個機制:決定權在「組出這支 request 的程式碼」宣告的允許版本範圍。送出端建 request 時,AbstractRequest.Builder 帶一個 [oldestAllowedVersion, latestAllowedVersion];底層送出前會把它跟對方 ApiVersions 廣播的範圍取交集:

Builder 給的範圍效果
全範圍 [oldest, latest]ApiVersions 協商,挑交集最高版
pin 成 [MV, MV]MV 決定(replication 路徑不查詢、直接照該版本送;不支援即 UnsupportedVersionException
pin 成固定常數寫死,不看 MV 也無協商空間

client↔broker 與 broker↔controller 的 Builder 給全範圍,落在第一格;複製面的 Fetch 被 pin 成 [MV, MV],落在第二格。複製面另外兩支 RPC 的分工更細(ListOffsets 以 MV 為上限、OffsetsForLeaderEpoch 寫死常數),屬進階細節,完整對照表收在附錄 A3;主訊息只需要一句:複製面的 Fetch 由 MV 決定,其餘都是協商。

為什麼複製面用 MV、其餘協商?

受眾看到「複製面不協商」的第一個問題必然是:為什麼它不像其他連線一樣協商就好?直接的答案是:協商能讓 broker 讀得懂彼此送來的 request,但「讀得懂」不等於「跨版本行為正確」。 例:KIP-903Fetch v15 加入 ReplicaState(帶 broker epoch)來擋住舊 epoch 的 follower 被加進 ISR——leader 就算讀得懂舊版 Fetch(協商也降得到舊版),少了那個欄位就擋不住這件事。所以複製面要的不是「兩端能通」,而是「全叢集一致地啟用同一版行為」;finalized MV 提供的正是 operator 可控、與滾 binary 脫鉤的原子切換——一次把全叢集切到指定版本,這也是升級要分兩階段的由來。其餘連線是點對點,各自挑最好的版本即可。

再往下想一層,「為什麼這樣分」有三個理由(設計推論,但每一條都對得上已驗證的機制):

  1. Bootstrap 的雞生蛋。 finalized MV 本身存在 metadata log 裡;broker 是靠「跟 controller 抓 metadata log」才知道 MV。若抓 log 的那支 Fetch 版本要由 MV 決定,就會循環——要知道 MV 得先抓 log,要抓 log 又得先知道 MV。所以啟動期的 RPC(registration、抓 metadata log 的 Fetch)不能被 MV gate,只能用自足的 ApiVersions 協商。鐵證:KRaft 抓 metadata log 的 Fetch 走全範圍協商、不看 MV(見附錄 A4)。
  2. 控制面與資料面對「一致性」的需求不同。 broker↔controller 是點對點(broker 對現任 controller),每條連線各自挑最好版本即可,不需要全叢集一致。複製資料面則需要所有 follower↔leader 講同一版——MV 就是那個「集中、一次切換」的開關。
  3. 不拿權威發的值去 gate 通往權威的通道。 controller 是 MV 的來源;用「它發的 MV」去決定「連到它的那條 RPC」的版本,邏輯上不成立。

另補一個常見誤解的修正:這不是「broker 加入叢集前協商、加入後改用 MV」的階段切換BrokerHeartbeat 每隔幾秒送一次、貫穿 broker 整個生命週期,從頭到尾都是協商。真正的區分是永久按 RPC 角色分:控制面永遠協商、複製資料面永遠由 MV 決定。

MV 集中決定的帳單,與一個反事實

這個集中決定不是免費的,帳單有三筆:升級從一步變兩步(先滾動換 binary、再手動 finalize);多一個會被忘記的人工步驟——忘了 finalize,replication 就一直講舊版(fetchRequestVersion 的門檻表停在舊值,upgrade guide 也明確把 finalize 列為 binary 換完之後的獨立步驟);以及沒有退讓空間——leader 不支援 MV 指定的版本就直接失敗,不像協商還能降版。要說明的是,這個「兩階段」是 finalized MV 治理的通性、不是 replication 專屬:任何由 MV gate 的能力(含 metadata log 的 record 版本)都循同一條「先滾 binary、再 finalize」的節奏。

要理解這筆帳為什麼值得付,看反事實最快:PostgreSQL 的實體複製(physical / streaming replication)傳的是 raw WAL bytes,而 WAL 格式跨大版不相容,複製能力於是硬鎖在同一個 major——所以 HA replica 不能就地跨 major 滾動升級,只能 pg_upgrade(快但要停機)。要做近零停機的大版升級,得改搭 logical replication(decode WAL 成 row changes、獨立於實體格式,可跨 major)——blue/green、另建新 major 叢集再 failover,但那是外掛(bolt-on)而非複製層內建,且有 DDL / sequence 不複製等限制。Kafka 的兩階段升級再麻煩,換到的正是 PostgreSQL 實體複製給不了的那件事:對複製層本身做線上滾動升級。我認為這是全套設計裡最站得住的取捨。(MongoDB 的 featureCompatibilityVersion 與 MV 是同一套思路;三家對照見附錄 C。)

最後一句防混淆:feature(如 group.versionshare.version不決定 RPC 版本——只有 MV 對複製面的 Fetch / ListOffsets 這麼做;feature 與 RPC 版本是兩條正交的軸(詳見附錄 A5)。

5. 以 Fetch 為例:同一顆 broker 同時講兩個版本

把上一節的架構落到一支具體的 RPC。同一支 Fetch API 有兩種身分,分別走 client↔broker 與 broker↔broker 兩條選版路徑:

consumer fetch   Kafka 2.4 client(validVersions 0-11)  對 4.1 broker 取交集 → Fetch v11   ← 協商
replica fetch follower 版本 = fetchRequestVersion(MV) → v17 ← 由 finalized MV 決定

於是同一顆 4.1 broker,會同時對 Kafka 2.4 老 client 協商出 Fetch v11(其 validVersions 是 0-11,取交集的最高共同版本)、對 follower 用 finalized MV 決定的 v17——同一個 release,同時存在多個 wire 版本。一個版本號根本表達不了這件事。

順帶評價這個 dual-role 設計本身:consumer 與 replica 共用一支 Fetch,是「replica 也只是 log 的讀者」這個抽象的紅利——讀取路徑、fetch session 機制都只需維護一份。帳單則是 replica 專屬語意不斷滲進共用 schema:LogStartOffset 欄位註明「只在 follower 發出時使用」(FetchRequest.json:103);KIP-903 又為了 broker epoch 驗證在 v15 加入 ReplicaState、順勢廢棄扁平的 ReplicaId。抽象紅利先收,語意租金慢慢付。

小測驗 1:replica fetch(broker↔broker)的 Fetch 版本怎麼決定?(答案見文末 附錄 B


Part 2 — 失敗會有什麼訊息

本場只講「通訊當下協商不出版本」的錯誤。finalize / 升降當下的錯誤——kafka-features upgradeINVALID_UPDATE_VERSION、fenced broker 擋住 feature/MV 升級、feature↔MV 依賴與降級規則——屬同系列《運行時的版本升降》那場,本場不展開,只在此點一句。

6. 沒有版本交集:client 端本地中止

client 不能直接用自己支援的最新版 API version,因為 broker 不一定支援。規則是取交集的最高版:

chosen version = max(intersection(client allowed range, broker supported range))

當交集為空,NodeApiVersions.latestUsableVersion(...) 丟出 UnsupportedVersionExceptionNetworkClient 接到後跳過 socket、不送出,把 request 丟進 abortedSends,最後 producer / consumer / admin 各自在收到 response.versionMismatch() 時把錯誤交回應用層。關鍵:這類 UnsupportedVersionException 很多時候不是 broker 回來的 response,而是 client 在送出前就發現沒有可用 protocol version、本地 abort。

client allows Produce 0-13 , broker supports 0-10  -> chosen 10
client allows Produce 11-13, broker supports 0-10 -> UnsupportedVersionException(送出前中止)

7. 繞過協商、直接送出不支援的版本:broker 關閉連線

若繞過協商、直接送出一個 broker 不支援的 API version,失敗路徑是固定的一條:broker 端 RequestContext 解析 request 失敗 → 丟出 UnsupportedVersionExceptionSocketServer 直接關閉連線(RequestContext.java:112SocketServer.scala:781)。

為什麼關閉連線、而不是回一個錯誤 response?broker 其實讀得到 header(知道 apiKey 與版本),但一般 response 的序列化必須依 client 指定的 request version 進行——既然那個版本本身不受支援,broker 無法保證組出來的回應能被對方正確解讀。關閉連線是唯一可靠的動作。

唯一的例外是 ApiVersions 本身:它是 bootstrap 的逃生口——即使 client 送的 ApiVersions 版本超出 broker 支援範圍,broker 也不會關線,而是回一個 v0 的 response 帶 UNSUPPORTED_VERSION 錯誤碼與自己支援的版本範圍,讓 client 得以 recover、重新協商。

小測驗 2:client 繞過協商、直接送出 broker 不支援的版本會怎樣?(答案見文末 附錄 B

8. 版本截斷:為什麼「升一點點」不夠

第 6 節「交集為空」最容易被忽略的根因是——舊的 wire protocol API version 會被整個移除。每個 API 都有自己的 validVersions 範圍,而這個範圍不保證永遠從 0 開始。

Kafka 4.0 就移除了一批舊 wire API 版本,例如 FetchRequest.jsonvalidVersions 已是 "4-18"(Fetch v0–v3 移除,min 升到 4)。因此若 client 太舊、只會講已被截斷的版本,就會落到交集外——協商結果直接是 no usable version。這時「再升一點點」沒用,得跨過 upgrade guide 的版本下限(升任一端到 4.0 前,另一端要 ≥ 2.1,雙向要求)。

值得停一秒看這個決策的形狀。從 0.8.0(2013)到 3.x,Kafka 保留了每一個 protocol API 版本整整九年——「相容性至上」推到極端的取捨:好處是任何老 client 永遠連得上;代價是每個舊版本都是活的程式碼路徑與測試矩陣,broker 永遠不能假設 client 具備任何新能力。4.0 用 KIP-896 把 baseline 收到 2.1(2018),本質是一次帳務結算:用「斷掉 2018 年以前的 client」換「刪碼、縮測試面、讓協定假設前進七年」。為什麼等這麼久?因為斷 client 是不可逆的破壞性變更,只有 major 版本邊界的社會契約付得起。我的評價:收得對,甚至偏晚——九年的窗說明這個專案在相容性上保守到近乎自虐,而這份保守正是理解 Kafka 一切版本設計的鑰匙。

Recap

本場的因果鏈只有一條:client 長壽、broker 滾動升級 → 版本天生對不齊 → 只能在連線當下決定每條連線講第幾版——client↔broker 與 broker↔controller 靠 ApiVersionsRequest 查詢後取交集協商,partition replication 的 Fetch 由 finalized MV 集中決定。「一個版本打天下」不成立的最好證據,就是那顆 4.1 broker:同一支 Fetch,同一時刻,對 Kafka 2.4 老 client 講 v11、對 follower 講 v17。而當版本選不出來:交集為空時 client 在送出前本地中止;繞過協商直接送出不支援的版本,broker 解析失敗、關閉連線。至於 finalize / 升降當下會出什麼錯,交給同系列《運行時的版本升降》。

最後一句立場:per-API 細粒度協商+replication 集中治理,這套組合是為「client 生態極度分散、API 數十支」的系統量身打造的取捨——對 Kafka 划算,但不是通用解。MongoDB 選了較粗的全域粒度、PostgreSQL 乾脆放棄複製層的版本治理,各自都對得上自家的生態;讀懂一個系統的版本設計,就是讀懂它對自己使用者的假設。


附錄 A:原始碼對照

正文把機制講完,這裡放對應的 source 片段與 file:line。commit 基準 trunk b7b1c0a8

A1 — Builder 版本範圍與協商

  • AbstractRequest.Builder[oldestAllowedVersion, latestAllowedVersion]NetworkClient 送出前取交集。
  • 四條路徑的發起端共用同一顆 org.apache.kafka.clients.NetworkClient、各帶一份 ApiVersions cache——是否先送 ApiVersionsRequest 由建構參數 discoverBrokerVersions 決定:client、controller、KRaft 路徑為 true,replica fetcher 為 false(實作細節,正文不展開)。
  • client↔broker 取交集最高版:NodeApiVersions.latestUsableVersion(...)clients/src/main/java/org/apache/kafka/clients/NodeApiVersions.java:149)。
  • Doc:server-common/src/main/java/org/apache/kafka/server/common/MetadataVersion.java:31「when communicating with clients, the client decides on the API version.」

A2 — broker ↔ controller(broker 當 client)

  • BrokerLifecycleManager.java:580channelManager.sendRequest(new BrokerHeartbeatRequest.Builder(data), handler)
  • BrokerHeartbeatRequest.Builder 只有 super(ApiKeys.BROKER_HEARTBEAT)、未 pin 版本 → 全範圍協商。
  • 底層:NodeToControllerChannelManagerImpl.java:67private final ApiVersions apiVersions)、:115 / :129NetworkClientapiVersions)。
  • 走這條管線的 request:BrokerHeartbeatBrokerRegistrationControllerRegistrationAssignReplicasToDirs

A3 — broker ↔ broker(複製面)三層分工

正文只講「複製面的 Fetch 由 MV 決定,其餘都是協商」;完整版是三支 RPC 三種作法(core/src/main/scala/kafka/server/RemoteLeaderEndPoint.scala)。先補一個名詞:ListOffsets = 把時間戳/哨兵(earliest / latest / by-timestamp)換算成一個 offset(consumer 的 seekToBeginningoffsetsForTimes 靠它,follower 則用來找截斷點)。

RPC版本怎麼訂證據
FetchMV exact pin[v, v]RemoteLeaderEndPoint.scala:215FetchRequest.java:170-172
ListOffsetsMV 當上限再協商([oldest, MV]RemoteLeaderEndPoint.scala:122ListOffsetsRequest.java:88-90
OffsetsForLeaderEpoch寫死常數 v4,非 MV、非協商OffsetsForLeaderEpochRequest.java:60-65Builder.forFollower(...)new Builder((short)4, (short)4, data)
  • 底層 BrokerBlockingSender.scala:82 仍是 NetworkClient,但 :95discoverBrokerVersions=false——不送 ApiVersionsRequest,直接照 MV 決定的版本送。
  • MetadataVersion 裡跟 RPC 版本有關的方法只有兩個:fetchRequestVersion():273)與 listOffsetRequestVersion():289),只作用在複製面的 Fetch / ListOffsets
  • 一句解讀:同一條複製路徑上三支 RPC 三種選版法,並非設計失誤。OffsetsForLeaderEpoch 的 v4 固定值是移除舊 MetadataVersion 之後留下的簡化(KAFKA-18465 清理的結果),程式碼並明示:未來若要加新版本,須改用 metadata.version gate(OffsetsForLeaderEpochRequest.java:64 的註解)。

MetadataVersion.fetchRequestVersion()server-common/src/main/java/org/apache/kafka/server/common/MetadataVersion.java:273):

public short fetchRequestVersion() {
if (isAtLeast(IBP_4_1_IV1)) {
return 18;
} else if (isAtLeast(IBP_3_9_IV0)) {
return 17;
} else if (isAtLeast(IBP_3_7_IV4)) {
return 16;
} else if (isAtLeast(IBP_3_5_IV1)) {
return 15;
} else if (isAtLeast(IBP_3_5_IV0)) {
return 14;
} else {
return 13;
}
}

A4 — KRaft metadata-log Fetch:協商 + 獨立 feature(進階)

正文第 4 節「雞生蛋」的鐵證在此。broker 以 observer、controller quorum 彼此之間抓 metadata log 的 Fetch,走協商、不看 MV。

  • KafkaRaftClient.buildFetchRequest():2985)→ RaftUtil.singletonFetchRequest(...)KafkaNetworkChannel.buildRequest:192)包成 FetchRequest.SimpleBuilder
  • SimpleBuilder = super(ApiKeys.FETCH) 全範圍(FetchRequest.java:133)→ ApiVersions 協商,不是 MV。
  • 這條路徑的能力由獨立於 MV 的 kraft.version feature 治理(KRaftVersion.java);KafkaRaftClient.java:185localSupportedKRaftVersion: SupportedVersionRange 是各節點自報的支援範圍。

A5 — feature 與 RPC 正交、honor(進階)

正文只留了一句「feature 不決定 RPC 版本」;完整版如下。這段與《版本定義》場的 feature gate 相鄰,放附錄避免搶正文主線。

RPC 版本與 feature 是兩條正交的軸,要用一個功能得兩個都滿足

  • RPC 支援(wire 能力):兩端「能不能講」這支 RPC / 這個版本——由 binary 能力 + ApiVersions 協商決定(複製面由 MV)。
  • feature(叢集政策):叢集「准不准用」這個功能——由 metadata log 裡 finalized 的 feature level 決定。

要點:

  • feature 不決定 RPC 版本。只有 MV 對複製面的 Fetch / ListOffsets 這麼做;feature 產出的是布林能力或 record 格式版本。
  • feature 真正 gate 的是 broker 要不要 honor 一支 RPC。 honor 指 broker 收到請求後「認這筆請求、照該功能的語意去處理」,而不是拒絕、回錯或忽略。RPC 在 binary 裡一直存在、也能協商成功送達,但 broker 會查 finalized feature 決定是否 honor:group.version(KIP-848)未開時,handleConsumerGroupHeartbeat 直接 fail(KafkaApis.scala:2642-2650);share.version 未開時不受理 ShareFetch,toggle off 時清掉 share session(SharePartitionManager.java:633KafkaApis.scala:4290)。
  • 對外 feature 需要 RPC 承載:新功能通常帶新 RPC(如 ConsumerGroupHeartbeatShareFetch / ShareAcknowledge)或現有 RPC 的更高版本。純內部 feature 不需要對外 RPC(只改 record 格式或內部行為)。
  • 帶版本的 feature 反向用 RPC 版本推能力transactionVersionForAddPartitionsToTxn(request) 看 request 版本 > 3 → client 支援 TV2(TransactionVersion.java:67)。方向與「feature → RPC 版本」相反。

A6 — 失敗路徑

client 端取交集(clients/src/main/java/org/apache/kafka/clients/NodeApiVersions.java:149):

Optional<ApiVersion> intersectVersion = ApiVersionsResponse.intersect(supportedVersion,
new ApiVersion()
.setApiKey(apiKey.id)
.setMinVersion(oldestAllowedVersion)
.setMaxVersion(latestAllowedVersion));

if (intersectVersion.isPresent())
return intersectVersion.get().maxVersion();
else
throw new UnsupportedVersionException(...);

NetworkClient.doSend(...) 接到 UnsupportedVersionException 後跳過 socket、丟進 abortedSendsNetworkClient.java:591 呼叫、:597 catch);NetworkClient.poll(...) drains aborted sends(:651 / :940)。Producer / Consumer 收到 response.versionMismatch()Sender.java:595ConsumerNetworkClient.java:614)。

broker 端收到不支援版本的路徑:RequestContext 解析 request 失敗、丟 UnsupportedVersionExceptionclients/src/main/java/org/apache/kafka/common/requests/RequestContext.java:112)→ SocketServer 關閉連線(core/src/main/scala/kafka/network/SocketServer.scala:781)。

broker 組 ApiVersionsResponse 時放進每個 API 的 min/max(clients/src/main/java/org/apache/kafka/common/protocol/ApiKeys.java:287);ApiVersions 對外仍宣告 v0 的特例讓它成為 bootstrap 逃生口。版本截斷的 schema 證據:FetchRequest.json:61 = "4-18"ListOffsetsRequest.json:45 = "1-11"ProduceRequest.json = "3-13"。upgrade guide 對 4.0 截斷的說明:docs/getting-started/upgrade.md:229


附錄 B:常見誤解 / 隨堂考

兩題對應兩段主線,每題附「直覺答案(多半錯)」與正解,可當現場有獎徵答。

Q1:replica fetch(broker↔broker)的 Fetch 版本怎麼決定?

  • 直覺:兩個 broker 用 ApiVersions 協商取交集。
  • 正解:不協商。由 finalized metadata.version 決定(fetchRequestVersion(MV),建成 [v, v] exact pin);所有 broker 從同一個 MV 推出同一版。
  • 出處RemoteLeaderEndPoint.scala:215MetadataVersion.java:273

Q2:client 繞過協商、直接送出 broker 不支援的 API version,會怎樣?

  • 直覺:broker 一律回一個 UNSUPPORTED_VERSION 錯誤碼。
  • 正解:一般 API → RequestContext 解析失敗、broker 丟 UnsupportedVersionExceptionSocketServer 關閉連線。唯一例外是 ApiVersions:它是 bootstrap 逃生口,會回 v0 response 帶 UNSUPPORTED_VERSION 錯誤碼 + 支援範圍讓 client recover。
  • 出處RequestContext.java:112SocketServer.scala:781ApiKeys.javaApiVersions 特例)

附錄 C:別家怎麼答同一題

完整對照(含來源 URL)的來源 URL 見各家官方文件;這裡只留骨架。

client↔server 版本節點間/複製層版本治理
Kafkaper-API [min,max] 協商(粒度最細、協定面積最大)finalized metadata.version(手動 finalize、跟 binary 脫鉤)
MongoDB全域 wire version(hello 交換)featureCompatibilityVersion——與 MV 同一套思路
PostgreSQL全域協定版本(v3 逾二十年未變)——實體複製鎖 major(WAL 跨大版不相容),HA replica 不能就地跨 major 滾;跨大版近零停機得改搭 logical replication(bolt-on)

兩個讀法:MongoDB 的 FCV 證明「叢集能力世代跟 binary 脫鉤、手動 finalize」不是 Kafka 的怪癖,而是分散式系統滾動升級的共同答案;PostgreSQL 則是反事實——複製層本身不做版本治理,實體複製鎖 major,跨大版的近零停機升級只能靠外掛的 logical replication,而非複製層內建。

Kafka 4.2.0 KIP-1034:內建 DLQ,終結手動錯誤處理

· 16 min read

本篇搭配的範例程式已一併放進這個 repo:examples/kafka/kip-1034-dlq-blog-post/

before/ 對應 Kafka 4.2.0 以前常見的手動 DLQ 作法,after/ 對應 Kafka 4.2.0 / KIP-1034 的內建 DLQ 作法。

Kafka Streams 應用在處理資料流時,經常需要面對不合法或無法反序列化的 record。遇到這類資料時,系統可以選擇 fail,讓應用停止;也可以選擇 continue,略過該筆資料。實務上通常還有第三種需求:保留錯誤資料,寫入另一個 topic,供後續修補、重送、追查或告警使用。這正是 DLQ(Dead Letter Queue)的用途。

問題在於,Kafka Streams 4.2.0 以前並未提供完整的內建 DLQ 寫入路徑。應用程式可以自行補上這項能力,但必須同時處理 producer lifecycle、error metadata、錯誤發生位置,以及 transaction 邊界;若啟用 exactly_once_v2,這些限制會更加明顯。

KIP-1034 補上的正是這段缺口。Kafka Streams 的內建 exception handlers 現在可以把 DLQ record 回交給框架,由框架透過既有寫入流程送出,使 DLQ 寫入得以回到 Kafka Streams 的 transactional write flow。以下以 repo 內的範例對照舊作法與新作法。

先界定問題

範例使用一個 click-events topic,內容為 JSON 字串:

{"ad_id":"banner-A","count":3}

Streams topology 會把它讀進來、deserialize 成 ClickEvent,接著做一點簡單轉換,最後寫到 click-events-output

整體流程可以想成:

click-events -> deserialize -> process -> click-events-output

若資料格式正確,處理流程相當單純。但只要出現 NOT_VALID_JSON 這類資料,Kafka Streams 在進入 processor 前就可能失敗,因為 record 必須先由 bytes 轉為應用程式期待的物件;一旦反序列化失敗,後續 processor 尚未開始執行。

因此,在 Kafka Streams 中討論 DLQ,不能只理解為「catch exception 後寫入另一個 topic」。錯誤可能發生在 topology 之內,也可能發生在 topology 之前。

case A: processing error

click-events -> deserialize -> process X -> output
|
+-> still inside topology


case B: deserialization error

click-events -> deserialize X -> process -> output
|
+-> processor 還沒開始

Kafka 4.2.0 以前:兩種 exception,兩套處理方式

先看 Kafka 4.2.0 以前常見的手動 DLQ 作法。主要負擔在於,應用程式不是只需處理單一錯誤型態,而是必須分別處理兩種不同型態的 exception。repo 裡的 before/ 將這兩種情況整理成可重現的範例,方便對照各自限制。

Processing error:發生在 topology 裡

若錯誤出現在 topology 內部,應用程式仍有機會在 DSL 轉換或 processor 中處理。這裡示範的做法,是先讓資料正常完成 deserialization,接著在 flatMap 這類 DSL 轉換中執行 business rule,並於必要時自行 try/catch

為什麼這裡用 flatMap

這個 before 範例要表達的是手動 DLQ 的處理方式:資料先正常完成 deserialization,成功資料繼續往下送;若後續 business rule 失敗,資料改送 DLQ,主流程不再產生 output。對這種「成功 1 筆、失敗 0 筆」的 flow 而言,flatMapmapValues 更自然。

也可對照後續 after 範例。KIP-1034 之後,DLQ 交回 Kafka Streams 內部處理,主流程只剩正常資料轉換,因此 ClickEventTopology.java 可直接使用 mapValues。若在這個 before 範例中直接拋出 exception,當然也可執行;但那就不是此處要示範的手動攔截與分流路徑。

這段是 ClickEventManualDlqTopology.java 的核心:

stream
.flatMap((key, event) -> {
try {
if (event.count < 0) {
throw new IllegalArgumentException("count must be non-negative");
}
String processed = "user=" + key + " clicked ad=" + event.adId + " count=" + event.count;
return Collections.singletonList(KeyValue.pair(key, processed));
} catch (Exception e) {
sendToDlq(key, event, e);
return Collections.emptyList();
}
})
.to(outputTopic);

這段 code 的重點不在 flatMap,而在 catch 裡的 sendToDlq()。這裡的錯誤已經不是 JSON parse 失敗,而是 record 成功進入 topology 後,因 business rule 不合法而被手動導向 DLQ。舊版最常見的做法,是準備一個獨立的 KafkaProducer,遇到這類錯誤就直接寫入 DLQ。

這個方式能夠運作,也可以一併補上 error metadata:

ProducerRecord<String, String> dlqRecord = new ProducerRecord<>(dlqTopic, key, value);
dlqRecord.headers().add("error.message", cause.getMessage() != null
? cause.getMessage().getBytes() : "null".getBytes());
dlqRecord.headers().add("error.class", cause.getClass().getName().getBytes());
dlqProducer.send(dlqRecord).get();

限制在於,這個 dlqProducer 與 Kafka Streams 內部 producer 並非同一個 instance。應用程式不只要另行維護 producer,也無法把這條 DLQ 寫入納入 Kafka Streams 的同一個 transaction,因此 DLQ 寫入與主流程無法共同達成 EOS。

Deserialization error:發生在 topology 之前

deserialization error 的限制更明確。

這類錯誤不是發生在 mapflatMaptransform 等 topology 步驟內,而是在 record 被 consumer 取回後、真正進入 topology 前就已發生。換言之,processor 尚未接手該筆資料,deserialization 已經失敗。

在這種情況下,topology 內的分流手段都無法介入。flatMapsplit()branch() 均無法觸及該筆資料;可用的處理入口只剩 DeserializationExceptionHandler

Kafka Streams 原始碼中的對應位置

RecordQueue.addRawRecords() 先把 raw records 放進 queue,接著 updateHead() 會呼叫 recordDeserializer.deserialize(processorContext, raw);之後 StreamTask.process() 才從 partitionGroup.nextRecord(...) 取出 record,交給 doProcess() 傳進 source node。也就是說,deserialization 確實發生在 record 進入 topology 之前。

參考:

ManualDlqHandler.java 示範的就是這條路:

@Override
public DeserializationHandlerResponse handle(
ErrorHandlerContext context,
ConsumerRecord<byte[], byte[]> record,
Exception exception) {

sendToDlq(record, exception);
return DeserializationHandlerResponse.CONTINUE;
}

此處的限制相當直接:deserialization error 發生時,record 尚未進入 topology,因此不能用 topology 內的 routing 手段處理。context.forward() 無法使用,branch()split()flatMap 等 DSL 轉換也無法觸及該筆資料。若要寫入 DLQ,舊版常見作法仍是自行建立獨立的 KafkaProducer

除了自行送出之外,應用程式也必須自行複製原始 headers,並補上 topic / partition / offset / exception 等 metadata:

record.headers().forEach(h -> dlqRecord.headers().add(h));
dlqRecord.headers().add("__manual.error.topic", record.topic().getBytes());
dlqRecord.headers().add("__manual.error.partition",
String.valueOf(record.partition()).getBytes());
dlqRecord.headers().add("__manual.error.offset",
String.valueOf(record.offset()).getBytes());

這正是舊版作法的主要負擔:不同錯誤類型必須掛在不同處理位置,處理方式也不一致。

綜合 before/ 的兩條路徑,application 層必須自行承擔下列責任:

  • 判斷錯誤應於哪一層攔截;processing error 與 deserialization error 並非同一套寫法。
  • 另行維護 KafkaProducer,包含 lifecycle、配置與送出失敗時的處理策略。
  • 自行定義 headers 命名與需要攜帶的 metadata。
  • 在 EOS 開啟時承擔 DLQ 可能於 retry 過程中重複寫入的風險。
  • 測試往往必須圍繞 workaround 撰寫,而不是直接驗證框架行為。

舊版的問題不只是程式碼較多,而是錯誤處理、資料一致性與 observability 的責任都被推回 application 層。

真正的痛點在 tx-safe

即使接受「只能自行寫入 DLQ」這個前提,transaction 邊界仍然沒有解決。

Kafka Streams 在 EOS 模式下會自行管理 transaction。簡化後的處理流程如下:

BEGIN TX
-> consume record
-> deserialize
-> process
-> send output via RecordCollector
-> sendOffsetsToTransaction
COMMIT TX

若在此流程中另以獨立 KafkaProducer 寫入 DLQ,問題相當直接:dlqProducer 與 Kafka Streams 內部用於送出 output 的 producer 不是同一個 producer instance。既然不是同一個 producer,就無法共享同一個 Kafka transaction。

用圖看會更清楚:

Kafka Streams internal producer
-> output records
-> transaction A

manual dlqProducer
-> DLQ records
-> not part of transaction A

也就是說,手動送出的 DLQ record 不會落在 Kafka Streams 那條 transactional write path 裡。

可能發生的情境如下:

  1. 手動 DLQ producer 已經將錯誤資料送出。
  2. Kafka Streams 內部 transaction 隨後因 rebalance、crash 或其他原因 abort。
  3. Kafka Streams retry 後重新處理同一筆資料。
  4. DLQ record 再次被寫入。
caution

獨立 producer 送出的 record 不在 Kafka Streams 的 transaction 之內;abort 或 retry 不會使該筆 DLQ 寫入隨之 rollback,因此 DLQ 可能被重複寫入。這是舊版作法的根本限制:框架沒有提供正式且可納入 transaction 的 DLQ 寫入路徑。

Kafka 4.2.0 / KIP-1034:框架終於把這條路補起來

到了 Kafka 4.2.0,KIP-1034 將這件事正式納入 Kafka Streams 的 error handling flow。

核心方向是:exception handler 可以把要寫入 DLQ 的 records 回交給框架,由 Kafka Streams 透過內部 producer 送出。這項能力加在 Kafka Streams 內建的 deserialization / processing / production exception handling 流程上;本文的 after/ 範例同時示範 deserialization error 與 processing error。

這個改變的關鍵在於,只要 DLQ record 由框架送出,就能沿用 Kafka Streams 既有的 producer 與 transaction,而不必在 application 層另行建立獨立 producer。

KIP-1034 之後,topology 本身可以維持單純:

builder
.stream(inputTopic, Consumed.with(Serdes.String(), new ClickEventSerde()))
.mapValues(event -> {
if (event.count < 0) {
throw new IllegalArgumentException("count must be non-negative");
}
return "user clicked ad=" + event.adId + " count=" + event.count;
})
.to(outputTopic);

這段 ClickEventTopology.java 仍不包含任何 DLQ 相關 code;沒有手動 try/catch、沒有另行建立 producer,也沒有自行補 headers。count < 0 只是 business validation;該 exception 如何寫入 DLQ,仍由 Kafka Streams 的 processing exception handler 負責。

真正啟用 DLQ 的設定位於 App.java。DLQ topic 由下列 config 指定:

props.put(StreamsConfig.ERRORS_DEAD_LETTER_QUEUE_TOPIC_NAME_CONFIG, DLQ_TOPIC);

再搭配內建 deserialization / processing handlers:

props.put(StreamsConfig.DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
LogAndContinueExceptionHandler.class);
props.put(StreamsConfig.PROCESSING_EXCEPTION_HANDLER_CLASS_CONFIG,
LogAndContinueProcessingExceptionHandler.class);

這裡也是 KIP-1034 最核心的 API 變化。舊版 exception handler 的回傳值本質上只是在回答「繼續」或「失敗」;4.2.0 之後,handler 的新 Response 可以額外攜帶「需要由框架送出的 DLQ records」。也因為 handler 現在可以把 DLQ records 回交給 Kafka Streams,框架才得以透過內部 StreamsProducer / RecordCollector 送出,而不是把 DLQ 寫入責任留在 application 層。

本文範例中的 malformed JSON 會觸發 deserialization error,因此 LogAndContinueExceptionHandler 這條路徑的效果如下:

  1. ClickEventSerde 反序列化失敗時,會拋 exception。
  2. LogAndContinueExceptionHandler 會接手。
  3. 4.2.0 的 handler 可以建立 DLQ record,並交還給 Kafka Streams。
  4. Kafka Streams 透過 RecordCollectorImpl 用同一個 StreamsProducer 把 record 送出去。
  5. 因為走的是同一個 producer,DLQ 寫入也落在同一個 transaction 邊界內。

本文範例中的 count < 0 則會觸發 topology 內部的 processing error,由 LogAndContinueProcessingExceptionHandler 接手。Kafka Streams 4.2.0 的 processing.exception.handler 預設值是 LogAndFailProcessingExceptionHandler;即使設定了 errors.dead.letter.queue.topic.name,預設 handler 仍會回傳 fail。因此,若 processing error 也要「寫入 DLQ 後繼續」,必須明確設定 LogAndContinueProcessingExceptionHandler

補充一點:ERRORS_DEAD_LETTER_QUEUE_TOPIC_NAME_CONFIG 之所以有效,是因為內建 exception handlers 會讀取該 config,並透過 Kafka Streams 內部工具建立 DLQ record;它不是框架對所有 handler 強制套用的行為。多數情境下,內建 handler 已足以涵蓋需求;若需要自訂 DLQ record 內容,仍可改用 custom handler。

但 custom handler 不必回到舊版的手動 producer 寫法。KIP-1034 之後,exception handler 介面本身已改變:舊版 handle() 只能回傳 CONTINUE 或 FAIL;新版 handleError() 回傳 Response,其中可以攜帶 ProducerRecord 列表,由 Kafka Streams 透過同一個內部 producer 送出。

// Kafka 4.2.0 以前:若要寫入 DLQ,通常只能自行建立 producer
@Override
public DeserializationHandlerResponse handle(
ErrorHandlerContext context,
ConsumerRecord<byte[], byte[]> record,
Exception exception) {
ProducerRecord<byte[], byte[]> dlqRecord =
new ProducerRecord<>("app-dlq", record.key(), record.value());
dlqProducer.send(dlqRecord).get(); // 獨立 producer,不在 Streams tx 裡
return DeserializationHandlerResponse.CONTINUE;
}

// Kafka 4.2.0:把 DLQ record 回交給框架,走同一條 transaction 路徑
@Override
public DeserializationExceptionHandler.Response handleError(
ErrorHandlerContext context,
ConsumerRecord<byte[], byte[]> record,
Exception exception) {
ProducerRecord<byte[], byte[]> dlqRecord =
new ProducerRecord<>("app-dlq", record.key(), record.value());
return DeserializationExceptionHandler.Response.resume(List.of(dlqRecord));
}

介面差異就在於:handleError() 允許 handler 把 DLQ records 回交給框架送出,不需要在 application 層另行建立 producer。

這是 KIP-1034 最重要的差別。它不只是省去自行建立 producer 的負擔,也讓 DLQ 寫入重新納入 Kafka Streams 的一致性模型。

換言之,本文所說的「透過 config 啟用 DLQ」成立的前提,是使用內建 handler;若改用 custom deserialization / processing / production handler,errors.dead.letter.queue.topic.name 不會自動替該 handler 建立 DLQ record,handler 必須自行決定是否建立 records。不過,custom handler 仍可透過 Response.resume(...) 把 records 回交給 Kafka Streams,因此依然可以走內建寫入路徑,而不需要自行建立 producer。

若拆開 after 帶來的差異,可以整理為下列幾點:

  • 不必另行建立 DLQ producer;Kafka Streams 內部會負責送出。
  • 不必把 DLQ 邏輯放入 topology;主流程可以維持單純。
  • 不必自行補齊常見 error headers;exception / topic / partition / offset 等 metadata 由框架建立。
  • deserialization error 不再需要 application 層 workaround;框架已提供正式處理路徑。
  • processing error 也可以透過內建 handler 回交 DLQ records,不必在 topology 中手動建立 producer。
  • KIP-1034 的能力也延伸到 production exception handler;ProductionExceptionHandler 處理的是 Kafka Streams 送出到下游時的寫入錯誤。4.2.0 以前,handle() 只能回傳 CONTINUE 或 FAIL,沒有 DLQ records;4.2.0 之後,handleError() 回傳 Response,可以攜帶 DLQ records,也具備 RETRY 選項。
  • DLQ 與 exactly_once_v2 可以放在同一個 transaction 模型中理解。

整體而言,設定更集中,topology 更單純,error metadata 由框架補齊,測試也能直接驗證框架行為。更重要的是,DLQ 寫入回到 transaction 邊界內,能與 EOS 模型一致。

note

KIP-1034 只定義了「如何送出 DLQ record」以及「預設應攜帶哪些 headers」,但 DLQ topic 本身不會由 Kafka Streams 自動建立。

如果 broker 開啟 auto.create.topics.enable=true,topic 可以由 broker 的 auto-create 機制建立。但 production 環境通常不應依賴此行為:許多 cluster 會直接關閉 auto-create;即使開啟,topic 也會套用 broker 預設的 partitions、replication factor、retention、cleanup policy,未必符合 input / output / DLQ topic 的需求。

因此,repo 裡的 after/src/main/java/io/example/App.java 會先建立 click-events-dlq topic,而不是依賴 broker auto-create。

after 的資料流則比較像這樣:

                +-------------------+
| click-events |
+-------------------+
|
v
+-------------------+
| deserialize |
+-------------------+
| |
success| |error
v v
+-------------+ +------------------------------+
| process | | LogAndContinueExceptionHandler|
+-------------+ +------------------------------+
| |
v v
+-------------------+ +-------------------+
| click-events-out | | click-events-dlq |
+-------------------+ +-------------------+

both writes go through Kafka Streams internals

如果只看 transaction 邊界,after 的差異會更明顯:

+---------------- Kafka Streams transaction ----------------+
| consume -> deserialize -> process |
| | |
| +-> DLQ record via Kafka Streams |
| +-> output record via Kafka Streams |
| |
| both use the same StreamsProducer / RecordCollector |
+--------------------- commit / abort ---------------------+

不只 tx-safe,連 error headers 也一起內建

另一項實用的改變,是 error metadata 不必再由 application 層自行補齊。

在舊版手動作法中,header 名稱、內容格式,以及是否攜帶原始 topic / partition / offset,都必須由應用程式自行決定。不同團隊可能各自定義一套格式,時間一久也容易分歧。

KIP-1034 之後,框架會自動附上這些 __streams.errors.* headers:

  • __streams.errors.exception
  • __streams.errors.message
  • __streams.errors.stacktrace
  • __streams.errors.topic
  • __streams.errors.partition
  • __streams.errors.offset

這個細節之所以重要,是因為 DLQ 並不只是承接錯誤資料。實務上,後續往往還牽涉到原因追查、告警、資料回補與 replay。若這些 metadata 能由框架穩定補齊,後續處理會更一致,也更容易被測試覆蓋。

總結

項目Kafka 4.2.0 以前的手動作法Kafka 4.2.0 / KIP-1034
Processing error可以自行攔截,但通常必須自行導向 DLQ可改走 4.2.0 新增的 processing exception handler 路徑
Deserialization error只能掛 DeserializationExceptionHandler,且通常必須自行送出可透過內建 DLQ flow 處理
DLQ 寫入方式常見作法是獨立 KafkaProducer,責任在 application 層由 Kafka Streams 內部送出
Tx-safe容易在 transaction 外送出,retry 時可能重複寫入使用同一個 StreamsProducer,可納入同一個 transaction
Error headers必須自行補齊、命名與維護框架附上 __streams.errors.*
程式碼量topology、handler、headers、producer lifecycle 都要自行處理搭配內建 handler 時,主流程通常只需配置即可啟用

Kafka 4.2.0 以前的 DLQ 較像 application 層自行補出的機制;KIP-1034 之後,DLQ 才正式進入 Kafka Streams 框架,並能與 transaction 模型一併運作。

用測試看行為差異,比講概念更準

許多細節放在測試中觀察會更直接,因為 input、output、DLQ record 與 headers 都能在同一處驗證。若要對照本文提到的 routing 與 header 行為,可直接閱讀 before/after/ 的測試。

若要自行執行範例,專案目錄如下:

cd examples/kafka/kip-1034-dlq-blog-post
./gradlew test

何時應評估升級到 4.2.0?

若系統具備下列需求,KIP-1034 的價值會相當明確:

  • deserialization error 需要穩定寫入 DLQ。
  • 不希望在 topology 外另行維護 producer。
  • 已啟用 exactly_once_v2,且不希望承擔 transaction 外寫入導致的重複寫入風險。
  • 需要一致的 error headers,以支援後續告警、查錯與回補。

換言之,4.2.0 之後,DLQ 不再只是 application 層自行補上的機制,而是 Kafka Streams 框架正式承接的責任。

結語

KIP-1034 補的是 Kafka Streams 長期存在的 DLQ 缺口。

Kafka 4.2.0 以前,真正制約 DLQ 設計的是 transaction 邊界:只要使用獨立 producer 寫入 DLQ,該筆寫入就脫離 Kafka Streams 的 transaction 範圍;在 EOS 開啟時,retry 可能造成重複寫入。除此之外,錯誤攔截層級、送出方式與 headers 命名也都必須由 application 層自行維護。

4.2.0 之後,設定 StreamsConfig.ERRORS_DEAD_LETTER_QUEUE_TOPIC_NAME_CONFIG 並搭配內建 exception handler,錯誤資料即可交由 Kafka Streams 內部寫入 DLQ,不必另行建立 producer。對於仍在維護手動 DLQ 的 Kafka Streams 專案,KIP-1034 值得納入升級評估。

參考資料:

API Design: Use type state pattern to avoid ambiguous option flags

· One min read

For example, ZADD is a command that add member with score to sorted set, and it can accept NX or XX as option.

ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member
...]
  • XX: Only update elements that already exist. Don't add new elements.
  • NX: Only add new elements. Don't update already existing elements.

NX and XX can only choose one. In go-redis, this structure is used to hold arguments, but this requires extra comments and checks to tell users that NX and XX are mutually exclusive.

type ZAddArgs struct {
NX bool
XX bool
LT bool
GT bool
Ch bool
Members []Z
}

Ref: go-redis ZAddArgs

But in redis/rueidis, it provides a command builder where the type system directly prevents you from setting both NX and XX at the same time.

client.B().Zadd().Key("1").Nx().Gt().Ch().Incr().ScoreMember().ScoreMember(1, "1").ScoreMember(1, "1").Build()

Build Nested JSON in PostgreSQL

· 2 min read

Original Stackoverflow thread:

https://stackoverflow.com/questions/42222968/create-nested-json-from-sql-query-postgres-9-4/42226253#42226253

Suppose we have this tables:

person car wheel And the relation between is:

person:car = 1:N car:wheel = 1:N We need to build some nested JSON Object with SQL Query to get the summary about details of each car this person has, what would you do ?

The Goal

{
"persons": [
{
"person_name": "Johny",
"cars": [
{
"carid": 1,
"type": "Toyota",
"comment": "nice car",
"wheels": [
{
"which": "front",
"serial number": 11
},
{
"which": "back",
"serial number": 12
}
]
},
{
"carid": 2,
"type": "Fiat",
"comment": "nice car",
"wheels": [
{
"which": "front",
"serial number": 21
},
{
"which": "back",
"serial number": 22
}
]
}
]
},
{
"person_name": "Freddy",
"cars": [
{
"carid": 3,
"type": "Opel",
"comment": "nice car",
"wheels": [
{
"which": "front",
"serial number": 3
}
]
}
]
}
]
}

Approach 1 - Left Join

select
json_build_object(
'persons', json_agg(
json_build_object(
'person_name', p.name,
'cars', cars
)
)
) persons
from person p
left join (
select
personid,
json_agg(
json_build_object(
'carid', c.id,
'type', c.type,
'comment', 'nice car', -- this is constant
'wheels', wheels
)
) cars
from
car c
left join (
select
carid,
json_agg(
json_build_object(
'which', w.whichone,
'serial number', w.serialnumber
)
) wheels
from wheel w
group by 1
) w on c.id = w.carid
group by personid
) c on p.id = c.personid;

Approach 2 - Put sub-query in SELECT-List with json_build_object and json_agg

This is the SQL query based on Nico Van Belle's answer, but I replaced row_to_json with json_buid_object.

select json_build_object(
'persons', (
SELECT json_agg(
json_build_object(
'person_id',id,
'cars', (
SELECT json_agg(
json_build_object(
'car_id', car.id,
'wheels', (
SELECT json_agg(
json_build_object(
'wheel_id', wheel.id,
'whichone', wheel.whichone,
'serialnumber', wheel.serialnumber,
'car_id', wheel.carid
)
)
FROM wheel WHERE wheel.carid = car.id
)
)
) FROM car WHERE id = person.id
)
)
) FROM person
)
);

You can view the result ojnline with db<>fiddle

Why Cost is so high ?

  • Each Sub-node has to be executed N times, where N is number of person

Query Plan

Summary

I think putting sub-query in SELECT-List is elegant, but it's costly.

https://medium.com/@e850506/note-more-nested-json-5f3c1e4a87e

Use sync.Pool to reduce memory consumption

· 5 min read

Our service is like a excel document datastore. and we use xorm as ORM framework, Everytime we need to get data from DB, we call session.Find(&[]Author{}) with the slice of table beans, but this have a problem,

  • Memory allocation is very high

So every time lots of clients try to download excel file, the memory consumption is too high, and downloadling excel file takes too long to complete.

Find the root cause with pprof

I wrote a benchmark and by leveraging GO's pprof profiling tool, we can easily check out the flamegraph using some tool like pyroscope.

Here's the result we got:

CPU

Structure-Binding-cpu

Memory Allocation

Structure-Binding-mem

We can see that under the frame of (*Session).rows2Beans, except the function underneath xorm framework that we can't touch, (*Session).slice2Bean took a lot of CPU time and had lot of memory allocation.

The problem of Structure Binding

After took a look at the code in noCacheFind, I found that if we use bean (a structure with information about db schema definition) to hold the result set, xorm will call session.rows2Beans to convert rows into tableBean.

In sesson.rows2Beans(), it will:

  • convert rows to slices ([]any) by calling session.row2Slice()
  • convert []any to []bean by calling session.slice2Bean()

And this tooks a lot of time.

But I also found that if we use [][]string to hold the result set, after getting xorm.Rows (underlying data structure is database/sql.Rows), noCacheFind() will call rows.Scan for each row, so simple ! This is the chance we can make session.Find() much faster.

Step 1: Use [][]string to hold the data

Based on the assumption, we can use [][]string to reduce the cost of structure binding, you can see the benchmark below unifyContainerNoPool .

Step 2: Use sync.Pool to reduce memory allocation

But it still need huge amount of memory allocation for every []string and every [][]string, let's see how we can reduce this cost.

The solution I came out is very simple, if memory allocation is time-consuming, why don't we reuse the data structure in memory ? In this case, we're using [][]string

var unifyContainerRowPool = sync.Pool{
New: func() interface{} {
conRow := make([]string, DefaultContainerColLen)
conRow = resetUnifyContainerRow(conRow)
return conRow[:0]
},
}

var unifyContainerPool = sync.Pool{
New: func() interface{} {
// fmt.Println("New is called for unifyContainerPool")
con := make([][]string, 0, DefaultContainerRowLen)
return con
},
}

Experiment:

To demonstrate the improvement of our code, I design a simple benchmark,

There are three ways we can get data from database.

  • Use []Author to hold the data (Structure Binding)
  • Use [][]string to hold the data (Unify Container without sync.Pool)
  • Use [][]string to hold the data, and use sync.Pool to reuse [][]string (Unify Container with sync.Pool)

For row number between 1000 and 8000 to demonstrate the benefit of sync.Pool, we use runtime.NumCPU() worker to perform runtime.NumCPU()*4 jobs, every job gets all rows from the author table

$ make BENCHTIME=1s
go test -benchmem -benchtime=1s \
-bench=. \
| tee data/result_all.txt
goos: darwin
goarch: arm64
pkg: github.com/unknowntpo/playground-2022/go/xorm/unifyContainer
BenchmarkContainer/StructureBinding-1000-8 13 78949647 ns/op 91926655 B/op 3146081 allocs/op
BenchmarkContainer/UnifyContainerWithPool-1000-8 31 39028380 ns/op 31799634 B/op 1882362 allocs/op
BenchmarkContainer/UnifyContainerNoPool-1000-8 22 48651809 ns/op 48547759 B/op 2407600 allocs/op
BenchmarkContainer/StructureBinding-2000-8 8 137213729 ns/op 189730109 B/op 6284178 allocs/op
BenchmarkContainer/UnifyContainerWithPool-2000-8 15 72343683 ns/op 63592857 B/op 3759864 allocs/op
BenchmarkContainer/UnifyContainerNoPool-2000-8 12 87559920 ns/op 97780912 B/op 4807668 allocs/op
BenchmarkContainer/StructureBinding-3000-8 6 199308167 ns/op 281507561 B/op 9422225 allocs/op
BenchmarkContainer/UnifyContainerWithPool-3000-8 10 105695333 ns/op 97377107 B/op 5654077 allocs/op
BenchmarkContainer/UnifyContainerNoPool-3000-8 8 128159927 ns/op 146226483 B/op 7207695 allocs/op
BenchmarkContainer/StructureBinding-4000-8 4 256713490 ns/op 379839898 B/op12560279 allocs/op
BenchmarkContainer/UnifyContainerWithPool-4000-8 8 140550521 ns/op 129773817 B/op 7537186 allocs/op
BenchmarkContainer/UnifyContainerNoPool-4000-8 7 165150417 ns/op 195457696 B/op 9607724 allocs/op
BenchmarkContainer/StructureBinding-5000-8 4 323341906 ns/op 486299350 B/op15698332 allocs/op
BenchmarkContainer/UnifyContainerWithPool-5000-8 7 162782482 ns/op 163561488 B/op 9417513 allocs/op
BenchmarkContainer/UnifyContainerNoPool-5000-8 5 200822450 ns/op 245477224 B/op12007762 allocs/op
BenchmarkContainer/StructureBinding-6000-8 6 195379785 ns/op 195452422 B/op11307507 allocs/op
BenchmarkContainer/UnifyContainerWithPool-6000-8 6 195379785 ns/op 195452422 B/op11307507 allocs/op
BenchmarkContainer/UnifyContainerNoPool-6000-8 4 258140198 ns/op 296804806 B/op14407787 allocs/op
BenchmarkContainer/StructureBinding-7000-8 3 512568570 ns/op 720955394 B/op21974306 allocs/op
BenchmarkContainer/UnifyContainerWithPool-7000-8 4 251422083 ns/op 224965602 B/op13170581 allocs/op
BenchmarkContainer/UnifyContainerNoPool-7000-8 4 288070792 ns/op 349445756 B/op16807820 allocs/op
BenchmarkContainer/StructureBinding-8000-8 2 531542583 ns/op 792064800 B/op25112484 allocs/op
BenchmarkContainer/UnifyContainerWithPool-8000-8 4 271685614 ns/op 260817526 B/op15089126 allocs/op
BenchmarkContainer/UnifyContainerNoPool-8000-8 4 338913490 ns/op 395270596 B/op19207827 allocs/op
PASS
ok github.com/unknowntpo/playground-2022/go/xorm/unifyContainer 46.676s

The result shows that the number of allocation per operation is quite different,

The Structure Binding Method needs the largest number of allocations, and the speed is way slower that other two methods. When row number goes high, performance get worse very quickly.

The Method of using [][]string with sync.Pool on the other hand, needs smallest number of memory allocation, and compare to the one without sync.Pool, and because memory allocation takes significant amount of time, it's still faster.

Here's the plot:

perf

I put my code at the repo, please go check it out!

Optimize a PARTITION - SELECT query up to 60x faster

· 9 min read

This post demonstrates my experience of optimizing a PARTITION - SELECT query, and how I made it up to 60x faster.

Original Query and the use case

Our App is a simple excel data version control system, the data is organized by project, key and data is stored in seperated table called dbKey and dbData.

create table dbKey (
id serial ,
project_id int,
-- keys goes here
-- NOTE: key can be 1...N fields, and we use string.Join(fields, sep)
-- to handle it has the key string in backend service
name text
);
create table dbData (
id serial ,
key_id int ,
timestamp int

--- data stores at here
);

and there's also a sheet_version table that stores the version, timestamp information.

create table sheet_version (
id serial ,
version integer,
timestamp int
);

Every time we need to get specific version of data (let's say: version 2), we access sheet_version table first, and get the sheet_version.timestamp to construct the PARTITION - SELECT query.

To get the actual data, we need to do these steps:

  1. Partition the data table dbData by key_id,
  2. Rank it by timestamp (DESC), get the rank=1 datas from dbData
  3. Join dbKey and dbData back togetter.

Here's the query:

SELECT
dbKey.*, finalDBData.*
FROM
dbKey,
(
SELECT
*,
rank() OVER (PARTITION BY key_id ORDER BY TIMESTAMP DESC) AS rank
FROM
dbData where "timestamp" <= 101) finalDBData
where
dbKey.project_id = 10
and rank =1
and finalDBData.key_id = dbKey.id;

Here's the db<>fiddle you can play with this query.

info

We choose this design because it can save a lot of space to store every version of data. If version 2 has 10 keys, each key has 50 data, and if we change data under only 1 key, we only have to re-insert all data under this modified key. and only need to insert 50 data. Of course, this design has some limitations, but in this post, let's focus on the PARTITION - SELECT query optimization.

Identifying the root cause

SELECT
dbKey.*, finalDBData.*
FROM
dbKey,
(
SELECT
*,
rank() OVER (PARTITION BY key_id ORDER BY TIMESTAMP DESC) AS rank
FROM
dbData where "timestamp" <= 101) finalDBData
where rank =1
and finalDBData.key_id = dbKey.id;

Useless index and time-consuming Sequential scan

This query is slow because it has to:

  1. Scan the whole dbData table
  2. partition it by key_id, and rank the timestamp.
  3. Join it with dbKey table with rank=1 and finalDBData.key_id = dbKey.id

Planner tends to range over every row in data table to get rank=1 data because the rank=1 key_id - timestamp can be anywhere in the whole table.

This query it's so slow, we current have about 30000 keys in key table, each project has about 2000 keys, and almost 100 milion data rows in data table, it usually takes at least 60 second to get the particular version of data.

Here's the plan of this query:

------------------------------------------------------------------------------------------------------------------------------------------------------------------
Hash Join (cost=1125351.65..1289874.58 rows=5621 width=57) (actual time=9082.308..9468.256 rows=11020 loops=1)
Output: dbkey.id, dbkey.name, finaldbdata.id, finaldbdata.key_id, finaldbdata."timestamp", finaldbdata.rank
Hash Cond: (finaldbdata.key_id = dbkey.id)
Buffers: shared hit=358 read=545756, temp read=3000 written=3018
-> Subquery Scan on finaldbdata (cost=1125043.98..1289482.62 rows=5614 width=20) (actual time=9077.986..9459.255 rows=11000 loops=1)
Output: finaldbdata.id, finaldbdata.key_id, finaldbdata."timestamp", finaldbdata.rank
Filter: (finaldbdata.rank = 1)
Rows Removed by Filter: 1100200
Buffers: shared hit=274 read=545756, temp read=3000 written=3018
-> WindowAgg (cost=1125043.98..1275448.81 rows=1122705 width=20) (actual time=9077.985..9432.015 rows=1111200 loops=1)
Output: dbdata.id, dbdata.key_id, dbdata."timestamp", rank() OVER (?)
Buffers: shared hit=274 read=545756, temp read=3000 written=3018
-> Gather Merge (cost=1125043.98..1255801.47 rows=1122705 width=12) (actual time=9077.972..9174.199 rows=1111200 loops=1)
Output: dbdata.key_id, dbdata."timestamp", dbdata.id
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=274 read=545756, temp read=3000 written=3018
-> Sort (cost=1124043.95..1125213.44 rows=467794 width=12) (actual time=9060.365..9078.656 rows=370400 loops=3)
Output: dbdata.key_id, dbdata."timestamp", dbdata.id
Sort Key: dbdata.key_id, dbdata."timestamp" DESC
Sort Method: external merge Disk: 8304kB
Buffers: shared hit=274 read=545756, temp read=3000 written=3018
Worker 0: actual time=9048.365..9066.503 rows=354371 loops=1
Sort Method: external merge Disk: 7656kB
Buffers: shared hit=105 read=175482, temp read=957 written=963
Worker 1: actual time=9060.662..9079.499 rows=372284 loops=1
Sort Method: external merge Disk: 8040kB
Buffers: shared hit=105 read=180922, temp read=1005 written=1011
-> Parallel Seq Scan on public.dbdata (cost=0.00..1071990.75 rows=467794 width=12) (actual time=5.360..8698.716 rows=370400 loops=3)
Output: dbdata.key_id, dbdata."timestamp", dbdata.id
Filter: (dbdata."timestamp" <= 101)
Rows Removed by Filter: 33296333
Buffers: shared hit=192 read=545756
Worker 0: actual time=4.511..8532.085 rows=354371 loops=1
Buffers: shared hit=64 read=175482
Worker 1: actual time=3.410..8640.241 rows=372284 loops=1
Buffers: shared hit=64 read=180922
-> Hash (cost=183.41..183.41 rows=9941 width=37) (actual time=4.312..4.313 rows=10010 loops=1)
Output: dbkey.id, dbkey.name
Buckets: 16384 Batches: 1 Memory Usage: 803kB
Buffers: shared hit=84
-> Seq Scan on public.dbkey (cost=0.00..183.41 rows=9941 width=37) (actual time=0.007..1.395 rows=10010 loops=1)

And you can also view it on explain.dalibo.com

Approach 1: Materialized View

We can use materialized view to cache the result set of particalar version of data, but the first one who needs to get data still suffers from the slow query.

Improvement: Index-Only Scan

But this query still can be better. There's a new feature introduced in PostgreSQL 9.2, which allow us to get data from index itself, without touching the actual table data.

The documentation stats that There are two fundamental restrictions on when this method can be used:

  1. The index type must support index-only scans. B-tree indexes always do. GiST and SP-GiST indexes support index-only scans for some operator classes but not others. Other index types have no support. The underlying requirement is that the index must physically store, or else be able to reconstruct, the original data value for each index entry. As a counterexample, GIN indexes cannot support index-only scans because each index entry typically holds only part of the original data value.
  2. The query must reference only columns stored in the index. For example, given an index on columns x and y of a table that also has a column z, these queries could use index-only scans:
  3. If these two fundamental requirements are met, then all the data values required by the query are available from the index, so an index-only scan is physically possible. But there is an additional requirement for any table scan in PostgreSQL: it must verify that each retrieved row be "visible" to the query's MVCC snapshot, as discussed in Chapter 13. Visibility information is not stored in index entries, only in heap entries; so at first glance it would seem that every row retrieval would require a heap access anyway. And this is indeed the case, if the table row has been modified recently. However, for seldom-changing data there is a way around this problem. PostgreSQL tracks, for each page in a table's heap, whether all rows stored in that page are old enough to be visible to all current and future transactions. This information is stored in a bit in the table's visibility map. An index-only scan, after finding a candidate index entry, checks the visibility map bit for the corresponding heap page. If it's set, the row is known visible and so the data can be returned with no further work. If it's not set, the heap entry must be visited to find out whether it's visible, so no performance advantage is gained over a standard index scan. Even in the successful case, this approach trades visibility map accesses for heap accesses; but since the visibility map is four orders of magnitude smaller than the heap it describes, far less physical I/O is needed to access it. In most situations the visibility map remains cached in memory all the time.

Let's verify if all these restrictions is satisfied:

info
  1. It's staicfied because we are using B-tree index.
  2. It's satisfied by modifying our SQL query,
  3. It means all data in that page must be visible in visibility map, and it's also satisfied because the data is append-only.

We can build the map between key_id and the rank=1 timestamp first,

WITH map AS (
SELECT
DISTINCT(key_id),
timestamp
FROM (
SELECT
key_id,
timestamp,
rank() OVER (PARTITION BY key_id ORDER BY TIMESTAMP DESC) AS rank
FROM
dbData
-- filtering stuff depends on business logic
where "timestamp" <= 10000 and key_id < 100
) sub WHERE rank = 1)
SELECT * FROM map;

Result will be like:

 key_id | timestamp
--------+-----------
1 | 10000
2 | 300
3 | 6000
4 | 90303

And then, get actual data from dbData with specific key_id and timestamp pair.

WITH map AS (
SELECT
DISTINCT(key_id),
timestamp
FROM (
SELECT
key_id,
timestamp,
rank() OVER (PARTITION BY key_id ORDER BY TIMESTAMP DESC) AS rank
FROM
dbData
-- filtering stuff depends on business logic
where "timestamp" <= 10000 and key_id < 100
) sub WHERE rank = 1)
SELECT
dbKey.*, dbData.*
FROM
dbKey
INNER JOIN map m ON m.key_id = dbKey.id
INNER JOIN dbData ON dbData.key_id = m.key_id AND m.timestamp = dbData.timestamp;

The reason we build the map first is that the SELECT list in map are all stored in the index, which satisfied requirement 2 in the documentation, and later when we query dbData , we can still have Index Scan.

Here's the example

note

UPDATE: The key_id in map should be unique, or there will be duplicated keys with same timestamp, so I added DISTINCT(key_id) to the map query.

Final choice: I want them all!

We decided to use this optimized query to build the materialized view, and maintain a materialized view (we call it mat_view for short) management system to organize the creation, deletion of these mat_views.

Reference:

ChatGPT First Glance

· One min read

This is my first glance of ChatGPT, and I ask her to generate a peice of code in Haskell, which can map a function to a list.

The result she generated is totally correct, and can be run in playground.

addOneToEach :: [Int] -> [Int]
addOneToEach xs = map (+1) xs

myMap :: (a -> b) -> [a] -> [b]
myMap _ [] = []
myMap f (x:xs) = f x : myMap f xs

main = do
let myList = [1, 2, 3, 4]
let doubledList = myMap (*2) myList
print doubledList
-- Output: [2,4,6,8]

Here's the link to our chat: https://sharegpt.com/c/yedzb1N

My First Post

· One min read

Inline Formula: E=iEi=E1+E2+E3+\mathbf{E}=\sum_{i} \mathbf{E}_{i}=\mathbf{E}_{1}+\mathbf{E}_{2}+\mathbf{E}_{3}+\cdots

SELECT 'hello-world' FROM me

Block Formula:

a=b+cd+e=fa=b+c \\ d+e=f a=b+cd+e=fa=b+c \\ d+e=f

Test IFrame