Антипаттерны и типичные ошибки

Запросы, которые «работают» на тестовых данных и разваливаются на проде — и как их распознать заранее.

Антипаттерн — привычное решение, которое выглядит правильным, но систематически приводит к проблемам: медленным запросам, лишней нагрузке, хрупкому коду.

На сотне строк любой запрос летает. Беда приходит на миллионах: то, что СУБД раньше прощала, теперь сканирует всю таблицу и кладёт сервер. Разберём самые частые антипаттерны и научимся читать план запроса, чтобы ловить их до прода.

1. SELECT *

SELECT * кажется удобным, но в продакшене это мина. Он тянет все колонки, даже тяжёлые (большие тексты, blob), даже те, что не нужны. Хуже: он мешает оптимизатору применить покрывающий индекс (когда все нужные данные лежат в самом индексе и до таблицы идти не надо). И ещё он хрупок: добавили колонку — изменился порядок и состав ответа, и код, читающий результат по позиции, ломается.

-- Антипаттерн: тянем всё, мешаем покрывающему индексу
SELECT * FROM orders WHERE user_id = 42;

-- Лучше: только нужные колонки
SELECT id, total, created_at FROM orders WHERE user_id = 42;

2. Неявные касты ломают индекс

Если тип в условии не совпадает с типом колонки, СУБД незаметно приводит типы — и индекс по колонке перестаёт использоваться. Классика: колонка phone хранится как строка, а в условии сравнивают с числом. Тогда движок приводит каждую строку таблицы к числу, чтобы сравнить, — это полный скан. Сравнивайте значением того же типа, что и колонка: строку со строкой.

-- Антипаттерн: phone — VARCHAR, а сравниваем с числом → каст всей колонки, индекс не работает
SELECT * FROM users WHERE phone = 79991234567;

-- Лучше: тип совпадает с колонкой
SELECT id FROM users WHERE phone = '79991234567';

3. Функция на индексируемом столбце

Родственная беда. Как только вы оборачиваете индексируемую колонку в функцию — LOWER(email), DATE(created_at), col + 0 — обычный индекс по колонке становится бесполезен: он хранит исходные значения, а не результат функции. Движок вынужден вычислить функцию для каждой строки. Решения: переписать условие как диапазон (для дат) или завести функциональный индекс ровно по тому выражению, что в запросе.

-- Антипаттерн: функция поверх колонки убивает индекс по created_at
SELECT * FROM events WHERE DATE(created_at) = '2026-06-27';

-- Лучше: диапазон по «голой» колонке — индекс работает
SELECT id FROM events
WHERE created_at >= '2026-06-27 00:00:00'
  AND created_at <  '2026-06-28 00:00:00';

4. Проблема N+1

Самый частый антипаттерн в коде приложений. Сначала один запрос достаёт список (например, 100 заказов), а потом приложение в цикле делает по запросу на каждую строку, чтобы догрузить связанные данные (имя пользователя к заказу). Итого 1 + 100 = 101 запрос вместо одного. На странице это превращается в секунды задержки. Лечится JOIN или одним запросом WHERE id IN (...), который тянет всё связанное разом.

-- N+1: 1 запрос на заказы + по запросу на каждого пользователя
for order in orders:
    user = query("SELECT name FROM users WHERE id = ?", order.user_id)

-- Лучше: один JOIN тянет заказы вместе с именами
SELECT o.id, o.total, u.name
FROM orders o
JOIN users u ON u.id = o.user_id;

5. OFFSET vs keyset-пагинация

Постраничный вывод обычно делают через LIMIT ... OFFSET .... На первых страницах нормально, но OFFSET 100000 заставляет СУБД прочитать и выбросить сто тысяч строк, прежде чем отдать нужные — чем дальше страница, тем медленнее. Альтернатива — keyset-пагинация («бесконечная лента»): вместо номера страницы запоминаем последнее увиденное значение и просим «всё, что после него». Это использует индекс и не зависит от глубины.

CREATE TABLE feed (id INTEGER PRIMARY KEY, title TEXT);
INSERT INTO feed (title) VALUES
  ('a'),('b'),('c'),('d'),('e'),('f'),('g');

-- Keyset: «следующие 3 после id = 2» — без OFFSET
SELECT id, title
FROM feed
WHERE id > 2
ORDER BY id
LIMIT 3;

Вывод:

3|c
4|d
5|e

Сравните с LIMIT 3 OFFSET 2: результат тот же, но keyset не перебирает пропускаемые строки — он сразу прыгает по индексу к id > 2. На глубоких страницах разница в скорости — порядки.

EXPLAIN: как заглянуть в план

EXPLAIN (в SQLite — EXPLAIN QUERY PLAN) показывает, как СУБД собирается выполнять запрос: пойдёт ли по индексу или просканирует всю таблицу. Главные слова-маркеры: SCAN (полный проход по таблице — подозрительно на больших данных) против SEARCH ... USING INDEX (точечный доступ через индекс — то, что нужно). Запустите и сравните план до и после индекса.

CREATE TABLE big (id INTEGER PRIMARY KEY, user_id INTEGER, total INTEGER);
INSERT INTO big (user_id, total) VALUES
  (1,100),(2,200),(1,150),(3,300),(2,250);

-- До индекса: полный SCAN таблицы
EXPLAIN QUERY PLAN
SELECT total FROM big WHERE user_id = 2;

CREATE INDEX idx_big_user ON big(user_id);

-- После индекса: точечный SEARCH USING INDEX
EXPLAIN QUERY PLAN
SELECT total FROM big WHERE user_id = 2;

Вывод (примерно):

SCAN big
SEARCH big USING INDEX idx_big_user (user_id=?)

Первый план — SCAN (читаем все строки), после создания индекса — SEARCH ... USING INDEX. Именно так проверяют, «зацепился» ли запрос за индекс. Точная формулировка строки зависит от версии SQLite, но ключевые слова SCAN/SEARCH/INDEX опознать легко.

Как это работает под капотом

Индекс — это отсортированная структура (B-дерево), по которой движок за логарифм находит нужные строки, не читая остальные. Любой из перечисленных антипаттернов лишает оптимизатор возможности этим воспользоваться: каст или функция меняют значение, по которому отсортирован индекс; OFFSET всё равно вынуждает пройти пропускаемые строки; SELECT * заставляет идти в таблицу за лишними колонками мимо покрывающего индекса. Планировщик запросов оценивает стоимость вариантов по статистике таблиц — но если условие написано так, что индекс неприменим, выбирать ему не из чего, остаётся полный скан.

Частые ошибки

  • «У меня же есть индекс, почему медленно?» — потому что функция/каст/SELECT * не дают им воспользоваться. Сначала EXPLAIN.
  • Оптимизация вслепую. Навешивать индексы наугад вредно: каждый замедляет запись и ест место. Сначала смотрят план, потом добавляют точечно.
  • Глубокий OFFSET в «бесконечной ленте». На странице 5000 это уже тормоза. Keyset решает проблему в корне.
  • N+1, спрятанный в ORM. Ленивая загрузка связей незаметно порождает сотни запросов. Включайте логирование SQL и используйте eager-загрузку (JOIN).

Итоги

  • SELECT * тянет лишнее и мешает покрывающему индексу — перечисляйте нужные колонки.
  • Неявный каст и функция на колонке отключают индекс — сравнивайте тем же типом и держите колонку «голой».
  • N+1 = 1 + много запросов в цикле; лечится JOIN или IN (...).
  • Глубокий OFFSET медленный — для лент берите keyset-пагинацию (WHERE id > ?).
  • EXPLAIN QUERY PLAN: ищите SEARCH ... USING INDEX вместо SCAN — это ваш главный диагностический инструмент.
Проверьте себя
1. Почему условие WHERE DATE(created_at) = '2026-06-27' обычно не использует индекс по created_at?
AФункция DATE запрещена в WHERE
BИндекс хранит исходные значения колонки, а не результат функции, поэтому движок вынужден вычислить DATE() для каждой строки
CДаты вообще нельзя индексировать
DDATE() работает только в SELECT
2. В чём суть проблемы N+1?
AЗапрос возвращает на одну строку больше нужного
BПосле одного запроса списка приложение в цикле делает по отдельному запросу на каждую строку, итого 1 + N запросов вместо одного
CИндекс создаётся N+1 раз
DТранзакция откатывается N раз
3. Чем keyset-пагинация (WHERE id > ?) лучше глубокого LIMIT ... OFFSET ...?
AОна возвращает больше строк
BОна не перебирает и не выбрасывает пропускаемые строки, а сразу прыгает по индексу к нужному месту, поэтому не замедляется на дальних страницах
COFFSET не поддерживается в SQLite
DKeyset не требует ORDER BY
4. Какое слово в плане EXPLAIN QUERY PLAN говорит, что запрос идёт через индекс, а не сканирует всю таблицу?
ASCAN
BSEARCH ... USING INDEX
CTEMP B-TREE
DCOMPOUND QUERY