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頭和裸露的Bearertoken,保留認證方案(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 在任意輸入上檢查三個不變數:
- 快速路徑永遠不改變結果。 跳過一行得到的輸出,必須與對它執行全部規則得到的輸出相同,因此任何觸發條件都不會讓正則本能捕獲的敏感資訊漏網。
- 脫敏是冪等的。 經過兩道脫敏器(先是應用,再是日誌 shipper)的日誌不會被進一步破壞。
- 合法的單行 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 說明原因 */ }
評估順序
- 規則不可用 → 拒絕。 儲存錯誤、編譯失敗的規則集、載入超時或已取消的 context 都會拒絕。
Check返回的是Decision,而不是(Decision, error),所以呼叫方無法忽略錯誤後落到「允許」。 - 允許名單,每次只看一個維度:先賬號,再暱稱,再 IP/CIDR。 第一個命中即允許,無論是否存在拒絕規則。
- 拒絕規則,按優先順序從高到低。 一條規則可以指定賬號、暱稱、IP/CIDR、IP 歸屬地和銀行卡中的任意子集,並且只有在全部匹配時才命中(AND)。優先順序相同時按規則 ID 排序,保證決策是確定的。
- 其餘情況允許。
輸入會被規範化:暱稱轉小寫並去除首尾空白,歸屬地轉大寫,卡號只保留數字,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:帶版本化鍵的 RedisCache,以及 stale-if-error。idempotent:用於配合 lane 提交 offset 的分割槽級水位線;PostgreSQL 和 MySQL 整合測試。
許可證
MIT