Как одно слово удалило 700 тысяч записей: ретроспектива Botseller, неделя 127

Как одно слово удалило 700 тысяч записей: ретроспектива Botseller, неделя 127

Разбор этой статьи

AI-подкаст BotsellerКак одно слово удалило 700 тысяч записей
0:00 / 0:00

Эту тему разобрали в подкасте. Слушай параллельно с чтением.

Я Дмитрий Дьяконов, основатель Botseller AI. Это двенадцатый выпуск серии «Ретроспектива», бортжурнал в прошлое: от настоящего к первому коммиту. Если предыдущий выпуск был про пересборку биллинга, где мы нашли правильную единицу ценности и заменили materialized view на прямые связи, то сегодня я расскажу, почему эта пересборка стала неизбежной. Отматываю на 5-11 января 2026 года. У этого выпуска есть точная дата рождения: воскресенье 11 января, без одной минуты полдень. В этот момент я понял, что слово CASCADE нужно читать как предупреждение на упаковке яда.

Вы, читая серию в обратном порядке, уже знаете финал. В неделе 130 я мимоходом упомянул «январский вечер с каскадными удалениями» и обещал отдельный выпуск. Вот он. Рассказываю без купюр: что строили, как сломали, как починили, и какой чек-лист теперь висит у нас над каждой операцией с базой.

Две стройки одновременно

Начало января 2026 года. Праздники закончились, и неделя стартует с двух параллельных треков, каждый из которых важен.

Первый трек: CRM обрастает новой ролью. У нас появился первый клиент с фитнес-студией, и ему нужна роль «тренер» в системе. Тренер видит расписание и свои группы, но не видит чужие данные и не может менять настройки. За понедельник-вторник мы собрали полный контур: константы ролей, фильтрация данных по роли, ограничение маршрутов на фронте, исправление бага подсчёта слотов при записи на занятие. Четыре коммита, каждый закрывает свою грань одной задачи. Чистая, контролируемая работа.

Второй трек: тот самый биллинг, перестройка которого станет темой следующего выпуска. К среде 7 января мы запускаем систему диалогового биллинга v2. Идея простая: вместо подсчёта токенов считать диалоги. Один разговор клиента с ботом в течение 24 часов это одна единица тарификации. Для подсчёта используется materialized view, которое автоматически группирует транзакции в диалоги. Удобно. Элегантно. И смертельно опасно, как выяснится через четыре дня.

Высокая скорость, иллюзия безопасности: два параллельных трека недели 127, CRM и биллинг

Ловушка элегантной архитектуры

Почему я выбрал materialized view? Потому что это казалось инженерно правильным. У нас была таблица транзакций с сотнями тысяч записей. Нужно было группировать их по окнам в 24 часа: один клиент, один бот, один мессенджер. Materialized view делает это на уровне базы данных, без прикладного кода. Одна SQL-команда, и все диалоги посчитаны.

К среде 8 января система стала сложнее: атомарное создание транзакций с балансом, защита маржи, постоплата для B2B-клиентов, фоновая задача на синхронизацию каждые пять минут. Пятнадцать коммитов за два дня. Работало. Проходило тесты. Клиенты начали получать корректные счета.

Проблема была не в логике. Проблема была в связях между таблицами.

Ловушка элегантной архитектуры: Materialized View скрывал длину цепочки зависимостей

Четверг-пятница: обогащение метаданных

Параллельно с биллингом мы обогащали транзакции метаданными. Каждая транзакция теперь хранила не просто «потрачено X рублей», а полный контекст: какой бот ответил, какой промпт сработал, какой клиент написал, ссылка на воронку. Двенадцать коммитов за два дня, каждый добавлял новое поле или исправлял баг совместимости типов.

Зачем это было нужно? Для страницы биллинга, которую мы в это же время строили на фронте. Пользователь должен видеть не абстрактные цифры, а конкретику: вот этот диалог с Марией стоил столько-то, бот использовал такую-то модель, вот ссылка на переписку.

Красиво. Полезно. И всё это данные, которые через два дня чуть не исчезнут.

Воскресенье, 11 января, 11:59

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

При работе с базой данных на продакшене нужно было очистить одну таблицу от тестовых данных. Обычная операция: убрать мусор, оставить боевые записи. Команда TRUNCATE делает это быстро и чисто, в отличие от DELETE она не проходит строка за строкой, а просто обнуляет таблицу. Быстро. Эффективно. И одно слово в конце команды изменило всё: CASCADE.

Воскресенье, 11:59: обычная задача очистки тестовых данных превратилась в катастрофу из-за одного слова

TRUNCATE test_data_table CASCADE.

Что делает CASCADE? Оно говорит базе данных: если другие таблицы ссылаются на эту через внешние ключи, очисти и их тоже. И те, на которые ссылаются они. И те, на которые ссылаются те. Как вирус, команда прошла по каждому внешнему ключу. Одна команда, одно слово, и в течение секунд из базы исчезли 700 000 записей. Не из одной таблицы. Из нескольких связанных: транзакции, балансы, записи биллинга, метаданные.

Анатомия цепной реакции: одна таблица потянула за собой транзакции, балансы, записи биллинга и метаданные

Первые 30 секунд

Запрос выполнился подозрительно быстро. В консоли ни одной ошибки. Сервер работает, боты отвечают, фронтенд рисует страницы. Но начинаешь проверять и понимаешь масштаб. Таблица транзакций пуста. Таблица балансов пуста. Связанные записи исчезли по цепочке.

Физическое ощущение падения: под капотом полностью стёрта история биллинга. «Query returned successfully in 45ms» в зелёном терминале, и тишина.

Первые 30 секунд: тишина в терминале, графики падают в ноль, а в консоли зелёная галочка «Query returned successfully»

Бекап спас данные, но не достоинство

У нас всегда есть бекапы. Это не обсуждается, это гигиена. Ежедневные снапшоты базы, ротация за последние 7 дней. Воскресный снапшот был свежим: данные за последние сутки.

Восстановление заняло менее часа. 700 000 записей вернулись на место. Ни одна запись клиента не потеряна навсегда. Система продолжила работать так, будто ничего не было.

Но «мы всё восстановили» это не инженерия, это везение. Продукт, который позиционирует себя как надёжную платформу для бизнеса, не имеет права терять данные даже на час. Если бы это случилось в понедельник утром, когда сотни ботов обрабатывают входящие сообщения, история была бы другой. Бекапы это парашют. Они спасают жизнь. Но лучше не прыгать из самолёта, который исправен.

Бекап спас данные, но не достоинство: свежий снапшот позволил восстановить всё, но в понедельник утром это была бы катастрофа

Три структурных отказа

Разберу по частям, что именно пошло не так. Не чтобы оправдать, а чтобы вы могли проверить, нет ли у вас того же.

Первый отказ: заряженное ружьё в DDL. Когда создаёшь внешний ключ между таблицами, можно указать поведение при удалении: RESTRICT (запретить), SET NULL (обнулить ссылку), CASCADE (удалить зависимые). CASCADE кажется удобным при разработке: удалил родительскую запись, и все дочерние подчистились автоматически. Но на продакшене с живыми данными это ружьё, которое когда-нибудь выстрелит.

Второй отказ: ноль предохранителей. Ни один скрипт, ни одна политика не мешала выполнить TRUNCATE CASCADE на боевой базе. Не было ни подтверждения, ни задержки, ни алерта «вы собираетесь удалить N записей из M таблиц». Одна команда, один Enter.

Третий отказ: невидимые связи. Materialized view зависел от таблицы транзакций. Транзакции зависели от биллинга. Биллинг ссылался на клиентов. Цепочка зависимостей была длиннее, чем казалось при взгляде на одну таблицу. Чтобы увидеть полную картину, нужно было проверить все зависимости заранее.

Три структурных отказа: CASCADE в DDL, ноль предохранителей, невидимые связи через Materialized View

Матрица связей: удобство vs. безопасность

Вот как выглядят три варианта поведения внешних ключей при удалении, если честно сравнить их на dev-окружении и на продакшене.

CASCADE на dev-окружении удобен: удалил запись, и все зависимые подчистились автоматически. На проде это цепное удаление данных. SET NULL допустим на dev, но на проде оставляет осиротевшие записи без родителя. RESTRICT на dev раздражает: выдаёт ошибки, заставляет удалять вручную. Но на проде это золотой стандарт: лучше получить жёсткую ошибку «нельзя удалить», чем молча стереть половину базы.

Матрица связей: CASCADE, SET NULL и RESTRICT в сравнении на dev-окружении и на продакшене

Пересборка: от магии к явным связям

В тот же день, 11 января:

  • Отключили фоновую синхронизацию биллинга (чтобы система не пыталась работать с пустыми таблицами)
  • Пересоздали миграцию через autogenerate (чтобы структура базы была чистой)
  • Починили баг потери транзакций из-за race condition между обработчиками (нашли параллельно, при расследовании)

На следующий день, 12 января, начали полную пересборку архитектуры: убрали materialized view, заменили на прямые FK-связи с partial unique index. Написали 1024 строки документации BILLING_ARCHITECTURE.md. Не потому что любим документацию, а потому что после инцидента каждый, кто прикасается к биллингу, должен видеть полную карту зависимостей, прежде чем написать хоть один запрос.

Пересборка: от магии к явным связям с RESTRICT вместо скрытых зависимостей через Materialized View

Чек-лист: перед удалением на проде

Этот чек-лист висит у нас с 11 января 2026 года. Публикую, потому что если вы работаете с базами данных, он вам нужен.

  1. Проверь зависимости. Перед любым DELETE или TRUNCATE выведи список таблиц, которые ссылаются на целевую. Если список непустой, остановись.
  2. Никогда CASCADE на проде. Просто никогда. Если нужно удалить связанные данные, делай это явно, таблица за таблицей, в правильном порядке.
  3. Свежий снапшот. Не «у нас есть ежедневный бекап». А конкретно: сними снапшот прямо сейчас, перед этой конкретной операцией.
  4. WHERE обязателен. DELETE без WHERE это замаскированный TRUNCATE.
  5. Сначала SELECT, потом DELETE. Выполни тот же запрос с SELECT COUNT(*), убедись что число ожидаемое, и только потом меняй SELECT на DELETE.
  6. Двое глаз. Любая деструктивная операция на боевой базе требует аппрува второго инженера.

Чек-лист: шесть правил перед удалением данных на продакшене

Урок фаундера: правило одного слова

Вот что я вынес из этого воскресенья как фаундер, а не как инженер.

В продукте, где хранятся чужие деньги и чужие данные, между нормальной работой и катастрофой расстояние в одно слово. Одно ключевое слово в одной команде. Не взлом, не сбой оборудования, не стихийное бедствие. Просто одно лишнее слово, которое ты написал потому что так привык, потому что на dev-окружении это было удобно, потому что не подумал о масштабе.

Мой фреймворк с тех пор: «Думай масштабом последствий, а не удобством команды». Каждый раз, когда пишешь что-то, что меняет данные, спроси себя: если я ошибся, сколько записей пострадает? Одна? Сто? Семьсот тысяч? И если ответ не «одна», добавь предохранитель. Любой. Подтверждение, проверку, паузу. Что угодно между твоим пальцем и кнопкой Enter.

Бекапы это парашют. Они спасают жизнь. Но лучше не прыгать из самолёта, который исправен.

Урок фаундера: правило одного слова, удобство команды против масштаба последствий

Цифры недели

Четыре репозитория, 60+ коммитов. Из них 10 коммитов на роли тренеров в CRM, 25+ на систему биллинга, 15+ на обогащение метаданных. Один инцидент. 700 000 удалённых записей. Ноль потерянных навсегда (спасибо бекапам). Один чек-лист, который работает по сей день.

FAQ

Что такое CASCADE в базе данных?

CASCADE это модификатор, который говорит базе данных: при удалении записи автоматически удали все записи в других таблицах, которые на неё ссылаются. Удобно для dev-окружения, опасно на продакшене с живыми данными.

Почему не RESTRICT вместо CASCADE?

RESTRICT запрещает удаление, если есть зависимые записи. После инцидента мы поменяли все критичные связи на RESTRICT: лучше получить ошибку «нельзя удалить», чем молча потерять данные.

Как защитить продакшен-базу от случайных удалений?

Три уровня: 1) используйте RESTRICT на внешних ключах для критичных таблиц; 2) заведите политику «никогда CASCADE на проде»; 3) настройте автоматические бекапы с проверкой восстановления. Бекап, который не проверен восстановлением, это иллюзия безопасности.

Узнали ли клиенты об инциденте?

Нет. Инцидент случился в воскресенье, восстановление заняло менее часа, данные вернулись полностью. Но это везение, а не заслуга. С тех пор у нас есть процедура, чтобы это не зависело от дня недели.

Что такое materialized view и зачем от него отказались?

Materialized view это предвычисленная «витрина» данных в базе. Удобна для аналитики, но создаёт скрытые зависимости: при очистке основной таблицы view тоже затрагивается. Мы заменили её на прямые связи (FK), где зависимости явные и контролируемые.

Какой главный урок инцидента?

Расстояние между нормальной работой и катастрофой это одно слово, один клик, одна непроверенная команда. Предохранители нужны не «когда-нибудь потом», а до первого инцидента. После первого уже поздно учиться на чужих ошибках.