SQL и индексы
Устройство и типы индексов PostgreSQL, составные индексы, агрегация в SQL, оконные функции, self- и anti-join, партиционирование таблиц и VACUUM.
22 вопросов
JuniorТеорияОчень частоЧто такое индекс базы данных и какая структура данных лежит в основе индекса по умолчанию?
Что такое индекс базы данных и какая структура данных лежит в основе индекса по умолчанию?
Индекс — это вспомогательная структура, отображающая значения столбца на расположение строк, чтобы движок находил строки без полного сканирования таблицы. Индекс по умолчанию в PostgreSQL — это B-tree, хранящий ключи отсортированными и поддерживающий поиск по равенству и диапазону.
Типичные ошибки
- ✗Считать, что индекс хранит полную копию таблицы, а не лишь указатели от ключа к расположению
- ✗Думать, что индексы бесплатны — они добавляют накладные расходы записи и место на диске при каждой вставке и обновлении
- ✗Полагать, что любой индекс — это B-tree, игнорируя, что для не-диапазонных нагрузок есть другие типы
Уточняющие вопросы
- →Почему добавление индекса замедляет вставки и обновления в этой таблице?
- →Как B-tree позволяет запросу диапазона вроде
WHERE age > 30использовать индекс?
MiddleТеорияОчень частоЧто такое проблема N+1 запросов и как её исправить?
Что такое проблема N+1 запросов и как её исправить?
N+1 — это когда один запрос достаёт список из N строк, а затем код выполняет ещё по запросу на каждую строку, чтобы подгрузить связанные данные — 1 + N обращений, и каждое платит за сеть и планирование. Исправляется выборкой всего одним запросом: JOIN либо один батч WHERE id IN (...) / = ANY($1) по идентификаторам родителей.
Типичные ошибки
- ✗Путать N+1 с отсутствием индекса, а не с лишними обращениями к базе
- ✗Грузить связанные строки в цикле по элементам вместо одного батч-запроса
- ✗Считать, что
JOINне может заменить построчные доборы из другой таблицы
Уточняющие вопросы
- →Как заметить паттерн N+1 в логах запросов или трейсах сервиса?
- →Когда батч
WHERE id IN (...)предпочтительнееJOINдля загрузки дочерних строк?
MiddleКодЧастоНайти клиентов, ни разу не сделавших заказ
Найти клиентов, ни разу не сделавших заказ
LEFT JOIN Orders к Customers по id клиента, затем фильтр WHERE o.customer_id IS NULL — анти-join оставляет клиентов без совпавшего заказа. NOT EXISTS эквивалентен; NOT IN вернёт пусто, если в подзапросе есть NULL, поэтому предпочтительнее LEFT JOIN ... IS NULL или NOT EXISTS.
Типичные ошибки
- ✗Использовать
INNER JOINсIS NULL, который никогда не совпадёт - ✗Доверять
NOT IN, когда подзапрос может содержатьNULL - ✗Забывать, что проверка
IS NULLдолжна быть по присоединённому (правому) столбцу
Уточняющие вопросы
- →Почему
NOT INне возвращает строк, когда подзапрос даётNULL? - →Как
LEFT JOIN ... IS NULLиNOT EXISTSсравниваются по производительности?
MiddleТеорияЧастоЧто такое B-Tree и какова сложность поиска и вставки для него в базе данных?
Что такое B-Tree и какова сложность поиска и вставки для него в базе данных?
B-tree — это сбалансированное отсортированное многопутевое дерево поиска с высоким ветвлением, поэтому оно остаётся неглубоким для миллионов строк и является индексом по умолчанию в PostgreSQL. Отсортированные ключи дают равенство, диапазоны, ORDER BY и поиск по префиксу. Поиск, вставка и удаление — все O(log n), ведь оно балансируется разбиением и слиянием узлов.
Типичные ошибки
- ✗Говорить, что поиск
O(1)как у хеша — уB-treeонO(log n), плата за хранение ключей отсортированными - ✗Путать
B-treeс бинарным деревом — узелB-treeхранит много ключей и имеет высокое ветвление, оставаясь неглубоким - ✗Забывать, что вставки и удаления тоже
O(log n), ведь дерево должно перебалансироваться разбиением или слиянием узлов
Уточняющие вопросы
- →Почему высокое ветвление важнее для дискового индекса, чем для дерева в памяти?
- →Как
B-treeотвечает на запрос диапазона вродеWHERE age BETWEEN 20 AND 30?
MiddleТеорияЧастоЧто такое составной многоколоночный индекс и правило leftmost-prefix?
Что такое составной многоколоночный индекс и правило leftmost-prefix?
Составной индекс на (a, b, c) — одно B-tree, отсортированное по a, затем b, затем c. По правилу leftmost-prefix он обслуживает фильтры по ведущему префиксу — a, a,b, a,b,c — но не по b или c отдельно. Столбцы равенства ставьте первыми, диапазонный — последним.
Типичные ошибки
- ✗Ожидать, что индекс на
(a, b, c)ускоритWHEREтолько поbилиc, без ведущегоa - ✗Ставить диапазонный столбец перед столбцами равенства, что блокирует seek по последующим столбцам
- ✗Считать составной индекс тем же, что три независимых однотолбцовых индекса
Уточняющие вопросы
- →Почему столбец равенства должен идти перед диапазонным в определении индекса?
- →Когда применяется index-only scan и как
INCLUDEего включает?
MiddleКодЧастоПосчитать сотрудников по отделам, включая пустые
Посчитать сотрудников по отделам, включая пустые
LEFT JOIN Employee к Departments, чтобы выжил каждый отдел, GROUP BY d.id, d.name и COUNT(e.id) — счёт по столбцу сотрудника даёт 0 для пустых отделов, тогда как COUNT(*) посчитал бы единственную NULL-строку соединения как 1. INNER JOIN отбросил бы пустые отделы.
Типичные ошибки
- ✗Использовать
COUNT(*)и показывать1для пустых отделов вместо0 - ✗Использовать
INNER JOIN, отбрасывающий отделы без сотрудников - ✗Считать ключ отдела вместо столбца сотрудника
Уточняющие вопросы
- →Почему
COUNT(e.id)возвращает0, аCOUNT(*)—1для пустого отдела? - →Какая таблица должна быть слева в
LEFT JOIN, чтобы это работало?
MiddleТеорияЧастоЧто показывает EXPLAIN ANALYZE и как им диагностировать медленный запрос?
Что показывает EXPLAIN ANALYZE и как им диагностировать медленный запрос?
EXPLAIN печатает выбранный планировщиком план выполнения — порядок соединений, типы сканирований и оценки стоимости — не выполняя запрос. EXPLAIN ANALYZE запрос выполняет и добавляет фактическое число строк и время, поэтому сравнение оценки с реальностью находит плохие оценки и лишние сканирования.
Типичные ошибки
- ✗Путать
EXPLAIN ANALYZEс командойANALYZE, которая обновляет статистику таблицы - ✗Читать только оценочное число строк, игнорируя фактическое, которое выдаёт плохие оценки
- ✗Считать последовательное сканирование всегда плохим — на малой таблице оно лучше индекса
Уточняющие вопросы
- →На что обычно указывает большой разрыв между оценочным и фактическим числом строк?
- →Как читать числа стоимости у каждого узла плана
EXPLAIN?
MiddleКодЧастоНайти вторую по величине зарплату, обработав случай её отсутствия
Найти вторую по величине зарплату, обработав случай её отсутствия
Возьмите MAX(salary) там, где salary < (SELECT MAX(salary) FROM Employee): внутренний максимум — топ-зарплата, поэтому внешний максимум — вторая. Если второй зарплаты нет, эта форма аккуратно возвращает одну строку NULL. Вариант ORDER BY salary DESC LIMIT 1 OFFSET 1 вместо этого не возвращает строк.
Типичные ошибки
- ✗Считать, что
LIMIT 1 OFFSET 1совпадает с подзапросомMAX < MAXпри равных топ-зарплатах - ✗Забывать, что форма
LIMIT/OFFSETне возвращает строк, аMAX(... < MAX)возвращаетNULL - ✗Не убирать дубликаты зарплат, из-за чего равные топ-значения сдвигают результат
Уточняющие вопросы
- →Как дубликаты топ-зарплаты меняют результат
LIMIT/OFFSETпротив подзапроса? - →Почему форма
MAX(... < MAX)возвращаетNULL, а не пустой результат?
MiddleКодЧастоНайти сотрудников, зарабатывающих больше своего менеджера
Найти сотрудников, зарабатывающих больше своего менеджера
Соедините Employee саму с собой: один экземпляр под алиасом e (сотрудник), другой — m (менеджер), по e.manager_id = m.id, затем оставьте строки, где e.salary > m.salary. Одна таблица фигурирует дважды под разными алиасами, проходя связь manager_id → id внутри одной таблицы.
Типичные ошибки
- ✗Соединять по
e.id = m.idвместоe.manager_id = m.id - ✗Пытаться выразить сравнение через
GROUP BYвместо self-join - ✗Забывать дать двум копиям одной таблицы разные алиасы
Уточняющие вопросы
- →Почему условие соединения использует
manager_id → id, а неid → id? - →Как
LEFT JOINизменил бы результат для сотрудников без менеджера?
MiddleКодЧастоТоп-10 RU-клиентов по сумме корзины выше порога
Топ-10 RU-клиентов по сумме корзины выше порога
Отфильтруйте RU-строки в WHERE country = 'ru', сделайте GROUP BY customer.id, email, затем суммируйте каждую корзину через SUM(amount * price). HAVING отбирает группы с итогом >= 1000 (он работает после агрегации, в отличие от WHERE), ORDER BY по этой сумме DESC и LIMIT 10 оставляет верхние строки.
Типичные ошибки
- ✗Помещать агрегатную проверку
SUM(...) >= 1000вWHERE, который работает до группировки и её отвергает - ✗Забывать, что каждый неагрегированный выбранный столбец должен быть в
GROUP BY - ✗Использовать
INNER JOINи молча терять RU-клиентов с пустой корзиной
Уточняющие вопросы
- →Почему итог корзины должен быть в
HAVING, а не вWHERE? - →Как заодно вернуть число товаров каждого клиента вместе с итогом?
SeniorТеорияЧастоПочему HAVING может ссылаться на SUM(...), а WHERE нет, и как LEFT против INNER JOIN меняет группы?
Почему HAVING может ссылаться на SUM(...), а WHERE нет, и как LEFT против INNER JOIN меняет группы?
Логический порядок — FROM/JOIN → WHERE → GROUP BY → агрегаты → HAVING → SELECT → ORDER BY. WHERE работает до группировки и не может ссылаться на SUM(...); HAVING — после. INNER JOIN теряет клиентов без строк корзины, и они не образуют группу; LEFT JOIN оставляет их с NULL-агрегатом.
Типичные ошибки
- ✗Считать, что
WHEREиHAVINGработают на одной стадии и оба фильтруют по агрегатам - ✗Полагать, что
INNERиLEFT JOINдают одни и те же группы, когда у части клиентов нет строк корзины - ✗Думать, что
SELECTвычисляется первым, поэтому его псевдонимы доступны вWHERE
Уточняющие вопросы
- →На какой стадии логического порядка работает
WHEREпо неагрегированному столбцу? - →Почему
COUNT(cart_item.id)даёт 0 для клиента без корзины приLEFT JOIN?
MiddleКодИногдаНайти N-ю по величине различную зарплату, учитывая совпадения
Найти N-ю по величине различную зарплату, учитывая совпадения
Ранжируйте строки через DENSE_RANK() OVER (ORDER BY salary DESC) в подзапросе, затем фильтруйте WHERE rnk = N. DENSE_RANK учитывает совпадения — равные зарплаты делят ранг, не пропуская следующий, поэтому N=2 — это вторая различная зарплата. ROW_NUMBER даёт одну строку, RANK пропускает ранги.
Типичные ошибки
- ✗Использовать
ROW_NUMBER, когда совпадения должны делить ранг (нуженDENSE_RANK) - ✗Считать, что
LIMIT OFFSET Nубирает дубликаты равных зарплат - ✗Путать
RANK(пропускает ранги) иDENSE_RANK(без пропусков)
Уточняющие вопросы
- →Чем отличаются
RANK,DENSE_RANKиROW_NUMBERна трёх людях с одной топ-зарплатой? - →Почему ранжирование должно быть в подзапросе, а не прямо в
WHERE?
MiddleТеорияИногдаЧто такое партиционирование таблиц, и шардирует ли PostgreSQL из коробки?
Что такое партиционирование таблиц, и шардирует ли PostgreSQL из коробки?
Партиционирование разбивает одну логическую таблицу на меньшие физические дочерние таблицы по ключу (range, list или hash); планировщик отсекает лишние партиции, уменьшая сканы и облегчая обслуживание вроде VACUUM по партициям. Это остаётся в пределах одного сервера. PostgreSQL не шардирует по серверам из коробки — для этого нужно расширение вроде Citus или маршрутизация на уровне приложения.
Типичные ошибки
- ✗Путать партиционирование (один сервер, дочерние таблицы) с шардингом (данные разнесены по серверам)
- ✗Ждать, что PostgreSQL шардирует по машинам нативно без Citus или маршрутизации на уровне приложения
- ✗Считать, что запрос ускорится, даже когда его условие не позволяет планировщику отсечь партиции
Уточняющие вопросы
- →Когда range-партиционирование по дате сильнее всего помогает временным рядам?
- →Почему запрос должен фильтровать по ключу партиции, чтобы планировщик отсёк партиции?
MiddleТеорияИногдаКакие типы индексов предлагает PostgreSQL и когда уместен каждый из них?
Какие типы индексов предлагает PostgreSQL и когда уместен каждый из них?
B-tree — по умолчанию: равенство, диапазоны, сортировка. Hash обслуживает только равенство. GIN индексирует составные значения: массивы, jsonb, полнотекстовый поиск. GiST покрывает геометрические и диапазонные поиски. BRIN подходит огромным физически упорядоченным таблицам.
Типичные ошибки
- ✗Использовать индекс
Hash, ожидая помощи запросам диапазона — он обслуживает лишь равенство - ✗Хвататься за
B-treeна столбцеjsonbили массива, где правильный выбор —GIN - ✗Добавлять
BRINк малой или случайно упорядоченной таблице, где он даёт почти нулевую выгоду
Уточняющие вопросы
- →Почему индексу
BRINнужна физическая упорядоченность таблицы, чтобы быть полезным? - →Как индекс
GINпредставляет один документjsonbсо множеством ключей?
MiddleТеорияИногдаЧто делает VACUUM в PostgreSQL, как он работает и какие у него ограничения?
Что делает VACUUM в PostgreSQL, как он работает и какие у него ограничения?
MVCC в Postgres оставляет мёртвые версии строк после каждого UPDATE/DELETE. VACUUM возвращает это место для повторного использования внутри таблицы и обновляет visibility map и статистику планировщика; autovacuum запускает его в фоне. Обычный VACUUM не отдаёт диск ОС и не блокирует таблицу — это делает только VACUUM FULL, беря эксклюзивную блокировку и переписывая всю таблицу.
Типичные ошибки
- ✗Считать, что обычный VACUUM возвращает место ОС (это делает только VACUUM FULL)
- ✗Полагать, что VACUUM берёт эксклюзивную блокировку таблицы
- ✗Считать, что UPDATE перезаписывает на месте, поэтому мёртвые строки не копятся
Уточняющие вопросы
- →Почему долгая транзакция может мешать VACUUM удалять недавние мёртвые строки?
- →Что такое переполнение transaction ID и как VACUUM его предотвращает?
SeniorТеорияИногдаПочему планировщик может проигнорировать индекс и выбрать последовательное сканирование?
Почему планировщик может проигнорировать индекс и выбрать последовательное сканирование?
Когда запрос возвращает большую долю таблицы, последовательное сканирование дешевле множества случайных обращений по индексу, поэтому планировщик пропускает индекс. Устаревшая статистика, функция или приведение типа над столбцом либо низкая селективность тоже делают индекс невыгодным.
Типичные ошибки
- ✗Считать, что существующий индекс всегда используется — выборка большой доли строк выгоднее сканированием
- ✗Забывать, что функция или приведение типа над столбцом отключает обычный индекс по столбцу
- ✗Игнорировать устаревшую статистику, из-за которой планировщик ошибается в оценке и берёт сканирование
Уточняющие вопросы
- →Как запуск
ANALYZEдля обновления статистики меняет выбранный план? - →Почему оборачивание индексируемого столбца в функцию ломает использование индекса?
SeniorКодИногдаВычислить нарастающий итог зарплат по дате найма
Вычислить нарастающий итог зарплат по дате найма
Используйте оконную SUM(salary) OVER (ORDER BY hire_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). OVER ... ORDER BY делает агрегат нарастающим, а явная рамка суммирует каждую строку вплоть до текущей. Добавьте PARTITION BY dept_id для нарастающего итога по отделу.
Типичные ошибки
- ✗Использовать
GROUP BY hire_dateи получать суммы по дате, а не нарастающий итог - ✗Опускать
ORDER BYвOVER, из-за чегоSUMсуммирует всю партицию - ✗Думать, что оконная рамка не может ссылаться на все предыдущие строки
Уточняющие вопросы
- →Что возвращает
SUM(...) OVER (ORDER BY ...)без явной рамкиROWS? - →Как
PARTITION BY dept_idменяет нарастающий итог?
SeniorКодИногдаКак найти самого высокооплачиваемого сотрудника в каждом отделе?
Как найти самого высокооплачиваемого сотрудника в каждом отделе?
Пронумеруйте строки по отделу через ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) в подзапросе, затем оставьте WHERE rn = 1. PARTITION BY сбрасывает ранжирование для каждого отдела, поэтому строка 1 — топ отдела. Альтернатива — коррелированный подзапрос по MAX(salary) на отдел.
Типичные ошибки
- ✗Использовать
GROUP BY dept_id+MAX(salary)и ждать, что имя сотрудника подтянется - ✗Ранжировать глобально без
PARTITION BY dept_id - ✗Использовать
LIMIT 1и получить топ только одного отдела
Уточняющие вопросы
- →Чем
PARTITION BYотличается отGROUP BYпо тому, что возвращает? - →Когда предпочесть
RANKвместоROW_NUMBERпри совпадениях на вершине отдела?
SeniorТеорияИногдаКак журнал упреждающей записи обеспечивает устойчивость и восстановление после сбоя?
Как журнал упреждающей записи обеспечивает устойчивость и восстановление после сбоя?
Журнал упреждающей записи фиксирует каждое изменение в WAL устойчиво до сброса соответствующих страниц данных. Коммит подтверждается, как только его записи WAL попали на диск. После сбоя движок воспроизводит закоммиченные записи WAL и отбрасывает незакоммиченные, поэтому устойчивость держится, хотя грязные страницы данных так и не были записаны.
Типичные ошибки
- ✗Переворачивать порядок — записи WAL должны попасть на диск до страниц данных, а не после
- ✗Думать, что коммит ждёт сброса страниц данных, тогда как он ждёт лишь устойчивости записей WAL
- ✗Полагать, что восстановление отбрасывает всё в WAL, тогда как оно воспроизводит закоммиченные записи и отбрасывает лишь незакоммиченные
Уточняющие вопросы
- →Что такое контрольная точка, и как она ограничивает объём WAL, который должно воспроизвести восстановление?
- →Как тот же поток WAL служит ещё и основой физической репликации?
SeniorДебаггингРедкоЗапросы замедлились за недели, хотя EXPLAIN показывает использование индекса — найдите причину
Запросы замедлились за недели, хотя EXPLAIN показывает использование индекса — найдите причину
Индекс всё ещё выбран, но возвращает 12 строк, трогая 4120 буферов — признак bloat: мёртвые строки MVCC от тяжёлого трафика UPDATE/DELETE копились быстрее, чем autovacuum успевал их вернуть, поэтому heap и индекс полны мёртвых страниц, через которые скан вынужден продираться. Исправление: сделать autovacuum агрессивнее на этой таблице, выполнить VACUUM и REINDEX (или pg_repack), чтобы перестроить раздутый индекс.
Типичные ошибки
- ✗Читать «индекс используется» как доказательство, что план в порядке, игнорируя разрыв строк и буферов
- ✗Винить устаревшую статистику или отсутствие составного индекса вместо bloat
- ✗Считать, что тяжёлые чтения буферов всегда означают малый кэш
Уточняющие вопросы
- →Какие столбцы
pg_stat_user_tablesподтверждают избыток мёртвых строк в таблице? - →Почему
REINDEX CONCURRENTLYважен на таблице, которой нельзя простоя?
SeniorДебаггингРедкоCode review — исправьте этот стор истории статусов заказа для Postgres
Code review — исправьте этот стор истории статусов заказа для Postgres
Пять ошибок. sql.Open вызывается на каждый вызов, утекая новым пулом каждый раз — откройте один *sql.DB в NewStore и переиспользуйте. Результат fmt.Errorf отбрасывается, а метод не возвращает ошибку — верните обёрнутую ошибку. Запрос через fmt.Sprintf уязвим к SQL-инъекции — используйте параметризованный запрос $1..$4, передавая аргументы в Exec. go db.Exec — это fire-and-forget, теряющий ошибку и порядок — вызывайте синхронно. И нет context — принимайте ctx и используйте ExecContext.
Типичные ошибки
- ✗Считать, что
db.Execобеззараживает запрос изfmt.Sprintf, поэтому инъекция невозможна - ✗Думать, что fire-and-forget
go db.Execприемлем, ведь вставка выполнится «когда-нибудь» - ✗Вызывать
sql.Openна каждый запрос, считая, что он открывает одно реальное соединение
Уточняющие вопросы
- →Почему
sql.Openна самом деле не открывает соединение и что добавляетdb.Ping? - →Как подстановка
$1останавливает инъекцию, которую не остановит экранирование строки?
SeniorТеорияРедкоЧто такое переполнение transaction ID и как его предотвращает freeze?
Что такое переполнение transaction ID и как его предотвращает freeze?
PostgreSQL помечает каждую версию строки 32-битным идентификатором транзакции (XID), а видимость определяется сравнением XID в кольцевом пространстве. По мере роста XID очень старые могут показаться лежащими в будущем — переполнение — из-за чего живые строки будто исчезают. VACUUM это предотвращает, замораживая старые строки: помечает их видимыми для всех, чтобы их исходный XID больше не имел значения.
Типичные ошибки
- ✗Считать идентификатор транзакции 64-битным, поэтому он якобы не кончается
- ✗Путать переполнение XID с переполнением последовательности первичного ключа
- ✗Не знать, что от переполнения защищает именно шаг freeze в VACUUM
Уточняющие вопросы
- →Что такое
autovacuum_freeze_max_ageи почему он может вызвать агрессивный vacuum? - →Почему очень старая открытая транзакция или неиспользуемый слот репликации толкают базу к переполнению?