Каждый разработчик 1С рано или поздно сталкивается с ситуацией, когда при выполнении отчета или обработке данных система аварийно завершает работу с ошибкой «Недостаточно памяти для выполнения запроса». Эта проблема может возникать как в файловом варианте базы данных, так и в клиент-серверном режиме работы на СУБД MS SQL Server, PostgreSQL или Oracle.
Многие ошибочно полагают, что корень проблемы кроется в разрядности операционной системы или сервера 1С. Действительно, 32-битные рабочие процессы имеют жесткое ограничение на использование адресного пространства (до 2–4 ГБ). Однако перевод системы на 64-битную платформу в данном случае — лишь временная «таблетка», которая замаскирует симптомы, но не вылечит саму «болезнь». Истинной причиной ошибки практически всегда является некорректно написанный запрос, создающий колоссальную нагрузку на СУБД и оперативную память.
В этой статье мы подробно разберем, почему возникает данная ошибка, проанализируем критические ошибки в структуре сложных запросов и построим правильный, быстрый и безопасный алгоритм получения данных с использованием временных таблиц.
---
Давайте проанализируем, что происходит на уровне СУБД, когда мы выполняем тяжелый запрос с множественными левыми соединениями. Основной источник проблемы — это так называемое декартово произведение (или неконтролируемое размножение строк в результате соединений).
Рассмотрим структуру исходного проблемного запроса:
ВЫБРАТЬ
ТоварыНаСкладахОстаткиИОбороты.Номенклатура,
...
ИЗ
РегистрНакопления.ТоварыНаСкладах.ОстаткиИОбороты(..., Регистратор) КАК ТоварыНаСкладахОстаткиИОбороты
ЛЕВОЕ СОЕДИНЕНИЕ
Документ.ПеремещениеТоваров.Товары КАК ПеремещениеТоваровТовары
ПО ТоварыНаСкладахОстаткиИОбороты.Номенклатура = ПеремещениеТоваровТовары.Номенклатура
ЛЕВОЕ СОЕДИНЕНИЕ
Документ.ПоступлениеТоваровУслуг.Товары КАК ПоступлениеТоваровУслугТовары
ПО ТоварыНаСкладахОстаткиИОбороты.Номенклатура = ПоступлениеТоваровУслугТовары.Номенклатура
В этом запросе программист соединяет виртуальную таблицу остатков и оборотов с табличными частями двух очень крупных документов: ПеремещениеТоваров.Товары и ПоступлениеТоваровУслуг.Товары. Связь идет по единственному полю — Номенклатура.
Представим реальную ситуацию в базе данных:
ПеремещениеТоваров этот же товар фигурирует 100 раз за все время существования базы.ПоступлениеТоваровУслуг этот товар встречается еще 200 раз.Когда СУБД начинает выполнять ЛЕВОЕ СОЕДИНЕНИЕ без предварительной группировки, она физически сопоставляет каждую строку из левой таблицы с каждой строкой из правой таблицы. Количество результирующих строк для одного только этого товара рассчитывается как произведение записей: 10 * 100 * 200 = 200 000 строк!
А если в базе данных десятки тысяч наименований товаров? Итоговая выборка моментально разрастается до сотен миллионов строк. СУБД пытается переварить этот колоссальный объем данных, задействует временную базу данных (например, tempdb в MS SQL Server), активно сбрасывает кэш на диск, но при попытке передать этот гигантский массив в платформу 1С оперативная память процесса (особенно 32-битного) мгновенно переполняется. Как результат — аварийное завершение работы.
---
Пытаясь исправить ошибку памяти, разработчики часто прибегают к промежуточным временным таблицам, но совершают при этом грубейшую логическую ошибку, которая приводит к выдаче абсолютно неверных результатов («мусора» в отчете). Рассмотрим схему, предложенную автором в Сообщении 52:
// Шаг 1: Получаем остатки и обороты (сворачиваем по номенклатуре)
ВЫБРАТЬ
ТоварыНаСкладахОстаткиИОбороты.Номенклатура,
СУММА(ТоварыНаСкладахОстаткиИОбороты.КоличествоНачальныйОстаток) КАК КоличествоНачальныйОстаток
ПОМЕСТИТЬ Таб1
ИЗ
РегистрНакопления.ТоварыНаСкладах.ОстаткиИОбороты КАК ТоварыНаСкладахОстаткиИОбороты
СГРУППИРОВАТЬ ПО
ТоварыНаСкладахОстаткиИОбороты.Номенклатура;
// Шаг 2: Присоединяем табличную часть перемещений и снова группируем
ВЫБРАТЬ
Таб1.Номенклатура,
СУММА(ПеремещениеТоваровТовары.Количество) КАК Количество,
СУММА(Таб1.КоличествоНачальныйОстаток) КАК КоличествоНачальныйОстаток
ПОМЕСТИТЬ Таб2
ИЗ
Таб1 КАК Таб1
ЛЕВОЕ СОЕДИНЕНИЕ Документ.ПеремещениеТоваров.Товары КАК ПеремещениеТоваровТовары
ПО Таб1.Номенклатура = ПеремещениеТоваровТовары.Номенклатура
СГРУППИРОВАТЬ ПО
Таб1.Номенклатура;
В чем заключается смертельная опасность такого подхода? Обратите внимание на поле СУММА(Таб1.КоличествоНачальныйОстаток) на Шаге 2.
Допустим, начальный остаток товара в таблице Таб1 равен 10 штукам (одна строка). В табличной части перемещений этот товар встречается 5 раз (5 строк по 2 штуки). При левом соединении строка из Таб1 продублируется 5 раз. В результате в промежуточной выборке мы получим 5 строк, в каждой из которых значение поля КоличествоНачальныйОстаток будет равно 10.
Когда СУБД выполнит операцию СГРУППИРОВАТЬ ПО Таб1.Номенклатура и вычислит сумму начального остатка, она сложит эти десятки: 10 + 10 + 10 + 10 + 10 = 50 штук!
Начальный остаток товара исказился и увеличился ровно в 5 раз! А если на следующем шаге мы точно так же присоединим поступления товаров, то все предыдущие показатели (уже искаженный остаток и количество перемещений) умножатся еще и на количество строк из поступлений. Итоговый отчет покажет заоблачные цифры, не имеющие никакого отношения к реальности.
Золотое правило разработки запросов в 1С:
Никогда не соединяйте таблицы, содержащие агрегируемые числовые показатели (остатки, обороты, суммы), с таблицами связей «один-ко-многим» (табличные части документов, детальные регистры) без предварительного сворачивания (группировки) правой таблицы до уникального ключа связи! В правой таблице на одну строку левой таблицы должна приходиться строго одна (или ноль) строк.
---
Использование конструкций вида Документ.ПоступлениеТоваровУслуг.Товары напрямую в тяжелых отчетах крайне нежелательно по трем основным причинам:
Номенклатура. Любое левое соединение с такой таблицей по товару заставляет СУБД выполнять операцию Table Scan (сканирование всей таблицы на диске), что парализует производительность базы данных.ГДЕ Ссылка.Проведен = ИСТИНА, что еще сильнее замедлило бы выполнение из-за обращения через точку к реквизитам шапки документа.РегистрНакопления.Закупки.Обороты или РегистрНакопления.ТоварыНаСкладах.Обороты), а не пытаться собирать их напрямую из первичных документов. Регистры накопления изначально оптимизированы, проиндексированы и содержат только достоверные, проведенные данные.---
Часто в отчетах требуется вывести актуальную (последнюю по времени) закупочную или розничную цену для каждой позиции номенклатуры. Прямое соединение с регистром сведений без ограничения по дате или использование агрегатных функций вроде МИНИМУМ(Цена) или МАКСИМУМ(Цена) — в корне неверный путь. Минимальная цена — это не последняя цена, а просто самая дешевая закупка в истории.
Рассмотрим классический, максимально производительный и методически верный шаблон получения последней цены закупки из документов поступления за определенный период с помощью двух последовательных временных таблиц:
// Шаг 1: Находим максимальный момент времени поступления для каждого товара
ВЫБРАТЬ
ПоступлениеТовары.Номенклатура КАК Номенклатура,
МАКСИМУМ(ПоступлениеТовары.Ссылка.МоментВремени) КАК МаксМоментВремени
ПОМЕСТИТЬ втМаксДатыПоступлений
ИЗ
Документ.ПоступлениеТоваровУслуг.Товары КАК ПоступлениеТовары
ГДЕ
ПоступлениеТовары.Ссылка.Проведен = ИСТИНА
И ПоступлениеТовары.Ссылка.Дата МЕЖДУ &НачалоПериода И &КонецПериода
СГРУППИРОВАТЬ ПО
ПоступлениеТовары.Номенклатура
ИНДЕКСИРОВАТЬ ПО
Номенклатура,
МаксМоментВремени
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 2: Получаем цену, соответствующую найденному моменту времени
ВЫБРАТЬ
ПоступлениеТовары.Номенклатура КАК Номенклатура,
МАКСИМУМ(ПоступлениеТовары.Цена) КАК ПоследняяЦенаЗакупки
ПОМЕСТИТЬ втПоследниеЦеныЗакупки
ИЗ
втМаксДатыПоступлений КАК втМаксДаты
ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.ПоступлениеТоваровУслуг.Товары КАК ПоступлениеТовары
ПО втМаксДаты.Номенклатура = ПоступлениеТовары.Номенклатура
И втМаксДаты.МаксМоментВремени = ПоступлениеТовары.Ссылка.МоментВремени
СГРУППИРОВАТЬ ПО
ПоступлениеТовары.Номенклатура
ИНДЕКСИРОВАТЬ ПО
Номенклатура
;
Использование предложения ИНДЕКСИРОВАТЬ ПО гарантирует, что последующие соединения с этими временными таблицами в СУБД пройдут мгновенно по индексным путям (Index Seek), минуя тяжелое сканирование памяти.
---
Проанализируем еще одно важное различие в поведении виртуальных таблиц. Обратите внимание на два варианта обращения к регистру накопления:
Вариант 1 (высокая нагрузка, детализация до документа):
РегистрНакопления.ТоварыНаСкладах.ОстаткиИОбороты(&Начало, &Конец, Регистратор, , )
Вариант 2 (высокая производительность, агрегированные данные):
РегистрНакопления.ТоварыНаСкладах.ОстаткиИОбороты(&Начало, &Конец, , , )
Если в третьем параметре виртуальной таблицы указана периодичность Регистратор, СУБД отключает механизмы использования заранее рассчитанных итогов регистра. Вместо этого база данных начинает «на лету» рассчитывать остатки на момент времени каждого проведенного документа за указанный период. Количество строк на выходе увеличивается в сотни раз.
Если в итоговом отчете вам не нужно выводить детальные записи с указанием конкретного документа-регистратора, использование периодичности Регистратор категорически запрещено! Всегда используйте агрегированные таблицы без этого параметра.
---
Давайте соберем воедино все правила оптимизации и перепишем исходный запрос автора с нуля. Мы применим пакетный подход, разобьем задачу на простые шаги, задействуем индексируемые временные таблицы и полностью исключим дублирование строк.
Рассмотрим структуру правильного запроса шаг за шагом:
ВЫБРАТЬ
ТоварыНаСкладахОстаткиИОбороты.Номенклатура КАК Номенклатура,
СУММА(ТоварыНаСкладахОстаткиИОбороты.КоличествоНачальныйОстаток) КАК КоличествоНачальныйОстаток,
СУММА(ТоварыНаСкладахОстаткиИОбороты.КоличествоКонечныйОстаток) КАК КоличествоКонечныйОстаток,
СУММА(ТоварыНаСкладахОстаткиИОбороты.КоличествоПриход) КАК КоличествоПриход,
СУММА(ТоварыНаСкладахОстаткиИОбороты.КоличествоРасход) КАК КоличествоРасход
ПОМЕСТИТЬ втОстаткиИОбороты
ИЗ
РегистрНакопления.ТоварыНаСкладах.ОстаткиИОбороты(
НАЧАЛОПЕРИОДА(&НачалоПериода, ДЕНЬ),
КОНЕЦПЕРИОДА(&КонецПериода, ДЕНЬ),
,
,
) КАК ТоварыНаСкладахОстаткиИОбороты
СГРУППИРОВАТЬ ПО
ТоварыНаСкладахОстаткиИОбороты.Номенклатура
ИНДЕКСИРОВАТЬ ПО
Номенклатура
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 2: Получаем объемы перемещений за период, сгруппированные по номенклатуре.
// Вместо обращения к ТЧ документа используем виртуальную таблицу оборотов регистра
ВЫБРАТЬ
ТоварыНаСкладахОбороты.Номенклатура КАК Номенклатура,
СУММА(ТоварыНаСкладахОбороты.КоличествоОборот) КАК КоличествоПеремещения
ПОМЕСТИТЬ втПеремещения
ИЗ
РегистрНакопления.ТоварыНаСкладах.Обороты(
НАЧАЛОПЕРИОДА(&НачалоПериода, ДЕНЬ),
КОНЕЦПЕРИОДА(&КонецПериода, ДЕНЬ),
,
Номенклатура.Ссылка В (&СписокНоменклатуры)
ИЛИ &БезОтбораНоменклатуры
И Регистратор ССЫЛКА Документ.ПеремещениеТоваров) КАК ТоварыНаСкладахОбороты
СГРУППИРОВАТЬ ПО
ТоварыНаСкладахОбороты.Номенклатура
ИНДЕКСИРОВАТЬ ПО
Номенклатура
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 3: Получаем базовые цены на конец периода
ВЫБРАТЬ
ЦеныНоменклатурыСрезПоследних.Номенклатура КАК Номенклатура,
ЦеныНоменклатурыСрезПоследних.Цена КАК БазоваяЦена
ПОМЕСТИТЬ втБазовыеЦены
ИЗ
РегистрСведений.ЦеныНоменклатуры.СрезПоследних(
КОНЕЦПЕРИОДА(&КонецПериода, ДЕНЬ),
ТипЦен = ЗНАЧЕНИЕ(Справочник.ТипыЦенНоменклатуры.БазовыеЦены)) КАК ЦеныНоменклатурыСрезПоследних
ИНДЕКСИРОВАТЬ ПО
Номенклатура
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 4: Получаем последние цены закупки (используем ранее описанный шаблон)
ВЫБРАТЬ
ПоступлениеТовары.Номенклатура КАК Номенклатура,
МАКСИМУМ(ПоступлениеТовары.Ссылка.МоментВремени) КАК МаксМоментВремени
ПОМЕСТИТЬ втМаксДатыПоступлений
ИЗ
Документ.ПоступлениеТоваровУслуг.Товары КАК ПоступлениеТовары
ГДЕ
ПоступлениеТовары.Ссылка.Проведен = ИСТИНА
И ПоступлениеТовары.Ссылка.Дата МЕЖДУ &НачалоПериода И &КонецПериода
СГРУППИРОВАТЬ ПО
ПоступлениеТовары.Номенклатура
ИНДЕКСИРОВАТЬ ПО
Номенклатура,
МаксМоментВремени
;
////////////////////////////////////////////////////////////////////////////////
ВЫБРАТЬ
ПоступлениеТовары.Номенклатура КАК Номенклатура,
МАКСИМУМ(ПоступлениеТовары.Price) КАК ЗакупочнаяЦена
ПОМЕСТИТЬ втЦеныЗакупки
ИЗ
втМаксДатыПоступлений КАК втМаксДаты
ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.ПоступлениеТоваровУслуг.Товары КАК ПоступлениеТовары
ПО втМаксДаты.Номенклатура = ПоступлениеТовары.Номенклатура
И втМаксДаты.МаксМоментВремени = ПоступлениеТовары.Ссылка.МоментВремени
СГРУППИРОВАТЬ ПО
ПоступлениеТовары.Номенклатура
ИНДЕКСИРОВАТЬ ПО
Номенклатура
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 5: Финальное левое соединение строго структурированных и проиндексированных таблиц
ВЫБРАТЬ
Остатки.Номенклатура.Код КАК Код,
Остатки.Номенклатура.Артикул КАК Артикул,
Остатки.Номенклатура.Производитель КАК Производитель,
Остатки.Номенклатура.Родитель КАК Родитель,
Остатки.Номенклатура КАК Номенклатура,
Остатки.КоличествоПриход КАК КоличествоПриход,
Остатки.КоличествоРасход КАК КоличествоРасход,
Остатки.КоличествоНачальныйОстаток КАК КоличествоНачальныйОстаток,
Остатки.КоличествоКонечныйОстаток КАК КоличествоКонечныйОстаток,
ЕСТЬNULL(БазовыеЦены.БазоваяЦена, 0) КАК БазоваяЦена,
ЕСТЬNULL(Перемещения.КоличествоПеремещения, 0) КАК КоличествоПеремещения,
ЕСТЬNULL(ПараметрыНоменклатуры.МинимальноеКратноеКоличество, 0) КАК ТОП,
ЕСТЬNULL(ЦеныЗакупки.ЗакупочнаяЦена, 0) КАК ЗакупочнаяЦена
ИЗ
втОстаткиИОбороты КАК Остатки
ЛЕВОЕ СОЕДИНЕНИЕ втБазовыеЦены КАК БазовыеЦены
ПО Остатки.Номенклатура = БазовыеЦены.Номенклатура
ЛЕВОЕ СОЕДИНЕНИЕ втПеремещения КАК Перемещения
ПО Остатки.Номенклатура = Перемещения.Номенклатура
ЛЕВОЕ СОЕДИНЕНИЕ РегистрСведений.ПараметрыНоменклатуры КАК ПараметрыНоменклатуры
ПО Остатки.Номенклатура = ПараметрыНоменклатуры.Номенклатура
ЛЕВОЕ СОЕДИНЕНИЕ втЦеныЗакупки КАК ЦеныЗакупки
ПО Остатки.Номенклатура = ЦеныЗакупки.Номенклатура
---
Применение пакетных запросов дает колоссальное преимущество, но требует соблюдения нескольких важных правил:
ИНДЕКСИРОВАТЬ ПО... СУБД не сможет быстро сопоставить строки при финальном соединении. Индексируйте те поля, по которым в дальнейшем будет происходить связь (в нашем случае это Номенклатура).Остатки.Номенклатура.Артикул). СУБД прекрасно оптимизирует такие обращения.УНИЧТОЖИТЬ втИмяТаблицы. На самом деле СУБД автоматически очищает все временные таблицы по завершении сеанса выполнения запроса (при закрытии менеджера временных таблиц). Явное уничтожение целесообразно использовать только в циклических алгоритмах или при экстремально больших объемах данных, когда важно освободить место в системной базе tempdb прямо по ходу выполнения пакета, не дожидаясь финала.Соблюдение этих простых принципов позволит вам навсегда забыть об ошибке переполнения памяти, а ваши отчеты будут формироваться за считанные секунды даже на базах данных объемом в несколько терабайт.