vatt'ghern jaskier's ballads
本文 7 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

一個 12 萬行的 Rust 後端,260 個模組,ships 成單一 binary、一句 docker compose up 就起得來。但真正值得偷的不是規模,而是作者反覆把「不該發生的事」從 runtime 的祈禱挪進 compile time 的事實——「插入一個沒簽名的 plugin」這句話,在他的型別系統裡根本寫不出來。

120k 行 Rust 後端的一趟導覽——把多租戶、同步引擎與外掛安全都交給型別系統

Nosdesk 的作者把一個生產級後端攤開來寫了一篇導覽:120,000 行 Rust、約 260 個模組、約 1,030 個測試,stack 是 Actix-web 跑 HTTP、Diesel 打 Postgres、Redis 做 fan-out、Tokio 當 runtime。這篇文章不是線性教學,而是「值得學的設計決策」的散點集合——他自己挑了一連串決策,攤開來講為什麼這樣設計。本文也不打算複述全部,而是挑其中五條我認為最有遷移價值的主軸,織成一趟導覽:同步引擎的 single-write log、Postgres LISTEN/NOTIFY 的去重喚醒、把多租戶與外掛安全壓進型別層、email 子系統裡那套手寫韌性機制,以及一套用 lint 當測試逼著每個 write 函式發事件的紀律。

挑這五條的理由很簡單:它們都不是 Rust 特有的炫技,而是「任何後端遲早會碰到、但大多數人會用 runtime check 草草帶過」的問題,被這個 codebase 用更狠的方式解掉了。每一段都會落到具體的 crate 名與機制——Actix-web、Tokio、Diesel、tokio-postgres、yrs、ring——因為脫離了具體機制,這種文章只剩雞湯。

sync_actions:一次寫入,三個 consumer 讀同一行

先講整篇文章我認為最值得偷的一招。Nosdesk 是一個協作型產品,意味著「有人改了東西」這件事要同時餵給好幾個下游:正在連線的 client 要即時收到 push、剛上線在追進度的 client 要 HTTP delta sync 把落後補回來、合規面要一條 audit trail。樸素做法是每個下游各自監聽、各自從業務表撈差異——三條讀路徑,三種「我以為的最新狀態」,遲早 diverge。

Nosdesk 的做法是一張 append-only 的 sync_actions log:每一個業務變更,除了寫進它自己的表,還在 sync_actions append 一列,描述「發生了什麼、順序是多少」。然後三個 consumer——HTTP delta sync、live push channel、audit trail——全部只從這一張 log 讀。作者那句話點破了全部:「If the write landed, every consumer sees the same canonical event in the same order.」只要寫進去了,每個 consumer 看到的就是同一個 canonical event、同一個順序。

這個轉換的威力在於它把「三個下游一致性」這個分散式問題,降維成「三個 reader 讀同一張單調遞增的表」這個單機問題。順序由 sync_id 這個單調遞增的欄位定義,consumer 之間不需要互相協調,各自記住自己讀到哪、下次從那之後撈即可。下面這張可點擊的架構圖是這一段的單一入口——點任一個 box,看它負責什麼、以及它刻意不知道什麼。

one write · one canonical order · three independent readers write handler 業務表 + append 一列 sync_actions append-only log sync_id 單調遞增 HTTP delta sync catch-up client live push connected client audit trail compliance 三個 reader 各記各的 last_seen,互不協調

write handler · responsibility

執行一個業務變更,寫進它自己的表的同時,在 sync_actions append 一列 canonical event。兩件事在同一個 transaction 裡——要嘛都成立,要嘛都不成立。

Does not know:誰會消費這個 event、有幾個 consumer、它們各自讀到哪。

「emit 一個 sync event」這件事後面會被一條 lint-as-test 強制檢查。

sync_actions · responsibility

append-only,永不就地修改。sync_id 單調遞增,定義了全 workspace 的 canonical order。它是三條讀路徑唯一的事實來源。

Does not know:event 的業務語意。它只保證「順序」與「不可變」。

把「三個下游一致」降維成「三個 reader 讀同一張單調表」。

HTTP delta sync · responsibility

給剛上線、落後的 client 補進度。client 帶上自己的 last_seen,server 用 WHERE sync_id > last_seen 撈出之後的所有 event 回放。

Does not know:live push 推了什麼、audit 記了什麼。它只認 sync_id 區間。

catch-up 與 live 共用同一份 event,不會出現「補進度補到跟即時推的不一致」。

live push · responsibility

把新 event 即時推給連線中的 client,走 Redis fan-out。被喚醒後一樣用 sync_id > last_seen 撈,而不是信任喚醒訊號本身帶來的內容。

Does not know:哪些 client 在 catch-up、audit 何時讀。

喚醒只是「去看一下」的訊號,真相永遠回 log 撈。

audit trail · responsibility

合規面的完整歷史。因為 log 本身 append-only 且有序,audit 不需要另一條寫路徑——它就是同一張 log 的第三個 reader。

Does not know:sync 的即時性需求。它在意的是「不漏、有序、不可改」。

audit 與 sync 共用同一份 canonical event,意味著「審計看到的」永遠等於「使用者同步到的」。

互動圖表

sync_actions 把三個 consumer 的一致性降維成「讀同一張單調遞增 log」的單機問題。

這個設計有一個容易被忽略的副作用:它讓「正確性」與「即時性」解耦。catch-up 走 HTTP、live 走 Redis push,兩條傳輸機制完全不同,但它們讀的是同一份 event、同一個順序,所以一個剛上線的 client 透過 delta sync 補到的狀態,跟一個全程連線的 client 透過 live push 收到的狀態,必然收斂到同一個結果。如果這三條是各自從業務表撈差異,光是「delta sync 撈的 cut-off 時間點」跟「live push 開始監聽的時間點」之間那道縫,就足以讓兩個 client 對「現在是什麼狀態」產生永久分歧。

值得強調的是 append-only 這個約束本身。一旦 sync_actions 允許就地修改或刪除,sync_id 的單調性就失去意義,consumer 的 last_seen watermark 也就不再可信。append-only 不是為了節省什麼,而是為了讓「順序」這個性質變成 structural invariant——資料結構本身保證的,而不是靠程式紀律維持的。

LISTEN/NOTIFY:通知不帶 payload,50 筆 commit 收斂成 1 次喚醒

有了 log,live push 還缺一件事:怎麼知道「有新東西寫進來了」。Nosdesk 用 Postgres 的 LISTEN/NOTIFY,但做法有兩個刻意的反直覺選擇,都值得學。

第一,監聽走一條獨立的 tokio-postgres 連線,跟主 connection pool 完全分開。Diesel 是同步驅動,主 pool 上的連線隨時可能被 spawn_blocking 的重查詢佔住;如果 LISTEN 也擠在這個 pool 裡,喚醒延遲就會被慢查詢綁架。所以監聽自己一條 async 連線,作者用 stream::poll_fn(move |cx| conn.poll_message(cx)) 把 tokio-postgres 的底層 poll 介面橋成一個 Rust async stream,喚醒就是這個 stream 吐出一個 item。

第二,也是更關鍵的:NOTIFY 刻意不帶 payload。作者寫得很白——它「carries no payload, no row id, no hint at what changed」。通知就是一個空的「有事發生」訊號,收到之後 consumer 不去解讀通知內容,而是回 sync_actions 跑一次 WHERE sync_id > last_seen 把自己落後的部分撈出來。

為什麼要這樣自廢武功、不在 NOTIFY 裡塞 row id?因為「不帶 payload」換來兩個性質。其一是 correctness under concurrent writers:作者點出,一個信任 payload 的 handler「would silently miss the rows everyone else committed」——如果你信通知帶來的那個 row id,那麼在你處理它的同時、別人 commit 進來的那些 row 你就漏了,而且是無聲地漏。回 log 撈 watermark 區間則永遠撈得乾淨,不管期間有多少並發寫入。

其二,也是最漂亮的一個性質:去重。NOTIFY 不帶內容,意味著「收到 N 個通知」跟「收到 1 個通知」在 consumer 眼裡是一樣的——反正都是「回去撈 last_seen 之後的全部」。作者那句話是整段的核心:「Fifty rows committed in one transaction collapse to one wakeup instead of fifty. A burst of writes debounces on its own.」一個 transaction 裡 commit 50 列,不是觸發 50 次喚醒、各自撈一列,而是收斂成 1 次喚醒、一次撈完 50 列。一陣寫入的爆量,自己就 debounce 掉了。下面這個 slider 把這個收斂算給你看。

拖左 slider 調一個 transaction 裡 commit 的列數,右 slider 調這個 burst 含幾個 transaction。空 payload 的設計讓「喚醒次數」只跟 transaction 數綁,跟列數無關——因為每次喚醒都是回 log 一次撈完區間。
naïve(每列一通知,payload 帶 row id) coalesced(空 payload,每 tx 一喚醒) 每個刻度 = 一次把 consumer 喚醒(context switch + log 撈一次)
rows committed50
naïve wakeups50
coalesced wakeups1

互動圖表

NOTIFY 不帶 payload,同一 transaction 的 50 列 commit 只觸發 1 次喚醒而非 50 次。

把 rows 拖到 50、transaction 留 1,naïve 路徑是 50 次喚醒,coalesced 是 1 次——這正是作者那句話的數字。把 transaction 數拉高,你會看到 coalesced 的喚醒次數只跟 transaction 數成正比,完全不理會每個 transaction 裡塞了多少列。這就是「空 payload」這個看似自廢武功的決定真正買到的東西:在寫入爆量時,喚醒成本與 context switch 次數被天然壓到 transaction 的數量級,而不是 row 的數量級。一個重寫的 batch job 一次 commit 上千列,consumer 只被叫醒一次。

這裡有一個微妙的取捨值得點出:空 payload 換來去重與正確性,代價是每次喚醒都要回 Postgres 跑一次查詢,而不是直接從通知裡讀內容。但這個代價在 sync_id > last_seen 有索引的前提下極小——它是一次有界的索引掃描——而它換來的「並發寫入下不漏、爆量自動 debounce」兩個性質,是 payload-carrying 設計用任何補丁都補不回來的。這是一個典型的「用一次便宜的查詢,換掉一整類 race condition」的交易。

spawn_blocking + ReceiverStream:同步 Diesel 怎麼餵 async 串流

Nosdesk 一個有意思的選擇是 async runtime 用 Tokio,但 ORM 用同步的 Diesel。這不是矛盾,而是一個務實的接縫設計:Diesel 的型別安全與成熟度作者要,async runtime 的並發他也要,於是中間用 spawn_blocking 把同步查詢丟到 blocking thread pool,不去阻塞 async executor。

真正精彩的是 bootstrap sync——新 client 上線要把整個 workspace 的初始快照拉下來。樸素做法是把所有 row 撈進一個 Vec 序列化後回傳,但作者點明這會造成「workspace-sized memory spike on every connection」:每一條新連線都在 server 記憶體裡堆起一份整個 workspace 大小的緩衝。對一個多租戶系統,這是一條等著被大客戶引爆的路。

他的解法是串流 + back-pressure。row 被序列化成 newline-delimited JSON(NDJSON),透過一個 mpsc::channel(64) 推出去,Diesel 查詢跑在 spawn_blocking 上,產出的 bytes 用 ReceiverStream 包成一個 HTTP response body 串回 client。channel 容量 64 是關鍵:當 client 讀得慢,channel 填滿,producer 就被 back-pressure 卡住,「a slow reader back-pressures the producer instead of pinning the whole result set in RAM」——慢的 reader 反壓 producer,而不是把整個結果集釘在 RAM 裡。整段 bootstrap 跑在單一 transaction 內,所以 client 拿到的是一個一致的 point-in-time 視圖,不會撈一半被別人的寫入插隊。

// bootstrap:同步 Diesel 查詢丟到 blocking pool,
// NDJSON 透過 bounded channel 串回,慢 reader 自動 back-pressure
let (tx, rx) = mpsc::channel::<Bytes>(64);
tokio::task::spawn_blocking(move || {
    conn.transaction(|conn| {
        for row in stream_workspace_rows(conn)? {
            let line = to_ndjson(&row);
            // channel 滿時 blocking_send 卡住 → 反壓到查詢游標
            if tx.blocking_send(line).is_err() {
                break; // client 斷線,停止掃描
            }
        }
        Ok(())
    })
});
// ReceiverStream 把 rx 包成 HTTP response body
HttpResponse::Ok().streaming(ReceiverStream::new(rx))

這個 channel(64) 的數字本身就是一個 back-pressure 旋鈕:太小,producer 與 consumer 頻繁 ping-pong,throughput 受損;太大,慢 client 能在你被卡住前先讓你緩衝大量資料,記憶體保護就鬆了。64 是一個「足以吸收抖動、又不至於讓記憶體無界成長」的折衷。重點不在這個數字,而在「有界 channel」這個結構本身——它把 memory safety 從「希望 client 讀得夠快」變成「channel 容量數學上保證的上界」。下面這張示意圖把這條串流路徑的四個環節攤開,並把那條從慢 reader 一路反推回查詢游標的 back-pressure 回饋線畫出來。

四環一路:spawn_blocking 跑同步 Diesel 查詢 → 序列化成 NDJSON → 推進 mpsc::channel(64) → ReceiverStream 包成 HTTP body。channel 滿時,blocking_send 卡住 producer,反壓沿紅線一路推回查詢游標——記憶體上界由 channel 容量保證,而非祈禱 client 讀得快。 一個 transaction 內的 point-in-time 串流,記憶體不堆整份 workspace spawn_blocking 同步 Diesel 查詢 逐列吐,不收進 Vec to_ndjson 每列序列化成一行 newline-delimited mpsc::channel(64) bounded buffer 滿了就卡住 producer ReceiverStream → HTTP body 串給慢 client back-pressure:慢 reader 反壓 producer channel 填滿 → blocking_send 卡住 → 反壓回查詢游標,停止掃描 記憶體上界 = channel 容量(64),不隨 workspace 大小膨脹

互動圖表

mpsc::channel(64) 容量上界讓慢的 client 反壓 Diesel 查詢游標,記憶體不隨 workspace 膨脹。

而 bounded mpsc 只是這個子系統挑用的並發原語之一。作者那段話把整套挑選邏輯講得極乾淨——「DashMap for the lazily-populated topic map. tokio::broadcast for fan-out with built-in lag detection. Bounded mpsc where I want back-pressure. std::sync::RwLock where no await crosses the critical section; tokio::sync::RwLock only where one does.」每一個選擇都掛在兩條問題上:critical section 裡有沒有 .await?要不要 back-pressure 或 lag detection?下面這張決策圖把這五個原語照這兩條軸攤開——它不是教你某個 API,而是把「該用哪個」這個每天都要做的判斷,定成一棵可以照著走的樹。

兩條軸定一切:critical section 裡是否有 .await,以及你要的是 back-pressure 還是 lag detection。照著樹走,落到的葉子就是該用的原語。 問兩個問題,落到一個原語 你要的是什麼? 共享 map / 傳值 / 鎖狀態 並發共享 map 傳值(channel) 鎖共享狀態 DashMap lazily-populated topic map 要 back-pressure, 還是 fan-out + lag detection? critical section 裡 有 .await 嗎? 要 back-pressure bounded mpsc 慢 reader 反壓 producer 要 fan-out + lag detection tokio::broadcast 內建 lag detection 無 .await std::sync:: RwLock 有 .await 跨越 tokio::sync:: RwLock 同一個 vocabulary,五個原語各有唯一一條進來的路

互動圖表

critical section 有無 .await 與需不需要 back-pressure 兩個問題決定五種並發原語的選擇。

型別層安全:illegal states 寫不出來

這是整篇文章我認為最有 Rust 味、也最值得搬到任何強型別語言的一段。Nosdesk 把兩類「絕對不能出錯」的事——跨租戶資料隔離、未簽名 plugin 安裝——從 runtime check 挪進了型別系統,讓錯誤的程式根本編不過。

先講多租戶。作者的第一句話就很狠:「Handlers don't get a raw database connection at all.」handler 拿不到裸的資料庫連線。能碰 connection pool 的只有兩個 extractor:TenantConnPlatformConnTenantConn 會把每個查詢包在一個設好 workspace context 的 transaction 裡,讓 Postgres 的 Row-Level Security(RLS)自動過濾掉不屬於這個 workspace 的 row;PlatformConn 則 elevate 到一個特殊 role,做那種罕見的跨租戶操作。關鍵在於:一個 handler 的簽名只要寫了 PlatformConn,它就在型別上公開宣告「我會跨越租戶邊界」——「announces 'I cross tenant boundaries' right there in its type」。code review 與 audit 不再需要逐行讀 SQL 找有沒有人漏了 workspace filter,只要 grep 函式簽名裡的 PlatformConn 就鎖定了所有跨租戶的 surface。

plugin 那一段更是型別技巧的教科書範例。插入一筆 plugin row 的函式,要求一個 InstallToken 當參數——「the function that inserts a plugin row requires one as an argument」。而 InstallToken 唯一的建構路徑,是 private 在「verified-install 模組」裡的。模組外的任何程式碼都拿不出一個 InstallToken,於是「插入未簽名 plugin」這件事,在型別系統裡表達不出來——你想呼叫 insert 函式,就得先有 token;想有 token,就得走過驗證模組裡的簽名檢查。簽名本身是 Ed25519 over「a length-prefixed canonical digest with a domain-separation prefix」。下面這張圖把這兩條建構路徑並排:一條編得過,一條從根本上拼不出來。

左:唯一合法路徑——簽名驗過 → verified-install 模組構造 InstallToken → insert。右:任何想跳過驗證的程式碼,缺一個它構造不出來的 token,編譯器在 call site 就擋住。 合法路徑 · compiles plugin bytes + Ed25519 signature length-prefixed canonical digest verified-install 模組 驗簽通過 → 構造 InstallToken(private) insert_plugin(token: InstallToken) row 進 DB ✓ 繞過路徑 · does not compile 某 handler 想直接塞 plugin 沒驗簽、想抄捷徑 要 InstallToken 但構造子 private E0603: constructor is private 編譯器在 call site 就擋下 「插入未簽 plugin」在型別系統裡是一個寫不出來的句子

互動圖表

insert_plugin 需要 InstallToken,其建構子只存在於 verified-install 模組,未驗簽的插入根本寫不出來。

這個 pattern 在 Rust 圈有個名字:把不變式編碼進建構子可見性(也常被叫做 witness type 或 capability token)。InstallToken 不是一個帶資料的物件,它是一個「我已經通過驗證」的型別層證明——持有它本身就是證據。把這個證明設成「只有驗證模組能簽發」,等於把整條簽名檢查管線,變成 plugin 進資料庫的唯一道路:「the type system makes the signing-checked install pipeline the single path that can get a plugin into the database」。沒有第二條路可以漏掉,因為第二條路在型別上不存在。

對照 runtime 的做法你就懂這招的價值:runtime check 是「在 insert 前記得呼叫 verify()」——而「記得」是人類紀律,會在某個 1am 的 hotfix 裡被某個趕時間的人略過。型別層的做法把這個紀律變成編譯器強制:你不是「應該」先驗證,而是「不先驗證就生不出能呼叫 insert 的參數」。這正是 opener 那句話的意思——「插入一個沒簽名的 plugin」這個句子,在這個 codebase 裡根本造不出來。

email 子系統:手寫的韌性機制

整個 codebase 裡,email 是最大的單一子系統——約 14,000 行。理由不難猜:email 要對接外部 SMTP/IMAP 伺服器,而外部世界是不可靠的,於是這 14k 行裡塞滿了各種「對抗不確定性」的機制,每一個都手寫、每一個都值得學。

第一個是 circuit breaker。作者手刻了一個 closed / open / half-open 的狀態機,建在「a rolling window of recent failures」上——一個滾動視窗統計最近的失敗。最聰明的細節是狀態轉換「computed lazily when the state is next read, not from a background timer」:不是開一個背景 timer 去定時把 open 轉回 half-open,而是在下次讀取狀態時才惰性計算該不該轉。這省掉了一整類「timer 與狀態 race」的併發 bug,也省掉了背景 task 的資源。下面這張圖是這個狀態機的形狀。

三態加上「lazy transition」這個關鍵:open → half-open 不是 timer 推的,而是下次讀狀態時才算 cooldown 過了沒。沒有背景 timer,就沒有 timer 與狀態的 race。 CLOSED 正常放行 OPEN 直接拒絕 HALF-OPEN 放一個試探 rolling window 失敗率超標 下次讀狀態時才算:cooldown 過了 → lazy 轉 試探成功 → 回 CLOSED 試探失敗 → 退回 OPEN

互動圖表

circuit breaker 的 open→half-open 轉換惰性計算而非 timer 推送,消除計時器與狀態之間的 race condition。

第二個是退避:full-jitter backoff,作者照搬 AWS Builders' Library 那條公式,寫成一個帶小心 overflow 處理的純函式。full-jitter 的重點不是「指數退避」這件事本身,而是那個 jitter——在 [0, base × 2^n] 這個區間裡均勻取一個隨機值,而不是退避到 base × 2^n 這個確定值。為什麼?因為當一批 client 同時撞到失敗、同時開始退避,純指數退避會讓它們在同一個時間點同時重試,形成 retry storm 的同步尖峰;full-jitter 把每個 client 的下次重試打散到整個區間裡,把尖峰抹平成均勻分布。下面這個可拖的 widget 讓你比較兩者。

拖圓點調 retry attempt n。上排是純 exponential(所有 client 退到同一點,尖峰);下排是 full-jitter(退避散布在 0 到 cap 之間,每個刻度是一個 client 的下次重試時刻)。 pure exponential · base × 2ⁿ(同步尖峰) full jitter · random(0, base × 2ⁿ)(攤平) n = 1 n = 8
attempt n = 3 backoff cap = 800 ms exponential:800 ms(全部撞同一點)

互動圖表

full-jitter 把重試時機散布在 [0, cap] 區間,防止多個 client 同時退避到相同時間點爆發重試風暴。

第三個是 email 投遞的 at-least-once 保證,用 FOR UPDATE SKIP LOCKED 配上五分鐘的 lease。多個 worker 同時搶待送的 email row,FOR UPDATE SKIP LOCKED 讓每個 worker 只鎖到自己搶到的那批、跳過別人鎖住的,天然做到無協調的工作分配。lease 是五分鐘——一個 worker 拿走一封信但中途掛了,五分鐘後 lease 過期,別的 worker 可以重新領走。這意味著同一封信可能被送兩次,所以 Message-ID 在 enqueue 時就被決定性地戳上去,靠接收端伺服器用 Message-ID 去重。作者把這個取捨講得很白:「I'd rather send twice than drop once.」寧可送兩次,不要漏一次。

這三個機制疊在一起,構成一套很清楚的失敗哲學:circuit breaker 在對方掛掉時快速止血、不浪費資源去撞一面死牆;full-jitter 在重試時不製造同步尖峰;SKIP LOCKED + lease + Message-ID 則在 worker 自己掛掉時保證信不丟、最多重送。每一條都是「假設一切都會壞」之後的具體設計,而不是「希望一切正常」的樂觀路徑。

幾個值得單獨記住的細節

除了上面五條主軸,文章還散落著幾個短而利的招式,列出來當作工具箱。

CRDT 的 client id 推導。協作編輯用 yrs(Yjs 的 Rust 實作)的 CRDT。一個容易踩的坑是:server 自己也是 CRDT 的一個參與者,需要一個 client ID。如果 server 每次重啟都隨機選一個新 ID,那麼「每次後端重啟」在 CRDT 眼裡都像「一個全新的參與者加入」,狀態會無限膨脹。Nosdesk 的解法是把 server 的 client ID 從 document ID 的 hash 決定性地推導出來,再 mask 到 53 bits 以塞進 JavaScript 的 safe integer 範圍。重啟之後同一個 document 推導出同一個 ID,CRDT 認得它是同一個老參與者。

不信任的程式碼用 catch_unwind 圍起來。yrs 在遇到 malformed UTF-8 時可能在 library 深處 panic。Nosdesk 對它的每一次呼叫都包一層 catch_unwind,把 panic 攔在 call site,不讓它炸穿整個 worker。作者把這上升成一條通則:「I treat anything I don't own the same way」——任何不是我自己寫的東西,都當成可能 panic 的,在呼叫點就攔住。

SSRF 防護的 custom DNS resolver。當後端要對使用者給的 URL 發請求時,SSRF 的經典繞法是「DNS 解析出來是公網 IP、實際連線時卻指向內網」。Nosdesk 把過濾做在 DNS resolver 層、跟連線用的是同一次解析結果,枚舉所有 non-routable 範圍,包含 CGNAT 與那個 IPv4-mapped-IPv6 的把戲——::ffff:127.0.0.1 這種把 loopback 偽裝成 v6 位址、想騙過只檢查 v6 的過濾的招數。把過濾釘在「連線真正會用的那次解析」上,是這招的關鍵:在別的層檢查都可能被 TOCTOU 繞過。

登入的 timing 防護。每一條登入失敗路徑——不管是帳號不存在還是密碼錯——都統一走一次對 dummy hash 的 bcrypt 驗證,讓「帳號不存在」與「密碼錯」兩種失敗耗時一致,堵住 user-enumeration-via-timing。作者甚至 prewarm 這條路徑,免得第一次真實登入付出一次性的冷啟動成本、反而從 timing 上洩漏出「這是第一次」。

AES-256-GCM 把 context 綁進 auth tag。加密走 ring 的 AES-256-GCM,並把一個 context 字串綁進 auth tag(也就是 AEAD 的 associated data)。效果是「為某個用途封的 ciphertext,不能被當成另一個用途解開」——換了 context,auth tag 驗證就失敗。plaintext buffer 在用完後 zeroize。這擋住的是「把 A 場景加密的東西餵進 B 場景的 decrypt」這類 confused-deputy。

~1030 個純函式測試,與 lint-as-tests

最後一條主軸是測試策略,它跟前面所有設計決策互為表裡。約 1,030 個測試,但作者刻意不追求均勻覆蓋率——測試「cluster where the code is most likely to be wrong」,集中在最容易錯的地方:manifest 驗證、IMAP 解析、email threading、HTML sanitisation、plugin 的型別層。這些幾乎全是純函式,不碰資料庫、不碰 socket,所以跑得快、好寫、不 flaky。需要碰資料庫的測試則跑在一個「drop 時 rollback 的 transaction」裡,不留痕跡、可以並行對同一個 dedicated test DB 跑。

但全文最讓我印象深刻的,是那條把架構決策變成測試的 lint-as-test。還記得開頭 sync_actions 那一段嗎?整套 sync 引擎的正確性,建立在「每一個 write 函式都有 append 一個 sync event」這個前提上。漏掉一個,那個變更就不會被任何 consumer 看到——一個無聲的、極難 debug 的資料同步 bug。Nosdesk 怎麼保證沒人會漏?一條測試「walks the repository layer, finds every write function」,然後「fails the build unless that function emits a sync event or carries an explicit marker saying it doesn't」——走遍 repository 層、找出每一個 write 函式,除非它發了 sync event、或帶了一個明確標記說「我故意不發」,否則直接讓 build 失敗。

// lint-as-test:把架構不變式變成編譯/測試強制
#[test]
fn every_write_emits_a_sync_event() {
    for f in repository_layer::write_functions() {
        assert!(
            f.emits_sync_event() || f.has_marker("intentionally_silent"),
            "write fn `{}` 既沒發 sync event 也沒標記豁免——build 失敗",
            f.name()
        );
    }
}

作者點破了這條測試的真正目的:「how the rule survives me forgetting about it at 1am six months from now」——讓這條規則能在六個月後某個凌晨一點、當我自己都忘了它存在的時候,依然活著。這跟 InstallToken 是同一個哲學的兩種實作:把「人類必須記得做的事」轉成「機器強制檢查的事」。InstallToken 用型別系統強制,lint-as-test 用測試套件強制——目標都是把正確性從紀律問題降級成 build 問題。

當然,這個 codebase 不是完美的,作者自己也誠實列了 pre-v1 的待辦:main.rs 還是個約 1,900 行的 monolith 待拆、graceful shutdown 接好了但還沒接到 SIGTERM、search service 上有一個「在現代 Tantivy 上幾乎確定不必要」的 unsafe impl Send/Sync、plugin proxy 與 SMTP 處理那邊的 error type 還在退化。把這些攤出來寫,反而讓前面那些漂亮的設計更可信——它們不是事後美化的 architecture diagram,而是一個還在演進、有債也有亮點的真實系統。

The model:這篇文章真正的主題只有一句——把「不該發生的事」從 runtime 的祈禱,往 compile time 或 build time 挪。TenantConn/PlatformConn 把「跨租戶」變成簽名上看得見的事;InstallToken 把「未簽 plugin 安裝」變成寫不出來的句子;sync_actions 把「三個下游一致」變成「讀同一張單調表」;空 payload 的 NOTIFY 把「並發漏寫」變成不可能;lint-as-test 把「記得發事件」變成 build gate。每一招都在做同一件事:把一個本來要靠人類紀律維持的不變式,焊進機器會替你檢查的地方。值得搬走的不是 Rust,是這個習慣。