Concurrency in Online Services
One book, one database, two buyers clicking "purchase" at the same instant. Everything in this chapter — isolation levels, locks, snapshot isolation — exists to make sure exactly one of them succeeds and the data never lies.
After this you should be able to
- Explain the core idea in Concurrency in Online Services
- Apply Concurrency in Online Services in a representative design
One book, one database, two buyers clicking "purchase" at the same instant. Everything in this chapter — isolation levels, locks, snapshot isolation — exists to make sure exactly one of them succeeds and the data never lies.
This opens a new, advanced course — Designing Modern Web-Scale Distributed Services. It starts where it must: getting concurrency right on a single database before scaling to many. The thread is one bookstore example carried throughout — first the problem (why a code lock fails across servers), then the four anomalies isolation must prevent, the four isolation levels ranked by strictness, the locks and snapshots that implement them, and finally the optimistic-vs-pessimistic choice. Anything marked [+] is context I added; everything else is rewritten from the three teaching lessons.
A code-level lock breaks the moment you scale out#
The naive fix for concurrent access is a language keyword — and it works right up until you add a
second server. Picture an online bookstore on a single database, and one book with one copy left as
multiple customers try to buy it. The tempting solution is Java's synchronized keyword on the
purchase method:
public synchronized void buyBook() { // book purchase code }
synchronized ensures only one request thread enters buyBook() at a time; other
concurrent buyers' threads wait until the first transaction completes. That handles the race — with one catch.
synchronized obtains its lock within a single JVM instance. Run the app
across multiple servers behind a load balancer and it fails — each JVM locks independently, so two servers
can both enter buyBook() at once. You'd need a distributed lock across JVMs. The better answer:
let the database handle concurrent access via transaction isolation levels.
synchronized lock is per-JVM. Behind a load balancer, each server
locks independently, so both reach the single database concurrently — the coordination has to move into the
DB. [+] Diagram of the lesson's multi-JVM failure.Isolation is the "I" in ACID. An ACID transaction executes without affecting other transactions, ending in a durable, consistent state — and rolling back entirely on any failure. Every ACID-compliant database offers transaction isolation levels (each with its own implementation) so the database, not your code, keeps concurrent transactions isolated enough to stay consistent.
The four ways concurrent reads go wrong#
Isolation levels are defined by which of four anomalies they prevent. All four leave the database showing something false.
| Anomaly | What happens (bookstore example) |
|---|---|
| Dirty read | T1 updates a row but hasn't committed; T2 reads that uncommitted value and sends it to the UI. Then T1 rolls back or changes it — T2 already delivered wrong data. A clerk bills a customer for an order the salesperson hasn't finalized (or later cancels). |
| Lost update | T1 reads count 10, sells 2 → writes 8. Concurrently T2 also read 10, sells 4 → writes 6. T1's update is lost; the true count should be 4. |
| Non-repeatable read | T1 reads a book's count as 4 and shows it; before checkout, T2 buys 2; T1 re-reads and now sees 2. The same read gave two different values within one transaction. |
| Phantom read | T1 runs a search matching a criteria; T2 concurrently inserts rows matching that same criteria; T1 re-runs the search and new "phantom" rows appear that weren't in the first result set. |
Non-repeatable read is about an existing row's value changing between two reads. Phantom read is about the set of rows changing — new rows appearing that match your query. That difference is exactly what separates the two middle isolation levels. [+]
Four isolation levels, strictest to loosest#
The databases offer four isolation levels; each looser level permits one more anomaly in exchange for better performance. Here's the whole ladder at a glance:
| Level | Dirty read | Lost update | Non-repeatable | Phantom |
|---|---|---|---|---|
| Serializable (strictest) | ✕ prevented | ✕ | ✕ | ✕ |
| Repeatable Reads | ✕ | ✕ | ✕ | ⚠ possible |
| Read Committed | ✕ | ⚠ | ⚠ | ⚠ |
| Read Uncommitted (loosest) | ⚠ | ⚠ | ⚠ | ⚠ |
Serializable — the strictest
Guarantees concurrent transactions behave as if they ran serially, one after another. It prevents all four anomalies. The cost is performance: the database locks resources so only one transaction touches a resource at a time, which cuts throughput.
Repeatable Reads
A transaction gets the same value on repeated reads despite concurrent updates. T1 reads a page's comment count as 20; T2 adds two (→22); T1's second read still returns 20. Prevents dirty reads, lost updates, and non-repeatable reads — but phantom reads are still possible (new matching rows can appear).
Read Committed
A transaction sees the latest committed value on each read. Same example: T1 reads 20, T2 commits +2, T1's second read now returns 22. Prevents only dirty reads; lost updates, non-repeatable reads, and phantoms are all still possible. Less strict than Repeatable Reads.
Read Uncommitted — the loosest
Transactions aren't isolated from each other at all — all four anomalies can occur, including dirty reads. Mostly useful for read-only use cases where inconsistency doesn't matter.
(1) Business requirements and (2) the database's specific implementation — isolation levels have subtle differences between databases. Confirm your choice by reading the DB's documentation and checking its concurrency-test results for your specific scenarios.
How it's enforced: locks and snapshots#
Databases implement isolation with read and write locks on resources — applied at row, table, or whole-database scope. The bigger the lock's span, the bigger the performance hit.
Why even lock reads? A read lock stops other transactions from seeing a partial update mid-flight — like the salesperson/clerk billing example, where reading an uncommitted order caused a wrong bill. But locking every read is a heavy performance overhead. The fix is snapshots.
Snapshot isolation
Instead of locking, give every transaction its own snapshot of the record it reads or writes. Writes happen on the transaction's private snapshot version, not the real record. At commit, the update is applied to the actual record only if no other concurrent transaction made a conflicting update to it — otherwise the losing transaction is aborted and must retry.
The upside is on reads — new transactions never wait for read locks, so everyone gets a consistent snapshot and read-only transactions never abort. The downsides: it offers too much isolation when transactions need to see each other's updates quickly (they're working on separate versions merged later), and holding a version per transaction is memory-intensive.
With no write locks, n users could each book the same flight seat on their own snapshot — only to be told at merge that their transaction failed. Same danger in banking transactions on one account. For these, locks are a must. Also: every DB implements isolation differently, and concurrency bugs mostly surface in production — they're hard to reproduce in a dev environment, so test concurrent scenarios thoroughly.
Optimistic vs. pessimistic concurrency control#
The two implementation strategies come down to a single assumption: how many write conflicts do you expect?
| Optimistic | Pessimistic | |
|---|---|---|
| Assumes | Few/no write conflicts. | Many write conflicts. |
| Uses locks? | No — discourages locks; transactions work on snapshots and merge. | Yes — uses DB locks to serialize access. |
| Implemented by | Snapshot isolation. | Locking (e.g. serializable via read/write locks). |
| Trade-off | Better performance; conflicts cause aborts/retries at merge. | Guaranteed consistency; performance hit from locking. |
| Pick when | Conflicts are rare. | Conflicts are common (flights, banking). |
This restates the chapter's central tension: serializable + locks guarantees consistency but hurts performance; snapshot isolation needs no locks and performs better but breaks down under many write conflicts. Match the strategy to your conflict rate.
Everything here assumed a single database instance. The next chapter asks what happens when load outgrows one instance — either the data swells beyond one server or traffic spikes beyond what one DB can serve — which opens the door to partitioning and replication.
Recap#
synchronized is per-JVM; behind a load
balancer it fails. Push concurrency control into the database.Consistency and performance pull in opposite directions, and isolation level is the dial between them. Every stricter level buys correctness by giving up throughput; every looser level buys speed by permitting one more way for the data to lie. The whole skill is knowing exactly how much lying your use case can survive — a like counter can drift, a bank balance cannot.