Пользователю при проведении документа прилетает 1205 deadlock victim, и указатель ведёт на строку выполнения запроса в модуле объекта. Дальше все идут смотреть таблицу движений регистра и не находят там ничего.
Не находят по простой причине: конфликта на таблице движений нет. Он разворачивается на таблице итогов того же регистра, и участвуют в нём не две разные операции, а две копии одной и той же.
Сразу оговорюсь про рамки. Ниже разобран механизм, доказанный на живой системе. Частотных цифр в этом разборе нет: ни дедлоков в сутки, ни числа затронутых документов, ни уровня параллельности. И лечение здесь не проверялось. Варианты я перечислю вместе с ценой каждого, но ни один из них не выкачен и не замерен. Если вам нужна готовая настройка, которая всё чинит, её в этой статье не будет.
Зато будет то, чего мне самому не хватало, когда я в это упирался: понятный ответ на вопрос "почему диагностика показывает пустоту, хотя пользователи ловят ошибку каждый день".
Где обычно ищут и почему там пусто
Привычный маршрут разбора выглядит так: взять регистр, на котором встали, посмотреть, кто с кем конкурирует на его основной таблице, найти два документа, которые пишут в пересекающиеся ключи.
На регистре накопления это почти всегда пустой маршрут. Движения пишутся по ключам, в которые входит сам документ-регистратор. У двух разных документов ключи разные по построению, диапазоны не пересекаются, конфликтовать нечему. Ровно поэтому на форумах регулярно появляется вопрос в формулировке "конфликт блокировок при параллельной записи двух непересекающихся по измерениям и регистратору наборов" - человек честно проверил, что наборы не пересекаются, и получил отказ, которого по его картине мира быть не может.
Диагностика, направленная на таблицу движений, честно возвращает ноль. Это ноль не потому, что плохо искали. Искали в месте, где конфликта не бывает.
Настоящий эпицентр: таблица итогов
У регистра накопления с включёнными итогами есть вторая таблица. В ней хранятся заранее посчитанные остатки и обороты в разрезе измерений, без привязки к документу. Смысл её существования в том, чтобы запрос остатка брал готовую строку вместо пробега по миллионам движений.
Разница принципиальная. Движения индивидуальны, итоги общие. Два документа по одному складу и одной номенклатуре пишут разные строки движений и обновляют одну и ту же строку итогов.
Отсюда и получается, что конкуренция живёт на агрегате. Место, где данные складываются вместе, это же место, где встречаются транзакции. Причём встречаются они там тем плотнее, чем крупнее гранулярность итогов: если в измерениях склад и номенклатура, а работает вся смена по одному складу, все проводки смены упираются в один и тот же кусок таблицы итогов.
Важная деталь про запись итогов: она происходит не отдельным шагом, который можно увидеть в коде. Платформа обновляет итоги внутри записи набора записей, в той же транзакции, автоматически. В модуле проведения этой строки нет. Разработчик видит Движения.Записать() и не видит второго обращения, которое ушло на агрегат.
Механизм, шаг за шагом
Теперь сама конструкция отказа. Она проще, чем кажется, и опаснее, чем выглядит.
Документ в одной транзакции проведения является и писателем, и читателем одного и того же регистра. Сначала записывает движения. Потом, в той же транзакции, читает итоги, чтобы проверить остаток или подобрать партии.
Дальше две параллельные проводки делают ровно одно и то же:
- Каждая пишет свои движения и получает на таблице итогов исключительные диапазонные блокировки на своих ключах. Пока конфликта нет: ключи разные.
- Каждая читает итоги и просит разделяемую диапазонную блокировку на диапазоне, который включает ключи соседа. Диапазон запроса шире того, что транзакция трогала сама: она читает остаток по измерению целиком, одной своей строкой тут не обойтись.
- Взаимное ожидание. Первая держит исключительный замок на том, что просит вторая; вторая держит исключительный на том, что просит первая. Ни одна не может ни продолжить, ни отпустить, потому что обе внутри транзакции. Сервер выбирает жертву и убивает её с кодом 1205.
Обратите внимание, чем это отличается от классической инверсии, про которую пишут чаще всего. Здесь нет двух объектов, захваченных в разном порядке. Порядок у обеих транзакций одинаковый: сначала запись, потом чтение. Инверсия происходит по типу замка на пересекающихся диапазонах, и никакой канонический порядок захвата объектов её не лечит. Можно сколько угодно причёсывать код по правилу "обращаемся к объектам всегда в одной последовательности" и не сдвинуть частоту отказов ни на сколько.
Почему диапазон чтения шире, чем кажется
Тут стоит задержаться, потому что это самое неочевидное место.
Диапазонный замок закрывает не только найденный ключ индекса, но и промежуток до соседнего ключа. Так сервер базы данных гарантирует, что в этот промежуток никто не вставит новую строку, пока транзакция не закончилась. Значит ширина захвата определяется тем, сколько ключей индекса пришлось просмотреть по дороге. Количество строк, которые запрос в итоге вернул, к ней отношения не имеет.
Практические следствия из этого прямые:
- запрос остатка по всему складу закрывает диапазон всех номенклатур этого склада, даже если реально нужна одна позиция;
- отбор по полю, которого нет в начале индекса, заставляет просматривать больше ключей и расширяет захват;
- условие вида "остатки на дату" тянет за собой диапазон по периоду, а период в итогах общий для всех документов сразу.
То есть транзакция закрывает территорию сильно больше собственной. И этой территории хватает, чтобы накрыть строки, которые прямо сейчас держит сосед.
Почему конфликтуют две копии одной операции
Это то место, из-за которого стоило писать статью.
Обычно взаимоблокировку представляют как столкновение разных сценариев: проведение против закрытия месяца, обмен против отчёта. Здесь сталкиваются два экземпляра одной и той же нормальной операции.
Практическое следствие неприятное: чем лучше идёт обычная работа, тем чаще воспроизводится отказ. Нет сценария, который надо перестать запускать по ночам, нет тяжёлого регламента, который можно подвинуть на выходные. Есть проведение документов, и оно и есть проблема.
Отсюда же классическая ловушка в разговоре с бизнесом. Вопрос "что вы запускали в этот момент" получает честный ответ "ничего особенного, работали как обычно", и разбор глохнет. Ответ правильный, вопрос неправильный.
Почему частота растёт быстрее нагрузки
Здесь единственная арифметика, которая в этом разборе есть, и я сразу скажу: она выведена из формулы. Замера под ней нет.
Если одновременно проводится N документов по пересекающимся измерениям, число пар, которые могут встретиться, равно N(N-1)/2. Посчитайте на пальцах: пять одновременных проводок дают 10 пар, десять дают 45, двадцать дают 190. При удвоении числа одновременных проводок количество опасных пар вырастает примерно вчетверо.
Отсюда ощущение внезапности. Пока одновременных проводок мало, пар мало, и отказ выглядит как редкая случайность, которую списывают на "сеть моргнула". Стоит числу одновременных проводок подрасти, пары растут быстрее, и редкая случайность становится фоном, на который жалуется вся смена. Насколько подросла параллельность в разобранном случае, я не скажу: замера в источнике нет. Квадрат здесь описывает форму кривой, конкретных цифр за ним не стоит.
И отсюда же вывод для планирования: нельзя оценивать риск по средней нагрузке. Считать надо по пиковой параллельности, потому что вклад в частоту даёт именно она. Средняя за день нагрузка может быть смешной, а в получасовое окно перед отгрузкой параллельность такая, что пар хватает на всех.
Почему симптом указывает мимо
Ошибка приходит на чтении, в строке выполнения запроса. Причина при этом в записи, которая случилась раньше в той же транзакции.
Это тот же класс обманчивых симптомов, что и в других разборах: место, где транзакция упала, не совпадает с местом, где она сломалась. Первое обращение к базе после того, как транзакция уже набрала конфликтных замков, принимают за место аварии.
Хуже того, строка запроса выглядит убедительно виноватой. Она читает, она тяжёлая, да ещё и крутится в цикле по строкам табличной части. По ней и начинают оптимизировать: навешивают индексы, переписывают сам запрос. Кто-то заодно выносит его из цикла. Запрос после этого честно работает быстрее. Дедлоки остаются, потому что замки, из-за которых всё встало, набраны строкой выше.
Практический вывод: при разборе взаимоблокировки смотрите не на строку, где прилетела ошибка, а на всё, что транзакция успела захватить до неё. Строка с ошибкой это последний шаг, и почти никогда не первый.
Оговорка, чтобы не спорить с самим собой. В прошлом разборе я показывал ровно обратное: там дедлоки снял индекс, потому что запрос сканировал таблицу целиком и держал замки в сотню раз дольше нужного. Разница видна в графе. Если ресурс это страницы и целые объекты таблицы движений, вы в том классе, и индекс помогает. Если ресурс это ключ индекса таблицы итогов с составными режимами замков, вы в этом, и индекс не сдвинет ничего.
"А разделение итогов вы включали?"
Этот вопрос прилетит первым, поэтому отвечу заранее.
Режим разделения итогов - штатный ответ платформы на конкуренцию за агрегат. Устроен он так: в таблицу итогов добавляется служебное измерение-разделитель, и параллельные сеансы пишут свои изменения в строки с разными его значениями. Одна строка итогов превращается в несколько, конкуренция за неё исчезает, а при чтении платформа складывает все разделители вместе. Периодически лишние строки схлопывает пересчёт итогов.
Против конкуренции двух записей это работает отлично. Против нашей конструкции - по механизму не должно.
Смотрите, что получается. Разделитель разносит записи по разным строкам, но чтение он не разносит: запрос остатка по измерению обязан пройти все значения разделителя, иначе он получит неполную сумму. Значит его диапазон по-прежнему накрывает ключи соседа, и пара "исключительный замок против разделяемого на пересекающемся диапазоне" никуда не девается. Разделение итогов лечит столкновение писателей и не трогает столкновение писателя с читателем.
Отдельно оговорю честно: в разборе, на котором основана статья, разделение итогов не включали и не замеряли. Написанное выше - вывод из механизма, опыта за ним нет. Если у кого-то есть замер, который его опровергает, я такому комментарию буду рад больше, чем согласию.
Что смотреть, если управляемые блокировки молчат
Отдельная сложность этого класса отказов в том, что диапазонных блокировок в терминах 1С не существует. В управляемых блокировках их нет и быть не может: они возникают уровнем ниже, на сервере системы управления базами данных (СУБД), когда транзакция идёт под уровнем изоляции, который берёт диапазонные замки. У Microsoft SQL Server это SERIALIZABLE, а 1С включает его сама в автоматическом режиме управления блокировками.
Из этого следует занятная вещь: сам факт диапазонных замков в картине конфликта уже говорит вам, в каком режиме работал этот кусок конфигурации. Проверять отдельно не нужно, ответ виден в графе.
Значит анализ на прикладном уровне бесполезен по определению. Ставить управляемые блокировки, разбирать порядок их захвата в коде, строить канон - всё это про другой слой, и на диапазонах итогов оно не отражается никак.
Смотреть надо граф взаимоблокировки, который отдаёт сервер СУБД. В нём видно то, чего не видно нигде больше: тип замка у каждой стороны, ресурс, на котором встали, и режим. Именно там будет видно, что один ресурс это диапазон на индексе таблицы итогов. Строки таблицы движений в графе не окажется вовсе.
Порядок действий такой:
- Снять граф взаимоблокировки на стороне СУБД. Штатные способы - расширенные события или трассировка, оба дают одну и ту же картину.
- Посмотреть, на какой таблице лежит ресурс. Попали на итоги - вы в этом классе.
- Открыть модуль проведения и найти в нём чтение итогов этого же регистра после записи движений. Есть такое - механизм подтверждён, и дальше вопрос уже не в диагностике.
Опознаётся такой конфликт по двум приметам, и обе видны прямо в графе.
Примета первая: тип ресурса. Обе стороны стоят на ключе индекса. Ни строки, ни страницы в ресурсе не будет. Ключ индекса и есть точка, где живёт диапазонный замок.
Примета вторая: режимы замков. У диапазонных замков режим составной, из двух частей: первая описывает, что закрыт промежуток между ключами, вторая описывает, что делается с самим ключом. Когда с одной стороны стоит режим с исключительной частью, с другой запрашивается режим с разделяемой, и оба на одном индексе, это ровно та картина, что описана выше. Простые замки на строках так не выглядят.
Если же в графе фигурирует таблица движений, разбор надо начинать заново: вы в другом классе отказов, и всё написанное здесь к нему не применимо.
Что с этим делают и почему я не называю решение
Теперь честная часть, ради которой я оговаривался в начале.
В разборе, на котором основана статья, лечение не выбиралось и не проверялось. Механизм доказан, фикс не назван. Поэтому ниже перечень того, что напрашивается, вместе с ценой каждого варианта. Ни один не замерен, ни один не выкачен, и выдавать это за рекомендации я не буду.
Вынести чтение итогов за пределы транзакции записи. Прямое устранение конструкции отказа: когда читатель и писатель разведены по разным транзакциям, взаимного ожидания не возникает. Цена в том, что логика проведения перестаёт видеть собственные движения в момент проверки, и это меняет прикладной смысл. Годится не везде и требует пересборки проверок остатка.
Сузить диапазон чтения до тех же ключей, которые пишутся. Самый локальный вариант из всех: если проверка остатка идёт по конкретному складу и конкретной номенклатуре, без захода на измерение целиком, диапазон захвата сжимается и перекрытие с соседом становится менее вероятным. Цена низкая, но и гарантий нет: пока диапазоны хоть где-то пересекаются, механизм жив, просто реже срабатывает.
Отключить использование итогов у регистра. Конкурировать станет не на чем, второй таблицы просто не будет. Цена высокая и очевидная: все запросы остатков начнут считать по движениям, и производительность чтения упадёт тем сильнее, чем больше накоплено. На большом регистре это лечение хуже болезни.
Сменить уровень изоляции на тот, где нет диапазонных замков. Убирает механизм целиком. Цена в том, что меняется модель корректности всей системы, одним регистром дело не ограничится, и то, что раньше сериализовалось само собой, становится гонкой. Это отдельная большая работа с полным перебором мест, где на сериализацию молча полагались.
Снизить пиковую параллельность проведения. Из квадратичной зависимости следует, что даже небольшое снижение числа одновременных проводок ощутимо снижает частоту. Самый дешёвый и самый нелюбимый вариант: он лечит следствие и никого не радует.
Из перечисленного только сужение диапазона чтения и снижение параллельности не трогают ни прикладную логику, ни модель корректности. Остальные три тянут на проект, галочкой в конфигураторе они не закрываются. Правильный выбор зависит от цифр, которых у меня нет, поэтому и рекомендации нет.
Границы применимости
Механизм описан для регистра накопления с включёнными итогами. Без итогов второй таблицы не существует и конфликтовать не на чем.
Диапазонные замки возникают только под уровнем изоляции, который их использует. Вне этого условия картина отказа будет другой, и переносить разбор нельзя.
Это не тот же случай, что конверсия разделяемого замка в исключительный на одном объекте. Там одна операция повышает тип замка, здесь две операции встречаются на пересекающихся диапазонах. Лечится по-разному, и путать их дорого.
Частоты нет. Ни дедлоков в сутки, ни доли пострадавших проводок, ни уровня параллельности. Механизм доказан, масштаб не измерен. Всё, что в тексте похоже на оценку масштаба, выведено из формулы для пар, и я это отдельно пометил.
Замера до и после нет, потому что "после" не наступало: лечение не выбиралось.
Триггер неизвестен. Конструкция существовала в коде всегда, а жаловаться начали в какой-то конкретный момент. Что изменилось перед этим, в разборе не установлено. Кстати, это первый вопрос, который стоило бы задать: механизм объясняет, почему отказ возможен, и не объясняет, почему он начался именно тогда. Разница между этими двумя вопросами обычно и есть разница между "поняли" и "починили".
Итого, что получается:
- Диагностика по таблице движений ничего не находит - смотрите итоги. Движения пишутся по индивидуальным ключам и почти не конфликтуют, агрегат общий для всех.
- Ищите в модуле проведения запись движений и чтение итогов того же регистра в одной транзакции. Это и есть конструкция отказа, остальное следствия.
- Строка, в которой прилетела ошибка, не место поломки. Разбирать надо всё, что транзакция захватила до неё.
- Диапазонных блокировок нет в управляемых блокировках. Прикладной уровень этот класс не видит вообще, нужен граф взаимоблокировки со стороны СУБД.
- Ширина захвата считается по просмотренным ключам индекса. Число возвращённых строк её не описывает. Отсюда все сюрпризы с "я же читал одну позицию".
- Риск считайте по пиковой параллельности. Средняя нагрузка за день про него молчит: число опасных пар растёт как квадрат числа одновременных проводок.
И вопрос, на который мне самому нужен ответ. Кто-нибудь выносил чтение итогов из транзакции проведения на живой системе? И что пришлось переделать в проверках остатка, когда логика перестала видеть собственные движения?
Вступайте в нашу телеграмм-группу Инфостарт