Индекс — структура данных, которая ускоряет поиск строк в таблице. Без индексов база сканирует всю таблицу (Sequential Scan), что катастрофично при больших объёмах данных.
Зачем нужны индексы
-- Table: 10 million rows, no index
SELECT * FROM orders WHERE user_id = 42;
-- PostgreSQL scans ALL 10 million rows → 3-5 seconds
-- After: CREATE INDEX idx_orders_user_id ON orders(user_id);
SELECT * FROM orders WHERE user_id = 42;
-- B-tree lookup → 1-3 milliseconds
Индекс — компромисс: ускоряет чтение, замедляет запись (INSERT/UPDATE/DELETE должны обновлять индекс).
B-tree индекс
Самый распространённый тип. По умолчанию при CREATE INDEX.
Структура
B-tree (Balanced Tree) — сбалансированное дерево, где:
- Корень — верхний узел
- Внутренние узлы — диапазоны ключей, ведущие к листьям
- Листья — значения ключей + указатели на строки в таблице (heap)
[50]
/ \
[20, 35] [70, 85]
/ | \ | \
[1-19][21-34][36-49][51-69][71-100]
Поддерживаемые операции
-- B-tree supports: =, <, >, <=, >=, BETWEEN, IN, LIKE 'prefix%'
SELECT * FROM users WHERE id = 42; -- exact match
SELECT * FROM orders WHERE total BETWEEN 100 AND 500; -- range
SELECT * FROM products WHERE name LIKE 'iPhone%'; -- prefix
SELECT * FROM users ORDER BY created_at; -- sort (index scan)
-- NOT supported efficiently:
SELECT * FROM products WHERE name LIKE '%phone%'; -- leading wildcard
SELECT * FROM logs WHERE date_part('year', created_at) = 2024; -- function
Создание
-- Simple index
CREATE INDEX idx_users_email ON users(email);
-- Unique index (also enforces uniqueness constraint)
CREATE UNIQUE INDEX idx_users_email_uniq ON users(email);
-- Index with specific order
CREATE INDEX idx_orders_created_desc ON orders(created_at DESC);
-- Concurrent creation (doesn't lock table for reads/writes)
CREATE INDEX CONCURRENTLY idx_products_category ON products(category_id);
Hash индекс
Оптимизирован исключительно для операции =. Хранит хэши значений.
CREATE INDEX idx_sessions_token ON sessions USING HASH (token);
-- Efficient:
SELECT * FROM sessions WHERE token = 'abc123xyz'; -- O(1)
-- NOT efficient (falls back to seq scan):
SELECT * FROM sessions WHERE token > 'abc'; -- no range support
SELECT * FROM sessions ORDER BY token; -- no sort support
Когда использовать Hash
- Поле используется только для поиска по точному совпадению
- Большие текстовые ключи (хэш фиксированного размера vs полный текст в B-tree)
- UUID-поля для lookup
На практике: Hash индексы используются редко. B-tree немного медленнее для =, но поддерживает всё остальное.
GIN (Generalized Inverted Index)
Используется для полнотекстового поиска и JSONB.
-- Full-text search
CREATE INDEX idx_articles_search ON articles USING GIN (to_tsvector('russian', body));
SELECT * FROM articles
WHERE to_tsvector('russian', body) @@ to_tsquery('russian', 'индекс & транзакция');
-- JSONB search
CREATE INDEX idx_events_data ON events USING GIN (data);
SELECT * FROM events WHERE data @> '{"type": "click"}';
SELECT * FROM events WHERE data ? 'error_code'; -- key exists
Когда использовать GIN
- Полнотекстовый поиск (
tsvector @@ tsquery) - Поиск в JSONB (
@>,?,?|,?&) - Массивы (
@>,<@,&&)
GiST (Generalized Search Tree)
Для геометрических данных и диапазонов.
-- Geographic data (PostGIS)
CREATE INDEX idx_locations_point ON locations USING GIST (coordinates);
SELECT * FROM restaurants WHERE coordinates <-> point(55.75, 37.61) < 1000;
-- Range types
CREATE INDEX idx_events_duration ON events USING GIST (duration);
SELECT * FROM events WHERE duration && '[2024-01-01, 2024-12-31]'::daterange;
Составные индексы (Composite / Multi-column)
Индекс по нескольким столбцам.
Порядок столбцов критичен
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- Uses index:
SELECT * FROM orders WHERE user_id = 42; -- ✓ leading column
SELECT * FROM orders WHERE user_id = 42 AND status = 'new'; -- ✓ full index
SELECT * FROM orders WHERE user_id = 42 ORDER BY status; -- ✓ index sort
-- Does NOT use index:
SELECT * FROM orders WHERE status = 'new'; -- ✗ not leading column
Правило: запрос может использовать составной индекс только если включает левый префикс столбцов индекса.
Когда составной индекс лучше двух раздельных
-- Query: WHERE user_id = 42 AND status = 'new'
-- Option 1: two separate indexes
CREATE INDEX ON orders(user_id);
CREATE INDEX ON orders(status);
-- PostgreSQL picks ONE index and filters, or uses bitmap and merge
-- Option 2: composite index
CREATE INDEX ON orders(user_id, status);
-- Single index lookup, more efficient
Покрывающий индекс (Covering Index)
Индекс содержит все столбцы, нужные запросу — чтение данных не требует обращения к таблице.
-- Query needs: user_id (filter), total (return), created_at (sort)
CREATE INDEX idx_orders_covering ON orders(user_id) INCLUDE (total, created_at);
SELECT total, created_at FROM orders WHERE user_id = 42 ORDER BY created_at;
-- Index Only Scan — data fetched entirely from index, no heap access
Как проверить
EXPLAIN SELECT total, created_at FROM orders WHERE user_id = 42;
-- Look for "Index Only Scan" vs "Index Scan"
-- Index Only Scan = covering index (faster, no heap access)
Частичный индекс (Partial Index)
Индексирует только часть строк таблицы.
-- Only index active orders (99% of queries)
CREATE INDEX idx_orders_active ON orders(user_id)
WHERE status = 'active';
-- Only index non-null values
CREATE INDEX idx_users_phone ON users(phone)
WHERE phone IS NOT NULL;
-- Only index large orders for analytics
CREATE INDEX idx_large_orders ON orders(total, created_at)
WHERE total > 10000;
Преимущества:
- Меньший размер индекса
- Быстрее обновляется (меньше строк)
- Планировщик использует его только для подходящих запросов
Индексы на выражениях (Expression Indexes)
-- Case-insensitive email lookup
CREATE INDEX idx_users_email_lower ON users(lower(email));
SELECT * FROM users WHERE lower(email) = lower('[email protected]');
-- Index on date part
CREATE INDEX idx_orders_year ON orders(date_part('year', created_at));
SELECT * FROM orders WHERE date_part('year', created_at) = 2024;
-- Index on computed value
CREATE INDEX idx_products_full_name ON products((brand || ' ' || model));
Когда индексы НЕ помогают
-- 1. Low cardinality (few unique values) — table scan may be faster
-- Column "gender" with values: 'M', 'F' — 50% of table = seq scan better
CREATE INDEX idx_users_gender ON users(gender); -- often useless
-- 2. Small tables (< 1000 rows) — seq scan is almost always faster
-- PostgreSQL planner will ignore the index
-- 3. Leading wildcard in LIKE
SELECT * FROM users WHERE name LIKE '%Иван%'; -- can't use B-tree
-- 4. Function on indexed column without expression index
SELECT * FROM users WHERE UPPER(name) = 'ИВАН'; -- no index on UPPER(name)
-- 5. Type mismatch
-- Index on INTEGER column id, but:
SELECT * FROM users WHERE id = '42'; -- '42' is text, implicit cast breaks index
Управление индексами
-- List all indexes for a table
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'orders';
-- Index size
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass))
FROM pg_indexes
WHERE tablename = 'orders';
-- Index usage statistics
SELECT schemaname, tablename, indexname,
idx_scan, -- number of index scans
idx_tup_read, -- tuples read via index
idx_tup_fetch -- live tuples fetched
FROM pg_stat_user_indexes
WHERE tablename = 'orders'
ORDER BY idx_scan DESC;
-- Find unused indexes (candidates for removal)
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND NOT indisprimary -- not primary key
ORDER BY pg_relation_size(indexname::regclass) DESC;
-- Rebuild index (fix bloat)
REINDEX INDEX CONCURRENTLY idx_orders_user_id;
Типичные вопросы на интервью
Q: «Какой индекс создать для LIKE '%search%'?»
Ответ: «B-tree не поддерживает leading wildcard. Варианты: GIN-индекс на tsvector для полнотекстового поиска (to_tsvector, tsquery), или расширение pg_trgm с GIN/GiST-индексом по триграммам для произвольных LIKE-паттернов.»
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);
SELECT * FROM products WHERE name LIKE '%phone%'; -- now uses GIN index
Q: «Почему NOT IN плохо работает с индексами?»
Ответ: «NOT IN требует проверить, что значение НЕ входит в множество. При наличии NULL в списке или в столбце результат всегда пустой из-за трёхзначной логики SQL. Планировщик часто выбирает seq scan. Предпочтительнее NOT EXISTS или LEFT JOIN.»