Плавающие ошибки: как воспроизвести сбой и найти причину
Плавающий баг появляется один раз на сотни запросов, исчезает под отладчиком и не повторяется на стенде. Разбираем, как устроена недетерминированность, что фиксировать и как превратить случайный сбой в воспроизводимый тест.

Если ошибка возникает один раз на 500 запросов, обычный ручной прогон почти бесполезен: вероятность не увидеть сбой после десяти попыток превышает 98%. Под отладчиком проблема может исчезнуть, потому что меняются тайминги, порядок потоков и нагрузка на систему. Воспроизведение плавающих ошибок начинается не с бесконечных перезапусков, а с фиксации условий, которые однажды привели систему к плохому состоянию.
Почему ошибка становится плавающей
Детерминированная функция при одинаковом вводе возвращает одинаковый результат. Плавающий сбой появляется, когда на результат влияет ещё один параметр, который команда не записала в тест или лог: порядок выполнения потоков, задержка сети, состояние кеша, случайное число, время на часах или содержимое общей базы.
В распределённом сервисе таких параметров десятки. Один запрос может пройти через другой экземпляр приложения, получить другой ответ от кеша или попасть на реплику базы с иной задержкой репликации. Поэтому «тот же запрос» не всегда означает «те же условия».
Полезно разделить источники недетерминированности на четыре группы:
- время — таймауты, дедлайны, переход через полночь, разная длительность операций;
- порядок — гонки потоков, конкурентные записи, порядок доставки сообщений;
- состояние — кеш, очередь, транзакция, остаток данных после предыдущего теста;
- случайность и окружение — генераторы случайных чисел, переменные окружения, версия драйвера, лимит файловых дескрипторов.
* Гонка данных — ситуация, в которой результат зависит от того, какой поток первым прочитал или изменил общие данные. Ошибка может не проявляться в отладчике: дополнительные паузы меняют порядок операций и прячут конфликт.
Цель воспроизведения — не «поймать баг ещё раз», а сузить набор переменных до минимального сценария, где сбой перестаёт быть случайным.
Механика воспроизведения: от симптома к условию
Сначала зафиксируйте не предположение, а наблюдаемое событие. «Иногда падает» слишком мало для работы. Нужна запись: какой запрос пришёл, какой ответ ожидался, что вернулось фактически, на каком экземпляре это произошло, какой был correlation ID и сколько времени заняли ключевые этапы.
1. Составьте временную линию
Один итоговый лог с текстом ошибки редко объясняет плавающий сбой. Нужны события до него. Для каждого запроса или фоновой задачи записывайте монотонное время, идентификатор операции, имя компонента, номер попытки и результат. Настенные часы могут корректироваться синхронизацией, поэтому длительность измеряйте монотонным таймером, а календарное время оставляйте для сопоставления между сервисами.
Временная линия должна отвечать на четыре вопроса: что началось первым, где возникла задержка, какая операция изменила состояние и кто зафиксировал ошибку. Если в цепочке нет одного из этих событий, следующий запуск может снова оказаться непонятным.
2. Зафиксируйте контекст запуска
Снимайте не только версию приложения. Для инцидента важны версия схемы базы, конфигурация таймаутов, размер пула соединений, имя экземпляра, регион, тип хранилища, состояние фичефлага и наличие кеша. Конфигурация должна попадать в диагностический контекст автоматически, а не собираться человеком после сбоя.
Секреты и персональные данные в такой журнал не записывайте. Вместо токена храните название секрета или его безопасный отпечаток, а тело запроса заменяйте очищенной выборкой с теми же типами и размерами полей.
3. Отделите случайность от нагрузки
Повторите сценарий в нескольких режимах: один запрос, фиксированная последовательность запросов и параллельный прогон. Если ошибка появляется только при параллельности, подозрение падает на гонку, блокировку, пул ресурсов или порядок сообщений. Если она появляется и на одном запросе, ищите состояние, время, данные или случайный выбор.
Число прогонов выбирайте по вероятности события. Если вероятность сбоя в одном прогоне равна p, то вероятность увидеть его хотя бы раз за n независимых попыток равна 1 − (1 − p)n. Независимость здесь часто нарушается: повторный запуск использует тот же кеш или те же записи. Поэтому число попыток без очистки состояния создаёт ложное ощущение проверки.
Четыре способа сделать сбой наблюдаемым
Инструмент выбирают по источнику неопределённости, а не по популярности. Таблица ниже описывает не взаимоисключающие решения: для одного инцидента часто нужны два или три слоя.
| Подход | Что фиксирует | Цена | Когда применять |
|---|---|---|---|
| Структурированные логи и trace ID | Порядок событий между сервисами | Объём логов, необходимость очистки данных | Сбой виден в проде, но неясно, на каком этапе он возник |
| Контролируемые часы и таймауты | Переходы времени и дедлайны | Тестовая среда может отличаться от боевой | Ошибка зависит от времени, TTL или повторных попыток |
| Фиксированный seed и запись входов | Случайный выбор и последовательность данных | Не покрывает гонки и внешние сервисы | Сбой зависит от генерации данных или случайного маршрута |
| Нагрузочный и конкурентный прогон | Ошибки порядка, блокировок и лимитов | Нужны изолированные данные и контролируемая нагрузка | Сбой появляется только при параллельных операциях |
Как читать след, когда ошибка уже поймана
Не начинайте с последнего сообщения «connection timeout». Сначала сравните успешный и неуспешный трассировочный след. Ищите первое расхождение: другой экземпляр, лишний повтор, пропавшее подтверждение, более длинную паузу между чтением и записью.
Для конкурентной ошибки полезна модель «событие — условие — эффект». Например: два воркера прочитали один статус; оба решили, что задача доступна; один обновил запись; второй отправил устаревшую операцию. Такая запись лучше формулировки «иногда дублируются задачи», потому что подсказывает, какие события надо синхронизировать или сделать идемпотентными.
Если сервис состоит из нескольких процессов, одного локального лога недостаточно. Объединяйте события по trace ID и сохраняйте номер попытки. Повтор после таймаута не доказывает, что первая операция не состоялась: клиент мог не получить ответ, хотя сервер уже записал результат.
Где подход ломается
Отладчик меняет саму проблему
Пошаговая остановка добавляет паузы. Поток, который раньше проигрывал гонку, успевает завершить операцию. Для таких случаев лучше использовать трассировку, снимок состояния и контролируемые точки ожидания, а не долгую остановку процесса.
Повторные попытки маскируют первичный сбой
Ретрай может превратить ошибку в успешный ответ, но не убрать причину. Логируйте каждую попытку отдельно: код результата, задержку, причину повтора и итог. Иначе в метриках останется только «успешно», а пользователь получит увеличившееся время ответа.
Тестовое окружение слишком чистое
Пустая база, один экземпляр приложения и локальная сеть не воспроизводят конфликт блокировок, задержку реплики или исчерпание пула. Скопируйте форму состояния, а не реальные персональные данные: объём таблиц, долю просроченных записей, размер очереди и число параллельных операций.
Логи создают новую нагрузку
Синхронная запись подробного тела запроса может сама добавить задержку и изменить порядок событий. Начинайте с идентификаторов, длительностей и размеров. Полный диагностический режим включайте временно и ограничивайте по конкретному trace ID или тестовому запуску.
Как превратить случайный сбой в тест
Когда найдено минимальное условие, уберите лишние компоненты. Оставьте два участника гонки, одну запись и один переход состояния. Для времени задайте границы явно, для случайности сохраните входные значения, для внешнего сервиса зафиксируйте последовательность ответов. Тест должен проверять не внутренний порядок вызовов сам по себе, а наблюдаемое свойство: например, одна задача не обрабатывается дважды.
Затем добавьте проверку на повторяемость. Один успешный прогон ничего не доказывает. Сценарий запускают серией, сохраняют seed или входной набор и сравнивают частоту сбоя до и после исправления. Если ошибка исчезла, это ещё не означает, что исправление доказано: возможно, изменился тайминг.
Для Python 3.12, Go 1.22 и PostgreSQL 16 конкретные средства диагностики различаются, но принцип тот же: фиксировать ввод, состояние и порядок событий. Флаги и версии инструментов воспроизведения проверяйте по документации вашей версии, особенно если команда использует сборку с оптимизациями или несколько платформ.
Что делать дальше
Выберите один плавающий сбой и составьте для него карту переменных: время, порядок, состояние, случайность, окружение. Добавьте trace ID, длительность каждого внешнего вызова и номер попытки. После следующего проявления сравните успешный и неуспешный след, найдите первое расхождение и только затем меняйте код.
В проде отдельно смотрите на долю повторных попыток, p95 и p99 задержки, таймауты по каждому downstream-сервису, размер очередей и ошибки по экземплярам. Эти показатели часто показывают причину раньше, чем общий процент HTTP 500.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Go Data Race Detector, rr: lightweight record and replay debugger
Частые вопросы
Почему ошибка исчезает, когда я ставлю breakpoint?
Сколько раз нужно повторить сценарий?
Поможет ли увеличить таймаут?
Что делать, если ошибка возникает только в проде?
Можно ли доказать, что исправление сработало, если ошибка больше не повторяется?
Что делать, если трассировка показывает разные сервисы на каждом запуске?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.