All database ACID isolation levels and race conditions explained
Each isolation level in chronological order
Introduction
In this post, I want to go over stuff I've learned about isolation levels from weakest to strongest and the race conditions covered along the way.
This post assumes you know what ACID is and understand some basic SQL.
Stuff I learned here are from the book DDIA.
Read uncommitted
Read uncommitted is the lowest isolation level in databases. At this level:
No locks are taken when reading data
No locks are taken when writing data
This leads to two major issues:
- Dirty Reads:
Transaction A Transaction B
UPDATE balance = 200
-- Not committed yet!
READ balance (200)
ROLLBACK
-- We read data that never actually existed!
- Dirty Writes:
Transaction A Transaction B
UPDATE balance = 100 UPDATE balance = 200
-- Both writes happen simultaneously with no locks!
-- Data will be in an inconsistent state
Read Committed
The next level is read committed. It prevents dirty reads by only showing committed data. However, it introduces a new issue: non-repeatable reads.
Non-repeatable reads occur when a transaction reads the same data twice and gets different results:
Transaction A Transaction B
READ balance (100)
UPDATE balance = 200
COMMIT
READ balance again (200)
-- Different values in same transaction
Under read committed:
Reads only see committed data
Each read gets latest committed value
No protection against data changing between reads
Snapshot isolation
Snapshot isolation works by creating a snapshot of the database when the transaction starts. This snapshot is then used for the duration of the transaction. It's like taking a photo of the database at the beginning of the transaction. The nice part here is that any data you read is from this snapshot, so you won't see changes made by other transactions, even if they committed.
Transaction A Transaction B
-- Takes snapshot at start!
READ balance (100) UPDATE balance = 200
READ balance again (100) COMMIT
-- Consistent reads!
However, you may run into a problem if you try to update data that's been changed by another transaction (after commit).
Transaction A Transaction B
READ balance (100) READ balance (100)
UPDATE balance = 200
COMMIT
UPDATE balance = 150
-- FAILS! Version check catches this
Here, transaction A is trying to update the balance to 150, but transaction B has already updated it to 200. Snapshot isolation catches this and rolls back transaction A's update. You would have to retry the transaction.
Snapshot isolation under the hood uses multi-version concurrency control (MVCC). MVCC keeps multiple versions of each row in the database. When you update a row, instead of overwriting it, the database creates a new version. Each transaction sees the version that was current when the transaction started. Old versions are cleaned up when no transaction needs them anymore.
Problems with snapshot isolation
We actually have two more issues: Write Skew and Phantoms.
Write Skew
Write skew is called write skew because it's based on outdated reads.
-- Two doctors on call: Alice and Bob
Transaction A Transaction B
READ doctors READ doctors
(sees Alice & Bob) (sees Alice & Bob)
"Bob's here, I can leave" "Alice's here, I can leave"
Remove Alice Remove Bob
COMMIT COMMIT
-- Result: NO doctors on call! ๐ฑ
In this case, both transactions think the other doctor will stay, but they both remove each other. If we have a rule that one doctor must stay, then we have an issue here. The problem here is that it's completely valid for both transactions to see two doctors on call, and they're removing themselves and not each other.
One solution is to lock the table for the duration of the transaction.
Transaction A Transaction B
LOCK TABLE doctors WAIT...
READ doctors
Remove Alice
COMMIT
Now can proceed
This isn't efficient. Our only concerns are locking the specific rows that are being updated.
You can use SELECT ... FOR UPDATE
to lock the rows for the duration of the transaction. This is more efficient because it locks only the rows that are being updated. If other transactions need to read other rows, they won't be blocked.
Transaction A Transaction B
SELECT * FROM doctors
WHERE on_call = true
FOR UPDATE; -- Tries same SELECT FOR UPDATE
-- Locks both Alice & Bob rows -- Must wait! Rows locked!
Remove Alice
COMMIT
-- Now can proceed, sees only Bob
-- Knows can't go off duty!
Phantoms
A phantom occurs in two common scenarios:
- A transaction reads rows matching a condition, while another transaction adds/removes rows that match that condition:
Transaction A Transaction B
SELECT * FROM rooms
WHERE capacity > 100
-- Sees 5 large rooms INSERT INTO rooms
VALUES ('L6', 150)
COMMIT
SELECT * FROM rooms
WHERE capacity > 100
-- Now sees 6 large rooms!
- Two transactions check for absence of data and then insert (like double-booking):
Transaction A Transaction B
SELECT * FROM bookings SELECT * FROM bookings
WHERE room = '123' AND WHERE room = '123' AND
date = '2024-01-01' date = '2024-01-01'
-- Sees no bookings -- Sees no bookings
INSERT INTO bookings INSERT INTO bookings
-- Both succeed! -- Double booking! ๐ฑ
The challenge with phantoms is you can't lock rows that don't exist yet. This is different from write skew where we could lock existing rows with SELECT FOR UPDATE
.
Materializing conflicts
One solution to phantoms is to create placeholder rows in the database. This way, you can lock the placeholder rows and prevent the phantom from happening. This works well for cases where the domain has finite sets.
If you have potentially infinite items e.g. in an ecommerce store where you're targeting all items > $100, you can't materialize all of them. It'd be too expensive.
You need add placeholder rows and cleanup them. And you'll in such cases add more unnecessary rows than necessary. Since at that point you're guessing.
To achieve this, you'd do:
Transaction A Transaction B
-- Insert placeholder if not exists
INSERT INTO booking_locks
(room, date)
VALUES ('123', '2024-01-01')
ON CONFLICT DO NOTHING
-- Now we can lock this row!
SELECT * FROM booking_locks
WHERE room = '123'
AND date = '2024-01-01'
FOR UPDATE
-- Safe to proceed with actual booking
You'd do this before you insert the actual booking.
If someone else tries to insert a booking for the same room and date, they'll need to wait. After the wait, they'll see the placeholder row and know they need to retry.
Transaction A Transaction B
INSERT ... ON CONFLICT
DO NOTHING INSERT ... ON CONFLICT
DO NOTHING
-- This succeeds but...
SELECT ... FOR UPDATE -- WAITS HERE
-- Gets lock! -- Transaction B waits because
-- Transaction A has the lock
-- Can safely insert actual
-- booking now
COMMIT -- Now B can proceed but will
-- see the booking exists
Three Ways to achieve serializability
There are three ways to achieve serializability:
Serial Execution
Two phase locking (2PL)
Serializable snapshot isolation (SSI)
Serial execution
Serial execution is the simplest way to achieve serializability. You just run transactions one at a time in order. The downside here is that you can only use a single CPU core to ensure serializability. So this doesn't scale well. The reason for that is because with multiple cores, you can't guarantee that the transactions will run in order.
It can be suitable if you've low throughput and your transactions are short. But again, it doesn't scale. Likely not what you want.
Two phase locking (2PL)
Two phase locking explained:
Read locks: Readers don't block each other. They share the same lock. However, they block writers.
Write locks: Writers block readers and writers.
The good part here is that you can read without worrying about other transactions writing to the same data. If you want to write, you can write knowing you won't disturb other readers.
Sounds amazing, right?
Well, first issue is performance. Writers block readers and vice versa, so you have a lot of contention (waiting for access to the data that's locked).
Second issue is deadlocks. If two transactions hold a lock on the data they need and wait for each other, you have a deadlock.
Transaction A Transaction B
GET lock on account_1 GET lock on account_2
READ account_1 READ account_2
WANT lock on account_2 WANT lock on account_1
-- Wait for B's lock! -- Wait for A's lock!
-- Deadlock: both waiting -- Deadlock: both waiting
Dealing with deadlocks
Databases typically handle deadlocks in two ways:
1. Deadlock Detection
Database detects the circular wait
Picks a "victim" transaction (usually the youngest/smallest)
Youngest -> Hasn't waited as long.
Smallest -> Has less data to roll back.
Rolls it back
Application needs to retry
2. Deadlock Prevention (Timeouts)
Transactions automatically timeout if they wait too long
Usually configurable timeout period
Application needs to retry
Best practices to avoid deadlocks
Always access tables/rows in the same order
Keep transactions short
Don't hold locks while doing external operations
Access tables and rows in the same order?
BAD approach (might deadlock):
-- Transaction A: Transfer $100 from account 5 to account 2
GET lock on account_5
GET lock on account_2
-- Transaction B at same time: Transfer $50 from account 2 to account 5
GET lock on account_2
GET lock on account_5
GOOD approach (no deadlock possible):
-- Transaction A: Transfer $100 from account 5 to account 2
GET lock on account_2 -- Always lock lower number first!
GET lock on account_5
-- Transaction B at same time: Transfer $50 from account 2 to account 5
GET lock on account_2 -- Also locks lower number first
GET lock on account_5 -- Will wait for A, but no deadlock
By always locking in a consistent order (like lowest account number first), you prevent the circular waiting that causes deadlocks. One transaction might have to wait, but they can't end up waiting for each other.
Serializable snapshot isolation (SSI)
This is one of the most popular and recent ways to achieve serializability.
We take an optimistic approach where we assume most transactions don't conflict.
Here, we use snapshot isolation, but with a twist:
All transactions proceed freely.
At commit time, we check if the transaction has conflicts.
If so, we abort the transaction.
In human words, at commit time:
Check: "Did anyone change what I read?"
Check: "Did I ignore any writes that are now committed?"
If yes to either โ Abort
The reason this is popular is because you literally let all transactions proceed freely while checking for conflicts at commit time.
It works quite well in practice.
Practical example
Transaction A Transaction B
READ balance (100) READ balance (100)
UPDATE balance = 200
COMMIT
UPDATE balance = 150
COMMIT
-- A aborts: balance changed after we read it
When to use SSI and trade offs
When to use SSI:
High-concurrency environments
When conflicts are rare
When occasional retries are acceptable
When you need full serializability but 2PL's blocking is too expensive!
Performance note: The trade-off is between blocking (2PL) vs potentially having to retry (SSI). SSI wins when conflicts are rare because most transactions succeed without any waiting.