All database ACID isolation levels and race conditions explained

All database ACID isolation levels and race conditions explained

Each isolation level in chronological order

ยท

9 min read

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:

  1. Dirty Reads:
Transaction A                    Transaction B
                                UPDATE balance = 200
                                -- Not committed yet!
READ balance (200)
                                ROLLBACK
-- We read data that never actually existed!
  1. 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:

  1. 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!
  1. 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.

ย