我正在建立帳本服務(ledger-service), 一個雙軸入賬電子錢包賬本,使用 Java 21 / Spring Boot 3.5 / PostgreSQL。它透過 Render 進行直播早期我寫了一個壓力測試,它會發出50次轉移到相同賬號 立刻斷言書籍從未被損毀。它變紅了 — — 它變紅的方式是我建立這個過程中最有用的東西。
這篇文章追溯整個鏈條:為什麼一個錢財賬本保持平衡 緩存 無論如何,快取引發的讀寫覆寫競態,我是如何偵測它的,修正方法(樂觀鎖定 + 有界重試),證明選擇樂觀鎖定優於悲觀鎖定的基準測試,以及如何讓 idempotency 與重試組合,以避免網路暫停導致重複花費。
設定:一個分錄帳,以及為何它需要快取
真實來源是一個 雙重入賬,僅追加式 表 (ADR-0005)。每個金錢操作都至少寫入一組平衡的 DEBIT/CREDIT 對,其中 Σ DEBIT == Σ CREDIT,而 ledger_entries 僅可插入 — 沒有 UPDATE,沒有 DELETE。一個錯誤是通過發布一個 來修正的 的進入,永遠不透過編輯歷史。這就是 Stripe、Modern Treasury 和 Formance 都使用的模式,也是為什麼你能夠信任審計軌跡的東西。
但「賬戶 X 的餘額是多少?」不應該是對賬戶所曾有過的每一筆記錄的 SUM。所以我保留了一個 快取:accounts.balance 是該賬戶記錄的實體化 Σ,並且會更新在同一交易中與條目本身(ADR-0006)。條目就是事實;餘額是一個衍生讀取緩存,保持O(1).
那個緩存就是並發問題的癥結點.
競爭
兩個請求同時對同一賬戶進行借記:
R1: read balance $500 (enough) R2: read balance $500 (enough)
R1: commit −$300 → $200 R2: commit −$400 → −$200 ← overdraft / lost update
兩者都讀取$500,兩者都決定自己已經足夠,兩者都寫回自己對新平衡的觀點。其中一個默默地覆蓋了另一個:一個丟失的更新,以及一個不再與其下方的分錄相符的平衡。
陷阱在於假設資料庫會為你阻止這一切。它不會。PostgreSQL的預設隔離層級,READ COMMITTED,僅僅保證你無法讀取未提交的資料 — 它對於兩個同時讀寫同一行的事務什麼也做不了。一個讀寫競態會直接穿過它.
檢測它:壓力測試
這是暴露出錯誤的測試。先注銷一個賬戶,然後發射N = 50 同時從其中轉移出去並在之後核對賬目:
AtomicInteger successes = new AtomicInteger();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int i = 0; i < N; i++) {
pool.submit(() -> {
start.await(); // line them all up...
int code = post("/transfers", from, to, AMOUNT); // ...then fire at once
if (code == 201) successes.incrementAndGet();
});
}
start.countDown();
// after all complete:
assertThat(balanceCache(from)).isEqualTo(ledgerBalance(from)); // cache == Σ entries
assertThat(balanceCache(from)).isGreaterThanOrEqualTo(0); // no overdraft
assertThat(balanceCache(to)).isEqualTo((long) ok * AMOUNT); // exact accounting
這些斷言是故意設計成與時間無關 — 它們對任何 分割成功與失敗,因為它們將快取與賬本真實情況進行比較,而不是與固定的預期計數進行比較。那就是使測試成為穩定的回歸保護而不是不可靠的原因。
我透過實驗確認了這個錯誤:移除了@Version欄位,~85%的快取更新就丟失了 和這些斷言變紅了 — 缓存中的餘額遠離了所有記錄的總和。緩存和真實情況不一致,在金融體系中這就是整個遊戲的關鍵。
解決方案,第一部分:樂觀鎖定
accounts 已經有一個 version BIGINT 欄位,因為 Account 是綜合/鎖定邊界 (ADR-0010)。將其映射為 JPA @Version 會將每次餘額寫入變成比較和設置:
UPDATE accounts SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
兩個並行寫入者同時載入版本 7。第一個提交的將其設置為 8。第二個的 UPDATE ... WHERE version = 7 現在匹配 零行,Hibernate 在提交時引發 OptimisticLockingFailureException。現在丟失的更新是不可能的:取而代之的是,輸贏者 告知 它損失了。
主要特性:這是 檢測,不是阻擋。 無法讀者從來不等待一個鎖。對於一個賬本 — 在那裡餘額/歷史讀數遠遠多於寫入 — 這很重要。
修復,第二部分:有界重試
檢測單獨不夠。使用@Version 已部署但無重試,壓力測試停止了數據損毀,但一大塊轉移現在不失敗且發生衝突 — 正確,但體驗很糟糕。所以輸家需要重試.
重試輔助程序位於外部 @Transactional,而這個佈局正是整個要點:
public <T> T execute(Supplier<T> operation) {
for (int attempt = 1; ; attempt++) {
try {
return operation.get(); // a FRESH transaction each attempt
} catch (OptimisticLockingFailureException e) {
if (attempt >= maxAttempts) throw new ConcurrencyConflictException();
sleep(backoffWithFullJitter(attempt)); // 25–200 ms, capped
}
}
}
每次嘗試都是一筆全新的交易,將當前行重新加載到其當前版本 — 在失敗的交易中重試只會對過期的版本再次失敗。預設值:5次嘗試,指數退避,帶有完整抖動(以防止雷鳴之群重新同步到另一個衝突),當耗盡時進行一個乾淨的409 Conflict — 永遠不會500.
有一段小推理,使得在適度負載下可證明終止:第 k 個提交者只能輸給一個不同的更早的提交者,所以它最多需要k 次嘗試。有 4 個並行寫入者且嘗試預算為 5,所有 4 個都確定性地成功 — 沒有不可靠的測試。一個真正的熱帳戶(比嘗試預算還多的並行寫入者)浮現為 409,這是誠實的壓力控制而不是隱藏的損毀。
極樂主義對悲觀主義:測量的選擇
顯然的替代方案是悲觀鎖定 —SELECT ... FOR UPDATE 在觸碰前鎖定該列,因此寫入者 #2 只需等待。不重試,容易理解。那麼為何要使用樂觀鎖?
我不希望僅憑直覺爭論此事,因此我寫了一個基準測試 (TransferConcurrencyBenchmark),它在兩種策略下運行完全相同的轉移邏輯,50 個並發寫入者,對抗一個真正的 PostgreSQL:
| 場景 | 樂觀 + 重試 | 悲觀FOR UPDATE
|
|---|---|---|
| 低爭奪 (50 獨立賬戶對) | 34 ms · 50/50 ok · 0 重試浪費 | 31 ms · 50/50 ok |
| 高爭奪 (50 傳輸 → 1 熱行) | 731 毫秒 · 50/50 正常 · 185 重試浪費 | 358 毫秒 · 50/50 正常 |
讀取數字:
- 低爭奪是常態,而且它是一個平手 (34 vs 31 毫秒) — 但樂觀浪費 零 次重試,並且關鍵在於 從不阻礙讀取。那是高讀取量帳本的决定性因素。
- 在單個熱行中,悲觀方式快約2倍(358 vs 731 ms),而且完全不浪費資源,而樂觀方式在衝突和回退上浪費了185次額外嘗試(約是4.7倍的工作量)。但悲觀方式在此處的“勝利”正是通過序列化並阻塞讀取——我正試圖避免的事情——而且它實際上解決 一個熱帳戶,它只是排隊處理。
所以判斷是樂觀+重試,而基準的價值不是「樂觀更快」(在爭奪情況下並非如此)—而是那些185浪費的重試量化了真正熱帳戶(想想:每個充值扣款一個共享)的閾值。SYSTEM_FUNDING 列) 需要真正的升級:非同步併發或子帳戶分片,不是把整個系統切換到悲觀鎖.
具有幂等性的組合
還有一種方式會讓重試實際上使情況更糟 如果你不够小心。如果客戶端在伺服器提交後連接丟失,它會重試整個 HTTP 請求 — 而現在你有風險會重複發送轉移。衝突時重試和網絡暫時中斷時重試是不同的問題,一個問題的修復不應該破壞另一個.
所以兩個金錢終點都需要一個 Idempotency-Key 標頭 (ADR-0012, Stripe 的模式)。讓它變得並發安全的機制是聲明首次:
INSERT INTO idempotency_keys (key, status) VALUES (?, 'PENDING')
ON CONFLICT (key) DO NOTHING; -- committed immediately, before business logic
那個原子插入是序列化點。誰贏得聲明就執行操作;一個同時的請求使用相同鍵看到已提交的PENDING 行並獲得409 (空中) 而不是再次執行。一個完成的金鑰會重播儲存的回應;一個與 不同的 體重用會得到 422 (客戶合約違規,故意與 409 矛盾代碼區分開)。
這能與之前的重試整潔地組合的原因:樂觀鎖重試位於 之後 已聲明鍵。所有內部嘗試都發生在一個已經聲明的 idempotency 鍵之下,所以它們對客戶完全不可見,而且永遠無法產生第二次發布。衝突重試和請求 idempotency 堆疊,而不是互相爭鬥。
下一步我要嘗試的
快取很快,但它 會漂移(一個錯誤,部分失敗)。所以一個排程調和 這個工作從不可變的記錄中重新導出每一筆餘額,並在任何不符處發出警報 — 它從不自動更正;操作員會登記一筆更正記錄。只能附加的賬簿意味著真相總是可以恢復。
而那些185次重試暴露出的熱帳戶上限是下一個真正的擴展問題:當一列資料真正發生爭奪時,答案就是非同步發布或分割該帳戶,並以重試率作為信號,告訴你當你越過了界限。
主線:在一個金錢體系中,快取與賬本不一致 才是重要的失敗,而一個預設隔離數據庫不會阻止你創建它。一個 @Version 比較並設定讓丟失更新變得不可能,有界重試帶抖動讓它在正常負載下變得無法察覺,基準測試告訴你付出的代價以及上限在哪裡,而可重入性確保重試——在每一層——從不變成重複花費。
程式碼: github.com/xidoke/ledger-service — 關於 並發模型 的文件和 ADRs 0005、0006、0011、0012 將會更深入探討。 實時示範: ledger-service-bjzr.onrender.com (免費實例 — 首次請求冷啟動約50秒)。












