Question 12 · Section 11

What is the default isolation level in MySQL

In MySQL (when using the InnoDB engine), the default isolation level is Repeatable Read.

Language versions: English Russian Ukrainian

🟢 Junior Level

In MySQL (when using the InnoDB engine), the default isolation level is Repeatable Read.

What this means in simple terms: Within a single transaction, you always see the same data, even if other transactions modify it in the meantime. It’s as if you took a “snapshot” of the data at the start of the transaction and are working with that snapshot.

Simple analogy: You downloaded a PDF version of an article and are reading it. While you’re reading, the author might edit the article on the website, but you see your downloaded version without any changes.

SQL example:

-- Initial state: account.balance = 1000

-- Transaction 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- sees 1000

-- Transaction 2 (concurrent)
BEGIN;
UPDATE accounts SET balance = 1500 WHERE id = 1;
COMMIT;

-- Transaction 1 continues:
SELECT balance FROM accounts WHERE id = 1;  -- STILL sees 1000!
COMMIT;

What is protected at Repeatable Read:

Anomaly Possible?
Dirty Read No
Non-repeatable Read No
Phantom Read No for index queries (gap/next-key locks). For full table scans or non-indexed range queries, phantoms are possible!

Key difference from PostgreSQL: In PostgreSQL the default is Read Committed, while in MySQL it’s Repeatable Read. MySQL chooses a stricter level.

How to change:

-- In config (my.cnf)
[mysqld]
transaction-isolation = READ-COMMITTED

-- At runtime
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

🟡 Middle Level

How it works internally

MySQL/InnoDB implements Repeatable Read through a combination of:

  • MVCC (Multi-Version Concurrency Control — storing multiple row versions)
  • Next-Key Locking (Record Lock + Gap Lock). Gap = interval between index entries. Gap Lock blocks insertion of new rows into that interval.
MVCC (Multi-Version Concurrency Control):
  - Each row has hidden fields:
    * DB_TRX_ID: ID of the transaction that modified the row
    * DB_ROLL_PTR: pointer to the previous version (in undo log)
    * DB_ROW_ID: unique identifier

  - On the first SELECT in a transaction, a snapshot is created
  - All subsequent SELECTs use this same snapshot
  - The snapshot determines which row versions are "visible"

Senior insight: InnoDB at Repeatable Read protects not only against non-repeatable reads but also against phantom reads — this goes beyond the ANSI SQL standard. This is achieved through Gap Locks and Next-Key Locks.

Next-Key Locking (unique MySQL feature)

SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;

InnoDB locks:
1. Record locks: rows with id = 10, 11, ..., 20
2. Gap locks: intervals (9,10), (10,11), ..., (19,20), (20,21)
3. Next-key lock = record lock + gap lock

Result: INSERT INTO orders (id=15) is impossible  gap locked!

This prevents phantom reads at Repeatable Read, but creates a side effect — more locks and a higher probability of deadlocks.

Why did MySQL choose Repeatable Read as default?

  1. Statement-based Replication (historical reason): In older MySQL versions, replication copied SQL queries. Correct replication on slaves required determinism, which Repeatable Read provided. Without RR, a SELECT on a slave could see intermediate states the master never saw in its original transaction. This would cause replicated UPDATEs to affect different rows.
  2. Stability guarantee: MySQL developers decided safety is more important than out-of-the-box performance.
  3. Backward compatibility: Changing the default would break existing applications.

Practical application

When Repeatable Read is useful:

  • Applications migrating from MySQL to other DBMS (less behavior change)
  • Complex reports requiring a consistent snapshot
  • Historical statement-based replication

When to switch to Read Committed:

  • High-load systems with frequent updates
  • Microservices where each transaction is short
  • Row-based replication (modern approach, doesn’t require RR)

Typical mistakes

Mistake Consequence Solution
Assuming all DBMS have the same default Different behavior when migrating PostgreSQL → MySQL Know defaults for each DBMS
Ignoring gap lock side effects Unexpected deadlocks Use Read Committed for hot-spot tables
Long transaction on Repeatable Read Undo log grows, old row versions not purged Shorten transaction, use Read Committed
Gap lock on a large range Locks many unrelated rows Use point queries by primary key

Comparison: MySQL vs PostgreSQL default

Characteristic MySQL (InnoDB) PostgreSQL
Default level Repeatable Read Read Committed
MVCC Undo log Dead tuples in table
Phantoms at RR No (Next-Key Locks) No (MVCC snapshot)
Deadlocks More often (gap locks) Less often
Undo overhead Grows with long tx Table bloat with long tx
VACUUM equivalent Purge thread Autovacuum

When NOT to use Repeatable Read in MySQL

  • Row-based replication (MySQL 5.7+) — Read Committed is safe and faster
  • High-write workload — gap locks create unnecessary contention
  • Microservices — short transactions, RR overhead is excessive

🔴 Senior Level

Internal Implementation: InnoDB MVCC and Lock System

MVCC through Undo Log

InnoDB stores row versions in undo log (not in the table, unlike PostgreSQL):

Clustered index record structure:
  +------------------+
  | DB_ROW_ID (6B)   |  ← hidden PK if no explicit one
  | DB_TRX_ID (6B)   |  ← transaction ID that modified the row
  | DB_ROLL_PTR (7B) |  ← pointer to undo log record
  | data columns...  |
  +------------------+

Undo log record:
  +------------------+
  | type: INSERT/UPDATE |
  | table_id           |
  | prev_version_ptr   |  ← version chain
  | old_values...      |
  +------------------+

On SELECT:
  1. A read view (snapshot) is taken with up_limit_id and low_limit_id
  2. If DB_TRX_ID < up_limit_id → version is visible
  3. If DB_TRX_ID >= low_limit_id → version is NOT visible
  4. If up_limit_id <= DB_TRX_ID < low_limit_id → check commits
  5. If not visible → follow DB_ROLL_PTR to the previous version

Read View structure:

struct ReadView {
    trx_id_t up_limit_id;     // Oldest active transaction
    trx_id_t low_limit_id;    // ID of the next (not yet created) transaction
    trx_id_t creator_trx_id;  // ID of the transaction that created this read view
    ids_t m_ids;              // Set of active transaction IDs
};

Next-Key Locking: Internal Structure

InnoDB lock manager stores locks in a hash table:

struct lock_t {
    trx_t* trx;        // owner transaction
    uint32_t type_mode; // LOCK_REC | LOCK_GAP | LOCK_ORDINARY
    hash_node_t hash;   // for hash table
    // ...
};

LOCK_ORDINARY = next-key lock (record + gap)
LOCK_GAP = gap only (no record)
LOCK_REC = record only (no gap)
LOCK_INSERT = lock for INSERT operation

Gap lock escalation:

-- Query without index:
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;

-- No index on status → full table scan
-- InnoDB locks ALL rows + ALL gaps → table-level lock!
-- This is called "gap lock on entire table"

-- With index:
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- (INDEX idx_status (status))

-- InnoDB locks only rows with status='pending' + adjacent gaps

Architectural Trade-offs

Approach A: Repeatable Read (MySQL default)

  • ✅ Pros: Consistent snapshot, phantom protection, statement-based replication compatibility
  • ❌ Cons: Undo log bloat (old versions retained), gap lock deadlocks, lower throughput for write-heavy workloads
  • Suitable for: legacy systems, reporting, migration compatibility

Approach B: Read Committed

  • ✅ Pros: Fewer locks (no gap locks for SELECT), faster undo log purge, higher throughput
  • ❌ Cons: Non-repeatable reads, phantom reads, incompatible with statement-based replication
  • Suitable for: high-write systems, row-based replication, microservices

Approach C: Serializable

  • ✅ Pros: Full ACID
  • ❌ Cons: Implicit LOCK IN SHARE MODE on all SELECTs, maximum locking
  • Suitable for: legacy financial systems (rarely used in production)

Edge Cases and Corner Cases

1. Gap Lock on non-existing rows:

-- Table has: id = 10, 20, 30
BEGIN;
SELECT * FROM t WHERE id = 15 FOR UPDATE;  -- nothing found
-- BUT: gap lock set on range (10, 20)!

-- Another transaction:
INSERT INTO t (id, val) VALUES (15, 'test');
-- BLOCKED! Waiting for gap lock release

2. Deadlock due to Gap Locks:

T1: SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;  -- locks gaps (10,20)
T2: SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;  -- locks gaps (15,25)
T1: INSERT INTO t (id) VALUES (17);  -- waits for T2 gap lock
T2: INSERT INTO t (id) VALUES (18);  -- waits for T1 gap lock
→ DEADLOCK

Why deadlock: gap locks are incompatible for INSERT. When T1 holds gap (10,20)
and T2 holds gap (15,25), each INSERT in the overlapping region (15-20) waits
for the other transaction's gap lock to be released → circular wait.

3. Undo Log Explosion:

T1: BEGIN; (Repeatable Read)
T1: SELECT * FROM accounts;  -- creates read view

-- 10,000 UPDATEs on accounts from other transactions
-- Each UPDATE creates an undo log record
-- T1's read view prevents purge from deleting old versions

T1: SELECT * FROM accounts;  -- still sees old data
-- Undo log size: can grow to GBs during a long transaction

4. Implicit Locking and Gap Locks:

-- InnoDB sets an implicit lock on INSERT
-- Another transaction doing SELECT ... LOCK IN SHARE MODE
-- may create a gap lock on an already inserted (but uncommitted) row
-- This leads to unexpected blocking

5. Auto-Increment Locking (MySQL 5.7 vs 8.0):

MySQL 5.7: AUTO-INC lock — table-level lock for duration of INSERT (with innodb_autoinc_lock_mode = 1 — traditional)
MySQL 8.0: AUTO-INC lock — released immediately after ID generation (with innodb_autoinc_lock_mode = 2 — interleaved, default in 8.0+)

With Read Committed in MySQL 8.0:
  T1: INSERT INTO t (val) VALUES ('a');  -- id=1
  T2: INSERT INTO t (val) VALUES ('b');  -- id=2
  T1: ROLLBACK;
  T2: COMMIT;

  Result: id = 2, but next auto-inc = 3 (hole!)

6. Consistent Read vs Locking Read:

-- Repeatable Read:
SELECT * FROM t WHERE id = 1;       -- consistent read (snapshot, no lock)
SELECT * FROM t WHERE id = 1 FOR UPDATE;  -- locking read (current value, locks row)

-- Important: FOR UPDATE UPDATES the read view!
-- After FOR UPDATE, subsequent consistent reads see CURRENT data

Performance Implications

Level Latency (p50) Latency (p99) Throughput Lock overhead
Read Committed 1-2ms 3-10ms 40,000+ TPS Minimal
Repeatable Read 1-3ms 5-20ms 30,000-35,000 TPS Gap locks: 5-15% overhead
Serializable 10-50ms 100-500ms 3,000-8,000 TPS Table-level locks possible

Concrete numbers (MySQL 8.0, InnoDB, 8 cores, NVMe, sysbench):

  • Read Committed, read-only: ~45,000 TPS
  • Repeatable Read, read-only: ~42,000 TPS (7% overhead from gap lock management)
  • Read Committed, 50/50 read/write: ~20,000 TPS
  • Repeatable Read, 50/50 read/write: ~15,000 TPS (25% reduction due to gap lock contention)
  • Deadlock rate (RR, 100 concurrent writers): ~0.5-2%

Undo log impact:

  • With RR and long transactions: undo log can grow 5-10x normal size
  • Purge thread cannot delete versions visible to active read views
  • Disk space: undo tablespace can grow to 100GB+ with long-running reporting queries

Memory Implications

  • Read View: ~100-500 bytes (depends on number of active transactions). Allocated on first SELECT.
  • Gap locks: ~100 bytes per lock in lock hash table. With 10,000 gap locks = ~1MB.
  • Undo log retention: Each row version ~50-200 bytes. With 1M updates = 50-200MB retained undo.
  • InnoDB Buffer Pool: Old row versions take space in buffer pool → eviction of hot pages → increased disk I/O.

Concurrency Aspects

Deadlock detection:

InnoDB deadlock detector:
  - Runs on every lock wait timeout
  - Builds wait-for graph: transaction → waits → transaction → ...
  - Cycle detection: DFS, O(V + E)
  - Victim: transaction with least "work" (fewer undo log records)
  - Victim rollback: proportional to number of undo records

Deadlock frequency pattern:

Read Committed:  deadlock rate ~0.01-0.1% (record locks only)
Repeatable Read: deadlock rate ~0.5-5% (record + gap locks)
Serializable:    deadlock rate ~5-20% (table-level locks possible)

Real Production Scenario

Situation: E-commerce platform (2023), MySQL 8.0, processing 3000 orders/hour.

Problem: During peak hours (Black Friday), deadlock rate rose to 15%, orders failed with:

Deadlock found when trying to get lock; try restarting transaction

Root cause: Repeatable Read + Gap Locks on order_items:

@Transactional  // Repeatable Read by default
public void processOrder(Long orderId) {
    Order order = orderRepo.findById(orderId);

    // InnoDB: gap lock on order_items by order_id
    List<OrderItem> items = orderItemRepo.findByOrderId(orderId);

    // Process each item
    for (OrderItem item : items) {
        // UPDATE with gap lock on product inventory
        productRepo.decrementStock(item.getProductId(), item.getQuantity());
    }

    order.setStatus("PROCESSED");
    orderRepo.save(order);
}

With 3000 orders/hour, multiple transactions blocked overlapping ranges in order_items and product_inventory. Gap locks created cyclic dependencies → deadlocks.

Solution 1 (quick): Switch to Read Committed:

SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- my.cnf: transaction-isolation = READ-COMMITTED

Solution 2 (long-term): Eliminate gap lock triggers:

@Transactional
public void processOrder(Long orderId) {
    // Point SELECT by primary key — no gap lock
    Order order = orderRepo.findById(orderId);

    // Use atomic update instead of read-modify-write
    productRepo.decrementStockWhereSufficient(itemId, quantity);

    order.setStatus("PROCESSED");
    orderRepo.save(order);
}

Result:

  • Deadlock rate: from 15% to 0.1%
  • Throughput: +25% (fewer locks)
  • Latency p99: from 500ms to 50ms

Monitoring and Diagnostics

MySQL internal stats:

-- Current locks
SELECT
    r.trx_id AS waiting_trx_id,
    r.trx_query AS waiting_query,
    b.trx_id AS blocking_trx_id,
    b.trx_query AS blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id;

-- Deadlock log (last deadlock)
SHOW ENGINE INNODB STATUS\G
-- Look for "LATEST DETECTED DEADLOCK" section

-- Transaction isolation level
SELECT @@GLOBAL.transaction_isolation, @@SESSION.transaction_isolation;

-- Undo log size
SELECT
    table_name,
    table_rows,
    data_length,
    index_length
FROM information_schema.tables
WHERE table_name LIKE '%undo%';

-- Long-running transactions
SELECT
    trx_id, trx_state, trx_started,
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration,
    trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

Application-level monitoring:

// Micrometer: track deadlocks
Counter.builder("mysql.deadlocks")
    .tag("table", "order_items")
    .register(meterRegistry);

// Track isolation level usage
Counter.builder("db.transaction.isolation")
    .tag("level", "repeatable_read")
    .tag("operation", "process_order")
    .register(meterRegistry);

Percona Monitoring:

-- Percona Server: extended InnoDB metrics
SELECT * FROM information_schema.innodb_metrics
WHERE name LIKE '%lock%' OR name LIKE '%deadlock%';

Best Practices for Highload

  1. Switch to Read Committed for write-heavy workloads — reduces deadlock rate by 80-95%.
  2. Use point queries by primary key — avoids gap locks.
  3. Keep transactions short — smaller window for lock contention.
  4. Sort updates in a consistent order — prevents deadlocks (e.g., always by product_id ASC).
  5. Set innodb_deadlock_detect = 0 for ultra-high throughput (with application-level retries). MySQL 5.7+ only.
  6. Monitor undo log size — alert at >10GB.
  7. Use row-based replication (binlog_format=ROW) — doesn’t require Repeatable Read.
  8. For reporting — use read replicas with a separate connection pool.
  9. Optimistic locking for hot-spot tables:
    @Version
    private Long version;
    // Instead of SELECT ... FOR UPDATE
    
  10. Tune InnoDB lock settings:
    innodb_lock_wait_timeout = 10  # default 50, reduce for faster deadlock detection
    innodb_table_locks = ON        # for explicit LOCK TABLES
    

🎯 Interview Cheat Sheet

Must know:

  • MySQL (InnoDB) default: Repeatable Read — stable snapshot for entire transaction
  • MySQL prevents phantoms at RR through Next-Key Locking (Record + Gap Lock)
  • MVCC via undo log (not dead tuples in the table, as in PostgreSQL)
  • Historical reason: RR for statement-based replication determinism
  • Gap Locks — reason for more frequent deadlocks in MySQL vs PostgreSQL
  • For write-heavy workloads, recommend switching to Read Committed

Common follow-up questions:

  • Why did MySQL choose RR by default? — Statement-based replication, stability, backward compatibility
  • How does MVCC in MySQL differ from PostgreSQL? — MySQL: undo log, PG: dead tuples in same pages
  • What happens on Gap Lock deadlock? — InnoDB deadlock detector picks a victim (fewer undo records)
  • When to switch to Read Committed? — Row-based replication, high-write workload, microservices

Red flags (DO NOT say):

  • “MySQL and PostgreSQL have the same default” — MySQL: RR, PG: RC
  • “Gap Locks are always good” — can cause unnecessary deadlocks
  • “Undo log doesn’t affect performance” — with long RR transactions, undo log grows significantly

Related topics:

  • [[5. What is Repeatable Read]]
  • [[11. What is the default isolation level in PostgreSQL]]
  • [[2. What transaction isolation levels exist]]
  • [[10. What is Lost Update]]