Базы данных MySQL и MariaDB: InnoDB, репликация, различия форков
0%

MySQL и MariaDB: InnoDB, репликация, различия форков

MySQL и MariaDB: InnoDB, репликация, различия форков

MySQL победила не техническим превосходством, а доступностью: в 2000-х это была единственная СУБД, которую можно поставить на дешёвый хостинг за пять минут. Вокруг неё вырос весь веб — LAMP, WordPress, ранние Facebook, Booking, YouTube. Сегодня по DB-Engines она по-прежнему в первой тройке, и это значит: вы почти наверняка столкнётесь с легаси на MySQL, даже если новое пишете на PostgreSQL.

«Знать MySQL» — это не знать синтаксис SQL, он везде примерно одинаковый. Это понимать, почему поиск по вторичному индексу делает два спуска по дереву, почему длинная транзакция на реплике тормозит мастер, почему REPEATABLE READ здесь ведёт себя не по стандарту и чем ALTER TABLE на 200 GB отличается от того же в PostgreSQL.

Реляционную модель мы разбирали раньше, PostgreSQL — в отдельной статье. Здесь мы постоянно сравниваем с ним: это самый честный способ показать границы применимости.

Развилка: откуда взялись форки

История здесь не «для эрудиции» — от неё напрямую зависят несовместимости при миграции.

Ключевое: до 2013 года MariaDB была совместима побайтово, после — нет. MySQL 8.0 перевёл словарь данных в транзакционные InnoDB-таблицы вместо .frm-файлов, MariaDB этого не сделала. Следствие: перенести данные MySQL 8 в MariaDB копированием файлов нельзя, только логическим дампом; обратно — тем более.

Отдельно: расширенная поддержка MySQL 8.0 закончилась в апреле 2026 года. Если у вас всё ещё 8.0 — это версия без патчей безопасности. Целевая ветка сегодня — 8.4 LTS.

Архитектура: сервер отдельно, хранилище отдельно

Главная особенность MySQL — pluggable storage engines. Верхний слой (парсер, оптимизатор, binlog, репликация) не знает, как физически лежат данные, и общается с движком через handler API: «дай следующую строку», «вставь строку», «начни скан по индексу N».

Цена этой красивой идеи, о которой редко говорят:

  • Оптимизатор не знает специфики движка — он мыслит в терминах «прочитать N строк по индексу», а не реальных страниц. Отсюда исторически более слабые планы на сложных запросах, чем в PostgreSQL.
  • Binlog живёт на уровне сервера, redo log — внутри InnoDB. Два журнала приходится согласовывать двухфазным коммитом на каждой транзакции: реальный оверхед и источник тонких багов при крэше.
  • Смешивать движки в одной транзакции нельзя — точнее, можно, но откат затронет только InnoDB-часть, молча и без ошибки.

Вывод: в 2026 году используйте только InnoDB. Совет «поставьте MyISAM, он быстрее на чтение» — привет из 2007-го, неверный уже лет пятнадцать.

InnoDB изнутри

Кластерный индекс: таблица и есть индекс

Отличие номер один от PostgreSQL, определяющее всё остальное. В PostgreSQL таблица — неупорядоченная куча, а индекс хранит указатель на физическое место строки (ctid). В InnoDB таблица физически хранится как B+ дерево, отсортированное по первичному ключу, строки лежат прямо в листьях, а вторичный индекс хранит в листьях не адрес строки, а значение первичного ключа.

Кластерный и вторичный индексы в InnoDB

Четыре следствия, которые надо помнить наизусть:

  • Поиск по вторичному индексу — два спуска по дереву. Сначала находим PK, потом по нему лезем в кластерный индекс за остальными колонками. Исключение — покрывающий индекс, когда все нужные колонки уже есть в самом индексе.
  • PK входит в каждый вторичный индекс. Толстый ключ (CHAR(36) под UUID) раздувает все индексы разом: 36 байт против 8 у BIGINT, при пяти индексах разница в размере доходит до трёх раз.
  • Случайный PK убивает вставки. AUTO_INCREMENT пишет в правый край дерева, страницы заполняются плотно. UUIDv4 бьёт в случайные места: постоянные split-ы, фрагментация до 50%, промахи мимо buffer pool. Нужен UUID — берите UUIDv7 (упорядоченный по времени) в виде BINARY(16), а не строкой.
  • Диапазонный скан по PK почти бесплатен — листья связаны в список. Диапазон по вторичному индексу с обращением к строке — случайный I/O.
-- ПЛОХО: строковый случайный UUID как PK
CREATE TABLE orders_bad (
  id CHAR(36) PRIMARY KEY,                 -- 36 байт в КАЖДОМ вторичном индексе
  user_id BIGINT NOT NULL,
  KEY idx_user (user_id)
) ENGINE=InnoDB;

-- ХОРОШО: суррогатный autoincrement + бизнес-ключ отдельным уникальным индексом
CREATE TABLE orders (
  id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  public_id     BINARY(16) NOT NULL,       -- UUIDv7 в бинарном виде
  user_id       BIGINT UNSIGNED NOT NULL,
  status        ENUM('new','paid','shipped','cancelled') NOT NULL DEFAULT 'new',
  total_cents   INT UNSIGNED NOT NULL,     -- деньги целыми, никогда не FLOAT
  created_at    DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uq_public (public_id),
  KEY idx_user_created (user_id, created_at),    -- «заказы юзера по дате»
  KEY idx_status_created (status, created_at)    -- фоновые воркеры
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC;

Путь записи: buffer pool, redo, undo, doublewrite

InnoDB — классическая WAL-система: на диск сначала попадает журнал изменений, страницы данных догоняют потом.

Путь записи в InnoDB

Компонент Зачем Что настраивать Где болит
Buffer pool кэш страниц 16 KB, всё чтение и запись идут через него innodb_buffer_pool_size = 60–75% RAM если рабочий набор не помещается — деградация обрывом, не плавно
Redo log WAL, определяет выживание транзакции при крэше innodb_redo_log_capacity (8.0.30+), 2–8 GB под нагрузкой маленький redo → форсированные checkpoint, «пилообразный» TPS
Undo log старые версии строк для MVCC и ROLLBACK отдельные undo-tablespaces, авто-truncate длинная транзакция → history list растёт, чтения замедляются
Doublewrite защита от torn page при частичной записи innodb_doublewrite=ON, выключать только при атомарной записи устройства до +10% write amplification
Change buffer откладывает обновление некластерных индексов deprecated в MySQL 8.4 на SSD выгода околонулевая, потому и убирают
Adaptive Hash Index хеш-кэш поверх горячих участков дерева innodb_adaptive_hash_index на многоядерных бывает точкой контеншена — часто выгоднее выключить

Durability определяется двумя ручками, и понимать их надо как пару:

innodb_flush_log_at_trx_commit = 1   # fsync redo на каждом COMMIT — полная durability
sync_binlog                    = 1   # fsync binlog на каждом COMMIT — реплика не отстанет

1/1 — единственная конфигурация, при которой подтверждённые транзакции переживают отключение питания. 2/0 даст заметно больше TPS ценой окна потери до секунды: осознанный выбор для аналитической реплики, но не для платежей.

MVCC: undo-цепочки против bloat

В PostgreSQL новая версия строки — новая физическая строка в куче, мусор убирает VACUUM. В InnoDB наоборот: в кластерном индексе всегда последняя версия, старые восстанавливаются отматыванием undo-записей. У каждой строки есть DB_TRX_ID (6 байт, кто менял последним) и DB_ROLL_PTR (7 байт, указатель в undo). Consistent read создаёт read view — снимок множества активных транзакций; читая строку, InnoDB сравнивает DB_TRX_ID со снимком и при необходимости идёт назад по цепочке, пока не найдёт видимую версию.

PostgreSQL InnoDB
Где старые версии в самой таблице (heap) в undo tablespace
Уборка мусора VACUUM / autovacuum purge threads
Симптом длинной транзакции table bloat, разрастание файлов, wraparound рост history list length, замедление чтений
Чтение старой версии почти бесплатно, версия рядом обход undo-цепочки, тем дороже чем длиннее
UPDATE неиндексированной колонки HOT-update правка на месте, вторичные индексы не трогаются

Обе модели наказывают за долгие транзакции, но по-разному. В MySQL это выглядит так: аналитик открыл транзакцию в REPEATABLE READ, ушёл на обед — и OLTP тормозит, потому что каждый читатель обходит всё более длинную undo-цепочку.

-- Длина history list: сколько версий ещё не вычищено
SELECT COUNT AS history_list_length FROM information_schema.INNODB_METRICS
WHERE NAME = 'trx_rseg_history_len';

-- Транзакции старше 60 секунд — первый подозреваемый
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
       trx_rows_modified, trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

Порог тревоги: history list в несколько миллионов — уже плохо, десятки миллионов — деградация видна невооружённым глазом.

Изоляция и блокировки: где MySQL нестандартен

Общая теория уровней изоляции — в отдельной статье трека, здесь специфика InnoDB.

По умолчанию REPEATABLE READ, а не READ COMMITTED как в PostgreSQL и Oracle, и реализован он через next-key locking: блокируется не только запись, но и «зазор» (gap) перед ней.

BEGIN;  -- REPEATABLE READ
SELECT * FROM orders WHERE user_id = 42 FOR UPDATE;
-- Заблокированы не только существующие строки user_id=42, но и ЗАЗОРЫ вокруг них:
-- вставка нового заказа этому пользователю будет ждать.

Три момента, на которых спотыкаются все:

  1. Смешанная семантика чтений. Обычный SELECT читает снимок, а SELECT ... FOR UPDATE и UPDATE — последнюю зафиксированную версию. В одной транзакции можно увидеть два разных состояния мира. Это дизайн, а не баг, но ловит регулярно.
  2. REPEATABLE READ в InnoDB не даёт сериализуемости. Write skew воспроизводится: две транзакции читают снимок, проверяют инвариант, обе пишут — инвариант нарушен. Нужна настоящая сериализуемость — либо SERIALIZABLE (все SELECT становятся блокирующими, дорого), либо явные блокировки.
  3. Gap-локи — главный источник дедлоков при конкурентных вставках. Классика: INSERT ... ON DUPLICATE KEY UPDATE из нескольких потоков по одному уникальному ключу.

Многие крупные продакшены сознательно переводят MySQL в READ COMMITTED:

transaction_isolation = READ-COMMITTED
binlog_format         = ROW      # обязательно: STATEMENT+RC небезопасен для репликации

Это убирает почти все gap-локи, снижает дедлоки и приближает поведение к PostgreSQL. Цена — фантомы возможны, приложение должно это учитывать.

SHOW ENGINE INNODB STATUS\G          -- секция LATEST DETECTED DEADLOCK
SET GLOBAL innodb_print_all_deadlocks = ON;   -- систематический сбор
SELECT waiting_pid, waiting_query, blocking_pid, blocking_query, wait_age
FROM sys.innodb_lock_waits;          -- кто кого ждёт прямо сейчас

Индексы и планы выполнения

Общая теория — в статье про индексы и планы. Специфика MySQL:

  • Только B+ tree (hash — лишь в MEMORY и AHI). Нет GIN, GiST, BRIN, частичных индексов — главная функциональная потеря против PostgreSQL.
  • Есть функциональные индексы (8.0.13+, через скрытые generated columns), невидимые индексы (INVISIBLE — бесценно для проверки «можно ли удалить индекс»), descending indexes (реально по убыванию, а не «синтаксис принимается и игнорируется»).
  • Префиксные индексы (KEY (email(20))) — уникальная фича, спасает на длинных строках, но ломает покрывающие индексы и ORDER BY.
-- Функциональный индекс: работает ТОЛЬКО если запрос дословно повторяет выражение
ALTER TABLE users ADD INDEX idx_email_lower ((LOWER(email)));
SELECT * FROM users WHERE LOWER(email) = 'bob@ya.ru';

-- Невидимый индекс: «выключаем», наблюдаем сутки, потом удаляем
ALTER TABLE orders ALTER INDEX idx_status_created INVISIBLE;

EXPLAIN ANALYZE — читаем реальный план

MySQL 8.0.18+ умеет EXPLAIN ANALYZE с фактическим временем и числом строк — то, чего не хватало пятнадцать лет.

EXPLAIN ANALYZE
SELECT o.id, o.total_cents, u.email
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid' AND o.created_at >= '2026-06-01'
ORDER BY o.created_at DESC LIMIT 50;
-> Limit: 50 row(s)  (actual time=0.412..0.605 rows=50 loops=1)
    -> Nested loop inner join  (actual time=0.410..0.598 rows=50 loops=1)
        -> Index range scan on o using idx_status_created
             over (status='paid' AND '2026-06-01' <= created_at), reverse
             (cost=1284 rows=4210) (actual time=0.031..0.140 rows=50 loops=1)
        -> Single-row index lookup on u using PRIMARY (id=o.user_id)
             (cost=0.25 rows=1) (actual time=0.008..0.008 rows=1 loops=50)
  • cost=1284 rows=4210оценка оптимизатора, actual ... rows=50реальность. Расхождение на порядок и больше означает протухшую статистику (ANALYZE TABLE) или неудачную форму запроса.
  • reverse — индекс сканируется в обратную сторону, ORDER BY ... DESC обслужен индексом, сортировки нет. Появись Sort: o.created_at DESC — сортировались бы все 4210 строк, и LIMIT 50 не спас бы.
  • loops=50 — пятьдесят обращений к кластерному индексу users. Это цена nested loop join.
-- Кто самый дорогой на проде, без слоу-лога
SELECT DIGEST_TEXT, COUNT_STAR AS calls,
       ROUND(SUM_TIMER_WAIT/1e12, 2) AS total_sec,
       SUM_ROWS_EXAMINED / NULLIF(SUM_ROWS_SENT, 0) AS examined_per_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC LIMIT 15;

examined_per_sent — лучший однострочный детектор отсутствующего индекса. Если запрос просматривает 100 000 строк ради 10 — там нет индекса, и железо это не вылечит. Тонкая настройка — подсказками, а не грубым FORCE INDEX: SELECT /*+ JOIN_ORDER(o, u) INDEX(o idx_status_created) */ ....

Репликация: сердце эксплуатации MySQL

Репликация MySQL — асинхронная логическая репликация через binary log. Это её сила (гибкость, репликация между версиями, фильтрация таблиц) и её слабость (отставание, расхождение данных).

Формат binlog. STATEMENT пишет текст SQL (компактно, но недетерминизм NOW(), UUID(), LIMIT без ORDER BY ломает реплику), ROW пишет образы строк (безопасно всегда, но UPDATE на миллион строк даёт миллион событий), MIXED — компромисс с непредсказуемым объёмом. Используйте ROW; объём смягчается через binlog_row_image=MINIMAL, но осторожно — некоторым CDC-инструментам (см. трек data engineering) нужен FULL.

GTID обязателен. Без него позиция реплики — пара «файл + смещение», привязанная к конкретному мастеру, и failover превращается в ручную археологию. GTID даёт каждой транзакции идентификатор server_uuid:transaction_id, и реплика сама понимает, что уже применила.

gtid_mode                = ON
enforce_gtid_consistency = ON
log_replica_updates      = ON     # нужен для каскадов и failover
-- Переключение на нового мастера — одна команда, без вычисления позиций
CHANGE REPLICATION SOURCE TO SOURCE_HOST='db-new-primary', SOURCE_AUTO_POSITION=1;
START REPLICA;

Полусинхронная репликация закрывает дыру «мастер подтвердил COMMIT, упал, транзакция потеряна»:

plugin_load_add                             = semisync_source.so
rpl_semi_sync_source_enabled                = 1
rpl_semi_sync_source_wait_for_replica_count = 1
rpl_semi_sync_source_timeout                = 1000   # мс, потом деградация в async
rpl_semi_sync_source_wait_point             = AFTER_SYNC

AFTER_SYNC против AFTER_COMMIT — критическая деталь. При AFTER_SYNC мастер ждёт ack до коммита в InnoDB: если он упал, транзакцию никто не мог прочитать, и она либо есть на реплике, либо её нет нигде. При AFTER_COMMIT возможен фантомный read: клиент прочитал данные, мастер упал, транзакции нигде нет. Всегда AFTER_SYNC.

Честная цена: semi-sync добавляет один сетевой round-trip к каждому COMMIT — ~0.5–1 мс внутри AZ, десятки миллисекунд между регионами, что убивает запись. И ..._timeout означает, что при недоступности реплик мастер молча переходит в async. Это не сильная гарантия, а гарантия по умолчанию; настоящий консенсус даёт Group Replication.

Отставание реплики. Seconds_Behind_Source — плохая метрика: показывает 0 при разорванном соединении и врёт при каскадах. Правильно — heartbeat-таблица (pt-heartbeat из Percona Toolkit) плюс performance_schema.replication_applier_status_by_worker.

Причина лага Признак Лечение
Однопоточное применение один воркер загружен, CPU реплики простаивает replica_parallel_type=LOGICAL_CLOCK, replica_parallel_workers=8..16, binlog_transaction_dependency_tracking=WRITESET
Таблица без PK реплика делает full scan на каждое ROW-событие добавить PK — самая частая причина катастрофического лага
Массовый DELETE/UPDATE лаг растёт скачком резать на чанки по 1000–5000 строк с паузами
Медленный диск реплики высокий I/O wait не экономить на дисках реплик

replica_preserve_commit_order=ON держите включённым, иначе реплика покажет транзакции в порядке, отличном от мастера, и «read your own writes» сломается.

Высокая доступность: Group Replication vs Galera

Здесь ветки расходятся принципиально: MySQL предлагает Group Replication (и надстройку InnoDB Cluster = GR + MySQL Router + Shell), MariaDB — Galera (wsrep). Обе — репликация с сертификацией на базе консенсуса, но родословная разная.

Group Replication (MySQL) Galera (MariaDB / Percona XtraDB Cluster)
Консенсус XCom (вариант Paxos) собственный EVS + сертификация
Режим по умолчанию single-primary multi-primary
Присоединение узла clone plugin / binlog SST (XtraBackup/mariabackup) или IST
Максимум узлов 9 (жёсткий лимит) практически 3–5
Расстояние одна AZ, низкая latency одна AZ, низкая latency
Слабое место flow control тормозит группу по самому медленному узлу то же + «горячая строка» даёт шторм откатов

Главное правило для обоих: это высокая доступность, а не масштабирование записи. Multi-primary с записью в одну строку с разных узлов даёт лавину откатов (ER_LOCK_DEADLOCK) на коммите. Практика: писать всегда в один узел, остальные — резерв и чтение. И ни один из них не для разнесения по регионам: сертификация синхронна, latency складывается в каждую транзакцию.

Промышленный ответ MySQL на «нужно масштабировать запись» — Vitess (на нём работают YouTube и Slack) или переход на NewSQL; подробнее о шардировании — в отдельной статье трека.

Различия форков: честная таблица

Аспект MySQL 8.4 (Oracle) MariaDB 11.4+ Percona Server 8.x
Лицензия GPLv2 + коммерческая (dual) GPLv2, без коммерческого ядра GPLv2, полностью открыт
Совместимость данных эталон несовместим на уровне файлов с MySQL 8 drop-in, файлы совместимы
Тип JSON нативный бинарный, JSON_TABLE алиас для LONGTEXT, хранится текстом как в MySQL
Движки InnoDB InnoDB + Aria, MyRocks, ColumnStore, Spider, S3 InnoDB + MyRocks
HA Group Replication / InnoDB Cluster / ClusterSet Galera (встроен) Galera (PXC) + GR
Оптимизатор hash join, гистограммы свой, оптимизация подзапросов исторически сильнее как в MySQL + диагностика
Уникальное descending/invisible index, CHECK, clone plugin SEQUENCE, system-versioned tables, RETURNING, тип VECTOR (11.8) аудит, thread pool, детальная инструментация в опенсорсе
Аутентификация caching_sha2_password; mysql_native_password выключен в 8.4 ed25519, unix_socket как в MySQL
Бэкап Enterprise Backup (платно) / clone plugin / XtraBackup mariabackup в комплекте XtraBackup бесплатно и полноценно

Три вывода:

  1. JSON в MariaDB — это текст. Функции те же, но парсинг при каждом обращении и больший размер. Активно работаете с JSON — MySQL 8 объективно лучше.
  2. Аргумент «MariaDB открытее» ослаб. Ядро MySQL — GPL, а MaxScale (прокси MariaDB) — под BSL. Реально свободный и полный стек — Percona: это MySQL с открытыми аналогами платных фич Oracle плюс XtraBackup, индустриальный стандарт горячего бэкапа InnoDB. Для self-hosted это почти всегда чистый выигрыш над ванильным MySQL.
  3. Миграция между форками — односторонняя дверь. MySQL → MariaDB работает логическим дампом, обратно официально не поддерживается. Это архитектурное решение, а не «потом поменяем».

Схема и данные: подводные камни

Составной PK (order_id, product_id) в ORDER_ITEMS — не каприз: позиции одного заказа физически лежат рядом в кластерном индексе, и выборка всего заказа превращается в один последовательный скан вместо N случайных чтений. Приём, недоступный в PostgreSQL, где автоматической кластеризации нет.

Грабли, на которые наступают все:

utf8 — это не UTF-8. Историческая кодировка MySQL трёхбайтовая, эмодзи и часть CJK не умеет. Всегда utf8mb4. Побочный эффект: при ROW_FORMAT=COMPACT лимит индексного префикса 767 байт, отсюда легендарное UNIQUE KEY (email(191)) в старых схемах; с ROW_FORMAT=DYNAMIC лимит 3072 байта и проблема снята.

Неявное приведение типов молча убивает индекс:

-- phone VARCHAR(20), индекс есть
SELECT * FROM users WHERE phone = 79161234567;    -- ЧИСЛО → приводится КОЛОНКА → full scan
SELECT * FROM users WHERE phone = '79161234567';  -- индекс работает

То же с несовпадающими коллациями при JOIN (utf8mb4_general_ci против utf8mb4_0900_ai_ci) — join по индексу превращается в перебор. Проверяйте collation_name в information_schema.columns.

TIMESTAMP умрёт в 2038 году — четыре байта, диапазон до 2038-01-19. Для дат берите DATETIME (8 байт, до 9999), храните UTC. TIMESTAMP оправдан только для updated_at с авто-обновлением.

COUNT(*) без условия — полный скан. Точного счётчика в InnoDB нет из-за MVCC; information_schema.TABLES.TABLE_ROWS даёт оценку с погрешностью до 50%. Точный счётчик под нагрузкой — только материализованный.

Глубокий OFFSET — квадратичная боль. LIMIT 100000, 20 читает и выбрасывает 100 000 строк. Лечится keyset-пагинацией:

SELECT * FROM orders ORDER BY id LIMIT 100000, 20;              -- ПЛОХО
SELECT * FROM orders WHERE id > :last_seen_id ORDER BY id LIMIT 20;  -- ХОРОШО

AUTO_INCREMENT прожигает значенияINSERT ... ON DUPLICATE KEY UPDATE и откаченные транзакции двигают счётчик. INT UNSIGNED (4.29 млрд) при интенсивных upsert-ах исчерпывается за годы, а не десятилетия. Берите BIGINT UNSIGNED сразу: 8 лишних байт дешевле аварийного ALTER на 500 GB.

sql_mode. В MySQL 8 по умолчанию строгий режим с ONLY_FULL_GROUP_BY — это правильно. Если легаси «мешает» — не выключайте, чините запросы: GROUP BY с невыбранными колонками возвращает произвольные значения, то есть скрытый баг.

Конфигурация для продакшена

Реальный my.cnf для выделенного сервера 64 GB RAM, NVMe, OLTP:

[mysqld]
# --- Память ---
innodb_buffer_pool_size          = 44G     # ~70% RAM
innodb_buffer_pool_instances     = 8       # снижает контеншен мьютексов
innodb_log_buffer_size           = 64M
tmp_table_size                   = 64M
max_heap_table_size              = 64M
# sort_buffer_size / join_buffer_size — ПОСОЕДИНЕНЧЕСКИЕ: умножайте на max_connections,
# прежде чем поднимать. Дефолты почти всегда адекватны.
sort_buffer_size                 = 2M
join_buffer_size                 = 1M

# --- Durability ---
innodb_flush_log_at_trx_commit   = 1
sync_binlog                      = 1
innodb_doublewrite               = ON
innodb_flush_method              = O_DIRECT   # не кэшировать дважды (page cache + buffer pool)

# --- Redo и IO ---
innodb_redo_log_capacity         = 4G
innodb_io_capacity               = 4000       # NVMe; для SATA SSD ~1000
innodb_io_capacity_max           = 8000
innodb_adaptive_hash_index       = OFF        # на многоядерных часто выгоднее — но ЗАМЕРЬТЕ

# --- Транзакции ---
transaction_isolation            = READ-COMMITTED
innodb_lock_wait_timeout         = 10         # быстро падать, а не висеть 50 секунд
innodb_print_all_deadlocks       = ON

# --- Репликация ---
server_id                        = 101
log_bin                          = /var/log/mysql/binlog
binlog_format                    = ROW
binlog_row_image                 = MINIMAL
binlog_expire_logs_seconds       = 604800     # 7 дней
gtid_mode                        = ON
enforce_gtid_consistency         = ON
log_replica_updates              = ON
replica_parallel_type            = LOGICAL_CLOCK
replica_parallel_workers         = 16
replica_preserve_commit_order    = ON
binlog_transaction_dependency_tracking = WRITESET

# --- Соединения и диагностика ---
max_connections                  = 512
slow_query_log                   = ON
long_query_time                  = 0.2
log_queries_not_using_indexes    = OFF        # иначе лог захлебнётся
performance_schema               = ON

MySQL — thread-per-connection: тысяча соединений это тысяча потоков ОС, переключения контекста и контеншен. Ставьте перед базой пулер (ProxySQL или thread pool из Percona/MariaDB) и держите max_connections в пределах нескольких сотен.

Эксплуатация

Изменение схемы без простоя

MySQL 8 умеет ALGORITHM=INSTANT для части операций (колонка в конец, переименование, смена дефолта) — это метаданные, миллисекунды. Всё остальное (ADD INDEX, смена типа) — INPLACE или COPY с перестроением: часы на больших таблицах и репликационный лаг.

ALTER TABLE orders ADD COLUMN promo_code VARCHAR(32) NULL, ALGORITHM=INSTANT;
-- ERROR 1845 → мгновенно не получится, нужен инструмент онлайн-миграции
gh-ost pt-online-schema-change
Механизм читает binlog, копирует в теневую таблицу триггеры на исходной таблице
Нагрузка на мастер минимальная, вне критического пути триггеры удваивают стоимость каждой записи
Управление на лету пауза, дросселирование, смена скорости ограниченное
Требования ROW binlog, желательно отдельная реплика работает почти везде
Внешние ключи не поддерживает частично
gh-ost \
  --host=db-primary --database=shop --table=orders \
  --alter="ADD INDEX idx_promo (promo_code)" \
  --max-load="Threads_running=25" --critical-load="Threads_running=100" \
  --max-lag-millis=1500 --allow-on-master --execute

Резервные копии

Способ Тип Восстановление 500 GB Когда
mysqldump логический часы–сутки мелкие базы, миграции между версиями и форками
mydumper / myloader логический параллельный заметно быстрее dump средние базы, частичное восстановление
XtraBackup физический горячий десятки минут стандарт для прода
Clone plugin (8.0.17+) физический встроенный быстро быстрое поднятие новой реплики
Снапшот тома (EBS/LVM) блочный минуты только согласованный: FLUSH TABLES WITH READ LOCK или LVM
xtrabackup --backup --target-dir=/backup/full --parallel=8 --compress
xtrabackup --backup --target-dir=/backup/inc1 --incremental-basedir=/backup/full

# Point-in-time recovery поверх бэкапа
mysqlbinlog --start-datetime="2026-07-15 03:00:00" \
            --stop-datetime="2026-07-15 03:47:12" \
            binlog.000144 binlog.000145 | mysql -u root -p

Правило, которое дорого достаётся: бэкап, который вы не восстанавливали, — не бэкап. Восстановление на staging раз в месяц с замером RTO — обязательная практика.

Замеры

Порядки величин на сервере 32 vCPU / NVMe, sysbench oltp_read_write, 128 потоков. Это ориентир, а не бенчмарк:

Сценарий Пропускная способность Что ограничивает
Point select по PK 200–400k QPS CPU, контеншен мьютексов
OLTP read-write, данные в памяти 20–50k TPS CPU + fsync redo
То же, flush_log_at_trx_commit=2 +30–60% TPS ценой окна потери ~1 с
То же, рабочий набор больше RAM падение в 5–20 раз случайный I/O
Semi-sync внутри AZ −15–30% TPS round-trip на каждый COMMIT
Semi-sync между регионами падение в разы RTT 30–80 мс на COMMIT

Главный вывод — обрыв при выходе за buffer pool. Планирование ёмкости в MySQL — это в первую очередь планирование того, помещается ли горячий набор в память.

Стоимость владения

Вариант Порядок цены Что получаете Скрытые издержки
MySQL/Percona на своих серверах железо + инженер полный контроль, ноль лицензий нужен человек, умеющий чинить репликацию в 3 ночи
MySQL Enterprise (Oracle) десятки тысяч $/год за сервер Enterprise Backup, аудит, TDE, поддержка lock-in на инструментах
AWS RDS for MySQL $$ managed, автобэкапы, Multi-AZ нет SUPER, ограниченные настройки, дорогой IOPS
Aurora MySQL $$$ распределённое хранилище, до 15 реплик, быстрый failover плата за I/O-операции, свой форк движка
Vitess / PlanetScale $$$ горизонтальное шардирование нет внешних ключей, ограничения на схему, сложность

Про Aurora: это MySQL-совместимый движок с переписанным слоем хранения (лог уходит в распределённое хранилище, страницы материализуются там же). Отличный failover и масштабирование чтения, но биллинг за I/O делает дешёвые-на-вид инстансы дорогими под нагрузкой, а поведение оптимизатора местами отличается от ванильного MySQL.

Когда НЕ брать MySQL

  • Аналитика по сотням миллионов строк. MySQL строчный, без векторизации и параллельного выполнения одного запроса. Это работа для ClickHouse.
  • Богатая типизация и расширения. Нужны массивы, диапазоны, JSONB с GIN, PostGIS, морфология — PostgreSQL выигрывает нокаутом.
  • Полнотекстовый поиск с релевантностью и фасетами. InnoDB FULLTEXT существует, но это не конкурент Elasticsearch/OpenSearch.
  • Векторный поиск. В MariaDB 11.8 появился VECTOR, в MySQL — только в HeatWave; для серьёзной задачи берите специализированное решение.
  • Горизонтальное масштабирование записи из коробки. Ни GR, ни Galera этого не дают.
  • Геораспределённая строгая консистентность. Синхронная репликация через регионы — гарантированная деградация; это профиль распределённых СУБД.

Зеркально, MySQL всё ещё отличный выбор при: OLTP с понятным набором запросов и высокой конкурентностью, чтение-доминирующих веб-приложениях с read-репликами, потребности в огромной экосистеме инструментов и доступных на рынке инженерах, простой операционной модели репликации и рабочем наборе, помещающемся в память.

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

  1. Таблица без первичного ключа. На мастере работает, на реплике каждое ROW-событие превращается в full scan. Аудит обязателен.
  2. SELECT * везде — лишает покрывающих индексов и тянет TEXT/BLOB с overflow-страниц.
  3. Индекс на каждую колонку отдельно. MySQL умеет index merge, но плохо. Составной индекс (a, b, c) покрывает a, (a,b), (a,b,c) — думайте о leftmost prefix.
  4. FLOAT/DOUBLE для денег. Всегда DECIMAL или целые копейки.
  5. Foreign key на горячем пути. MySQL проверяет FK честно, беря блокировку на родительскую строку — точка контеншена. Не повод отказываться, но повод знать цену.
  6. Диагностика через SHOW PROCESSLIST вместо performance_schema и sys-схемы, где на все типовые вопросы есть готовые представления.
  7. Один гигантский DELETE для чистки истории. Раздувает undo, блокирует, ломает репликацию. Резать на чанки, а лучше — партиционировать по времени и делать ALTER TABLE ... DROP PARTITION (мгновенно).
  8. ORDER BY RAND() — полная сортировка таблицы. Никогда.
  9. Отсутствие отрепетированного failover. Репликация настроена, а что делать при падении мастера — не написано и не проверено. Инструменты: Orchestrator, InnoDB Cluster, ProxySQL.

Мини-итог

  • InnoDB хранит таблицу как B+ дерево по первичному ключу. Отсюда почти всё: важность узкого возрастающего PK, двойной спуск по вторичным индексам, дешёвые диапазонные сканы по PK.
  • MVCC реализован undo-цепочками, а не версиями в куче: нет bloat, но есть деградация чтений при длинных транзакциях — мониторьте history list length.
  • REPEATABLE READ по умолчанию и next-key locking дают защиту от фантомов ценой gap-локов и дедлоков; многие продакшены осознанно переходят на READ COMMITTED.
  • Репликация логическая и асинхронная через binlog. GTID обязателен, ROW обязателен, параллельное применение с WRITESET обязательно под нагрузкой. Semi-sync закрывает потерю данных, но только в пределах одной AZ.
  • Group Replication и Galera — высокая доступность, а не масштабирование записи.
  • Форки разошлись достаточно, чтобы выбор стал архитектурным: MariaDB даёт больше движков и своих фич, но JSON там текстовый и обратная миграция невозможна; Percona — самый практичный вариант для self-hosted.
  • Не берите MySQL под аналитику, полнотекст, векторы и геораспределённую запись.

Источники

  • MySQL 8.4 Reference Manual — разделы InnoDB и Replication читаются как учебник.
  • MariaDB Server Documentation — Galera и различия с MySQL.
  • Baron Schwartz, Peter Zaitsev, Vadim Tkachenko. High Performance MySQL, 4-е издание (O’Reilly, 2021) — до сих пор лучшая книга по эксплуатации.
  • Jeremy Cole, «InnoDB: A journey to the core» — разбор физического формата страниц на уровне байтов.
  • Percona Database Performance Blog — практика, замеры, разборы инцидентов.
  • gh-ost design docs — как устроена онлайн-миграция схемы через binlog.
  • Vitess documentation — шардирование MySQL в проде.
  • Martin Kleppmann. Designing Data-Intensive Applications — главы 5 и 7 про репликацию и транзакции, лучший контекст для всего вышеописанного.

Что дальше

Мы разобрали два самых распространённых открытых SQL-сервера. Дальше — противоположный полюс: корпоративные СУБД, где технические решения неотделимы от лицензионной модели, а стоимость владения считается сотнями тысяч долларов.

Microsoft SQL Server и Oracle: корпоративный мир, лицензии, особенности

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов