Приветствуем вас, коллеги! Сегодня мы детально разберем один из наиболее глубоких и неочевидных архитектурных нюансов платформы «1С:Предприятие 8». Этот нюанс напрямую связан с конкуренцией транзакций, блокировками на уровне СУБД и контролем отрицательных остатков при использовании так называемой «новой методики проведения».
Многие разработчики, особенно при подготовке к экзаменам семейства «1С:Специалист», привыкают использовать объект МоментВремени при получении остатков из виртуальных таблиц регистров накопления. Однако перенос этой практики в реальные высоконагруженные информационные базы (продакшен) при оперативном проведении документов может привести к неожиданному и крайне неприятному эффекту — физическому уходу складских остатков в минус при параллельной работе пользователей. Давайте подробно выясним причину этого поведения, проанализируем физику процессов СУБД и найдем единственно верное архитектурное решение.
Начнем наше погружение с детального разбора того, как именно возникает эта проблема.
Представим стандартную бизнес-ситуацию. На складе «А» числится ровно 1 штука «Товара 1». Два оператора одновременно оформляют продажу этого товара. В этот момент запускаются параллельные транзакции проведения двух документов «Расходная накладная».
Давайте по шагам проанализируем хронологию событий на оси времени и в СУБД:
ОбработкаПроведения. К этому моменту документы уже записаны в базу данных, они получили свои уникальные моменты времени на временной оси.Накладная 1 хронологически находится на оси времени раньше, чем Накладная 2. То есть выполняется условие: МоментВремени(Накладная 1) < МоментВремени(Накладная 2).Накладная 2) доходит до записи движений и установки управляемых блокировок быстрее, чем в первом сеансе (Накладная 1).Накладная 2 успешно записывает свое движение (расход 1 шт.) и устанавливает блокировку на остатки по своей номенклатуре.Накладной 2. Так как движение Накладной 1 еще физически не записано в базу данных (транзакция первого документа еще не дошла до записи движений), система видит, что на складе есть 1 шт. товара. Накладная 2 успешно списывает этот товар и завершает свою транзакцию проведения.Накладная 1) «просыпается» и пытается записать свои движения. Из-за установленной ранее управляемой блокировки вторым сеансом, первая транзакция кратковременно ожидает освобождения ресурса. Как только транзакция Накладной 2 фиксируется (commit), блокировка снимается, и Накладная 1 продолжает выполнение.Накладной 1 мы передаем параметр МоментВремени() самого документа Накладная 1, платформа формирует запрос к СУБД с ограничением по периоду.Давайте проанализируем, как СУБД рассчитывает остатки на МоментВремени() для более раннего документа (Накладная 1), если более поздний документ (Накладная 2) уже записал свои движения:
СУБД не может просто прочитать готовые данные из таблицы актуальных итогов, поскольку на актуальный («конец времен») момент товар уже списан Накладной 2. Чтобы рассчитать остаток на момент времени Накладной 1, СУБД берет актуальный итог и «отматывает» его назад. Но на момент времени Накладной 1 движения Накладной 2 еще не существовали! С точки зрения временной шкалы, движение Накладной 2 находится после Накладной 1. Поэтому СУБД игнорирует движение второго документа при расчете остатка для первого.
В результате запрос для Накладной 1 возвращает остаток в количестве 1 шт. Документ успешно проводится. Мы получаем ситуацию, когда два документа списали по 1 штуке товара при фактическом наличии всего 1 штуки на складе. В актуальных итогах регистра накопления образуется физический отрицательный остаток (-1 шт.), который система успешно пропустила!
Давайте разберем, почему возникает путаница среди разработчиков. На аттестации «1С:Специалист по платформе» преподаватели часто требуют жестко указывать МоментВремени при расчете остатков, мотивируя это защитой от документов, введенных будущим числом.
Рассмотрим эту проблему подробнее:
Если в системе разрешено проведение документов «будущим числом» (например, кто-то программно или неоперативно ввел приходную или расходную накладную на завтрашний день), то при проверке остатков без указания периода (на «конец времен») система учтет эти будущие движения. То есть, если сегодня у нас лежит 1 штука товара на складе, но на завтра уже запланирован расход этой штуки, то запрос актуальных остатков (без периода) вернет 0. Система не даст нам продать товар сегодня, хотя физически он лежит на складе. На экзамене это считается грубой ошибкой (false-negative контроль).
Однако в реальном продакшене ситуация принципиально иная:
ПередЗаписью.Следовательно, использование МоментВремени при оперативном проведении в реальной рабочей базе данных гарантированно приведет к появлению минусов и к колоссальным проблемам с производительностью.
Помимо логической ошибки с появлением отрицательных остатков, использование МоментВремени наносит серьезный удар по производительности СУБД:
1. Обращение к таблице актуальных итогов. Когда мы не указываем период в виртуальной таблице остатков (передаем значение Неопределено), платформа «1С:Предприятие» обращается напрямую к таблице актуальных итогов регистра (таблица _AccumRegTotals). СУБД выполняет простейший и максимально быстрый запрос SELECT по индексу без каких-либо вычислений.
Если же мы передаем конкретную дату или МоментВремени, СУБД вынуждена рассчитывать остаток на эту точку, соединяя таблицу итогов с таблицей движений (_AccumReg) и выполняя математические операции вычитания/сложения записей, которые находятся во времени позже нашего документа. Это резко увеличивает время выполнения запроса и нагрузку на дисковую подсистему.
2. Эскалация блокировок. При расчете остатков на момент времени СУБД накладывает разделяемые блокировки (S-блокировки) на диапазоны строк таблицы движений регистра накопления. При высокой интенсивности записи это мгновенно приводит к эскалации блокировок до уровня всей таблицы, лавинообразному росту взаимоблокировок (Deadlocks) и ошибкам таймаута ожидания ресурсов пользователями.
3. Механизм разделения итогов. В современных конфигурациях для регистров с высокой частотой записи включают свойство «Разрешить разделение итогов». Этот механизм позволяет параллельно записывать движения, распределяя их по специальным скрытым разделителям, что минимизирует блокировки. Однако эффективная работа разделения итогов возможна только тогда, когда остатки получаются на «конец времен» (без указания периода). Запрос остатков на конкретный момент времени фактически сводит на нет всю пользу этого механизма.
Для того чтобы решить проблему ухода остатков в минус и обеспечить максимальный параллелизм работы системы, нам необходимо разделить логику получения остатков в зависимости от режима проведения документа (оперативный или неоперативный).
Рассмотрим правильный шаблон кода в обработчике проведения документа. Для этого проанализируем следующий пример:
Процедура ОбработкаПроведения(Отказ, РежимПроведения)
// 1. Подготавливаем движения документа к записи
Движения.ТоварыНаСкладах.Записывать = Истина;
// Очищаем старые движения, если документ перепроводится
Движения.ТоварыНаСкладах.Очистить();
// Заполняем новые движения на основании табличной части документа
Для Каждого СтрокаТЧ Из Товары Цикл
Движение = Движения.ТоварыНаСкладах.ДобавитьРасход();
Движение.Период = Дата;
Движение.Номенклатура = СтрокаТЧ.Номенклатура;
Движение.Склад = Склад;
Движение.Количество = СтрокаТЧ.Количество;
КонецЦикла;
// 2. Записываем новые движения в базу данных, чтобы СУБД учла их при контроле остатков
Движения.Записать();
// 3. Определяем период для получения остатков
// Если проведение оперативное - период НЕ указываем (берем актуальные текущие остатки)
// Если проведение неоперативное (задним числом) - берем остатки на МоментВремени документа
ПараметрПериод = ?(РежимПроведения = РежимПроведенияДокумента.Оперативный, Неопределено, МоментВремени());
// 4. Настраиваем транзакционную блокировку для контроля остатков
Блокировка = Новый БлокировкаДанных;
ЭлементБлокировки = Блокировка.Добавить("РегистрНакопления.ТоварыНаСкладах");
ЭлементБлокировки.УстановитьЗначение("Склад", Склад);
ЭлементБлокировки.ИсточникДанных = Товары;
ЭлементБлокировки.ИспользоватьИзИсточникаДанных("Номенклатура", "Номенклатура");
Блокировка.Заблокировать();
// 5. Выполняем запрос для проверки остатков на предмет появления минусов
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| ТоварыНаСкладахОстатки.Номенклатура КАК Номенклатура,
| ТоварыНаСкладахОстатки.КоличествоОстаток КАК КоличествоОстаток
|ИЗ
| РегистрНакопления.ТоварыНаСкладах.Остатки(&Период, Склад = &Склад И Номенклатура В (&СписокНоменклатуры)) КАК ТоварыНаСкладахОстатки
|ГДЕ
| ТоварыНаСкладахОстатки.КоличествоОстаток < 0";
Запрос.УстановитьПараметр("Период", ПараметрПериод);
Запрос.УстановитьПараметр("Склад", Склад);
Запрос.УстановитьПараметр("СписокНоменклатуры", Товары.ВыгрузитьКолонку("Номенклатура"));
РезультатЗапроса = Запрос.Выполнить();
Если НЕ РезультатЗапроса.Пустой() Тогда
Отказ = Истина;
Выборка = РезультатЗапроса.Выбрать();
Пока Выборка.Следующий() Цикл
Сообщение = Новый СообщениеПользователю;
Сообщение.Текст = "Недостаточно товара " + Выборка.Номенклатура + ". Дефицит: " + (-Выборка.КоличествоОстаток) + " шт.";
Сообщение.Сообщить();
КонецЦикла;
КонецЕсли;
КонецПроцедуры
Давайте подробно проанализируем этот алгоритм:
ПараметрПериод = ?(РежимПроведения = РежимПроведенияДокумента.Оперативный, Неопределено, МоментВремени()); является ключевой. При оперативном проведении мы гарантированно обращаемся к таблице актуальных итогов без указания периода (Неопределено). Это исключает «эффект гонки» и появление отрицательных остатков на конец времен.МоментВремени(), так как нам важно знать состояние склада именно на ту историческую секунду, в которую записывается документ, чтобы не нарушить хронологию прошлых периодов.Обратите внимание на то, как концепция контроля остатков реализована в современных типовых решениях фирмы «1С» (например, в ERP 2.5, Комплексной автоматизации 2.5 или Управлении торговлей 11):
Разделение оперативного контроля и партионного учета. В современных архитектурах 1С оперативный контроль остатков на складах всегда происходит по актуальным текущим остаткам (то есть без указания периода, на конец времен). Система не пытается рассчитывать себестоимость и списывать партии непосредственно в транзакции оперативного проведения документа.
Списание партий, расчет себестоимости и распределение расходов вынесены в отдельные фоновые задания (оффлайн-расчет себестоимости) или выполняются регламентно в конце отчетного периода. Это позволяет разгрузить транзакцию проведения документов и обеспечить колоссальный параллелизм работы пользователей без взаимных блокировок и без риска получить отрицательные остатки товаров на складах.
Подводя итог нашему подробному анализу, сформулируем основные правила проектирования транзакций проведения:
МоментВремени или Дата в качестве параметра периода виртуальных таблиц остатков. Всегда используйте значение Неопределено. Это убережет вашу базу от появления физических «минусов» и обеспечит максимальную производительность СУБД.ПередЗаписью (например, сравнением даты документа с текущей датой компьютера/сервера с допустимым люфтом в несколько минут). Не перекладывайте эту проверку на плечи запросов контроля остатков.МоментВремени(). В высоконагруженных системах неоперативное проведение должно выполняться в периоды минимальной активности пользователей, либо контроль должен осуществляться отложенно через механизмы последовательностей или регламентных заданий.Используйте этот подход в своей повседневной практике разработки, проектируйте надежные и быстрые системы, и пусть ваши транзакции всегда завершаются успешно!