vatt'ghern jaskier's ballads

兩個 batch 用同一個時間戳分別想把餘額寫成 (100, 1900) 與 (1800, 200),Cassandra 逐個 cell 比大小、留下數值較大的一邊,最後寫進資料庫的是誰都沒打算寫的 (1800, 1900)。

從無交易到跨分區 ACID,Cassandra 6 走了 13 年

Cassandra 一開始是一套完全沒有交易的 eventually-consistent 系統,介面是 Thrift、schema 也是空的——vendor-neutral、內建 sharding 與複寫這個組合,是 Apple、eBay、Bloomberg、Netflix 這些大型使用者會選它的原因,但一致性從來不是賣點。The Consensus 這篇文章用一個最直白的銀行轉帳情境測了這件事:兩個帳戶各起始 1000,兩個寫入者互轉、模擬併發轉帳,第三個執行緒不停讀、斷言兩帳戶總和永遠是 2000——這個不變量,貫穿了接下來每一種模式的每一次測試。用最原始的 UPDATE 語句跑這個情境,1500 次併發讀取斷言裡有 939 次總和不對,六成以上讀到一個不可能的數字。Cassandra 從 2013 年的 BATCH 算起,一路疊加 BATCH、LWT、Accord 三層機制,要解決的正是這個問題——而且到了 Accord,答案依然沒有乾淨到毫無破綻。

問題之所以現實,跟 Cassandra 的資料模型有關:沒有 join,自動 sharding 加上有限的 secondary index 能力,逼得你依查詢模式設計資料表——同一筆邏輯上的更新,常常得拆成好幾次寫入,分別落在不同的表。單一 partition 內的原子性從來不夠用,這正是「跨 partition 交易」在 Cassandra 上特別現實的理由,也是這篇文章想弄清楚的:BATCH、LWT、Accord 三層機制,各自把這道邊界推到哪裡。

Cassandra 能被這種規模的公司選中,靠的是一個少見的組合:vendor-neutral、open source,同時提供 SQL-like 的查詢語言,還內建 sharding 與複寫——市場上同時具備這幾項的資料庫並不多。正是這個組合把「交易能力補齊」變成一件很多人在意的事:應用程式已經被迫用 SQL-like 語法思考、又因為沒有 join 被迫把寫入拆成多筆,缺乏跨 partition 的一致性保護,風險就特別具體。光是最原始的 UPDATE,Cassandra 就撐不住一間銀行——這也是 BATCH、LWT、Accord 三層機制存在的理由。

測試設計本身有三個軸:一個是模式(plain、BATCH、LWT、Accord);一個是要不要跨 partition——帳戶表用 customer 欄位分區,跨分區版本讓兩個帳戶落在不同 customer 底下;第三個是要不要先讀再寫,盲寫版本直接算好新值就送出,read-modify-write 版本要先讀當下餘額、算出差值,再寫回去。支援條件寫入的 LWT 與 Accord 用了額外的 op 欄位當 idempotency key;plain 與一般 BATCH 沒有用到,不是為了討巧,是它們的語法本來就沒有條件寫入,用不上這個欄位。

拖曳時間軸看每一次升級解決了什麼、留下什麼 · 4 個里程碑

最初的 Cassandra Thrift、schemaless 完全沒有交易 2013 年 1 月 · BATCH 同分區原子+隔離 跨分區只剩最終原子性 2013 年 9 月 · LWT Paxos-based CAS 完全跨不了 partition 2026(未發布)· Accord EPaxos 共識、跨分區 ACID 仍非互動式
cassandra-6.0 分支:EPaxos 啟發的無 leader 共識協定,讓標記 transactional_mode='full' 的資料表所有讀寫都走 Accord,終於做到跨分區的 ACID 交易——但仍是非互動式。

BATCH:同分區原子,時間戳打平就拆夥

BATCH 是這條路的第一站,Cassandra 1.2(2013 年 1 月)定型至今。它把多個語句包成一個 mutation:落在同一個 partition 內,套用是原子且隔離的;如果 batch 跨越 partition,保證降級成「最終都會套用」,不含隔離。同一個 batch 裡所有語句共用一個時間戳,其他情況下每個語句有自己的時間戳;衝突用時間戳決勝,而且是逐個 cell 決勝,不是逐 row。正常情況下不同 batch 的時間戳不會一樣,較晚的 batch 整批贏過較早的 batch,同一個 batch 寫入的欄位不會被拆散。但時間戳是用戶端自己產生的,兩個用戶端是有機會打平的——打平時 Cassandra 逐個 cell 保留數值較大的一方,兩個打平的 batch 因此可能各贏一部分欄位。這個「最終」保證本身仍然有用:就算某個 replica 當下故障,batch 裡的每一句最後都會被套用,不會遇到「一半語句永遠遺失」的情況,只是併發讀取這段期間看到的畫面可能是錯的。

點按或聚焦底線詞看衝突解決機制 · 1 個名詞解釋

writer 1 想寫的 batch

customer=1 → 100

customer=2 → 1900

writer 2 想寫的 batch

customer=1 → 1800

customer=2 → 200

兩個 batch 用同一個時間戳送達,Cassandra 用cell 層級 last-write-wins衝突不是整批比大小,是逐一個欄位(cell)比大小——時間戳打平時,每個 cell 各自保留數值較大的一邊,勝負可能兩邊各贏一半。逐一決勝。

實際寫入結果

customer=1 → 1800

customer=2 → 1900

兩邊都沒打算寫出 (1800, 1900)——這組數字是逐 cell 各自比大小的副產品,兩個 batch 的寫入時間戳固定為同一個值:1755400000000000。日常測試裡兩個用戶端各自產生的時間戳不常剛好相等,但轉帳這種高頻寫入同一組欄位的情境,撞上的機率並不算低,這正是前面同分區盲寫測試裡偶爾出現的那 6 次失敗的來源。

實測結果印證了這一點。同分區內的盲寫轉帳測試,多數執行完全乾淨,但作者說得很直接:每跑幾輪就會撞到一次糟糕的結果——其中一輪 1500 次讀取斷言裡有 6 次失敗,兩個帳戶的餘額同時衝到 1897 與 1893,加總遠超過 2000。換成跨分區轉帳,BATCH 保住了原子性——每個 batch 的寫入最終都會一起生效——但完全不隔離,1500 次讀取斷言裡有 231 次總和不對。更糟的是 read-modify-write:CQL 的文法根本不允許把 SELECT 放進 BATCH 裡,讀取這一步從一開始就不算在「交易」範圍內。同分區內先讀餘額、算出新值、再包成 batch 寫回去,1000 次讀取斷言裡 831 次錯,連測試結束後兩個帳戶該收斂到的 600 與 1400,最後也沒收斂到。作者特別補了一句:不論是時間戳打平還是跨分區失去隔離,都是 Cassandra 文件已經寫明的預期行為,不是意外撞見的 bug——這一點在讀到後面 Accord 那段時值得記住,兩者的性質並不一樣。

測試環境本身也有一個限制值得記住:叢集複寫係數是 3、節點數也是 3,等於每個節點都握有全部資料,這次測試其實沒有真的做到 sharding——要是加進更多節點、複寫係數維持 3,sharding 才會真正發生。跨分區的 BATCH 測試裡,測試結束後的最終檢查是過的,但作者也提醒這不是保證:如果最後兩個 batch 剛好也打平了時間戳,連最終持久化的結果都可能被打亂,不只是併發讀取瞬間看到的錯。

LWT:Paxos 解決了打平,過不了 partition 邊界

Lightweight Transaction(LWT)在同一年 9 月的 Cassandra 2.0 上市,用 Paxos 換來原子的 compare-and-swap——不能原子地先 SELECT 再 UPDATE,但至少可以原子地「條件成立才 UPDATE」。LWT 的時間戳來自 Paxos、同一個 partition 內唯一,BATCH 那種用戶端時間戳打平的問題在這裡不會發生。LWT 還有一個限制:條件不成立時你知道失敗了,但逾時時完全不知道寫入到底成不成功。所以測試裡得靠一個額外的 op 欄位撐住:每個寫入者有自己的 op 欄位,每次重複迴圈送出一個遞增的 op 值,逾時就重試同一個 LWT,直到讀回自己剛剛送出的那個 op 值為止——用重試迴圈把「逾時等於失敗還是成功」這個模糊地帶蓋掉。讀取這一側也不是預設受保護的:LWT 的 SELECT 預設不會走 Paxos,得由用戶端顯式送出 CONSISTENCY SERIAL 才會讓讀取一起經過 Paxos——這類讀取一樣可能因逾時失敗,所以測試把讀取斷言放寬成「總和是 2000,或者讀取本身報錯」兩種都算過關。加上這層保護之後,同分區的 read-modify-write 與盲寫兩種轉帳測試都是零失敗——1000 次與 1500 次讀取斷言全過。盲寫這一版甚至不需要真正的條件:兩個 UPDATE 用 IF EXISTS 這個最寬鬆的判斷式包進帶條件的 BATCH,純粹是為了讓寫入照樣走 Paxos、共用 LWT 的保護。問題出在跨 partition:LWT 一旦跨分區,Cassandra 連寫都不讓你寫,直接回一句「Batch with conditions cannot span multiple partitions」。兩個寫入者各嘗試 400 次,800 次全部被拒——不是資料算錯,是操作根本沒發生。

Accord:無 leader 共識,終於跨過那道邊界

跨 partition 這個缺口,是 Apple 與密西根大學的工程師做 Accord 的理由。Accord 是 EPaxos 啟發、無 leader 的共識協定,讓標記 transactional_mode='full' 的資料表所有讀寫都走 Accord 共識——Cassandra 終於有了真正的 ACID 交易,只是仍非互動式。跟 LWT 比,關鍵差異在「讀」跟「條件判斷」是不是同一步:LWT 先讀再比較,讀到的值常常已經過期,失敗了只能整個重來;Accord 把條件判斷跟讀取放在同一時刻評估,讀到的值不會過期,會失敗的原因基本只剩用戶端逾時或節點故障。這個能力要等 Cassandra 6 正式發布才會出現在穩定版——作者測試當下用的是 cassandra-6.0 這條尚未發布的分支,發布時間點連作者自己都只能說「也許是今年稍晚」。

語法上,Accord 也帶來 BATCH 與 LWT 都沒有的寫法:BEGIN TRANSACTION 開出一個區塊,LET 把一次 SELECT 的結果綁定成變數,IF...THEN...END IF 決定要不要真的寫入,COMMIT TRANSACTION 收尾。這段區塊要嘛整包成功、要嘛整包不生效,而且必須一次送完——中途不能像應用程式那樣等一個網路來回、再根據結果決定下一步該做什麼,這正是作者說 Accord 交易「非互動式」的意思:能一次表達的邏輯變複雜了,但仍然是一次送出、一次判定。作者也點出這次測試的邊界:Accord 幾乎不會因為逾時或節點故障而失敗,是因為整個叢集只是本機三個 process,沒有真正的網路延遲,是一個「happy localhost environment」;op 欄位這種 idempotency key 在正式環境仍然有用,只是在這個實驗室設定裡被拿掉了。

同分區的測試結果沒有懸念,Accord 跟 LWT 一樣乾淨:read-modify-write 轉帳測試 1000 次讀取斷言全過。真正的分水嶺在跨分區——同樣是先讀餘額、算新值、再寫回去,這次橫跨兩個不同的 customer partition,1000 次讀取斷言依然全過。這是 Cassandra 6 之前完全不存在的組合:BATCH 能跨分區但不隔離,LWT 隔離但跨不了分區,只有 Accord 兩件事同時做到。底下每個百分比多數是用「併發讀取斷言」當分母算出來的(LWT 跨分區那格是例外,800/800 算的是寫入被拒的次數),不是用測試結束後的最終檢查——同一輪測試,最終持久化的結果很可能是乾淨的,過程中的併發讀取卻已經記錄下上百次違反不變量的瞬間。把四種模式攤開來看,四個情境各自的失敗率長這樣:

切換情境比較四種模式的失敗率 · 4 種情境 × 4 種模式

四種模式・併發讀取的失敗率 0% 50% 100% plain BATCH LWT Accord

同分區、只做盲寫:plain 六成多的讀取撞到錯的總和,完全沒有隔離;BATCH 多數情況乾淨,但時間戳打平時會偶發出錯;LWT 全過;Accord 幾乎全過,只有一次觀察到瞬間讀到錯的總和,細節留到下一節談。

跨分區、只做盲寫:plain 與 Accord 都沒有在這個情境下被測試;BATCH 有原子性、沒有隔離,231 次讀到錯的總和;LWT 直接被 Cassandra 拒絕寫入——不是資料算錯,是整批操作根本沒發生。

同分區、要先讀再寫:plain 沒被測試;BATCH 的文法根本不允許把 SELECT 放進 batch,831 次讀到錯的總和,連測試結束後的最終餘額都不是預期的 600 與 1400;LWT 與 Accord 都乾淨過關。

跨分區、要先讀再寫:只有 Accord 撐得住,1000 次讀取斷言 0 次失敗;plain、BATCH 未在這個情境測試;LWT 因為文法限制,只要跨分區就會被拒絕,測這個組合沒有意義。

實線長條=實測百分比;虛線框=該情境未測試;LWT 的滿格代表寫入遭拒,不是資料算錯。

四種模式的能力邊界,攤成一張表:

四種模式在同一份帳戶 schema 上的能力邊界,數字與行為全部來自本文的測試結果;「未測試」代表原文沒有針對該格跑這個組合,不代表結果是零失敗。
模式 同分區隔離 跨分區原子性 跨分區隔離 Read-modify-write 底層機制
plain未測試未測試未測試無交易語意,逐語句各自的時間戳
BATCH有(時間戳打平會壞)有(最終套用)不支援(SELECT 不能放進 batch)用戶端時間戳,cell 層級 last-write-wins,打平時各自為政
LWT有(Paxos CAS)不支援(直接遭拒)不支援支援(限同分區)Paxos 產生的唯一時間戳,條件式 UPDATE;讀取需顯式送 CONSISTENCY SERIAL
Accord支援(含跨分區)EPaxos 啟發、無 leader 共識,transactional_mode='full';讀取與條件判斷同一時刻評估,不會讀到過期值

找 bug 的方法,跟 bug 本身一樣重要

整篇文章的測試工具是作者自己寫的開源併發測試器 Monastery,概念很直接:腳本裡宣告幾個具名的 client——兩個寫入者 w1、w2 各自重複執行一段轉帳語句,一個讀取者 r1 不停讀、不停斷言——Monastery 就照著腳本把這些 client 同時打到叢集上,每個語句後面用註解宣告「這句話跑完該符合什麼條件」。叢集是三節點的本機部署,複寫係數 3、每節點 4GB heap,cassandra.yaml 裡明確打開 accord.enabled。真正抓出問題的不是測試跑完後最終持久化的資料,是「併發讀者不斷讀、不斷斷言總和是 2000」這個持續施壓的第三個角色——如果只檢查最終狀態,plain 與 BATCH 那些隔離漏洞、以及底下這個 Accord 的瞬間異常,全都會被完全蓋過去,必須有人在寫入進行中不斷讀,才能把這種問題逼出來。整個情境的正確性依據是雙重的:過程中兩個帳戶加總永遠是 2000 這個不變量,加上 read-modify-write 版本有明確的收斂目標——兩個寫入者各跑 200 次一加一減,理論上該收斂到 600 與 1400。這個雙重標準讓測試除了抓「有沒有算錯」,也能抓「算錯了之後會不會自己修正回來」。

用同一套方法測 Accord 的同分區盲寫轉帳時,作者偶爾看到併發讀者讀到不等於 2000 的總和,只發生在同分區的情境。一次具體觀察到的讀數是 1851 與 1853,加總 3704,跟前面 plain、BATCH 那些「加總對不上」的失敗長得很像,差別是 Accord 理論上應該完全不會有這種情況。每次測試結束後,持久化的資料永遠是對的——問題只出現在寫入進行到一半的那個瞬間。作者當時的說法很保守:也許是 Cassandra 的 bug,也許是自己測試腳本寫錯,要等 ASF JIRA 上的社群意見。隔天的編輯附註給出了答案:Apple 的工程師 C. Scott Andreas 確認,這是一個真的 bug。作者自己對嚴重程度的校準很清楚:就算真的是 bug,也不算特別致命——分散式系統本來就會有 bug,何況 Cassandra 6 都還沒正式發布。作者在文末也坦言,這是他第一次真正接觸 Cassandra,整體印象正面:內建複寫、內建 sharding、加上 Accord 這個新穎的共識協定與 strictly serializable 保證,都讓他覺得這套系統這些年的演進值得關注。

把 BATCH 跟 Accord 的兩種「壞」放在一起看,性質完全不同:BATCH 的隔離缺口是文件寫明的已知行為,讀懂文件、避開跨分區與時間戳打平就能繞過;Accord 的這個瞬間異常,是連作者自己都要等社群驗證才敢確認的未知數,截至發文,Cassandra 6 也還沒有正式發布。這是評估要不要現在就依賴 Accord 時,兩種完全不同等級的風險——一種是讀文件就能避開的已知限制,另一種是連原作者都得等上游社群確認的未知數。

拖曳看併發讀者在不同瞬間讀到什麼 · 1 個真實觀測到的異常瞬間

blind-accord-same · 併發讀者在測試進行中不斷讀取 寫入開始 寫入結束

此刻併發讀者讀到 customer=1 → 1851、customer=2 → 1853,總和 3704,不等於 2000——這是作者實際觀測到的異常瞬間,已由 Apple 工程師 C. Scott Andreas 確認是真的 bug。每次測試結束後,持久化的資料仍然永遠正確;問題只出現在寫入進行到一半的這個瞬間。

這次解鎖的能力:跨分區、讀取後再寫入的 ACID 交易,是 Cassandra 從 2013 年算起,第一次真正做到;但 Accord 距離完美還有距離——一個已被 Apple 工程師證實存在的併發隔離 bug,還沒有下文。