Процедура называется так, что вопросов не возникает: заблокировать остатки. Внутри неё объект блокировки создаётся, элемент добавляется, значения измерений заполняются. И рядом лежит флаг, который включает саму установку замка. Он выставлен в ложь. Метод захвата не вызывается вообще. Процедура честно отрабатывает, ничего не возвращает и ничего не блокирует.
Таких процедур в разобранном коде нашлось три. Все три с правильными именами, все три вызываются из прикладного кода, ни одна не берёт живой замок. Когда на базе включили режим снимка на чтение, выяснилось, что за счёт этих пустышек жила приличная часть системы: 95 мест, где замок нужен по смыслу операции, и 41 из них прикрыт как раз поддельной защитой.
Сразу граница, чтобы потом не спорить в комментариях. Число 95 получено сплошным чтением кода. На проде это не наблюдалось. Три связанные задачи, ни одна на момент разбора не выкачена, воспроизведение на восьми и более потоках запланировано и не сделано. Единственное, что тут действительно измерено, - дедлоки до и после включения режима снимка. Их таблица идёт сразу в следующем разделе. Всё остальное - ревизия по коду.
Речь про платформу ветки 8.3 в режиме управляемых блокировок, СУБД MS SQL Server. Номер сборки не называю: он тут ни при чём, ломается прикладной код. Платформа отрабатывает как описано. Объект: складская система крупного розничного бизнеса, конфигурация заказная.
Что было перед этим и почему это важно
Началось со штормов дедлоков на ночных и утренних волнах проведения, ревизия пришла позже. Проводка рушилась пачками, ошибки двух видов: серверные взаимоблокировки с тайм-аутами и неустранимый конфликт блокировок на стороне платформы.
Первое, что показал разбор, было контринтуитивным. Из 3 183 конфликтов примерно 2 642, то есть около 83%, оказались конфликтами таблицы с самой собой: справочник резервов сам с собой, регистр размещения сам с собой. Перекрёстных, когда мешают друг другу два разных объекта, около 5%. Привычный вопрос "кто кому мешает" на таком распределении уводит в сторону. Работающий звучит иначе: почему одна и та же операция дерётся со своей копией.
Главным рычагом оказался выключенный режим снимка на чтение. В MS SQL это параметр READ_COMMITTED_SNAPSHOT: пока он выключен, читающая транзакция берёт на строках разделяемые замки и стоит в очереди к писателям. Включили - и читающая половина конфликта исчезла.
Замер честный, три ночи до и три ночи после:
| Метрика | До | После | Изменение |
|---|---|---|---|
| Пик дедлоков в час | 73 | 7-8 | около -90% |
| Всего за ночь | ~200 | 20-27 | около -87% |
Две независимые метрики дали один порядок улучшения. Разойдись они, я бы не поверил ни одной.
Сразу скажу, как эта таблица соотносится с прошлым разбором дедлоков на той же системе, иначе выйдет, что мы дважды чиним один шторм и дважды записываем победу себе. Шторм действительно один: тридцать три дедлока за полчаса из того текста дают шестьдесят шесть в час и ложатся в пик 73. Только разбиралась там вторая половина конфликтов, где пишут обе стороны, и уровнем изоляции она не лечится в принципе - она пережила эти минус девяносто процентов и осталась. Индекс, которым её лечили, до прода не доехал. Цифры выше сделала одна настройка СУБД.
А теперь неприятная часть. Разделяемые замки, которые читатели брали до переключения, кое-где делали работу, которую им никто не поручал. Код читал остатки, что-то решал и что-то писал, и всё это время его случайно прикрывало блокирующее чтение. Замка в коде нет, сериализация есть. После включения режима снимка сериализация пропала одномоментно и во всех таких местах сразу.
Практический вывод раздела: включение режима снимка на чтение - это правка не производительности, а модели корректности. Всё, что раньше выстраивалось в очередь побочным эффектом чтения, становится гонкой в один момент. Не через неделю деградации, а сразу, как только переключили.
Три процедуры, которые не блокируют
Дальше пришлось идти по коду и смотреть, чем защищены места "прочитал, подумал, записал". Оказалось, в основном вызовом одной из трёх процедур.
Первая: блокировка остатков. Внутри процедуры объект блокировки данных собирается полностью, а флаг, включающий установку, выставлен в ложь, и метод захвата не вызывается ни разу. Сборка объекта есть, захвата нет. Со стороны вызывающего кода не отличается от рабочей: параметры принимает, исключений не кидает, отрабатывает быстро.
Схематично это выглядит так:
Процедура ЗаблокироватьОстатки(Номенклатура, Ячейка, УстанавливатьБлокировку = Ложь)
Блокировка = Новый БлокировкаДанных;
Элемент = Блокировка.Добавить("РегистрНакопления.ОстаткиТоваров");
Элемент.УстановитьЗначение("Номенклатура", Номенклатура);
Элемент.УстановитьЗначение("Ячейка", Ячейка);
Элемент.Режим = РежимБлокировкиДанных.Исключительный;
Если УстанавливатьБлокировку Тогда
Блокировка.Заблокировать();
КонецЕсли;
КонецПроцедуры
Почему так получается. Параметр с умолчанием в ложь появляется, когда кто-то боится сломать существующие вызовы: добавил флаг, включил в одном новом месте, старые оставил как были. Через год никто не помнит, что флаг есть, а имя процедуры продолжает обещать блокировку. Хуже всего то, что режим тут выставлен исключительный: человек, читающий процедуру сверху вниз, видит правильный режим и до условия долистывает не всегда.
Вторая: блокировка элемента справочника. Тут замок берётся по-настоящему, но в разделяемом режиме. Разделяемый замок не пускает писателей к читателю, но двух писателей друг с другом не сериализует. То есть две параллельные записи одного и того же элемента спокойно проходят обе. Для защиты от потерянного обновления это бесполезно.
Отдельная тонкость: вне транзакции эта процедура сама открывала транзакцию, брала замок и тут же фиксировала. Замок снимался раньше, чем вызывающий код успевал что-либо сделать с данными. Формально блокировка была. Практически её не существовало.
Третья: глобальная очередь выполнения. Метод, который должен был выстраивать операции в очередь, начинается с возврата. Первой строкой. Остальное тело недостижимо. Похоже, кто-то так гасил зависания и забыл вернуть обратно.
Добивают картину два факта. Повторная попытка при дедлоке в коде есть, но целиком закомментирована. Монопольная блокировка в конфигурации отсутствует как механизм. Живого гейта сериализации не было ни одного, хотя по коду казалось, что их три.
Практический вывод раздела: имя процедуры ничего не доказывает. Единственный способ проверить - открыть тело, найти глазами вызов захвата и убедиться, что до него доходит исполнение.
Почему это не видно на ревью
Мне было непонятно, как такое живёт годами в системе, которую смотрят десятки людей. Ответ оказался скучным: ревью смотрит на изменённые строки, а сломано в неизменённых.
Место вызова выглядит идеально:
НачатьТранзакцию();
Попытка
ЗаблокироватьОстатки(Номенклатура, Ячейка);
Остаток = ПолучитьОстаток(Номенклатура, Ячейка);
...
Ревьюер видит: транзакция открыта, блокировка взята, потом чтение. Шаблон канонический, придраться не к чему. Проверить, что внутри процедуры, никто не идёт, потому что процедура старая и лежит в общем модуле, который никто не менял.
Тот же класс я поймал в этой системе ещё раз, уже свежий. В незакоммиченном слое рабочей копии, около 44 строк разницы в одном модуле, у процедуры блокировки элемента справочника появляется параметр "исключительно", и умолчание у него встаёт в разделяемый режим. Все существующие вызовы меняют поведение, не меняя ни единого символа в месте вызова. В диффе одна строка, в поведении вся система.
Практический вывод раздела: самая дорогая правка - та, у которой нет диффа в местах вызова. Смена значения по умолчанию у функции блокировки меняет поведение всех вызовов сразу, а ревью видит одну строку. Если вы добавляете такой параметр, ставьте умолчание в строгую сторону и чините вызовы явно, даже если их сто.
Проверка на шесть строк выше транзакции
Отдельная находка, которую стоит вынести из общего списка, потому что она воспроизводится в чужих системах чаще всего.
Ядро движения остатков перед списанием проверяет, не уйдёт ли остаток в минус. Проверка написана правильно, читает актуальные данные, отрабатывает корректно. Она выполняется до открытия транзакции. На шесть строк выше.
Пока читатели брали блокирующие разделяемые замки, эту проверку случайно прикрывало само чтение: второй поток вставал в очередь и приходил к проверке уже после того, как первый записал движение. После включения режима снимка оба потока читают один и тот же снимок данных, оба видят одинаковый остаток, оба считают, что списание допустимо, и оба списывают.
Отсюда растут сразу три класса последствий: отрицательный остаток, двойной резерв и продажа сверх наличия. Все три через один участок кода.
Расстояние в шесть строк стоило целого класса отказов. И заметьте: этот шаблон не ловится ни линтером, ни ревью, ни статическим анализом обычного вида. Синтаксически всё в порядке, вызовы на месте, порядок строк выглядит логичным. Ловится только чтением по цепочке вызовов с ответом на вопрос "а где тут граница транзакции".
Практический вывод раздела: в любой системе, где есть проверка достаточности чего-либо перед списанием, найдите эту проверку и посмотрите, где относительно неё стоит НачатьТранзакцию. Если проверка снаружи, защиты нет ни при каком режиме изоляции.
Сколько таких мест оказалось
Ревизия шла тремя задачами. Основная, третья, дала 95 мест, где по смыслу операции нужен замок, а его нет.
Раскладка по тяжести: 14 находок высшей, 38 следующей, 34 третьей и 9 низшей.
Раскладка по последствиям:
| Что произойдёт | Мест |
|---|---|
| Затирание статуса | 22 |
| Потерянное обновление | 20 |
| Дубль документа | 16 |
| Двойной резерв | 15 |
| Отрицательный остаток | 12 |
| Продажа сверх наличия | 10 |
Обе раскладки закрываются ровно в 95. Я специально это пересчитал, прежде чем брать число в текст: две независимые группировки одного множества, сходящиеся точь-в-точь, получаются при подсчёте и не получаются при оценке на глаз.
41 находка из 95, то есть 43%, опирается именно на фантомные защиты из предыдущего раздела. Почти половина проблемы - это не "забыли взять замок". Это "взяли, и он поддельный". Разница принципиальная: забытый замок находится по отсутствию вызова, поддельный по отсутствию вызова не находится никогда.
Первая задача разбиралась с местами, где намерение записи есть. Всего таких мест насчитано 234. Из них 153 переведены в исключительный режим, там где это не создаёт взаимной блокировки по порядку. 59 оставлены разделяемыми (про них ниже). 22 признаны латентными, потому что находятся вне транзакции вообще. 2 не опознаны.
Тут я обязан показать расхождение. 153 плюс 59 плюс 22 плюс 2 даёт 236 при заявленных 234. Разница ровно два, и она совпадает с числом неопознанных мест. Скорее всего эти два уже посчитаны в одной из предыдущих категорий и были продублированы в отдельную строку. Но это моя догадка, и на 234 против 236 стоит смотреть как на плюс-минус две позиции в самой большой из трёх задач.
Практический вывод раздела: если ваша ревизия не сходится в двух независимых разрезах, у вас не подсчёт, а ощущение. Считайте одно и то же множество дважды разными осями, это дешевле любой перепроверки.
Что пережило переключение
Не всё оказалось поддельным, и это тоже важный результат.
Конструкция "для изменения" в запросе под режимом снимка на чтение продолжает работать, то есть продолжает ставить настоящий замок. Из трёх механизмов, которые в коде выглядят взаимозаменяемыми, переключение пережил ровно один. Поэтому проверять надо каждый отдельно: то, что один механизм уцелел, про остальные не говорит ничего.
Вторая задача ревизии как раз разбирала эти вхождения. Их нашлось 27. Шестнадцать удалены как избыточные: запрос выполнялся вне транзакции, либо таблица в этой операции не пишется вовсе, и замок брался впустую. Оставшиеся 11 разложились так: одно перенацелено на другую таблицу, восемь признаны необходимыми, два ушли на ревью.
Ещё одна цифра, которую я чуть не склеил с предыдущей. В конфигурации 27 объектов остались в автоматическом режиме блокировок при 378 управляемых, автоматических 6,7% от 405. Эти 27 не имеют отношения к 27 вхождениям конструкции "для изменения", совпадение случайное. Не заметь я его при сверке, в статью уехало бы красивое и неверное обобщение.
Автоматические объекты из ревизии исключили с обоснованием "только журналы и инфраструктура". Обоснование разумное, но это принятое допущение, а не проверенный факт. Если хоть один из этих 27 участвует в движении товара, вся ревизия неполна.
Практический вывод раздела: перед переключением режима изоляции составьте список механизмов защиты, которыми пользуется ваш код, и проверьте каждый отдельно. У трёх похожих на вид конструкций поведение после переключения оказалось разным.
Почему "поставить везде исключительные" не работает
Очевидное решение выглядит так: раз замки поддельные, сделаем их настоящими и исключительными везде. Мы это попробовали, и через два дня получили два инцидента.
Первый: неустранимый конфликт блокировок на объекте загрузки, вызванный инверсией порядка захвата. Второй: превышение времени ожидания на длинной секции, которая после ужесточения замков перестала выполняться параллельно.
Причина первого нашлась быстро. В порядке взятия замков в ядре обнаружилось пять замкнутых колец. Наличие колец означает, что канонического порядка захвата в системе не существовало вовсе, и пока замки были разделяемыми, это никого не беспокоило. Стоило перевести их в исключительные, кольца превратились во взаимные блокировки. Порядок пришлось выстраивать отдельной работой, и ядро в итоге разложилось на девять рангов, после чего колец не осталось.
Причина второго измерена: p95 длительности транзакций составляла 68 и 64 секунды по двум замерам. При такой длительности любой исключительный замок держится больше минуты. Тайм-ауты ожидания при таких цифрах не случайность, а арифметика.
Практический вывод раздела: перевод замков в исключительные вносит новый класс отказов. Если у вас p95 транзакции измеряется десятками секунд, сначала укоротите транзакцию, потом ужесточайте замки. В обратном порядке вы получите тайм-ауты вместо потерянных обновлений и будете считать, что стало хуже.
Четверть мест оставлена незащищённой, и это решение
Из 234 мест с намерением записи 59 сознательно оставлены разделяемыми. Это 25%, четверть. Причина: у этих мест конфликтные пары по порядку захвата, и перевод их в исключительный режим гарантированно даёт взаимную блокировку.
То есть команда явно выбрала риск потерянного обновления вместо риска дедлока. Не потому что не разобралась, а потому что разобралась и выбрала.
Я знаю, что за этот абзац прилетит, и заранее скажу свою позицию. Потерянное обновление - тихий отказ: данные разъезжаются, никто не видит ошибки, следы находятся через недели. Дедлок - громкий: транзакция падает, пользователь видит сообщение, повтор снаружи транзакции чинит ситуацию.
Уточню, какой повтор имеется в виду, потому что про повторы у меня есть отдельный разбор, и вывод там обратный. Внутри упавшей транзакции повторять бесполезно всегда: она уже помечена на откат, а исходную ошибку повтор затирает. Здесь речь про уровень выше: вызывающий код поймал отказ, начал транзакцию заново и прогнал операцию с нуля. Такой повтор работает.
По-хорошему выбирать надо громкий отказ. Но повтор при дедлоке в этой системе закомментирован, и пока он не восстановлен, громкий отказ ничего сам не чинит: он просто роняет проводку в ночной волне. При таком раскладе выбор в пользу разделяемых замков перестаёт быть трусостью и становится расчётом.
Хвост это не закрывает. 59 мест названы и оставлены, что с ними делать дальше, в этой ревизии не решено.
Практический вывод раздела: если вы оставляете место незащищённым осознанно, запишите это решение рядом с кодом. Через год отличить осознанный размен от забытого замка будет невозможно, и следующий человек починит вам то, что чинить не надо было.
Отклонённая гипотеза и границы
Гипотеза, с которой начинали разбор дедлоков, звучала так: виноваты перекрёстные конфликты, когда одна операция держит один объект, а вторая другой, и они ждут друг друга. Это первое, что приходит в голову, и это оказалось неправдой. Замер показал 83% конфликтов таблицы с самой собой и около 5% перекрёстных. Гипотезу пришлось выбросить и переписать методику разбора с вопроса "кто кому мешает" на вопрос "почему операция конфликтует со своей же копией".
Теперь честные границы, чтобы никто не принял эту работу за то, чем она не была.
Число 95 лабораторное, про это сказано в самом начале. Добавлю неприятное: нигде не зафиксировано, сколько из этих 95 мест реально стреляли. Классы последствий названы, а инцидентов из этих классов в материалах нет. Отрицательный остаток и продажа сверх наличия обычно не проходят незамеченными, и отсутствие таких историй смущает меня больше, чем расхождение в две позиции.
Цена перевода 153 мест в исключительные не оценена. Мы знаем, что перевод меньшего числа мест дал два инцидента за два дня, и переносить это на 153 линейно нельзя, но и игнорировать глупо.
И про инструменты анализа. Часть ревизии пробовали делать агентами на большой языковой модели: они галлюцинировали цепочки вызовов и путались из-за незакоммиченного хака в рабочей копии. Каждую находку проверяли руками. Показательный момент: проверочный прогон дал 12 подтверждений и 6 частичных при нуле опровержений на 18 вердиктов. Прогон, который не опроверг ничего, автора не проверяет, он ему поддакивает.
Практический вывод раздела: если проверка вашей работы не опровергла ни одного вывода, чинить надо проверку. Качество работы тут ни при чём.
Как проверить это у себя за вечер
Порядок, который я бы повторил на любой чужой базе:
- Найдите поиском по конфигурации все вызовы метода захвата блокировки. Посчитайте их. Потом посчитайте процедуры, в именах которых есть слово "заблокировать". Если второе число заметно больше первого, у вас есть процедуры-обёртки, и внутрь каждой надо заглянуть.
- В каждой такой процедуре найдите условие, под которым стоит захват. Условие без захвата в ветке "иначе" и параметр с умолчанием в ложь - это ваши кандидаты в пустышки.
- Проверьте режим. Разделяемый замок в процедуре, которая вызывается перед записью, защиты от потерянного обновления не даёт.
- Проверьте, что процедура не фиксирует транзакцию сама. Захват с немедленной фиксацией внутри обёртки снимает замок раньше, чем вызывающий код успеет им воспользоваться.
- Возьмите главную проверку достаточности в вашей предметной области (остаток, лимит, свободное место) и посмотрите, с какой стороны от границы транзакции она стоит.
- Если включаете режим снимка на чтение, сначала пройдите пункты 1-5. В обратном порядке вы получите улучшение по дедлокам и тихую порчу данных разом, и второе заметите сильно позже.
Открытый вопрос
Вопрос к тем, кто уже переводил базы на режим снимка на чтение. Вы искали у себя места, где сериализация держалась на блокирующем чтении, до переключения или после? И если после, то по каким признакам вы их находили: по жалобам пользователей, по расхождениям в остатках или всё-таки чтением кода? У меня получилось только третьим способом, и я подозреваю, что первые два просто медленнее.
Вступайте в нашу телеграмм-группу Инфостарт