慣性聚合 高效追蹤和閱讀你感興趣的部落格、新聞、科技資訊
閱讀原文 在慣性聚合中打開

推薦訂閱源

博客园 - 司徒正美
V
V2EX
T
Tailwind CSS Blog
有赞技术团队
有赞技术团队
aimingoo的专栏
aimingoo的专栏
Apple Machine Learning Research
Apple Machine Learning Research
IT之家
IT之家
Blog — PlanetScale
Blog — PlanetScale
A
About on SuperTechFans
月光博客
月光博客
T
The Blog of Author Tim Ferriss
宝玉的分享
宝玉的分享
Martin Fowler
Martin Fowler
博客园 - 聂微东
The GitHub Blog
The GitHub Blog
V
Visual Studio Blog
WordPress大学
WordPress大学
酷 壳 – CoolShell
酷 壳 – CoolShell
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python)
我在我的雙重記錄帳中發現的一個競態條件——以及我是如何解決它的
Do Pham Dinh · 2026-05-24 · via DEV Community

我正在建立帳本服務(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 0005000600110012 將會更深入探討。 實時示範: ledger-service-bjzr.onrender.com (免費實例 — 首次請求冷啟動約50秒)。