πŸ’₯ The β€œMoney Vanishing” Story: Concurrency & Data Integrity!

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


1. The Problem

Two goroutines simultaneously deduct $10 from the same account. After both finish, only $10 was deducted β€” not $20. Money vanished.

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)

This is the Lost Update Problem β€” one of the most common bugs in concurrent systems, and the most dangerous because it produces no errors.


2. Root Cause

Read-Modify-Write without synchronization.

// ❌ 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 decrement

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.


3. Three Strategies Tested

Fix #1 β€” Pessimistic Locking

Principle: Assume conflict will happen. Lock the resource before reading.

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 released

SELECT 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 guaranteedSerializes all deductions β†’ lower throughput
Simple mental modelLock contention grows with concurrency
Database enforces itDeadlock risk with multiple rows

Fix #2 β€” Optimistic Concurrency Control (OCC)

Principle: Assume conflict is unlikely. Don’t lock β€” detect conflicts on write and retry.

-- 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 β†’ RETRY

The WHERE 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 lowCPU burns on retries under high contention
No deadlock riskRetry logic adds code complexity
Scales horizontallyFairness not guaranteed β€” goroutines can starve

4. Real Benchmark Results

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.

MetricBuggyOptimisticPessimistic
Completed Iterations160,20721,79851,914
Effective Deductions964 ❌12,768 βœ…51,293 βœ…
Final Balance990,360 ❌872,320 βœ…487,070 βœ…
Money Lost$9,640 vanished$0$0
p95 Latency (peak)~600ms~4,250ms πŸ”΄~2,200ms πŸ”΄
Error Rate0% βœ…~5–8% (409/500)~15–28% (503)
k6 SLA Thresholdsβœ… Passed❌ Failedβœ… Passed
DB Version at End1 (never incremented)12,769 βœ…N/A
Data Integrity❌ CORRUPTEDβœ… Correctβœ… Correct

5. Detailed Findings

🚨 Finding 1 β€” The Invisible Failure (Buggy Mode)

The most dangerous failure mode in production.

  • βœ… HTTP 200 on every request, 0% error rate, lowest latency, highest throughput
  • ❌ Only 964 effective deductions out of 160,207 attempts. $9,640 vanished silently.
  • Version column stayed at 1 β€” every goroutine overwrote the same cycle

⚑ 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.


🚨 Finding 2 β€” The Retry Storm (Optimistic Mode)

  • Postgres logs confirmed two distinct failure modes: 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.
  • Only 21,798 iterations completed β€” 7Γ— less useful work than buggy mode
  • p95 climbed to ~4,250ms β€” highest of all three
  • version=12,769 matched 12,768 effective deductions exactly β€” correctness maintained, at enormous cost
  • Postgres hit 49.6% CPU just managing retry overhead
  • System continued processing in-flight retries after k6 stopped β€” retry storms have a latency tail that outlasts the load

The version column is the smoking gun: proves OCC was always correct β€” but correctness required ~580,000 retries.


🚨 Finding 3 β€” The Availability Cliff (Pessimistic Mode)

  • ~155 failed requests/second at peak (503)
  • p95 reached ~2,200ms
  • Only 51,914 iterations β€” 3Γ— fewer than buggy, but nearly all correct
  • Without the 2s context timeout, goroutines would pile up until OOM. 503 is the right production behavior.

6. Broader Concepts This Experiment Demonstrates

ACID Guarantees

The buggy endpoint violates Isolation β€” two transactions observe each other’s in-progress state. Pessimistic locking restores it. Maps to DDIA Β§7.2.

Isolation Levels

  • Read Committed (PostgreSQL default): prevents dirty reads, but not lost updates
  • Repeatable Read / Serializable: prevents lost updates, higher overhead
  • SELECT FOR UPDATE at Read Committed gives Serializable-like safety for specific rows without changing the whole transaction’s isolation level β€” surgical and efficient

Lost Updates β€” Six Prevention Strategies (DDIA Β§7.4)

  1. Atomic writes (UPDATE ... SET balance = balance - 10 β€” safe if no read in between)
  2. Explicit locking (SELECT FOR UPDATE) β€” what we tested
  3. Compare-and-set (OCC version column) β€” what we tested
  4. Conflict detection (database detects and aborts)
  5. Application-level locking (Redis distributed lock)
  6. Serializable isolation (nuclear option β€” correct but expensive)

Throughput vs Consistency

  • Buggy: max throughput, zero consistency
  • Pessimistic: min throughput, max consistency
  • Optimistic: good throughput when contention is low, catastrophic under heavy conflict

This is the fundamental tension in distributed systems β€” the tradeoff DDIA explores across Chapters 7–9.

Retries and Idempotency

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.

What This Looks Like at Stripe/Netflix Scale

At millions of requests/second on the same row, even OCC breaks down. Escalation path:

  • Shard the account β€” split one row into N shards, sum on read (reduces hot-row contention by NΓ—)
  • Event sourcing β€” append-only log, no in-place updates, replay to get balance
  • CRDT-based balance β€” conflict-free replicated data types, mathematically merge concurrent updates without coordination

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)

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.

[800 VUs] β†’ [Ring Buffer] β†’ [Single Worker Thread] β†’ [Async DB write]
                                (sequential, zero contention)

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.


Option B β€” Pessimistic Locking + Go Semaphore (Pragmatic Middle Ground)

Cap concurrent DB writers so excess requests queue cheaply in Go, not expensively in Postgres:

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)
}

Still DB-row-bound, but eliminates the goroutine flood causing 503 storms. Right choice when simplicity matters more than maximum throughput.


Option C β€” Sharding, Eventual Consistency, Event Sourcing

ApproachUse When
ShardingMany independent accounts, low per-account contention
Async Queue (SQS/Kafka)Can tolerate stale reads, non-critical aggregates
Event SourcingFull 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