π₯ The βMoney Vanishingβ Story: Concurrency & Data Integrity!
Go concurrency lab: 800 VUs, ledger integrity, OCC vs pessimistic locking. GitHub (reproduce locally): https://github.com/muhammadahmed-01/Go-Concurrency-Ledger-Integrity Β· Measured results: k6/K6_RESULTS.md Maps directly to DDIA Chapter 7 (Transactions) and Chapter 8 (Distributed Systems). Hardware: Intel i5-12500H Β· 16GB RAM Β· Windows 11 (battery; absolute latencies would be lower on AC/server hardware; relative comparisons hold) Stack: Go Β· PostgreSQL Β· Prometheus Β· Grafana Β· k6 Load: 800 VUs ramped over 50s (200 β 500 β 800), starting balance: $1,000,000 Two goroutines simultaneously deduct $10 from the same account. After both finish, only $10 was deducted β not $20. Money vanished. This is the Lost Update Problem β one of the most common bugs in concurrent systems, and the most dangerous because it produces no errors. Read-Modify-Write without synchronization. The check and write are not atomic. Between reading and writing, another goroutine reads the same stale value. Both write back a balance reflecting only their deduction. Maps to DDIA Β§7.1 β Dirty Reads and Lost Updates. Principle: Assume conflict will happen. Lock the resource before reading. Principle: Assume conflict is unlikely. Donβt lock β detect conflicts on write and retry. The Key insight: Completed iterations differ significantly per mode β this is a finding, not noise. Buggy has no overhead so all requests complete. Pessimistic serializes. Optimistic hits retry limits. The most dangerous failure mode in production. β‘ The Key Insight: Your monitoring shows all green while silently destroying data. This is why data integrity bugs are more dangerous than crashes. Crashes are obvious. Silent corruption is not. The version column is the smoking gun: proves OCC was always correct β but correctness required ~580,000 retries. The buggy endpoint violates Isolation β two transactions observe each otherβs in-progress state. Pessimistic locking restores it. Maps to DDIA Β§7.2. This is the fundamental tension in distributed systems β the tradeoff DDIA explores across Chapters 7β9. OCC requires retries. If your operation is not idempotent, retrying causes double-charges, double-sends, double-emails. Always pair retry logic with idempotency keys. Maps to DDIA Β§11.5. At millions of requests/second on the same row, even OCC breaks down. Escalation path: Remove the database from the hot path entirely. Route all deductions through a single-threaded processor fed by an in-memory ring buffer. DB write is async β the processor never waits for disk. Why this beats pessimistic + semaphore: A semaphore reduces how many goroutines fight over the row β the fight still exists. LMAX removes the fight entirely. Processes at memory speed: no locks, no retries, no conflicts. LMAX-based systems handle millions of ops/sec per core. Cons: High implementation complexity, fully async I/O required, hard to scale horizontally. Use when: Payment processors, matching engines, HFT β anywhere hot row contention is unavoidable and throughput targets canβt be met with locking. Cap concurrent DB writers so excess requests queue cheaply in Go, not expensively in Postgres: Still DB-row-bound, but eliminates the goroutine flood causing 503 storms. Right choice when simplicity matters more than maximum throughput.1. The Problem
Account balance: 1,000,000
Goroutine A: READ balance β 1,000,000
Goroutine B: READ balance β 1,000,000 β same stale value, before A writes
Goroutine A: WRITE balance = 999,990
Goroutine B: WRITE balance = 999,990 β overwrites A. One deduction lost.
Final: 999,990 (should be 999,980)2. Root Cause
// β BUGGY β classic lost update
balance := SELECT balance FROM accounts WHERE id=1 // read
// another goroutine reads the same value here
UPDATE accounts SET balance = balance-10 WHERE id=1 // overwrite, not decrement3. Three Strategies Tested
Fix #1 β Pessimistic Locking
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- acquires exclusive row lock
-- other goroutines trying FOR UPDATE BLOCK here
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
COMMIT; -- lock releasedSELECT FOR UPDATE acquires an exclusive row-level lock. Any other transaction attempting to read or write the same row blocks until the first commits or rolls back. Guarantees serialization β no two goroutines inside the critical section simultaneously.β
Pros β Cons Correctness guaranteed Serializes all deductions β lower throughput Simple mental model Lock contention grows with concurrency Database enforces it Deadlock risk with multiple rows Fix #2 β Optimistic Concurrency Control (OCC)
-- Read without any lock
SELECT balance, version FROM accounts WHERE id = 1;
-- balance=999,990, version=42
-- Write only if version hasn't changed (Compare-And-Swap)
UPDATE accounts
SET balance = balance - 10, version = version + 1
WHERE id = 1 AND version = 42;
-- If another goroutine already incremented version β rows_affected = 0 β RETRYWHERE version = $current_version guard is a CAS operation. If rows_affected == 0, a conflict occurred β retry from the read. The database never blocks; goroutines spin instead of waiting.β
Pros β Cons Higher throughput when contention is low CPU burns on retries under high contention No deadlock risk Retry logic adds code complexity Scales horizontally Fairness not guaranteed β goroutines can starve 4. Real Benchmark Results
Metric Buggy Optimistic Pessimistic Completed Iterations 160,207 21,798 51,914 Effective Deductions 964 β 12,768 β
51,293 β
Final Balance 990,360 β 872,320 β
487,070 β
Money Lost $9,640 vanished $0 $0 p95 Latency (peak) ~600ms ~4,250ms π΄ ~2,200ms π΄ Error Rate 0% β
~5β8% (409/500) ~15β28% (503) k6 SLA Thresholds β
Passed β Failed β
Passed DB Version at End 1 (never incremented) 12,769 β
N/A Data Integrity β CORRUPTED β
Correct β
Correct 5. Detailed Findings
π¨ Finding 1 β The Invisible Failure (Buggy Mode)
1 β every goroutine overwrote the same cycleπ¨ Finding 2 β The Retry Storm (Optimistic Mode)
409 responses where the version CAS guard rejected the write after retries were exhausted, and 500 responses where the Go context timeout cancelled the in-flight Postgres query mid-execution β visible in DB logs as canceling statement due to user request.version=12,769 matched 12,768 effective deductions exactly β correctness maintained, at enormous costπ¨ Finding 3 β The Availability Cliff (Pessimistic Mode)
6. Broader Concepts This Experiment Demonstrates
ACID Guarantees
Isolation Levels
SELECT FOR UPDATE at Read Committed gives Serializable-like safety for specific rows without changing the whole transactionβs isolation level β surgical and efficientLost Updates β Six Prevention Strategies (DDIA Β§7.4)
UPDATE ... SET balance = balance - 10 β safe if no read in between)SELECT FOR UPDATE) β what we testedThroughput vs Consistency
Retries and Idempotency
What This Looks Like at Stripe/Netflix Scale
7. The Contention Spectrum
SINGLE HOT ROW (extreme contention)
β
Buggy ββββββββββββββββββββββΌββββββββββ Pessimistic
(fast, silent β (correct, serialized,
corruption) β 503s under load)
β
Optimistic
(correct but retry storm
at high contention,
excellent at low contention)
β
MANY INDEPENDENT ROWS (low contention)
(OCC wins β conflicts rare,
no blocking, horizontally scalable)8. Real-World Architectural Solutions at Scale
Option A β LMAX Sequencer (Right Answer for High-Frequency Deductions)
[800 VUs] β [Ring Buffer] β [Single Worker Thread] β [Async DB write]
(sequential, zero contention)Option B β Pessimistic Locking + Go Semaphore (Pragmatic Middle Ground)
var sem = make(chan struct{}, 25)
func deductHandler(w http.ResponseWriter, r *http.Request) {
sem <- struct{}{}
defer func() { <-sem }()
// Now 25 goroutines compete, not 800
runPessimisticDeduction(r.Context(), db)
}Option C β Sharding, Eventual Consistency, Event Sourcing
Approach Use When Sharding Many independent accounts, low per-account contention Async Queue (SQS/Kafka) Can tolerate stale reads, non-critical aggregates Event Sourcing Full audit trail required, append-only semantics 9. Decision Framework
Is contention on a single hot row unavoidable?
βββ No (many independent rows) β OCC (Optimistic Locking)
βββ Yes
βββ Maximum throughput required β LMAX Sequencer
βββ Simplicity > max throughput
βββ Low-medium traffic β Pessimistic + Semaphore
βββ Can tolerate stale reads β Eventual Consistency