Топ 6 ошибок в SQL-запросах
Запрос, который отлично работает на сотне тестовых строк, на боевых данных умеет положить весь сервис. Шесть ошибок, которые встречаются чаще всего, — и объяснение, почему каждая из них больно бьёт именно в проде.
Почему это не видно на этапе разработки
Локальная база обычно содержит несколько десятков строк, налитых для проверки. На таком объёме база выполнит вообще любой запрос мгновенно — даже полный перебор таблицы занимает микросекунды. Разница между хорошим и катастрофическим запросом проявляется только тогда, когда строк становятся сотни тысяч.
Вторая причина — прослойка. Многие пишут не SQL, а вызовы библиотеки, которая генерирует SQL за них. Запрос выглядит как обращение к обычному объекту, и понять по коду, что за этой строкой скрывается двести обращений к базе, невозможно.
Главный инструмент. План выполнения. Почти в любой базе есть команда, которая показывает, как именно СУБД собирается выполнять запрос: пойдёт ли она по индексу или переберёт всю таблицу, в каком порядке соединит таблицы, сколько строк ожидает получить. Читать план — навык, который окупается быстрее всех остальных в этой теме.
Примеры ниже нейтральны к конкретной базе: детали синтаксиса и поведение в мелочах отличаются, но все шесть ошибок одинаково живут и в PostgreSQL, и в MySQL, и в остальных реляционных базах.

Топ 6 ошибок
Звёздочка в рабочем коде
удобно писать, дорого выполнять
SELECT * FROM таблица — первое, чему учат, и первое, от чего стоит отвыкнуть в коде приложения. Запрос тянет все колонки, включая те, которые вам не нужны: длинные текстовые поля, картинки в двоичном виде, служебные метки. По сети едет в разы больше данных, чем требуется, и всё это ещё нужно разобрать на стороне приложения.
Что ломается неочевидно. Звёздочка мешает покрывающему индексу. Если бы вы попросили две колонки, которые уже лежат в индексе, база отдала бы ответ прямо из индекса. Со звёздочкой ей придётся за каждой найденной строкой лезть в саму таблицу — и выигрыш от индекса испаряется.
И хрупкость. Кто-то добавил колонку — и запрос молча начал возвращать другой набор полей. Если код рассчитывает на порядок колонок или складывает результат в структуру с фиксированным набором полей, поломка приедет неожиданно и не туда, где меняли схему. Отдельно больно, когда звёздочка спрятана внутри представления или подзапроса: там она тянет за собой лишние колонки через все уровни.
Где допустимо. Разовые запросы руками в консоли, когда вы просто смотрите на данные. В коде — перечисляйте колонки явно.
- Исправляется механически, без изменения логики
- Сразу уменьшает объём передаваемых данных
- Явный список колонок документирует, что нужно коду
- Списки колонок приходится поддерживать при изменении схемы
- В генераторах запросов звёздочка часто стоит по умолчанию
частая ошибка · оценка 9,0
Запрос внутри цикла
самая дорогая ошибка, и в коде её почти не видно
Классика: получили список из ста записей одним запросом, а дальше в цикле для каждой запрашиваем связанные данные — автора, категорию, количество комментариев. Вместо одного обращения к базе получается сто один. Отсюда и название — проблема N плюс один.
Почему это дороже, чем кажется. Дело не в самих запросах — каждый из них выполняется быстро. Дело в накладных расходах: на каждый запрос уходит сетевой круг до базы, разбор запроса, построение плана. Сто маленьких запросов легко оказываются медленнее одного большого в десятки раз, а при удалённой базе — в сотни.
Как лечится. Три способа. Соединить таблицы в одном запросе. Собрать все идентификаторы и запросить связанные записи одним обращением с условием на список значений. Или, если вы пользуетесь библиотекой отображения объектов, включить для связи «жадную» загрузку — почти в каждой такой библиотеке это одна строка.
Как заметить. Выводите в лог количество запросов к базе на каждый запрос к приложению. Если при открытии одной страницы счётчик показывает трёхзначное число, вы нашли ровно это. У большинства фреймворков есть панель отладки, которая показывает список запросов и повторы в нём.
- После исправления ускорение видно невооружённым глазом
- Обнаруживается счётчиком запросов за пять минут
- Средства борьбы есть в любой библиотеке
- Жадная загрузка «на всякий случай» создаёт обратную проблему — тянет лишнее
- Соединение вместо цикла усложняет запрос и разбор результата
частая ошибка · оценка 9,5
Нет индекса под условие — или он есть, но не работает
полный перебор таблицы там, где хватило бы одного обращения по ключу
Без индекса база честно читает всю таблицу и проверяет каждую строку. На десяти тысячах строк это незаметно, на десяти миллионах — секунды на каждый запрос. Индекс нужен под колонки, по которым вы фильтруете, соединяете таблицы и сортируете.
Порядок колонок в составном индексе важен. Индекс по паре колонок работает для условия по первой колонке и для условия по обеим сразу, но не для условия только по второй. Это самая частая причина ситуации «индекс же есть, а он им не пользуется».
Функция над колонкой убивает индекс. Условие вида «привести колонку к нижнему регистру и сравнить» или «взять от даты только день и сравнить» заставляет базу вычислить функцию для каждой строки — обычный индекс тут бесполезен. Лечение: переписать условие в диапазон (от начала суток до начала следующих) либо завести индекс по выражению, если ваша база это умеет.
Поиск подстроки. Условие с шаблоном, начинающимся с любого символа, индексом не ускоряется в принципе. Для поиска по тексту нужен полнотекстовый индекс или отдельный поисковый движок, а не хитрости с обычным.
Обратная сторона. Индексы замедляют вставку и обновление и занимают место. Вешать их на каждую колонку не надо: лишние индексы делают запись медленнее, а планировщику от них только сложнее.
- Один правильный индекс превращает секунды в миллисекунды
- Добавляется без изменения кода приложения
- План запроса сразу показывает, помог он или нет
- Каждый индекс — плата при записи и место на диске
- Построение индекса на большой таблице может заблокировать её
- Работает не под каждое условие, и это надо знать заранее
частая ошибка · оценка 9,4
Неявные преобразования типов
тихо превращает индекс в бесполезный
Колонка числовая, а в условие приезжает строка — или наоборот. База не ругается: она приводит одно к другому и выполняет запрос. Результат обычно верный, но чтобы сравнить, ей приходится преобразовать значение в каждой строке, и индекс по этой колонке перестаёт применяться.
Где это берётся. Чаще всего — из кода: идентификатор пришёл из адреса страницы строкой, так и уехал в запрос. Или в схеме числовой по смыслу столбец описан как текстовый, потому что «так было проще при импорте».
Родственный случай — разные правила сравнения строк. Если две таблицы созданы с разной кодировкой или разным набором правил сортировки, соединение по текстовым колонкам между ними тоже потребует преобразования на каждой строке. Внешне запрос выглядит нормально, а работает как полный перебор.
И совсем неприятное. В части баз сравнение строки с числом даёт не ошибку, а результат по своим правилам — и запрос возвращает не то, что вы ожидали, вообще без предупреждений. Поэтому типы в схеме и типы параметров должны совпадать осознанно, а параметры всегда передаваться через подстановку, а не склейкой строк.
- Исправление обычно локальное — привести тип в одном месте
- После починки индекс начинает работать сразу
- План запроса честно показывает лишнее преобразование
- Не проявляется ни ошибкой, ни предупреждением
- Смена типа колонки на большой таблице — отдельная операция с простоем
частая ошибка · оценка 8,8
Логика с пустыми значениями
NULL — не значение, а отметка о его отсутствии
Отсюда всё странное поведение. Сравнение с пустым значением никогда не даёт истину: условие «колонка равна NULL» не найдёт ничего, даже если пустых строк полтаблицы. Нужны отдельные проверки — IS NULL и IS NOT NULL.
Классическая ловушка. Условие «значение не входит в список», где в списке или в подзапросе встретился NULL, возвращает пустой результат целиком. Не часть строк, а вообще ничего — и это выглядит как поломка приложения, а не как логика базы. Безопаснее строить такие проверки через отсутствие связанной записи, а не через отрицание списка.
Агрегаты. Подсчёт по конкретной колонке пропускает пустые значения, подсчёт строк — нет, и два числа в отчёте расходятся. Среднее считается только по заполненным строкам, поэтому «среднее по всем» и «среднее по тем, у кого есть значение» — разные числа, и надо понимать, какое из них вам нужно.
Соединение таблиц. Внешнее соединение с условием на правую таблицу, вынесенным в общий блок условий, тихо превращается в обычное: строки без пары отсеиваются. Такие условия должны стоять в самом соединении, а не в блоке фильтрации.
Что помогает. Запрещать пустые значения в схеме везде, где по смыслу их быть не должно, и подставлять значение по умолчанию функцией вида COALESCE там, где пустота допустима.
- Правила простые, выучиваются за вечер
- Запрет пустых значений в схеме убирает проблему в корне
- Тесты на граничные случаи ловят такое надёжно
- Ошибка даёт не сбой, а неверные данные — заметить трудно
- Изменить схему на живой таблице не всегда просто
частая ошибка · оценка 9,2
Забытое ограничение количества строк
запрос, который работал год, однажды кладёт сервис
Выборка без ограничения на количество — бомба замедленного действия. Пока в таблице тысяча строк, всё хорошо. Через год их миллион, и приложение пытается загрузить в память весь этот миллион. Падает не база, а сервис приложения — по нехватке памяти, обычно в самый неудобный момент.
Про постраничный вывод. Смещение на больших страницах работает медленно: чтобы отдать строки с миллионного места, база вынуждена пройти весь миллион до них. Для длинных списков надёжнее листать по ключу — запрашивать записи после последнего показанного значения.
Сортировка обязательна. Без явного порядка база не обязана возвращать строки одинаково от запроса к запросу. При постраничном выводе это даёт дубли на одних страницах и пропуски на других, и такую жалобу пользователей потом очень долго воспроизводят.
Родственная беда — изменение без условия. Обновление или удаление, где забыли блок условий, меняет всю таблицу. Привычка простая: сначала пишете выборку с тем же условием, смотрите, сколько строк попало, и только потом заменяете начало на изменение. На боевой базе — внутри транзакции, чтобы был путь назад.
- Ограничение добавляется в одну строку
- Листание по ключу работает одинаково быстро на любой странице
- Проверка выборкой перед изменением стоит десять секунд
- Листание по ключу неудобно, если нужны произвольные номера страниц
- Ограничение без сортировки создаёт иллюзию порядка
критичная ошибка · оценка 9,3

Сравнение по главному
| Ошибка | Как часто | Цена | Исправить |
|---|---|---|---|
| Звёздочка в выборке | очень часто | средняя | минуты |
| Запрос в цикле | часто | высокая | часы |
| Нет индекса | часто | высокая | минуты |
| Преобразование типов | иногда | средняя | часы |
| Логика с NULL | часто | высокая | часы |
| Нет ограничения строк | иногда | критичная | минуты |

Как находить это у себя
Читайте план запроса. Полный перебор большой таблицы, неожиданная сортировка на диске, оценка в миллионы строк там, где вы ждали десяток, — всё это видно сразу. Достаточно уметь опознавать три-четыре типовые картины, глубокая теория не нужна.
Включите журнал медленных запросов. База умеет записывать все запросы дольше заданного порога. Это самый честный способ узнать, что именно тормозит, — обычно список оказывается коротким и неожиданным.
Считайте запросы на страницу. Один счётчик в логе отладки закрывает всю тему запросов в цикле раз и навсегда.
Тестируйте на объёме. Налейте в тестовую базу хотя бы сотни тысяч строк, приближённых к реальным. Половина описанных ошибок на такой базе проявляется сразу, а на двадцати строках не проявится никогда.
Смотрите на схему, а не только на запросы. Часть проблем лечится не переписыванием запроса, а типом колонки, запретом пустых значений или нормальным внешним ключом. Про выбор базы под небольшой проект у нас есть отдельный разбор.
Частые вопросы
Нужно ли знать SQL, если есть библиотека отображения объектов?
Да, и именно из-за таких ошибок. Библиотека прекрасно пишет простые запросы, но не понимает ваших намерений: она не знает, что вы сейчас в цикле и что связанные данные лучше подтянуть заранее. Уметь посмотреть сгенерированный SQL и его план — обязательный навык, иначе вы отлаживаете чёрный ящик.
Как понять, что нужен индекс?
По журналу медленных запросов и плану: если база перебирает всю таблицу, а вам нужны единицы строк — индекс нужен. Ориентир для схемы: индексы обычно оправданы под внешние ключи, под колонки, по которым вы регулярно фильтруете, и под сортировку в длинных списках. Всё остальное — по результатам замеров, а не по ощущениям.
Сколько индексов на таблицу — нормально?
Единого числа нет, но здравый ориентир такой: если индексов больше, чем колонок, участвующих в условиях, что-то не так. Каждый индекс замедляет вставку и обновление. Полезнее иметь несколько продуманных составных индексов под реальные запросы, чем десяток одиночных на все колонки подряд.
Что делать, если запрос не ускоряется?
Сначала проверьте, нужен ли он в таком виде: часто отчёт можно посчитать заранее и хранить готовый результат. Дальше по нарастающей — кеширование ответа, предпосчитанные таблицы, разделение большой таблицы на части. И только в конце — переезд на другое хранилище: это самый дорогой шаг, и до него доходят реже, чем кажется.
Итог
Если браться за одну вещь — беритесь за запросы в цикле. Это самая частая причина того, что страница открывается три секунды вместо трёхсот миллисекунд, и исправляется она обычно одной строкой в описании связи.
Дальше научитесь читать план запроса и включите журнал медленных запросов. После этого остальные пять ошибок вы будете находить у себя сами, до того как их найдут пользователи.
Комментарии
0Будьте первым, кто ответит автору.