SQL и индексы
Сервис на Go редко бывает сложным сам по себе — горутина приняла запрос, сходила в PostgreSQL по SQL, отдала ответ. Вся настоящая борьба за производительность сосредоточена в том, как написан запрос и какие индексы под ним стоят. database/sql и pgx дают прямой доступ к SQL, но не защищают ни от одной концептуальной ошибки: лишний индекс, неверный порядок колонок в составном индексе, COUNT(*) вместо COUNT(col), отстающий autovacuum — всё это компилируется, проходит локальные тесты и проявляется только на реальном объёме данных под нагрузкой.
Ловушки тут не про синтаксис SELECT. Кандидаты считают индекс бесплатным ускорителем чтения и забывают про его цену на каждом INSERT. Ждут, что составной индекс (a, b, c) ускорит фильтр по b в одиночку. Берут B-tree на jsonb и удивляются, почему он бесполезен. Путают RANK с DENSE_RANK, называют партиционирование шардированием, считают, что обычный VACUUM возвращает место операционной системе. Эта тема разбирает SQL и индексы по слоям — от устройства B-tree до уборки мёртвых версий строк — так, чтобы каждый из этих вопросов вы закрывали механизмом, а не заученной фразой.
Карта темы
- Основы индексов — что такое индекс под капотом, почему по умолчанию
B-tree, и какую цену он берёт на каждой записи. - Типы индексов PostgreSQL —
B-tree,Hash,GIN,GiST,SP-GiST,BRINи класс запросов, под который заточен каждый. - Составной индекс — одно
B-treeпо(a, b, c), правило левого префикса и почему порядок колонок — проектное решение. - Агрегация в SQL —
GROUP BYсCOUNT/SUM, разницаWHEREиHAVINGи ловушкаCOUNT(*)противCOUNT(col)приLEFT JOIN. - Оконные функции —
OVER (PARTITION BY ... ORDER BY ...), ранжирование без схлопывания строк иRANKпротивDENSE_RANK. - Self-join — соединение таблицы с собой через два псевдонима для иерархий и парного сравнения строк одной таблицы.
- Anti-join — поиск строк без пары через
LEFT JOIN ... IS NULLилиNOT EXISTSи ловушкаNOT INсNULL. - Партиционирование таблиц — разбиение одной таблицы на дочерние по ключу с отсечением партиций; почему это не шардирование.
- VACUUM в PostgreSQL — уборка мёртвых версий строк MVCC, заморозка
XIDи раздувание при долгой транзакции. - Проблема N+1 — почему запрос за списком плюс запрос на каждую строку дают 1 + N обращений к базе, и как свести их к одному через
JOINили батч.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Считать индекс бесплатным ускорителем чтения | Упустить цену записи — каждый INSERT/UPDATE/DELETE дополнительно правит каждый индекс |
Считать, что любой индекс — это B-tree | Промахнуться с типом — jsonb и массивам нужен GIN, не B-tree |
Использовать Hash для запроса диапазона | Hash обслуживает только равенство; диапазон уйдёт в seq scan |
Ждать, что индекс (a, b, c) ускорит фильтр по b в одиночку | Индекс обслуживает только ведущий префикс — остальное уходит в seq scan |
| Ставить range-столбец раньше equality-столбца в составном индексе | После диапазона правые столбцы по дереву не отсекаются — индекс работает вполовину |
Считать COUNT(*) и COUNT(col) взаимозаменяемыми | COUNT(col) пропускает NULL — при LEFT JOIN даёт ложную 1 вместо 0 для пустых групп |
Фильтровать по агрегату в WHERE | Агрегата там ещё нет — фильтр по SUM/COUNT идёт только в HAVING |
Путать RANK и DENSE_RANK | После ничьей RANK оставляет пропуск (1,1,3), DENSE_RANK — нет (1,1,2) |
Фильтровать по оконной функции в WHERE того же SELECT | Окна считаются после WHERE — нужен подзапрос |
Писать anti-join через NOT IN с подзапросом | Один NULL в подзапросе обнуляет весь результат — используйте NOT EXISTS |
| Называть партиционирование шардированием | Партиции остаются на одном сервере; шардирование раскидывает данные по узлам |
Считать, что обычный VACUUM возвращает место на диск ОС | Он лишь освобождает место для повторного использования; ОС отдаёт только VACUUM FULL |
| Грузить связанные строки в цикле (N+1) | 1 + N обращений к базе вместо одного JOIN или батча через IN (...) |
Значение для собеседований
SQL и индексы — обязательная тема на любом backend-интервью, и спрашивают не «знаешь ли ты слово индекс», а умеешь ли ты рассуждать о цене записи и о том, какой запрос индекс ускорит, а какой — нет.
Что обычно проверяют:
- Что такое индекс, почему
B-treeпо умолчанию и какую цену он берёт на записи. - Какие типы индексов есть в PostgreSQL и под какой класс запросов заточен каждый.
- Как работает составной индекс — правило левого префикса и почему порядок колонок решает.
- Разницу
WHEREиHAVINGи ловушкуCOUNT(*)противCOUNT(col)послеLEFT JOIN. - Чем оконная функция отличается от агрегата и в чём разница
RANK/DENSE_RANK. - Как выражают self-join и anti-join и почему
NOT INсNULLопасен. - Чем партиционирование отличается от шардирования и когда оно оправдано.
- Что чистит
VACUUM, почему он не отдаёт место ОС и как долгая транзакция держит горизонт уборки.
Типичный неверный ответ: «индекс — это всегда хорошо, чем их больше, тем быстрее». Это запускает разбор того, что каждый индекс — это налог на каждую запись, что лишний неиспользуемый индекс только замедляет вставки, и что тип индекса нужно подбирать под класс запроса (GIN для jsonb, BRIN для огромных упорядоченных таблиц), а не ставить B-tree на всё подряд.