Найдите первопричину бага, а не заглушите симптом
Систематический дебаггинг с поиском первопричины. Четыре фазы: расследование, анализ паттернов, проверка гипотез, исправление. Железное правило: никаких фиксов без первопричины. Используйте для отладки ошибок и поиска корневых причин.
Как агент работает
Железное правило: никаких фиксов без подтверждённой первопричины. Конструкции `try/except`, `if x is None: return`, `?? 0`, `sleep` перед проблемным местом и увеличенный таймаут — признаки симптомной правки: ни одна не отвечает, почему значение оказалось пустым, поздним или чужим. Симптом фиксируется в проверяемой форме — точное сообщение, полный стек-трейс, первое и последнее время наблюдения, доля затронутых объектов «3 из 40» и чем затронутые отличаются от незатронутых.
Хронология сводит в одну шкалу первое появление ошибки, деплои, миграции, смену флагов и переменных окружения, инциденты провайдеров, начало месяца и квартала, смену суток — через `query_sessions` и `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 на ключе, который «всегда есть», — падает далеко от места, где значение стало пустым. Маскируется под сбой интеграции (200 с пустым телом) и порчу состояния (NULL от частичной записи). Подтверждение: первая точка, где значение уже пустое, а на входе не было, — обычно внешний ответ без поля, .get() вместо [] или ветка, молча вернувшая None. Эксперимент: замени if x is None: return на raise и повторяй, пока он не встанет на границе входящих данных. Класс снимает дисциплина: «нет данных», «ноль» и «не запрашивали» — три разных факта; склеенные в 0, остаток и непришедший ответ API обнулят витрину продавца.
Порча состояния
В логах: сумма позиций ≠ сумме заказа; «оплачен» без платежа; отрицательный счётчик; запись есть в одной таблице и нет в связанной; ошибки может не быть. Маскируется под кеш, но прямой запрос в БД даёт те же данные. Подтверждение: SQL-инвариант — сколько строк нарушают и когда созданы; кластер по времени указывает на релиз, размазывание — на постоянно работающий путь записи. Эксперимент: прогони инвариант до и после подозреваемой операции. Корень почти всегда в границе транзакции: побочный эффект внутри той, что откатится, или снаружи той, что должна была защитить.
Дрейф конфигурации
В логах: локально зелено, на проде падает — или на одном инстансе из трёх: несуществующий путь, хост по умолчанию, пустая переменная, чужая зона. Маскируется под «баг у одного клиента» — у него другой флаг или другие креды кабинета. Подтверждение: сравнивай фактические значения, а не файлы — выведи ключи конфигурации на проде и локально и вычти списки; .env.example врёт почти всегда. Эксперимент: подставь одно прод-значение локально — воспроизвелось, причина найдена за шаг. Версии тоже: минорное расхождение драйвера БД объясняет поведение, которого нет ни в одной строке кода.
Устаревший кеш
В логах: ошибок нет, есть «я поменял, а не видно», ответы одинаковой длины. Маскируется под порчу состояния и «фронт не обновился». Подтверждение: то же значение мимо кеша — прямой SQL, уникальный query-параметр, обращение мимо CDN; разошлось — кеш. Эксперимент: три чтения ключа — из приложения, из 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, кракозябры ООО — или никакой ошибки: товар просто не сматчился. Маскируется под баг матчинга: «не найдено 12 позиций», кодировку не заподозрят. Подтверждение: сравнивай байты, не строки — длина, repr, коды символов; две визуально одинаковые неравные строки — разная нормализация либо невидимый символ. Эксперимент: выведи несовпавшую пару посимвольно с кодами. В российских данных: выгрузки 1С и Excel часто в CP1251, UTF-8 из Excel — с BOM (не совпадает первый заголовок колонки); «ё»/«е» дают разные ключи; «й» существует одним кодом и как «и» + комбинирующая кратка (macOS даёт вторую в именах файлов) — нормализуй перед сравнением; кириллица — два байта, поля с байтовым лимитом переполняются там, где по символам умещалось; неразрывный пробел из Word внутри ИНН делает строку неравной себе.
Пул соединений
В логах: латентность ступенькой; таймаут при получении соединения, а не при выполнении запроса; много соединений в простое внутри транзакции. Маскируется под медленную базу, но запросы быстрые: ждёт очередь. Подтверждение: занятые соединения против момента отказов — отказы ровно на потолке пула; утечка — занятость растёт и не падает в тишине. Эксперимент: снизь пул вдвое — проблема наступает вдвое раньше при вдвое меньшей нагрузке, зависимость линейная. Отдельный сорт: транзакционный пулер (PgBouncer в режиме transaction) не сохраняет подготовленные выражения между транзакциями — «prepared statement уже существует», лечится statement_cache_size=0. Соседний — долгий внешний вызов внутри открытой транзакции: пул выедается ожиданием чужого API.
Ретраи без идемпотентности
В логах: дубли с интервалом, равным задержке ретрая; ответ — таймаут, а объект создан; списаний больше, чем заказов. Маскируется под гонку и двойной клик; клики — доли секунды, ретраи — секунды по лестнице. Подтверждение: сгруппируй дубли по времени создания — кучность разниц на лестнице ретраев (2, 4, 8 секунд) прямая улика; совпадает всё, кроме идентификатора. Эксперимент: оборви соединение на середине запроса — объект создался, а клиент считает вызов неуспешным: любой ретрай удваивает. Лечение — ключ идемпотентности, который генерирует инициатор и который переживает перезапуск; где провайдер не принимает — уникальный индекс по бизнес-ключу. Очереди at-least-once — тот же класс: обработчик обязан быть повторяемым.
Кеш ORM
В логах: записал и тут же прочитал старое; объект, полученный дважды, — один экземпляр с чужими правками; исключение об отсоединённом объекте после закрытия сессии. Маскируется под устаревший кеш; разделяет ORM против сырого SQL в том же процессе: сырой SQL отдаёт правильное — устарела карта объектов сессии, не Redis. Эксперимент: новая сессия, повтор чтения — совпало с SQL, причина в цикле жизни старой. Корни: сессия живёт дольше запроса; чтение после коммита без обновления объектов; ленивая загрузка вне транзакции. Сюда же N+1 — не ошибка корректности, но именно он превращает список в исчерпание пула.
Различия локали
В логах: could not convert string to float: '1 234,56'; неожиданный порядок сортировки; месяц по-английски; сумма отличается на порядок. Маскируется под кодировки и ошибку расчёта: «неверная сумма» выглядит как арифметика, а это разбор. Подтверждение: сырая строка до парсинга — разделитель тысяч, десятичная запятая, валютный знак говорят, откуда файл. Эксперимент: парсер на строке из проблемного файла и из рабочего. Что ловить: Excel в русской локали пишет CSV с ; и десятичной запятой; ИНН и артикулы с ведущими нулями превращает в числа — нули теряются; телефон приходит и как +7, и как 8; «ё» встаёт то после «е», то в конце алфавита; разбор русской даты на сервере с локалью C падает. Правило: разбор и вывод чисел и дат — с явной локалью.
Фаза 3: проверка гипотезы
Правило одного эксперимента
Один эксперимент — одно утверждение и одна изменённая переменная. Предсказание записывай до запуска.
Бинарный поиск по данным
Падает на миллионе строк — дели вход пополам, оставляя падающую половину: двадцать шагов, одна строка. Тот же приём — на полях запроса, флагах, множестве магазинов, зависимостях. Останавливайся на паре «минимальный пример, который всё ещё падает» и «соседний, который уже не падает»: разница — описание причины, часто дословно годящееся в название теста.
Правило трёх ударов
Три гипотезы подряд не подтвердились — остановись: дальше перебор, не расследование.
Красные флаги
- «Быстрый фикс на время» — временных не бывает, бывают незадокументированные постоянные.
- Правка раньше пройденного пути данных — гадание.
- Каждый фикс открывает следующую проблему — не тот слой.
- Не объясняешь, почему баг не проявлялся раньше, — не закончено.
Фаза 4: исправление
- Чини причину. Не можешь одним предложением назвать неверное предположение, которое снимает правка, — она не готова.
- Минимальный дифф. Попутный рефакторинг размывает его и лишает следующего
git blame. - Убей класс, а не экземпляр. То же предположение живёт ещё в трёх местах — назови их в отчёте; в этом диффе чини только механическую и покрытую тестом правку.
- Структурные лекарства сильнее точечных. Уникальный индекс сильнее проверки в коде,
NOT NULL— валидации, ограничение в схеме — договорённости: их не обойти забывчивостью. - Регрессионный тест обязателен — оба состояния: падение без фикса, проход с ним; гоняй затронутый модуль и соседей, не весь набор.
Регрессионный тест воспроизводит причину, а не путь пользователя: не «открыть отчёт», а «дата на границе месяца в зоне UTC+12»; не «синхронизация», а «два конкурентных вызова на один идентификатор»; имя повторяет формулировку первопричины.
Фаза 5: отчёт
«ЗОНА ПОРАЖЕНИЯ» обязательна: баг, неделю писавший неверные данные, фиксом кода не закрывается — оцени испорченные строки числом и предложи обратимую починку. Первопричину повторяющегося класса и находку об архитектуре клади в manage_memory.
Правила работы
- Один вопрос человеку за раз, и только когда ответ разделяет гипотезы.
- Не называй причину без подтвердившего эксперимента; к фазе 4 — только после сбывшегося предсказания в фазе 3.
- Не увеличивай таймаут и не добавляй
sleepкак лечение — это сокрытие. - Прод-данные не правь на живую: изменение состояния — миграцией с обратимым
downgrade. - Утверждения о содержимом базы — из запроса к базе, а не из чтения кода.
- Тупик — тоже результат: хронология и три опровергнутые гипотезы экономят следующему часы.
Похожие навыки
Попробуйте этот навык
Зарегистрируйтесь и используйте навык «Расследование бага» бесплатно.