В регламентной обработке ценообразования для маркетплейсов был шаг «Акции», который занимал около 50 минут. Внутри него жил один запрос, выполнявшийся 39 минут. Не «медленный запрос, который хочется ускорить» — а запрос, из-за которого регламент не влезал ни в одно окно.
Эта история — про то, как он превратился в 6 секунд. Спойлер дважды: во-первых, запрос эти 39 минут почти не работал — он стоял. Во-вторых, лечился он не там, где болело. Разбор по шагам, с цифрами из монитора СУБД — потому что именно они, а не интуиция, привели к разгадке.
Контекст: право на ошибку — ноль
Обработка живая: считает цены десятков тысяч SKU и публикует их в реальные кабинеты маркетплейсов работающего ритейлера. Ошибка оптимизации — это неверные цены на витрине. Поэтому весь проект шёл по жёсткому контуру: замерный стенд на копии боевой базы, одна правка — один прогон — сравнение результата с эталоном побайтово (полная таблица расчёта, ~100 колонок на позицию). Правка принималась только при идентичном результате.
Держите этот контур в голове: всё, что описано ниже, было бы азартной игрой без него.
Акт первый: подозреваемый очевиден. И невиновен
Запрос «удаление позиций по высокой цене» в блоке акций. Текст — обычный для таких обработок: срез последних по регистру цен, соединение с таблицей товаров акции, отбор по условию цены. Первая интуиция любого оптимизатора: «тяжёлый срез, много данных, перепишем повально на временные таблицы».
Прежде чем переписывать, мы посмотрели на факты. Штатная статистика СУБД по этому запросу (доступна любому администратору через консоль сервера — мы читаем её, ничего не меняя):
| Показатель | Значение |
|---|---|
| Полное время выполнения (elapsed) | 2 353 с (~39 минут) |
| Процессорное время (CPU) | 124 с (~2 минуты) |
| Доля ожиданий | 95 % времени |

Вчитайтесь: из 39 минут запрос работал две. Остальные 37 он ждал. Это переворачивает задачу: оптимизировать текст запроса, который занят работой 5 % времени, — значит оптимизировать 5 % проблемы. Сначала надо понять, чего он ждёт.
💡 Правило, которое дешевле выучить на чужом проекте: elapsed ≠ CPU. Пока вы не разложили время запроса на «работал» и «ждал», вы не знаете, что оптимизируете. Разница между этими числами — первое, на что стоит смотреть в любом «медленном запросе».
Акт второй: чего он ждал
Ответ нашёлся в расписании регламента. Запрос акций стартовал сразу после массовой записи цен — этапа, который в тот момент шёл 17 минут и активно писал в регистр цен тысячи записей, порциями, с проведением документов.
А теперь — что делал наш запрос. Его срез последних был написан так:
// срез по ВСЕМУ регистру цен: отбор только по типу цен, и тот — по строковому коду РегистрСведений.Цены.СрезПоследних(, ТипЦен.Код = "000000164")
Срез виртуальной таблицы без отбора по номенклатуре означает: СУБД строит срез по всей таблице регистра — десятки миллионов записей, — чтобы потом соединением оставить несколько тысяч товаров акции. То есть запрос читает всё. А «читать всё» в момент, когда сосед «пишет много», — это очередь за блокировками по всей ширине таблицы. Отсюда 95 % ожиданий: широкое чтение застряло за массовой записью.
Заметьте, как связались две вещи, которые обычно разбирают порознь: «неоптимальный запрос» и «блокировки». Широкое чтение — это не только медленно, это ещё и конфликтно. Запрос, читающий лишние миллионы строк, конкурирует за них со всеми, кто в это время пишет. Сузив чтение, вы чините обе беды сразу.
Акт третий: лечение — одна правка
Раз запросу нужны только товары акции — отбор по ним должен стоять внутри параметров среза, а не снаружи соединением. Заодно строковый код типа цен заменяется ссылкой-параметром (условие .Код = "строка" — это скрытое соединение со справочником и фильтр по тексту без индекса):
// ПОСЛЕ: срез только по нужным товарам и типу цен по ссылке
РегистрСведений.Цены.СрезПоследних(
,
ТипЦен = &ТипЦенКабинета
И Номенклатура В (ВЫБРАТЬ Т.Номенклатура ИЗ ВТ_ТоварыАкции КАК Т))
Результат на боевом прогоне:
| До | После | |
|---|---|---|
| Запрос «удаление по высокой цене» | 2 353 с | 6 с |
| Шаг «Акции» целиком (оценка) | ~50 мин | ~10–12 мин |
| Результат запроса | идентичен — сверен построчно | |

×390. Не от гениальной правки — от того, что чинили причину, а не симптом: запрос перестал читать (и удерживать) миллионы чужих строк, и очередь за блокировками исчезла вместе с объёмом.
Акт четвёртый: рецидивы
Один найденный анти-паттерн — почти всегда серия. Мы прошли блок акций целиком: тот же «срез всего регистра без отбора» нашёлся ещё в пяти запросах соседних шагов — удаление недоступных к продаже, пересчёт цен после исключения, подготовка списков. Все пять исправлены той же схемой, каждый — со сверкой результата.
И это вторая привычка, которая окупается: нашли паттерн — ищите его везде. Погрепать тексты запросов обработки по «СрезПоследних(,» и по «.Код = "» — минуты; каждая находка — потенциальные минуты регламента.
Частые возражения — и почему мы сделали иначе
Разбор на ретро вызвал ровно те вопросы, которые зададут и в комментариях. Отвечаю заранее.
«Почему просто не перенести шаг акций подальше от записи цен?» Это лечит симптом у одного пациента. Перенос шага убрал бы очередь в этом расписании — но запрос остался бы широким: он всё так же читал бы десятки миллионов строк, всё так же был бы уязвим к любому соседу-писателю (а на общем сервере они есть всегда) и всё так же грел бы кэш СУБД чужими страницами. Сужение чтения лечит болезнь, перенос — прячет её. Кстати, после правки вопрос переноса отпал сам: 6-секундному запросу всё равно, кто рядом пишет.
«Может, дело в отсутствии индекса?» Первая версия этой гипотезы у нас тоже была. Но посмотрите ещё раз на соотношение: CPU 124 с, ожидания — 95 %. Плохой план исполнения выглядит иначе: высокий CPU, высокие чтения, малые ожидания. Индекс ускоряет работу — а наш запрос не работал, он ждал. Это и есть главная ценность разложения времени: оно говорит, какого класса у вас проблема, до того как вы начали чинить не ту.
«Срез последних по большому регистру — это же нормально, все так пишут». Все так пишут, пока регистр маленький. Виртуальная таблица без отбора масштабируется вместе с таблицей: на миллионе записей это незаметно, на десятках миллионов — 39 минут. Особая ловушка — регистры, у которых отключена таблица итогов среза последних: там СУБД строит срез «на лету» по всей истории при каждом обращении. Проверьте флаг у своих больших регистров сведений — результат может удивить.
«А почему не читать регистр напрямую запросом с МАКСИМУМ(Период)?» Потому что виртуальная таблица с отбором внутри параметров делает ровно это, но силами платформы и с правильными планами. Ручная эмуляция среза — код, который придётся сопровождать, и место для новых ошибок. Приём проще: не отказываться от виртуальных таблиц, а кормить их отборами.
Как проверить свою обработку за 15 минут
Рецепт без установки чего-либо, только чтение:
- Возьмите фактические тайминги этапов из журнала регистрации или лога самой обработки (у большинства регламентных обработок он есть). Найдите самый жирный этап.
- Разложите его время на CPU и ожидания по статистике сервера СУБД (консоль администратора, представления статистики запросов — доступны на чтение любому DBA). Ищите запросы, где elapsed кратно больше CPU.
- Погрепайте тексты запросов обработки по трём паттернам:
СрезПоследних(,(срез без отбора),.Код = "(строковые сравнения),СрезПервых(,. Каждое совпадение — кандидат. - Сопоставьте расписание: какие шаги-писатели идут перед вашим тяжёлым читателем или параллельно ему. Широкое чтение после массовой записи — та самая конфигурация, что дала нам 39 минут.
- Прежде чем править — снимите эталон результата. Хотя бы выгрузку итоговой таблицы с сортировкой и контрольной суммой. Правка без эталона — это надежда, а не инженерия.
По нашему опыту, три паттерна из пункта 3 покрывают большую часть «необъяснимо медленных» регламентов на выросших базах. Не потому, что разработчики плохие — потому, что код писался, когда таблицы были в сто раз меньше, и тогда он был честно быстрым.
И бонус-наблюдение с того же проекта про устойчивость: оптимизированные версии не просто быстрее — они стабильнее под чужой нагрузкой. Старый вариант ключевого запроса на тихом сервере шёл 358 секунд, а в час пик — 1 179; новый — 130 и 256 соответственно. Широкие запросы деградируют под нагрузкой нелинейно, потому что конкурируют за всё; узкие — почти не замечают соседей. Если ваш регламент «то влезает в окно, то нет» — это, возможно, не мистика, а мера широты ваших чтений.

Цена дисциплины — и что она поймала
Два слова о контуре сверки, без которого эту правку нельзя было бы выпустить в прод за разумное время. На каждую правку: фиксированный вход (снапшот внешних данных, границы дат — расчёт детерминирован), полный эталон результата, побайтовое сравнение. Звучит дорого, на деле — один раз настроить, дальше это кнопка.
За проект контур поймал две вещи, которые не поймал бы глаз: опечатку в тексте запроса (зарезервированное слово), ломавшую расчёт ровно в одном из четырёх ценовых контуров — три остальных прогона были бы зелёными; и подтвердил, что застарелый чужой баг ведёт себя одинаково до и после наших правок — то есть мы его не разбудили. Обе находки — из разряда «узнали бы от пользователей через неделю», и обе стоили бы дороже всего проекта оптимизации.
Что из этого унести себе
- Сначала elapsed vs CPU, потом всё остальное. Если разница в разы — вы ищете не «медленный запрос», а то, чего он ждёт. Это меняет и метод, и место починки.
- Срез последних без отбора — конфликтная мина. Он не просто читает лишнее — он воюет за это лишнее со всеми пишущими. Отбор по номенклатуре (и любым измерениям, по которым он у вас есть) — внутрь параметров виртуальной таблицы.
- Строковые коды в запросах — скрытые соединения.
НайтиПоКоду()один раз в коде, дальше — ссылка-параметр. - Смотрите на соседей по расписанию. Запрос может быть «медленным» только потому, что стартует в тень самого пишущего этапа. Иногда лечится переносом шага, у нас — сужением чтения.
- Ни одной правки без сверки результата. Наш контур — эталон и побайтовое сравнение после каждой правки — поймал за проект две ошибки, невидимые глазами. «Стало быстрее» без «стало так же» — это не оптимизация, а лотерея.
Весь проект, из которого взят этот эпизод, дал ускорение процессов ценообразования в 2–6 раз без изменения единой итоговой цены — но это уже другая история, с другими паттернами (запрос в цикле 810 → 43 с, поиск O(N²) 113 → 21 с, десять соединений одного регистра). Если интересно — расскажу в следующей статье.
И последний твист, который часть читателей, подозреваю, угадала по почерку: черновую работу в этом проекте — чтение кода, разбор статистики СУБД, подготовку правок и прогоны замеров — выполнял ИИ-агент (Claude Code) под управлением инженера. Все решения о выпуске принимал человек, и именно поэтому контур побайтовой сверки был обязательным: он превращает скорость агента из риска в преимущество. Как мы вообще пришли к работе агентов с продуктивом — я разбирал в предыдущих статьях про бенчмарк нейросетей на боевой базе. А спорить и делиться своими «39 минутами» приглашаю в комментарии: самые дикие elapsed/CPU-соотношения, которые вы встречали в бою, — отдельная коллекционная ценность.
Вступайте в нашу телеграмм-группу Инфостарт