What is the default isolation level in MySQL
In MySQL (when using the InnoDB engine), the default isolation level is Repeatable Read.
🟢 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?
- 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.
- Stability guarantee: MySQL developers decided safety is more important than out-of-the-box performance.
- 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
- Switch to Read Committed for write-heavy workloads — reduces deadlock rate by 80-95%.
- Use point queries by primary key — avoids gap locks.
- Keep transactions short — smaller window for lock contention.
- Sort updates in a consistent order — prevents deadlocks (e.g., always by product_id ASC).
- Set innodb_deadlock_detect = 0 for ultra-high throughput (with application-level retries). MySQL 5.7+ only.
- Monitor undo log size — alert at >10GB.
- Use row-based replication (binlog_format=ROW) — doesn’t require Repeatable Read.
- For reporting — use read replicas with a separate connection pool.
- Optimistic locking for hot-spot tables:
@Version private Long version; // Instead of SELECT ... FOR UPDATE - 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]]