Проект ЦИТадель
В обзоре распределённых систем мы установили печальный факт: сеть теряет, задерживает, дублирует и переупорядочивает сообщения, а отправитель, не дождавшийся ответа, принципиально не может узнать, что произошло. Эта статья — о том, что с этим фактом делать на практике. Она написана в формате поваренной книги: каждый приём разобран по схеме «проблема → решение → как настраивать → типичная ошибка». Первая часть — про серверную сторону (вызовы сервис–сервис), вторая — про клиентскую (что делать, когда плохая связь у пользователя), третья — практикум: как устроить себе плохую сеть на любом Linux и проверить всё написанное экспериментально.
Сквозная мысль руководства: надёжность — это не «сделать так, чтобы сбоев не было» (невозможно), а спроектировать поведение системы на время сбоя. Разница видна в первый же час первой же сетевой аварии.
Проблема. Вызов без тайм-аута при зависшем адресате висит вечно, удерживая соединение, поток и память; десяток таких — и уже ваш сервис не отвечает своим клиентам. Отказ распространился по цепочке — это главный механизм каскадных аварий.
Решение. Тайм-аут на каждый сетевой вызов без исключений: к соседнему сервису, к базе, к кэшу, к DNS. Отдельно ограничивают установление соединения и получение ответа; значения различаются для соседнего процесса, другого ЦОДа и мобильного клиента.
Как настраивать. Не с потолка и не «на всякий случай 60 секунд». Сначала выбирают допустимую долю ложных тайм-аутов, например 0,1%, затем смотрят соответствующий перцентиль реальной задержки адресата (для 0,1% это p99,9) и добавляют запас на сеть, установление соединения и TLS. Если выбрать ровно p99, то даже в штатном режиме примерно один запрос из ста станет кандидатом на лишний повтор. В цепочках вызовов используйте сквозной дедлайн: внешний запрос приходит с бюджетом (скажем, 1 с), и каждый внутренний вызов получает остаток бюджета, а не собственный независимый тайм-аут (иначе сумма внутренних тайм-аутов легко превышает терпение клиента, и вы старательно довычисляете ответ, который уже никто не ждёт). Готовая механика есть в gRPC (deadline propagation) и в контекстах Go.
Типичная ошибка. Тайм-аут установлен только на HTTP-клиенте, а зависает — DNS-резолвер или соединение с БД из пула. Проверяйте все точки ожидания.
Проблема. Значительная часть сетевых сбоев мимолётна: потерянный пакет, секундный рестарт, переключение реплики. Глупо показывать пользователю ошибку из-за события длиной 100 мс. Но наивный повтор «в лоб и немедленно» опасен: если сервис захлебнулся от нагрузки, дружный повтор всех клиентов её утраивает — шторм повторов добивает то, что ещё дышало, и превращает секундный сбой в часовой.
Решение. Повторять — но цивилизованно:
Типичная ошибка — повторы на нескольких уровнях сразу: клиент повторяет трижды, его вызывающий — трижды, шлюз — трижды; итого до 27 запросов на один сбой. Повторяйте на одном уровне (обычно самом внешнем осмысленном), остальные — пропускают ошибку наверх.
Проблема. Повтор безопасен, только если операция терпит многократное исполнение. «Списать 500 рублей» при повторе после потерянного ответа (операция-то прошла!) спишет тысячу. Это не экзотика: потерянный ответ неотличим от потерянного запроса — вспомните обзор.
Решение. Идемпотентность по построению. Чтение и присваивание одного значения могут быть идемпотентны, только если повтор не запускает побочный эффект заново; одинаковый HTTP-метод сам по себе этого не гарантирует. Операции-приращения делают идемпотентными через ключ идемпотентности: клиент генерирует уникальный идентификатор операции (UUID) и передаёт с запросом; сервер хранит таблицу обработанных ключей и на повтор отвечает сохранённым результатом, не исполняя операцию заново. Так работают платёжные API (заголовок Idempotency-Key), так же следует делать любой внутренний API с побочными эффектами. Запись ключа и операция должны быть атомарны, а срок хранения ключа обязан покрывать весь допустимый период повторов и сверок; универсального значения «сутки–неделя» нет.
Типичная ошибка. Дедупликация по содержимому запроса («такая же сумма от того же клиента») вместо явного ключа: легитимные одинаковые операции склеиваются, а настоящие дубли с разным временем — нет.
Проблема. Адресат мёртв надолго (упала база, деплой не удался). Каждый вызов к нему — это тайм-аут: ваши потоки заняты ожиданием заведомого отказа, ваша задержка выросла на величину тайм-аута, ваши повторы молотят лежащего.
Решение. Предохранитель (circuit breaker) — счётчик ошибок вокруг вызова. Доля ошибок превысила порог — предохранитель «размыкается»: вызовы мгновенно завершаются ошибкой без похода в сеть (fail fast), система живёт на деградации (раздел 5). Периодически пропускается пробный запрос; успех — предохранитель замыкается. Реализации есть в любой экосистеме (resilience4j, Polly, встроено в service mesh); принципиальны три параметра — окно измерения, порог, период проб.
Типичная ошибка. Один предохранитель на всех адресатов сразу: сбой одного бэкенда отключает походы во все. Гранулярность — по адресату (хосту, шарду), не по клиенту.
Проблема. Нагрузка превысила ёмкость (пик, потеря половины реплик, шторм повторов). Система, честно принимающая всё, начинает отвечать всем — но за 30 секунд, что для клиентов равно «не отвечать никому»: очереди растут, память кончается, каскад.
Решение. Признать ёмкость конечной и управлять избытком:
Типичная ошибка. Безлимитные пулы и очереди «чтобы ничего не терять»: система теряет не запросы, а себя целиком.
Проблема. Балансировщику и оркестратору надо решать, куда слать трафик, а «завис» и «думает» снаружи выглядят одинаково.
Решение. Здоровье проверяют явно и на двух уровнях: liveness — процесс жив (иначе перезапустить), readiness — готов принимать трафик (прогрет, видит базу; иначе вывести из балансировки, не убивая). Против «медленных хвостов» у критичных чтений работает приём hedged requests: не дождавшись ответа за p95, послать дубль на другую реплику и взять первый ответ — задержка хвоста срезается ценой нескольких процентов лишнего трафика (только для идемпотентных операций — см. раздел 3).
Типичная ошибка. Readiness-проверка, которая сама ходит во все зависимости глубоко: моргнула база — все инстансы разом объявили себя неготовыми, балансировщик остался без бэкендов, самодельная авария готова. Проверка должна отражать способность обслуживать, а не транзитивное здоровье мира.
Теперь развернём точку зрения: плохая связь — не авария в ЦОДе, а норма жизни клиента. Мобильный интернет в электричке, лифт, роуминг, перегруженный Wi-Fi, деградация каналов — приложение, которое умеет жить только при идеальной сети, в реальности неработоспособно регулярно. Приёмы этой части зеркальны серверным: та же очередь, тот же повтор, та же идемпотентность — но на стороне клиента.
Проблема. Классическая архитектура «каждое действие = синхронный запрос» при плохой сети превращает приложение в генератор крутящихся спиннеров и потерянных действий пользователя.
Решение. Перевернуть архитектуру: источник истины для интерфейса — локальное хранилище (SQLite, IndexedDB), а сеть — фоновый синхронизатор. Действие пользователя мгновенно применяется локально и ставится в локальную очередь операций; фоновый процесс отправляет очередь при появлении связи — с повторами, экспоненциальной задержкой и ключами идемпотентности из части I (сервер обязан их поддерживать — offline-first проектируется с двух сторон!). Интерфейс работает всегда; сеть лишь меняет свежесть данных. Так устроены зрелые мессенджеры, заметочники, почтовые клиенты.
Типичная ошибка. Очередь без сохранения на диск: убитое системой приложение теряет неотправленные действия — ровно те, что пользователь совершил в самый неудобный момент.
Проблема. Работа офлайн означает конкурентные правки: пользователь изменил запись с телефона в метро и с ноутбука дома; при синхронизации версии столкнулись.
Решение — лестница по возрастанию честности:
Типичная ошибка. Разрешение конфликтов «перезаписью целого объекта»: пользователь A правил телефон, пользователь B — адрес, слияние по объекту теряет одну правку, хотя по полям конфликт вовсе отсутствовал.
Проблема. При медленной сети выбор «показать старое мгновенно или свежее через десять секунд» встаёт на каждом экране.
Решение. Почти всегда правильный ответ — stale-while-revalidate: мгновенно показать кэшированное, параллельно запросить обновление, по приходе — обновить экран. Пользователь видит контент сразу и свежий — вскоре. Дополняется дельта-синхронизацией (запрашивать изменения с момента X, а не всё), сжатием и — для медиа — прогрессивной загрузкой. И обязательная вежливость к мобильному пользователю: уважать лимиты трафика (не качать тяжёлое вне Wi-Fi без разрешения) и батарею (синхронизация пачками по расписанию, а не постоянным опросом; на Android это штатные механизмы отложенных фоновых работ).
Типичная ошибка. Кэш без индикации свежести: пользователь принимает вчерашние данные за сегодняшние. Дешёвое лекарство — ненавязчивая метка «обновлено N минут назад» на закэшированных экранах.
Последний приём — не про байты, а про доверие. Оптимистичный интерфейс (действие сразу отражено, откат при ошибке синхронизации) делает приложение «быстрым» даже на ужасной сети — но требует честного отката с объяснением, если сервер операцию отверг. Состояние связи и очереди показывают спокойно и различимо: «офлайн, 3 изменения будут отправлены» — информация; модальная ошибка на весь экран из-за пропавшей на секунду сети — паника. Правило: сбой сети — рядовое состояние приложения, и дизайнится оно так же тщательно, как счастливый путь.
Всё перечисленное проверяется без внешней инфраструктуры: подсистема управления трафиком Linux умеет портить сеть по заказу. Инструмент — дисциплина netem команды tc из пакета iproute2; для изменения qdisc нужны права root или capability NET_ADMIN.
# добавить на интерфейс задержку 200 мс ± 50 мс и 5% потерь tc qdisc add dev eth0 root netem delay 200ms 50ms loss 5% # посмотреть / изменить / снять tc qdisc show dev eth0 tc qdisc change dev eth0 root netem delay 1000ms loss 30% duplicate 2% reorder 10% tc qdisc del dev eth0 root
Удобный полигон — два контейнера (клиент и сервис) с netem на интерфейсе одного из них; в Docker правьте сеть изнутри контейнера (нужен NET_ADMIN) или пользуйтесь готовой обёрткой pumba; есть и специализированный прокси toxiproxy, портящий отдельные TCP-соединения программируемо.
Программа экспериментов — по одному на приём из части I:
Для клиентской части то же самое встроено в инструменты разработчика: эмулятор Android умеет профили сети вплоть до GPRS и обрывов, Chrome DevTools — троттлинг и офлайн. Приложение, прожившее программу экспериментов, готово к реальной сети; не прожившее — сообщило вам об этом на стенде, а не в продакшене. Систематическое развитие этой практики — хаос-инженерия: регулярные учения с намеренными отказами.
У всех приёмов этой статьи один корень, заявленный во введении: сбой сети — не исключительная ситуация, а рабочее состояние, у которого должно быть спроектированное поведение. Обзорная статья объясняла, почему это неизбежно (ненадёжная сеть, неразличимость отказов, отсутствие общего времени); здесь мы увидели, что инженерный ответ на каждую теоретическую неприятность давно существует и умещается в чек-лист. Связка «тайм-аут → повтор → идемпотентность → предохранитель → деградация» на сервере и «локальная очередь → синхронизация → разрешение конфликтов» у клиента — это минимальный джентльменский набор системы, которой предстоит жить в настоящей сети. А проверить, что набор при вас, можно за вечер — одной командой tc netem.