В предыдущих статьях серии мы научились передавать запрос через API, складывать тяжёлую работу в очередь, держать готовые ответы в кэше, размазывать нагрузку по нескольким серверам, смотреть метрики и находить виновника по логам. Но почти любой запрос в итоге упирается в одно место — базу данных. И именно там чаще всего всё и тормозит: страница грузится три секунды, потому что база три секунды перебирает миллион строк.
Хорошая новость: база данных — очень честная система. Она почти никогда не «тормозит сама по себе». Она медленно отвечает, потому что её попросили сделать много лишней работы. Наша задача — эту лишнюю работу увидеть и убрать.
Плохая новость: без навыков вы будете угадывать. Оптимизация «на глаз» — это когда добавляют три индекса, перезагружают сервер, ставят кэш поверх кэша, а через месяц нагрузка возвращается и становится ещё хуже. Поэтому пойдём по порядку: сначала научимся измерять, потом разберём индексы, потом — масштабирование (шардинг и репликацию), задержку репликации, пул соединений и самое частое практическое зло — проблему N+1.
Термины расшифровываем по ходу. Никакой магии, только инструменты, которые есть в любой нормальной базе.
Часть I. Профилирование: сначала измерьте, потом лечите
1. Журнал медленных запросов — ваш первый инструмент
Slow query log (журнал медленных запросов) — это встроенный в MySQL и MariaDB наблюдатель, который записывает каждый запрос дольше заданного времени. Идея простая: вы не знаете, что тормозит, пока не посмотрите. А когда посмотрите — удивитесь, потому что почти всегда виноваты несколько конкретных запросов, а не «база в целом».
Включаем и настраиваем:
-- Включаем журнал медленных запросов на уровне работающего сервера
SET GLOBAL slow_query_log = 'ON';
-- Порог: писать запросы, которые выполняются дольше 0.5 секунды
SET GLOBAL long_query_time = 0.5;
-- Полезно писать сюда же запросы, которые отработали, но не использовали ни одного индекса
SET GLOBAL log_queries_not_using_indexes = 'ON';
-- Где лежит файл журнала (можно задать и в my.cnf)
SHOW VARIABLES LIKE 'slow_query_log_file';
Порог 0.5 секунды — разумная отправная точка для веб-приложения. Если страница должна отдаваться за 200 миллисекунд, любой запрос дольше полусекунды уже ломает весь отклик. Ставьте завышенный порог в 5 секунд и вы, конечно, увидите пару «монстров» — но реальные жалобы пользователей рождаются из сотни запросов по 300 миллисекунд, каждый из которых сам по себе «не медленный».
Дальше журнал нужно агрегировать, потому что сырой файл на 2 гигабайта нечитаем. Для этого есть готовые анализаторы (mysqldumpslow, pt-query-digest из набора Percona Toolkit) — они складывают одинаковые по форме запросы в одну группу и показывают сводку: сколько раз выполнялся, суммарное время, среднее время, сколько строк просмотрено.
Типичная сводка выглядит так:
| Запрос (упрощённо) | Выполнений за сутки | Среднее время | Всего времени |
|---|---|---|---|
SELECT ... FROM posts WHERE status = ? ORDER BY date DESC LIMIT 20 | 480 000 | 0.9 с | 120 часов |
SELECT ... FROM meta WHERE post_id = ? | 9 600 000 | 0.05 с | 133 часа |
UPDATE counters SET views = views + 1 WHERE id = ? | 200 000 | 0.2 с | 11 часов |
Вторая строка — это и есть классическое N+1, о котором мы поговорим в Части VII. Девять с половиной миллионов микрозапросов дают больше суммарного времени, чем один медленный. Поэтому и важно смотреть суммарное время, а не только «самый долгий запрос».
2. Профилирование в PostgreSQL
В PostgreSQL тот же смысл, но инструменты называются иначе. Расширение pg_stat_statements накапливает статистику по всем выполненным запросам:
-- В postgresql.conf: shared_preload_libraries = 'pg_stat_statements'
-- затем один раз:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Top-10 запросов по суммарному времени
SELECT round(total_exec_time::numeric, 1) AS total_ms,
calls,
round(mean_exec_time::numeric, 1) AS avg_ms,
rows,
left(query, 90) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Обращайте внимание на столбец calls. Запрос, который выполняется 2 миллисекунды, но вызывается миллион раз на страницу, — это катастрофа. Запрос в три секунды, который запускает админ раз в день из отчёта, — неудобство, но не проблема пользователей.
Правило, которое экономит недели работы: не оптимизируйте то, что не доказало свою медленность в суммарном времени. Красивая теория о «правильных индексах» без цифр превращается в десять лишних индексов, которые замедляют запись и никогда не читаются.
Часть II. EXPLAIN: читаем план выполнения запроса
3. Что такое план запроса
EXPLAIN (дословно «разъясни») — команда, которая просит базу не выполнять запрос, а показать, как она собирается его выполнять. База содержит оптимизатор — встроенный «планировщик», который перебирает варианты: брать индекс или перебирать всё, в каком порядке соединять таблицы, использовать ли временный буфер для сортировки. EXPLAIN этот выбор показывает.
-- MySQL / MariaDB: план с фактическими цифрами
EXPLAIN FORMAT = JSON
SELECT p.id, p.title, u.display_name
FROM posts AS p
JOIN users AS u ON u.id = p.author_id
WHERE p.status = 'publish'
ORDER BY p.date DESC
LIMIT 20;
Что смотреть в выводе:
type— способ доступа к таблице. По порядку от худшего к лучшему:ALL(полный перебор всех строк) →index(перебор по индексу) →range(диапазон) →ref(поиск по индексу по значению) →eq_refиconst(точечный поиск по уникальному ключу, самый быстрый).rows— сколько строк база собирается просмотреть. Если их больше, чем строк в результате на порядки — работы слишком много.Extra— «красные флаги» здесь:Using filesort(сортировка в памяти/на диске, потому что индекс не помогает порядку) иUsing temporary(промежуточная таблица). Оба часто лечатся правильным индексом.key— какой индекс реально применён. Если пусто при живых подходящих индексах — значит база сочла их невыгодными (обычно из-за устаревшей статистики или слишком «режего» фильтра).
Полный перебор таблицы в 20 миллионов строк, чтобы взять 20 записей, — это ровно та ошибка, из-за которой страница живёт три секунды.
Для PostgreSQL форма чуть отличается, и там важно добавлять ANALYZE:
-- PostgreSQL: план + фактические замеры времени по ходу выполнения
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.id, p.title, u.display_name
FROM posts AS p
JOIN users AS u ON u.id = p.author_id
WHERE p.status = 'publish'
ORDER BY p.date DESC
LIMIT 20;
ANALYZE заставляет запрос действительно выполниться и показывает план и факт: ожидаемое число строк (rows) против реального (actual rows). Если они расходятся в сто раз — оптимизатор обманут устаревшей статистикой; обычно помогает ANALYZE tablename (команда пересобрать статистику) или расширение автовакуума. BUFFERS показывает, сколько страниц данных пришлось читать с диска и сколько из кэша — это прямой ответ на вопрос «тормозит запрос или просто холодные данные».
4. План в действии: разбираем конкретный случай
Дано: запрос выбирает 20 последних опубликованных постов. В таблице 800 000 строк.
План «до» показывает type: ALL, rows: 794120, Extra: Using where; Using filesort. Разбор простыми словами: база прочитала все 800 тысяч строк, отбросила неопубликованные, потом отсортировала оставшееся в памяти, потом взяла 20 штук. Три секунды на это — совсем не удивительно.
Ставим индекс, который покрывает и фильтр, и порядок сортировки, — и переходим к Части III.
Часть III. Индексы: B-tree, GIN, GiST — какие для каких задач
5. Индекс — это оглавление, а не библиотека
Индекс — отдельная отсортированная структура, которая хранит значения колонки (или набора колонок) вместе с указателями на строки таблицы. Вместо перебора миллиона строк база делает несколько прыжков по дереву и выходит на нужные строки.
B-tree (расшифровывается как balanced tree — «сбалансированное дерево») — это классическая структура для поиска. Дерево сбалансировано: все листья на одной глубине, поэтому поиск всегда за одно и то же малое число шагов. В таблице на 10 миллионов строк поиск по B-tree индексу — это примерно 3–4 прочитанных блока, а не 10 миллионов.
B-tree хорош там, где значения упорядочены и можно сравнить «меньше / больше / равно»: числа, даты, строки при точном совпадении, идентификаторы.
Создаём индекс:
-- MySQL / MariaDB
-- Составной индекс: сначала фильтр по status, внутри него порядок по date.
-- Такой индекс обслуживает и WHERE, и ORDER BY — filesort исчезает.
CREATE INDEX idx_posts_status_date ON posts (status, date);
-- Уникальный индекс: значения не повторяются + работает как обычный индекс для поиска
CREATE UNIQUE INDEX idx_users_email ON users (email);
-- Индекс по префиксу строки: экономит место, если различия в начале текста
CREATE INDEX idx_posts_slug ON posts (slug(32));
-- PostgreSQL: тот же смысл, синтаксис чуть проще
CREATE INDEX IF NOT EXISTS idx_posts_status_date ON posts (status, date DESC);
-- Частичный индекс: только релевантные строки, индекс в десятки раз меньше
CREATE INDEX idx_posts_live ON posts (date DESC) WHERE status = 'publish';
Полезное свойство составного индекса: он работает как телефонный справочник, отсортированный по «фамилия, имя». Поиск по фамилии — отлично. Поиск только по имени — почти бесполезен, индекс не используется. Поэтому порядок колонок в индексе — это не косметика: первым ставят колонку точного фильтра, затем колонку сортировки или диапазона.
Индексы не бесплатны. Каждый индекс — это структура, которую надо обновлять при каждой вставке, изменении и удалении. Пять-шесть индексов на таблице обычно комфортны; двадцать — значит медленная запись и раздутый диск. Перед удалением «лишнего» индекса посмотрите его использование:
-- MySQL: счётчики применения индексов (после нагрузки и работы приложения)
SELECT object_name, index_name, count_read, count_fetch, count_add
FROM information_schema.index_statistics
WHERE object_name = 'posts';
-- PostgreSQL: то же самое, но нагляднее
SELECT relname, indexrelname, idx_scan, idx_tup_read, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
ORDER BY idx_scan;
Индекс с idx_scan = 0 за месяц — кандидат на удаление.
6. GIN: индекс для «внутри значения много чего»
GIN (расшифровка: Generalized Inverted Index — «обобщённый перевёрнутый индекс»). Принцип: вместо одной записи на строку строим список «значение → где оно встречается», как указатель в конце книги: слово «масштабирование» — страницы 12, 47, 131.
Это идеально для данных, где в одной ячейке лежит набор: массивы тегов, JSON с несколькими полями, полнотекстовый поиск по тексту статьи.
-- PostgreSQL: полнотекстовый поиск по тексту поста
CREATE INDEX idx_posts_search ON posts USING gin (to_tsvector('russian', body));
SELECT id, ts_headline('russian', body, q) AS snippet
FROM posts, plainto_tsquery('russian', 'оптимизация индексы') AS q
WHERE to_tsvector('russian', body) @@ q
ORDER BY ts_rank(to_tsvector('russian', body), q) DESC
LIMIT 10;
-- Массив тегов: «найти все посты, где есть тег "php"»
CREATE INDEX idx_posts_tags ON posts USING gin (tags);
SELECT id FROM posts WHERE tags @> ARRAY['php'];
Цена GIN: он большой и медленно строится и обновляется. Ставить его на каждую текстовую колонку нельзя; это инструмент для того, что действительно ищут по содержимому.
7. GiST: индекс для геометрии и «похожести»
GiST (Generalized Search Tree — «обобщённое поисковое дерево»). Отличие от B-tree в том, что GiST умеет сравнивать не «меньше-больше», а «пересекается — не пересекается». Поэтому его стихия:
- геоданные — «найди все объекты в радиусе 5 километров»;
- диапазоны и интервалы времени — «нет ли пересечения занятых слотов»;
- поиск по похожести в векторных расширениях — «найти 10 ближайших по смыслу»;
- небольшие таблицы, где B-tree не окупается, а также
LIKE 'подстрока%'с текстовыми операторными классами.
-- PostgreSQL: геоиндекс для поиска «все точки в радиусе 5 км»
CREATE EXTENSION IF NOT EXISTS cube;
CREATE EXTENSION IF NOT EXISTS earthdistance;
CREATE INDEX idx_shops_location ON shops USING gist (ll_to_earth(lat, lng));
SELECT name, earth_distance(ll_to_earth(:my_lat, :my_lng), ll_to_earth(lat, lng)) AS km
FROM shops
WHERE earth_box(ll_to_earth(:my_lat, :my_lng), 5000) @> ll_to_earth(lat, lng)
ORDER BY km
LIMIT 20;
Короткая таблица сравнения:
| Тип | Расшифровка | Для каких данных | Типовой запрос |
|---|---|---|---|
| B-tree | Balanced tree, сбалансированное дерево | числа, даты, строки, id | =, <, >, BETWEEN, сортировка |
| GIN | Generalized Inverted Index, перевёрнутый индекс | массивы, JSON, текст | «содержит», полнотекстовый поиск |
| GiST | Generalized Search Tree, обобщённое дерево | геометрия, диапазоны, похожесть | «пересекается», «в радиусе» |
Ещё два часто встречающихся типа, чтобы не путаться: hash — индекс только на точное равенство (в PostgreSQL исторически считался менее надёжным, сейчас применим); brin (block range index) — крошечный индекс «какой диапазон значений в каком блоке диска», идеален для очень больших таблиц, где данные пишутся по порядку (логи, счётчики).
8. Когда индекс не поможет
Индекс игнорируется, если:
- условие ломает сортировку:
WHERE YEAR(created_at) = 2026вместоWHERE created_at >= '2026-01-01'. В первом случае функция применена к колонке — индекс бесполезен. Пишите условие так, чтобы колонка стояла голая:created_at BETWEEN '2026-01-01' AND '2026-12-31'. - выборка слишком большая: если
status = 'draft'описывает 80% таблицы, читать её по индексу дороже, чем перебором. База сама выберет перебор — это нормально. LIKE '%подстрока%'с звёздочкой в начале. Тут нужен GIN сpg_trgmили внешний поисковый движок.- индекс есть, но статистика устарела — оптимизатор «не знает», что индекс выгоден.
Часть IV. Шардинг против репликации: горизонтальное и вертикальное масштабирование
9. Два разных вопроса
Тут путаются чаще всего, потому что оба слова начинаются с «несколько серверов». Разделяем:
- Репликация отвечает на вопрос «как снять нагрузку с одного сервера». Одна и та же база лежит на нескольких машинах: одна пишет (master, primary — основной сервер), несколько читают (replica, standby — реплики). Объём данных на каждой машине — полный. Если таблица не влезала на диск, репликация не поможет: она размножает проблему, а не решает её.
- Шардинг отвечает на вопрос «как хранить больше, чем влезает в одну базу». Данные режутся на куски — шарды (от англ. shard, «осколок, часть»): шард 1 хранит пользователей с id 1–10 млн, шард 2 — 10–20 млн и так далее. Каждый шард — отдельный полноценный сервер с куском данных.
- Вертикальное масштабирование — «купить сервер мощнее»: больше CPU, больше RAM, быстрее диски. Проще всего, но упирается в потолок: самый дорогой сервер когда-нибудь заканчивается, а простой на перезагрузку и миграцию никто не отменял.
- Горизонтальное масштабирование — «взять много обычных серверов». Именно оно реализуется репликацией и шардингом. Дешевле на масштабе, но добавляет распределённость: два сервера не могут атомарно изменить одну строку, как это делает один.
10. Репликация: как это устроено
Основной сервер пишет изменения и отправляет их в журнал репликации (binary log в MySQL, write-ahead log в PostgreSQL). Реплики читают журнал и применяют те же изменения у себя. Разница между записью на основном и применением на реплике и есть replication lag (задержка репликации) — о ней в Части V.
Типовая раскладка для веб-приложения:
запись (INSERT / UPDATE / DELETE)
│
▼
┌────────────────┐
│ PRIMARY │ одна база, одна точка записи
└───────┬────────┘
│ журнал репликации (binlog / WAL)
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ REPLICA 1│ │ REPLICA 2│ │ REPLICA 3│ чтение (SELECT)
└──────────┘ └──────────┘ └──────────┘
Прикладной код решает просто: все SELECT — на реплику, все INSERT/UPDATE/DELETE — на основной сервер. Так основной сервер (у него всегда есть работа по записи и он один) освобождается для редких операций записи, а три реплики держат всё чтение.
11. Шардинг: как режут данные
Выбор ключа шардинга (колонки, по которой делят данные) — это самое важное архитектурное решение и оно необратимо: переделывать шардинг потом очень больно.
Два основных способа:
- Диапазонный (range): id 1–10 млн → шард A, 11–20 млн → шард B. Удобен для запросов диапазона («все заказы за ноябрь»), но создаёт «горячий шард»: новые записи всегда идут в последний диапазон, и один сервер пашет, а остальные скучают.
- Хеш-шардинг:
shard = hash(user_id) % 8. Распределяет равномерно, но диапазонные запросы становятся дорогими — их приходится слать во все шарды и склеивать результат.
Паттерн запроса при шардинге:
-- Каждый шард — отдельный сервер со своей копией структуры таблиц.
-- Приложение (или proxy-шардер) сначала определяет целевой шард,
-- затем отправляет ЗАПРОС ТОЛЬКО В НУЖНЫЙ ШАРД:
--
-- shard_index = crc32(user_id) % 4 -> shard_2
--
-- Пример запроса, который выполняется внутри одного шарда.
-- ВАЖНО: в условии обязательно присутствует ключ шардинга (user_id),
-- иначе база не сможет отсечь лишние строки и будет читать всё подряд.
SELECT o.id,
o.user_id,
o.total_amount,
o.created_at
FROM shard_2.orders AS o
WHERE o.user_id = 4815162342 -- ключ шардинга: режет 99% данных сразу
AND o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 50;
-- Диапазонный запрос внутри шарда — тоже эффективен, если диапазон
-- попадает в один шард (ключ шардинга задан явно):
SELECT id, created_at
FROM shard_2.orders
WHERE user_id = 4815162342
AND created_at >= '2026-01-01'
AND created_at < '2026-02-01';
А вот запрос без ключа шардинга («найди все заказы со статусом paid») приходится рассылать во все шарды и складывать результаты. Это называется scatter-gather (дословно «рассеять и собрать») и это самый дорогой паттерн в шардированной системе. Такие запросы обычно уводят из пользовательских сценариев в отдельные аналитические базы.
Честно про цену шардинга:
- Нет нормальных JOIN между шардами — приходится собирать данные в приложении.
- Транзакции через два шарда — это «двухфазная подготовка» (2PC) или сага с компенсацией; сложность резко растёт.
- Изменить схему таблицы — значит изменить её на всех шардах, и сделать это синхронно трудно.
- Балансировка данных (переезд «горячего» пользователя на другой шард) — отдельная операция.
Поэтому шардинг — это не «следующий шаг после первого сервера». Сначала: профилирование → индексы → кеширование (см. Кеширование) → чтение с реплик → вертикальный рост → и только потом шардинг. Для большинства WordPress- и PHP-проектов на VPS шардинг вообще никогда не нужен; реплики и кэш закрывают вопрос.
Часть V. Replication lag: задержка репликации и согласованность данных
12. Что происходит, когда копия отстаёт
Replication lag (задержка репликации) — время между моментом, когда основной сервер записал изменение, и моментом, когда реплика его применила. В спокойной системе это доли секунды. В нагруженной — десятки секунд и минуты.
Причины от самых частых к редким:
- Однопоточное применение на реплике. Основной сервер пишет параллельно в сто соединений, а реплика исторически применяет журнал в одном-двух потоках. MySQL 8 и PostgreSQL умеют параллельное применение; надо убедиться, что оно включено.
- Огромная транзакция. Один
UPDATEна 5 миллионов строк на основном сервере блокирует применение у всех реплик на всё время, пока строки не применены. Дробите крупные изменения на пачки по 1–10 тысяч строк. - DDL-операция (
ALTER TABLE) без онлайн-инструмента: на время перестройки таблицы реплика стоит. Для больших таблиц используютgh-ost,pt-online-schema-changeилиALTER TABLE ... ALGORITHM=INSTANTтам, где это возможно. - Реплика слабее основного сервера — тот же объём изменений на диске в три раза медленнее.
- Сеть и диск. Реплика читает журнал, но упирается в собственный медленный диск.
- Длинные
SELECTна самой реплике мешают применению изменений — классика «аналитик запустил отчёт и всё легло».
13. Как это влияет на пользователя
Функция, которую ломает lag, всегда одна и та же: записали — прочитали и не увидели. Пользователь меняет телефон в профиле, страница перезагружается, читает с реплики — показывает старый номер. Покупатель оплачивает заказ, возвращается на страницу заказа — «заказа нет».
Это вопрос консистентности (согласованности) данных: в распределённой системе в один момент времени разные узлы видят разные версии реальности. Полная согласованность возможна, но стоит она дорого.
Практические приёмы, от дешёвых к дорогим:
- Read-your-writes (читать то, что сам записал). После любой записи на основной сервер следующие N секунд читай для этого пользователя с основного. Реализуется просто: в сессию кладётся
last_write_at; при запросе сравниваем — если запись свежее, чем lag, идём на primary. Это решает 95% жалоб. - Метки версии. Для критичных сущностей храните номер версии (или offset журнала репликации) и при чтении требуйте от реплики «догони до этой версии»: в PostgreSQL —
application_name+pg_wait_for_lsn(), в MySQL —WAIT_FOR_EXECUTED_GTID_SET(). Реплика подождёт и отдаст свежие данные; платите задержкой, но получаете согласованность. - Синхронная репликация. Основной сервер не подтверждает запись, пока хотя бы одна реплика не подтвердит приём. Потеря данных исключается, но каждая запись становится медленнее на время сетевого round-trip. Полусинхронный режим (semi-sync) в MySQL — компромисс: ждём подтверждения только о получении, а не применении.
- Не читать с реплик то, что должно быть точным: остатки на балансе, количество товаров на складе, статус оплаты. Такие данные читаем с primary, остальное — с реплик.
- Мониторинг задержки как метрики. Лаг (задержку) измеряют в секундах (
Seconds_Behind_Masterв MySQL, разницаpg_wal_lsn_diff/pg_last_wal_receive_lsnв PostgreSQL). Порог алерта обычно 2–5 секунд; если у вас есть аналитика на репликах — следите, чтобы отчёты не шли в момент большого лага. Подробности — в статье про Логирование и трассировку: лаг реплики это именно то, что надо писать в логи и трассы, иначе вы увидите «страница тормозит», но не поймёте почему.
14. Eventual consistency: принять как норму
Eventual consistency (дословно «консистентность в конечном счёте», по-русски — «согласованность со временем») — идея, что копия обязательно догонит оригинал, но не обязана быть одинаковой в каждый момент. Для ленты новостей, счётчика просмотров, списка «похожие товары» это полностью приемлемо: пользователь не заметит, что счётчик отстал на две секунды. Для списания денег — неприемлемо.
Хорошая архитектура не борется с лагом, а разделяет данные на две категории: те, где лаг безразличен (и они идут на реплики и в кэш), и те, где его быть не должно (и они всегда читаются с основной базы). Это решение надо принимать на этапе проектирования API, когда вы выбираете, какие ресурсы вообще стоит отдавать с кэша и реплики — подробно в Проектировании API.
Часть VI. Connection pooling: пул соединений
15. Зачем нужен пул соединений
Connection pooling (пул соединений) — приём, при котором приложение держит не «новое соединение на каждый запрос», а несколько открытых и готовых, и берёт их из пула на время запроса, а потом возвращает обратно.
Почему это важно. Открыть соединение с базой — дорогая операция:
- TCP-хендшейк (сетевое рукопожатие) и, если включён TLS, обмен ключами шифрования — это несколько сетевых «туда-обратно»;
- аутентификация (проверка пароля, совмещение прав доступа);
- выделение памяти под буферы соединения на стороне сервера;
- на стороне PostgreSQL — отдельный процесс на каждое соединение, каждый со своей копией памяти. Это дорого: 400 процессов по 10 мегабайт — 4 гигабайта только на соединения.
В нормальном веб-приложении один HTTP-запрос делает к базе десять-тридцать обращений. Если на каждое открывать новое соединение, вы платите 30 × 5 миллисекунд = 150 миллисекунд на соединениях, не начав ничего полезного. При росте числа пользователей это превращается в исчерпание max_connections (лимит соединений в базе) и ошибки «too many connections» (слишком много соединений) для всех.
16. Как настраивать
Уровень 1: персистентные соединения в PHP. MySQLi и PDO могут переиспользовать уже открытое соединение между запросами (persistent=yes). Это маленькая победа, но она не ограничивает количество: при всплеске трафика вы получите столько соединений, сколько одновременных PHP-процессов.
Уровень 2: выделенный пул-прокси. Когда процессов много, ставят прослойку: PgBouncer для PostgreSQL, ProxySQL или MySQL Router для MySQL. Приложение подключается к прокси (он отвечает мгновенно, потому что соединения с базой уже открыты и ждут), а прокси держит маленькое число реальных соединений к базе и подставляет их.
Два режима PgBouncer, которые важно различать:
; /etc/pgbouncer/pgbouncer.ini — фрагмент конфигурации
[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
; Режим пула. transaction = переиспользуем соединение между транзакциями,
; даёт максимум уплотнения; session = соединение закрепляется за клиентом
; до его закрытия (нужно, если код использует prepared statements на уровне сервера).
pool_mode = transaction
; Реальных соединений к базе — мало и жёстко.
; Ориентир: (число ядер базы * 2) + 10, для 8-ядерной базы ~30-40.
default_pool_size = 32
max_client_conn = 2000
; Не держать соединение занятым дольше — иначе один кривой запрос
; блокирует пул для всех.
query_timeout = 30
idle_transaction_timeout = 10
; Логирование и статистика — обязательно, иначе слепая настройка
stats_period = 15Ключевое число — default_pool_size. Оно контринтуитивно: больше соединений к базе не значит быстрее. База обрабатывает столько запросов, сколько может одновременно выполнить её CPU и диск. Лишние соединения просто выстраиваются в очередь и добавляют переключения контекста. Классическая ошибка — поднять max_connections со 100 до 2000 и получить не «в два раза быстрее», а полный коллапс.
Правило подбора: измеряйте среднее время запроса и пропускную способность. Если база стабильно выполняет 300 запросов в секунду при среднем времени 10 миллисекунд, ей нужно около 300 × 0.01 = 3 одновременно работающих запросов — плюс запас на пики и на запросы-долгострои. Пул в 30–50 соединений обычно закрывает потребности сервера на десятки тысяч клиентов.
Отдельно: пул в PHP-FPM не существует сам по себе. Число соединений = число воркеров PHP-FPM. Поэтому pm.max_children (максимум детей) в пуле PHP напрямую связан с max_connections базы. На пять веб-серверов по 50 воркеров — 250 соединений; если база держит 151 — часть запросов упадёт. Считайте это уравнение при любой нагрузке; проверять — в Нагрузочном тестировании.
17. Как понять, что пул — ваше узкое место
Сигнатуры в метриках:
- в базе много одновременно работающих запросов (
Threads_runningв MySQL) при маленьком числе соединений (Threads_connected) — база переваривает, а очереди к ней нет; - приложение ждёт, а база простаивает → пул слишком мал, много потоков стоят в очереди к прокси;
- приложение простаивает в ожидании ответа базы, соединений в пуле занято 100% → либо утечка (соединение не вернули в пул), либо длинные запросы держат его;
Aborted_clients/aborted connectionsрастут → таймауты или закрытие соединения без возврата.
И отдельно: пул не чинит медленные запросы. Если запрос идёт 800 миллисекунд, пул лишь сделает ожидание в очереди честным. Сначала индексы и N+1, потом пул.
Часть VII. Проблема N+1: классическая ошибка ORM
18. Что такое ORM и как рождается N+1
ORM (Object-Relational Mapping — объектно-реляционное отображение) — библиотека, которая позволяет писать не SQL-строки, а код с объектами: $post->author->name. Она прячет запросы к базе «под капотом» и это очень удобно ровно до момента, пока вы не узнали, сколько запросов она делает на самом деле.
N+1 (читается «эн плюс один») — паттерн, когда ORM выполняет один запрос на список объектов, а затем по одному запросу на каждый связанный объект. 1 + N запросов вместо одного.
Плохо:
<?php
// ПЛОХО: классическое N+1.
// 1 запрос на список постов + N запросов на авторов (N = 100 -> 101 запросов к базе)
$posts = Post::where('status', 'publish')
->orderBy('date', 'desc')
->take(100)
->get(); // SQL: SELECT * FROM posts ... LIMIT 100
foreach ($posts as $post) {
echo $post->title . ' — ' . $post->author->name . PHP_EOL;
// ^^^^^^^^^^^^^^^^
// Каждый цикл это отдельный: SELECT * FROM users WHERE id = ?
}Хорошо — eager loading (дословно «загружать заранее», по-русски «выбрать связанные данные сразу»):
<?php
// ХОРОШО: связанные данные достаются одним запросом.
// SQL 1: SELECT * FROM posts WHERE status = 'publish' ORDER BY date DESC LIMIT 100
// SQL 2: SELECT * FROM users WHERE id IN (17, 4, 9, 128, ...) -- ровно нужные авторы
$posts = Post::with('author') // with() = присоединить автора заранее
->where('status', 'publish')
->orderBy('date', 'desc')
->take(100)
->get();
foreach ($posts as $post) {
echo $post->title . ' — ' . $post->author->name . PHP_EOL;
// $post->author уже в памяти: нового запроса нет
}Обратите внимание: количество запросов упало со 101 до 2. Если бы в списке выводились ещё и комментарии, «плохая» версия дала бы 1 + 100 + 100×20 = 2101 запрос; «хорошая» — те же 3. Именно этот эффект и даёт те девять с половиной миллионов микрозапросов в сводке из Части I.
19. Как заметить N+1
- Считать запросы. В Laravel — Debugbar / Telescope; в Symfony — Web Debug Toolbar / profiler; в Django —
connection.queriesиdjango-debug-toolbar. В WordPress естьSAVEQUERIESвwp-config.phpи плагины отладки. Если страница делает 400 запросов к базе при 30 постах — у вас N+1. - Смотреть журнал медленных запросов агрегированно. Один медленный запрос бросается в глаза; десять тысяч одинаковых быстрых — только в суммарном времени.
- Слушать оптимизатор.
log_queries_not_using_indexesловит запросы видаSELECT ... WHERE id = ?по неиндексированному полю. - Проверить
IN— если вы видите одинWHERE id IN (...)на 100 значений, а рядом цикл с одиночным запросом — второй всегда хуже первого.
20. Способы вылечить
- Eager loading (
with()/join_with()/select_related()) — основной способ, описан выше. - JOIN в одном запросе — когда нужна сортировка или фильтр по связанной таблице:
posts JOIN usersвместо двух запросов. - Пакетная загрузка (batch, «загрузить пачкой») — собрать id из первого запроса и сделать один запрос
WHERE author_id IN (?), самому склеив результат в памяти. Это то, что делаетwith(), но в произвольном коде вы повторяете приём вручную. - Денормализация ради списка. Для часто отдаваемого списка «посты + имя автора» удобно хранить имя автора рядом с постом и не ходить в
usersвовсе. Плата — синхронизация при переименовании автора. - Кэш объектов поверх ORM. Ключ
author:{id}, значение — имя. Тогда связанный объект берётся из Redis в 0.3 миллисекунды вместо базы. Про стратегии — в Кешировании.
21. И про безопасность: prepared statements
Раз уж речь о том, как приложение ходит в базу, обязательно о prepared statements (подготовленных выражениях). Это механизм, когда SQL-шаблон отправляется серверу отдельно от значений: сначала текст запроса с подстановочными знаками-плейсхолдерами (? или :name), потом сами значения. Сервер компилирует план один раз и дальше только подставляет данные, никогда не принимая их за код.
Двойная выгода: защита от SQL-инъекций (введения чужого SQL-кода через данные пользователя) и экономия на повторной компиляции одинаковых запросов.
<?php
// ПРАВИЛЬНО: prepared statements (подготовленные выражения) через PDO.
// SQL-шаблон и данные идут раздельно, поэтому пользовательский ввод
// никогда не может превратиться в исполняемый SQL-код.
$pdo = new PDO(
'mysql:host=db.internal;dbname=myapp;charset=utf8mb4',
'app_user',
getenv('DB_PASSWORD'), // пароль — из окружения, не из кода
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // ошибки — исключениями
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // ВАЖНО: настоящие серверные prepare,
// а не подстановка строк на стороне PHP
PDO::ATTR_PERSISTENT => true, // переиспользуем соединение между запросами
]
);
// Именованные плейсхолдеры: имя параметра видно, опечатка падает сразу, а не тихо
$stmt = $pdo->prepare(
'SELECT id, title, date
FROM posts
WHERE status = :status
AND date >= :since
ORDER BY date DESC
LIMIT :limit'
);
$stmt->bindValue(':status', $statusFromRequest, PDO::PARAM_STR);
$stmt->bindValue(':since', $sinceDate, PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT); // числа — типом INT
$stmt->execute();
$posts = $stmt->fetchAll();
// Транзакция: несколько запросов либо применяются все, либо ни один
$pdo->beginTransaction();
try {
$upd = $pdo->prepare('UPDATE posts SET views = views + 1 WHERE id = :id');
$upd->execute([':id' => $postId]);
$log = $pdo->prepare('INSERT INTO view_log (post_id, created_at) VALUES (:id, NOW())');
$log->execute([':id' => $postId]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack(); // ничего не записано в полу-состоянии
throw $e; // ошибку видно в логах — см. статью про логирование
}Три типичные ошибки, которые стоит проверить в своём коде прямо сейчас:
ATTR_EMULATE_PREPARES => true— «подготовка» выполняется на стороне PHP путём склейки строки. Это и медленнее (база не помнит план), и небезопасно при неправильной экранировке. Требуйтеfalse.- Конкатенация в
prepare():prepare("... WHERE id = " . $_GET['id']). Это ровно то, от чего prepared statements защищают, — но защищать уже не от чего, потому что данные попали в SQL-шаблон. LIMITкак строка при нативных prepare:bindValue(':limit', $n, PDO::PARAM_STR)сEMULATE_PREPARES=falseпадает или работает криво. Числа —PDO::PARAM_INT.
Отдельное замечание про ORM: они используют prepared statements по умолчанию, поэтому соблазн «ускорить» цитатой с форума про склейку строк обычно не возникает. Но в ручных DB::select("...") и mysqli_query() — возникает. Там и ищите.
Инструмент под задачу
Попробовать, как тот же запрос ложится на EXPLAIN и индекс, можно прямо на странице: соберёте SQL с учётом префиксов WordPress и проверите план — Попробуйте инструмент прямо здесь, на своих данных:
Чек-лист оптимизации базы данных
- Включите журнал медленных запросов и агрегатор. Без сводки по суммарному времени вы лечите наугад. Порог 0.5 секунды, смотреть топ по
total_time, а не по максимуму. - Прогоните тяжёлые запросы через EXPLAIN. Ищите
ALL/Seq Scan, гигантскоеrowsиUsing filesort/Using temporaryвExtra. - Индексы — по порядку колонок. Фильтр (
=) первым, затем сортировка или диапазон. Проверяйте, что индекс действительно применяется, и удаляйте индексы с нулевымidx_scan. - Для текста, массивов и JSON — GIN, для гео и диапазонов — GiST. Не ждите от B-tree магии на нестандартных данных и не тащите GIN туда, где хватит обычного индекса.
- Сначала чтение, потом объём. Реплики снимают нагрузку чтения; шардинг — только когда данных физически не влезает или не помещается в RAM/диск одного узла.
- После записи — читайте с primary хотя бы короткое время (read-your-writes) или ждите нужной версии (offset/LSN) на реплике. Критичные к точности данные не отдавайте с реплик никогда.
- Лог лага реплики и алерт по нему. Без измеренного лага вы будете писать в тикетах «иногда показывает старые данные» и ничего не докажете.
- Пул вместо соединения-на-запрос. Прокси (PgBouncer / ProxySQL) с маленьким
default_pool_size; считайте число соединений = воркеры × число веб-серверов противmax_connections. - Считайте запросы на страницу. Больше 50 на обычную страницу — почти наверняка N+1. Чините eager loading’ом, JOIN’ом или пакетной загрузкой.
- Только prepared statements, всегда.
EMULATE_PREPARES=false, числа типомPARAM_INT, никакой конкатенации в SQL-шаблон. - Не путайте причину и следствие. Пул не ускорит медленный запрос; кэш не спасёт запрос с полным перебором; «сервер мощнее» не лечит N+1. Порядок: измерить → индекс → убрать лишний запрос → затем уже масштабировать.
Итог. Оптимизация — это дисциплина измерения
Мы прошли путь от «страница тормозит» до конкретной причины: включили журнал медленных запросов, прочитали план выполнения, поставили индекс там, где он нужен именно по типу данных, разделили чтение и запись по репликам, честно учли задержку репликации, уплотнили соединения пулом и схлопнули тысячи микрозапросов в два. Ни одного трюка, ни одной магии — только последовательность, которая работает одинаково в MySQL, MariaDB и PostgreSQL.
Главный вывод этой статьи даже не в инструментах, а в порядке действий. Оптимизация базы данных — это дисциплина: сначала измерить, потом изменить, потом снова измерить. Тот, кто пропускает первое измерение, неизбежно делает два бесполезных изменения и одно вредное.
База больше не тёмный ящик: вы знаете, где она болит, и умеете это лечить. Но все эти меры имеют цену ровно в одном месте — под нагрузкой. Реальные цифры «выдержим ли мы распродажу» и «какой лаг допустим» получают не рассуждениями, а прогоном: k6, профили нагрузки, ступенчатый рост и наблюдение, где система начинает отваливаться.
Материал написан простым языком; все аббревиатуры расшифрованы при первом упоминании (slow query log, EXPLAIN, B-tree, GIN, GiST, shard, replication lag, connection pooling, ORM, N+1, prepared statements, eventual consistency). Примеры используют MySQL/MariaDB и PostgreSQL, так как это самые частые базы в PHP-проектах; синтаксис может отличаться по версиям — сверяйтесь с документацией вашего СУБД (системы управления базами данных). Практические советы учитывают типичный VPS-хостинг и самостоятельное администрирование.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
