Ошибки блокировок строк в Postgres, вызывающие скрытые задержки
Ошибки блокировок строк в Postgres часто прячутся внутри долгих транзакций и смешанных путей записи. Узнайте, где начинаются задержки, как их заметить и что исправить.

Как скрытые задержки выглядят в реальных приложениях
Пользователи редко сообщают о блокировке базы. Они жалуются на крутилку, которая висит 8 секунд, на платёж, который кажется зависшим, или на форму, которая сохраняется с первой попытки неудачно, а со второй — нормально. Минуту спустя то же действие снова работает быстро.
Разработчики обычно видят другое. CPU выглядит нормально, хост базы спокоен, и план запроса для медленного запроса не кажется неправильным. Один запрос становится красным в дашборде, следующий — зелёным, и никто не может воспроизвести проблему по требованию.
Именно поэтому блокировки строк сбивают с толку многие команды. Медленный запрос и ожидающий запрос снаружи выглядят почти одинаково, но причина у них разная. Один запрос тратит время на выполнение работы. Другой тратит большую часть времени в блокировке, ожидая, пока другая транзакция освободит строку.
В любом случае приложение кажется медленным, но исправление разное. Индекс не поможет, если запрос быстро нашёл строку, а затем простаивал в ожидании.
В реальном приложении задержка выглядит случайной, потому что обычный трафик смешивает множество путей запросов. Большинство обновлений завершаются быстро. Затем один запрос открывает транзакцию, трогает строку и держит транзакцию открытой чуть дольше, пока приложение делает дополнительную работу. Все остальные запросы, которым нужна та же строка, выстраиваются за ним в очередь.
Небольшой пример показывает шаблон. Один пользователь обновляет статус заказа. В этой транзакции приложение также записывает запись аудита, вызывает другой сервис и форматирует ответ перед фиксацией. Трое других пользователей трогают тот же заказ в эти несколько секунд. Они все кажутся медленными, хотя их SQL сам по себе в порядке.
Логи часто упускают реальную причину. Многие системы записывают время выполнения только после завершения запроса, поэтому вы получаете запись о медленном запросе без подсказки, что 95 процентов этого времени было ожиданием блокировки. Логи приложения могут быть ещё менее полезны. Они говорят, что запрос занял 9 секунд, но не говорят, что первые 8.7 секунды он стоял в очереди за другим запросом.
Этот разрыв между ощущениями людей и тем, что показывают логи, — причина, по которой скрытые задержки продолжают удивлять команды.
Как блокировки строк превращаются в очередь
Одна запись на запись может заставить другие записи ждать. Шаблон прост: один запрос обновляет строку, Postgres блокирует эту строку, и каждый другой запрос, который хочет изменить ту же строку, должен приостановиться, пока первая транзакция не завершится.
Такая пауза обычно безвредна. Если первая транзакция завершается за пару миллисекунд, никто не замечает. Если она остаётся открытой, пока приложение делает дополнительную работу, очередь за ней быстро растёт.
Команды часто ожидают, что проблема с блокировками будет выглядеть драматично, как заморозка всей таблицы. Чаще всего это не так. Блокировка строки блокирует одну строку, а не всю таблицу, поэтому торможение ощущается непостоянно. Некоторые запросы остаются быстрыми. Другие напирают в очередь, потому что все затрагивают одну и ту же запись.
Горячие строки появляются в обычных местах: баланс одного аккаунта, один товар на складе с высоким трафиком, одна запись задания, которую рабочие постоянно ретраят, или одна строка настроек арендатора, обновляемая при каждом действии.
Теперь представьте: запрос A обновляет эту строку, а затем тратит 800 мс на вызов другого кода перед фиксацией. Запрос B приходит и ждёт. Затем C встаёт за B. Потом D присоединяется к той же линии. Пользователи не видят «заблокированную строку». Они видят медленные страницы, тайм-ауты и странные всплески задержки.
Очередь становится хуже, когда каждый ожидающий запрос также удерживает другие ресурсы открытыми. Рабочие приложения заняты. Слоты пула соединений заняты. Новые запросы начинают ждать соединения с базой ещё до того, как дойдут до заблокированной строки. Так одна медленная транзакция распространяется в более широкий простой.
Короткие блокировки редко вредят. Долгие блокировки вредны, потому что ожидание заразительно. Сама по себе блокировка строки мала. Настоящая проблема — как долго приложение её держит.
Исправление часто начинается вне базы данных. Держите транзакцию короткой. Обновляйте строку как можно позже, фиксируйте сразу и выносите медленную работу (сетевые вызовы, большие циклы или дополнительные чтения) за пределы заблокированной секции.
Где команды случайно расширяют область транзакции
Транзакция часто начинается мелкой, а потом несёт в себе половину запроса. Код обновляет одну строку, затем загружает связанные данные, снова проверяет права, вызывает другой сервис, ретраит неудачный шаг и только потом фиксирует. Каждый дополнительный шаг держит блокировки дольше, чем команда ожидает.
Небольшие задержки, которые суммируются
Проблемы обычно начинаются, когда код открывает транзакцию слишком рано. Обработчик запроса может начать транзакцию до валидации ввода, до проверок кэша или даже до того, как поймёт, нужно ли ему что‑то записывать. Оттуда код уходит в работу, которая вообще не нуждается в защите транзакцией.
Распространённые примеры легко пропустить: вызов платёжного сервиса, отправка письма или внутреннего HTTP API до фиксации; повторение бизнес‑логики внутри той же транзакции; чтение дополнительных таблиц «на всякий случай»; или построение записей аудита, уведомлений и обновлений поиска до освобождения блокировки.
Когда один из этих шагов замедляется на 300 мс, блокировка тоже ждёт 300 мс. Если десять запросов бьют по одному аккаунту, заказу или строке инвентаря, эта небольшая задержка превращается в очередь.
Когда пути записи становятся слишком широкими
Команды также расширяют область транзакции, когда веб‑запросы и фоновые задания касаются тех же строк по разным причинам. Пользователь нажимает «отменить заказ», пока задача пересчитывает итоги или отправляет обновления по отправке для того же заказа. Каждая задача поодиночке кажется безвредной. Вместе они ждут друг друга, и замедление кажется случайным, потому что зависит от временных совпадений.
Широкий путь записи усугубляет это. Одно действие пользователя может обновить основную строку, вставить событие, обновить счётчики, пометить уведомления, записать историю аудита и пересчитать производные поля. В этом нет ничего необычного. Проблема в том, что всё это делается до фиксации в одной длинной цепочке.
Лучший ревью‑подход начинается с простого вопроса: что обязательно должно произойти внутри транзакции, а что можно переместить после фиксации? Ответ обычно меньше, чем кажется по коду.
Как пошагово проверить путь записи
Возьмите один медленный запрос и проследите его с момента, когда приложение получает запрос, до момента фиксации в Postgres. Не беритесь за всё приложение сразу. Один запрос достаточно, чтобы показать, куда уходит время.
Запишите путь как таймлайн. Включите код контроллера, вызовы сервисов, действия ORM, сырой SQL и финальную фиксацию. Если вы пропустите это и будете полагаться на память, вы обычно упустите вспомогательный вызов или дополнительный запрос, который держит транзакцию открытой дольше, чем вы думаете.
Сделайте таймлайн транзакции
Для каждого шага отметьте точный момент, когда начинается транзакция. Затем перечислите каждый запрос, который выполняется до обновления или удаления, которое важно. Запросы на чтение тоже считаются, если они выполняются внутри той же транзакции, потому что они держат транзакцию живой, пока последующие записи ждут блокировки.
Простое ревью может выглядеть так:
- пришёл запрос
- открылась транзакция
- приложение загрузило строки user и account
- приложение вызвало другой сервис для проверки цен или мошенничества
- приложение обновило строку order и зафиксировало
Этот средний сервисный вызов — где команды обычно удивляются. В коде блокировка строки может не выглядеть дорогой, но транзакция остаётся открытой, пока приложение ждёт сети.
Отметьте всё, что не является прямой работой с базой данных. Это включает вызовы API, промахи кэша, ретраи, паузы, отрисовку шаблонов, обработку больших JSON и циклы по многим элементам. Даже 200 миллисекунд важны, когда одна и та же строка часто обновляется.
Вынесите как можно больше этой работы за пределы транзакции. Сначала посчитайте итоги. Сначала получите удалённые данные. Постройте сообщения аудита после фиксации, если это не влияет на корректность. Если вам нужны блокировки строк, держите заблокированную часть короткой и простенькой.
Сравните конкурирующие пути кода
Затем проверьте, могут ли два запроса трогать одни и те же строки в разном порядке. Один путь может обновлять accounts, а затем invoices. Другой — обновлять invoices, а затем accounts. Такое несоответствие создаёт ожидания и иногда дедлоки.
Выберите одно правило порядка для записей и придерживайтесь его везде. Если несколько сервисов трогают одни и те же таблицы, запишите этот порядок в комментариях к коду или в заметках ревью, чтобы он не разойтись.
Это простая, но полезная работа. Команды обычно находят одну транзакцию, которая делает слишком много, один сетевой вызов в неправильном месте или два пути записи, которые бьются друг с другом за одну и ту же строку.
Простой пример цепочки задержек
Представьте сервис заказов, который помечает заказ как «оплачен» и уменьшает запас для одной строки инвентаря. Код открывает транзакцию, обновляет заказ, обновляет элемент инвентаря и теперь держит блокировки строк на обеих записях.
В этом нет ничего необычного. Проблема начинается, когда запрос продолжает делать дополнительную работу до фиксации.
Обычная цепочка выглядит так:
- Запрос A начинает транзакцию.
- Он обновляет
orders.id = 8241иinventory.id = 17. - Затем рассчитывает доставку, пишет запись аудита, рендерит PDF‑счёт и ждёт медленного API.
- Только после всего этого он фиксирует транзакцию.
Эти дополнительные шаги могут занимать 300 мс в хороший день и 4 секунды в плохой. Всё это время блокировки строк остаются на месте.
Теперь приходит запрос B. Он маленький. Ему нужно лишь изменить ту же строку заказа, потому что клиент перезагрузил страницу оформления или админ повторил проверку статуса. Запрос B доходит до обновления и останавливается. Сам по себе он не медленный. Он ждёт, пока запрос A отпустит блокировку.
Затем приходит запрос C и хочет ту же строку инвентаря, потому что другой покупатель оформляет последний товар. Он тоже ждёт. Если ваше приложение делает ретраи, вы получите запрос D и E в очереди за ними.
Скоро команда увидит случайные замедления. Задержка API вырастет. Несколько воркеров будут выглядеть «зависшими». База данных может по‑прежнему выглядеть здоровой на первый взгляд. CPU остаётся низким. Диск в порядке. Большинство запросов быстрые. У вас только небольшой набор заблокированных сессий, и они могут приходить и уходить достаточно быстро, чтобы их упустить в дашборде.
Вот почему блокировки строк кажутся хитрыми. Один запрос делает чуть больше внутри транзакции, и каждый запрос, который трогает ту же строку, встаёт в очередь.
Бэклог растёт и вне базы. Воркеры приложения заняты, пока ждут. Пулы соединений заполняются. Новые запросы начинают таймить ещё до того, как доберутся до обновления. То, что началось как одна медленная запись, превращается в цепочку простоев по всему пути записи.
Ошибки, которые создают проблемы с блокировками
Большинство проблем с блокировками начинается вне базы данных. Запрос открывает транзакцию, обновляет строку заказа и затем ждёт чего‑то ещё: платёжный API, кэш или медленный участок кода приложения. Пока этот запрос ждёт, строка остаётся заблокированной. Другие запросы выстраиваются за ним, и замедление кажется случайным, потому что база простаивает часть времени.
Одна распространённая версия встречается в оформлении заказов или админ‑флоу. Приложение вызывает BEGIN, меняет статус, а затем тратит 500 мс на вызов другого сервиса. Полсекунды достаточно, чтобы создать очередь, если несколько пользователей одновременно обращаются к тем же строкам.
Ещё одна ошибка — разделение изменения на «сначала чтение, потом обновление». Код загружает строку, проверяет значение в приложении, затем отправляет обновление отдельным запросом. Этот паттерн растягивает транзакцию и увеличивает вероятность ожиданий. Если один SQL‑запрос может проверить условие и обновить строку, используйте его. Меньше кругов обычно означает меньше времени удержания блокировки.
Общие счётчики и служебные строки быстро вызывают боль. Команды часто хранят одну строку для daily_totals, job_count или last_processed_id, а затем обновляют её на каждом запросе. Эта строка становится горячей точкой. Даже крошечные записи могут блокировать друг друга, когда все работают с одной и той же записью.
Порядок блокировок тоже важен. Одна обработка обновляет accounts, затем invoices. Другая обновляет invoices, затем accounts. Под нагрузкой эти два пути могут попасть в ожидание друг друга или в дедлок. Исправление занудное, поэтому команды часто его пропускают: выберите один порядок для связанных записей и соблюдайте его везде.
Последняя ловушка — скрытые записи. Триггеры, хранимые процедуры и вспомогательные функции могут обновлять дополнительные таблицы, о которых вызывающий код не подозревает. Простая смена статуса может также записать аудит, увеличить счётчик или обновить сводную таблицу. При анализе ожиданий блокировок проследите весь путь записи, а не только запрос, который виден в обработчике.
Если запросу нужно 20 мс SQL, держите транзакцию близкой к этим 20 мс. Всё лишнее обычно превращается в скрытые задержки позже.
Как подтвердить проблему в продакшне
Случайные медленные запросы часто начинаются с одной транзакции, которая оставалась открытой дольше, чем кто‑то ожидал. Если смотреть только на среднее время запроса, вы можете это пропустить. Обновление в 15 мс всё ещё может держать блокировку строки 20 секунд, если приложение делает другую работу перед фиксацией.
Начните с запущенных транзакций, а не только с медленных запросов. В случаях ожидания блокировок возраст транзакции говорит яснее, чем длительность текущего запроса. Сортируйте активные сессии по xact_start и обращайте внимание на сессии со статусом idle in transaction.
SELECT pid,
state,
now() - xact_start AS tx_age,
wait_event_type,
wait_event,
query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST;
Затем разделите сессии на две группы: те, кто выполняет работу, и те, кто ждёт. Если wait_event_type показывает Lock, база не занята на CPU — она ждёт завершения другой сессии.
Далее сопоставьте заблокированные запросы с сессией, которая держит блокировку. pg_blocking_pids(pid) помогает быстро найти блокировщика. Читайте оба запроса вместе. Заблокированный запрос часто выглядит безобидно. Блокирующий — обычно предыдущая запись, которая затронула ту же строку и затем осталась открытой, пока приложение делало что‑то ещё.
Несколько полей сильно упрощают картину: pid, возраст транзакции, wait_event_type, pid блокирующей сессии и текст текущего запроса.
После этого сравните тайминги базы с трассировками приложения. Совместите время начала запроса, время начала транзакции и момент, когда запрос замедлился. Если трассировка показывает вызов внешнего сервиса, цикл ретрая или дополнительную бизнес‑логику внутри той же транзакции, вы, вероятно, нашли источник очереди.
Зафиксируйте оператор, который начал цепочку. Команды часто логируют только заблокированный update и на этом останавливаются. Это упускает реальную причину. Важно зафиксировать первый оператор, который захватил строку, даже если он исполнился быстро.
Быстрые проверки перед релизом
Много проблем с блокировками начинается с кода, который выглядит безвредно в ревью. Один лишний запрос, один вызов API или один цикл внутри транзакции может превратить быстрый запрос в очередь при росте трафика.
Перед выпуском проверьте несколько базовых вещей:
- Держите каждую транзакцию короткой. Делайте минимум чтений и записей, затем фиксируйте.
- Трогайте общие строки в одном и том же порядке каждый раз.
- Вынесите внешние вызовы из тела транзакции.
- Тестируйте загруженные эндпоинты с конкурентным трафиком, а не только одним запросом.
Это правило порядка строк звучит мелко, но важно. Если фоновая задача обновляет сначала customer, затем invoice, webhook не должен обновлять invoice первым, а customer вторым. Так возникают дедлоки и скрытые задержки в приложениях, которые выглядели стабильными при простых тестах.
Здесь блокировки строк перестают быть абстракцией. Не нужна огромная система, чтобы столкнуться с проблемой. Поток регистрации, счётчик купонов, резервирование места или обновление запасов способны вызвать её, если транзакция остаётся открытой слишком долго.
Перед merge задайте два прямых вопроса: «Какие строки блокирует этот запрос?» и «Какая работа выполняется до фиксации, и нужна ли она там?» Если ответы неясны, код, вероятно, требует ещё одного прохода.
Что менять дальше
Не пытайтесь гнаться за каждым медленным запросом сразу. Выберите один путь записи, по которому люди ходят всё время, отобразите каждый шаг внутри транзакции и запишите, где приложение читает, пишет, ретраит или ждёт до фиксации.
Одна такая карта обычно даёт больше, чем ещё одна неделя догадок. Команды устраняют проблемы с блокировками быстрее, сокращая время транзакций, чем добавляя процессор или память базе.
Первый проход прост. Выберите горячий путь, например оформление заказа, обновление счёта или взятие задания. Перечислите все SQL‑операторы в порядке. Отметьте всё, что происходит внутри транзакции, но не обязательно должно. Запишите, какие строки часто трогают одновременно.
Затем сделайте ожидания блокировок видимыми в обычном рабочем цикле ревью. Если команда уже следит за ошибками, развёртыванием и временем отклика, добавьте в этот набор метрики времени ожидания блокировок. Когда ожидания скрыты, люди винят случайную медленность, серверы приложений или всплески трафика. Как только ожидания появляются на графике или в логах запросов, картина быстро проясняется.
Код‑ревью тоже должен иметь явное правило. Транзакции должны оставаться короткими, и ревьюеры должны требовать исправлений, когда код делает лишнюю работу до фиксации. Типичные примеры: вызов внешнего сервиса, загрузка лишних строк, тяжёлая валидация в конце или удерживание транзакции пока приложение строит ответ.
Простое командное правило работает хорошо: открывайте транзакцию как можно позже, трогайте минимальное количество строк и фиксируйте сразу после записи. Если шаг можно выполнить до или после транзакции — перемещайте его.
Не превращайте это в масштабный рефакторинг. Исправьте один путь записи, измерьте эффект и переходите к следующему по тому же методу. Два‑три небольших исправления часто устраняют самые худшие заразы.
Если хотите второй взгляд, Oleg Sotnikov на oleg.is работает как временный CTO и советник для стартапов. Он помогает стартапам и небольшим командам просмотреть область транзакций, пути записи и продакшен‑инфраструктуру, чтобы такие проблемы устранялись до того, как разойдутся по приложению.
Часто задаваемые вопросы
В чём разница между медленным запросом и ожиданием блокировки?
Медленный запрос тратит время на выполнение работы. Ожидание блокировки (lock wait) быстро находит строку, а затем простаивает, пока другая транзакция не зафиксирует изменения.
В дашбордах они похожи, потому что оба выглядят как долгое выполнение запроса. Но исправление разное — перед тем как оптимизировать SQL или добавлять индексы, проверьте, не показывает ли сессия Lock.
Как подтвердить, что задержку вызывают блокировки строк?
Начните с pg_stat_activity и отсортируйте по xact_start. Ищите старые транзакции, сессии в состоянии idle in transaction и случаи, где wait_event_type — Lock.
Затем сопоставьте заблокированные сессии с блокировщиком через pg_blocking_pids(pid). Сравните это с трассировкой приложения — обычно вы увидите, что блокирующий запрос выполнил запись и затем ждёт чего‑то ещё до фиксации.
Что такое горячая строка?
Горячая строка — это одна запись, которую много запросов постоянно обновляют. Обычно это один товар на складе, баланс аккаунта, заказ или общий счётчик.
Даже очень маленькие записи превращаются в очередь, когда многие запросы бьют по одной и той же строке, а одна транзакция держит её слишком долго.
Стоит ли выносить вызовы API за пределы транзакции?
Да. Открывайте транзакцию непосредственно перед нужной записью, а затем фиксируйте изменения как можно скорее.
Если вы вызываете платёжный сервис, отправляете письмо, проверяете мошенничество или делаете другой сетевой запрос внутри транзакции, вы держите блокировку строки открытой, пока сеть не ответит. При одновременном трафике это быстро распространяется.
Почему состояние idle in transaction — проблема?
Потому что это держит транзакцию открытой, хотя приложение ничего не делает. Все блокировки от этой транзакции остаются живыми, и другие запросы выстраиваются за ними.
Даже короткая пауза вредна, если много запросов обращаются к одной и той же записи. Исправьте путь так, чтобы транзакция сразу фиксировалась или откатывалась.
Может ли одна заблокированная строка замедлить всё приложение?
Да. Ожидающие запросы всё ещё занимают рабочие потоки приложения и слоты в пуле соединений. Как только пул заполнится, новые запросы будут ждать ещё до того, как доберутся до заблокированной строки.
Поэтому одна длинная транзакция может выглядеть как массовая замедленность приложения, даже если CPU и диск в порядке.
С чего начать, чтобы сократить время блокировки?
Сначала сузьте область транзакции. Валидируйте ввод, получите удалённые данные и выполните тяжёлую работу до начала транзакции, когда это возможно.
Внутри транзакции трогайте минимум строк и фиксируйте сразу после записи. Это обычно убирает самые сильные задержки быстрее, чем попытки масштабировать базу или добавлять ресурсы.
Усугубляет ли ситуацию паттерн «сначала чтение, потом обновление»?
Да. Когда код сначала читает строку, проверяет условие в приложении, а потом обновляет её отдельным запросом, это добавляет круги и удлиняет транзакцию.
Если одной SQL‑операцией можно проверить условие и обновить строку, используйте её. Меньше кругов — меньше времени удержания блокировки и меньше шансов на состязание между запросами.
Как избежать дедлоков, когда два пути трогают одни и те же таблицы?
Выберите один порядок записей для связанных таблиц и соблюдайте его всюду. Если один путь делает accounts затем invoices, другие пути тоже должны соблюдать этот порядок.
Просмотрите задания, вебхуки, админ‑операции и обработчики запросов вместе. Смешивание порядка создаёт ожидания и может привести к дедлокам под нагрузкой.
Что нужно проверить перед выпуском изменений с большим количеством записей?
Задайте два прямых вопроса перед merge: какие строки блокирует этот запрос и какая работа выполняется до фиксации, которая не обязательна?
Прогоняйте конкурентные тесты по загруженным путям, а не одиночные запросы. Проблемы с блокировками часто скрыты в коде, который выглядит нормальным при одиночных тестах.