Skip to content

Serializable fixed the bug. Why did checkout get slower?

When transactions disagree · Part 5 of 5 · 6 min read

Read the worked explanation ↓

A ticket-booking team found that concurrent transactions could break a seat-allocation rule. They changed the relevant database operations to PostgreSQL SERIALIZABLE. The original anomaly disappears in their tests. A week later, 15 percent of booking attempts abort during peak demand, and customers see longer waits.

Someone proposes switching back to REPEATABLE READ. Someone else proposes ten automatic retries. Neither suggestion starts with the right question.

The interview question. Keep the invariant. Explain why serializable transactions can abort even without deadlocks, determine how much retry work the service is doing, and choose a plan to recover throughput without silently weakening correctness.

Think first

After moving booking writes to PostgreSQL SERIALIZABLE, 15 percent of transaction attempts abort with 40001 during popular event launches. Developers propose ten retries per request or reverting isolation. Explain the safety mechanism, estimate useful throughput under a simplifying independent-abort model, and produce a measured mitigation plan that preserves the seat invariant.

Take a few minutes to form an approach, then compare it with the worked explanation.

Reveal the worked explanation

Correctness can require rejecting apparently successful work

In PostgreSQL, SERIALIZABLE does not simply lock every record in the database until one transaction completes. Its implementation builds on snapshot isolation and monitors read/write dependencies that could produce an outcome inconsistent with every valid serial ordering.

Two booking transactions may run using snapshots that each looked reasonable. If their combined read/write dependencies would allow a forbidden result, PostgreSQL can reject a transaction with SQLSTATE 40001. The rejected transaction's work is not committed. PostgreSQL transaction isolation

A deadlock is different. In the previous lesson, two transactions were each waiting for locks held by the other. Here, the database may detect a dangerous dependency pattern even though the transactions are not waiting on each other in a circular lock graph. PostgreSQL's serializable predicate or SIReadLock information supports conflict detection without acting like an ordinary blocking row lock.

What changed for the application is the success contract. A transaction can reach the end of its computation and still fail at commit. Only a successful commit makes its earlier reads and decisions valid for the business result.

Count successful decisions, not just transaction attempts

Suppose booking demand requires 100 successfully committed operations per second. Assume for a simple calculation that each transaction attempt has an independent 15 percent chance of serialization failure, and retries see the same probability. This independence is a teaching assumption, not a description of what actually happens under contention.

The expected attempts per success are approximately:

1 / (1 - 0.15) = 1.1765 attempts
100 committed operations/s require ~118 attempts/s
Serializable transaction aborts cause additional attempts per successful booking, with rising contention potentially creating a feedback loop
Serializable transaction aborts cause additional attempts per successful booking, with rising contention potentially creating a feedback loop

In that model, the database executes about 18 percent more transaction attempts than the useful committed throughput suggests. If transactions spend time reading, validating, and waiting on resources before aborting, much of that work is wasted. Additional attempts can increase overlap between transactions, which may raise the conflict rate and invalidate the independence assumption. A hot system can therefore become worse because of its own recovery traffic.

Also distinguish two metrics. Fifteen aborted attempts out of 100 attempts leaves 85 commits if no retries occur. That is not the same as a steady service delivering 100 completed business operations per second through retries. Measure both attempt volume and completed operations.

What would you investigate before changing isolation?

Break serialization failures down by transaction type, endpoint, table access, and time window. Look for the few operations that dominate the abort count. A global 15 percent average might conceal a single seat-sale workflow aborting half its attempts while most other writes are unaffected.

Examine transaction duration and concurrency. A transaction that reads availability, calls a remote service, calculates several reports, and then updates one seat keeps its dependency window open longer than necessary. Move nonessential work outside the database transaction. Do not move the actual invariant check outside it.

Look at the query access paths too. In PostgreSQL, serializable conflict tracking depends partly on which data a query actually accesses. Coarse predicate locks can arise from broad scans or lock promotion under memory pressure. If a supposedly narrow check scans a whole table, a suitable index or a better key scope may reduce unnecessary conflicting read footprints. The PostgreSQL documentation specifically discusses this relationship between predicate-lock granularity and serialization failures. PostgreSQL transaction isolation

Finally ask whether bookings for different events share one unnecessary hotspot. If every concert uses a single global availability record, transactions that could be independent are forced to contend. Moving enforcement to a per-event or per-seat authority may help, provided every operation that could violate that specific invariant still meets at the right boundary.

Retry the decision, not the old conclusion

The application must handle 40001 by aborting and rerunning the complete transaction, including reads that led to its choice. If the original snapshot showed seat A available, a retry cannot simply repeat an earlier instruction to book seat A without checking its current state.

Set a bounded retry budget and a deadline that includes all database attempts. Use jittered backoff or explicit admission control when contention is high. After the budget is exhausted, return a truthful outcome that the client can act on. Do not spend five seconds repeatedly fighting for the same last seat while other work starves.

If the transaction must trigger an external side effect, keep that operation outside the retryable transaction body behind an appropriate durable boundary. An external effect that may already have happened needs deduplication or reconciliation before it is repeated. Here the lesson is the cost of repeated transaction computation.

It may also be possible to enforce a narrow rule with a conditional update or short lock on a shared owner, rather than using serializable reads across many independent rows. That is a design comparison, not permission to weaken the invariant. Benchmark both approaches on the actual contention shape and the failure mode you need to protect.

A good Staff interview answer demonstrates that serializable isolation protected something important, measures the work rejected to achieve it, and finds a way to reduce the conflicting work.

The system is now correct, but peak behavior still needs engineering. How much useful work can the database finish when competing requests keep repeating calculations that can never all commit?

Go deeper

A safe retry budget is a product decision too

A fixed maximum attempt count is not a universal answer. Combine a small attempt limit with remaining request deadline, conflict classification, and admission policy. Transient contention and a permanent business conflict need different client outcomes. Record p50, p99, retry count, abort cause, and successful transactions per second, not just the number of database statements executed.

PostgreSQL documents that SIReadLock predicate locks used for serializable dependency detection do not cause the ordinary blocking associated with row-level locks. They can be promoted to coarser granularity. Poor indexing or overly broad scans can make conflicts harder to avoid, while replacing scans with index probes also has its own cost.

A READ ONLY DEFERRABLE serializable transaction can wait for a safe snapshot before performing long reports, but that is a specialized optimization for a read-only workload, not a shortcut for a seat-booking write transaction.

A better benchmark records a range of concurrency and skew, not just maximum QPS. A booking workload with a single heavily contested event behaves differently from 10,000 independent events. The measured success rate must include the end-to-end business commit, not only how quickly the application accepted requests.

Check your reasoning

  • Explain serializable conflict aborts without treating them as deadlocks or a faulty database.
  • Calculate that a 15 percent independent abort chance yields about 1.176 attempts per eventual success before retry limits.
  • Retry the entire decision with fresh reads, and bound attempts by deadline and admission policy.
  • Measure abort distribution by workload and inspect transaction duration, predicate-lock scope, query plans and skew.
  • Compare narrower authoritative conditional writes or lock scopes without silently relaxing the correctness guarantee.

Common mistake: Switching back to unsafe isolation, endlessly replaying a precomputed write, or measuring attempted statements rather than completed business decisions.

PostgreSQL SERIALIZABLE returns 40001 because read/write dependencies could produce a non-serial history. What should the application do?

Read the answer explanation

Abort and retry the complete transaction from fresh reads within a bounded policy. — Serializable aborts invalidate the current decision. Rerun all reads and calculations in a new transaction with bounded attempts.

Apply it elsewhere

A customer quota service uses SERIALIZABLE to protect a per-tenant resource budget. A few huge tenants have very high abort rates, but ordinary tenants do not. What would you measure and redesign?

Compare your approach

Separate tenant hotspots, evaluate the predicate and lock footprint, reduce transaction span and use a per-tenant authoritative conditional decision. Preserve the same quota invariant.

One more constraint

Indexing reduces serialization failures but slows the query in isolation. What should determine whether the index is worthwhile?

Quick revision

PostgreSQL Serializable Snapshot Isolation rejects transactions when their read/write dependencies could produce a result inconsistent with every serial ordering. This can produce 40001 without a deadlock. Retry the complete transaction, including its reads, under a bounded attempt and deadline policy. A 15 percent per-attempt abort probability would require about 118 attempts for 100 successful commits per second under a simplified independent model, and real retries may increase contention. Investigate hot transaction types, duration, query access paths, predicate-lock scope, and concurrent workload. Keep the invariant instead of quietly lowering isolation.

References