Firestore 的 MongoDB 相容性中的 `retryWrites=false` 陷阱
Firestore 支援 MongoDB 的連線協定,但會拒絕可重試的寫入操作。如果您的驅動程式預設為開啟,寫入操作會以看似隨機的方式失敗。以下是原因以及修正方法。
企業版 Firestore 支援 MongoDB 的連線協定,所以您可以將標準的 MongoDB 驅動程式指向它,大部分的功能都能正常運作。您可以獲得 MongoDB 的查詢語法、MongoDB 的索引,以及一個無需執行伺服器的託管資料庫。然後某些寫入操作開始失敗,出現一個與您的資料無關的錯誤,而且只是偶爾發生。原因是 retryWrites,一個 Firestore 不支援的驅動程式預設設定。這篇文章將解釋這個設定的作用、為何相容層會拒絕它、如何識別這種失敗,以及在您關閉它之後,您對系統應負的責任。
可重試寫入的實際作用
在真實的 MongoDB 中,如果第一次寫入嘗試在網路層或選舉層失敗,可重試寫入允許驅動程式重新傳送該寫入一次,而不會有重複套用兩次的風險。其運作方式是為該操作附加一個交易編號。伺服器會記錄該編號,因此如果它再次看到相同的操作,它就知道這是一次可能已經套用過的寫入重試,並會執行去重複化,而不是寫入第二個副本。
這確實是一個很好的預設值。網路並不可靠,而且主節點選舉也會發生。可重試寫入將一整類的暫時性故障轉變為無聲且安全地恢復。這就是為什麼每個現代的 MongoDB 驅動程式都附帶 retryWrites=true,也是為什麼大多數人從未考慮過它的原因。它正在無形中發揮作用。
重要的細節是,其安全性完全取決於伺服器是否實作了該交易編號的簿記工作。用戶端無法自行使寫入操作具有冪等性。它只能請求伺服器進行去重複化,並相信伺服器知道如何操作。
為何 Firestore 會拒絕它
Firestore 的 MongoDB 相容性實作了網路協定與查詢介面,但其底層是不同的儲存引擎,且具備不同的交易模型。它並未實作該協定的交易編號所假設的可重試寫入記錄機制。因此,當驅動程式「貼心地」將可重試寫入的中繼資料附加到您的插入操作上時,伺服器會看到一個它不支援的功能,並拒絕該操作。
這種失敗模式會讓您耗費一個下午的時間。錯誤訊息並不會顯示「不支援 retryWrites,請停用它。」它會以更通用的寫入失敗形式出現,而且因為它取決於是哪個程式碼路徑在何時附加了中繼資料,所以看起來可能是間歇性的。您會發現某些寫入操作會出現此問題,但其他寫入操作則不會;或是在啟動時出現,但在使用不同連線的單元測試中卻不會。每個直覺都會引導您去檢查文件、結構描述和索引。文件沒有問題。是連線設定錯誤。
修復方法只是一個旗標,但你放在哪裡很重要
在連線 URI 中設定 retryWrites=false。這就是全部的修復方法,而且它必須在你開啟的第一個連線中就存在。
// retryWrites=false is required. Firestore's MongoDB compatibility rejects
// the retryable-write protocol that the driver enables by default.
uri := "mongodb://USER:PASS@HOST/db?retryWrites=false"
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
return nil, fmt.Errorf("connect firestore: %w", err)
}
你也可以用程式碼選項(options.Client().SetRetryWrites(false))來停用它,但建議使用 URI。原因在於人為因素,而非技術因素。URI 字串是人們會複製的東西:複製到新的服務、遷移腳本、一次性的偵錯 shell、或隊友的環境檔案中。每一次的複製,如果設定是存在於沒有跟著一起走的程式碼中,那麼預設的 true 值就會在這些地方悄悄地回來。把它放在連線字串裡,它就會跟著資料庫的每一次使用而走。
在啟動時進行驗證,讓這件事不可能被忘記。如果服務收到的 URI 沒有停用可重試寫入,就在啟動時大聲地失敗,而不是在第一次寫入時神秘地失敗。
if !strings.Contains(uri, "retryWrites=false") {
return nil, errors.New("firestore URI must set retryWrites=false")
}
關閉它並不會讓你的寫入變得安全
這就是人們在修復錯誤並繼續前進後,會遇到的陷阱。停用 retryWrites 並沒有消除重試的需求。網路仍然會中斷,逾時仍然會發生,而你自己的程式碼在某處仍然會重新傳送一筆可能已經成功也可能尚未成功的寫入。改變的是,再也沒有任何機制會對該重送進行重複資料刪除。你在真實 MongoDB 中所擁有的安全網已經消失,而責任轉移到了你身上。
如果你系統中的任何寫入路徑可以被重試——而在分散式系統中,每個寫入路徑都可以——那麼這些寫入就必須透過建構使其具備冪等性。執行相同的寫入兩次,必須讓資料庫處於與執行一次相同的狀態。
達成此目標的簡潔方法是,對於任何你可能會重新傳送的內容,停止使用隨機的文件 ID。從識別文件的內容中衍生出 ID,如此一來,重試的插入就會指向相同的 ID,並變成一次無害的覆寫,而不是一筆重複的資料列。
// Same logical document, same id, every time. A retried upsert overwrites
// in place instead of inserting a second copy.
id := PostID(slug, lang, publishedAt)
_, err := coll.UpdateByID(ctx, id,
bson.M{"$set": doc},
options.Update().SetUpsert(true),
)
一個帶有 upsert 和確定性 ID 的 UpdateByID 操作本身就是冪等的,不需要伺服器端的重複資料刪除。第一次呼叫會插入資料,每次重試都會覆寫相同的資料,而資料列的數量永遠不會增加。完整的衍生過程,包括那個你之後絕不能更改(否則會導致舊文件被遺棄)的欄位,在 deterministic ULIDs for idempotent upserts 中有詳細說明。
其他值得了解的相容性問題
retryWrites 是第一個會出問題的,因為它會在普通寫入時失敗,但同樣的原則適用於整個相容性層:支援線路協定,但並非其背後的所有伺服器端功能都支援。在您依賴某項功能之前,請確認相容的引擎實作了它,而不僅僅是接受該指令。實際上,這意味著將相容性層視為一個有其自身限制的獨立資料庫,而不是一個可以直接替代的 MongoDB。請預先完整閱讀一次支援功能的說明文件,而不是透過生產環境中的事件來逐一發現每個差距。
心態上的轉變才是真正的課題。相容性層提供您協定和查詢語言,這涵蓋了大部分的便利性。它不保證提供原版的所有操作保證,而它省略的保證往往正是那些您在不知不覺中依賴的隱形保證,例如自動寫入去重。
信任連線前的檢查清單
retryWrites=false位於 URI 本身,而不僅僅是在某個客戶端程式碼的選項中,因此它會隨著每個連線字串的副本傳遞。- 如果缺少此旗標,啟動時的驗證會明確地失敗,這樣一來,設定錯誤的環境就無法執行第一次寫入。
- 每個寫入路徑都是一個以穩定的、由內容衍生的 id 為鍵的冪等 upsert 操作,而不是使用隨機 id 的 insert 操作。
- 您的重試邏輯假設至少一次的傳遞,因此重新執行任何寫入操作都會留下相同的狀態。
做到前兩點,相容性層就不會再拋出看似隨機的錯誤。做到後兩點,一旦伺服器的安全網消失,您自己的重試就不會再悄悄地重複資料。前兩項能讓症狀消失。後兩項則修復了症狀所警告您的問題。