Ночной регламент переоценки товаров без движения работал от 55 до 100 минут, и самый тяжёлый его шаг - запись в регистр истории - стабильно занимал по 3,9-4,2 минуты каждую ночь. После пяти точечных правок этот шаг на первом же боевом ночном прогоне отработал за 4 секунды. Холодный кэш, ноль ошибок.
Это продолжение нашего бенчмарка ИИ-агентов. Ту же самую обработку мы давали четырём моделям (Fable 5, Opus 5 и двум сборкам Codex) прямо в продуктиве, с доступом только на чтение: каждая сама искала, где тормозит, и главные виновники у них разошлись; кто из четырёх попал точнее - разбор здесь. Сегодня другая часть истории: собранный диагноз выложили в прод и проверили реальным ночным прогоном. Цифры сравнения моделей оставляю в той статье, тут только боевые прогоны после выкладки.
Сразу про формат работы, чтобы не было вопросов по ходу. Диагностику и черновики правок гнали тем же способом, что в бенчмарке: read-only агент по базе, только на чтение (здесь это Claude Code, то есть прогоны Fable 5 и Opus 5). Каждую правку инженер сверял числом «было / стало», проверял на эквивалентность результата и выкладывал в прод сам. Агент - инструмент вроде профайлера, решения и ответственность на человеке. Ниже видно, где это дало результат, а где предложенную правку пришлось выбросить.
Обработку не переписывали с нуля. В ней около 9 400 строк рабочей логики, переписывание - это гарантированная регрессия и недели проверок. А время утекало в пяти конкретных местах, каждое чинится точечно.
Что за регламент
Внешняя обработка автоматической переоценки товаров без движения 180+ дней. Каждую ночь в 01:00 пересчитывает скидки, формирует документы установки цен, генерирует купоны и раскладывает их по складам (их больше 160). Система - сеть розничных магазинов на ERP-платформе 1С:Предприятие 8.3. Модуль объекта около 9 400 строк BSL, десятки процедур.
Формально регламент укладывался в норматив (10 800 секунд), и по нему одному его никто бы не трогал. Но по журналу за две недели прогон плавно рос (55-100 минут, в среднем около 73), а один шаг каждую ночь жёг по несколько минут на пустых накладных расходах. Большая часть времени уходила в инфраструктуру - срез по лишним данным, построчная запись, обращения к базе в циклах. Сама переоценка была ни при чём.
Пять причин, у каждой доказательство
Причина 1: срез цен строился по всей номенклатуре
Один из шагов читал историю расчёта и подтягивал к ней текущие цены через СрезПоследних(). В параметрах среза стоял отбор только по типу цен, без ограничения по номенклатуре. Платформа честно строила срез по всем товарам этого типа цен, а лишнее отбрасывала уже при соединении.
Цена вопроса - в счётчиках плана запроса за один прогон:
| Счётчик | До | После |
|---|---|---|
| Строки, добранные через key lookup | 549 383 | 0 |
| Итого логических чтений страниц | 585 739 | 9 090 |
Это именно логические чтения (буферный пул), а не диск. На прогретом кэше физического ввода-вывода тут почти нет. Но регистр цен - свыше 107 миллионов строк, около 93 ГБ индексов, и на холодном кэше заметная часть этих чтений превращается в физические. Поэтому число важно само по себе: полмиллиона обращений к структуре, чтобы вернуть 300 строк (на входе 9 295 строк истории, на выходе 300).
Правка: нужные номенклатуры материализуются во временную таблицу с индексом, и оба среза цен ограничиваются ею прямо в параметрах виртуальной таблицы: Номенклатура В (ВЫБРАТЬ … ИЗ ВТ_ИсторияСрез). Результат совпал посимвольно - отпечаток выборки в 50 685 символов на 300 строк знак в знак. По логическим чтениям 64,4x, по времени на холодном кэше 3,24x.
Всё дело в том, куда попадает отбор. Когда он в параметрах виртуальной таблицы, платформа строит срез сразу узким - по тремстам нужным номенклатурам. Когда отбор вынесен в условие соединения после среза, платформа сначала строит срез по всем товарам типа цен, а для этого перелопачивает историю регистра - те самые сто с лишним миллионов строк, - и только потом отбрасывает лишнее. Внешне запрос почти одинаковый, разница только в месте отбора, а в счётчике чтений это 585 739 против 9 090.
Причина 2: запись в регистр по одной строке
Для Каждого Строка Из ТЗРасчёта Цикл
МЗ = РегистрыСведений.ИсторияРасчёта.СоздатьМенеджерЗаписи();
…
МЗ.Записать(); // отдельная операция записи на КАЖДУЮ строку
КонецЦикла;
На 8 406 строках это 103 629 мс. Заменили на чтение набора записей за период, точечную замену строк по ключу в памяти и одну операцию записи всем набором: 5 265 мс, ускорение 19,68x. Состав и контрольная сумма регистра до и после совпали побитово. Одна запись набором - это одна транзакция и один коммит вместо восьми с лишним тысяч мелких, каждый со своим сбросом лога на диск; отсюда и разница на порядок.
Набор = РегистрыСведений.ИсторияРасчёта.СоздатьНаборЗаписей(); Набор.Отбор.Период.Установить(Период); Набор.Прочитать(); ЗаменитьСтрокиПоКлючу(Набор, ТЗРасчёта); // точечно, по нормализованному ключу Набор.Записать(); // одна операция вместо 8 406
Здесь есть о чём спросить, и правильно. Набор.Записать() с отбором по периоду перезаписывает весь период - это осознанный размен: одна большая запись вместо тысяч мелких. Безопасно это потому, что регламент - единственный писатель в этот регистр, работает ночью в эксклюзивное окно, между Прочитать() и Записать() данные периода менять некому. Плюс подстраховка: Записать() набором атомарна, при сбое транзакция откатывается целиком, регистр не остаётся в полузаписанном виде, и тогда срабатывает резервный построчный путь. Так что надёжность не пострадала, а способ проверки остался прежним - сверка состава и контрольной суммы.
Тонкость, без которой правка была бы неверной: одно измерение регистра хранится в базе усечённым (обрезанное имя вместо полного), и сопоставление ключей «что перезаписываем» пришлось делать по нормализованному значению. Иначе вместо перезаписи в регистре появлялись бы дубли. Пакетную запись вообще нельзя ставить вслепую: без сверки такой дубль легко проехать.
Причины 3-5: обращения к базе в циклах
- Реквизит справочника «через точку» внутри цикла. На каждую новую ссылку платформа читает весь объект целиком в кэш объектов. Пока номенклатура повторяется - дёшево, но когда в цикле тысячи разных ссылок, это тысячи чтений объектов вместо одного запроса до цикла. Заменили на предварительную выборку. Замеряли время участка цикла до и после на тех же данных: 212-265x в трёх разных местах.
- Массив.Найти() при накоплении уникальных значений - линейный поиск на каждой вставке. Рядом с массивом завели Соответствие (хеш-таблицу) для проверки наличия. Около 7x.
- Чтение настроек из базы на каждой строке детальной таблицы - закэшировали в переменной модуля. Было 3,05 мс на вызов, стало почти 0.
Как мерили и как убедились, что ничего не сломали
Время брали из журнала регламентного задания по шагам за 14 дней подряд: нужна была разбивка, где именно утекают минуты. Общая цифра прогона этого не покажет. Логические чтения считали через план запроса и статистику ввода-вывода на одном и том же входе: сколько чтений страниц сделал шаг, чтобы вернуть свои строки.
Эквивалентность проверяли для каждой из пяти правок: фиксировали результат до, применяли правку, сверяли после - контрольной суммой набора записей или посимвольным отпечатком выборки. Отпечаток - это конкатенация полей результата в фиксированном порядке в одну строку, которую потом сравнивали посимвольно: так видно любое расхождение, вплоть до одного изменившегося символа. У среза цен отпечаток совпал знак в знак, у регистра истории совпали состав и контрольная сумма. Без такой сверки я бы эти правки в прод не повёз.
Мерили на двух кэшах. Днём буферный пул прогрет, ночью данные регламента вытесняются из него дневной OLTP-нагрузкой, и шаг стартует на холодных страницах. Часть правок (та, что экономит чтения) выигрывает именно на холодном кэше, а на прогретом почти незаметна. Холодный кэш в лаборатории получали на копии базы; сбрасывать буферы на боевом сервере нельзя.
Что не стали трогать
Отдельно замерили гипотезу об индексации временных таблиц в главном тяжёлом запросе. На холодном кэше правка не сокращала чтений, а на прогретом оказалась медленнее исходного варианта. Правку отклонили, в релиз она не пошла.
Это и есть лучшее доказательство, что работой управлял инженер, а не «агент что-то сделал в базе»: идея звучала правильно, замер её не подтвердил, и её выбросили, а не оставили «раз уж всё остальное переписали». В отчётах об оптимизации такой пункт обычно не показывают.
Прод: два прогона на живых данных
Обновлённая обработка встала в продуктив. Первому же контрольному прогону в тот же день не поверили: кэш был тёплый, а правки рассчитаны на холодный ночной. Дождались планового ночного запуска.
Прогон 1 - ручной, в день выкладки, тёплый дневной кэш. Запись регистра истории 0,4 мин вместо обычных 3,9-4,2 (10x), ошибок нет. Но кэш тёплый, это ещё не целевой сценарий.
Прогон 2 - первый автоматический ночной по расписанию, без человека, холодный кэш. Ровно те условия, ради которых всё делалось:
| Шаг | Обычно (14 дней) | Ручной (тёплый кэш) | Ночной (холодный кэш) |
|---|---|---|---|
| Запись регистра истории | 3,9-4,2 мин | 0,4 мин (10x) | 4 с (около 60x) |
| Расчёт автоцен | 5,1-11,7 мин | 2,7 мин | 1,0 мин |
| Полный прогон | 55-100 мин (сред. ~73) | 35,8 мин | 46,7 мин |
Полный ночной прогон (46,7 мин) длиннее ручного (35,8): холодный кэш и другой объём данных за сутки. Отдельные шаги при этом ускорились; смотреть надо по шагам, а не по общему времени.
Оба прогона: 0 ошибок, 0 откатов на резервный путь. Регистр истории за ночь: 8 422 записи, 8 251 номенклатура, дублей ключей 0. На холодном кэше эффект по записи регистра оказался даже сильнее лабораторного прогноза (19,68x): лаборатория считала на отдельной копии, а прод-сервер мощнее и ночью почти без конкурентной нагрузки.
Что это дало по сути: ночное окно разгрузилось, и риск не уложиться в него и получить утром сломанные цены ушёл. Подтверждение - журнал реального ночного прогона, где тяжёлый шаг занял 4 секунды.
Как найти то же самое у себя
Если есть ночной регламент, который «пока укладывается», проверьте его по четырём шагам:
- Читайте журнал регламента по шагам. Общее время прячет, где именно потери. Нужна разбивка: какой шаг сколько занимает. Часто основное время висит на одном месте, о котором никто не думал.
- Смотрите логические чтения тяжёлых шагов (план запроса, статистика ввода-вывода, технологический журнал). «На выходе триста строк, а чтений полмиллиона» - верный признак виртуальной таблицы без отбора по нужному измерению.
- Грепните модуль по антипаттернам:
.Записать()внутри цикла, реквизиты «через точку» в циклах,Массив.Найти(при накоплении уникальных, чтение настроек внутри цикла по строкам. - Любую правку меряйте числом «было / стало» и сверяйте результат контрольной суммой или отпечатком. Без этого «оптимизация» ничем не отличается от угадывания.
Чаще всего большую часть окна съедает один шаг, и обычно это либо срез виртуальной таблицы без нужного отбора, либо запись в цикле. С них и стоит начинать: здесь эти два места дали основной выигрыш, остальные три правки добрали по мелочи, хотя по кратности выглядят эффектно.
Антипаттерны тут универсальные, цифры у вас будут свои. Если у вас похожий регламент - интересно, где утекает время у вас.
Вступайте в нашу телеграмм-группу Инфостарт