«Let it crash» на практике: изоляция отказов

Превращаем философию из первого раздела в инженерную практику.

Изоляция отказов — свойство системы, при котором сбой остаётся внутри одного процесса и не распространяется на остальные.

Мы уже знаем девиз «let it crash» и инструменты обнаружения смерти. Теперь соберём это в практику: как проектировать систему, где падения не страшны, а полезны. Стоит сразу снять недопонимание, которое возникает у большинства новичков: «let it crash» — это не призыв писать небрежный код и не «авось пронесёт». Это осознанная инженерная стратегия, в которой ответственность за обработку ошибок перенесена с каждой отдельной строки на уровень архитектуры. Вместо того чтобы обвешивать каждую функцию проверками на все мыслимые беды, мы пишем чистую логику нормального сценария и заранее строим механизм, который аккуратно поднимет систему, если что-то всё же пойдёт не так. Парадокс в том, что система, спроектированная вокруг падений, на практике оказывается надёжнее системы, которая отчаянно пытается никогда не падать.

Разделяй ответственность: worker и его хранитель

Ключевая идея — разделить процессы на две роли. Рабочие (workers) делают полезную работу и пишут «счастливый путь», падая при неожиданностях. Хранители (supervisors) ничего полезного не делают, кроме одного: следят за рабочими и перезапускают упавших. Это разделение похоже на устройство больницы: есть пациенты, с которыми может случиться что угодно, и есть дежурный персонал, чья единственная задача — реагировать на сигналы тревоги. Если смешать роли — заставить worker одновременно делать работу и героически восстанавливать самого себя, — получится тот самый запутанный оборонительный код, от которого мы и уходим. А вот когда восстановление вынесено в отдельный простой процесс, оба становятся понятнее: worker честно сосредоточен на задаче, supervisor честно сосредоточен на надзоре. Простота каждой части — не побочный эффект, а главная цель такого деления.

  [Supervisor]  <- ничего не делает, только следит
       |
   [Worker]     <- делает работу, может упасть
       |
   при падении -> Supervisor запускает нового Worker

Такое разделение делает рабочий код простым (никаких оборонительных проверок), а надёжность — централизованной.

Почему перезапуск лечит

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

Пример: устойчивый обработчик

handle(Request) ->
    %% Пишем счастливый путь, без оборонительных проверок
    {ok, User} = lookup_user(Request),
    {ok, Order} = create_order(User, Request),
    {ok, Order}.
%% Если lookup_user вернул не {ok, _} — процесс упадёт.
%% Супервизор перезапустит обработчик; запрос можно повторить.

Сравните с защитным стилем, где каждый шаг обёрнут в проверки и вложенные case. Версия выше короче, читается как описание нормального сценария, а ошибки обрабатывает архитектура. Обратите внимание на приём с сопоставлением {ok, User} = lookup_user(Request): если функция вернёт что-то, не подходящее под образец, сопоставление провалится с ошибкой badmatch и процесс упадёт ровно в точке проблемы, не утаскивая некорректное значение дальше по коду. Это называют «утвердительным программированием»: вы прямо в коде заявляете, каким обязан быть результат, и любое отклонение немедленно и громко обнаруживается, а не прячется в глубине и не всплывает где-то позже в искажённом виде. Громкое раннее падение почти всегда лучше тихого продолжения с испорченными данными.

Где «let it crash» неуместен

Принцип не абсолютен. Ожидаемые, штатные ситуации — не повод падать. «Пользователь не найден» при логине — это нормальный результат, его возвращают как {error, not_found}, а не роняют процесс. Падение оставляют для программных ошибок и действительно нештатных состояний: пришли данные неверной формы, нарушен инвариант, недоступен критичный ресурс. Граница проходит по простому вопросу: «ожидал ли я такого исхода?». Если да — это часть нормальной логики, и его нужно вернуть значением. Если нет, если случившееся означает, что мир оказался не таким, как предполагал код, — пусть падает. Хороший ориентир: ошибки, на которые может повлиять пользователь или внешняя система (неверный пароль, недоступный сервис, кривой ввод), обычно ожидаемы и обрабатываются явно; ошибки, которые означают баг в вашей собственной логике (невозможное состояние, нарушенный инвариант), должны валить процесс, потому что притворяться, будто их нет, опаснее, чем упасть.

СитуацияКак реагировать
Неверный парольвернуть {error, ...} (штатно)
Пустой ввод формывернуть {error, ...} (штатно)
Данные неожиданной структурыпусть падает (ошибка инварианта)
Внутренний баг логикипусть падает (супервизор перезапустит)

Как работает под капотом

Изоляция отказов держится на трёх свойствах BEAM: раздельная память процессов (сбой не портит соседей), сигналы выхода по связям (супервизор узнаёт о смерти) и мгновенное освобождение памяти упавшего процесса (нет утечек после краха). Эти три вещи вместе делают перезапуск дешёвым и безопасным — на их основе и стоит OTP, который мы изучим дальше.

Частые ошибки

  • Ронять процесс на штатных ситуациях. «Не найдено» — это результат, а не сбой.
  • Хранить общее состояние нескольких задач в одном процессе. Тогда падение губит всё сразу — теряется изоляция.
  • Писать оборонительный код в worker. Это работа архитектуры, а не каждой функции.

Итоги

  • Разделяйте роли: worker делает работу, supervisor восстанавливает.
  • Перезапуск в чистом состоянии устраняет редкие невоспроизводимые сбои.
  • Штатные ситуации возвращают {error, ...}; падают только на нештатных.
  • Изоляция отказов опирается на раздельную память, сигналы и быструю очистку BEAM.
Проверьте себя
1. Как «let it crash» делит роли процессов?
AВсе процессы одинаковы
BWorker делает работу и может падать, supervisor следит и перезапускает
CОдин процесс делает всё
DПроцессы не падают
2. Почему перезапуск часто устраняет сбой?
AОн меняет код
BНовый процесс стартует в чистом состоянии без испорченного контекста, и редкий сбой не повторяется
CОн ускоряет процессор
DОн удаляет ошибки навсегда
3. Как реагировать на штатную ситуацию «пользователь не найден»?
AУронить процесс
BВернуть {error, not_found} — это нормальный результат, а не сбой
CПерезагрузить сервер
DИгнорировать запрос