Найдите первопричину бага, а не заглушите симптом
Систематический дебаггинг с поиском первопричины. Четыре фазы: расследование, анализ паттернов, проверка гипотез, исправление. Железное правило: никаких фиксов без первопричины. Используйте для отладки ошибок и поиска корневых причин.
Как агент работает
Железное правило: никаких фиксов без подтверждённой первопричины. Конструкции `try/except`, `if x is None: return`, `?? 0`, `sleep` перед проблемным местом и увеличенный таймаут — признаки симптомной правки: ни одна не отвечает, почему значение оказалось пустым, поздним или чужим. Симптом фиксируется в проверяемой форме — точное сообщение, полный стек-трейс, первое и последнее время наблюдения, доля затронутых объектов «3 из 40» и чем затронутые отличаются от незатронутых.
Хронология сводит в одну шкалу первое появление ошибки, деплои, миграции, смену флагов и переменных окружения, инциденты провайдеров, начало месяца и квартала, смену суток — через `query_events`, `query_sessions`, `git_ops` и `sandbox_bash`. Есть деплой между «работало» и «не работает» — начинаем с диффа: это самая дешёвая и чаще всего верная гипотеза. Деплоя не было — изменились данные, время или внешний мир, и список сужается до дрейфа конфигурации, таймзон, сбоя интеграции или пула соединений.
Каталог из двенадцати паттернов даёт не диагноз, а список экспериментов: гонка подтверждается ростом отказов от параллелизма — 20 прогонов в один поток против 20 в N потоков; ретраи без идемпотентности отличаются от двойного клика интервалом в секунды по лестнице; таймзоны дают ошибку в узкой полосе часов и разницу ровно в 3 или 24 часа при одиннадцати поясах от UTC+2 до UTC+12 без сезонного перевода стрелок; различия локали ловятся на строке «1 234,56» и на CSV с разделителем «;» из русского Excel.
Гипотеза проверяется одним экспериментом с одной изменённой переменной и предсказанием, записанным до запуска; когда известны рабочий и сломанный коммиты, `git bisect` находит виновника за log₂(N) шагов — 1000 коммитов за 10 проверок, 10 000 за 14. Правка чинит причину минимальным дифом и обязана нести регрессионный тест на причину, а не на путь пользователя. Прод-данные на живую не правятся, только миграцией с обратимым `downgrade`; тупик тоже результат — три опровергнутые гипотезы идут в отчёт.
Железное правило
НИКАКИХ ФИКСОВ БЕЗ ПОДТВЕРЖДЁННОЙ ПЕРВОПРИЧИНЫ.
Исправление симптома создаёт «игру в крота»: баг уходит с экрана и возвращается там, где его уже никто не свяжет с исходным. Признак симптомной правки: try/except, if x is None: return, ?? 0, sleep перед проблемным местом, увеличенный таймаут — ни одна не отвечает на вопрос «почему значение оказалось пустым, поздним или чужим».
Исключение одно: прод лежит и есть прямые денежные потери — тогда ставишь барьер с # TODO(<тикет>) и продолжаешь расследование тем же днём. Барьер без тикета — симптомный фикс с оправданием.
Фаза 1: расследование
1.1 Зафиксируй симптом в проверяемой форме
Плохо: «не работает выгрузка в Ozon». Хорошо: «с 14:20 MSK 27.07 ozon_sync падает с KeyError: 'result' на 3 из 40 магазинов, и у всех трёх offer_id с кириллицей».
Минимум: точное сообщение, полный стек-трейс, первое и последнее время наблюдения, доля затронутых объектов (3 из 40, а не «некоторые») и чем затронутые отличаются от незатронутых — последнее и есть половина причины. Не хватает данных — задай один разделяющий вопрос.
1.2 Построй хронологию
В одну шкалу: первое появление ошибки, деплои, миграции, смена флагов и env, инциденты провайдеров, начало месяца и квартала, смена суток. Инструменты — query_events, query_sessions, git_ops, sandbox_bash.
Есть деплой между «работало» и «не работает» — начинай с диффа: это самая дешёвая гипотеза и чаще всего верная. Деплоя не было, а поведение изменилось — изменились данные, время или внешний мир, и список сужается до дрейфа конфигурации, таймзон, сбоя интеграции или пула.
1.4 Воспроизведи
Хорошее воспроизведение падает не реже 1 раза из 5; реже — повышай детерминизм (seed, время, порядок, воркеры), иначе не отличишь «фикс помог» от «повезло».
Итог фазы — «Гипотеза первопричины: …»: утверждение, из которого следует опровержимое предсказание вида «если я прав, у продавца X в логе будет строка Y, а у Z — нет».
Фаза 2: каталог паттернов
| Паттерн | Сигнатура |
|---|---|
| Гонка | Прерывистость под нагрузкой |
| Null-пропагация | NoneType, undefined на «обязательном» поле |
| Порча состояния | Данные несогласованы между собой |
| Сбой интеграции | Таймаут, 4xx/5xx, чужая форма ответа |
| Дрейф конфигурации | Локально работает, на проде нет |
| Устаревший кеш | Показывает вчерашнее |
| Таймзоны и сутки | Ломается в конкретные часы и числа |
| Кодировки и Unicode | Кракозябры, ложные несовпадения |
| Пул соединений | Латентность ступенькой, таймаут на acquire |
| Ретраи без идемпотентности | Дубли заказов, платежей, поставок |
| Кеш ORM | Прочитал старое сразу после записи |
| Различия локали | Числа и даты разъезжаются |
Совпадение — не диагноз, а список экспериментов. По каждому: что видно в логах, чем подтверждается, чем маскируется, чем отделяется от соседа.
Гонка
В логах. Один и тот же запрос иногда проходит; два события с одним идентификатором в пределах миллисекунд; «обновлено 0 строк» там, где запись обязана была найтись.
Подтверждение. Частота отказов зависит от параллелизма: вставь задержку между чтением и записью состояния — она вырастет скачком, не на проценты.
Маскируется под нестабильную сеть, но не коррелирует с провайдером и воспроизводится локально.
Эксперимент. 20 прогонов в один поток и 20 в N потоков: ноль отказов в первом и хотя бы один во втором — доказано без чтения кода. Место ищи структурно: SELECT … FOR UPDATE или уникальный индекс на «не более одной открытой строки» — падает вставка, точка найдена.
Null-пропагация
В логах. 'NoneType' object is not subscriptable, Cannot read properties of undefined, KeyError на ключе, который «всегда есть», — и падает далеко от места, где значение стало пустым.
Подтверждение. Найди первую точку, где значение уже пустое, а на входе не было: обычно это внешний ответ без поля, .get() вместо [] или ветка, молча вернувшая None.
Маскируется под сбой интеграции (200 с пустым телом) и под порчу состояния (NULL от частичной записи).
Порча состояния
В логах. Сумма позиций не равна сумме заказа; «оплачен» без платежа; отрицательный счётчик; запись есть в одной таблице и нет в связанной. Ошибки может не быть.
Подтверждение. SQL-инвариант: сколько строк нарушают и когда созданы. Кластер по времени указывает на релиз, размазывание — на постоянно работающий путь записи.
Маскируется под кеш, но прямой запрос в БД даёт те же данные.
Эксперимент. Прогони инвариант до и после подозреваемой операции. Корень почти всегда в границе транзакции: побочный эффект стоит внутри той, что откатится, или снаружи той, что должна была его защитить.
Сбой интеграции
В логах. Таймауты, 429, 5xx — и самое опасное, 200 с телом не той формы: признак второго случая — ошибка парсинга, а не сети.
Эксперимент. Три сырых вызова: на «хорошем» объекте, на «плохом» и на «плохом» с урезанными параметрами. Что проверять первым в российских API: лимит страницы у метода часто меньше, чем принимает поле, и усечённая страница читается как конец данных — топ рынка выходит по первой сотне позиций; часть методов требует обязательный список идентификаторов, и без него отказ выглядит как лимит запросов; окно отчёта ограничено (типично 30–31 день), и квартальный запрос отдаёт не ошибку, а пустоту. У 1С и МойСклад обмен идёт файлом: «API сломался» значит «файл не приехал».
Дрейф конфигурации
В логах. Локально зелено, на проде падает — или падает на одном инстансе из трёх: несуществующий путь, хост по умолчанию, пустая переменная, чужая зона.
Подтверждение. Сравнивай фактические значения, а не файлы: выведи ключи конфигурации на проде и локально и вычти списки; .env.example врёт почти всегда.
Маскируется под «баг у одного клиента» — у него другой флаг или другие креды кабинета.
Эксперимент. Подставь одно прод-значение локально: воспроизвелось — причина найдена за шаг. Версии тоже: минорное расхождение драйвера БД объясняет поведение, которого нет ни в одной строке кода.
Устаревший кеш
В логах. Ошибок нет, есть жалоба «я поменял, а не видно», и ответы одинаковой длины.
Маскируется под порчу состояния и «фронт не обновился».
Эксперимент. Три чтения ключа — из приложения, из Redis, из БД: точка расхождения называет слой. Дальше вопрос не «как сбросить», а «почему не сработала инвалидация»: обычно ключ пишется одним набором полей, а инвалидируется другим, либо стоит TTL там, где нужно событие.
Таймзоны и переход через сутки
В логах. Ошибка в узкой полосе часов; отчёт за «вчера» на границе месяца пуст или задваивает день; расписание срабатывает на час раньше или на сутки позже; рядом два времени с разницей ровно 3 часа или 24.
Маскируется под «пропали данные»: отчёт пуст, потому что окно построено в UTC, а провайдер отдаёт MSK.
Эксперимент. Прогони расчёт для трёх дат: обычной, последнего дня месяца и такой, где время клиента отличается от серверного. Российский контекст: одиннадцать часовых поясов от UTC+2 до UTC+12, сезонного перевода стрелок нет (актуально на 2026-07-28) — смещение постоянное, но не одно на всех, и «сутки клиента» по Москве ошибаются на час для Калининграда и на девять для Камчатки. Класс снимают три правила: хранить в UTC с зоной, границы суток считать в явной зоне бизнеса, не выводить сутки как date(created_at) без приведения.
Кодировки и Unicode
В логах. UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd0, кракозябры вида ООО — или, хуже, никакой ошибки: товар просто не сматчился с товаром.
Подтверждение. Сравнивай не строки, а байты: длину, repr, коды символов. Две визуально одинаковые неравные строки — разная нормализация либо невидимый символ.
Маскируется под баг матчинга: отчёт скажет «не найдено 12 позиций», кодировку не заподозрят.
Пул соединений
В логах. Латентность растёт ступенькой; таймаут наступает при получении соединения, а не при выполнении запроса; в БД много соединений в простое внутри транзакции.
Маскируется под медленную базу, но запросы быстрые: ждёт очередь.
Эксперимент. Снизь размер пула вдвое: проблема наступает вдвое раньше и при вдвое меньшей нагрузке — зависимость линейная, это пул, а не запрос. Отдельный сорт: транзакционный пулер (PgBouncer в режиме transaction) не сохраняет подготовленные выражения между транзакциями, и кеширующий их код падает на «prepared statement уже существует» — лечится statement_cache_size=0. Соседний сорт — долгий внешний вызов внутри открытой транзакции: пул выедается ожиданием чужого API.
Ретраи без идемпотентности
В логах. Дубли с интервалом, равным задержке ретрая; ответ — таймаут, а объект создан; списаний больше, чем заказов.
Маскируется под гонку и двойной клик; клики — доли секунды, ретраи — секунды по лестнице.
Эксперимент. Оборви соединение на середине запроса и проверь, создался ли объект: создался, а клиент считает вызов неуспешным — любой ретрай удваивает. Лечение — ключ идемпотентности, который генерирует инициатор и который переживает перезапуск процесса; где провайдер его не принимает, ключом служит уникальный индекс по бизнес-ключу. Очереди at-least-once — тот же класс: обработчик обязан быть повторяемым, иначе вторая доставка создаст вторую поставку в кабинете.
Кеш ORM
В логах. Записал и тут же прочитал старое; объект, полученный дважды, оказался одним экземпляром с чужими правками; исключение об отсоединённом объекте после закрытия сессии.
Подтверждение. Тот же запрос сырым SQL мимо ORM отдаёт правильные данные: устарел не Redis, а карта объектов сессии.
Маскируется под устаревший кеш; разделяет сравнение ORM против сырого SQL в том же процессе.
Различия локали
В логах. could not convert string to float: '1 234,56'; неожиданный порядок сортировки; месяц в отчёте по-английски; сумма отличается на порядок.
Подтверждение. Посмотри сырую строку до парсинга: разделитель тысяч, десятичная запятая и валютный знак говорят, откуда файл.
Маскируется под кодировки и под ошибку расчёта: «неверная сумма» выглядит как арифметика, а это разбор.
Эксперимент. Прогони парсер на строке из проблемного файла и на строке из рабочего. Что ловить: Excel в русской локали пишет CSV с разделителем ; и десятичной запятой; ИНН и артикулы с ведущими нулями превращает в числа, и нули теряются; телефон приходит и как +7, и как 8; «ё» встаёт то после «е», то в конце алфавита в зависимости от локали сравнения; названия месяцев зависят от локали процесса, поэтому разбор русской даты на сервере с локалью C падает. Правило: разбор и вывод чисел и дат — с явной локалью.
Фаза 3: проверка гипотезы
Правило одного эксперимента
Один эксперимент — одно утверждение и одна изменённая переменная: меняешь две, узнаешь лишь «что-то из этого». Предсказание записывай до запуска.
Бинарный поиск по истории
Когда известны рабочий и сломанный коммиты, git bisect находит виновника за log₂(N) шагов: 1000 коммитов — 10 проверок, 10 000 — 14.
Бинарный поиск по данным
Падает на миллионе строк — дели вход пополам, оставляя половину, на которой падает: двадцать шагов, и остаётся одна строка. Тот же приём — на списке полей запроса, наборе флагов, множестве магазинов, списке зависимостей. Останавливайся на паре «минимальный пример, который всё ещё падает» и «соседний, который уже не падает»: разница — описание причины, часто дословно годящееся в название теста.
Красные флаги
- «Быстрый фикс на время» — временных не бывает, бывают незадокументированные постоянные.
- Правка предложена раньше, чем пройден путь данных, — это гадание.
- Каждый фикс открывает следующую проблему — не тот слой.
- Не объясняешь, почему баг не проявлялся раньше, — не закончено.
Фаза 4: исправление
- Чини причину. Не можешь одним предложением назвать неверное предположение, которое снимает правка, — она не готова.
- Минимальный диф. Попутный рефакторинг размывает его и лишает следующего
git blame. - Убей класс, а не экземпляр. То же предположение живёт ещё в трёх местах — назови их в отчёте; в этом дифе чини только механическую и покрытую тестом правку.
- Структурные лекарства сильнее точечных. Уникальный индекс сильнее проверки в коде,
NOT NULL— валидации, ограничение в схеме — договорённости: их не обойти забывчивостью. - Регрессионный тест обязателен — проверь оба состояния: падение без фикса и проход с ним; гоняй затронутый модуль и соседей, а не весь набор.
Каким должен быть регрессионный тест
Он воспроизводит причину, а не путь пользователя: не «открыть отчёт», а «дата на границе месяца в зоне UTC+12»; не «синхронизация», а «два конкурентных вызова на один идентификатор»; имя повторяет формулировку первопричины.
Фаза 5: отчёт
«ЗОНА ПОРАЖЕНИЯ» обязательна: баг, неделю писавший неверные данные, фиксом кода не закрывается — оцени испорченные строки числом и предложи обратимую починку. Первопричину повторяющегося класса клади в manage_memory, находку об архитектуре — в emit_insight.
Правила работы
- Один вопрос человеку за раз, и только когда ответ разделяет гипотезы.
- Не называй причину без эксперимента, который её подтвердил, и не переходи к фазе 4, пока в фазе 3 нет ни одного сбывшегося предсказания.
- Не увеличивай таймаут и не добавляй
sleepкак лечение — это сокрытие. - Прод-данные не правь на живую: изменение состояния — миграцией с обратимым
downgrade. - Утверждения о содержимом базы — из запроса к базе, а не из чтения кода.
- Тупик — тоже результат: хронология и три опровергнутые гипотезы экономят следующему часы.
Похожие навыки
Попробуйте этот навык
Зарегистрируйтесь и используйте навык «Расследование бага» бесплатно.