Разбираем только пару последовательных чтений двух разных регистров накопления внутри одной транзакции записи документа «Перераспределение зон отбора» без захвата между запросами. Не рассматриваем полный цикл отмены проведения, обмен через OData и сценарии с одним регистром: для них другие точки отказа и другой порядок работ СУБД.
1. Два среза и симптом на продуктиве
1.1. Условия документа и регистров
Документ переносит количество между зонами отбора на одном складе «Центральный-2» и одновременно уменьшает резерв под заказ клиента «ЗК-041882». Перед записью движений серверный модуль выполняет два запроса: первый читает остаток по измерениям «Склад, ЗонаОтбора, Номенклатура» в регистре «СвободноВЗонахОтбора», второй - резерв по «Склад, ЗаказКлиента, Номенклатура» в «РезервПодКлиента». Решение о допустимости переноса принимается по разности этих чисел. На тестовом контуре с одной активной сессией ошибка не воспроизводится.
1.2. Что фиксируем как дефект
Дефект проявляется как проведённый документ с отрицательным свободным остатком в зоне «Б-14», хотя контроль в коде сравнил два положительных значения. Если параллельная сессия между запросами успела изменить только один из регистров, пара чисел перестаёт описывать один момент времени. Это не «ошибка запроса» и не округление: это следствие модели изоляции по умолчанию и отсутствия общего захвата до второго чтения.
Распространённое мнение, что «оба запроса в одной транзакции на сервере уже согласованы», смешивает границу вызова 1С и контракт СУБД. Транзакция на сервере приложения длиннее одного SQL-оператора, но не обязывает СУБД удерживать снимок между двумя SELECT, пока прикладной код явно не выбрал уровень изоляции и не оформил захват данных (объект «БлокировкаДанных») по всем ключам, которые участвуют в обоих чтениях.
2. Пошаговая трассировка на сервере
2.1. Сессия A: открытие транзакции и первый запрос
Шаг 1. Клиент вызывает запись документа; сервер 1С открывает транзакцию с уровнем изоляции, заданным для прикладной операции (часто ReadCommitted без удержания версии строк между чтениями).
Шаг 2. Выполняется первый запрос к «СвободноВЗонахОтбора» для номенклатуры «Кабель-UTP-305» и зоны «Б-14»; СУБД возвращает, например, 120 упаковок.
Шаг 3. Автоматические блокировки, которые СУБД берёт на время изменения строк, здесь ещё не задействованы: чтение не оставляет прикладного захвата, если код не вызывал «БлокировкаДанных.Заблокировать()».
Шаг 4. Планировщик на сервере 1С может между шагами 2 и следующим запросом выполнить другую фоновую работу того же сеанса, но это не закрывает окно для чужой сессии: параллельный сеанс B видит те же строки регистра и может их изменить.
Шаг 5. Параллельная сессия B в той же секунде записывает «Отгрузку по заказу» для «ЗК-041882»: в одной транзакции она уменьшает «РезервПодКлиента» на 40 упаковок и списывает «СвободноВЗонахОтбора» в зоне «Б-14» на те же 40.
Шаг 6. СУБД фиксирует транзакцию B; для последующих чтений свободный остаток в зоне уже 80, резерв по заказу уменьшен.
Шаг 7. Сессия A ещё не читала резерв и опирается на устаревшее первое число 120.
Шаг 8. Второй запрос A к «РезервПодКлиента» возвращает актуальный после B резерв, например 10 вместо 50. Если контроль вычитает резерв из свободного остатка, он оперирует парой (120; 10), тогда как бизнес-смысл «сколько можно перенести, не ломая отгрузку» после шага 6 ближе к (80; 10).
Шаг 9. Модуль считает перенос допустимым и формирует набор записей по обоим регистрам; на этом этапе включаются автоматические блокировки на изменяемые агрегаты.
Шаг 10. Если суммарное списание из зоны «Б-14» вместе с переносом превышает фактические 80 упаковок, отрицательный остаток может проявиться сразу при записи набора или только в отчёте «Срез зон», в зависимости от того, где включён контроль отрицательных остатков.
Шаг 11. В журнале регистрации часто нет исключения на шаге 8: прикладная проверка прошла на логически несовместимой паре.
Шаг 12. Если между первым и вторым запросом вставить искусственную задержку на стенде или дополнительный серверный вызов без смены изоляции, окно для сессии B только расширяется, а не исчезает.
2.2. Запись движений и режим RepeatableRead
Шаг 1'. Перед первым запросом для текущей транзакции задаётся RepeatableRead, затем оформляется захват данных по складу и номенклатуре в каноническом порядке: сначала набор «СвободноВЗонахОтбора», затем «РезервПодКлиента». Шаг 2'. Первое чтение возвращает те же 120 упаковок, но СУБД обязана удерживать прочитанные версии до конца транзакции A. Шаг 3'. Сессия B при попытке изменить пересекающиеся ключи переходит в ожидание захвата; если истекает таймаут ожидания, заданный для сеанса (например, несколько десятков секунд на тестовом стенде), пользователь B получает ошибку, а A может завершиться без «тихой» порчи остатка. Шаг 4'. Второе чтение A возвращает резерв, согласованный с моментом начала транзакции A, и пара чисел снова пригодна для арифметического контроля. Шаг 5'. Цена - более длинные очереди на сезонном пике и жёсткое требование: все документы, которые меняют те же измерения, должны захватывать регистры в одном порядке, иначе согласованность внутри A не отменяет взаимного ожидания с B.
3. Режимы изоляции и захват между чтениями
3.1. ReadCommitted без повторяемости
Если между двумя SELECT нет общего захвата, каждый запрос видит зафиксированные на момент своего выполнения данные. Для задачи «две величины должны соответствовать одной бизнес-точке» этого недостаточно, даже когда оба запроса в одной процедуре на сервере. Дополнительный вызов клиент-сервер между чтениями удлиняет окно, но корневая причина не в сети, а в отсутствии единого контура согласованности.
Если разработчик ставит захват только после первого чтения, то второй запрос уже защищён от изменений затронутых ключей, но первое число могло устареть до захвата. Такой порядок иногда оправдан, когда первый запрос лишь фильтрует кандидатов, а решение принимается по узкому набору строк, однако для арифметики «свободно минус резерв» мы считаем его слабее, чем захват до любого чтения.
3.2. RepeatableRead и Serializable в прикладном коде
RepeatableRead удерживает прочитанные версии строк до конца транзакции и снижает риск «разъехавшейся» пары при двух регистрах, если захват покрывает все ключи, участвующие в обоих запросах. Serializable сужает класс аномалий сильнее, но на регистрах накопления под нагрузкой чаще даёт отказы по таймауту ожидания. Выбор между ними для документа зон отбора мы связываем с частотой параллельных отгрузок по тому же заказу, а не с формальной «максимальной строгостью».
Таймаут ожидания захвата и длительность транзакции связаны, но не совпадают: пользователь может получить отказ по таймауту на «БлокировкаДанных», хотя общее время транзакции ещё мало, если конкурирующая сессия держит пересекающийся ключ. Если же транзакция A после чтений выполняет тяжёлый пересчёт табличной части, очередь за тем же ключом растёт, и вероятность отказа B по таймауту увеличивается даже при корректном порядке захвата.
| Режим | Поведение пары чтений | Типичная цена на пике |
|---|---|---|
| ReadCommitted, без захвата | Каждый запрос со своего среза; пара может быть логически несовместима | Минимальные ожидания, максимальный риск тихого нарушения контроля |
| ReadCommitted + захват после первого чтения | Второй запрос согласован, если B не меняла те же ключи; иначе ожидание или отказ | Риск взаимных ожиданий при разном порядке захвата в других модулях |
| RepeatableRead + захват до первого чтения | Пара чтений в одной транзакции согласована относительно момента захвата | Очереди при параллельной отгрузке и перераспределении |
| Serializable | Жёсткая сериализация конфликтующих транзакций | Рост отказов по таймауту при длинной записи движений |
Даже при RepeatableRead два модуля, которые сначала захватывают «РезервПодКлиента», а потом «СвободноВЗонахОтбора», и модуль перераспределения с обратным порядком создают цикл ожиданий под нагрузкой. Канонический порядок по стабильному ключу (склад, номенклатура, затем тип регистра) нужно повторять в отгрузке, перераспределении и корректировке резерва. Иначе согласованность пары чтений в одном документе не спасает от взаимных ожиданий между документами.
На стенде мы воспроизводим цикл, запуская два сеанса с зеркальным порядком захвата и одинаковой номенклатурой; технологический журнал с отбором по событию ожидания захвата показывает пару процессов, каждый из которых держит один регистр и ждёт второй. Если выровнять порядок во всех трёх модулях, цикл исчезает, но среднее время ответа на пике может вырасти на доли секунды на документ, что для интерактивной операции всё же заметнее, чем редкий отрицательный остаток в отчёте.

Изоляция и захват: четыре комбинации
4. Практическая политика и пределы подхода
4.1. Минимальный протокол для модуля перераспределения
Мы закрепили для команды правило: один уровень изоляции на весь фрагмент «прочитал - сравнил - записал движения», захват ключей до первого запроса, второй запрос без промежуточных записей в другие регистры и без обращений к внешним сервисам внутри того же контура. На продуктиве мониторим длительность транзакции, число отказов по таймауту захвата и долю документов перераспределения, у которых между первым и вторым запросом прошло больше одного серверного вызова.
УстановитьУровеньИзоляцииТранзакции(УровеньИзоляцииТранзакций.RepeatableRead);
НачатьТранзакцию();
Блокировка = Новый БлокировкаДанных;
Элемент = Блокировка.Добавить("РегистрНакопления.СвободноВЗонахОтбора");
Элемент.УстановитьЗначение("Склад", Склад);
Элемент.УстановитьЗначение("Номенклатура", Номенклатура);
Элемент = Блокировка.Добавить("РегистрНакопления.РезервПодКлиента");
Элемент.УстановитьЗначение("Склад", Склад);
Элемент.УстановитьЗначение("Номенклатура", Номенклатура);
Блокировка.Заблокировать();
Свободно = ПрочитатьСвободноВЗоне(Склад, Зона, Номенклатура);
Резерв = ПрочитатьРезервПоЗаказу(Склад, ЗаказКлиента, Номенклатура);
Если Свободно - Резерв < КоличествоПереноса Тогда
ВызватьИсключение "Недостаточно связки зона-резерв";
КонецЕсли;
ЗаписатьДвиженияПерераспределения();
ЗафиксироватьТранзакцию();
// Сеанс A // Сеанс B
TX begin RC .
Q1: FreeZone = 120 .
. TX begin
. Q: Reserve -= 40; FreeZone -= 40
. TX commit
Q2: Reserve = 10 // видит B .
assert FreeZone still 120 in logic .
write movements .
TX commit .
4.2. Когда предложенный протокол не подходит
Если документ должен читать десятки тысяч строк регистров для отбора по всему складу, захват до первого чтения раздувает область конфликтов и мешает параллельным отгрузкам. Если бизнес допускает согласование «зона - резерв» с задержкой, дешевле вынести контроль в отдельный регистр сверки или в фоновое задание с ключом идемпотентности, а не ужимать Serializable на интерактивной записи. Если между чтениями нужен HTTP-вызов WMS, транзакция 1С не должна удерживать захват на время сети: иначе таймаут канала превращается в простой зоны отбора. Если конфигурация на поддержке и нельзя менять уровень изоляции глобально, остаётся только локальный фрагмент в расширении вокруг проблемного документа, но порядок захвата всё равно придётся согласовать с типовыми модулями отгрузки.
| Ситуация | Рекомендуемое решение | Риск |
|---|---|---|
| Два регистра, контроль по разности, пик параллельных отгрузок | RepeatableRead, захват ключей до первого запроса, единый порядок регистров | Очереди и таймаут ожидания на захвате |
| Редкие конфликты, мягкий контроль | ReadCommitted с повторной проверкой непосредственно перед записью набора без удержания транзакции между вызовами клиента | Остаётся окно между финальной проверкой и записью |
| Массовый пересчёт по всем зонам склада | Пакетное задание, разбиение по зонам, без длинной транзакции на весь склад | Временное расхождение отчётов во время пакета |
| Между чтениями нужен вызов WMS | Короткая транзакция только на запись; чтения и согласование вне удержания захвата | Дополнительная логика компенсации при расхождении |
Вступайте в нашу телеграмм-группу Инфостарт