Tento článok je momentálne v ruštine — anglický preklad sa pripravuje.
Обычный индекс часто создают по одной колонке.
Например:
CREATE INDEX idx_orders_user_id
ON orders (user_id);
Такой индекс помогает быстро искать заказы конкретного пользователя:
SELECT *
FROM orders
WHERE user_id = 42;
Но в реальных запросах мы редко фильтруем только по одному полю.
Чаще запрос выглядит так:
SELECT id, amount, status
FROM orders
WHERE user_id = 42
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
Здесь сразу несколько задач:
- найти заказы конкретного пользователя;
- взять только заказы после определённой даты;
- отсортировать от новых к старым;
- вернуть только первые 20 строк;
- выбрать
id, amount и status.
Для такого запроса один индекс по user_id может помочь, но не идеально. База найдёт заказы пользователя, а потом ей всё равно придётся разбираться с датой, сортировкой и выборкой нужных колонок.
Вот здесь появляется составной индекс.
Составной индекс — это индекс сразу по нескольким колонкам:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Он может помочь базе пройти по данным почти в том же порядке, в котором они нужны запросу.
Если подобрать порядок колонок правильно, составной индекс может заменить сразу несколько лишних операций: фильтрацию, сортировку и иногда даже поход в саму таблицу.
Но если порядок колонок выбрать плохо, индекс может оказаться почти бесполезным.
Базовая таблица для примеров
Будем использовать таблицу заказов:
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Типичные запросы к такой таблице:
SELECT id, amount, status
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
SELECT id, amount, status
FROM orders
WHERE user_id = 42
AND created_at >= '2026-01-01'
AND created_at < '2026-02-01'
ORDER BY created_at DESC;
SELECT id, user_id, amount
FROM orders
WHERE created_at >= now() - interval '1 day';
Все запросы вроде похожи, но индексы для них могут быть разными.
Что такое составной индекс
Составной индекс — это индекс, который строится по нескольким колонкам в заданном порядке.
Например:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Здесь ключ индекса состоит из двух частей:
user_id
created_at
PostgreSQL хранит значения в индексе примерно так, как словарь хранит слова: сначала сортировка идёт по первой колонке, потом внутри одинаковых значений первой колонки — по второй.
То есть индекс:
(user_id, created_at DESC)
можно представить так:
user_id = 1
created_at: 2026-06-10
created_at: 2026-06-09
created_at: 2026-06-08
user_id = 2
created_at: 2026-06-11
created_at: 2026-06-07
user_id = 42
created_at: 2026-06-18
created_at: 2026-06-17
created_at: 2026-06-01
Сначала данные сгруппированы и отсортированы по user_id.
А внутри каждого user_id они уже отсортированы по created_at DESC.
Это очень удобно для запроса:
SELECT id, amount, status
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
База быстро находит блок заказов пользователя 42, а внутри этого блока заказы уже лежат от новых к старым.
Правило левого префикса
Главное правило составного B-tree индекса — левый префикс.
Если индекс создан так:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
то он особенно хорошо работает для запросов, которые начинают использовать индекс слева направо.
Например, хорошо:
WHERE user_id = 42
Потому что user_id — первая колонка индекса.
Хорошо:
WHERE user_id = 42
AND created_at >= '2026-01-01'
Потому что мы используем первую колонку, а потом вторую.
Но хуже:
WHERE created_at >= '2026-01-01'
Здесь мы пытаемся использовать только вторую колонку индекса, пропустив первую.
Почему это проблема?
Потому что индекс отсортирован сначала по user_id, а уже потом по created_at.
Если искать только по дате, база не может просто взять один аккуратный диапазон по created_at. Даты разбросаны внутри каждого пользователя.
Условно:
user_id = 1: даты 2026, 2025, 2024
user_id = 2: даты 2026, 2025, 2024
user_id = 42: даты 2026, 2025, 2024
Дата есть внутри каждой группы user_id, а не единым общим списком.
Поэтому индекс (user_id, created_at) не заменяет отдельный индекс по created_at, если у вас часто бывают запросы только по дате.
Пример со словарём
Представьте бумажный справочник, отсортированный так:
город -> фамилия -> имя
Например:
Алматы
Иванов
Петров
Сидоров
Астана
Иванов
Петров
Москва
Иванов
Смирнов
Если вы знаете город, искать легко:
Найти всех людей в Алматы.
Если вы знаете город и фамилию, ещё легче:
Найти Иванова в Алматы.
Но если вы знаете только фамилию:
Найти всех Ивановых.
то справочник уже не так удобен. Вам придётся смотреть каждый город и внутри него искать Ивановых.
Составной индекс работает похожим образом.
Индекс:
(city, last_name, first_name)
хорош для:
WHERE city = 'Almaty'
и для:
WHERE city = 'Almaty'
AND last_name = 'Ivanov'
Но плохо подходит для:
WHERE last_name = 'Ivanov'
потому что первая колонка city пропущена.
Какие запросы покрывает индекс (user_id, created_at DESC)
Создадим индекс:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Теперь посмотрим, где он полезен.
Хорошо: фильтр по user_id
SELECT *
FROM orders
WHERE user_id = 42;
Индекс помогает, потому что используется первая колонка.
Хорошо: user_id и дата
SELECT *
FROM orders
WHERE user_id = 42
AND created_at >= '2026-01-01';
Индекс помогает ещё лучше: сначала сужаем поиск по user_id, потом берём нужный диапазон по created_at.
Отлично: user_id, сортировка и LIMIT
SELECT id, amount, status
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
Это почти идеальный запрос для такого индекса.
База находит пользователя 42, идёт по его заказам от новых к старым и останавливается после 20 строк.
Ей не нужно сортировать все заказы пользователя отдельно.
Плохо: только created_at
SELECT *
FROM orders
WHERE created_at >= '2026-01-01';
Такой запрос не совпадает с началом индекса. Для него лучше отдельный индекс:
CREATE INDEX idx_orders_created_at
ON orders (created_at);
или другой составной индекс, если дата идёт первой:
CREATE INDEX idx_orders_created_user
ON orders (created_at, user_id);
Но это уже другой сценарий.
Важная оговорка про оптимизатор
В современных версиях PostgreSQL оптимизатор иногда умеет использовать составной B-tree индекс даже без условия по первой колонке. Например, через skip scan.
Но для проектирования индексов новичку лучше не строить стратегию на исключениях.
Практическое правило остаётся таким:
Составной индекс проектируем под запросы,
которые используют его колонки слева направо.
Если вы часто ищете только по created_at, не надейтесь, что индекс (user_id, created_at) всегда спасёт. Скорее всего, вам нужен отдельный индекс или другой порядок колонок.
Равенство, диапазон и сортировка
Теперь самое важное: как выбирать порядок колонок в составном индексе.
Посмотрим на запрос:
SELECT id, amount, status
FROM orders
WHERE user_id = 42
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
Здесь:
user_id = 42
это равенство.
А:
created_at >= '2026-01-01'
это диапазон.
И ещё есть сортировка:
ORDER BY created_at DESC
Хороший индекс:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Почему именно так?
Потому что сначала мы точно знаем пользователя:
user_id = 42
Это резко сужает область поиска.
А внутри заказов этого пользователя нам нужны самые новые записи:
created_at DESC
Индекс уже хранит их в нужном порядке.
Вместе с LIMIT 20 это особенно эффективно: база не обязана читать все заказы пользователя, сортировать их и потом брать первые 20. Она может идти по индексу в нужном порядке и остановиться, когда набрала 20 строк.
Почему равенства обычно ставят в начало
Если в запросе есть несколько условий по равенству, такие колонки обычно хорошо ставить в начало индекса.
Например:
SELECT *
FROM orders
WHERE user_id = 42
AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;
Для такого запроса может подойти индекс:
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
Здесь:
user_id = 42
status = 'paid'
это точные условия.
Они помогают базе быстро сузить область поиска.
А потом:
created_at DESC
даёт нужный порядок.
Такой индекс хорошо совпадает с запросом:
WHERE user_id = 42
AND status = 'paid'
ORDER BY created_at DESC
Диапазон лучше ставить после равенств
Диапазонные условия:
>
>=
<
<=
BETWEEN
обычно ставят после колонок с равенством.
Например:
WHERE user_id = 42
AND created_at >= '2026-01-01'
Хороший индекс:
(user_id, created_at)
Сначала база находит пользователя, потом внутри его заказов берёт диапазон по дате.
Но если сделать наоборот:
(created_at, user_id)
то для этого запроса индекс может быть менее удобным.
Почему?
Потому что база сначала будет искать все заказы после 2026-01-01, а уже внутри них проверять user_id = 42.
Если после этой даты заказов очень много, это хуже.
Диапазон «ломает» дальнейший порядок
Есть важный нюанс.
Допустим, у нас индекс:
CREATE INDEX idx_orders_user_created_status
ON orders (user_id, created_at, status);
И запрос:
SELECT *
FROM orders
WHERE user_id = 42
AND created_at >= '2026-01-01'
AND status = 'paid';
База хорошо использует:
user_id = 42
created_at >= '2026-01-01'
Но после диапазона по created_at колонка status уже не так полезна для сужения сканируемого участка индекса, как если бы она стояла до диапазона.
Почему?
Потому что внутри диапазона дат строки уже идут по датам, а status распределён внутри этого диапазона.
Если статус важен и всегда указан равенством, часто лучше индекс:
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at);
Для запроса:
WHERE user_id = 42
AND status = 'paid'
AND created_at >= '2026-01-01'
это обычно логичнее:
сначала пользователь;
потом статус;
потом диапазон дат.
Практическое правило:
Сначала равенства.
Потом колонка для сортировки или диапазона.
Дальше — по ситуации.
Сортировка через индекс
Индекс может помогать не только искать строки, но и возвращать их уже в нужном порядке.
Например:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Запрос:
SELECT id, amount, status
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
может идти по индексу и сразу получать строки в нужном порядке.
Без такого индекса базе пришлось бы:
найти все заказы пользователя;
отсортировать их по created_at DESC;
взять первые 20.
С индексом порядок уже готов.
Особенно большой выигрыш появляется вместе с LIMIT.
Если пользователю нужны только последние 20 заказов, база может не читать тысячу старых заказов. Она возьмёт первые 20 из нужного участка индекса и остановится.
Индекс можно читать в обратную сторону
B-tree индекс в PostgreSQL можно читать как вперёд, так и назад.
Например, если есть индекс:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
он хорошо подходит для:
WHERE user_id = 42
ORDER BY created_at DESC
Но PostgreSQL может прочитать его и в обратную сторону для:
WHERE user_id = 42
ORDER BY created_at ASC
Для одной колонки сортировки это обычно не проблема.
Поэтому отдельный индекс только ради обратного направления часто не нужен.
Например, не стоит автоматически создавать оба индекса:
CREATE INDEX idx_orders_user_created_desc
ON orders (user_id, created_at DESC);
CREATE INDEX idx_orders_user_created_asc
ON orders (user_id, created_at ASC);
Чаще всего достаточно одного.
Смешанные направления сортировки
Сложнее становится, когда сортировка идёт по нескольким колонкам с разными направлениями.
Например:
ORDER BY status ASC, created_at DESC
Для такого запроса может понадобиться индекс именно с таким порядком:
CREATE INDEX idx_orders_status_created
ON orders (status ASC, created_at DESC);
Почему?
Потому что обратное чтение индекса полностью меняет направление всех колонок сразу.
Индекс:
(status ASC, created_at DESC)
при обратном чтении даст:
status DESC, created_at ASC
Но он не превратится в:
status ASC, created_at ASC
или:
status DESC, created_at DESC
То есть направления в многоколоночной сортировке нужно подбирать осознанно.
Простой пример:
CREATE INDEX idx_emp_dept_salary
ON employees (dept ASC, salary DESC);
Он хорошо подходит для:
SELECT name, salary
FROM employees
ORDER BY dept ASC, salary DESC;
И при обратном чтении — для:
ORDER BY dept DESC, salary ASC;
Но не обязательно подойдёт без сортировки для:
ORDER BY dept ASC, salary ASC;
Потому что это уже другой смешанный порядок.
INCLUDE: добавить колонки в индекс для чтения
Теперь перейдём к INCLUDE.
Допустим, у нас есть запрос:
SELECT created_at, amount, status
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
И есть индекс:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Индекс помогает найти нужные строки и отдать их в правильном порядке.
Но в SELECT нам нужны ещё:
amount
status
Этих колонок в индексе нет.
Значит, PostgreSQL может сначала найти подходящие записи в индексе, а потом сходить в основную таблицу за amount и status.
Это всё равно может быть быстро, но иногда хочется, чтобы база вообще не ходила в таблицу.
Для этого можно добавить колонки через INCLUDE:
CREATE INDEX idx_orders_user_created_inc
ON orders (user_id, created_at DESC)
INCLUDE (amount, status);
Теперь индекс содержит:
ключевые колонки:
user_id, created_at
дополнительные колонки:
amount, status
Ключевые колонки участвуют в поиске и сортировке.
Колонки из INCLUDE просто лежат в индексе как дополнительные данные для чтения.
Чем INCLUDE отличается от обычного добавления в индекс
Сравним два индекса.
Вариант 1:
CREATE INDEX idx_orders_user_created_amount_status
ON orders (user_id, created_at DESC, amount, status);
Вариант 2:
CREATE INDEX idx_orders_user_created_inc
ON orders (user_id, created_at DESC)
INCLUDE (amount, status);
В первом варианте amount и status становятся частью ключа индекса. Они участвуют в сортировке и структуре дерева.
Во втором варианте amount и status не участвуют в поисковом порядке. Они просто добавлены в листовые страницы индекса, чтобы запрос мог прочитать их оттуда.
Если по amount и status мы не фильтруем и не сортируем, а только выбираем их в SELECT, обычно логичнее использовать INCLUDE.
То есть:
Колонки для WHERE и ORDER BY — в основную часть индекса.
Колонки только для SELECT — часто в INCLUDE.
Index-only scan: когда база читает только индекс
Если все данные, нужные запросу, уже есть в индексе, PostgreSQL иногда может выполнить index-only scan.
Это значит:
База берёт результат прямо из индекса
и не идёт в основную таблицу за строками.
Пример индекса:
CREATE INDEX idx_orders_user_created_inc
ON orders (user_id, created_at DESC)
INCLUDE (amount, status);
Пример запроса:
SELECT created_at, amount, status
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
Все нужные колонки есть в индексе:
user_id нужен для фильтра;
created_at нужен для сортировки и вывода;
amount нужен для вывода;
status нужен для вывода.
Теоретически PostgreSQL может ответить только по индексу.
Это особенно полезно для частых запросов, которые читают небольшую часть данных и не требуют всех колонок таблицы.
Почему index-only scan не всегда срабатывает
Важно: наличие всех колонок в индексе ещё не гарантирует index-only scan на 100%.
В PostgreSQL есть механизм видимости строк. База должна понимать, какие версии строк видны текущей транзакции.
Иногда для проверки видимости PostgreSQL всё равно должен сходить в таблицу.
Index-only scan чаще хорошо работает на таблицах, которые достаточно регулярно обслуживаются VACUUM, потому что PostgreSQL может использовать visibility map.
Для новичка достаточно запомнить:
INCLUDE создаёт возможность для index-only scan,
но итоговый план всё равно решает оптимизатор.
Проверять нужно через:
EXPLAIN (ANALYZE, BUFFERS)
SELECT created_at, amount, status
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
Если в плане вы увидите Index Only Scan, значит база действительно смогла прочитать данные только из индекса.
Составной индекс или два одиночных
Допустим, у нас есть два отдельных индекса:
CREATE INDEX idx_orders_user_id
ON orders (user_id);
CREATE INDEX idx_orders_created_at
ON orders (created_at);
И запрос:
SELECT id, amount, status
FROM orders
WHERE user_id = 42
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
PostgreSQL иногда может использовать оба индекса через bitmap scan: отдельно найти строки по user_id, отдельно по created_at, потом пересечь результаты.
Это может быть полезно.
Но у такого подхода есть минус: после bitmap scan порядок индекса обычно теряется. Если запросу нужен ORDER BY, базе может понадобиться отдельная сортировка.
Составной индекс:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
часто лучше для такого запроса, потому что он сразу хранит данные в нужной логике:
сначала user_id;
внутри пользователя — created_at DESC.
То есть один составной индекс может одновременно помочь:
- отфильтровать по пользователю;
- взять диапазон по дате;
- вернуть строки в нужном порядке;
- быстро остановиться на
LIMIT.
Когда лучше составной индекс
Составной индекс хорош, когда одни и те же колонки часто используются вместе.
Например, у вас много запросов такого вида:
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 20
Тогда индекс:
(user_id, created_at DESC)
выглядит очень логично.
Или много запросов:
WHERE user_id = ?
AND status = ?
ORDER BY created_at DESC
Тогда можно подумать про:
(user_id, status, created_at DESC)
Составной индекс особенно хорош, когда он повторяет структуру важного запроса:
сначала точные фильтры;
потом сортировка;
потом диапазон;
потом INCLUDE для колонок вывода.
Когда лучше отдельные индексы
Отдельные индексы могут быть лучше, если колонки часто используются независимо.
Например, есть разные запросы:
SELECT *
FROM orders
WHERE user_id = 42;
SELECT *
FROM orders
WHERE created_at >= '2026-01-01';
SELECT *
FROM orders
WHERE status = 'paid';
Если эти запросы действительно частые и независимые, один составной индекс не закроет их все хорошо.
Например, индекс:
(user_id, created_at)
не будет идеальным для запроса только по created_at.
А индекс:
(created_at, user_id)
не будет идеальным для запроса только по user_id.
В такой ситуации могут понадобиться отдельные индексы или несколько разных составных индексов под разные важные запросы.
Главное — не создавать индексы наугад.
Индекс (a, b) часто покрывает запросы по a
Если у вас есть составной индекс:
CREATE INDEX idx_example_ab
ON table_name (a, b);
то отдельный индекс по a часто оказывается лишним:
CREATE INDEX idx_example_a
ON table_name (a);
Почему?
Потому что (a, b) уже начинается с a.
Запрос:
WHERE a = 10
может использовать индекс (a, b).
Но есть нюанс: составной индекс шире. Он может занимать больше места и быть чуть тяжелее, чем простой индекс по a.
Поэтому нельзя автоматически сказать, что отдельный индекс по a всегда не нужен. Но очень часто он действительно дублирует составной индекс.
А вот отдельный индекс по b составной индекс (a, b) не заменяет так же хорошо.
Для запроса:
WHERE b = 20
индекс (a, b) уже не так удобен, потому что пропущена первая колонка.
Не делайте слишком широкие индексы
Иногда хочется сделать один индекс «на всё»:
CREATE INDEX idx_orders_everything
ON orders (user_id, status, created_at, amount, id);
А потом ещё добавить:
INCLUDE (country, currency, comment, delivery_address)
Это плохой путь.
Широкие индексы имеют цену:
- занимают больше места на диске;
- медленнее создаются;
- замедляют
INSERT;
- замедляют
UPDATE колонок, которые входят в индекс;
- увеличивают нагрузку на память и кэш;
- могут всё равно плохо подходить под реальные запросы.
Индекс — это не бесплатная магия.
Каждый индекс ускоряет одни запросы, но делает запись и обслуживание таблицы тяжелее.
Поэтому индекс нужно создавать под конкретный важный запрос, а не просто «на всякий случай».
Как проектировать составной индекс по запросу
Возьмём запрос:
SELECT id, amount, status
FROM orders
WHERE user_id = 42
AND status = 'paid'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
Разберём его.
Равенства:
user_id = 42
status = 'paid'
Диапазон и сортировка:
created_at >= '2026-01-01'
ORDER BY created_at DESC
Выводимые колонки:
id, amount, status
Хороший индекс-кандидат:
CREATE INDEX idx_orders_user_status_created_inc
ON orders (user_id, status, created_at DESC)
INCLUDE (amount);
Почему так?
user_id и status стоят в начале, потому что это равенства.
created_at DESC стоит дальше, потому что по нему нужен диапазон и порядок.
amount добавлен в INCLUDE, потому что он нужен в SELECT, но не нужен для поиска или сортировки.
А id в PostgreSQL может быть доступен из индекса не всегда как обычная покрывающая колонка, поэтому если вы хотите уверенно получить index-only scan для id, его тоже можно включить:
CREATE INDEX idx_orders_user_status_created_inc
ON orders (user_id, status, created_at DESC)
INCLUDE (id, amount);
Но опять же: чем больше INCLUDE, тем тяжелее индекс.
Проверяйте через EXPLAIN
Нельзя надёжно проектировать индексы только в голове.
После создания индекса нужно смотреть план запроса:
EXPLAIN
SELECT id, amount, status
FROM orders
WHERE user_id = 42
AND status = 'paid'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
Ещё лучше:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, amount, status
FROM orders
WHERE user_id = 42
AND status = 'paid'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
В плане стоит смотреть:
- использовался ли нужный индекс;
- был ли отдельный
Sort;
- сколько строк база прочитала;
- был ли
Index Scan или Index Only Scan;
- сколько было чтений буферов;
- насколько оценка строк совпала с реальностью.
Если вы создали индекс, а план всё равно делает Seq Scan, это не обязательно ошибка. Возможно, таблица маленькая, условие не селективное или оптимизатор считает, что читать всю таблицу дешевле.
Примеры хороших индексов под запросы
Последние заказы пользователя
Запрос:
SELECT id, amount, status
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
Индекс:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Оплаченные заказы пользователя, новые сверху
Запрос:
SELECT id, amount, created_at
FROM orders
WHERE user_id = 42
AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;
Индекс:
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
Заказы за период по всем пользователям
Запрос:
SELECT id, user_id, amount
FROM orders
WHERE created_at >= '2026-01-01'
AND created_at < '2026-02-01';
Индекс:
CREATE INDEX idx_orders_created_at
ON orders (created_at);
Здесь user_id не нужен первым, потому что запрос ищет по дате для всех пользователей.
Последние оплаченные заказы в системе
Запрос:
SELECT id, user_id, amount
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 100;
Индекс:
CREATE INDEX idx_orders_status_created
ON orders (status, created_at DESC);
Частая ошибка: один индекс под все запросы
Допустим, вы создали индекс:
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
Он хорош для:
WHERE user_id = 42
AND status = 'paid'
ORDER BY created_at DESC
Но он не идеален для:
WHERE status = 'paid'
ORDER BY created_at DESC
Почему?
Потому что первая колонка индекса — user_id, а в запросе её нет.
Если такой запрос важный и частый, ему может понадобиться другой индекс:
CREATE INDEX idx_orders_status_created
ON orders (status, created_at DESC);
Это нормально.
Индексы проектируют под реальные паттерны запросов, а не под абстрактное желание «ускорить таблицу».
MySQL: INCLUDE нет, но covering index есть
В MySQL нет синтаксиса PostgreSQL:
INCLUDE (...)
Но сама идея покрывающего индекса есть.
Если запросу нужны колонки:
user_id, created_at, amount, status
то в MySQL их обычно добавляют прямо в индекс:
CREATE INDEX idx_orders_user_created_amount_status
ON orders (user_id, created_at, amount, status);
Такой индекс может стать covering index для запроса, если все нужные колонки находятся внутри индекса.
Также в InnoDB есть важная особенность: каждый вторичный индекс содержит значение первичного ключа. Поэтому если запросу нужен id, а id — это primary key, он уже может быть доступен через вторичный индекс.
Но детали зависят от структуры таблицы и запроса, поэтому всё равно нужно смотреть EXPLAIN.
ClickHouse: другая логика
В ClickHouse нельзя напрямую переносить мышление из PostgreSQL.
Там ORDER BY в таблице задаёт физический порядок хранения данных в MergeTree.
Например:
CREATE TABLE orders
(
id UInt64,
user_id UInt64,
amount Decimal(12, 2),
status String,
created_at DateTime
)
ENGINE = MergeTree
ORDER BY (user_id, created_at);
Это не такой же B-tree индекс, как в PostgreSQL.
ClickHouse использует разреженный первичный индекс и хорошо читает большие диапазоны данных, если запрос совпадает с ключом сортировки.
Принцип похожий:
порядок колонок важен;
левые колонки особенно важны;
ключ нужно выбирать под реальные запросы.
Но механика другая.
В PostgreSQL мы говорим про B-tree индексы.
В ClickHouse — про порядок хранения данных и sparse index внутри MergeTree.
Короткая шпаргалка
Составной индекс:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Хорош для:
WHERE user_id = 42
WHERE user_id = 42
AND created_at >= '2026-01-01'
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20
Не идеален для:
WHERE created_at >= '2026-01-01'
Потому что пропущена первая колонка user_id.
Индекс с INCLUDE:
CREATE INDEX idx_orders_user_created_inc
ON orders (user_id, created_at DESC)
INCLUDE (amount, status);
Хорош для:
SELECT created_at, amount, status
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC;
Общее правило порядка колонок:
1. Колонки с равенством: =, IN.
2. Колонки для сортировки ORDER BY.
3. Колонки с диапазоном: >, >=, <, <=, BETWEEN.
4. Колонки только для SELECT — в INCLUDE.
Но это не железный закон. Всегда проверяйте реальные запросы через EXPLAIN.
Главное, что нужно запомнить
Составной индекс — это не просто «индекс на несколько колонок».
Это структура с определённым порядком.
И порядок колонок решает почти всё.
Индекс:
(user_id, created_at)
и индекс:
(created_at, user_id)
это разные индексы для разных запросов.
Первый хорош, когда мы сначала ищем пользователя, а потом его заказы по дате.
Второй хорош, когда мы сначала ищем период времени, а потом, возможно, пользователя внутри этого периода.
Главное правило для новичка:
Проектируйте индекс под конкретный запрос.
Не под таблицу.
Не под список всех колонок.
Не по принципу «добавлю всё, вдруг поможет».
А именно под запрос:
WHERE ...
ORDER BY ...
LIMIT ...
SELECT ...
Хороший составной индекс может:
- быстро найти нужные строки;
- вернуть их уже в нужном порядке;
- помочь с
LIMIT;
- убрать лишнюю сортировку;
- иногда позволить прочитать данные прямо из индекса через index-only scan.
Но каждый индекс имеет цену: место на диске, нагрузка на вставку, обновление и обслуживание.
Поэтому лучший индекс — не самый широкий, а тот, который точно соответствует важному и частому запросу.
Обычный индекс часто создают по одной колонке.
Например:
CREATE INDEX idx_orders_user_id ON orders (user_id);Такой индекс помогает быстро искать заказы конкретного пользователя:
SELECT * FROM orders WHERE user_id = 42;Но в реальных запросах мы редко фильтруем только по одному полю.
Чаще запрос выглядит так:
SELECT id, amount, status FROM orders WHERE user_id = 42 AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20;Здесь сразу несколько задач:
id,amountиstatus.Для такого запроса один индекс по
user_idможет помочь, но не идеально. База найдёт заказы пользователя, а потом ей всё равно придётся разбираться с датой, сортировкой и выборкой нужных колонок.Вот здесь появляется составной индекс.
Составной индекс — это индекс сразу по нескольким колонкам:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);Он может помочь базе пройти по данным почти в том же порядке, в котором они нужны запросу.
Если подобрать порядок колонок правильно, составной индекс может заменить сразу несколько лишних операций: фильтрацию, сортировку и иногда даже поход в саму таблицу.
Но если порядок колонок выбрать плохо, индекс может оказаться почти бесполезным.
Базовая таблица для примеров
Будем использовать таблицу заказов:
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL, amount numeric(12,2) NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() );Типичные запросы к такой таблице:
-- recent orders of a user SELECT id, amount, status FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;-- a user's orders over a period SELECT id, amount, status FROM orders WHERE user_id = 42 AND created_at >= '2026-01-01' AND created_at < '2026-02-01' ORDER BY created_at DESC;-- all new orders SELECT id, user_id, amount FROM orders WHERE created_at >= now() - interval '1 day';Все запросы вроде похожи, но индексы для них могут быть разными.
Что такое составной индекс
Составной индекс — это индекс, который строится по нескольким колонкам в заданном порядке.
Например:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);Здесь ключ индекса состоит из двух частей:
PostgreSQL хранит значения в индексе примерно так, как словарь хранит слова: сначала сортировка идёт по первой колонке, потом внутри одинаковых значений первой колонки — по второй.
То есть индекс:
(user_id, created_at DESC)можно представить так:
Сначала данные сгруппированы и отсортированы по
user_id.А внутри каждого
user_idони уже отсортированы поcreated_at DESC.Это очень удобно для запроса:
SELECT id, amount, status FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;База быстро находит блок заказов пользователя
42, а внутри этого блока заказы уже лежат от новых к старым.Правило левого префикса
Главное правило составного B-tree индекса — левый префикс.
Если индекс создан так:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);то он особенно хорошо работает для запросов, которые начинают использовать индекс слева направо.
Например, хорошо:
WHERE user_id = 42Потому что
user_id— первая колонка индекса.Хорошо:
WHERE user_id = 42 AND created_at >= '2026-01-01'Потому что мы используем первую колонку, а потом вторую.
Но хуже:
WHERE created_at >= '2026-01-01'Здесь мы пытаемся использовать только вторую колонку индекса, пропустив первую.
Почему это проблема?
Потому что индекс отсортирован сначала по
user_id, а уже потом поcreated_at.Если искать только по дате, база не может просто взять один аккуратный диапазон по
created_at. Даты разбросаны внутри каждого пользователя.Условно:
Дата есть внутри каждой группы
user_id, а не единым общим списком.Поэтому индекс
(user_id, created_at)не заменяет отдельный индекс поcreated_at, если у вас часто бывают запросы только по дате.Пример со словарём
Представьте бумажный справочник, отсортированный так:
Например:
Если вы знаете город, искать легко:
Если вы знаете город и фамилию, ещё легче:
Но если вы знаете только фамилию:
то справочник уже не так удобен. Вам придётся смотреть каждый город и внутри него искать Ивановых.
Составной индекс работает похожим образом.
Индекс:
хорош для:
WHERE city = 'Almaty'и для:
WHERE city = 'Almaty' AND last_name = 'Ivanov'Но плохо подходит для:
WHERE last_name = 'Ivanov'потому что первая колонка
cityпропущена.Какие запросы покрывает индекс (user_id, created_at DESC)
Создадим индекс:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);Теперь посмотрим, где он полезен.
Хорошо: фильтр по user_id
SELECT * FROM orders WHERE user_id = 42;Индекс помогает, потому что используется первая колонка.
Хорошо: user_id и дата
SELECT * FROM orders WHERE user_id = 42 AND created_at >= '2026-01-01';Индекс помогает ещё лучше: сначала сужаем поиск по
user_id, потом берём нужный диапазон поcreated_at.Отлично: user_id, сортировка и LIMIT
SELECT id, amount, status FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;Это почти идеальный запрос для такого индекса.
База находит пользователя
42, идёт по его заказам от новых к старым и останавливается после 20 строк.Ей не нужно сортировать все заказы пользователя отдельно.
Плохо: только created_at
SELECT * FROM orders WHERE created_at >= '2026-01-01';Такой запрос не совпадает с началом индекса. Для него лучше отдельный индекс:
CREATE INDEX idx_orders_created_at ON orders (created_at);или другой составной индекс, если дата идёт первой:
CREATE INDEX idx_orders_created_user ON orders (created_at, user_id);Но это уже другой сценарий.
Важная оговорка про оптимизатор
В современных версиях PostgreSQL оптимизатор иногда умеет использовать составной B-tree индекс даже без условия по первой колонке. Например, через skip scan.
Но для проектирования индексов новичку лучше не строить стратегию на исключениях.
Практическое правило остаётся таким:
Если вы часто ищете только по
created_at, не надейтесь, что индекс(user_id, created_at)всегда спасёт. Скорее всего, вам нужен отдельный индекс или другой порядок колонок.Равенство, диапазон и сортировка
Теперь самое важное: как выбирать порядок колонок в составном индексе.
Посмотрим на запрос:
SELECT id, amount, status FROM orders WHERE user_id = 42 AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20;Здесь:
user_id = 42это равенство.
А:
created_at >= '2026-01-01'это диапазон.
И ещё есть сортировка:
ORDER BY created_at DESCХороший индекс:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);Почему именно так?
Потому что сначала мы точно знаем пользователя:
Это резко сужает область поиска.
А внутри заказов этого пользователя нам нужны самые новые записи:
Индекс уже хранит их в нужном порядке.
Вместе с
LIMIT 20это особенно эффективно: база не обязана читать все заказы пользователя, сортировать их и потом брать первые 20. Она может идти по индексу в нужном порядке и остановиться, когда набрала 20 строк.Почему равенства обычно ставят в начало
Если в запросе есть несколько условий по равенству, такие колонки обычно хорошо ставить в начало индекса.
Например:
SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;Для такого запроса может подойти индекс:
CREATE INDEX idx_orders_user_status_created ON orders (user_id, status, created_at DESC);Здесь:
это точные условия.
Они помогают базе быстро сузить область поиска.
А потом:
даёт нужный порядок.
Такой индекс хорошо совпадает с запросом:
WHERE user_id = 42 AND status = 'paid' ORDER BY created_at DESCДиапазон лучше ставить после равенств
Диапазонные условия:
> >= < <= BETWEENобычно ставят после колонок с равенством.
Например:
WHERE user_id = 42 AND created_at >= '2026-01-01'Хороший индекс:
Сначала база находит пользователя, потом внутри его заказов берёт диапазон по дате.
Но если сделать наоборот:
то для этого запроса индекс может быть менее удобным.
Почему?
Потому что база сначала будет искать все заказы после
2026-01-01, а уже внутри них проверятьuser_id = 42.Если после этой даты заказов очень много, это хуже.
Диапазон «ломает» дальнейший порядок
Есть важный нюанс.
Допустим, у нас индекс:
CREATE INDEX idx_orders_user_created_status ON orders (user_id, created_at, status);И запрос:
SELECT * FROM orders WHERE user_id = 42 AND created_at >= '2026-01-01' AND status = 'paid';База хорошо использует:
Но после диапазона по
created_atколонкаstatusуже не так полезна для сужения сканируемого участка индекса, как если бы она стояла до диапазона.Почему?
Потому что внутри диапазона дат строки уже идут по датам, а
statusраспределён внутри этого диапазона.Если статус важен и всегда указан равенством, часто лучше индекс:
CREATE INDEX idx_orders_user_status_created ON orders (user_id, status, created_at);Для запроса:
WHERE user_id = 42 AND status = 'paid' AND created_at >= '2026-01-01'это обычно логичнее:
Практическое правило:
Сортировка через индекс
Индекс может помогать не только искать строки, но и возвращать их уже в нужном порядке.
Например:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);Запрос:
SELECT id, amount, status FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;может идти по индексу и сразу получать строки в нужном порядке.
Без такого индекса базе пришлось бы:
С индексом порядок уже готов.
Особенно большой выигрыш появляется вместе с
LIMIT.Если пользователю нужны только последние 20 заказов, база может не читать тысячу старых заказов. Она возьмёт первые 20 из нужного участка индекса и остановится.
Индекс можно читать в обратную сторону
B-tree индекс в PostgreSQL можно читать как вперёд, так и назад.
Например, если есть индекс:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);он хорошо подходит для:
WHERE user_id = 42 ORDER BY created_at DESCНо PostgreSQL может прочитать его и в обратную сторону для:
WHERE user_id = 42 ORDER BY created_at ASCДля одной колонки сортировки это обычно не проблема.
Поэтому отдельный индекс только ради обратного направления часто не нужен.
Например, не стоит автоматически создавать оба индекса:
CREATE INDEX idx_orders_user_created_desc ON orders (user_id, created_at DESC); CREATE INDEX idx_orders_user_created_asc ON orders (user_id, created_at ASC);Чаще всего достаточно одного.
Смешанные направления сортировки
Сложнее становится, когда сортировка идёт по нескольким колонкам с разными направлениями.
Например:
ORDER BY status ASC, created_at DESCДля такого запроса может понадобиться индекс именно с таким порядком:
CREATE INDEX idx_orders_status_created ON orders (status ASC, created_at DESC);Почему?
Потому что обратное чтение индекса полностью меняет направление всех колонок сразу.
Индекс:
(status ASC, created_at DESC)при обратном чтении даст:
status DESC, created_at ASCНо он не превратится в:
status ASC, created_at ASCили:
status DESC, created_at DESCТо есть направления в многоколоночной сортировке нужно подбирать осознанно.
Простой пример:
CREATE INDEX idx_emp_dept_salary ON employees (dept ASC, salary DESC);Он хорошо подходит для:
SELECT name, salary FROM employees ORDER BY dept ASC, salary DESC;И при обратном чтении — для:
ORDER BY dept DESC, salary ASC;Но не обязательно подойдёт без сортировки для:
ORDER BY dept ASC, salary ASC;Потому что это уже другой смешанный порядок.
INCLUDE: добавить колонки в индекс для чтения
Теперь перейдём к
INCLUDE.Допустим, у нас есть запрос:
SELECT created_at, amount, status FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;И есть индекс:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);Индекс помогает найти нужные строки и отдать их в правильном порядке.
Но в
SELECTнам нужны ещё:Этих колонок в индексе нет.
Значит, PostgreSQL может сначала найти подходящие записи в индексе, а потом сходить в основную таблицу за
amountиstatus.Это всё равно может быть быстро, но иногда хочется, чтобы база вообще не ходила в таблицу.
Для этого можно добавить колонки через
INCLUDE:CREATE INDEX idx_orders_user_created_inc ON orders (user_id, created_at DESC) INCLUDE (amount, status);Теперь индекс содержит:
Ключевые колонки участвуют в поиске и сортировке.
Колонки из
INCLUDEпросто лежат в индексе как дополнительные данные для чтения.Чем INCLUDE отличается от обычного добавления в индекс
Сравним два индекса.
Вариант 1:
CREATE INDEX idx_orders_user_created_amount_status ON orders (user_id, created_at DESC, amount, status);Вариант 2:
CREATE INDEX idx_orders_user_created_inc ON orders (user_id, created_at DESC) INCLUDE (amount, status);В первом варианте
amountиstatusстановятся частью ключа индекса. Они участвуют в сортировке и структуре дерева.Во втором варианте
amountиstatusне участвуют в поисковом порядке. Они просто добавлены в листовые страницы индекса, чтобы запрос мог прочитать их оттуда.Если по
amountиstatusмы не фильтруем и не сортируем, а только выбираем их вSELECT, обычно логичнее использоватьINCLUDE.То есть:
Index-only scan: когда база читает только индекс
Если все данные, нужные запросу, уже есть в индексе, PostgreSQL иногда может выполнить index-only scan.
Это значит:
Пример индекса:
CREATE INDEX idx_orders_user_created_inc ON orders (user_id, created_at DESC) INCLUDE (amount, status);Пример запроса:
SELECT created_at, amount, status FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;Все нужные колонки есть в индексе:
user_idнужен для фильтра;created_atнужен для сортировки и вывода;amountнужен для вывода;statusнужен для вывода.Теоретически PostgreSQL может ответить только по индексу.
Это особенно полезно для частых запросов, которые читают небольшую часть данных и не требуют всех колонок таблицы.
Почему index-only scan не всегда срабатывает
Важно: наличие всех колонок в индексе ещё не гарантирует index-only scan на 100%.
В PostgreSQL есть механизм видимости строк. База должна понимать, какие версии строк видны текущей транзакции.
Иногда для проверки видимости PostgreSQL всё равно должен сходить в таблицу.
Index-only scan чаще хорошо работает на таблицах, которые достаточно регулярно обслуживаются
VACUUM, потому что PostgreSQL может использовать visibility map.Для новичка достаточно запомнить:
Проверять нужно через:
EXPLAIN (ANALYZE, BUFFERS) SELECT created_at, amount, status FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;Если в плане вы увидите
Index Only Scan, значит база действительно смогла прочитать данные только из индекса.Составной индекс или два одиночных
Допустим, у нас есть два отдельных индекса:
CREATE INDEX idx_orders_user_id ON orders (user_id); CREATE INDEX idx_orders_created_at ON orders (created_at);И запрос:
SELECT id, amount, status FROM orders WHERE user_id = 42 AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20;PostgreSQL иногда может использовать оба индекса через bitmap scan: отдельно найти строки по
user_id, отдельно поcreated_at, потом пересечь результаты.Это может быть полезно.
Но у такого подхода есть минус: после bitmap scan порядок индекса обычно теряется. Если запросу нужен
ORDER BY, базе может понадобиться отдельная сортировка.Составной индекс:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);часто лучше для такого запроса, потому что он сразу хранит данные в нужной логике:
То есть один составной индекс может одновременно помочь:
LIMIT.Когда лучше составной индекс
Составной индекс хорош, когда одни и те же колонки часто используются вместе.
Например, у вас много запросов такого вида:
WHERE user_id = ? ORDER BY created_at DESC LIMIT 20Тогда индекс:
(user_id, created_at DESC)выглядит очень логично.
Или много запросов:
WHERE user_id = ? AND status = ? ORDER BY created_at DESCТогда можно подумать про:
(user_id, status, created_at DESC)Составной индекс особенно хорош, когда он повторяет структуру важного запроса:
Когда лучше отдельные индексы
Отдельные индексы могут быть лучше, если колонки часто используются независимо.
Например, есть разные запросы:
SELECT * FROM orders WHERE user_id = 42;SELECT * FROM orders WHERE created_at >= '2026-01-01';SELECT * FROM orders WHERE status = 'paid';Если эти запросы действительно частые и независимые, один составной индекс не закроет их все хорошо.
Например, индекс:
не будет идеальным для запроса только по
created_at.А индекс:
не будет идеальным для запроса только по
user_id.В такой ситуации могут понадобиться отдельные индексы или несколько разных составных индексов под разные важные запросы.
Главное — не создавать индексы наугад.
Индекс (a, b) часто покрывает запросы по a
Если у вас есть составной индекс:
CREATE INDEX idx_example_ab ON table_name (a, b);то отдельный индекс по
aчасто оказывается лишним:CREATE INDEX idx_example_a ON table_name (a);Почему?
Потому что
(a, b)уже начинается сa.Запрос:
WHERE a = 10может использовать индекс
(a, b).Но есть нюанс: составной индекс шире. Он может занимать больше места и быть чуть тяжелее, чем простой индекс по
a.Поэтому нельзя автоматически сказать, что отдельный индекс по
aвсегда не нужен. Но очень часто он действительно дублирует составной индекс.А вот отдельный индекс по
bсоставной индекс(a, b)не заменяет так же хорошо.Для запроса:
WHERE b = 20индекс
(a, b)уже не так удобен, потому что пропущена первая колонка.Не делайте слишком широкие индексы
Иногда хочется сделать один индекс «на всё»:
CREATE INDEX idx_orders_everything ON orders (user_id, status, created_at, amount, id);А потом ещё добавить:
Это плохой путь.
Широкие индексы имеют цену:
INSERT;UPDATEколонок, которые входят в индекс;Индекс — это не бесплатная магия.
Каждый индекс ускоряет одни запросы, но делает запись и обслуживание таблицы тяжелее.
Поэтому индекс нужно создавать под конкретный важный запрос, а не просто «на всякий случай».
Как проектировать составной индекс по запросу
Возьмём запрос:
SELECT id, amount, status FROM orders WHERE user_id = 42 AND status = 'paid' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20;Разберём его.
Равенства:
user_id = 42 status = 'paid'Диапазон и сортировка:
created_at >= '2026-01-01' ORDER BY created_at DESCВыводимые колонки:
Хороший индекс-кандидат:
CREATE INDEX idx_orders_user_status_created_inc ON orders (user_id, status, created_at DESC) INCLUDE (amount);Почему так?
user_idиstatusстоят в начале, потому что это равенства.created_at DESCстоит дальше, потому что по нему нужен диапазон и порядок.amountдобавлен вINCLUDE, потому что он нужен вSELECT, но не нужен для поиска или сортировки.А
idв PostgreSQL может быть доступен из индекса не всегда как обычная покрывающая колонка, поэтому если вы хотите уверенно получить index-only scan дляid, его тоже можно включить:CREATE INDEX idx_orders_user_status_created_inc ON orders (user_id, status, created_at DESC) INCLUDE (id, amount);Но опять же: чем больше
INCLUDE, тем тяжелее индекс.Проверяйте через EXPLAIN
Нельзя надёжно проектировать индексы только в голове.
После создания индекса нужно смотреть план запроса:
EXPLAIN SELECT id, amount, status FROM orders WHERE user_id = 42 AND status = 'paid' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20;Ещё лучше:
EXPLAIN (ANALYZE, BUFFERS) SELECT id, amount, status FROM orders WHERE user_id = 42 AND status = 'paid' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20;В плане стоит смотреть:
Sort;Index ScanилиIndex Only Scan;Если вы создали индекс, а план всё равно делает
Seq Scan, это не обязательно ошибка. Возможно, таблица маленькая, условие не селективное или оптимизатор считает, что читать всю таблицу дешевле.Примеры хороших индексов под запросы
Последние заказы пользователя
Запрос:
SELECT id, amount, status FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;Индекс:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);Оплаченные заказы пользователя, новые сверху
Запрос:
SELECT id, amount, created_at FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;Индекс:
CREATE INDEX idx_orders_user_status_created ON orders (user_id, status, created_at DESC);Заказы за период по всем пользователям
Запрос:
SELECT id, user_id, amount FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';Индекс:
CREATE INDEX idx_orders_created_at ON orders (created_at);Здесь
user_idне нужен первым, потому что запрос ищет по дате для всех пользователей.Последние оплаченные заказы в системе
Запрос:
SELECT id, user_id, amount FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 100;Индекс:
CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC);Частая ошибка: один индекс под все запросы
Допустим, вы создали индекс:
CREATE INDEX idx_orders_user_status_created ON orders (user_id, status, created_at DESC);Он хорош для:
WHERE user_id = 42 AND status = 'paid' ORDER BY created_at DESCНо он не идеален для:
WHERE status = 'paid' ORDER BY created_at DESCПочему?
Потому что первая колонка индекса —
user_id, а в запросе её нет.Если такой запрос важный и частый, ему может понадобиться другой индекс:
CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC);Это нормально.
Индексы проектируют под реальные паттерны запросов, а не под абстрактное желание «ускорить таблицу».
MySQL: INCLUDE нет, но covering index есть
В MySQL нет синтаксиса PostgreSQL:
Но сама идея покрывающего индекса есть.
Если запросу нужны колонки:
то в MySQL их обычно добавляют прямо в индекс:
CREATE INDEX idx_orders_user_created_amount_status ON orders (user_id, created_at, amount, status);Такой индекс может стать covering index для запроса, если все нужные колонки находятся внутри индекса.
Также в InnoDB есть важная особенность: каждый вторичный индекс содержит значение первичного ключа. Поэтому если запросу нужен
id, аid— это primary key, он уже может быть доступен через вторичный индекс.Но детали зависят от структуры таблицы и запроса, поэтому всё равно нужно смотреть
EXPLAIN.ClickHouse: другая логика
В ClickHouse нельзя напрямую переносить мышление из PostgreSQL.
Там
ORDER BYв таблице задаёт физический порядок хранения данных в MergeTree.Например:
CREATE TABLE orders ( id UInt64, user_id UInt64, amount Decimal(12, 2), status String, created_at DateTime ) ENGINE = MergeTree ORDER BY (user_id, created_at);Это не такой же B-tree индекс, как в PostgreSQL.
ClickHouse использует разреженный первичный индекс и хорошо читает большие диапазоны данных, если запрос совпадает с ключом сортировки.
Принцип похожий:
Но механика другая.
В PostgreSQL мы говорим про B-tree индексы.
В ClickHouse — про порядок хранения данных и sparse index внутри MergeTree.
Короткая шпаргалка
Составной индекс:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);Хорош для:
WHERE user_id = 42WHERE user_id = 42 AND created_at >= '2026-01-01'WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20Не идеален для:
WHERE created_at >= '2026-01-01'Потому что пропущена первая колонка
user_id.Индекс с
INCLUDE:CREATE INDEX idx_orders_user_created_inc ON orders (user_id, created_at DESC) INCLUDE (amount, status);Хорош для:
SELECT created_at, amount, status FROM orders WHERE user_id = 42 ORDER BY created_at DESC;Общее правило порядка колонок:
Но это не железный закон. Всегда проверяйте реальные запросы через
EXPLAIN.Главное, что нужно запомнить
Составной индекс — это не просто «индекс на несколько колонок».
Это структура с определённым порядком.
И порядок колонок решает почти всё.
Индекс:
и индекс:
это разные индексы для разных запросов.
Первый хорош, когда мы сначала ищем пользователя, а потом его заказы по дате.
Второй хорош, когда мы сначала ищем период времени, а потом, возможно, пользователя внутри этого периода.
Главное правило для новичка:
Не под таблицу.
Не под список всех колонок.
Не по принципу «добавлю всё, вдруг поможет».
А именно под запрос:
WHERE ... ORDER BY ... LIMIT ... SELECT ...Хороший составной индекс может:
LIMIT;Но каждый индекс имеет цену: место на диске, нагрузка на вставку, обновление и обслуживание.
Поэтому лучший индекс — не самый широкий, а тот, который точно соответствует важному и частому запросу.