複寫事實,而不是覆蓋狀態:hoshi-data 的 Commit DAG
從不可變提交、因果關係與確定性重播,談 hoshi-data 如何處理多節點資料,以及為什麼收斂不等於定案
兩個節點暫時無法通訊,卻都收到了寫入。連線恢復後,我們究竟應該保留哪一份資料?
若交換的只是「現在的值」,這個問題很快就會變成選邊:哪一份較新、哪個節點說了算,以及另一邊的修改是否必須丟棄。但「各自做了什麼」與「最後應該看到什麼」,其實是兩個不同的問題。
hoshi-data 的操作日誌從這個區分出發:節點複寫不可變的事實,再以明確的規則推導目前的狀態。 承載這些事實的結構,是 Commit DAG——一張由提交及其因果關係組成的有向無環圖。
本文記錄截至 2026 年 9 月 12 日的工程設計。所述保證只適用於已採用操作日誌、採用相應政策的資料集;不是整個服務的所有資料都已具有相同的複寫能力,也不是一份效能或形式化證明報告。
1. 不是把 Git 當資料庫
hoshi-data 是 Hoshivel 的內部資料服務。應用透過具語意的操作介面讀寫資料,持久化與查詢仍由 PostgreSQL 承擔;Commit DAG 並沒有取代資料庫的交易、索引或查詢能力。
改變的是複寫的基本單位。對採用日誌的資料集,我們不只保留目前的資料列,也保留導致狀態改變的操作。每次提交描述一組一起被接受的操作,以及作者當時已經看見的歷史。供應用查詢的資料,則是由這些提交推導出的投影,也就是 materialized state。
可以把它理解成兩個層次:
不可變提交集合 + 版本化規則
|
v
確定性重播
|
v
可查詢的資料投影
這與 Git 的相似之處,在於內容定址、不可變歷史與父提交關係,而不是使用 Git 指令儲存業務資料。它也不是把任意 SQL 包進提交後自動取得多主寫入能力:每種操作都必須先有可以重播、可以解釋衝突的語意。
2. 一個 commit 記住什麼
在概念上,一個提交需要回答:誰產生它、它在該來源歷史中的位置、它依賴哪些已知提交、它包含哪些操作,以及那些操作應按哪一版規則解讀。
hoshi-data 為此儲存來源序號、父提交集合、混合邏輯時鐘(HLC)、結構與政策版本,以及有順序的操作內容。這裡描述的是資料模型,不是公開線路格式。
提交的識別符由其規範化內容計算。同一份提交沿不同路徑抵達,仍會得到相同的識別符,因此可以去重;更改有意義的內容,就不再是同一份提交。父提交是集合,編碼順序必須固定;操作卻是序列,交換兩個操作的位置可能改變語意,不能任意排序。
「不可變」也有很直接的含義:不重寫舊提交的父關係,不把另一邊的歷史 rebase 成自己喜歡的形狀。需要修正先前操作時,追加新的操作;舊事實留在歷史裡,新的狀態由完整歷史重新解釋。
不過,內容定址保證的不是業務正確性。同一個請求若被做成兩份不同提交,不能單靠 hash 判斷它們是否應算同一次業務操作;那仍需要操作層的冪等設計。
3. DAG 保留的是因果,不是抵達時間
假設 A 是共同歷史,兩個節點各自在看過 A 之後產生 B 與 C。稍後,某個節點看過 B、C,再產生 D:
B
/ \
A --- --- D
\ /
C
B.parents = {A}
C.parents = {A}
D.parents = {B, C}
圖中由左向右表示「先前事實到後續操作」;實際父引用則由子提交指回父提交。
B 與 C 沒有互相依賴,所以它們是並行操作。並行不代表發生在完全相同的物理時間,也不必然構成衝突;它只表示這份歷史沒有記錄其中一個先看見另一個。D 則必須在兩者之後解讀。
因此,不能把網路抵達順序當成操作順序。某個節點先收到 C,另一個先收到 B,並不應導致永久不同的投影。hoshi-data 先做因果拓撲排序,再對當下可處理的並行提交,以 HLC、來源識別與提交識別符作確定性的決勝排序。實作使用帶優先佇列的拓撲排序,而不是依插入順序取出的普通佇列。
HLC 在這裡參與可重現的排序,不證明真實世界中哪個操作比較晚,也不取代父提交所記錄的因果關係。
尤其重要的是:這個全序是各節點對「已知提交集合」算出的順序,不是所有節點事先以共識承諾、永遠不再變動的全域日誌位置。晚到的並行提交仍可能改變尚未定案的結果。
4. 同樣的事實,必須得到同樣的狀態
DAG 本身不會替任意程式提供收斂性。真正需要守住的是 reducer,也就是把操作逐一解讀成狀態的規則。
較完整的表達是:
S = Reduce(B, CanonicalOrder(C), R)
B:相同且有效的起始狀態或 checkpoint
C:相同、有效且依賴完整的提交集合
R:具有相同語意的版本化規則
S:推導出的投影
只有 B、C、R 都相同,才能要求兩個節點得到相同的 S。缺少父提交或來源序號有缺口時,不能假裝資訊完整;不知道某個政策版本時,也不能改用「最接近」的版本繼續執行。
這也限制了 reducer 可以讀取的資訊。它不能因本機現在幾點、網路先送來哪一包、資料庫剛好回傳哪個行序,或程式語言的 map 迭代順序,而產生不同答案。操作的數值與編碼,也必須避免不同表示方式帶來的歧義。
版本欄位是這個契約的一部分,但記下版本號,不等於已經解決任意版本混跑或自動升級。歷史規則需要被保留;無法解讀的歷史必須拒絕處理。跨版本演進仍然需要相容性設計與驗證,不能由一個欄位代替。
5. 同步的工作是找出缺少的事實
節點同步不只是推送最新值,也要辨認對方缺了哪些提交,並在通訊恢復後反覆補齊。這類反覆比對與修補的過程,通常稱為 anti-entropy。
來源序號在這裡尤其有用。若某個來源的第 4 與第 6 份提交已抵達,第 5 份尚未抵達,連續進度仍只能到 4,不能用「見過的最大序號是 6」宣稱同步完成。父提交引用則提供另一種依賴檢查:即使收到子提交,也必須先取得它需要的歷史,才能安全投影。
這裡有兩種容易混淆的 frontier:父提交集合描述作者已看見的 DAG 前沿;同步進度則按來源記錄最高連續序號。前者回答因果關係,後者幫助找出缺口,不能只拿一個最大時間戳同時代替兩者。
重複投遞可以由提交識別符去重,缺少依賴可以等待補齊;但來源重用序號、內容不合法或來源驗證失敗,不是普通的並行寫入,不能「合併一下」就算了。
即使有內容與來源驗證,也不能因此宣稱具備完整的 Byzantine 容錯。辨識竄改、接受哪些來源的事實,以及容忍任意惡意節點,是不同層次的問題。
6. 需要多少協調,由操作語意決定
最容易誤解這個模型的地方,是把「所有節點最後會得到同一個答案」當成「那個答案一定合法」。
假設只有一個名額。兩個節點各自把它承諾給不同的人,最後再確定性地選出一個贏家,確實可以收斂;但先前已經對兩個人作出的承諾,不會因此變成正確。這是應用不變式的問題,不是換一種排序方式就能解決。
hoshi-data 因此區分不同的操作類別:
- 可交換或單調的操作:在其政策保證下,新的並行事實不會推翻已確認的效果,不必為每次寫入先取得全域順序。
- 可確定性解衝突的操作:可以計算目前勝出的值,但結果仍可能是暫定的;「能選出贏家」不代表「已不可推翻」。
- 受額度或排他條件約束的操作:使用預先分配的權利(escrow),或取得所需的協調證據,而不是無條件在各處接受寫入。
例如,總量為 10 的虛構資源,可以先把 6 與 4 的使用權分配給兩個節點。各自只消耗持有的權利,即使暫時失聯,也不會合計消耗超過 10。代價是持有 4 的節點不能自行批准消耗 5,即使它猜測另一邊還有剩餘。
權利轉移也不是在兩端各加減一次便算完成。模型把轉移中的權利放在接收方的待領取區,接收方領取後才能使用,維持:
可用權利總和 + 轉移中權利總和 + 已消耗量 = 總額度
無法用這種方式拆分的排他條件,仍需協調。取得某個鍵所需的 quorum 或證據,不代表整個資料庫、所有跨資料集操作都自動具有全域可序列化保證。
因此,我們追求的不是「消滅協調」,而是把協調留在確實需要它的語意邊界。目前這些邊界由明確的操作政策實作,不是自動分析任意業務程式得出的結論。
7. 已寫入、已收斂、已定案,是三件事
一份提交已在本機持久化,不代表所有節點都已持有它;所有已知提交算出了相同的投影,也不代表未見過的並行提交再也不會改變結果。
hoshi-data 因此把操作結果的 finality 納入模型。結果可能已定案(finalized)、仍屬暫定(provisional),或已被後續解讀取代(superseded)。其中「定案」描述的是相應政策下的結果保證,不是宣告每個副本或異地備份都已完成持久化。
這個區分在外部副作用上尤其重要。投影可以重建,已寄出的郵件或已交付的資源卻不能靠重播資料庫自動收回。Outbox 必須知道事件的定案狀態,接收端也必須明確決定能否接受暫定結果;不能把一次本地 append 的成功,直接當成執行不可逆副作用的充分條件。
同樣地,提交去重不等於端到端 exactly-once。跨服務重試與 outbox 投遞仍需要各自的冪等處理;整條鏈路的保證,不能由其中一段的去重能力代為宣告。
8. 投影可以重建,歷史卻不能隨意丟棄
操作日誌讓目前狀態不再是唯一能解釋資料的地方。當投影需要核對或重建時,可以從相同的事實與規則重新推導,而不是直接挑某個節點當下的資料列作為答案。
不過,把這件事接到資料庫仍需交易邊界。相關提交、投影及冪等紀錄,必須按寫入路徑放在相應的本地交易中;一份提交的操作不能任意跨越原本不具原子性的資料庫邊界。這不是通用的跨資料庫 two-phase commit。
Checkpoint 則儲存某個前沿的投影、連續進度與版本資訊,讓已覆蓋的歷史有可核對的摘要。有 checkpoint,仍不等於可以安全刪掉所有舊提交。 摘要相符只回答內容一致性的一部分問題,不能自行證明來源可信、依賴完整,或某個長期離線節點再也不需要那些歷史。
目前的新成員加入流程,也沒有把一份外來快照直接當成本機真相。它以 checkpoint 為核對目標,補齊歷史,自行重播並比對目標投影,通過後才進入可讀狀態;允許寫入是另一個明確步驟。這與「取得快照後即可略過全部歷史」是不同的能力。
安全裁剪歷史、直接以 checkpoint 啟動,以及保留可驗證的因果依賴,是需要分別完成的工程問題。
9. 我們要驗證什麼,又沒有承諾什麼
這套設計不能只用「正常情況下寫入成功」驗收。模型層需要直接檢查:對同一個有效提交集合改變投遞順序、加入重複投遞,或在依賴補齊後重播,是否仍得到相同投影;對缺口、未知政策與不合法來源,是否確實拒絕猜測。
接到持久化與網路後,還要驗證分區、程序中止、重啟、新成員追趕與外部副作用。純 reducer 的性質成立,不代表交易接線、同步協定與還原流程就不可能出錯。
這些也界定了本文的承諾:收斂需要相同且有效的事實、相同的規則,以及缺失資料最終可取得。所有副本都遺失的提交,不會由 DAG 憑空恢復;複寫仍不能取代獨立備份。跨節點讀取的因果要求也需要額外的讀取條件,不能因為存在 DAG 就假定所有讀取立即一致。
我們沒有在本文宣稱比共識複寫更快,也沒有給出經形式化證明的整體正確性。延遲、吞吐量、歷史增長、重播成本與恢復時間,都需要在一致性保證可比較的工作負載下實測。
10. 從工程系統走向研究問題
內容定址、因果 DAG 與收斂資料型別,都有重要的既有研究。Merkle-CRDTs 已討論 Merkle-DAG 作為 CRDT 的傳輸與持久化層;Coordination Avoidance in Database Systems 用 invariant confluence 分析哪些應用不變式可以在無協調的執行與合併下維持;Highly Available Transactions 則釐清高可用性與不同交易保證之間的邊界。
hoshi-data 的工程價值,不在宣稱發明了 DAG,而在把操作語意、定案狀態、持久化與故障恢復放進同一套可檢查的契約。借用相近的資料結構,也不代表自動繼承那些研究的全部定理。
若要把這條路推進為研究成果,我們更關心兩類具體問題。第一,規則與結構持續演進時,如何在明確的相容性條件下維持歷史語意,讓不相容的節點安全拒絕,而不是悄悄重解讀資料。第二,裁剪歷史之後,如何仍讓晚加入或長期離線的節點驗證依賴、重建結果,並保留必要的稽核能力。
這些是研究方向,不是已完成的功能宣告。要形成論文貢獻,還需要清楚的問題模型、相對既有工作的差異、正確性論證,以及可重現的實驗。比較也必須包含相同保證下的基線,量測同步流量、重播與儲存成本、故障後收斂時間及必要協調的比例,而不只挑一張吞吐量圖。
對現在的 hoshi-data 而言,核心原則仍然很簡單:不要讓「這份資料現在長什麼樣子」,成為我們唯一知道的事。保留它如何形成的事實,才有機會在並行、故障與演進之後,重新算出可以解釋的答案。
延伸閱讀
- Héctor Sanjuán 等,Merkle-CRDTs: Merkle-DAGs meet CRDTs,2020。
- Peter Bailis 等,Coordination Avoidance in Database Systems (Extended Version),2014。
- Peter Bailis 等,Highly Available Transactions: Virtues and Limitations (Extended Version),2013。