vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

Log 最基本的操作是 append,而 R2 不支援 append。K2 要在這樣的儲存上,蓋出一條有序的 log。

K2 把事件 log 蓋在 R2 上

K2 是 Cloudflare 推出的 serverless 事件串流服務,資料放在 R2 物件儲存上,對外提供有順序、可持久保存的 log,使用者不必自己維運 broker 叢集。文章從 RPC 架構的老問題講起:生產端與消費端「must align in scale and in time」。生產端送得比消費端處理得快,或消費端與下游服務暫時不可用,事件就會掉。K2 站在兩端之間,把規模與時間上的耦合拆開,讓寫入和讀取各走各的節奏。文章是 Cloudflare 自己的發表,本文的數字與引語都出自這一份來源,沒有第二方的獨立量測。

沒有 append 的儲存層上,log 怎麼成立

文章對底層架構只用一句話交代:「Under the hood, K2 implements a partitioned, durable log on top of R2 object storage」。耐久性來自 R2,文章形容為「extremely durable storage (11 9s!)」。K2 自己不做副本複製,也不跑共識協定,這個取捨寫在另一句:「Offloading replication and consensus to the storage layer allows us to make the application layer (K2 in this case) radically simpler, cheaper, and higher performance.」副本與共識由儲存層負責,應用層就只剩 log 本身的語意要處理:資料怎麼切、順序怎麼排、讀取者怎麼領。

把 log 蓋在物件儲存上,第一個撞到的限制是 R2「does not support appends, the standard operation on a log」。Log 的寫入模型是在尾端不斷追加,物件儲存的寫入模型是整個物件一次寫入。兩者對不上,K2 的處理方式是改變寫入的單位:「we must write complete files, or segments, that are large enough to overcome the cost of writing and reading each one」。K2 寫進 R2 的是一個個完整的 segment 檔,每個 segment 都要夠大,才攤得平單次寫入與單次讀取的成本。

這句話藏著設計上最重要的耦合:segment 要夠大,事件就得先等人湊齊,所以寫入延遲裡一定有一段是等待。這一點到寫入路徑那一節會量到數字。至於 segment 的實際大小與湊批的等待上限,文章都沒有公開。

把這兩句併起來看,K2 的設計方向就清楚了:對外是一條 log,落到 R2 的卻是一個個寫完就定型的檔案。要驗證這套設計,讀者能先問三件事,segment 多大、湊批最久等多久、單次寫入的成本怎麼攤。三個答案文章都沒給,所以這一節只能確定方向,還算不出任何一條延遲或成本曲線。

另一個值得先記下的字是 partitioned。文章說 K2 實作的是「partitioned」log,引用的段落沒有展開 partition 怎麼劃分,也沒有說使用者能不能指定 partition。路線圖列了「Message keys and key-based ordering guarantees」一項,表示按 key 分流與按 key 保序目前還沒有。談順序時會再用到這一點。

K2 讀寫路徑(依文章描述整理) Producer HTTP API Worker binding edge service 記憶體累積寫入 短暫等待後 整批寫成 segment R2 物件儲存 segment segment segment offset 嚴格遞增 由 R2 原子操作決定 subscription A consumer 池 lease 5 分鐘 subscription B consumer 池 ack / nack / extend 1 2 3 4 5 虛線框:segment 大小、等待時間、原子操作種類,文章未公開
  1. 事件經 HTTP API 或 Worker binding 送入,K2 把資料當成 bytes,編碼格式由應用自己決定。
  2. edge service 先把寫入累積在記憶體,等一小段時間讓資料到齊。
  3. R2 不支援 append,所以整批事件寫成一個完整的 segment 檔。
  4. 順序與嚴格遞增的 offset 靠 R2 的原子操作取得,沒有獨立的協調服務。
  5. 每個 subscription 領到的是一批事件加上 5 分鐘的 lease,之後 ack、nack 或 extend。

記憶體批次,與約 1 秒的 p99

寫入路徑的描述只有兩句:「We do this by first accumulating writes in-memory on an edge service. After waiting a short period for data to arrive, we write all events as a segment file.」生產端這一側,文章說資料經「an HTTP API or Worker binding」送入,K2 把資料當成 bytes,所以「you can use whatever format or encoding makes sense for your application」。K2 不替事件規定 schema,也不解析內容,它比較像一條位元組的管線,格式的責任留在兩端的應用程式。

bytes 的選擇也有代價。K2 不解析內容,事件的 schema 怎麼演進、編碼怎麼標版本,全由應用自己管;同一條 stream 若中途換了編碼,消費端得自己認得出來。文章只說格式由應用決定,引用的段落沒有談版本管理,團隊導入前最好先把事件格式與版本欄位定下來,這是本文補上的提醒。

上圖第二步的記憶體批次,是整個設計裡最容易被忽略的一環。事件進到 edge service 之後、寫進 R2 之前,只存在記憶體裡。R2 的 11 個 9 是儲存層的耐久性,從這個層次推得,它描述的是寫進 R2 之後的物件,湊批期間停在記憶體的那一小段不在其中。文章量的是「produce latency at the 99th percentile of response times」,回應時間包含等待湊批與寫入 R2 兩段,所以本文推測回應要等 segment 寫完才回去,耐久性的邊界也因此落在回應之後,而不在請求進來的那一刻;這是推測,文章沒有明說。生產端何時收到成功回應、edge service 在湊批期間失效會怎樣,引用的段落都沒寫。需要確認的團隊,該直接問 Cloudflare 或自己測。

延遲的數字文章給得很直接:「Writing to object storage is slower than a local disk, and we have to wait for the local batch to accumulate before starting the write. In our initial release of K2, this adds up to about 1 second of produce latency at the 99th percentile of response times.」兩個原因都點明了:物件儲存比本機磁碟慢,加上要等批次湊齊才開始寫。大約 1 秒的 p99 是「initial release」的數字,路線圖另列了「Express tier with lower produce and end-to-end latencies」,連文章自己也把現在的水準當作第一版。給的只有 p99,沒有 p50,也沒有端到端延遲,也就是從生產到被消費者讀到的總時間。

p99 描述的是尾端。照定義,一百次寫入裡大約有九十九次落在約 1 秒以內,剩下那一次可能更久,文章沒給最大值。對每秒都在寫入的生產端,這個尾端天天都會碰到,逾時與重試的設定以 1 秒為底再留餘裕才合理,這是從定義往下讀的結果。至於 p50 落在哪裡,文章沒量,也就無從得知多數請求實際等多久。

湊批的等待時間,是延遲與成本之間的旋鈕。segment 越大,單次寫入與單次讀取的成本越容易攤薄,湊滿它也要等更久。K2 在兩端之間選了哪個折衷點,文章沒有公開;這個折衷點加上 R2 寫入本身的耗時,構成了文章承認的那約 1 秒。

這個延遲適合什麼用途,文章沒列建議情境,這裡依量級先這樣讀:記錄事件、搬運資料、非同步的後處理,這類不卡在使用者請求路徑上的工作負載,大約 1 秒的 p99 多半能接受。要在請求路徑裡同步等寫入完成的場景,就得先問這 1 秒值不值得。

讀取端有一個對稱的問題。文章把成本寫成「writing and reading each one」,寫與讀都以 segment 為單位計算,但 consumer 領到的「一批事件」是否剛好等於一個 segment,引用的段落沒有說明。兩個名詞各自出現在寫入路徑與消費路徑,彼此的對應關係在文章裡是空白的;本文的圖把它們畫在同一條路徑上,只表示資料的流向,批與 segment 是否一對一,圖上沒有主張。

edge 這個詞也值得停一下。文章在談為什麼不採用 Kafka 時說,Cloudflare 的 edge「spans a huge number of servers across over 335 cities」。K2 的批次發生在 edge service 上,而 edge 是很多台伺服器,所以同一條 stream 的寫入很可能從不同的伺服器進來,文章沒有直接描述同一條 stream 的寫入路由。各處湊好的批,誰來決定先後?下一節處理這個問題。

不靠協調服務的 offset

文章對順序的說明只有一句:「We achieve ordering and strictly incrementing offsets using R2's atomic operations without needing a separate coordination service.」這句話交代了順序從哪裡來:offset 嚴格遞增,順序就定了;做法是 R2 的原子操作;另外沒有獨立的協調服務。至於是條件式寫入、版本比對還是別的機制,文章沒說。

嚴格遞增這四個字,檢查時要抓準範圍。它保證後一筆的 offset 比前一筆大,文章沒寫 offset 是否連續,所以消費端的程式不該拿「offset 少了一號」當成資料遺失的訊號。這一點是對字面的謹慎讀法,實際行為得以 Cloudflare 文件或實測為準。

順序問題需要有個機制裁決先後,K2 把這個工作放進 R2 已經提供的原子性裡。這與前面引用的設計原則同一條線:「Offloading replication and consensus to the storage layer」。副本與共識交出去,順序也交出去,應用層剩下的元件就更少。「radically simpler, cheaper, and higher performance」這三個形容詞沒有附上對照量測,只能當設計目標看。

順序保證的範圍要讀仔細。文章的保證是 offset 嚴格遞增,這是一條 stream 之內的全序。路線圖同時列出兩項:「Higher write parallelism, up to multi-GB/s streams」與「Message keys and key-based ordering guarantees」。前者對照現在每條 stream 30 MB/s 的寫入上限,後者說明按 key 的保序目前沒有。文章沒明說這幾項之間的因果,這裡先這樣讀:單條 stream 的全序與它的寫入上限有關,要在同一個全序下提高寫入就得靠更多平行度,而按 key 保序正好能讓平行度不破壞順序。

實務上的讀法是:現在要的是跨事件的全序,就用一條 stream,並且接受 30 MB/s 的上限;要的是按使用者或按訂單保序,目前沒有 key 這一層,只能自己處理,或等路線圖兌現。路線圖沒有給時程。

subscription 與 5 分鐘的 lease

消費端的第一個概念是 subscription。文章說它帶來 read parallelism,也就是「scaling out to multiple readers to handle more load than a single server can manage」。同一個 subscription 底下的 consumer 分攤資料,文章的說法是「splitting work amongst multiple consumers such that each consumer gets a portion of the data」。另一種是每個 consumer 各有一個 subscription,也就是「the pub/sub pattern」,此時「each consumer sees all of the messages」。第三種是兩者混用。下面的圖把前兩種擺在同一個畫面上切換。

切換兩種 subscription 配置,箭頭會重新接線 · 2 種模式

stream 有序的 log subscription subscription 2 Consumer A 各拿一部分資料 Consumer B 各拿一部分資料 共用一個 subscription,consumer 分攤這條 stream;文章未說明分配規則

互動圖表

同一個 subscription 的 consumer 分攤訊息,各開 subscription 則各看全部,也能混成多個獨立 pool。

混合配置是「multiple independent consumer pools」:每個 pool 有自己的 subscription,pool 之內分攤,pool 之間各看全部。同一條事件流因此可以同時餵給即時處理的 pool 與另一組寫入下游系統的 pool,彼此的進度互不影響,因為 subscription 各自獨立。文章在後面對比 Queues 時,也把「fan-out consumption」列為 K2 的設計目標之一。

消費端的第二個概念是 lease。文章寫道,呼叫 consume 時客戶端拿到「a lease for that particular batch of events for 5 minutes」,接著有三個動作:「ack the batch, which marks it as processed and ensures it will not be redelivered」、「nack (negative ack) it, meaning we've failed to process it and would like it to be redelivered」,或「extend its lease, in case it needs more time to complete processing」。領取、處理、回報,三步各有明確的出口。

拖曳處理時間,看它落在 5 分鐘 lease 的哪一側 · 0 到 10 分鐘

3 分鐘
lease 5 分鐘(文章給定) 超過 5 分鐘 012345678910 分鐘 extend 可延長 lease,文章未說明一次延長多久 處理時間 3 分鐘,仍在 lease 之內 此時可以 ack、nack,或 extend 延長 lease

三個出口的語意,文章寫得很清楚。ack 之後不會重新投遞;nack 表示處理失敗、希望重新投遞;extend 是手上的批次還沒處理完,需要更多時間。lease 到期而消費者既沒 ack 也沒 nack 時會發生什麼,文章沒寫。nack 的描述是「would like it to be redelivered」,本文由此推測到期的批次會回到可重新投遞的狀態。extend 一次能延長多久,以及投遞語意是否保證至少一次,同樣沒有答案。

拿 5 分鐘換算,lease 是 300 秒。一批事件平均處理 3 分鐘,落在 lease 之內,處理完 ack 即可;處理時間逼近 5 分鐘,就得在途中 extend,而一次延長多久文章沒說,程式裡別假設一次延長就夠。三個出口之中,只有 ack 有明確保證,也就是不會重新投遞;nack 用的是 would like it to be redelivered,語氣是希望,文章沒有承諾投遞次數或時限。

還有一件事會影響程式寫法。文章說 K2 的訊息「produced and consumed as batches」,而 lease 的單位是一整批事件。同一批裡有一筆處理失敗,能做的就是 nack 整批,沒有只重送那一筆的出口,這與文章自己的說法吻合:批次處理「enabling efficient processing at the expense of message-level retries」。整批重送代表同一筆事件可能再來一次,消費端的處理邏輯最好重複執行也不會出錯。文章沒有討論冪等性,這一條是本文補上的建議。

價格表上的三個數字,與兩條限制

文章列出的限制是「Maximum of 10GB of storage used」與「30 MB/s produce per stream」,價格表是 Data Produced 每 GB 0.04 美元、Data Consumed 每 GB 0.04 美元、Data Retained 每 GB 每月 0.02 美元。文章示範的 stream 設定裡,retention_seconds 是 604800,也就是 7 天。下面的計算器把這幾個數字接起來,三個滑桿分別是每日寫入量、subscription 數、保留天數。月費是本文用自己的假設算的:每個 subscription 把全部資料讀一次、一個月以 30 天計、保留量取穩態平均,也就是每日寫入量乘上保留天數。

拖曳寫入量、subscription 數、保留天數,看月費怎麼分配 · 3 個滑桿

100 GB / 天
2 個
7 天
月費估算(30 天、穩態、每個 subscription 讀全部資料一次) 寫入 produced $120.00 讀取 consumed $240.00 保留 retained $14.00 合計 $374.00 / 月 寫入 3,000 GB × $0.04 + 讀取 6,000 GB × $0.04 + 平均保留 700 GB × $0.02 穩態保留約 700 GB,高於文章列出的儲存上限 10 GB 平均寫入約 1.16 MB/s,低於單一 stream 的 30 MB/s 上限

預設的情境是每天寫入 100 GB、兩個各讀全部資料的 subscription、保留 7 天:寫入 120 美元、讀取 240 美元、保留 14 美元,合計 374 美元。讀取這一項最大,原因在價格表本身:寫入與讀取的單價都是每 GB 0.04 美元,而讀取量是寫入量乘上 subscription 數。每多一個各讀全部資料的 subscription,月費就多一份寫入費用的大小;保留只有每 GB 每月 0.02 美元,在這個例子裡幾乎可以忽略。反過來,所有 consumer 都在同一個 subscription 底下分攤的話,讀取量等於寫入量。subscription 怎麼切,直接決定帳單,也決定資料的流向。

把 subscription 降到一個,同樣每天 100 GB、保留 7 天,寫入 120 美元、讀取 120 美元、保留 14 美元,合計 254 美元,比兩個 subscription 的 374 美元少 120 美元,剛好是多出來那份讀取費。這個差額就是切 subscription 的定價。若兩組 consumer 其實只想平分工作,併進同一個 subscription 就能省下這筆;若兩組各自要看到全部事件,這筆費用就是 fan-out 的價格,文章把 fan-out consumption 列為設計目標,帳單上看得到它的成本。

保留費的換算很直觀:每 GB 每月 0.02 美元,一份資料放兩個月,保留費就等於寫入它一次的費用。文章把「long-term retention」列為 K2 的設計目標,價格表給出的單價與這個目標相容;實際保留多久才划算,要看資料被讀幾遍,這取決於各團隊的 subscription 設計。

兩條限制線在同一張圖裡也各說了一件事。30 MB/s 換算成一天,是 30 乘上 86,400 秒,約 2,592 GB,所以每日 100 GB 平均下來只有約 1.16 MB/s,離單 stream 的寫入上限很遠;平均速率不等於尖峰,流量集中在短時間時,該看的是尖峰。儲存上限的約束要緊得多:10 GB 除以 7 天,保留 7 天時每天只能寫約 1.4 GB 就會碰到上限。預設情境的穩態保留約 700 GB,遠超過 10 GB。文章把這些列為目前的限制,路線圖也提到更高的寫入平行度,但在現階段,儲存上限比寫入速率更先擋住真實的工作負載。價格與上限會不會調整,以官方文件為準。

和 Queues、Pipelines、Kafka 的邊界

Cloudflare 已經有 Queues 與 Pipelines,文章花了一段話替 K2 劃界。Queues 的重心是「tracking individual items of expensive or time consuming work that need to be asynchronously completed」,支援「complex logic on the grain of a particular work item, like retries, delays, and dead-letter queues」。K2 則是「designed for high-scale data movement, long-term retention, and fan-out consumption」,並且訊息以批次為單位處理,換來「efficient processing at the expense of message-level retries」。兩者的差別在粒度:Queues 追蹤一件一件的工作,K2 搬運一批一批的資料。

產品文章描述的設計重心文章給的取捨或建議
Queues追蹤一件一件昂貴或耗時、需要非同步完成的工作支援 retries、delays、dead-letter queues 這類以單一工作項為粒度的邏輯
K2高規模的資料搬運、長期保留、fan-out 消費以批次生產與消費,換來處理效率,代價是沒有訊息層級的重試
Pipelines事件的攝取服務最終目的地是 object storage 或 Iceberg tables 時選 Pipelines,要自訂處理或寫到其他目的地時選 K2

Pipelines 的邊界由文章直接給了建議:「We recommend Pipelines when the end result is writing your events to object storage or Iceberg tables, and K2 when doing custom processing or writing to other destinations.」把表格讀成決策規則:資料終點是 object storage 或 Iceberg,Pipelines 已經夠用;需要自己寫處理邏輯、或終點是別的系統,才輪到 K2。至於 K2 與 Queues 的選擇,看工作單位:每件工作要單獨重試、延遲或進 dead-letter,選 Queues;整批搬運、長期保留、多組消費,選 K2。

判斷用 Queues 還是 K2,可以先問失敗的單位是什麼。文章的分界落在粒度上:Queues 的 retries、delays、dead-letter queues 都綁在單一工作項上,K2 的重送綁在整批上。需要某一筆工作失敗後單獨延遲再試,K2 的批次模型給不出這個出口;只要能接受整批重來,K2 換到的是處理效率。

Kafka 在文章裡的地位不太一樣。文章的說法是:「Pipelines runs on the Cloudflare edge, which spans a huge number of servers across over 335 cities. Our unique architecture means we often cannot run traditional distributed systems software like Kafka」。這是一個關於部署環境的論證:Cloudflare 自己的平台形狀,讓它不能直接跑 Kafka 這類傳統分散式系統,所以才把副本與共識交給 R2。路線圖列了「Drop-in support for Apache Kafka clients」,相容 Kafka 客戶端是計畫中的功能,目前還沒有。

文章沒有拿 K2 與 Kafka 做逐項的延遲、吞吐或成本對照,這裡也不補。能從文章讀出的只有結構上的差異:K2 的耐久性與副本在 R2,順序來自 R2 的原子操作,沒有獨立的協調服務;代價是約 1 秒的 p99 寫入延遲、批次層級的重試,以及目前每條 stream 30 MB/s 與 10 GB 儲存的限制。從這些限制與路線圖缺的項目推得,需要毫秒級延遲、或需要按 key 保序的工作負載,現階段不適合 K2。

放在台灣工程團隊的視角,評估 K2 可以逐條過一遍:能不能接受約 1 秒的 p99 寫入延遲;處理邏輯能不能以批次為單位重試、並容忍整批重送;需不需要按 key 保序,目前沒有;每條 stream 的寫入與儲存量是否在目前限制之內;subscription 要怎麼切,因為它同時決定資料流向與讀取費用。五條都過,K2 省下的是 broker 叢集的維運;任何一條不過,路線圖裡對應的項目就是該追蹤的清單。

What this enables:不自己養 broker,就能得到一條有序、可保留、可多組消費的 log,代價是約 1 秒的 p99 寫入延遲、批次層級的重試,以及目前單 stream 30 MB/s 與 10 GB 儲存的上限。