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:
Каждый физический кортеж в кластерном индексе несет в заголовке два служебных указателя:
DB_TRX_ID(6 байт): Идентификатор последней транзакции, создавшей или изменившей данную строку.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 требует строгого управления контекстами и обязательной защиты от утечек соединений в пуле:
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
}
Резюме для инженера
- Кэширование в Buffer Pool: Все чтения и модификации выполняются над 16 КБ страницами в оперативной памяти с защитой от вымывания через двухзонный LRU (Midpoint).
- Кластерная природа данных: Таблица InnoDB — это сбалансированное B+ дерево первичного ключа. Поиск по
PRIMARY KEYизвлекает всю строку за один проход по дереву. - Надежность через Redo Log и Doublewrite: Последовательный журнал Redo гарантирует Durability, а Doublewrite Buffer предотвращает разрушение страниц при Torn Page.
- MVCC через Undo Logs: Неблокирующее чтение достигается переходом по скрытым указателям
DB_ROLL_PTRв изолированные таблицы отката. - Транзакционная дисциплина в Go: Конструкция
defer tx.Rollback()защищает пул соединений от зависших блокировок строк.
Зная, как устроен страничный фундамент и транзакционный конвейер InnoDB, мы готовы перейти к исследованию поисковых структур: как организованы кластерные и вторичные индексы, что такое покрывающие выборки (Covering Index) и почему порядок колонок в составном ключе определяет судьбу запроса. В следующей лекции: 3. Индексы в MySQL.