← 返回開源列表

go-reliability-patterns

以 Go 實作的三個後端可靠性模式:日誌脫敏、失敗即拒絕的規則引擎、冪等消費者

Go singleflight database/sql SQLite Fuzzing
Coreychen4444/go-reliability-patterns License: MIT Language: Go

關於

從零以 Go 重新實作的三個後端可靠性模式,每個都寫清設計、失效模式與取捨。不包含任何雇主的程式碼、命名或資料。優先使用標準函式庫,主模組唯一的相依套件是 golang.org/x/sync。

核心技術亮點

1

redact:一個 io.Writer 裝飾器,在日誌中遮蔽敏感資訊(密鑰欄位、Bearer token、URL 密碼、查詢參數、手機號碼);乾淨的日誌行走零配置快速路徑,JSON 有專門的走訪器

2

rules:依租戶的允許/拒絕規則引擎,失敗即拒絕(fail-closed);並行快取未命中經 singleflight 合併,空規則集也會快取,並防止進行中的載入覆蓋失效操作

3

idempotent:至少一次投遞的消費者,冪等鍵與業務副作用在同一交易中提交,支援重試、退避、死信,以及依鍵保序的處理通道

4

表格驅動測試全部在 -race 下通過,附基準測試、日誌脫敏的模糊測試不變量,以及獨立模組中的 SQLite 整合測試

5

README 的每一節都把失效模式與限制寫在設計旁邊

此 README 由 README.zh-CN.md(簡體中文)以 OpenCC 自動轉換為繁體中文。

English | 簡體中文

go-reliability-patterns

用 Go 實現的三個後端可靠性模式,每個都配有表驅動測試、race detector 執行、基準測試, 以及(針對 redactor 的)模糊測試。每個包都是對我在生產 Go 服務中做過的某個模式的從零 重新實現,用來展示設計本身、它的失效模式和取捨。未使用任何僱主的程式碼、名稱或資料。

包 模式 看點
redact 以 io.Writer 裝飾器實現日誌脫敏 零分配的關鍵字快速路徑、合併後的正則、感知 JSON 的遍歷器、經模糊測試驗證的不變數
rules 按租戶的允許/拒絕引擎 fail-closed 決策、singleflight 填充快取、快取空結果、失效競態
idempotent 至少一次投遞、效果冪等的消費者 確定性的鍵、鍵與效果在同一事務中提交、重試/退避/死信、按鍵保序

Go 1.22+,優先使用標準庫。主模組唯一的依賴是 golang.org/x/sync(singleflight)。 SQLite 示例放在獨立模組中(examples/sqlite),這樣它的驅動不會 進入主 go.mod。

make lint      # gofmt + go vet(兩個模組)
make test      # 單元測試,外加 SQLite 整合測試
make race      # 同上,開啟 -race
make bench     # 基準測試
make fuzz      # 執行 FuzzRedact 60 秒(FUZZTIME=5m 可跑更久)
make example   # 執行 SQLite 消費者演示

redact:在日誌中遮蔽敏感資訊

redact.NewWriter(w, nil) 可以包裝任意 io.Writer。把它放在 log/slog、zap 或 zerolog 的 handler 下面,敏感資訊會在日誌行離開程序之前被遮蔽:

log := slog.New(slog.NewJSONHandler(redact.NewWriter(os.Stdout, nil), nil))
log.Info("login failed", "user", "bob", "password", "hunter2", "phone", "+65 9123 4567")
// {"time":"...","level":"INFO","msg":"login failed","user":"bob","password":"[REDACTED]","phone":"+65 9*** *567"}

它會遮蔽:

  • 敏感鍵的值:password、token、secret、api_key、authorization 等,覆蓋 key=value、key: value、帶引號的值、JSON 欄位以及嵌在字串裡的 JSON;
  • Authorization 頭和裸露的 Bearer token,保留認證方案(Bearer [REDACTED]);
  • URL 中的密碼(postgres://app:[REDACTED]@db/main);
  • 敏感的查詢引數(?access_token=[REDACTED]&page=2);
  • 位於電話類鍵下、或以 + 開頭書寫的電話號碼,保留可見的字首和字尾(+65 9*** *567)。始終至少遮蔽四位數字。

工作原理

一行 ──► 快速路徑:小寫化寫入棧上緩衝區,查詢觸發關鍵字
           │ 沒有(大多數行)──► 原樣寫出,0 次分配
           ▼ 有
         合法 JSON?──是──► 遍歷器:按鍵遮蔽值;其餘字串值走自由文本規則
           │ 否
           ▼
         一個合併後的正則(5 條規則合為一個 alternation)──► 改寫匹配到的分組
  • 快速路徑。 每條規則都聲明瞭它匹配時必不可少的字面量,例如它的鍵名、bearer、URL 憑據所需的 :// 和 @,或電話號碼所需的「+ 後跟八位及以上數字」。一次掃描同時檢查所有關鍵字:用每個關鍵字前兩個位元組組成的點陣圖,幾乎可以跳過所有位置。1 KiB 以內的行會被小寫化寫入棧上緩衝區,所以乾淨的行不產生任何分配;有一個測試用 testing.AllocsPerRun 斷言了這一點。
  • 大小寫摺疊。 Go 的 (?i) 使用 Unicode 簡單摺疊:ſ(U+017F)匹配 s,開爾文符號(U+212A)匹配 k。快速路徑也會摺疊這兩個字元;否則 paſſword=x 會跳過快速路徑,卻能被正則匹配。
  • JSON 遍歷器。 在合法的 JSON 文件中,後面跟著 : 的字串是鍵,其他字串都是值。這足以在不使用完整解析器的情況下按鍵遮蔽值。敏感鍵的值(字串、數字、物件或陣列)會變成 "[REDACTED]"。電話號碼會變成數字已遮蔽的字串。null、布林值和 "" 保持不變。只替換完整的值 token,而且只用 JSON 字串替換,所以輸出仍是合法 JSON。
  • 自由文本。 五條規則被編譯成一個不區分大小寫的 alternation。每條規則記錄自己要改寫哪些捕獲組,因此一次掃描就能得到唯一的優先順序順序:最左匹配優先,其次按規則順序。值的字元類排除了 \ 和 ",所以替換永遠不會拆開字串裡的 JSON 轉義序列。

經模糊測試驗證的性質

FuzzRedact 在任意輸入上檢查三個不變數:

  1. 快速路徑永遠不改變結果。 跳過一行得到的輸出,必須與對它執行全部規則得到的輸出相同,因此任何觸發條件都不會讓正則本能捕獲的敏感資訊漏網。
  2. 脫敏是冪等的。 經過兩道脫敏器(先是應用,再是日誌 shipper)的日誌不會被進一步破壞。
  3. 合法的單行 JSON 仍然是合法 JSON。

種子語料在第一次執行時就發現了一個冪等性 bug:Authorization: Bearer [REDACTED] 被再次脫敏成了 Authorization: [REDACTED] [REDACTED]。現在鍵值規則會吃掉已經遮蔽過的值,而不改寫它。

失效模式與侷限

情形 行為 原因 / 緩解
敏感資訊被拆到兩次 Write 呼叫中 漏掉 每次呼叫單獨脫敏。slog、zap 和 zerolog 每條記錄只調用一次。
未加引號且含空格或 ,;& 的值 只遮蔽第一部分 password=a b 與「password=a 後面跟著其他文本」無法區分。給值加引號,或作為結構化欄位記錄。
沒有 +、也不在電話鍵下的裸數字 不遮蔽 91234567 看起來和訂單 ID 或時間戳一樣。這裡選擇了精確率而非召回率。
用轉義寫的 JSON 鍵("password") 漏掉 鍵按原始位元組匹配。
電話鍵對應一個物件("phone":{"number":...}) 巢狀的鍵逐個檢查 number 不是電話鍵。用 WithPhoneKeys 新增它。
散文中出現關鍵字:refresh token: expired 過度脫敏為 token: [REDACTED] 對脫敏器來說,過度脫敏是有意選擇的失敗方向。
大量包含關鍵字的行 走慢路徑,每行約 50 µs 見基準測試。縮小鍵列表,或參見下面的後續計劃。

基準測試

Apple M2,darwin/arm64,Go 1.22.2;取 3 次執行的中位數(make bench)。

行 快速路徑(實際採用) 僅合併正則 每條規則一個正則
乾淨的 JSON(275 B) 837 ns · 0 次分配 89.4 µs 83.7 µs
乾淨的文本(162 B) 445 ns · 0 次分配 53.3 µs 49.1 µs
含密碼和電話的 JSON(222 B) 3.0 µs · 0 次分配 62.4 µs 67.4 µs
含 token 和 bearer 的文本(213 B) 52.5 µs · 8 次分配 53.2 µs 54.6 µs

在 slog.NewJSONHandler 之後,一條幹淨的記錄使用脫敏 writer 時耗時 1.08 µs, 不使用時為 0.58 µs,兩種情況都是 0 次分配。

這些數字說明:

  • 真正起作用的最佳化是快速路徑。 乾淨的行比執行正則便宜 100–120 倍。含敏感資訊的 JSON 行便宜約 20 倍,因為遍歷器只對觸發了關鍵字的字串值執行正則。
  • 在 Go 中合併正則並不划算。 Go 的 regexp 沒有 DFA。它執行有界回溯器(程式最多 500 條指令)或 Pike VM,兩者的開銷大致都是程式大小 × 輸入長度。合併後的程式有 357 條指令;五條規則分別編譯,總數也差不多。所以對一個大程式掃一遍,與對五個小程式各掃一遍開銷相同。在 RE2/C++ 或 Rust 的 regex 中,惰性 DFA 讓合併後的掃描在輸入長度上接近線性,與規則數量無關。在 Go 裡,關鍵在於快速路徑和保持程式精簡。把電話模式寫成 + 並在程式碼裡數數字,而不是用 {7,14},減少了約 170 條指令。
  • 快速路徑的第一版在時區偏移上失效了。 它把任何 + 後跟數字的情況都當作可能的電話號碼,於是每一條帶 +08:00 時間戳的 slog 行都走了慢路徑(每條記錄 16 µs)。現在的觸發條件要求 + 後面有八位數字。

rules:fail-closed 的允許/拒絕引擎

engine := rules.NewEngine(store) // store 載入某個租戶的 RuleSet,例如從 SQL 載入
d := engine.Check(ctx, rules.Request{Tenant: "t1", Account: "bob", IP: ip, Location: "SG", BankCard: card})
if !d.Allowed { /* d.Reason、d.RuleID 說明原因 */ }

評估順序

  1. 規則不可用 → 拒絕。 儲存錯誤、編譯失敗的規則集、載入超時或已取消的 context 都會拒絕。Check 返回的是 Decision,而不是 (Decision, error),所以呼叫方無法忽略錯誤後落到「允許」。
  2. 允許名單,每次只看一個維度:先賬號,再暱稱,再 IP/CIDR。 第一個命中即允許,無論是否存在拒絕規則。
  3. 拒絕規則,按優先順序從高到低。 一條規則可以指定賬號、暱稱、IP/CIDR、IP 歸屬地和銀行卡中的任意子集,並且只有在全部匹配時才命中(AND)。優先順序相同時按規則 ID 排序,保證決策是確定的。
  4. 其餘情況允許。

輸入會被規範化:暱稱轉小寫並去除首尾空白,歸屬地轉大寫,卡號只保留數字,IPv4 對映的 IPv6 地址被還原(::ffff:10.0.0.1 匹配 10.0.0.0/8)。Compile 會拒絕非法的規則集, 例如沒有任何條件的拒絕規則(它會拒絕所有人)或錯誤的 CIDR。在寫入規則時呼叫它,這樣壞 資料永遠不會進入讀路徑。

缺失訊號是一個顯式的選擇。預設情況下,如果某個條件針對的欄位在請求中缺失(比如沒有 歸屬地),該條件不匹配,因此「拒絕歸屬地 XX」會放過歸屬地未知的請求。WithStrictSignals() 會反轉這一行為,缺失資料即拒絕。兩種預設都不是處處正確:嚴格模式會把一次 geo-IP 故障 變成一次登入故障。

快取

策略按租戶編譯一次,快取在 Cache 介面之後(內建了 MemoryCache;Redis 實現會儲存規則 集並重新編譯)。

  • 併發未命中共享一次載入(singleflight,以租戶為鍵)。載入使用與發起它的呼叫方解耦的 context(context.WithoutCancel 加上自己的超時)。等待者使用 DoChan 並在自己的 context 上 select,因此一個呼叫方放棄,不會讓加入同一次 flight 的所有人都失敗。
  • 空規則集也會被快取。 singleflight 只合並併發的未命中。如果不快取「沒有規則」,一個沒有規則的租戶每次檢查都會打到儲存,永遠如此。Cache 介面區分「已快取、為空」(ok == true)與未命中,空策略使用更短的 TTL(WithEmptyTTL),這樣即使某次失效丟失,第一條規則也能很快生效。
  • 失效不會被進行中的載入撤銷。 在經典的 cache-aside 競態中,一次載入讀到舊規則,管理員寫入新規則並刪除快取條目,隨後這次載入又把舊規則寫了回去。Invalidate 會遞增該租戶的 generation 並呼叫 singleflight.Forget,讓新的呼叫方發起一次新的載入。載入在 Set 之後重新檢查 generation,如果變了就刪除該條目。之所以在之後而不是之前檢查,是為了消除刪除恰好落在檢查與寫入之間的視窗。
  • 快取出錯時回退到儲存。 快取只是最佳化;快取故障不能導致拒絕所有人。

解耦的載入 context、空結果快取以及 Set 之後的 generation 檢查,都分別經過驗證:去掉 它們,看著對應的測試失敗。

失效模式與取捨

情況 結果 替代方案
儲存宕機 已快取的租戶在 TTL 到期前繼續正常工作,之後被拒絕 出錯時返回舊資料(stale-if-error)並限制資料年齡:可用性更好,新鮮度更弱
大量 pod 上的 TTL 同時到期 每個 pod 每個租戶各載入一次(singleflight 是程序內的) 給 TTL 加抖動;在到期前提前重新整理
多個 pod 共享一個快取(如 Redis) generation 守衛是程序內的,所以另一個 pod 正在進行的載入仍可能寫入舊規則 帶版本號的快取鍵、pub/sub 失效,或延遲二次刪除
大規則集 拒絕規則按順序掃描:每次檢查 O(rules)。100 條規則且都不匹配時,一次命中快取的檢查耗時 1.25 µs;8 個 goroutine 每秒約可完成 4M 次檢查(M2,make bench) 按最具選擇性的欄位為規則建索引
允許名單中的賬號 永不被拒絕,即使來自被拒絕的歸屬地 面向內部和客服賬號;審計允許名單的變更

idempotent:至少一次投遞,效果只發生一次

Broker 會在崩潰、rebalance 或重試後重新投遞訊息,所以重複是常態。這個包讓每條訊息的 效果只發生一次:

c := idempotent.NewConsumer(store,
	func(m idempotent.Message) (string, error) { return idempotent.Key("invoice-paid", invoiceID(m)), nil },
	handler,
	idempotent.WithRetry(3, idempotent.Backoff{Base: 100 * time.Millisecond, Max: 5 * time.Second, Jitter: 0.5}),
	idempotent.WithDeadLetter(dlq),
	idempotent.WithLanes(8),
)
err := c.Run(ctx, deliveries, func(m idempotent.Message, err error) { if err == nil { ack(m) } })

冪等鍵

Key(namespace, parts...) 把操作及其業務身份(例如 ("invoice-paid", invoiceID))雜湊為 invoice-paid:<32 hex>。鍵必須來自訊息的含義。時間戳、消費時生成的隨機 ID 或投遞 offset 都會在重新投遞時變化,讓冪等失去意義。各部分在雜湊前會加上長度字首,因此 ("ab","c") 與 ("a","bc") 不同。

鍵與效果一起記錄

BEGIN
  INSERT INTO processed_messages (idempotency_key, ...) ... ON CONFLICT DO NOTHING
  影響 0 行?→ 已處理過:ROLLBACK,報告重複,ack
  handler 使用同一個事務寫入其效果(sqlstore.Tx(ctx))
COMMIT

順序就是關鍵所在:

  • 先在單獨的事務裡記錄鍵,然後在執行效果前崩潰:效果丟失。重新投遞會被當作重複跳過。
  • 先執行效果,然後在記錄鍵之前崩潰:效果發生兩次。
  • 兩者放在一個事務中:要麼都提交,要麼都不提交。

當兩個消費者同時處理同一個鍵時(比如在 rebalance 期間),它們會在主鍵上序列化。第二次 插入會等待第一個事務結束,然後什麼也不插入。當效果位於另一個系統中時,就沒有共享事務。 把冪等鍵作為那個系統的請求 ID 或事務 ID 傳過去,讓它在自己那一側去重,這樣整個方案就是 端到端的。

sqlstore 支援 SQLite、PostgreSQL(ON CONFLICT DO NOTHING)和 MySQL。對 MySQL 它使用 INSERT IGNORE,而不是空操作的 ON DUPLICATE KEY UPDATE k = k。在驅動開啟 CLIENT_FOUND_ROWS 標誌時,後者對重複行會報告影響了一行,看起來就像一次新插入。而 INSERT IGNORE 會把截斷之類的錯誤變成警告,可能悄無聲息地把兩個長鍵合併成一個,因此超過 128 位元組的鍵會在查詢前被拒絕。Purge 刪除舊鍵。鍵的保留時間要長於可能的最長重新投遞 延遲(broker 保留期加上任何重放視窗),否則遲到的重複訊息會被再次處理。examples/sqlite 在真實的 SQLite 資料庫上執行以上全部內容,包括對同一個鍵的 20 次併發嘗試。

重試與死信

失敗 處理方式 是否 ack?
Handler 出錯 以帶上限的指數退避加抖動重試;最後一次嘗試後進入死信,併合並每次嘗試的錯誤 是,進入死信後
Permanent(err)、handler panic,或無法推匯出鍵 立即進入死信;重試無濟於事 是
儲存或提交出錯(資料庫宕機) 重試;最後一次嘗試後返回錯誤,不進入死信 否:broker 稍後會重新投遞
死信 sink 失敗 返回錯誤 否
Context 被取消(關閉) 立即停止 否

區別對待儲存故障很重要。如果像對待壞訊息那樣把故障送進死信,一次資料庫抖動就會把整個 topic 排空到 DLQ 裡。

順序

Run 按 Message.Key(實體 ID)的 FNV 雜湊把每條訊息路由到 N 條 lane 之一。一條 lane 一次只處理一條訊息,因此同一實體的訊息按順序生效,不同實體則並行處理。在 Kafka 上,生產者 應使用相同的實體 ID 作為訊息 key,使 broker 的分割槽內順序與消費者的 lane 內順序一致。沒有 key 的訊息按輪詢分發。

取捨:

  • 隊頭阻塞。 正在重試的訊息會佔住它的 lane,也就佔住了所有雜湊到這條 lane 的鍵,最長可達整個重試計劃的時長。這是按鍵保序的代價。程序內重試要短,長時間的重試交給延遲 topic 或工作流引擎。
  • 死信之後的順序。 某個實體的一條訊息進入死信後,該實體後續的訊息仍會執行,因此這個實體可能跳過一步。更嚴格的設計會把該鍵掛起(把它後續的訊息也送進 DLQ),直到運維人員處理完畢。
  • Offset 提交。 使用 lane 時,一個分割槽的訊息會亂序完成。只能提交到每個分割槽最小的未完成 offset,這部分不包含在本倉庫中。

測試方法

  • 每個包都有表驅動單元測試,全部在 go test -race 下通過。
  • FuzzRedact 驗證上面的三個性質,另有一張 TestNoLeak 表,把同一個敏感資訊嵌入十幾種形式(請求頭、URL、巢狀 JSON、字串內的 JSON)。
  • 併發測試基於阻塞在 channel 上而不是靠 sleep 的 fake:singleflight 把 50 次冷未命中合併為一次載入、leader 被取消而等待者繼續存活、失效恰好發生在載入中途。
  • 在獨立模組中針對真實 SQLite 的整合測試:鍵與效果一起回滾、同一鍵的併發嘗試、purge,以及把一批訊息端到端重放三次。
  • redact、rules 和 idempotent 中的可測試示例(go test 會檢查其輸出)展示了公開 API。

目錄結構

redact/               io.Writer 裝飾器、JSON 遍歷器、模糊測試與基準測試
rules/                帶快取、singleflight 和失效機制的策略編譯器與引擎
idempotent/           鍵推導、Store、MemoryStore、Consumer(重試、DLQ、lane)
idempotent/sqlstore/  面向 SQLite、PostgreSQL 和 MySQL 的 database/sql Store
examples/sqlite/      獨立模組:SQLite 整合測試與可執行的演示

後續計劃

  • redact:按一行中實際出現的關鍵字集合來編譯合併後的正則,這樣一行只觸發一個鍵時可以減少慢路徑的開銷。
  • rules:帶版本化鍵的 Redis Cache,以及 stale-if-error。
  • idempotent:用於配合 lane 提交 offset 的分割槽級水位線;PostgreSQL 和 MySQL 整合測試。

許可證

MIT