2. InnoDB storage engine

В предыдущей главе мы исследовали трехуровневую архитектуру MySQL и выяснили, что логическое ядро СУБД (Server Layer) делегирует всю физическую работу с накопителями и транзакционными гарантиями подключаемым подсистемам через унифицированный контракт Handler API.

Сегодня мы погружаемся в святая святых современного MySQL — подсистему хранения InnoDB. Разработанный финским программистом Хейкки Туури (Heikki Tuuri) в конце 1990-х годов, InnoDB стал инженерным триумфом, превратившим легковесный MySQL в полноценную промышленную СУБД с бескомпромиссными транзакционными гарантиями ACID.

Если вы бэкенд-инженер, создающий высоконагруженные сервисы на Go, в 99% сценариев взаимодействия с MySQL вы будете работать именно с InnoDB. Понимание того, как этот движок раскладывает байты по 16-килобайтным страницам диска и как он управляет оперативной памятью, отличает зрелого Senior-инженера от новичка, слепо верящего в то, что база данных — это просто бездонный хэш-мап на диске.


Анатомия InnoDB: Философия взаимодействия RAM и Disk

Движок InnoDB спроектирован вокруг фундаментального принципа Mechanical Sympathy к подсистеме ввода-вывода (I/O): дисковый накопитель (даже самый передовой NVMe SSD) работает на порядки медленнее полупроводниковой оперативной памяти и многоуровневых кэшей CPU (L1/L2/L3).

Главная философская максима InnoDB звучит так: Любые операции чтения и модификации выполняются исключительно в оперативной памяти (RAM), а сброс данных на диск производится либо строго асинхронно, либо укрупненными последовательными блоками (Sequential I/O).

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph RAM ["In-Memory Structures (Оперативная память)"]
        direction LR
        BP["Buffer Pool <br> (Главный кэш страниц 16KB)"]
        CB["Change Buffer <br> (Асинхронный кэш вторичных индексов)"]
        LB["Log Buffer <br> (Кэш транзакций Redo)"]
        AHI["Adaptive Hash Index <br> (O-1 кэш для горячих B+ Tree страниц)"]
    end

    subgraph IOLayer ["OS & File System Layer (Ядро Linux)"]
        FSYNC["Системный вызов fsync()"]
        AIO["Linux AIO (Asynchronous I/O)"]
    end

    subgraph Disk ["On-Disk Structures (Физические файлы на накопителе)"]
        direction LR
        IBD["Tablespaces (.ibd) <br> (Кластерные индексы и страницы данных)"]
        SYS["System Tablespace (ibdata1) <br> (Doublewrite Buffer)"]
        UNDO["Undo Tablespaces <br> (Логи отката для MVCC)"]
        REDO["Redo Logs (ib_logfile*) <br> (Журнал предзаписи WAL)"]
    end

    BP -->|"Сброс dirty pages (AIO flush)"| SYS
    SYS -->|"Безопасная запись страниц"| IBD
    CB -.->|"Мердж изменений в фоне"| BP
    LB -->|"Синхронно при COMMIT"| FSYNC
    FSYNC --> REDO
  

1. Дисковые структуры данных (On-Disk Structures)

Страницы и экстенты (Pages & Extents)

Вся дисковая организация данных в InnoDB квантуется блоками строго фиксированного размера — Страницами (Pages). По умолчанию размер страницы в InnoDB составляет ровно 16 КБ (16 384 байта) — в два раза больше, чем в PostgreSQL (8 КБ).

Страницы объединяются в более крупные физические кластеры — Экстенты (Extents) по 64 страницы подряд (ровно 1 МБ). Набор экстентов формирует Табличные пространства (Tablespaces) — индивидуальные файлы с расширением .ibd, в которых хранятся таблицы вашей базы данных (режим innodb_file_per_table = ON).


Кластерный индекс (Clustered Index): Таблица — это B+ дерево

Это самое глубокое концептуальное отличие InnoDB от PostgreSQL и исторического MyISAM.
В PostgreSQL таблица — это неупорядоченная Куча (Heap), а индексы — внешние вспомогательные файлы.
В InnoDB таблица физически и есть индекс!

Вся таблица организована как сбалансированное B+ Tree дерево, упорядоченное строго по Первичному ключу (Primary Key):

  • Во внутренних промежуточных узлах дерева хранятся ключи и указатели на дочерние 16 КБ страницы.
  • В листовых узлах (Leaf Nodes) самого нижнего уровня B+ дерева физически размещаются целиком все данные строк (все остальные колонки таблицы).
flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph ClusteredBTree ["Кластерный индекс InnoDB (PRIMARY KEY: id)"]
        direction TB
        Root["Root Page (16KB) <br> [ id: 1..100 | id: 101..200 ]"]
        Root --> B1["Branch Page 1"]
        Root --> B2["Branch Page 2"]

        B1 --> L1["Leaf Page 1 (16KB) <br> id=1 | name='Brian' | balance=100 <br> id=2 | name='Ken' | balance=250"]
        B1 --> L2["Leaf Page 2 (16KB) <br> id=50 | name='Rob' | balance=500"]
    end
  

Именно поэтому точечный запрос по первичному ключу SELECT * FROM users WHERE id = 1 в InnoDB отрабатывает с запредельной скоростью: алгоритм совершает спуск по B+ дереву за 2–3 перехода по страницам и в листовом узле сразу находит готовую строку со всеми колонками, без вторичных скачков по оперативной памяти! Подробно геометрию вторичных индексов мы разберем в лекции 3. Индексы в MySQL.


2. Структуры в оперативной памяти (In-Memory Structures)

Buffer Pool (Пул буферов)

Buffer Pool — центральный вычислительный резервуар InnoDB. Движок никогда не модифицирует байты прямо на накопителе. При выполнении любого SELECT, UPDATE или DELETE сервер сначала поднимает 16 КБ страницу с диска в Buffer Pool, вносит изменения в памяти (страница становится "грязной" — Dirty Page), а затем фоновый процесс асинхронно сбрасывает ее обратно на диск.

Для управления кэшем страниц Buffer Pool задействует модифицированный двухуровневый алгоритм LRU (Least Recently Used):

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph BufferPoolLRU ["Buffer Pool LRU List"]
        Young["Молодой сегмент (Young / New) <br> ~5/8 объема (Горячие страницы)"]
        Midpoint["Midpoint Insertion Point <br> (Точка вставки новых страниц)"]
        Old["Старый сегмент (Old / Sublist) <br> ~3/8 объема (Холодные страницы)"]

        Young <--> Midpoint
        Midpoint <--> Old
    end
  

Change Buffer (Буфер отложенных модификаций)

Когда запрос обновляет строку, ему требуется обновить не только кластерный индекс, но и вторичные B-Tree индексы. Если целевой 16 КБ страницы вторичного индекса в данный момент нет в Buffer Pool, подъем этой страницы с диска ради изменения пары байтов вызвал бы губительный всплеск Random I/O.
Вместо этого InnoDB буферизирует изменение в Change Buffer в оперативной памяти. Позже, когда эта страница индекса будет прочитана другим пользовательским запросом, InnoDB прозрачно выполнит слияние (Merge) изменений.


3. Транзакционность и ACID: Взаимодействие с операционной системой

Гарантии надежности и транзакционной изоляции ACID — самые ресурсоемкие операции СУБД, требующие постоянной координации с ядром ОС и дисковыми контроллерами.

Atomicity & Durability: Redo Log и системный вызов fsync

Как гарантировать сохранность зафиксированных транзакций при внезапном обесточивании сервера, не сбрасывая тяжелые 16-килобайтные страницы на медленный диск при каждом COMMIT?

Решение строится на парадигме Write-Ahead Logging (WAL), которая в InnoDB реализована циклическим журналом повтора — Redo Log (файлы ib_logfile0, ib_logfile1 в MySQL 5.7 или директория #innodb_redo в MySQL 8.0).

При фиксации транзакции (COMMIT) InnoDB записывает в буфер памяти Log Buffer не саму страницу данных, а компактную дельту (физико-логический вектор изменений: «на странице №42 по смещению 128 записать байты 0xDEADBEEF»). Затем инициируется блокирующий системный вызов ядра Linux fsync(), который приказывает контроллеру накопителя физически сбросить буфер на диск.

Запись в Redo Log идет строго последовательно (Sequential I/O), благодаря чему диск выжимает максимальную пропускную способность. Измененные же 16 КБ страницы данных (Dirty Pages) сбрасываются из Buffer Pool асинхронно, фоновыми воркерами page_cleaner. При аварийном рестарте InnoDB считывает Redo Log и накатывает дельты на базовые страницы (Crash Recovery, подробнее в 8. WAL. Write Ahead Log).


Consistency: Защита от разорванных страниц через Doublewrite Buffer

Вспомните разницу в физических размерах блоков: страница InnoDB равна 16 КБ, а страница дисковой подсистемы Linux — 4 КБ.
Когда фоновый воркер InnoDB сбрасывает 16-килобайтную страницу на SSD, для операционной системы это последовательность из четырех независимых операций записи по 4 КБ.

Представьте катастрофический сценарий: контроллер успел физически записать первые два блока по 4 КБ, и в этот момент в дата-центре вырубается питание.
На накопителе образуется Torn Page (Разорванная страница): ее первая половина содержит новые данные, а вторая — старые. Заголовок страницы и контрольная сумма повреждены.
Журнал Redo Log бессилен восстановить такую страницу: Redo содержит лишь дифференциальные векторы изменений, для применения которых требуется заведомо валидная исходная страница!

Для предотвращения этой аварии в InnoDB встроен Doublewrite Buffer:

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    BP["Buffer Pool <br> (Dirty Page 16KB)"]:::data --> DW["1. Запись в Doublewrite Buffer <br> (Непрерывный массив в System Tablespace)"]
    DW --> F1["2. fsync() последовательной области <br> (Очень быстрый Sequential I/O)"]
    F1 --> Final["3. Запись на законное место в файл .ibd <br> (Случайный Random I/O)"]
  

Перед тем как отправить измененную страницу в ее постоянное табличное пространство .ibd, InnoDB записывает ее в монолитный последовательный сегмент диска — Doublewrite Buffer (в системном файле ibdata1), и выполняет fsync. Если в момент записи в .ibd произойдет аппаратный сбой, при старте после аварии InnoDB обнаружит поврежденную страницу и восстановит ее эталонную 16-килобайтную копию из Doublewrite Buffer.


Isolation: MVCC и сегменты Undo Logs

Для реализации неблокирующего чтения при параллельной записи InnoDB задействует многоверсионный контроль конкурентности (7. MVCC. Multi Version Concurrency Control).

В отличие от PostgreSQL, где старые версии плодятся прямо на страницах Кучи, в InnoDB старые версии вытесняются в специализированные сегменты — Undo Logs:

Каждый физический кортеж в кластерном индексе несет в заголовке два служебных указателя:

  1. DB_TRX_ID (6 байт): Идентификатор последней транзакции, создавшей или изменившей данную строку.
  2. DB_ROLL_PTR (7 байт): Roll Pointer — физический адрес обратного указателя на предыдущую версию строки в пространстве Undo Log.
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph ClusteredPage ["Страница Кластерного Индекса"]
        Row["Текущая строка: <br> id=1 | balance=150 <br> DB_TRX_ID: 105 <br> DB_ROLL_PTR: -> Undo#1"]:::process
    end

    subgraph UndoSpace ["Undo Tablespace (Логи отката)"]
        Undo1["Версия 100: <br> balance=100 <br> DB_ROLL_PTR: -> Undo#0"]:::process
        Undo0["Исходная версия: <br> balance=50"]:::process
        Undo1 --> Undo0
    end

    Row -.->|"Следование по указателю отката"| Undo1
  

Если транзакция A инициирует SELECT, а параллельная транзакция B обновляет баланс, транзакция A не блокируется: она переходит по цепочке указателей DB_ROLL_PTR в Undo Log и на лету воссоздает исторический снимок данных (Snapshot), актуальный на момент ее старта.


Практика в Go: Идиоматичная работа с транзакциями

В стандартной библиотеке Go взаимодействие с транзакционным движком InnoDB инкапсулировано типом *sql.Tx (разобранным в 1. Работа с БД в Go. database/sql). Безупречный код на Go требует строгого управления контекстами и обязательной защиты от утечек соединений в пуле:

Go
package repository

import (
    "context"
    "database/sql"
    "fmt"
)

// TransferMoney выполняет межбанковский перевод с фиксацией в InnoDB
func TransferMoney(ctx context.Context, db *sql.DB, fromID, toID int, amount float64) error {
    // 1. Задаем уровень изоляции транзакции (в MySQL по умолчанию — Repeatable Read)
    tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
    if err != nil {
        return fmt.Errorf("begin tx failed: %w", err)
    }

    // 2. Золотой паттерн Go: defer Rollback().
    // Если транзакция будет успешно закоммичена, вызов tx.Rollback() вернет sql.ErrTxDone
    // и завершится No-Op. Но при панике (panic) или преждевременном return по ошибке
    // defer гарантированно освободит Row-level блокировки InnoDB и вернет коннект в пул.
    defer tx.Rollback()

    // 3. Списываем средства (InnoDB накладывает Row-level X-lock на строку fromID, см. [[5. Блокировки. Locking]])
    _, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
    if err != nil {
        return fmt.Errorf("debit failed: %w", err)
    }

    // 4. Зачисляем средства
    _, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
    if err != nil {
        return fmt.Errorf("credit failed: %w", err)
    }

    // 5. Фиксация транзакции: на этой строке InnoDB выполняет fsync() для Redo Log
    if err := tx.Commit(); err != nil {
        return fmt.Errorf("commit tx failed: %w", err)
    }

    return nil
}

Резюме для инженера

  1. Кэширование в Buffer Pool: Все чтения и модификации выполняются над 16 КБ страницами в оперативной памяти с защитой от вымывания через двухзонный LRU (Midpoint).
  2. Кластерная природа данных: Таблица InnoDB — это сбалансированное B+ дерево первичного ключа. Поиск по PRIMARY KEY извлекает всю строку за один проход по дереву.
  3. Надежность через Redo Log и Doublewrite: Последовательный журнал Redo гарантирует Durability, а Doublewrite Buffer предотвращает разрушение страниц при Torn Page.
  4. MVCC через Undo Logs: Неблокирующее чтение достигается переходом по скрытым указателям DB_ROLL_PTR в изолированные таблицы отката.
  5. Транзакционная дисциплина в Go: Конструкция defer tx.Rollback() защищает пул соединений от зависших блокировок строк.

Зная, как устроен страничный фундамент и транзакционный конвейер InnoDB, мы готовы перейти к исследованию поисковых структур: как организованы кластерные и вторичные индексы, что такое покрывающие выборки (Covering Index) и почему порядок колонок в составном ключе определяет судьбу запроса. В следующей лекции: 3. Индексы в MySQL.