«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.