Why you need concurrency control, and when to use it
When two requests change the same data at the same time, the result can be wrong even though each request is correct on its own. Both read a balance of 100, both add 10, both write 110, and one update is lost. Concurrency control is the set of rules that makes simultaneous changes behave as if they ran one after another.
It gives you two things interviewers always ask about:
- Correctness: no lost updates, no double spending, no two people booking the same seat.
- Throughput: as many requests as possible run at the same time without breaking correctness.
Use it when:
- Several requests can change the same row or object: balances, stock, seats, counters.
- A decision depends on data that another request might change at the same moment (check, then act).
- You enforce a rule such as "at most one" or "never negative".
Skip it, or use something simpler, when:
- Requests touch different data, so they never collide.
- Changes can be written as one atomic statement (
UPDATE ... SET balance = balance + 10), which the database makes safe by itself. - Data is append-only, so nothing is ever overwritten.
Words used in this lesson
- Transaction: a group of reads and writes that the database treats as one unit, which either all take effect (commit) or none do (abort).
- Lock: a mark on a row that makes other transactions wait until the owner commits or aborts.
- Version number: a counter stored with the row that changes on every write, so a writer can tell if someone else got there first.
- Commutative: the order does not matter. Adding 10 and then 5 gives the same result as adding 5 and then 10.
- Lease: a lock that expires by itself after a set time, so a crashed owner cannot hold it for ever.
- Fencing token: a number that grows each time a lock is granted. The resource being protected rejects any request carrying an older number.
- Idempotent: doing it twice has the same effect as doing it once, which makes a retry safe.
A worked example of a lost update, step by step. The balance is 100 and both transactions run at the same time. Transaction 1 reads 100. Transaction 2 reads 100. Transaction 1 writes 110 and commits. Transaction 2 writes 110 and commits. The final balance is 110, not 120, because transaction 2 worked from a value that was already out of date. Locks make transaction 2 wait for transaction 1, and version checks make transaction 2 fail and read again.
Try it
Several users add 10 to the same balance. Change the control, then change the load.
Concurrency Control
Step 1 of 5 · Press Space to play, arrow keys to stepInitial state. 2 users each want to add 10 to a balance of 100. The correct final balance is 120.
- tick:
- 0
- final balance:
- 100
- lost updates:
- 0
- retries:
- 0
- waiting turns:
- 0
1 tick = one step of the simulation.
Things to try:
- Run None with 2 users and see an update lost. Then try 5 users.
- Run Pessimistic and watch users queue behind a lock. The answer is right, but users wait.
- Run Optimistic with 2 and then 5 users, and compare the retries. Finally try different rows, where no control is needed.
What to watch:
- The counters are lost updates, retries and waiting turns. A waiting turn means a user sat idle behind a lock, and a retry means a user did the work again after a rejected write. They are different costs, even when the numbers match.
- In the optimistic run every user starts at the same moment, so each round has exactly one winner and the rest retry. That is the worst case, so 5 users give 10 retries. With rare conflicts the real number is close to zero.
- The simulation is a simplified model. It does not show a lock holder crashing, a deadlock or write skew. Those are covered in the failure section below.
Concurrency control approaches
| Approach | How it works | Good when | Weakness |
|---|---|---|---|
| Atomic statement | Let the database do read and write as one step (SET x = x + 1) | Simple counters and updates | Only covers one statement |
| Pessimistic locking | Lock the row before reading it (SELECT ... FOR UPDATE) and release at commit | Conflicts are common, mistakes are costly | Waiting, and deadlocks when locks are taken in different orders |
| Optimistic locking | Read a version number, and write only if it has not changed | Conflicts are rare | Retries under contention, and wasted work |
| Isolation levels | The database hides or prevents anomalies between transactions | General correctness inside one database | Stronger levels reduce concurrency |
| Serialisable transactions | The database detects and aborts conflicting transactions | Complex rules across many rows | Aborts that the application must retry |
| Distributed locks or queues | One owner or one queue serialises access across services | Resources outside a single database | Failure handling is hard |
Pessimistic vs optimistic
- Pessimistic: assume conflict will happen, so take a lock first. Others wait. It is predictable under heavy contention, but it holds locks while waiting, which reduces throughput and risks deadlocks.
- Optimistic: assume conflict is rare, so read freely and check at write time that nothing changed. A loser retries. It is cheap when conflicts are rare and wasteful when they are common.
A third idea avoids both: design so most updates are atomic and commutative (add to a counter), or route all changes to one row through a single owner or queue.
How isolation levels work
A database transaction can see other transactions' changes in different ways. The standard levels, from weakest to strongest:
- Read committed: you see only committed data, but a repeated read can return a different value. This is the default in many databases.
- Repeatable read (snapshot): you see a consistent snapshot for the whole transaction. Lost updates are prevented or detected, depending on the database.
- Serialisable: the result is as if transactions ran one at a time. The database may abort a transaction, and your code must retry.
Higher levels give stronger guarantees with more waiting or aborts.
Databases differ, so check yours. PostgreSQL, Oracle and SQL Server default to read committed, but MySQL with InnoDB defaults to repeatable read. At repeatable read, PostgreSQL stops the second writer of a row that changed after your snapshot with a serialisation error, and your code must retry. MySQL InnoDB does not: if you read a balance with a plain query, compute in the application and write it back, the stale write succeeds and the first update is lost. In MySQL, use an atomic statement, SELECT ... FOR UPDATE, a version check or serialisable. Serialisable in PostgreSQL detects conflicts and aborts one transaction, while serialisable in MySQL InnoDB mostly uses locks, so you see waiting and deadlock aborts instead. This is a summary, so confirm the details in your database's documentation. Know which anomaly you are guarding against: lost update, non-repeatable read, write skew (two transactions each check a rule and both break it together) or phantom rows.
Concurrency control vs the alternatives
| Concern | Locks or versions | No control | Single-threaded owner |
|---|---|---|---|
| Correct with concurrent writers | Yes | No | Yes |
| Throughput under contention | Medium | Highest, but wrong | Limited to one worker |
| Waiting or retries | Yes | None | Queueing |
| Complexity | Medium | None | Medium |
| Works across services | Hard | No | Yes, via a queue |
| Best for | Shared rows in a database | Independent data | Hot items such as a ticket counter |
Use an atomic statement first, then locks or versions, and a single owner for very hot items.
Avoiding a new single point of failure
- Lock holder crashes: locks must have timeouts or be tied to the transaction, so a crash releases them.
- Deadlocks: take locks in a fixed order, keep transactions short, and let the database detect deadlocks and abort one.
- Distributed locks: use a lease with a fencing token so a paused holder cannot do damage after losing the lock.
- Hot rows: split a hot counter into several shards and add them up on read.
You accept waiting or retries, in return for correctness.
A few details that matter in practice:
- Timeouts: set a lock timeout and a statement timeout, and watch for transactions left open and idle, because one forgotten transaction can block everyone behind it.
- Fencing tokens: the lock service hands out an increasing number with each lock. The protected resource, such as the database or storage, must compare it and reject an older one. Without that check, a holder that paused (for example during a long garbage collection) and wakes up after its lease expired can still overwrite newer data.
- Retries: retry a conflicted or deadlocked transaction a limited number of times, with a short random delay (backoff with jitter), and make the work idempotent so a retry after an unknown outcome does no harm.
- Redis or in-memory locks: a lock with a lease in a single Redis node is fine for efficiency, such as avoiding duplicate work, but is not enough alone for correctness unless the resource also checks a fencing token.
Common mistakes
- Read, change in the application, write back. This loses updates. Use one atomic statement or a lock.
- Long transactions that hold locks. Keep them short and never wait on a user inside one.
- No retry for aborted or conflicted transactions.
- Different lock order in different code paths. It causes deadlocks.
- Assuming the default isolation level is serialisable. Check it.
- Using a lock in memory in a multi-server system. It protects only one server.
How to talk about concurrency control in an interview
- Name the anomaly: lost update, double booking, write skew.
- Offer the lightest fix first: an atomic update or a unique constraint, then optimistic locking when conflicts are rare, pessimistic locking when they are common.
- Mention isolation levels, retries, short transactions and deadlocks.
- Name the trade-off: strictness costs concurrency, either as waiting (locks) or as retries (versions).
Related topics: distributed transactions, databases and data modelling, consensus and coordination and time and ordering.