先協商,再取鎖
Calvin 的核心動作,是把所有跨機器的協商都放在交易邊界之外完成,也就是在取鎖與開始執行之前。一旦各機器對於要執行哪些交易、以什麼順序執行達成共識,這個計畫就具有約束力:節點故障之類的非決定性問題不能造成中止,因為失敗節點可由執行同一份計畫的複本頂替,或事後重播計畫歷史。協商因此不再是持鎖期間必須繳的稅,而變成一個前置步驟,其延遲永遠不會進入 contention footprint。這正是 Calvin 能跨洲付出 Paxos 成本卻不損失任何交易吞吐量的原因。
先以決定性方式排定交易順序再執行,分割式資料庫就能完全捨棄兩階段提交。
分散式交易之所以昂貴,主因不在 CPU 或頻寬,而在於兩階段提交逼迫每個參與節點在數個網路來回期間持續握著鎖,因此少數幾筆熱門紀錄就足以扼住整個叢集的吞吐量。Calvin 的作法是把所有協商都移到取鎖之前:分散式的 sequencing 層以 10 毫秒為一個 epoch 收集交易請求、複寫這些輸入(非同步或透過 Paxos),並產生一個所有複本都遵循的全域序列。接著由分割式的決定性鎖管理器嚴格依該順序發放鎖,工作執行緒以五個階段執行、單向轉送遠端讀取結果,於是節點當機這類非決定性事件永遠不會迫使交易中止,也就不需要任何提交協定。由於只記錄並重播輸入,實體 REDO 日誌完全消失,checkpoint 也能非同步地對齊全域順序中的一個虛擬一致點來擷取。在 100 台 EC2 節點上,Calvin 跑出接近每秒五十萬筆的 TPC-C New Order 交易,與當時 Oracle 在高階硬體上創下的 504,161 筆世界紀錄相當。
到了 2012 年,主流的擴展答案其實是放棄交易:Dynamo、MongoDB、CouchDB 與 Cassandra 完全不提供交易,Bigtable 只支援單列更新,Azure、Megastore 與 Oracle NoSQL Database 則把交易限制在資料庫的一小塊預先指定範圍內。少數保留完整 ACID 的系統如 VoltDB,作法是一旦交易跨越分割就停止或限制並行執行。真正需要跨分割原子性的人,只能回到 1980 年代 System R* 的設計:提交時所有參與者一邊握著全部的鎖、一邊跑兩階段提交。另一條並行的趨勢則以 CAP 為由弱化複寫一致性,不過這股潮流當時已在反轉,Megastore 與 IBM Spinnaker 都改採 Paxos 同步複寫。Calvin 的作者先前也做過決定性資料庫的原型,但那些原型仰賴單一節點的 sequencer,而且假設整個資料庫都放得進主記憶體。
Calvin 的核心動作,是把所有跨機器的協商都放在交易邊界之外完成,也就是在取鎖與開始執行之前。一旦各機器對於要執行哪些交易、以什麼順序執行達成共識,這個計畫就具有約束力:節點故障之類的非決定性問題不能造成中止,因為失敗節點可由執行同一份計畫的複本頂替,或事後重播計畫歷史。協商因此不再是持鎖期間必須繳的稅,而變成一個前置步驟,其延遲永遠不會進入 contention footprint。這正是 Calvin 能跨洲付出 Paxos 成本卻不損失任何交易吞吐量的原因。
兩階段提交存在的主要理由,是偵測某個參與者無法在本地提交,而原因可分為硬體故障這類非決定性事件,以及庫存歸零就中止這類決定性邏輯。如果有一個活躍複本正平行執行完全相同的交易序列,其他節點就不必等待當機節點復原,非決定性故障也就不再構成中止的理由。決定性中止同樣不需要完整的協商協定:每個節點只需等待所有可能決定性中止的節點送來一則單向訊息,收到後即可提交。拿掉這些來回之後,每筆分散式交易的 contention footprint 縮到大約只剩交易邏輯真正執行的時間。
先前主張決定性執行的作法,多半是在每個節點以單一執行緒序列化執行交易,只要有交易卡住,吞吐量就白白流失。Calvin 改用一個行為近似 strict two-phase locking 的鎖管理器,再加上兩條把衝突解決綁死在 sequencer 順序上的不變條件。由於互相衝突的交易只能依既定順序取得鎖,不衝突的交易則自由前進,執行結果在邏輯上等價於全域序列順序,同時仍有大量交易在工作執行緒池中並行執行。並行度被保留下來,被決定性化的只有衝突的解決方式。
因為排程是決定性的,兩個複本吃進相同的輸入序列就會走過相同的資料庫狀態序列,所以 Calvin 複寫的是成批的交易請求,而不是它們產生的寫入。這讓強一致複寫便宜得多:Megastore 與 Spinnaker 必須用 Paxos 複寫交易效果,Calvin 只需要對輸入達成 Paxos 共識。也因此,複寫模式的選擇純粹是延遲上的取捨——非同步 master-slave、資料中心內的 Paxos、或跨洲的 Paxos——對吞吐量完全沒有影響。而這些主動複寫的節點同時就是讓無中止執行成為可能的故障轉移機制。
Calvin 把系統拆成三層:sequencing 層決定全域輸入順序並負責複寫與日誌,scheduling 層負責鎖定與執行的調度,storage 層則在單純的 CRUD 介面後面掌管實體資料佈局。三層都在 shared-nothing 節點上水平分割,任何提供類似 CRUD 介面的儲存引擎都能接上來。這種解耦的代價,是日誌與並行控制都必須是純邏輯的,只能引用紀錄鍵值,不能碰頁面或索引結構。而決定性正是讓這個代價可以承受的原因,因為記錄邏輯輸入就足以完全決定資料庫狀態。
決定性通常對磁碟型資料庫不利,因為卡住的交易會擋住順序在它後面的所有交易,而傳統系統可以繞過這個停頓重新排序。Calvin 貫徹自己的設計原則來化解:sequencer 一旦收到可能造成磁碟停頓的請求,就在轉交給 scheduler 之前插入一段人工延遲,同時要求相關儲存元件先把紀錄預熱進記憶體。只要延遲蓋得住讀取時間,交易真正執行時就只會碰到記憶體中的資料,端到端延遲不會比在執行期做 I/O 更差,但磁碟時間完全不落在 contention footprint 裡。同樣的原則也驅動了 OLLP:在交易進入序列之前,先把未知的讀寫集合解出來。
sequencing 層分散在所有複本上,並在每個複本內再分割到每台機器,藉此擺脫早期原型那個單一節點 echo server。時間被切成 10 毫秒的 epoch;在一個 epoch 內,每台機器的 sequencer 元件累積用戶端的交易請求,到了 epoch 邊界就把它們編成一個批次,接著進行複寫。複寫成功後,sequencer 會向同一複本內每個分割的 scheduler 送出訊息,內容包含該 sequencer 的節點 ID、epoch 編號(全系統每 10 毫秒同步遞增),以及該收件者需要參與的那些交易輸入。每個 scheduler 再以決定性的 round-robin 方式交錯所有 sequencer 在該 epoch 的批次,拼出自己對全域順序的視圖。
節點被組織成 replication group,每個群組包含某一分割的所有複本。非同步模式下有一個複本被指定為 master,請求直接送到它的 sequencer,各 master 編好批次後再轉發給群組內的 slave sequencer;延遲極低,但故障轉移相當複雜,因為存活節點必須就失效 sequencer 送出的最後一個有效批次、以及該批次確切包含哪些交易達成一致,而每個 scheduler 只看過屬於自己的部分視圖。同步模式下,replication group 內所有 sequencer 以 Paxos(實作在 ZooKeeper 之上)對每個 epoch 的合併批次達成共識。ZooKeeper 並非最有效率的 Paxos 實作,但由於這個步驟發生在取鎖之前,完全不會延長 contention footprint,交易吞吐量因此完全不受複寫模式影響。
鎖管理器分割在整個 scheduling 層上,每個節點的 scheduler 只負責鎖定存放在該節點 storage 元件中的紀錄,即使交易同時會存取遠端資料也一樣。它的行為近似 strict two-phase locking,但多了兩條不變條件:若交易 A 與 B 都要對本地紀錄 R 取得互斥鎖,而 A 在 sequencer 給定的順序中排在 B 之前,則 A 必須先提出鎖請求;而且鎖必須嚴格依請求順序發放,因此 B 要等到 A 取得鎖、執行完畢並釋放後才能拿到。Calvin 以單一執行緒序列化所有鎖請求來落實第一條:該執行緒掃描序列順序,逐筆為交易一次請求其生命週期中會用到的所有鎖。這也正是為什麼所有交易都必須事先宣告完整的讀寫集合。
交易取得全部的鎖之後就交給工作執行緒,執行分為五個階段。第一階段分析讀寫集合,找出哪些元素在本地,以及哪些節點是 active participant(存有部分寫入集合)或 passive participant(只存有讀取集合元素)。第二階段執行本地讀取;第三階段把本地讀取結果轉送給每個 active participant 上的對應執行緒,passive participant 做完這一步就結束,永遠不必執行交易程式碼。第四階段由 active participant 收齊遠端讀取結果;第五階段執行交易邏輯並套用本地寫入,非本地寫入可以直接忽略,因為對應節點的執行緒會把它們視為本地寫入來套用。只要各參與者大致同時開始,所有讀取與所有結果傳遞都平行發生,執行期間沒有任何工作執行緒需要向別的節點索取資料。
整條流程中沒有任何類似 prepare 階段的東西。非決定性故障不會導致中止,因為有主動複寫的節點正在執行同一份計畫,其他節點可以改向它取得所需讀取,同時等待失效節點復原;交易憑著複本的完成即可提交。決定性中止(例如庫存會變成負數時就拒絕訂單的邏輯)則透過另一種方式處理:每個節點只需等待所有可能決定性中止的節點各送來一則單向訊息,收齊後即提交。當機機器的復原,就是還原到最近一次 checkpoint 再重播之後的交易輸入,過程中完全不涉及實體 REDO 日誌。
有些交易必須先讀資料才能知道自己的讀寫集合,論文稱之為 dependent transaction(相依交易),這類交易無法被原生支援,因為鎖必須在執行前就請求。Optimistic Lock Location Prediction 的作法,是在這種交易之前先跑一個廉價、低隔離、不複寫的唯讀偵察查詢(reconnaissance query),把判斷完整讀寫集合所需的讀取先做掉。真正的交易再帶著這組預測出來的集合送進全域序列;執行時會重新檢查讀取結果,若偵察到的集合已失效,就以決定性的方式重啟該交易。實務上重啟預期相當罕見,因為相依交易通常依賴次要索引,而次要索引修改成本高,一般只建在變動不頻繁的欄位上;TPC-C 的 Payment 交易正是這種形態,而由於該基準測試從不修改它所依賴的索引,Payment 交易永遠不需要重啟。
由於只記錄輸入,Calvin 需要定期擷取整個資料庫的 checkpoint 以限制重播長度,並提供三種模式。最單純的同步模式會凍結整個複本並產生完整快照,客戶端看不到中斷,但該複本會落後許多且不易追上。第二種模式改編自 Cao 等人的 Zig-Zag 演算法:每筆紀錄保留兩份副本 AS[K]0 與 AS[K]1,另加 MR[K] 與 MW[K] 兩個位元,分別指定讀取時該用哪個版本、更新時該覆寫哪個版本;Calvin 的變體利用全域序列定義一個虛擬一致點(全域順序中預先指定的位置),因而不需要像原始 Zig-Zag 那樣讓資料庫靜止下來。此後每筆紀錄同時存在只能被虛擬一致點之前的交易更新的 before 版本,以及由之後交易寫入的 after 版本;當所有較早的交易都執行完畢,before 版本即成為不可變,非同步執行緒便可將其寫出,完成後再回收重複版本。第三種模式適用於儲存層支援完整多版本的情況,此時 checkpoint 就等同一次 SELECT * 查詢,只是把結果寫進磁碟檔案而不是回傳給客戶端。
Calvin 把決定性執行從一個奇想變成可信的架構,其「先定序、再執行」的流程也成為一整條系統脈絡的參考設計。FaunaDB 直接以 Calvin 那套「執行前先對全域輸入順序達成共識」的模型打造分散式交易引擎,CalvinFS 則把 sequencer/scheduler 的切分延伸到分散式檔案系統的 metadata 上。Yale 團隊的後續研究沿著 Calvin 留下的缺口推進:Bohm 與 PWV 等決定性多版本引擎針對執行順序的耦合下手,Aria 後來則拿掉了必須事先知道讀寫集合這個 Calvin 最尖銳的限制。與 Spanner 同年問世的 Calvin,也定義了分散式 SQL 領域長久的架構論辯——是在執行期付出 TrueTime 加 Paxos 加兩階段提交的代價,還是只對輸入日誌付一次共識成本、之後決定性地執行——這場辯論 Abadi 之後又持續書寫了多年。更廣義地說,Calvin 是把 state machine replication 套用到交易上最清楚的資料庫版本陳述,而這正是所有以有序、可重播事件日誌為核心的 log-first 設計背後的同一個模式。
“when multiple machines need to agree on how to handle a particular transaction, they do it outside of transactional boundaries—that is, before they acquire locks and begin executing the transaction.”
“Since all Calvin nodes reach an agreement regarding what transactions to attempt and in what order, it is able to completely eschew distributed commit protocols, reducing the contention footprints of distributed transactions, thereby allowing throughput to scale out nearly linearly despite the presence of multipartition transactions.”
“move as much as possible of the heavy lifting to earlier in the transaction processing pipeline, before locks are acquired.”