sqlpostgresqlindexperformance

Composite Indexes in SQL: Leftmost-Prefix Rule and Column Order

How a composite index works under the leftmost-prefix rule, how to order columns for filter, sort and range, and when it beats two single-column indexes.

13 min läsningReferencesql · postgresql · index · performance · mysql
Den här artikeln finns för närvarande på ryska — en engelsk översättning är på gång.

Обычный индекс часто создают по одной колонке.

Например:

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()
);

Типичные запросы к такой таблице:

-- 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);

Здесь ключ индекса состоит из двух частей:

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.

Но каждый индекс имеет цену: место на диске, нагрузка на вставку, обновление и обслуживание.

Поэтому лучший индекс — не самый широкий, а тот, который точно соответствует важному и частому запросу.

Öva på riktiga uppgifter

Lös uppgifter i SQL-tränaren med omedelbar rättning och ledtrådar.

Öppna tränaren