資料庫

用於冪等更新插入的確定性 ULID

當資料庫禁止自動寫入重試時,每次寫入操作本身都必須是冪等的。一個由內容衍生的 ID 可免費達成此一要求。以下是其推導過程,以及那個確保其安全的唯一不變量。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

當資料庫禁止自動寫入重試時,你送出的每一次寫入本身都必須是冪等的 (idempotent),因為沒有其他東西會為你進行重複資料刪除。這篇文章要談論的技術是一種確定性的 ULID (deterministic ULID):一個從識別文件的內容中衍生出來的文件 ID,而非來自隨機來源。採用它的成本幾乎為零,它透過其建構方式讓重試變得安全,而且它帶有一條你絕對不能打破的規則。本文將完整說明其推導過程、它所能預防的失敗、維持其正確性的不變量 (invariant),以及它不適用的情況。

ID 推導管線的圖表

為何在手動重試下,隨機 ID 是危險的

ULID 是一種 128 位元的識別碼:高位元是時間戳記,低位元則是隨機數。時間戳記前綴意味著 ULID 大致上會依建立時間排序,這對於主鍵來說確實很有用。隨機後綴使其每一個都獨一無二,而這也正是當你手動重試寫入時,使其變得危險的原因。

想像一個使用全新隨機 ID 的插入操作。寫入請求離開了你的服務,資料庫套用了它,然後確認訊息在回傳途中遺失了。你的程式碼正確地判斷出它不知道寫入是否成功,所以它進行了重試。這次重試帶了一個新的隨機 ID,因為你產生了一個新的 ULID。現在有兩個文件描述著同一個邏輯上的事物,卻有著兩個不同的 ID,而系統中沒有任何東西可以分辨它們是重複的。你每一步都做對了,但仍然損壞了你的資料。

在實際的 MongoDB 中,可重試寫入 (retryable writes) 掩蓋了這個問題。驅動程式會附加一個交易編號,伺服器會進行重複資料刪除,你的重試是安全的。但有些引擎不支援該機制,並要求設定 retryWrites=false,此時安全網就消失了,責任就落在你身上。當伺服器為你進行重複資料刪除時,隨機 ID 是沒問題的,但當你變成重試的那一方時,它就成了一個隱患。

衍生 id 完全消除了歧義

解決方法是停止從隨機性產生 id,並開始從識別文件的欄位中衍生它。如果同一個邏輯文件總是產生相同的 id,那麼重試的寫入會指向同一個 id,而 upsert 會將其變成無害的覆寫,而不是產生第二列資料。

對於一個多語言部落格,一篇文章由其 slug、語言和發布時間唯一確定。所以我們就從這些欄位來建立 ULID:

// PostID derives a stable ULID from the fields that identify a post.
// The same (slug, lang, publishedAt) always yields the same id, so an
// upsert is a no-op on every retry instead of a duplicate insert.
func PostID(slug, lang string, publishedAt int64) string {
	sum := sha256.Sum256([]byte(slug + ":" + lang))
	return buildULID(uint64(publishedAt), sum[:10])
}

這裡有兩個關鍵的設計選擇。ULID 的時間戳部分來自 publishedAt,而不是寫入時的系統時鐘時間,所以如果你明天重新擷取同一篇文章,id 也不會改變。隨機部分被替換為識別欄位的 SHA-256 雜湊值的前十個位元組,因此它是確定性的,但仍然將 id 分散到整個鍵空間中,以避免在單一索引範圍上產生熱點。結果是一個看起來和排序方式都像普通 ULID 的 id,仍然帶有有意義的時間戳前綴,但可以僅從內容重現。

有了這個 id,寫入操作就變成了一個以它為鍵的 upsert:

// First call inserts, every retry overwrites identical data.
// Row count never grows, no server-side dedup required.
_, err := coll.UpdateByID(ctx, PostID(slug, lang, publishedAt),
	bson.M{"$set": doc},
	options.Update().SetUpsert(true),
)

執行一次,它會插入資料。執行一百次,資料庫中仍然只會有一份文件,因為第一次之後的每次呼叫都是將相同的欄位寫入相同的 id。這個操作是冪等的,不需要協調、交易或去重複資料表。

冪等性是整個管線的屬性,而非單一呼叫

人們很容易在更新插入 (upsert) 後就止步並宣告成功,但冪等性必須貫穿頭尾,否則就根本不成立。如果 ID 是穩定的,但文件主體包含一個新產生的時間戳記或隨機欄位,那麼兩次執行會為同一個 ID 產生兩個不同的主體,雖然你不會得到重複的資料列,但你會得到不必要的寫入,且文件會在每次管線執行時都發生變更。這會擾動你的變更摘要 (change feed)、使快取失效,並且讓你無法分辨真實的編輯與無操作 (no-op)。

因此,這項準則也延伸到了酬載 (payload)。文件中任何從識別內容衍生出來的東西,其本身都應該是確定性的 (deterministic)。來源的內容雜湊 (content hash) 就是一個好例子:從輸入計算它,將其儲存在文件上,並在儲存的雜湊值與傳入的雜湊值相符時,完全跳過寫入。現在,重新執行一筆未變更的貼文不僅僅是一次安全的覆寫,而是完全不寫入。

// Skip the write when nothing changed. Idempotent and cheap.
if existing != nil && existing.SourceHash == incoming.SourceHash {
	return nil // unchanged, nothing to do
}

保持此作法正確的心智模型是:相同的輸入、相同的 ID、相同的主體,並且在輸入未變更時,理想上不進行任何寫入。其中的每一層都強化了其上一層。

你必須持守的一項不變性

id 是從 publishedAt 衍生而來,這表示 publishedAt 在文件的生命週期內必須是不可變的。這是整個機制所依賴的唯一規則,而破壞它所造成的後果相當隱晦,值得特別提出警告。

假設一篇文章已發布,其 id 是根據其發布日期計算出來的,並且它存在於資料庫中。之後,有人在來源中編輯了發布日期。下一次的擷取會計算出一個新的 id,因為衍生的輸入改變了。使用新 id 的 upsert 操作會插入一份全新的文件,而舊 id 下的舊文件現在成了一個孤兒,再也不會有任何程式碼去更新或刪除它。你並未覆寫該篇文章。你將它分岔了。

應該明確地防範這種情況,而不是相信作者會記得。在擷取時,透過其識別欄位查詢現有文件,重新計算 id,如果儲存的 id 和新計算出的 id 不一致,就停止寫入並發出明確的錯誤訊息。

// If publishedAt changed, the derived id changed, and a blind upsert would
// orphan the old document. Refuse to proceed.
if existing != nil && existing.ID != PostID(slug, lang, publishedAt) {
	return fmt.Errorf("publishedAt is immutable for %s/%s: changing it orphans the old document", slug, lang)
}

編輯內文永遠沒問題,因為內文不是 id 的一部分。只有那些用於衍生的欄位是凍結的。在你的寫作文件中明確標示這個界線,讓這條規則成為一個已知的限制,而不是人們偶然發現的陷阱。

當決定性 ID 是錯誤的選擇時

這項技術並非放諸四海皆準,在不適合之處使用它會產生其自身的問題。當文件具有自然身份,即一組能真正定義其身份的欄位時,這項技術便能奏效。一篇部落格文章就有這樣的身份:slug、language、date。一個使用者帳號也有:一個 email 或外部 id。

當文件沒有自然鍵,且每一個都是一個獨立的事件時,這項技術便行不通。一個僅供附加的獨立事件日誌、一個點擊流、一個工作佇列(其中兩個看起來相同的條目實際上是兩個不同的東西),所有這些都需要唯一的隨機 ID,正是因為你不希望第二個相同的事件覆蓋第一個。對於這些情況,請使用一個正常的隨機 ULID,並且,如果你需要重試安全性,請從請求中攜帶的冪等性金鑰取得,而不是將其內建於主 ID 中。

測試方法只有一個問題:如果兩次寫入帶有相同的內容,第二次應該取代第一次,還是與第一次並存?如果是取代,就衍生出 ID。如果是並存,就保持隨機。針對每個集合回答這個問題,而不是為整個系統只回答一次。

這樣做的好處

付出的努力趨近於零,只需要一個雜湊和一個輔助函式,而回報是有一整類的分散式系統錯誤根本不會發生。重試是安全的,因為它們會收斂到相同的 id。重新擷取未變更的內容是一個成本低廉的無操作 (no-op),因為內容主體和雜湊是穩定的。而且,資料庫禁止自動寫入重試的規定,不再是你需要對抗的限制,而變成一個你已經滿足的約束條件,因為你的寫入從一開始就是冪等的。資料庫的嚴格性與你的 id 機制最終會指向同一個方向。