2026 г.

Надёжность поверх ненадёжной сети: практическое руководство

Проект ЦИТадель

Введение

В обзоре распределённых систем мы установили печальный факт: сеть теряет, задерживает, дублирует и переупорядочивает сообщения, а отправитель, не дождавшийся ответа, принципиально не может узнать, что произошло. Эта статья — о том, что с этим фактом делать на практике. Она написана в формате поваренной книги: каждый приём разобран по схеме «проблема → решение → как настраивать → типичная ошибка». Первая часть — про серверную сторону (вызовы сервис–сервис), вторая — про клиентскую (что делать, когда плохая связь у пользователя), третья — практикум: как устроить себе плохую сеть на любом Linux и проверить всё написанное экспериментально.

Сквозная мысль руководства: надёжность — это не «сделать так, чтобы сбоев не было» (невозможно), а спроектировать поведение системы на время сбоя. Разница видна в первый же час первой же сетевой аварии.

Часть I. Между сервисами

1. Тайм-ауты

Проблема. Вызов без тайм-аута при зависшем адресате висит вечно, удерживая соединение, поток и память; десяток таких — и уже ваш сервис не отвечает своим клиентам. Отказ распространился по цепочке — это главный механизм каскадных аварий.

Решение. Тайм-аут на каждый сетевой вызов без исключений: к соседнему сервису, к базе, к кэшу, к DNS. Отдельно ограничивают установление соединения и получение ответа; значения различаются для соседнего процесса, другого ЦОДа и мобильного клиента.

Как настраивать. Не с потолка и не «на всякий случай 60 секунд». Сначала выбирают допустимую долю ложных тайм-аутов, например 0,1%, затем смотрят соответствующий перцентиль реальной задержки адресата (для 0,1% это p99,9) и добавляют запас на сеть, установление соединения и TLS. Если выбрать ровно p99, то даже в штатном режиме примерно один запрос из ста станет кандидатом на лишний повтор. В цепочках вызовов используйте сквозной дедлайн: внешний запрос приходит с бюджетом (скажем, 1 с), и каждый внутренний вызов получает остаток бюджета, а не собственный независимый тайм-аут (иначе сумма внутренних тайм-аутов легко превышает терпение клиента, и вы старательно довычисляете ответ, который уже никто не ждёт). Готовая механика есть в gRPC (deadline propagation) и в контекстах Go.

Типичная ошибка. Тайм-аут установлен только на HTTP-клиенте, а зависает — DNS-резолвер или соединение с БД из пула. Проверяйте все точки ожидания.

2. Повторы

Проблема. Значительная часть сетевых сбоев мимолётна: потерянный пакет, секундный рестарт, переключение реплики. Глупо показывать пользователю ошибку из-за события длиной 100 мс. Но наивный повтор «в лоб и немедленно» опасен: если сервис захлебнулся от нагрузки, дружный повтор всех клиентов её утраивает — шторм повторов добивает то, что ещё дышало, и превращает секундный сбой в часовой.

Решение. Повторять — но цивилизованно:

  • Экспоненциальная задержка с джиттером: пауза перед k-м повтором ~ base·2k, умноженная на случайный коэффициент. Джиттер обязателен: без него клиенты, отвалившиеся одновременно, повторяют тоже одновременно, волнами.
  • Ограничение: 2–3 попытки, не десять; дальше — честная ошибка наверх.
  • Бюджет повторов: глобальное правило вида «повторы — не более 10% трафика»; исчерпан — повторы отключаются. Это предохранитель от шторма на уровне всей системы.
  • Повторять только то, что имеет смысл: сетевой сбой, 429 и временные 5xx — обычно да, с учётом Retry-After; 400 и обычный 404 — нет (повтор не превратит неверный запрос в верный). Точный набор зависит от контракта API.

Типичная ошибка — повторы на нескольких уровнях сразу: клиент повторяет трижды, его вызывающий — трижды, шлюз — трижды; итого до 27 запросов на один сбой. Повторяйте на одном уровне (обычно самом внешнем осмысленном), остальные — пропускают ошибку наверх.

3. Идемпотентность

Проблема. Повтор безопасен, только если операция терпит многократное исполнение. «Списать 500 рублей» при повторе после потерянного ответа (операция-то прошла!) спишет тысячу. Это не экзотика: потерянный ответ неотличим от потерянного запроса — вспомните обзор.

Решение. Идемпотентность по построению. Чтение и присваивание одного значения могут быть идемпотентны, только если повтор не запускает побочный эффект заново; одинаковый HTTP-метод сам по себе этого не гарантирует. Операции-приращения делают идемпотентными через ключ идемпотентности: клиент генерирует уникальный идентификатор операции (UUID) и передаёт с запросом; сервер хранит таблицу обработанных ключей и на повтор отвечает сохранённым результатом, не исполняя операцию заново. Так работают платёжные API (заголовок Idempotency-Key), так же следует делать любой внутренний API с побочными эффектами. Запись ключа и операция должны быть атомарны, а срок хранения ключа обязан покрывать весь допустимый период повторов и сверок; универсального значения «сутки–неделя» нет.

Типичная ошибка. Дедупликация по содержимому запроса («такая же сумма от того же клиента») вместо явного ключа: легитимные одинаковые операции склеиваются, а настоящие дубли с разным временем — нет.

4. Предохранитель

Проблема. Адресат мёртв надолго (упала база, деплой не удался). Каждый вызов к нему — это тайм-аут: ваши потоки заняты ожиданием заведомого отказа, ваша задержка выросла на величину тайм-аута, ваши повторы молотят лежащего.

Решение. Предохранитель (circuit breaker) — счётчик ошибок вокруг вызова. Доля ошибок превысила порог — предохранитель «размыкается»: вызовы мгновенно завершаются ошибкой без похода в сеть (fail fast), система живёт на деградации (раздел 5). Периодически пропускается пробный запрос; успех — предохранитель замыкается. Реализации есть в любой экосистеме (resilience4j, Polly, встроено в service mesh); принципиальны три параметра — окно измерения, порог, период проб.

Типичная ошибка. Один предохранитель на всех адресатов сразу: сбой одного бэкенда отключает походы во все. Гранулярность — по адресату (хосту, шарду), не по клиенту.

5. Сброс нагрузки и деградация

Проблема. Нагрузка превысила ёмкость (пик, потеря половины реплик, шторм повторов). Система, честно принимающая всё, начинает отвечать всем — но за 30 секунд, что для клиентов равно «не отвечать никому»: очереди растут, память кончается, каскад.

Решение. Признать ёмкость конечной и управлять избытком:

  • Ограниченные очереди везде: переполнение — немедленный отказ новым запросам (429/503 с Retry-After), а не бесконечный рост. Быстрый честный отказ лучше медленного тайм-аута: клиент хотя бы знает и может повторить с задержкой.
  • Приоритетный сброс: при перегрузке первыми отбрасываются некритичные запросы (аналитика, превью, фоновые обновления) — заранее размеченные, а не случайные.
  • Плановая деградация: каталог работает без рекомендаций, поиск — без подсветки, страница — из кэша. Деградацию проектируют и, главное, репетируют: непроверенный запасной режим при аварии не сработает.

Типичная ошибка. Безлимитные пулы и очереди «чтобы ничего не терять»: система теряет не запросы, а себя целиком.

6. Медленно или мертво?

Проблема. Балансировщику и оркестратору надо решать, куда слать трафик, а «завис» и «думает» снаружи выглядят одинаково.

Решение. Здоровье проверяют явно и на двух уровнях: liveness — процесс жив (иначе перезапустить), readiness — готов принимать трафик (прогрет, видит базу; иначе вывести из балансировки, не убивая). Против «медленных хвостов» у критичных чтений работает приём hedged requests: не дождавшись ответа за p95, послать дубль на другую реплику и взять первый ответ — задержка хвоста срезается ценой нескольких процентов лишнего трафика (только для идемпотентных операций — см. раздел 3).

Типичная ошибка. Readiness-проверка, которая сама ходит во все зависимости глубоко: моргнула база — все инстансы разом объявили себя неготовыми, балансировщик остался без бэкендов, самодельная авария готова. Проверка должна отражать способность обслуживать, а не транзитивное здоровье мира.

Часть II. У пользователя

Теперь развернём точку зрения: плохая связь — не авария в ЦОДе, а норма жизни клиента. Мобильный интернет в электричке, лифт, роуминг, перегруженный Wi-Fi, деградация каналов — приложение, которое умеет жить только при идеальной сети, в реальности неработоспособно регулярно. Приёмы этой части зеркальны серверным: та же очередь, тот же повтор, та же идемпотентность — но на стороне клиента.

7. Offline-first: очередь операций

Проблема. Классическая архитектура «каждое действие = синхронный запрос» при плохой сети превращает приложение в генератор крутящихся спиннеров и потерянных действий пользователя.

Решение. Перевернуть архитектуру: источник истины для интерфейса — локальное хранилище (SQLite, IndexedDB), а сеть — фоновый синхронизатор. Действие пользователя мгновенно применяется локально и ставится в локальную очередь операций; фоновый процесс отправляет очередь при появлении связи — с повторами, экспоненциальной задержкой и ключами идемпотентности из части I (сервер обязан их поддерживать — offline-first проектируется с двух сторон!). Интерфейс работает всегда; сеть лишь меняет свежесть данных. Так устроены зрелые мессенджеры, заметочники, почтовые клиенты.

Типичная ошибка. Очередь без сохранения на диск: убитое системой приложение теряет неотправленные действия — ровно те, что пользователь совершил в самый неудобный момент.

8. Конфликты

Проблема. Работа офлайн означает конкурентные правки: пользователь изменил запись с телефона в метро и с ноутбука дома; при синхронизации версии столкнулись.

Решение — лестница по возрастанию честности:

  • «Последний победил» (LWW) — допустимо лишь там, где потеря одной из версий безобидна (позиция ползунка, черновик настроения). Помните из обзора: часы клиентов врут, «последний» — понятие условное.
  • Версионирование и явное слияние: каждая запись несёт версию (счётчик или вектор); сервер, увидев конфликт версий, не выбирает молча, а либо сливает по полям (правки разных полей совместимы), либо возвращает конфликт клиенту — показать пользователю обе версии честнее, чем тихо выбросить одну.
  • CRDT — для совместного редактирования и счётчиков: типы данных, сходящиеся при любом порядке слияния (см. обзор, раздел о репликации). Готовые библиотеки (Automerge, Yjs) закрывают тексты, списки, карты — не пишите слияние текстов руками.

Типичная ошибка. Разрешение конфликтов «перезаписью целого объекта»: пользователь A правил телефон, пользователь B — адрес, слияние по объекту теряет одну правку, хотя по полям конфликт вовсе отсутствовал.

9. Кэш и свежесть

Проблема. При медленной сети выбор «показать старое мгновенно или свежее через десять секунд» встаёт на каждом экране.

Решение. Почти всегда правильный ответ — stale-while-revalidate: мгновенно показать кэшированное, параллельно запросить обновление, по приходе — обновить экран. Пользователь видит контент сразу и свежий — вскоре. Дополняется дельта-синхронизацией (запрашивать изменения с момента X, а не всё), сжатием и — для медиа — прогрессивной загрузкой. И обязательная вежливость к мобильному пользователю: уважать лимиты трафика (не качать тяжёлое вне Wi-Fi без разрешения) и батарею (синхронизация пачками по расписанию, а не постоянным опросом; на Android это штатные механизмы отложенных фоновых работ).

Типичная ошибка. Кэш без индикации свежести: пользователь принимает вчерашние данные за сегодняшние. Дешёвое лекарство — ненавязчивая метка «обновлено N минут назад» на закэшированных экранах.

10. Честный интерфейс

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

Часть III. Практикум: ломаем сеть у себя

Всё перечисленное проверяется без внешней инфраструктуры: подсистема управления трафиком 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:

  1. Тайм-ауты: задержка 5000ms без потерь — сколько висит ваш клиент без тайм-аута? С тайм-аутом из перцентилей?
  2. Повторы: loss 30% в netem означает потерю пакетов, а TCP обычно компенсирует её ретрансляциями, поэтому сначала измерьте рост задержки и число тайм-аутов, не ожидая 30% ошибок HTTP. Для воспроизводимой доли отказавших запросов используйте прокси уровня приложения либо сочетание задержки с коротким клиентским тайм-аутом. Затем устройте полную недоступность на минуту и сравните шторм немедленных повторов с ограниченными повторами, задержкой, джиттером и бюджетом.
  3. Идемпотентность: теряйте ответы на исходящем интерфейсе сервера (либо отдельным прокси; обычный netem действует только на исходящий трафик выбранного интерфейса), затем посчитайте задвоившиеся операции без ключа идемпотентности и с ним.
  4. Предохранитель: остановите сервис на 2 минуты — сравните задержку клиента с предохранителем (мгновенные отказы) и без (сплошные тайм-ауты); проверьте автоматическое восстановление.
  5. Деградация: delay 2000ms на зависимость «рекомендаций» — отдаёт ли ваш сервис основной ответ без неё и за какое время?

Для клиентской части то же самое встроено в инструменты разработчика: эмулятор Android умеет профили сети вплоть до GPRS и обрывов, Chrome DevTools — троттлинг и офлайн. Приложение, прожившее программу экспериментов, готово к реальной сети; не прожившее — сообщило вам об этом на стенде, а не в продакшене. Систематическое развитие этой практики — хаос-инженерия: регулярные учения с намеренными отказами.

Сводный чек-лист

  • Тайм-аут на каждом сетевом вызове; значения — из перцентилей; в цепочках — сквозной дедлайн.
  • Повторы: с экспоненциальной задержкой и джиттером, ограничены, с бюджетом, только для идемпотентного и только на одном уровне.
  • Мутирующие API принимают ключ идемпотентности; дедупликация атомарна с операцией.
  • Предохранитель на каждый внешний адресат; отказ — быстрый.
  • Очереди ограничены; перегрузка встречается сбросом по приоритетам и плановой деградацией; деградация отрепетирована.
  • Liveness и readiness разделены; readiness не транзитивна.
  • Клиент: источник истины локален, операции — в сохраняемой очереди, конфликты — по явной стратегии (LWW только для безобидного), кэш — stale-while-revalidate с меткой свежести.
  • Всё перечисленное проверено под netem, а не только в идеальной сети стенда.

Заключение

У всех приёмов этой статьи один корень, заявленный во введении: сбой сети — не исключительная ситуация, а рабочее состояние, у которого должно быть спроектированное поведение. Обзорная статья объясняла, почему это неизбежно (ненадёжная сеть, неразличимость отказов, отсутствие общего времени); здесь мы увидели, что инженерный ответ на каждую теоретическую неприятность давно существует и умещается в чек-лист. Связка «тайм-аут → повтор → идемпотентность → предохранитель → деградация» на сервере и «локальная очередь → синхронизация → разрешение конфликтов» у клиента — это минимальный джентльменский набор системы, которой предстоит жить в настоящей сети. А проверить, что набор при вас, можно за вечер — одной командой tc netem.

Список литературы

  1. M. Nygard, "Release It! Design and Deploy Production-Ready Software," 2nd ed., Pragmatic Bookshelf, 2018 — классика паттернов устойчивости (предохранители, переборки, каскады).
  2. B. Beyer et al. (ред.), "Site Reliability Engineering," O'Reilly, 2016 — главы о каскадных отказах и управлении перегрузкой. sre.google/sre-book
  3. Amazon Builders' Library: "Timeouts, retries, and backoff with jitter"; "Avoiding fallback in distributed systems". aws.amazon.com/builders-library
  4. J. Dean, L. Barroso, "The Tail at Scale," CACM 56(2), 2013 — хвостовые задержки и hedged requests.
  5. M. Kleppmann, "Designing Data-Intensive Applications," O'Reilly, 2017 — гл. 5, 8, 9: репликация, ненадёжные сети, согласованность.
  6. Документация netem: man tc-netem
404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией