User Tools

Site Tools


mysql:tx:best-practices-solving-transaction-anomalies-with-default-isolation-level

Best Practices — Solving Transaction Anomalies with Default Isolation Level

Core idea: Don't change the default isolation level globally.
Use `READ COMMITTED` (default) + the right lock technique per situation.
This gives you safety where needed without sacrificing performance everywhere.

Table of Contents

1. Golden Rules

Rule 1 — Keep READ COMMITTED as the default for everything.
Rule 2 — Add SELECT FOR UPDATE only where race conditions can occur.
Rule 3 — Use atomic updates (SET col = col + n) for simple counters.
Rule 4 — Use UNIQUE constraints to enforce uniqueness at the database level.
Rule 5 — Only raise to SERIALIZABLE for genuinely complex invariants.
Rule 6 — Always lock rows in a consistent order to avoid deadlocks.
Rule 7 — Always wrap transactions in try/catch and rollback on error.

2. Dirty Read — Already Solved by Default

The Problem

Transaction A reads data that Transaction B modified but has not yet committed. If B rolls back, A was working with a value that never officially existed.

balance = 100

Txn B:  UPDATE balance = 200  ← not committed
Txn A:  SELECT balance        → reads 200  ← DIRTY
Txn B:  ROLLBACK              → balance back to 100
Txn A:  already used 200 — WRONG

Why It Is Already Solved

Both PostgreSQL and MySQL default to an isolation level that prevents dirty reads:

Database Default Level Dirty Read
PostgreSQL READ COMMITTED ✅ Prevented
MySQL REPEATABLE READ ✅ Prevented
SQL Server READ COMMITTED ✅ Prevented

At `READ COMMITTED`, Transaction A can only see committed data. B's uncommitted change stays invisible to everyone else until B commits.

Best Practice — Do Nothing Extra

-- PostgreSQL / SQL Server: already safe, no changes needed
 
BEGIN;
  SELECT balance FROM accounts WHERE id = 1;
  -- Always returns the last committed value
  -- B's uncommitted changes are never visible here
COMMIT;

The Only Rule

Never use READ UNCOMMITTED in production.
It is the only isolation level where dirty reads can happen.
-- NEVER do this in production
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

3. Non-repeatable Read — Use SELECT FOR SHARE

The Problem

Within the same transaction, reading the same row twice returns different values because another transaction updated and committed that row in between.

balance = 1000

Txn A:  SELECT balance  →  1000
Txn B:  UPDATE balance = 500, COMMIT
Txn A:  SELECT balance  →  500   ← different result, same transaction!

Real scenario: A generates an audit report — reads balance twice to cross-check. The two reads disagree because someone transferred money in between.

Why It Happens at READ COMMITTED

At `READ COMMITTED`, each `SELECT` statement gets a fresh snapshot of committed data. So the second `SELECT` sees B's committed update — result differs from the first read.

Best Practice — SELECT FOR SHARE

Lock the row on first read so no one can update it until your transaction finishes.

BEGIN;
 
  -- Lock the row for reading — others can still read, but cannot update
  SELECT balance
  FROM accounts
  WHERE id = 1
  FOR SHARE;                    -- ← key addition
  -- → 1000
 
  -- Txn B tries UPDATE balance = 500
  -- → B is BLOCKED here, must wait for A to finish
 
  -- ... do calculations, generate report ...
 
  SELECT balance
  FROM accounts
  WHERE id = 1
  FOR SHARE;
  -- → still 1000  ✓  (B could not commit while A held the share lock)
 
COMMIT;
-- B is now unblocked and can proceed

FOR SHARE vs FOR UPDATE

FOR SHARE FOR UPDATE
Multiple readers? ✅ Yes, many transactions can hold it simultaneously ❌ No, only one at a time
Blocks writers? ✅ Yes ✅ Yes
Blocks other readers? ❌ No ✅ Yes
Use when You only need to read consistently You plan to update the row
-- Use FOR SHARE when you only READ and want consistency
SELECT balance FROM accounts WHERE id = 1 FOR SHARE;
 
-- Use FOR UPDATE when you READ then WRITE
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

Real-world Example — Audit Report

BEGIN;
 
  -- Lock all accounts being audited
  SELECT id, balance
  FROM accounts
  WHERE department = 'engineering'
  FOR SHARE;
 
  -- Calculate total from first read
  -- ... application logic ...
 
  -- Read again to verify — guaranteed same values
  SELECT id, balance
  FROM accounts
  WHERE department = 'engineering'
  FOR SHARE;
  -- All balances identical to first read ✓
 
COMMIT;

4. Phantom Read — Use SELECT FOR SHARE + Range Lock

The Problem

Within the same transaction, running the same query twice returns a different number of rows because another transaction inserted or deleted a matching row in between.

orders table: 3 rows with amount > 100

Txn A:  SELECT COUNT(*) WHERE amount > 100  →  3
Txn B:  INSERT order amount=500, COMMIT
Txn A:  SELECT COUNT(*) WHERE amount > 100  →  4  ← phantom row appeared!

Real scenario: A calculates the total value of large orders for a report. Between two reads, a new large order is inserted — the totals no longer match.

Why It Happens at READ COMMITTED

Each `SELECT` sees a fresh snapshot. A newly committed row from B becomes visible on A's second `SELECT` even though A's transaction started before B's insert.

Best Practice — FOR SHARE on the Range

Lock the range of rows matching your condition. No one can insert into that range while your transaction holds the lock.

BEGIN;
 
  -- Lock all rows matching the condition
  SELECT COUNT(*), SUM(amount)
  FROM orders
  WHERE amount > 100
  FOR SHARE;                    -- ← locks the range
  -- → 3 rows, total = 1500
 
  -- Txn B tries to INSERT order amount=500
  -- → B is BLOCKED, cannot insert into this range
 
  -- ... application logic ...
 
  SELECT COUNT(*), SUM(amount)
  FROM orders
  WHERE amount > 100
  FOR SHARE;
  -- → still 3 rows, total = 1500  ✓
 
COMMIT;
-- B is now unblocked and inserts successfully

Important — Index Required for Range Lock

-- Without an index, the database may lock the ENTIRE table
-- instead of just the matching range — very bad for performance
 
-- Always ensure an index exists on the column used in WHERE
CREATE INDEX idx_orders_amount ON orders(amount);
 
-- Now FOR SHARE only locks rows where amount > 100
-- Not the whole table

Real-world Example — Financial Report

BEGIN;
 
  -- Lock the exact range needed for the report
  SELECT
    category,
    COUNT(*)       AS order_count,
    SUM(amount)    AS total_amount
  FROM orders
  WHERE
    created_at BETWEEN '2026-01-01' AND '2026-03-31'
    AND STATUS = 'completed'
  FOR SHARE;
  -- → consistent results, no phantom rows can appear
 
COMMIT;

5. Lost Update — Use Atomic Update or SELECT FOR UPDATE

The Problem

Two transactions both read the same value, both modify it, and one overwrites the other. The first update is silently lost.

qty = 10

Txn A:  read qty = 10
Txn B:  read qty = 10
Txn B:  write qty = 10 - 3 = 7,  COMMIT
Txn A:  write qty = 10 - 2 = 8,  COMMIT   ← overwrites B's result!

Final:  qty = 8   (should be 5)   ← B's deduction of 3 is LOST

Real scenarios: Inventory deduction, bank transfers, like/view counters.

Solution A — Atomic Update (simplest, use when possible)

Combine read + calculate + write into one SQL statement. The database handles it atomically — no race condition possible.

-- WRONG: read in application, calculate, then write back
-- (race condition between read and write)
current_qty = SELECT qty FROM products WHERE id = 1;   -- 10
UPDATE products SET qty = current_qty - 2 WHERE id = 1;
 
-- CORRECT: let the database do it atomically
UPDATE products
SET qty = qty - 2
WHERE id = 1;
-- Database reads, subtracts, and writes in one atomic step
-- No other transaction can interfere in between
-- More examples of atomic updates
 
-- Counter: increment page views
UPDATE pages SET views = views + 1 WHERE id = 42;
 
-- Balance: add interest
UPDATE accounts SET balance = balance * 1.05 WHERE TYPE = 'savings';
 
-- Stock: reserve items
UPDATE products SET reserved = reserved + 1 WHERE id = 7;

When to use Atomic Update

  • Simple arithmetic only (add, subtract, multiply)
  • No need to check the current value before writing

When NOT to use (use FOR UPDATE instead)

  • Need to check balance before deducting (e.g. “is there enough stock?”)
  • Need the current value in application logic before writing

Solution B — SELECT FOR UPDATE (when you need to check before writing)

Lock the row on read so no other transaction can touch it until you commit.

BEGIN;
 
  -- Step 1: read AND lock the row
  SELECT qty
  FROM products
  WHERE id = 1
  FOR UPDATE;                   -- ← row is now locked
  -- → qty = 10
 
  -- Step 2: check business rule in application code
  IF qty < 2 THEN
    ROLLBACK;
    RAISE 'Insufficient stock';
  END IF;
 
  -- Step 3: write safely — no one else could have changed qty
  UPDATE products
  SET qty = qty - 2
  WHERE id = 1;
 
COMMIT;
-- Lock released — other transactions can now proceed

What happens to concurrent transactions

Txn A:  SELECT qty FOR UPDATE  →  locks row, reads 10
Txn B:  SELECT qty FOR UPDATE  →  BLOCKED, waiting for A

Txn A:  UPDATE qty = 8, COMMIT
        lock released

Txn B:  unblocked, reads qty = 8  (A's result)
Txn B:  UPDATE qty = 5, COMMIT   ✓  correct result

Real-world Example — Bank Transfer

-- Transfer $100 from account 1 to account 2
BEGIN;
 
  -- Lock BOTH accounts before reading
  -- Always lock in ascending ID order to prevent deadlocks
  SELECT id, balance
  FROM accounts
  WHERE id IN (1, 2)
  ORDER BY id                   -- ← consistent lock order, prevents deadlock
  FOR UPDATE;
 
  -- Check sender has enough balance (in application code)
  -- IF balance_of_1 < 100 THEN ROLLBACK
 
  -- Deduct from sender
  UPDATE accounts
  SET balance = balance - 100
  WHERE id = 1;
 
  -- Add to receiver
  UPDATE accounts
  SET balance = balance + 100
  WHERE id = 2;
 
COMMIT;

Deadlock prevention — always lock in consistent order

-- RISK OF DEADLOCK: inconsistent order
-- Txn A locks account 1, then needs account 2
-- Txn B locks account 2, then needs account 1
-- → both wait forever
 
-- SAFE: always lock by ascending ID
SELECT id, balance FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;

Solution C — Optimistic Lock (when conflicts are rare)

No real lock — add a `version` column. When writing, check that version hasn't changed. If it has, someone else updated first — retry.

-- Schema
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
 
-- Step 1: read with version
SELECT qty, version FROM products WHERE id = 1;
-- → qty=10, version=5
 
-- Step 2: write with version check
UPDATE products
SET
  qty     = qty - 2,
  version = version + 1
WHERE
  id      = 1
  AND version = 5;
 
-- Check rows affected:
--   1 row → success
--   0 rows → conflict, someone else updated first → retry from step 1

When to use Optimistic Lock

  • Conflicts are rare (most updates succeed on the first try)
  • High-concurrency reads, occasional writes
  • REST APIs, user profile updates, document editing

Choosing the Right Solution

Do you need to check the current value before writing?
    │
    ├── No  →  Use ATOMIC UPDATE
    │          UPDATE table SET col = col + n WHERE id = x
    │
    └── Yes →  Are conflicts frequent (many users, same row)?
                   │
                   ├── Yes  →  Use SELECT FOR UPDATE
                   │
                   └── No   →  Use OPTIMISTIC LOCK (version column)

6. Write Skew — Use SELECT FOR UPDATE on All Involved Rows

The Problem

Two transactions both read a shared condition, both decide it is safe to proceed, and both write to different rows.

Rule: at least 1 doctor must be on call

Alice on_call=true, Bob on_call=true  →  count = 2

Txn A (Alice):  SELECT COUNT(*) on_call=true  →  2
Txn B (Bob):    SELECT COUNT(*) on_call=true  →  2

Txn A: UPDATE Alice on_call=false
Txn B: UPDATE Bob  on_call=false

Final: count = 0  ← RULE VIOLATED

Best Practice — SELECT FOR UPDATE on All Rows Involved in the Condition

BEGIN;
 
  SELECT id, name
  FROM doctors
  WHERE on_call = TRUE
  FOR UPDATE;
 
  UPDATE doctors
  SET on_call = FALSE
  WHERE id = 1;
 
COMMIT;

UNIQUE Constraint Alternative

ALTER TABLE bookings
ADD CONSTRAINT uq_room_slot UNIQUE (room_id, slot);

7. Quick Reference

Solution per anomaly

Anomaly Default safe? Best practice solution
Dirty read ✅ Yes Nothing needed — default `READ COMMITTED` prevents it
Non-repeatable read ❌ No `SELECT … FOR SHARE`
Phantom read ❌ No `SELECT … FOR SHARE` on the range
Lost update (simple) ❌ No Atomic update
Lost update (with check) ❌ No `SELECT … FOR UPDATE`
Lost update (low conflict) ❌ No Optimistic lock
Write skew ❌ No `SELECT … FOR UPDATE` on all involved rows

Lock cheat sheet

-- Read consistently
SELECT ... FOR SHARE;
 
-- Read then write
SELECT ... FOR UPDATE;
 
-- Atomic write
UPDATE TABLE SET col = col + n WHERE id = x;
 
-- Prevent duplicate inserts
ALTER TABLE t ADD CONSTRAINT uq_name UNIQUE (col1, col2);

Deadlock prevention checklist

✓ Always lock multiple rows in the same order
✓ Keep transactions short
✓ Do not lock more rows than necessary
✓ Do not perform external calls inside a transaction
✓ Always retry on deadlock/serialization errors

Transaction template — production-ready

BEGIN;
 
  -- 1. Lock what you need
  SELECT col FROM TABLE WHERE id = x FOR UPDATE;
 
  -- 2. Check business rules
 
  -- 3. Write changes
  UPDATE TABLE SET col = new_value WHERE id = x;
 
COMMIT;
def run_transaction(conn, fn):
    MAX_RETRY = 3
    for attempt in range(MAX_RETRY):
        try:
            with conn.cursor() as cur:
                cur.execute("BEGIN")
                result = fn(cur)
                cur.execute("COMMIT")
                return result
        except Exception as e:
            conn.rollback()
 
            if "deadlock" in str(e).lower() or "40001" in str(e):
                continue
 
            raise
 
    raise Exception("Transaction failed after max retries")
async function runTransaction(pool, fn) {
  const MAX_RETRY = 3;
 
  for (let attempt = 0; attempt < MAX_RETRY; attempt++) {
    const client = await pool.connect();
 
    try {
      await client.query('BEGIN');
 
      const result = await fn(client);
 
      await client.query('COMMIT');
 
      return result;
 
    } catch (err) {
 
      await client.query('ROLLBACK');
 
      if (['40P01', '40001'].includes(err.code)) {
        continue;
      }
 
      throw err;
 
    } finally {
      client.release();
    }
  }
 
  throw new Error('Transaction failed after max retries');
}

For background theory, see: SQL Transaction Anomalies, SQL Isolation Levels.

mysql/tx/best-practices-solving-transaction-anomalies-with-default-isolation-level.txt · Last modified: by phong2018