Если у вас приложение вдруг перестало открываться целиком, а не отвалилась какая-то одна кнопка, и при этом ничего не падает с ошибкой, а всё просто висит, то первое, куда я бы советовал смотреть, это блокировки в базе. У меня в Картаре утром именно так и было: не грузился профиль, висела карточка карты дня, и со стороны выглядело так, будто умер весь сервер. А на деле виноват оказался один медленный вызов нейросети, который кто-то умный (то есть я, оркестрировавший Claude Code) засунул внутрь транзакции, державшей advisory lock. И пока этот вызов думал, лок не отпускался, и все остальные запросы выстраивались за ним в очередь и тихо умирали по таймауту.
Давайте по порядку, потому что история поучительная и я в неё вляпался не на ровном месте.
Утро, когда ничего не открывается
Симптом был максимально мутный. Не «ошибка 500», не «база недоступна», а вот это ощущение, которое я в тот момент и сформулировал примерно так: «не открывается профиль и все. карточка ежедневки тоже загрузиться не может. хз че сломалось». То есть даже я, человек, который это и собирал, в первую секунду не понимал, куда копать. Никаких красных логов, никаких упавших контейнеров, всё Up, всё зелёное, а пользователь видит вечную загрузку.
И вот это самое противное в дедлоках по блокировкам: они не кричат. Запрос не падает, он ждёт. А пока он ждёт, он сам держит то, чего ждут другие, и через минуту у тебя уже не один зависший запрос, а вообще всё.
Где сидел лок
Корень нашёлся в сервисе карты дня. Там при сборке ежедневной карточки код брал PostgreSQL advisory lock через `pg_advisory_xact_lock` — это блокировка на уровне транзакции, которую ты сам себе выдаёшь по какому-то ключу. Ключ формировался из связки «пользователь плюс дата» и через `hashtext` превращался в число, по которому база и вешала замок. Идея сама по себе нормальная: смысл был в том, чтобы два параллельных запроса от одного человека не сгенерировали ему две разные карты дня одновременно. Логично же, заходишь, лочишься, проверяешь есть ли карта, если нет, создаёшь, отпускаешь.
Проблема была не в самом локе, а в том, что происходило внутри него. А внутри, в той же самой транзакции, под тем же самым замком, шёл вызов LLM на генерацию текста карты. И вот это уже катастрофа, потому что обращение к нейросети это не миллисекунды, это секунды, а иногда и десятки секунд, если модель тупит или сеть моросит. И всё это время транзакция открыта, лок занят, а любой следующий запрос, который дёргает тот же `hashtext` от того же ключа, встаёт колом и ждёт.
Дальше эффект домино. Один пользователь зашёл, лок занялся, нейросеть думает. Пока она думает, у того же человека повторно дёрнулся профиль (а фронт любит ретраить), и этот запрос упёрся в тот же замок. Пул соединений к базе не резиновый, и довольно быстро все коннекты оказались заняты ждущими запросами. А раз коннектов нет, то даже те части приложения, которые с локом вообще никак не связаны, тоже перестают отвечать, потому что им не из чего сходить в базу. Вот и весь фокус: одна медленная генерация текста положила вообще всё.
Это было не в одном месте
Самое неприятное я понял, когда полез смотреть, а нет ли такого же ещё где-нибудь. И конечно нашёл. Тот же паттерн «беру лок, держу его, внутри зову нейросеть» жил ещё в трёх местах: в обычном гороскопе он повторялся дважды и отдельно в персональном гороскопе. То есть это была не случайная опечатка в одном файле, а устоявшаяся привычка, которую я через агента тиражировал из фичи в фичу, потому что один раз так написал и потом копировал паттерн как рабочий.
И вот тут важный момент про разработку с нейросетью, который я для себя зафиксировал. Claude Code прекрасно пишет код, но он повторяет твои же решения. Если ты заложил кривую архитектурную мысль в первый сервис, AI-агент с удовольствием размножит её по всем остальным, аккуратно, чисто, с типами и комментами. Кривое решение, написанное красиво, оно от этого не перестаёт быть кривым. Так что когда у тебя в проекте один баг находится в четырёх местах сразу, это не четыре бага, это один баг в голове, просто скопированный. У меня так уже было, когда одна и та же цена жила в нескольких местах и фронт с бэком разошлись, история ровно той же породы.
Быстрый фикс или нормально
Был соблазн сделать как обычно и заткнуть. Поднять таймаут на лок или вообще выкинуть этот лок к чёрту и понадеяться, что гонок не будет. Но я в тот момент себе сказал ровно так: «никаких быстрых фиксов, надо нормально делать сразу». Потому что быстрый фикс тут это либо снова дедлок через неделю, либо двойные карты дня у пользователей, и непонятно ещё что хуже.
Нормально это значит разобраться, зачем лок вообще был, и закрыть ту же задачу без него. А задача-то простая: не дать создаться двум записям на одного человека за одну дату. И для этого в базе уже всё было. У меня там и так висели уникальные ограничения, на карту дня по связке «пользователь и дата» и на гороскоп по знаку зодиака и дате. То есть база сама физически не даст вставить дубликат, она просто ругнётся на нарушение уникальности. Зачем тогда нужен был ручной advisory lock поверх этого? Да низачем, это был лишний слой защиты от того, что и так защищено.
Что в итоге сделал
Фикс собрался из нескольких частей, и каждая закрывает свой угол.
Первое и главное, я вынес генерацию текста нейросетью за пределы транзакции. Логика стала такая: сначала спокойно, без всяких замков, обращаемся к LLM и получаем текст, и это может занять сколько угодно секунд, никому не мешает. И только потом, когда текст уже на руках, открываем коротенькую транзакцию и пишем результат. Транзакция теперь живёт миллисекунды, а не секунды, и держать её под локом больше нечем.
Второе, я убрал сам advisory lock и заменил его на честный upsert через уникальный индекс. То есть пишем «вставь или, если уже есть запись по этому ключу, ничего не делай». Конкурентность теперь разруливает сама база на уровне уникального ограничения, а не я руками через блокировку. Это и проще, и надёжнее, потому что база умеет делать это атомарно по определению, а я через лок только имитировал то, что у меня и так было под капотом.
Третье, добавил `statement_timeout`. Это страховка на будущее: если вдруг какой-то запрос всё же решит висеть вечно, база сама его прибьёт через заданное время, а не даст ему утащить за собой весь пул. Лучше один упавший запрос с понятной ошибкой, чем тихо умирающее приложение, в котором «хз че сломалось».
И четвёртое, прикрутил нормальный docker healthcheck, чтобы инфраструктура хотя бы видела, что сервису плохо, а не считала его здоровым только потому, что контейнер не упал. Потому что в то утро формально всё было «работает», а по факту не открывалось вообще ничего, и это та же история, что и при подъёме Картары с нового сервера, где эндпоинт упал бы прямо на свежем проде, пока я не залез проверить руками.
Что я из этого вынес
Главный урок даже не технический, хотя правило простое и его стоит повесить на стену: никогда не зовите медленный внешний вызов, будь то нейросеть, или HTTP к чужому API, да хоть что, внутри транзакции, которая держит блокировку. Транзакция с локом должна быть максимально короткой и тупой: прочитал, записал, отпустил. Всё, что долго и непредсказуемо по времени, выноси наружу.
А второй урок про сам процесс. Когда код за тебя пишет AI-агент, ты экономишь часы, но ты же и масштабируешь свои собственные ошибки. Я этот лок один раз придумал не подумав, а дальше он расползся по четырём местам, потому что копировать рабочий с виду паттерн это самое естественное, что есть. Так что разбор одного дедлока в карте дня превратился в чистку всей этой логики разом по всему проекту, и это, честно, правильно вышло. Когда уже лезешь чинить, чини всё гнездо, а не одно яйцо. У меня в Картаре это, кстати, не первый случай, когда вроде мелкая невнимательность вылезала боком, про приватность сессий между устройствами я уже как-то писал отдельно, и там корень был такой же породы: вроде логично написал, а на проде вылезло то, чего не ждал.
По времени вся эта история, от «хз че сломалось» до выкаченного нормального фикса, заняла одно утро. Не неделю, не три дня. Но только потому, что я не стал клеить пластырь, а сразу полез смотреть, зачем оно вообще так было сделано. Вот и делайте выводы.