Вопрос 12 · Раздел 11

Какой уровень изоляции по умолчанию в MySQL

В MySQL (при использовании движка InnoDB) уровнем изоляции по умолчанию является Repeatable Read.

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

В MySQL (при использовании движка InnoDB) уровнем изоляции по умолчанию является Repeatable Read.

Что это значит простыми словами: В рамках одной транзакции вы всегда видите одни и те же данные, даже если другие транзакции в это время их изменили. Как будто вы сделали “фотографию” данных в начале транзакции и работаете с этой фотографией.

Простая аналогия: Вы скачали PDF-версию статьи и читаете её. Пока вы читаете, автор может редактировать статью на сайте, но вы видите свою скачанную версию без изменений.

SQL-пример:

-- Начальное состояние: account.balance = 1000

-- Транзакция 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- видит 1000

-- Транзакция 2 (параллельно)
BEGIN;
UPDATE accounts SET balance = 1500 WHERE id = 1;
COMMIT;

-- Транзакция 1 продолжает:
SELECT balance FROM accounts WHERE id = 1;  -- ВСЁ ЕЩЁ видит 1000!
COMMIT;

Что защищено на Repeatable Read:

Аномалия Возможна?
Dirty Read (Грязное чтение) Нет
Non-repeatable Read Нет
Phantom Read Нет для запросов по индексу (gap/next-key locks). Для full table scan или non-indexed range query фантомы возможны!

Важное отличие от PostgreSQL: В PostgreSQL дефолт — Read Committed, а в MySQL — Repeatable Read. MySQL выбирает более строгий уровень.

Как изменить:

-- В конфиге (my.cnf)
[mysqld]
transaction-isolation = READ-COMMITTED

-- В рантайме
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

🟡 Middle Level

Как это работает внутри

MySQL/InnoDB реализует Repeatable Read через комбинацию:

  • MVCC (Multi-Version Concurrency Control — хранение нескольких версий строк)
  • Next-Key Locking (Record Lock + Gap Lock). Gap = промежуток между индексными записями. Gap Lock блокирует вставку новых строк в этот промежуток.
MVCC (Multi-Version Concurrency Control):
  - Каждая строка имеет скрытые поля:
    * DB_TRX_ID: ID транзакции, которая изменила строку
    * DB_ROLL_PTR: указатель на предыдущую версию (в undo log)
    * DB_ROW_ID: уникальный идентификатор

  - При первом SELECT в транзакции создаётся snapshot
  - Все последующие SELECT используют этот же snapshot
  - Snapshot определяет, какие версии строк "видны"

Senior-инсайт: InnoDB на Repeatable Read защищает не только от неповторяющегося чтения, но и от фантомного чтения — это выходит за рамки стандарта ANSI SQL. Достигается это через Gap Locks и Next-Key Locks.

Next-Key Locking (уникальная особенность MySQL)

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

InnoDB блокирует:
1. Record locks: строки с id = 10, 11, ..., 20
2. Gap locks: промежутки (9,10), (10,11), ..., (19,20), (20,21)
3. Next-key lock = record lock + gap lock

Результат: Невозможно INSERT INTO orders (id=15)  gap locked!

Это предотвращает фантомное чтение на Repeatable Read, но создаёт побочный эффект — больше блокировок и выше вероятность deadlocks.

Почему MySQL выбрал Repeatable Read по умолчанию?

  1. Statement-based Replication (историческая причина): В старых версиях MySQL репликация копировала SQL-запросы. Для корректной репликации на слейвах требовалась детерминированность, которую обеспечивал Repeatable Read. Без RR SELECT на слейве мог бы увидеть промежуточные состояния, которые мастер никогда не видел в своей оригинальной транзакции. Это привело бы к тому, что replicated UPDATE затронул бы другие строки.
  2. Гарантия стабильности: Разработчики MySQL решили, что безопасность важнее производительности “из коробки”.
  3. Backward compatibility: Изменение дефолта сломало бы существующие приложения.

Практическое применение

Когда Repeatable Read полезен:

  • Приложения, мигрирующие с MySQL на другие СУБД (меньше изменений в поведении)
  • Сложные отчёты, требующие консистентного snapshot
  • Историческая statement-based репликация

Когда стоит переключить на Read Committed:

  • Высоконагруженные системы с частыми обновлениями
  • Микросервисы, где каждая транзакция короткая
  • Row-based репликация (современный подход, не требует RR)

Типичные ошибки

Ошибка Последствие Решение
Предположение, что все СУБД имеют одинаковый дефолт Разное поведение при миграции с PostgreSQL на MySQL Знать дефолты каждой СУБД
Игнорирование gap lock side effects Неожиданные deadlocks Использовать Read Committed для hot-spot таблиц
Длинная транзакция на Repeatable Read Undo log растёт, старые версии строк не удаляются Укоротить транзакцию, использовать Read Committed
Gap lock на большом диапазоне Блокировка множества несвязанных строк Использовать точечные запросы по primary key

Сравнение: MySQL vs PostgreSQL default

Характеристика MySQL (InnoDB) PostgreSQL
Default уровень Repeatable Read Read Committed
MVCC Undo log Dead tuples в таблице
Фантомы на RR Нет (Next-Key Locks) Нет (MVCC snapshot)
Deadlocks Чаще (gap locks) Реже
Undo overhead Растёт при длинных tx Table bloat при длинных tx
VACUUM аналог Purge thread Autovacuum

Когда НЕ стоит использовать Repeatable Read в MySQL

  • Row-based replication (MySQL 5.7+) — Read Committed безопасен и быстрее
  • High-write workload — gap locks создают不必要的 contention
  • Микросервисы — короткие транзакции, RR оверхед избыточен

🔴 Senior Level

Internal Implementation: InnoDB MVCC и Lock System

MVCC через Undo Log

InnoDB хранует версии строк в undo log (не в таблице, в отличие от PostgreSQL):

Структура clustered index record:
  +------------------+
  | DB_ROW_ID (6B)   |  ← скрытый PK, если нет явного
  | DB_TRX_ID (6B)   |  ← transaction ID, изменившая строку
  | DB_ROLL_PTR (7B) |  ← указатель на undo log record
  | data columns...  |
  +------------------+

Undo log record:
  +------------------+
  | type: INSERT/UPDATE |
  | table_id           |
  | prev_version_ptr   |  ← цепочка версий
  | old_values...      |
  +------------------+

При SELECT:
  1. Берётся read view (snapshot) с up_limit_id и low_limit_id
  2. Если DB_TRX_ID < up_limit_id → версия видна
  3. Если DB_TRX_ID >= low_limit_id → версия НЕ видна
  4. Если up_limit_id <= DB_TRX_ID < low_limit_id → проверяется commits
  5. Если не видна → следовать по DB_ROLL_PTR к предыдущей версии

Read View структура:

struct ReadView {
    trx_id_t up_limit_id;     // Самая старая активная транзакция
    trx_id_t low_limit_id;    // ID следующей (ещё не созданной) транзакции
    trx_id_t creator_trx_id;  // ID создавшей этот read view
    ids_t m_ids;              // Set IDs активных транзакций
};

Next-Key Locking: Internal Structure

InnoDB lock manager хранит блокировки в hash table:

struct lock_t {
    trx_t* trx;        // транзакция-владелец
    uint32_t type_mode; // LOCK_REC | LOCK_GAP | LOCK_ORDINARY
    hash_node_t hash;   // для hash table
    // ...
};

LOCK_ORDINARY = next-key lock (record + gap)
LOCK_GAP = только gap (без record)
LOCK_REC = только record (без gap)
LOCK_INSERT = блокировка для INSERT операции

Gap lock escalation:

-- Запрос без индекса:
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;

-- Нет индекса на status → full table scan
-- InnoDB блокирует ВСЕ строки + ВСЕ gaps → table-level lock!
-- Это называется "gap lock on entire table"

-- С индексом:
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- (INDEX idx_status (status))

-- InnoDB блокирует только строки с status='pending' + adjacent gaps

Архитектурные Trade-offs

Подход A: Repeatable Read (MySQL дефолт)

  • ✅ Плюсы: Консистентный snapshot, защита от фантомов, совместимость со statement-based replication
  • ❌ Минусы: Undo log bloat (old versions retained), gap lock deadlocks, ниже throughput при write-heavy workload
  • Подходит для: legacy systems, reporting, migration compatibility

Подход B: Read Committed

  • ✅ Плюсы: Меньше блокировок (нет gap locks для SELECT), undo log purge быстрее, выше throughput
  • ❌ Минусы: Non-repeatable reads, phantom reads, несовместимость со statement-based replication
  • Подходит для: high-write systems, row-based replication, микросервисы

Подход C: Serializable

  • ✅ Плюсы: Полная ACID
  • ❌ Минусы: Implicit LOCK IN SHARE MODE на всех SELECT, максимальная блокировка
  • Подходит для: legacy financial systems (редко используется в production)

Edge Cases и Corner Cases

1. Gap Lock на несуществующих строках:

-- В таблице: id = 10, 20, 30
BEGIN;
SELECT * FROM t WHERE id = 15 FOR UPDATE;  -- ничего не найдено
-- НО: gap lock установлен на диапазон (10, 20)!

-- Другая транзакция:
INSERT INTO t (id, val) VALUES (15, 'test');
-- BLOCKED! Ждёт release gap lock

2. Deadlock из-за 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);  -- ждёт T2 gap lock
T2: INSERT INTO t (id) VALUES (18);  -- ждёт T1 gap lock
→ DEADLOCK

Почему deadlock: gap locks не совместимы для INSERT. Когда T1 держит gap (10,20),
а T2 — gap (15,25), каждый INSERT в пересекающийся регион (15-20) ждёт
освобождения gap lock другой транзакции → circular wait.

3. Undo Log Explosion:

T1: BEGIN; (Repeatable Read)
T1: SELECT * FROM accounts;  -- создаёт read view

-- 10,000 UPDATE на accounts от других транзакций
-- Каждая UPDATE создаёт undo log record
-- T1's read view не даёт purge удалить старые версии

T1: SELECT * FROM accounts;  -- всё ещё видит старые данные
-- Undo log size: может вырасти до GBs за длинную транзакцию

4. Implicit Locking и Gap Locks:

-- InnoDB устанавливает implicit lock при INSERT
-- Другая транзакция, делающая SELECT ... LOCK IN SHARE MODE,
-- может создать gap lock на уже вставленную (но не закоммиченную) строку
-- Это приводит к unexpected blocking

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

MySQL 5.7: AUTO-INC lock — table-level lock на время INSERT (при `innodb_autoinc_lock_mode = 1` — traditional)
MySQL 8.0: AUTO-INC lock — released сразу после генерации ID (при `innodb_autoinc_lock_mode = 2` — interleaved, default в 8.0+)

При Read Committed в 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;
  
  Результат: id = 2, но 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)

-- Важно: FOR UPDATE ОБНОВЛЯЕТ read view!
-- После FOR UPDATE, последующие consistent reads видят ТЕКУЩИЕ данные

Performance Implications

Уровень Latency (p50) Latency (p99) Throughput Lock overhead
Read Committed 1-2ms 3-10ms 40,000+ TPS Минимальный
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

Конкретные цифры (MySQL 8.0, InnoDB, 8 cores, NVMe, sysbench):

  • Read Committed, read-only: ~45,000 TPS
  • Repeatable Read, read-only: ~42,000 TPS (7% overhead от gap lock management)
  • Read Committed, 50/50 read/write: ~20,000 TPS
  • Repeatable Read, 50/50 read/write: ~15,000 TPS (25% снижение из-за gap lock contention)
  • Deadlock rate (RR, 100 concurrent writers): ~0.5-2%

Undo log impact:

  • При RR с длинными транзакциями: undo log может вырасти до 5-10x от normal size
  • Purge thread не может удалить версии, видимые active read views
  • Disk space: undo tablespace может вырасти до 100GB+ при long-running reporting queries

Memory Implications

  • Read View: ~100-500 bytes (зависит от количества активных транзакций). Аллоцируется при первом SELECT.
  • Gap locks: ~100 bytes per lock в lock hash table. При 10,000 gap locks = ~1MB.
  • Undo log retention: Каждая версия строки ~50-200 bytes. При 1M обновлений = 50-200MB retained undo.
  • InnoDB Buffer Pool: Старые версии строк занимают место в buffer pool → eviction of hot pages → increased disk I/O.

Concurrency Aspects

Deadlock detection:

InnoDB deadlock detector:
  - Запускается при каждом lock wait timeout
  - Строит wait-for graph: транзакция → ждёт → транзакция → ...
  - Поиск цикла: DFS, O(V + E)
  - Жертва: транзакция с наименьшим "work" (меньше undo log records)
  - Rollback жертвы: пропорционально количеству undo records

Deadlock frequency pattern:

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

Real Production Scenario

Ситуация: E-commerce платформа (2023), MySQL 8.0, обработка заказов 3000/час.

Проблема: В часы пик (Black Friday) deadlock rate вырос до 15%, заказы падали с ошибкой:

Deadlock found when trying to get lock; try restarting transaction

Root cause: Repeatable Read + Gap Locks на order_items:

@Transactional  // Repeatable Read по умолчанию
public void processOrder(Long orderId) {
    Order order = orderRepo.findById(orderId);
    
    // InnoDB: gap lock на order_items по order_id
    List<OrderItem> items = orderItemRepo.findByOrderId(orderId);
    
    // Обработка каждой позиции
    for (OrderItem item : items) {
        // UPDATE с gap lock на product inventory
        productRepo.decrementStock(item.getProductId(), item.getQuantity());
    }
    
    order.setStatus("PROCESSED");
    orderRepo.save(order);
}

При 3000 заказов/час, множественные транзакции блокировали overlapping ranges в order_items и product_inventory. Gap locks создавали cyclic dependencies → deadlocks.

Решение 1 (быстрое): Переключение на Read Committed:

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

Решение 2 (долгосрочное): Устранение gap lock триггеров:

@Transactional
public void processOrder(Long orderId) {
    // Точечный SELECT по primary key — нет gap lock
    Order order = orderRepo.findById(orderId);
    
    // Использовать atomic update вместо read-modify-write
    productRepo.decrementStockWhereSufficient(itemId, quantity);
    
    order.setStatus("PROCESSED");
    orderRepo.save(order);
}

Результат:

  • Deadlock rate: с 15% до 0.1%
  • Throughput: +25% (меньше блокировок)
  • Latency p99: с 500ms до 50ms

Monitoring и Диагностика

MySQL internal stats:

-- Текущие блокировки
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 лог (последний deadlock)
SHOW ENGINE INNODB STATUS\G
-- Ищите секцию "LATEST DETECTED DEADLOCK"

-- 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 для Highload

  1. Переключите на Read Committed для write-heavy workloads — это снижает deadlock rate на 80-95%.
  2. Используйте точечные запросы по primary key — избегает gap locks.
  3. Укорачивайте транзакции — меньше window для lock contention.
  4. Сортируйте обновления в одном порядке — предотвращает deadlocks (например, всегда по product_id ASC).
  5. Настройте innodb_deadlock_detect = 0 для超高 throughput (при наличии application-level retries). Только для MySQL 5.7+.
  6. Monitor undo log size — алерт при >10GB.
  7. Используйте row-based replication (binlog_format=ROW) — не требует Repeatable Read.
  8. Для reporting — используйте read replicas с отдельным connection pool.
  9. Optimistic locking для hot-spot таблиц:
    @Version
    private Long version;
    // Вместо SELECT ... FOR UPDATE
    
  10. Tune InnoDB lock settings:
    innodb_lock_wait_timeout = 10  # default 50, снизить для faster deadlock detection
    innodb_table_locks = ON        # для explicit LOCK TABLES
    

🎯 Шпаргалка для интервью

Обязательно знать:

  • MySQL (InnoDB) дефолт: Repeatable Read — стабильный snapshot на всю транзакцию
  • MySQL предотвращает фантомы на RR через Next-Key Locking (Record + Gap Lock)
  • MVCC через undo log (не dead tuples в таблице, как в PostgreSQL)
  • Историческая причина: RR для statement-based replication детерминированности
  • Gap Locks — причина более частых deadlocks в MySQL vs PostgreSQL
  • Для write-heavy workload рекомендуется переключить на Read Committed

Частые уточняющие вопросы:

  • Почему MySQL выбрал RR по умолчанию? — Statement-based replication, stability, backward compatibility
  • Чем MVCC в MySQL отличается от PostgreSQL? — MySQL: undo log, PG: dead tuples в тех же страницах
  • Что произойдёт при Gap Lock deadlock? — InnoDB deadlock detector выбирает жертву (меньше undo records)
  • Когда переключить на Read Committed? — Row-based replication, high-write workload, микросервисы

Красные флаги (НЕ говорить):

  • “MySQL и PostgreSQL имеют одинаковый дефолт” — MySQL: RR, PG: RC
  • “Gap Locks — это всегда хорошо” — могут вызывать unnecessary deadlocks
  • “Undo log не влияет на производительность” — при длинных RR транзакциях undo log растёт значительно

Связанные темы:

  • [[5. Что такое Repeatable Read]]
  • [[11. Какой уровень изоляции по умолчанию в PostgreSQL]]
  • [[2. Какие уровни изоляции транзакций существуют]]
  • [[10. Что такое потерянное обновление (Lost Update)]]