Все записи
6 мин

Три месяца продукт был мёртв. Запустил обратно — и поймал дедлок БД прямо во время рассылки

Building in Publicинфраструктураtelegram mini app

Если у вас лежит закрытый три месяца назад продукт и вы думаете, что воскресить его — это неделя возни, то расскажу как было у меня. За один день я вернул боевую базу на 178 пользователей, заодно нашёл денежную дыру через которую утекал разовый велком-оффер, разослал всем «Картара вернулась», а потом сам зашёл проверить прямо в момент рассылки и поймал инцидент: карта дня показала «готовится» и упала в «недоступна». Причина оказалась не в коде самой фичи, а в том как Postgres держал advisory-блокировку всё время пока нейросеть генерила синтез, секунд двадцать, и параллельные запросы под наплывом людей просто упирались в lock_timeout. Чинил на живом проде, и ниже вся история целиком, без причёсывания.

Сначала надо было достать труп из земли

Картару я отключил 18 марта, и она пролежала закрытой примерно три месяца. Это Telegram Mini App с AI-персонажем, и за то время что она работала, там накопилась живая боевая база: реальные люди, реальные расклады, реальная история переписок. И вот когда я решил запускать её обратно, первое что надо было сделать — это аккуратно поднять эту базу, а не залить поверх пустышку и потерять всех.

Тут я и словил первую засаду. Бэкап через pg_restore я попытался влить, но база на той стороне оказалась непустой, и restore слился туда поверх существующих объектов так, что получилась каша. Чистого пути «просто восстановить дамп» не вышло. Пришлось делать DROP SCHEMA public CASCADE, то есть снести схему целиком, и уже в пустую базу заливать дамп заново. Звучит страшно, и это реально та операция перед которой надо три раза проверить что у тебя на руках именно тот дамп который нужен, потому что CASCADE сметает всё подчистую. Но это был единственный честный способ получить чистое состояние без призраков от первого, неудачного restore.

После заливки я почистил базу от мусора который туда натекал за время жизни продукта. Было 180 пользователей, стало 178: выпилил один дубль по telegram_id и два тестовых аккаунта, мои же. Вот эти 178 возвращающихся юзеров и есть тот актив ради которого вся возня и затевалась. Не «178 строк в таблице», а 178 человек, которые когда-то зашли, что-то про себя узнали, и которым теперь можно сказать что продукт жив.

Денежная дыра, которая покупается заново

Пока возился с базой, наткнулся на штуку от которой у меня дёрнулся глаз. Цитирую сам себя дословно из той сессии: «Велком оффер который покупается. 60 токенов за 99 рублей. Это же разовый пакет. Я его купил а он у меня остался.»

Перевожу на нормальный язык. Есть велком-оффер, разовый пакет, 60 токенов за 99 рублей, который человек по логике должен купить один раз и больше никогда. А по факту он оставался покупаемым повторно, потому что на сервере не было флага что пакет одноразовый. Никакого is_one_time. То есть клиент мог хоть десять раз взять «разовый» оффер, и система бы радостно его пропускала каждый раз. Для пользователя это звучит как «ну и хорошо, дешёвые токены», а для продукта это значит что весь смысл велком-цены — зацепить новичка один раз — просто протекает, и экономика оффера ломается. Чинится это серверным флагом, который проверяется до выдачи, а не на фронте где его кто угодно обойдёт. Это та же история, что и когда у меня цена на токены жила сразу в нескольких местах и фронт с бэком разъехались — любую денежную логику держишь на сервере в одном месте, иначе она протекает. Вот это и есть та разница про которую я всё время талдычу: вы видите фасад, кнопку «купить за 99», а как оно сделано внутри и где там дырки — не видите, пока сами не зайдёте и не купите свой же оффер второй раз.

Разослал «вернулись», сам зашёл проверить и поймал инцидент

Дальше я отправил рассылку, то самое «Картара вернулась». И тут включился тот самый рефлекс который спас мне не один проект: я не стал верить что всё ок, а сам открыл приложение проверить, как обычный юзер. И вот опять дословно из сессии, потому что лучше я не скажу: «Блять прикол. Разослал все. Сам зашел проверить. Карта дня готовится типа, а потом хоп. И недоступна.»

То есть фича, которую люди в первую очередь и пойдут смотреть после рассылки, падала. Карта дня показывает «готовится», а через момент уже «недоступна». И падала она ровно потому что в этот момент в приложение заходила толпа разом, ведь рассылка только что ушла.

Стал копать причину. Карта дня — это не просто запрос в базу, там внутри идёт генерация синтеза через LLM, и она занимает порядка двадцати секунд. Нормально для AI, никто за это не ругает. Проблема была в том как вокруг этой генерации был обмотан Postgres. На время генерации висела advisory-блокировка на уровне транзакции, xact-lock, и держалась она всё время пока нейросеть думала, все эти двадцать секунд. А пока висит лок, любой параллельный запрос который хочет тот же ресурс, встаёт в очередь и ждёт. И когда после рассылки люди полезли пачкой, эти параллельные запросы просто не дожидались своей очереди и упирались в lock_timeout, отсюда и «недоступна».

Чинил на живом проде, и проверил что не вру

Тут красивого решения «остановим прод, спокойно переделаем архитектуру, протестим неделю» не было. Люди уже получили рассылку и уже заходили прямо сейчас. Так что чинить пришлось на живом проде.

Фикс по сути в двух вещах. Первое — read-only запросы вообще перестали брать лок, ведь если ты просто хочешь прочитать готовую карту, тебе незачем кого-то блокировать, ты ничего не меняешь. Второе — для записи коммит транзакции делается до вызова LLM, а не после. То есть мы фиксируем состояние в базе, отпускаем блокировку, и только потом идём на эти двадцать секунд к нейросети. Тогда долгая генерация перестаёт держать чужие запросы за горло, и наплыв людей перестаёт ронять фичу.

Код под этот фикс писал, как обычно, не я руками — я оркестрировал Claude Code, он правил логику блокировок, я разбирался где именно держится лок и проверял что переделка не сломает запись. Сам деплой на прод был грубым и быстрым: docker cp нового кода в контейнер и рестарт, потому что собирать полный релиз посреди наплыва было бы дольше и рискованнее. И главное — я не оставил это на «ну вроде заработало». Зашёл и проверил замеры: генерация 24.6 секунды, ожидание блокировки 0.00 секунды, ошибок ноль. Вот это для меня и есть «починил» — не зелёный контейнер в статусе Up, а реальные цифры что фича отрабатывает под нагрузкой и никто больше не ждёт лок.

Что я из этого вынес

Первое и самое денежное: долгая LLM-генерация и блокировки базы — это вообще не сочетающиеся вещи, и если у вас в приложении есть AI который думает по двадцать секунд, проверьте что он в это время не держит лок которого ждут другие. Под одним пользователем вы этого никогда не увидите, всё будет летать. А в момент когда людей становится много разом, например прямо после рассылки, оно и выстрелит вам в лицо, ровно в самый показательный момент. Я именно так и поймал, на собственном запуске.

Второе, не про код а про деньги: проверяйте монетизацию руками, как обычный покупатель. Я нашёл дыру с велком-оффером не потому что читал код, а потому что купил свой же разовый пакет и он у меня остался. Любая логика «это можно только один раз» должна жить на сервере с честным флагом, а не на доверии к фронту.

И третье, общее. Воскресить мёртвый продукт — это не магия и не недели. Это аккуратно поднять боевую базу через чистый restore, выгрести из неё мусор, заткнуть денежные дыры и не побояться чинить на живом проде когда припёрло. За один день у меня из этого собрался запущенный обратно продукт с 178 живыми юзерами, и я закрыл ту сессию ровно так, как и хотел: «Официально запустились обратно. Все. Работаем дальше». Вот и делайте выводы.