MongoDB 多文件事務:何時不該使用
多文件交易看似是安全的預設選項,但提交模糊性與 PSA 可用性陷阱使其成本高昂。以下說明何時該跳過它們。
MongoDB 在 4.0 版新增了多文件交易功能,而一旦有了這項功能,大多數團隊便會像在關聯式資料庫中一樣,立即採用它們。這種直覺通常是錯的。交易本身並不能保證寫入的安全,也無法免除您讓操作具備冪等性的責任,而且在常見的複本集拓撲中,它可能導致資料庫在節點故障時,恰好拒絕寫入。這篇文章涵蓋了讓一個團隊放棄其重度依賴交易設計的三個陷阱,以及取代了大部分設計的單一文件模式。
多文件交易實際上保證了什麼
MongoDB 的交易功能提供您跨多個文件的全有或全無寫入。您開啟一個會話,啟動一筆交易,針對該會話執行您的寫入操作,然後提交。如果任何寫入失敗或您中止交易,所有寫入都將不可見。其隔離等級為快照等級,因此並行讀取者要麼看到整組已提交的變更,要麼什麼都看不到。
這是一項實質的保證,且有些問題確實需要它。問題在於,這項保證的範圍比表面上看起來的要窄,而其成本則更為廣泛。一筆交易能保護您免於部分寫入落入資料庫的情況。它無法保護您免於您自己的客戶端重新發送提交、無法形成多數的複本集,或是在負載下多文件寫入所造成的鎖競爭。這三個缺口正是此設計崩潰之處。
以下是每個人最初都會寫出的架構。
session, err := client.StartSession()
if err != nil {
return err
}
defer session.EndSession(ctx)
_, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) {
if err := debit(sc, from, amount); err != nil {
return nil, err
}
if err := credit(sc, to, amount); err != nil {
return nil, err
}
return nil, nil
})
它讀起來清晰,演示效果也很好。失敗模式只會在生產環境中出現。
UnknownTransactionCommitResult 問題
第一個陷阱是人們在測試中從未見過的。當您呼叫 commit 時,驅動程式會將指令傳送給主節點並等待確認訊息。如果在您的寫入已持久化之後、但在確認訊息送達您之前,網路中斷或主節點降級,驅動程式將無法判斷 commit 是否成功。它會以帶有 UnknownTransactionCommitResult 標籤的錯誤來呈現此問題。
官方指引是重試 commit,而驅動程式的 WithTransaction 輔助函式會為您執行此操作。這聽起來像是問題已經解決了,但讓我們看看重試 commit 意味著什麼。您正在重新傳送一個您不知道其結果的操作。如果第一次的 commit 實際上成功了,重試會提交一個已經提交的交易,MongoDB 會將其視為無操作 (no-op),所以這種特定情況是安全的。危險在於更高一個層級。如果整個交易是由一個本身可以被重試的請求所驅動——例如用戶端逾時、佇列重新傳遞、使用者重複點擊——那麼整個借貸操作就可能作為一個全新的交易再次執行。交易邊界對此無能為力。它只保證每次執行都是原子性的,而不保證該邏輯操作只執行一次。
// Retrying the commit is safe. Retrying the whole operation is not,
// unless debit() and credit() are idempotent by construction.
for {
err := doTransfer(ctx, from, to, amount, transferID)
if err == nil {
break
}
if hasErrorLabel(err, "TransientTransactionError") {
continue // safe to replay the transaction
}
if hasErrorLabel(err, "UnknownTransactionCommitResult") {
continue // safe to retry the commit
}
return err
}
TransientTransactionError 標籤標示了整個交易可以安全重播的失敗,而 UnknownTransactionCommitResult 則標示了可以重試的提交。這兩種重試迴圈都假設底層的工作可以安全地重複執行。這個假設是你必須建立的。交易不會免費提供你冪等性。如果你需要轉帳只執行一次,你仍然需要一個穩定的冪等性金鑰,通常是一個 transferID,你在交易內部記錄並檢查它,這樣重播就會變成一個無操作 (no-op)。一旦你有了那個金鑰,交易帶給你的大部分好處就已經由該金鑰處理了。
使用 w=majority 時的 PSA 可用性陷阱
第二個陷阱是結構性的,它影響的是可用性,而非正確性。交易預設以 w=majority 的寫入考量進行提交,而你也希望如此,因為以較弱考量提交的交易在容錯移轉時可能會被回滾,這就失去了意義。問題在於「多數」在主-從-仲裁者 (Primary-Secondary-Arbiter) 拓撲中的意義。
PSA 是一種常見的三節點佈局,因為仲裁者很便宜。它會在選舉中投票,但不持有任何資料,所以你只需支付兩個承載資料節點的費用,卻仍能獲得容錯移轉能力。訣竅在於,仲裁者無法確認寫入。在一個三成員的集合中,「多數」是兩個節點,而你的三個成員中只有兩個承載資料。如果任一承載資料的節點故障,你就剩下一個可以確認多數寫入的節點和一個無法確認的節點。對於一般的插入操作,該集合仍可寫入,但對於 w=majority 的寫入(每個交易提交都是如此),則無法再達到多數。提交會被阻塞或逾時,直到第二個承載資料的節點恢復為止。
| 拓撲 | 資料節點 | 多數 | 對於 w=majority 是否容忍 1 個資料節點故障 |
|---|---|---|---|
| PSA (主-從-仲裁者) | 2 | 2 | 否 |
| PSS (主-從-從) | 3 | 2 | 是 |
所以,這個看似能容忍節點故障的拓撲,實際上只對普通寫入有容忍能力,對交易則不然。你會在建構複本集以應對的那個確切事件中發現這個問題。一個從節點在凌晨 3 點故障,普通的流量繼續流動,但每個交易寫入路徑都掛起了。解決方法有兩種:一是採用 PSS,這需要花費第三個承載資料的節點;二是在該路徑上完全不要求經多數確認的多文件提交。如果你在那裡沒有使用交易,一個 w=1 的單文件寫入本可以繼續提供服務。
效能與鎖定成本
第三個陷阱更為隱蔽,並以延遲的形式顯現。一筆交易在其整個持續時間內都會持有資源。它會釘選一個快照,因此儲存引擎必須在交易的生命週期內保持文件的舊版本可讀,並且它會取得文件層級的鎖,導致其他對相同文件的寫入者必須排隊等待。在競爭情況下,存取熱點文件的交易會相互序列化,而一筆緩慢的交易會讓每個存取其文件的寫入者都變得更慢。
MongoDB 也對交易設定了硬性限制,促使你保持其規模小巧。預設的交易生命週期為 60 秒,超過此時間伺服器便會中止該交易。一筆 oplog 變更量增長超過 16MB 的交易必須在內部進行分割,且成本更高。這些限制本身都不是致命的,但它們共同意味著交易不適合用來做大量工作,而一個橫跨緩慢外部呼叫或大型批次處理的交易,是一種設計上的錯誤。一個健康的交易,是針對少數文件進行的幾次寫入,且持續時間僅為毫秒等級。一旦你的交易不符合這個模式,那麼交易就是個錯誤的工具,而不是一個你需要去調整的工具。
取代大多數交易的模式
人們想用交易來保護的大多數不變量,實際上並未橫跨多個文件。它們橫跨單一文件的多個欄位,或者它們是你可以塑模為單一文件的單一邏輯事實。MongoDB 完全無需交易即可原子性地更新單一文件,而該原子性的單一文件更新加上冪等的 upsert,涵蓋了出乎意料之多的情況。
以轉帳範例為例,它看起來像教科書般的雙文件交易。如果餘額以貨幣映射表的形式存在於單一帳戶文件中,整個操作就是單一的原子性更新。如果它們必須存在於不同的文件中,就將轉帳本身塑模為一個具有確定性 id 的文件,並讓每一方冪等地針對該 id 進行應用。
// One document, one atomic update. No transaction, no majority commit
// required, and it stays writable when a data node is down.
res, err := accounts.UpdateOne(ctx,
bson.M{"_id": from, "balance": bson.M{"$gte": amount}},
bson.M{"$inc": bson.M{"balance": -amount}},
)
if err != nil {
return err
}
if res.ModifiedCount == 0 {
return ErrInsufficientFunds // the guard failed atomically
}
篩選條件承載了不變性。balance >= amount 條件和 $inc 會在伺服器上針對單一文件一起進行評估,因此不存在一個時間窗口,讓並行的寫入者可以在檢查和減量操作之間插入。兩個競速的提款操作無法都通過這個防護,因為第二個操作會看到已經被減去的餘額。這提供了與交易在單一文件情況下相同的安全性,但沒有提交的模糊性、沒有多數節點的要求,也沒有跨越兩個文件的鎖。
當操作確實要產生一筆必須只存在一次的紀錄時,請使用一個確定性的 id 和一個 upsert 操作,這樣重試時會覆寫紀錄,而不是重複創建。
// Idempotent by id. First call inserts, every retry overwrites identical
// data, the row count never grows. Safe to replay from any retry loop.
id := TransferID(from, to, requestID)
_, err := transfers.UpdateByID(ctx, id,
bson.M{"$setOnInsert": bson.M{
"from": from, "to": to, "amount": amount, "at": time.Now(),
}},
options.Update().SetUpsert(true),
)
這與 用於冪等更新插入的確定性 ULID 背後的想法相同。id 是從識別該操作的內容衍生而來,因此第二次嘗試會鎖定相同的 id,並成為一個無操作 (no-op)。在原子性的防護更新 (atomic guarded update) 和冪等的更新插入 (idempotent upsert) 之間,交易從大多數路徑中消失了,上述的三個陷阱也隨之消失。這就是為什麼一個優先採用交易 (transaction-first) 的團隊,在經歷事故並了解到交易的實際成本後,往往會走回頭路,改用非交易的設計。
何時才真正需要交易
這一切都不代表交易永遠是錯的。真正適用交易的情況是:一個真正的「全有或全無」不變量,它橫跨了你無法合併成單一文件的多個文件,而且沒有任何單一文件的保護機制或冪等鍵能夠表達此約束。在兩個獨立查詢的集合之間移動一個項目,而讀取者絕不能看到該項目同時存在於兩者之中或兩者皆無,這就是一個真實的例子。記帳分類帳也是如此,其中位於不同文件中的借方線和貸方線必須同時存在或同時不存在,並且這對資料被獨立查詢的頻率高到你無法將它們合併到單一文件中。
測試方法很簡單。問問自己,該不變量是否真的需要兩個或多個文件一起變更,以及讀取者是否能以破壞規則的方式觀察到中間狀態。如果兩者的答案都是肯定的,那麼交易就是正確的工具,你應該審慎地接受其成本,這意味著選擇一個 PSS 拓撲以確保多數提交能在節點遺失後存活、保持交易小而快,並且仍然讓外層的操作具有冪等性,以便重放時不會重複應用。如果任一答案是否定的,那麼你訴諸於交易只是為了避免思考資料模型,而該模型其實有更簡單的解答。
在您使用交易前的檢查清單
- 不變性確實地跨越多個無法被塑模為單一文件的文件,而不僅僅是單一文件的多個欄位。
- 一個並行的讀取者實際上可以觀察到一個被破壞的中間狀態,因此跨文件的原子性是必要的,而不僅僅是為了整潔。
- 封閉的操作本身就是冪等的,由一個穩定的 ID 作為鍵值,因為提交可能會回傳
UnknownTransactionCommitResult,且整個請求可以在交易之上重試。 - 複本集是 PSS,而非 PSA,因此在一個帶有資料的節點故障時,
w=majority的提交仍然會成功。 - 交易是小而短的,僅持有幾個文件幾毫秒,內部沒有緩慢的外部呼叫或大量寫入。
全部五項都通過,那麼交易就在做其他任何東西都無法做到的工作,而其成本是您選擇付出的代價。若前兩項不通過,那麼單一文件的原子性更新加上一個冪等的 upsert,會帶給您相同的不變性,並具有更高的可用性和更少的競爭。預設應該是更簡單的設計,而交易應該是例外情況,您可以一次一個路徑地證明其合理性。