Системный дизайн

Программа, которой пользуется группа, со временем превращается в систему. Рядом с ней появляются база данных, общая для нескольких программ, очередь между сбором и обработкой, второй экземпляр сервиса и балансировщик перед обоими. Поведение такой системы определяется уже не только кодом, но и тем, как её части ведут себя под нагрузкой и при отказах. Одна программа либо работает, либо нет; система из нескольких программ отказывает по частям, и каждая часть отказывает по-своему: один экземпляр отвечает медленно, другой завис, очередь переполнилась, а повторы клиентов после кратковременного сбоя многократно умножили нагрузку.

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

Хорошо спроектированную систему описывают четырьмя свойствами. Масштабируемость — способность сохранять заданные показатели, прежде всего задержку и долю ошибок, при росте нагрузки за счёт добавления ресурсов. Отказоустойчивость — способность продолжать обслуживание, пусть и с ухудшением, при отказе машины, процесса, сети или зависимости. Наблюдаемость — возможность по журналам, метрикам и трассировкам понять, что происходит внутри работающей системы. Сопровождаемость — возможность изменять систему, выкатывать новые версии и откатывать неудачные быстро и предсказуемо. Свойства конкурируют за один бюджет: каждая копия, повышающая отказоустойчивость, требует затрат, а каждый новый компонент усложняет сопровождение.

Сквозным примером главы служит архив измерений установки: оцифровщики непрерывно записывают в него данные, а сотрудники и их скрипты читают запуски и графики по HTTP. Каждое типовое решение имеет цену: балансировщик добавляет сетевой переход, реплика базы отстаёт от главной, кеш может отдать устаревшее значение, повтор создаёт дополнительную нагрузку. Поэтому решения принимаются по числам — сколько запросов в секунду, какая задержка допустима, какая доля ответов может быть неуспешной. Числа же показывают, когда сложное устройство не требуется: лабораторному сервису с десятью пользователями одна программа и одна база подходят лучше распределённой системы.

Листинги главы выполнены на виртуальной машине под Python 3.12 с FastAPI 0.142, uvicorn 0.54, httpx 0.28 и nginx 1.31 — той же, на которой записаны демонстрации лекции «Архитектура и системный дизайн»; числа демонстраций приведены по их записям. Клиент и сервис на этой машине делят два ядра, поэтому пропускная способность и задержки на другой машине будут иными, а распределение запросов по экземплярам, коды ответов и счётчики — теми же.

Слайды к главе. Требования в числах, масштабирование, отказоустойчивость, работа с данными, наблюдаемость и проект системы сбора данных изложены также в лекции «Архитектура и системный дизайн» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

Требования в числах

Слайды к главе. Функциональные и нефункциональные требования, нагрузка и перцентили задержки, SLI, SLO и SLA, бюджет ошибок и «девятки» изложены также в первой части лекции «Архитектура и системный дизайн» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

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

ТребованиеПример для архива измерений
функциональноевыдача запуска по номеру, график канала за интервал
нагрузказапись 4 МБ/с непрерывно, чтение — 50 запросов/с в пике
задержкаp99 выдачи запуска меньше 300 мс
доступность99,9 % успешных ответов за 30 дней
сохранностьни одного потерянного измерения
стоимостьтри машины и диски на год записи

Требование без числа проверить нельзя: «быстро» превращается в перцентиль задержки, «надёжно» — в долю успешных ответов за окно времени. В требования входит и цена: каждая дополнительная девятка доступности кратно увеличивает число машин, площадок и объём работы.

Нагрузка и задержка

Нагрузку описывают числом запросов в секунду (rps), причём важно не только среднее за сутки, но и пик: у архива измерений он приходится на утро после ночной смены, когда скрипты обработки запускаются одновременно. Задержка представляет собой не одно число, а распределение, и описывают её перцентилями. Медиана p50 — время, которого не превышает половина запросов, p95 и p99 — время, которого не превышают 95 и 99 % запросов. Среднее для этого непригодно: несколько очень медленных ответов сдвигают его, но не показывают, насколько велика задержка именно этих ответов. Разницу показывает модель сервиса, у которого 4 % запросов обращаются к медленному диску:

import random
import statistics

rng = random.Random(14)
# 96 % запросов обслуживаются около 20 мс, 4 % обращаются к медленному диску
lat = [rng.gauss(200, 5) if rng.random() < 0.04 else rng.gauss(20, 2)
       for _ in range(10_000)]
q = statistics.quantiles(lat, n=100, method='inclusive')  # q[k - 1] — k-й
print(f'среднее {statistics.fmean(lat):.1f} мс, p50 {q[49]:.1f} мс')
print(f'p95 {q[94]:.1f} мс, p99 {q[98]:.1f} мс, максимум {max(lat):.1f} мс')
среднее 26.8 мс, p50 20.1 мс
p95 24.5 мс, p99 203.3 мс, максимум 215.4 мс

Функция statistics.quantiles с n=100 возвращает 99 точек разбиения, и k-й перцентиль равен элементу с индексом k − 1. Метод inclusive считает наименьшее и наибольшее значения выборки нулевым и сотым перцентилями и не выходит за пределы наблюдённых значений; метод по умолчанию, exclusive, предполагает, что в генеральной совокупности встречаются и более крайние значения. Среднее, 26,8 мс, больше медианы, но о медленных ответах ничего не сообщает: их показывает только p99, равный 203,3 мс.

Хвост распределения затрагивает многих пользователей. Оценим, как часто страница, для построения которой выполняется 20 запросов, получает хотя бы один ответ медленнее p99. Вероятность того, что все 20 ответов быстрее p99, равна \(0{,}99^{20} \approx 0{,}82\), поэтому медленный ответ встречается примерно на каждой пятой странице: \(1 - 0{,}99^{20} \approx 0{,}18\). Таким образом, требования к задержке записывают по p95 или p99, а не по среднему.

Нагрузку, задержку и число одновременно обрабатываемых запросов связывает закон Литтла: среднее число запросов в системе равно интенсивности потока, умноженной на среднее время пребывания запроса, \(L = \lambda W\). При \(\lambda = 100\) запросов/с и \(W = 30\) мс в работе одновременно находятся в среднем \(L = 3\) запроса. Если сервис способен обрабатывать одновременно четыре запроса, его ёмкость ограничена значением \(4/W\), и поток выше ёмкости приводит к неограниченному росту очереди и задержки.

Открытая и замкнутая модель нагрузки

Поведение задержки у границы ёмкости показывает демонстрация к лекции. Сервис svc.py на FastAPI пропускает к работе не больше четырёх запросов одновременно (семафор asyncio); обычный запрос длится 20 мс, а каждый двадцать пятый, то есть 4 % запросов, — 200 мс. Найдём ёмкость сервиса; для этого достаточно закона Литтла. Среднее время обработки равно \(0{,}96 \cdot 20 + 0{,}04 \cdot 200 = 27{,}2\) мс, и по закону Литтла четыре одновременных запроса обслуживаются с темпом около \(4 / 0{,}0272 \approx 147\) запросов/с. Генератор aload.py на httpx отправляет запросы по расписанию с заданной частотой, не дожидаясь ответов, и подаёт каждую частоту в течение 4 с:

Частота, запросов/сЗапросовСреднее, мсp50, мсp95, мсp99, мсМаксимум, мсОшибок
502003022272032050
1004002922322022030
1405603223352082140
1606402672654995636960
2008007997921545162118190

До 140 запросов/с медиана составляет 22–23 мс, среднее — 29–32 мс, а p99 — 202–208 мс: медленные 4 % не видны в медиане, слегка сдвигают среднее и полностью определяют p99. При 160 запросах/с, всего на 9 % выше ёмкости, медиана вырастает до 265 мс, а при 200 — до 792 мс с максимумом 1819 мс: запросы приходят быстрее, чем обслуживаются, и очередь растёт всё время теста. Ошибок нет ни в одном прогоне — перегрузка проявляется задержкой задолго до отказов.

Генератор aload.py реализует открытую модель нагрузки: моменты отправки задаёт расписание, как у независимых пользователей. Генераторы замкнутого типа с постоянным числом одновременных запросов, как ab, wrk или клиент hit.py из остальных демонстраций лекции, отправляют следующий запрос только после ответа на предыдущий. При перегрузке такой генератор замедляется вместе с сервером и не учитывает ожидания запросов, которые не успел отправить, поэтому занижает хвост распределения. Эту ошибку измерения называют coordinated omission, и задержку у границы ёмкости измеряют генератором с расписанием. Профили нагрузочных испытаний, прогрев и отдельное окружение для них рассмотрены в разделе «Нагрузочное тестирование» главы «Жизненный цикл ПО».

SLI, SLO и SLA

Требования к качеству обслуживания записывают тремя связанными понятиями из практики эксплуатации Google, Site Reliability Engineering [23].

ТерминЧто этоПример для архива
SLIизмеряемый показательдоля ответов без ошибки сервера; доля выдач запуска быстрее 300 мс
SLOцель для SLI за окно времени99,9 % успешных ответов за 30 дней
SLAдоговор с последствиями нарушенияниже 99,5 % — возврат части оплаты
бюджет ошибок1 − SLO0,1 % запросов за 30 дней

SLI (service level indicator, индикатор уровня обслуживания) представляет собой измеряемую величину: доля ответов без ошибки сервера, доля запросов быстрее 300 мс, задержка поступления данных в архив. SLO (service level objective, цель уровня обслуживания) — требуемое значение индикатора за окно времени, например 99,9 % успешных ответов за скользящие 30 дней. SLA (service level agreement, соглашение об уровне обслуживания) — договор с внешним заказчиком, в котором записаны последствия нарушения; его порог обычно мягче внутреннего SLO, чтобы команда успевала отреагировать до нарушения договора. Доступность распределённого сервиса считают не по времени работы машины, а по запросам: машина может работать, а пользователи — получать ошибки.

Разность между единицей и SLO называют бюджетом ошибок: при 99,9 % это 0,1 % запросов за окно, которые можно потратить на выкатки, эксперименты и аварии. Бюджет расходуется на всё сразу, и когда он исчерпан, выкатки приостанавливают до восстановления. Для архива измерений индикаторов несколько: доля успешных чтений, доля выдач запуска быстрее 300 мс и задержка поступления измерения в архив, которая не должна превышать 10 с после его снятия.

Девятки и составные системы

Доступность часто называют числом девяток: 99,9 % — три девятки, 99,99 % — четыре. Каждому уровню соответствует допустимое время простоя, равное произведению бюджета ошибок на длительность окна. Таблицу вычисляет скрипт демонстрации nines.py:

SLOГод30 днейНеделяСутки
99 %3 д 15 ч 36 мин7 ч 12 мин1 ч 40 мин 48 с14 мин 24 с
99,9 %8 ч 45 мин 36 с43 мин 12 с10 мин 5 с1 мин 26 с
99,95 %4 ч 22 мин 48 с21 мин 36 с5 мин 2 с43,2 с
99,99 %52 мин 34 с4 мин 19 с1 мин8,6 с
99,999 %5 мин 15 с25,9 с6,0 с0,9 с

При 99,99 % допустимый простой за 30 дней составляет 4 мин 19 с — меньше, чем занимает одна неудачная выкатка с откатом.

Доступность составной системы вычисляется по правилам теории надёжности. Если запрос проходит последовательно через несколько сервисов и успешен только при успехе каждого, доступности перемножаются, \(A = A_1 A_2 A_3\), и цепочка оказывается хуже каждого звена. Если же работают копии и отказ наступает, только когда отказали все, перемножаются вероятности отказа: \(A = 1 - (1 - A_1)(1 - A_2)\). Запишем обе формулы и бюджет ошибок в коде:

import math

slo = 99.9                                     # цель, %
budget = 1 - slo / 100                         # доля неуспешных запросов
print(f'простой за 30 дней: {budget * 30 * 24 * 60:.1f} мин')

chain = math.prod([0.999, 0.999, 0.999])       # три звена последовательно
copies = 1 - math.prod([1 - 0.99, 1 - 0.99])   # две независимые копии
print(f'цепочка {chain:.6f}, копии {copies:.6f}')
простой за 30 дней: 43.2 мин
цепочка 0.997003, копии 0.999900

Цепочка из трёх сервисов по 99,9 % даёт 99,7 %, две копии по 99 % — 99,99 %. Формула для копий верна только при независимых отказах: две копии на одном диске, в одной стойке или обновлённые одной выкаткой отказывают вместе и ведут себя как одна. В той же демонстрации бюджет пересчитан в запросы: при 100 000 запросов/с за 30 дней приходит около \(2{,}59 \cdot 10^{11}\) запросов, и при SLO 99,99 % неуспешными допустимо около \(2{,}59 \cdot 10^{7}\) из них.

Масштабирование

Слайды к главе. Вертикальное и горизонтальное масштабирование, сервисы без состояния, алгоритмы балансировки и консистентное хеширование изложены также во второй части лекции «Архитектура и системный дизайн» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

Когда сервис перестаёт справляться с нагрузкой, его мощность наращивают двумя способами.

ВертикальноеГоризонтальное
ресурспроцессоры и память машинычисло экземпляров
доработкане нужнасервис без состояния
пределсамая большая машинаобщие зависимости, бюджет
отказ машиныостановка всего сервисапотеря доли мощности
изменениеобычно с перезапускомпо одному экземпляру, без простоя

Вертикальное масштабирование даёт той же машине больше процессоров и памяти. Доработка программы не нужна, но предел задан самой большой доступной машиной, а сама машина остаётся единой точкой отказа. Изменение размера виртуальной машины обычно требует её перезапуска; квоту процессора работающего контейнера Docker команда docker update --cpus 2 <контейнер> меняет без перезапуска. Горизонтальное масштабирование добавляет одинаковые экземпляры за балансировщиком: рост ограничен бюджетом и общими зависимостями, например базой, отказ одного экземпляра снимает лишь часть мощности, а новые версии выкатываются по одному экземпляру без простоя. Платой является доработка сервиса, который не должен хранить состояние в себе, и сетевой переход через балансировщик.

Для Python граница между двумя способами проходит внутри машины. Из-за глобальной блокировки интерпретатора процесс обычной сборки CPython выполняет байт-код только в одном потоке за раз и при вычислениях на чистом Python загружает не больше одного ядра (глава «Многопоточность и GIL»). Поэтому многоядерную машину загружают несколько процессов, как это делают uvicorn --workers и gunicorn. Свободнопоточная сборка, официально поддерживаемая с Python 3.14, снимает это ограничение, но используемой по умолчанию пока не стала.

В демонстрации к лекции сервис cpu.py выполняет на каждый запрос около 10 мс вычислений на чистом Python в обычной функции-обработчике, которую FastAPI запускает в пуле потоков. Клиент отправляет 300 запросов, по восемь одновременно, открывая новое соединение на каждый запрос. Сервер uvicorn с одним рабочим процессом обрабатывает их за 4,9 с — 62 запроса/с при медиане 118 мс: восемь запросов делят одно ядро, а цикл вместе с обработкой HTTP занимает около 16 мс процессора на запрос. С ключом --workers 2 uvicorn открывает слушающий сокет в родительском процессе и передаёт его двум рабочим процессам, а новые соединения между ними распределяет ядро ОС: 142 и 158 запросов за 2,4 с, 126 запросов/с при медиане 57–61 мс. На двух ядрах второй процесс удвоил пропускную способность. Новое соединение на каждый запрос понадобилось потому, что постоянное соединение закрепляется за процессом, который его принял, и восемь постоянных соединений распределились бы по процессам неравномерно.

Сервисы без состояния

Горизонтально масштабируется прежде всего сервис без состояния: всё, что должно пережить запрос, — сессии пользователей, загруженные файлы, результаты расчётов — хранится вне процесса, в базе, общем кеше или хранилище файлов. Тогда любой экземпляр обслуживает любой запрос, экземпляры можно добавлять, убирать и перезапускать, а балансировщик может направить следующий запрос на любой из них. Словарь в памяти процесса нарушает это свойство: результат, записанный одним экземпляром, не виден другому и пропадает при перезапуске. Для физика типичным примером служит сервис расчётов: исполнитель берёт задание параметрического скана, считает и записывает результат в общее хранилище, и исполнители взаимозаменяемы.

Сервисы с состоянием — базы данных, кеши, брокеры очередей — масштабируются иначе: новую копию базы сначала наполняют данными, что при нескольких терабайтах занимает часы, а холодный кеш ещё не снимает нагрузку с базы. Даже балансировщик, не хранящий данных приложения, имеет рабочее состояние — открытые соединения, счётчики активных соединений и результаты проверок работоспособности, — и у каждого экземпляра балансировщика оно своё.

Балансировка нагрузки

Балансировщик распределяет запросы между экземплярами, и способ выбора экземпляра зависит от характера нагрузки.

АлгоритмКак выбирает экземплярКогда подходит
round robinпо очереди, с учётом весоводинаковые короткие запросы
least connectionsс наименьшим числом активных соединенийзапросы разной длительности
hashпо ключу: пользователь, адрес страницы, номер запускапривязка, локальный кеш экземпляра
random twoлучший из двух случайныхнесколько балансировщиков

Round robin отправляет запросы по очереди, а веса учитывают неравные машины: сервер с весом 2 получает вдвое больше запросов. Алгоритм подходит для одинаково коротких запросов, но не учитывает скорость экземпляров: медленный получает ту же долю. Least connections выбирает экземпляр с наименьшим числом активных соединений и тем самым снижает нагрузку на медленный. Хеш по ключу — пользователю, адресу страницы, номеру запуска — отправляет одинаковые ключи на один экземпляр, что позволяет использовать его локальный кеш. Выбор лучшего из двух случайных экземпляров почти так же хорош, как least connections, но не требует точного общего счётчика и подходит, когда балансировщиков несколько. Балансировщик транспортного уровня, L4 в модели OSI (глава «Компьютерные сети»), распределяет соединения TCP, не разбирая HTTP, а балансировщик прикладного уровня, L7, разбирает запрос и может выбирать экземпляр по пути, заголовку или cookie, но тратит на это больше процессора, прежде всего на завершение TLS.

В nginx группа экземпляров описывается блоком upstream, а алгоритм — директивой внутри него:

upstream app {
    zone app 64k;                # состояние группы — общее для рабочих процессов
    least_conn;                  # или hash $arg_user consistent;
    server 127.0.0.1:8001 weight=2;
    server 127.0.0.1:8002;
    server 127.0.0.1:8003;
}
server {
    listen 127.0.0.1:8080;
    location / { proxy_pass http://app; }
}

Директива zone размещает состояние группы в разделяемой памяти и тем самым обеспечивает общие для рабочих процессов nginx очередь и счётчики; без неё каждый рабочий процесс ведёт свою очередь round robin и свои счётчики соединений. Выбор лучшего из двух случайных задаётся директивой random two;: из двух случайно выбранных экземпляров nginx по умолчанию берёт тот, у которого меньше активных соединений. В главах «Веб-технологии» и «Docker» nginx служит обратным посредником перед единственным бэкендом; блок upstream превращает его в балансировщик.

В демонстрации к лекции за nginx в контейнере работают три экземпляра FastAPI, отвечающие своим именем; второй из них, app2, отвечает через 200 мс, остальные — через 10 мс. Клиент отправляет 300 запросов, по 12 одновременно. Round robin делит запросы поровну, по 100, весь прогон занимает 2,0 с (148 запросов/с), а медиана у app2 равна 202 мс: медленный экземпляр задерживает каждый третий запрос. С least_conn экземпляры получают 144, 12 и 144 запроса, и прогон занимает 0,6 с (465 запросов/с): медленный экземпляр дольше занят и реже оказывается наименее загруженным. С hash $arg_user каждый из восьми пользователей при всех своих запросах попадает на один и тот же экземпляр.

Консистентное хеширование

Когда экземпляр или шард выбирают по хешу ключа, важно, что происходит при изменении их числа. Простейший способ — остаток от деления хеша на число узлов — при переходе от N к N + 1 узлам меняет узел почти для всех ключей: переезжает доля \(N/(N+1)\), при переходе с четырёх узлов на пять — 80 %. Получим эту долю. Ключ остаётся на месте, только если остатки от деления хеша на N и на N + 1 совпадают; на каждом отрезке из \(N(N+1)\) последовательных значений хеша таких значений N, поэтому на месте остаётся доля \(1/(N+1)\) ключей, а переезжает \(N/(N+1)\). Для кеша это означает одновременный промах почти по всем ключам, для шардов базы — перенос почти всех данных.

Консистентное хеширование размещает на кольце значений хеша и узлы, и ключи; ключ принадлежит первому узлу по ходу кольца. Новый узел с одной точкой на кольце забирает ключи только с одной дуги, в среднем долю \(1/(N+1)\), а остальные ключи остаются на месте. Чтобы дуги были равномерными, каждый узел ставят на кольцо во многих точках, и тогда новый узел забирает понемногу ключей у каждого из прежних. Рандеву-хеширование достигает того же иначе: для ключа вычисляется хеш пары «узел, ключ» для каждого узла, и выбирается узел с наибольшим значением.

import bisect
import hashlib


def h(s):
    """Стабильный хеш: одинаков в любом процессе и на любой машине."""
    return int.from_bytes(hashlib.md5(s.encode()).digest()[:8], 'big')


class Ring:
    """Консистентное хеширование: vnodes точек на кольце для каждого узла."""

    def __init__(self, nodes, vnodes=100):
        self.points = sorted((h(f'{n}#{v}'), n)
                             for n in nodes for v in range(vnodes))
        self.hashes = [p for p, _ in self.points]

    def __call__(self, key):
        # первая точка по ходу кольца; за последней следует первая
        i = bisect.bisect(self.hashes, h(key)) % len(self.points)
        return self.points[i][1]


def by_mod(nodes):                     # остаток от деления
    return lambda key: nodes[h(key) % len(nodes)]


def rendezvous(nodes):                 # наибольший хеш пары «узел, ключ»
    return lambda key: max(nodes, key=lambda n: h(f'{n}:{key}'))

Функция h построена на MD5 и одинакова в любом процессе и на любой машине; криптографическая стойкость здесь не требуется, достаточно равномерности. Скрипт демонстрации shard.py теми же функциями распределяет 100 000 ключей вида run-000123 по четырём шардам и считает, сколько ключей сменят шард при добавлении пятого:

СпособКлючей на шард, наименьшееКлючей на шард, наибольшееПереехало при 4 → 5
остаток от деления24 89025 23080,0 %
кольцо, 100 точек на узел23 74926 01420,0 %
рандеву24 89725 11420,1 %

Остаток от деления распределяет ключи равномерно, но переносит 80,0 % ключей. Кольцо со ста точками на узел переносит 20,0 % — ровно ту долю, которую должен забрать новый шард, — но распределяет ключи менее равномерно. Рандеву-хеширование сочетает равномерность и малый перенос. В nginx консистентный хеш включается словом consistent в директиве hash, например hash $arg_user consistent;.

Встроенная функция hash() для распределения ключей непригодна: начальное значение хеша строк выбирается при запуске интерпретатора, если не задана переменная окружения PYTHONHASHSEED.

for i in 1 2; do python3 -c "print(hash('run-000123'))"; done
for i in 1 2; do PYTHONHASHSEED=0 python3 -c "print(hash('run-000123'))"; done
-6807953549254101815
7991815127659032150
-6516832473932046442
-6516832473932046442

Два первых запуска дают разные числа, два последних — одинаковые. Потомок, созданный вызовом fork, наследует начальное значение родителя (так создаёт рабочие процессы gunicorn), а рабочие процессы uvicorn --workers запускаются методом spawn, то есть новыми интерпретаторами: на той же машине два рабочих процесса uvicorn вернули для одной строки разные значения hash(), а с PYTHONHASHSEED=0 — одинаковые. Поэтому для распределения ключей используют стабильный хеш, как функцию h выше.

Отказоустойчивость

Слайды к главе. Резервирование и кворум, проверки работоспособности, тайм-ауты и дедлайн, повторы, идемпотентность и автомат защиты изложены также в третьей части лекции «Архитектура и системный дизайн» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

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

Резервирование и кворум

Отказоустойчивость начинается с резервирования: экземпляров на один больше, чем требуется для пиковой нагрузки (схема N + 1), чтобы отказ одного не перегружал остальные. Копии разносят по доменам отказа — разным машинам, стойкам, линиям питания и площадкам: три реплики на одной машине переживут отказ процесса, но не отказ диска или питания.

Когда копии хранят данные и должны договориться, например о том, какая из них главная, применяется кворум: решение принимает большинство, \(\lfloor N/2 \rfloor + 1\) узлов.

УзловКворумПереживает отказов
110
220
321
431
532

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

Проверки работоспособности

Чтобы перестать отправлять запросы отказавшему экземпляру, балансировщику необходимо узнать об отказе. Активная проверка периодически опрашивает служебный адрес, например /health, и исключает экземпляр после нескольких неудач подряд; она есть в HAProxy, Kubernetes и коммерческой версии nginx, но не в открытой. Пассивная проверка следит за настоящими запросами: в nginx экземпляр, давший max_fails неудач за время fail_timeout, исключается на fail_timeout, после чего получает пробный запрос. По умолчанию max_fails=1 и fail_timeout=10s.

upstream app {
    server 127.0.0.1:8001 max_fails=1 fail_timeout=10s;
    server 127.0.0.1:8002 max_fails=1 fail_timeout=10s;
    server 127.0.0.1:8003 max_fails=1 fail_timeout=10s;
}
server {
    listen 127.0.0.1:8080;
    location / {
        proxy_pass http://app;
        proxy_read_timeout 1s;              # ответа ждать не дольше 1 с
        proxy_next_upstream error timeout;  # когда пробовать соседа
    }
}

Неудачный запрос nginx повторяет на соседнем экземпляре по правилам директивы proxy_next_upstream, по умолчанию — при ошибке соединения и тайм-ауте. Запросы с неидемпотентными методами POST, LOCK и PATCH nginx на соседа не передаёт, если запрос уже был отправлен экземпляру: повтор мог бы выполнить действие дважды.

В демонстрации к лекции к этой конфигурации подключены три экземпляра, а в журнал nginx добавлены адреса и коды всех попыток. Процесс app2 остановлен сигналом STOP: он не отвечает, но ядро продолжает принимать его соединения — так выглядит зависший процесс. Из трёх запросов POST, распределённых по очереди, запрос к app2 через 1,0 с, по истечении proxy_read_timeout, завершается кодом 504: уже отправленный POST nginx соседу не передаёт. После этой неудачи app2 исключён на 10 с, и шесть запросов GET делят app1 и app3 по три без задержек, за 0,1 с. Через 10 с app2 получает пробный запрос GET; nginx ждёт ответа 1 с и, не дождавшись, повторяет запрос на app1, так что клиент получает ответ 200, но примерно через секунду: шесть запросов занимают уже 1,1 с. Журнал nginx показывает обе ситуации:

POST / HTTP/1.1 504 up=127.0.0.1:8002 st=504
GET / HTTP/1.1 200 up=127.0.0.1:8002, 127.0.0.1:8001 st=504, 200

Завершённый процесс ведёт себя иначе: его порт закрыт, ядро сразу отклоняет соединение, запрос ещё не отправлен, и nginx передаёт соседу даже POST. Тот же опыт с процессом app2, завершённым сигналом TERM (в демонстрациях лекции его нет), дал на три запроса POST три ответа 200, а запрос, адресованный app2, записан в журнал с двумя попытками:

POST / HTTP/1.1 200 up=127.0.0.1:8002, 127.0.0.1:8003 st=502, 200

Журнал ошибок nginx содержит для этого запроса сообщение connect() failed (111: Connection refused); неудача соединения тоже засчитывается в max_fails, и app2 исключается на fail_timeout.

Сама точка входа тоже резервируется: несколько адресов в DNS, плавающий адрес по протоколу VRRP, который соседняя машина забирает себе при отказе, или anycast — один адрес, анонсируемый из нескольких мест сети. Содержание /health требует отдельного решения: проверка только того, что процесс жив, пропустит экземпляр, потерявший соединение с базой, а проверка всех зависимостей при отказе базы снимет все экземпляры сразу.

Тайм-ауты и дедлайн

Каждому сетевому вызову необходим тайм-аут: без него зависший сервис бесконечно удерживает вызывающего, его потоки и соединения, и отказ распространяется вверх по цепочке. Значения тайм-аутов в цепочке необходимо согласовать. Пусть nginx ждёт ответа gateway 10 с, gateway ждёт сервис articles 30 с, а articles ждёт базу 60 с. Клиент получит ответ 504 через 10 с, а gateway и articles продолжат работу, результат которой уже никому не нужен, и под нагрузкой такие запросы накапливаются. Отсюда правило: тайм-аут вызываемого меньше тайм-аута вызывающего на запас сетевых расходов. Одинаковые значения на всех уровнях не помогают, поскольку внутренний и внешний тайм-ауты срабатывают одновременно.

Надёжнее передавать по цепочке дедлайн, который представляет собой остаток времени, отведённого на запрос, например в заголовке. Каждый уровень вычитает потраченное время, а получив исчерпанный бюджет, прекращает работу. Передают именно остаток, а не абсолютный момент, потому что часы разных машин расходятся; так устроены дедлайны gRPC.

import time


def get_with_deadline(client, url, deadline):
    """Запрос с остатком бюджета; deadline — момент по time.monotonic()."""
    left = deadline - time.monotonic()            # остаток бюджета, с
    if left <= 0:
        raise TimeoutError('бюджет исчерпан')
    return client.get(url, timeout=left,
                      headers={'X-Deadline-Ms': str(int(1000 * left))})

Здесь client — клиент httpx. Принимающий сервис вычисляет собственный дедлайн как сумму time.monotonic() и полученного остатка, а вызывая следующий уровень, передаёт остаток за вычетом небольшого запаса. Монотонные часы не зависят от перевода системного времени, поэтому дедлайн внутри процесса отсчитывают по ним.

В демонстрации к лекции скрипт deadline.py поднимает в рамках одного процесса цепочку из двух серверов, gateway и articles. Времена в демонстрации меньше, чем в примере выше, но соотношение то же — каждый уровень ждёт дольше вызывающего: клиент ждёт ответа 1 с, gateway ждёт articles 6 с, собственный тайм-аут articles — 60 с, а его работа занимает 3 с и выполняется частями по 0,1 с. Без дедлайна клиент через 1,0 с прекращает ждать с ошибкой TimeoutError, а articles доделывает работу, и gateway отвечает 200 через 3,1 с — ответ, который никто не прочтёт. С дедлайном в заголовке X-Deadline-Ms клиент так же получает отказ через 1,0 с, но gateway в тот же момент прекращает ждать и отвечает 504, а articles, обнаружив исчерпанный бюджет, прерывает работу через 1,0 с вместо 3. Для клиента разницы нет, но дедлайн позволяет системе не выполнять работу впустую.

Само значение тайм-аута выбирают по распределению задержки вызываемого сервиса: тайм-аут на уровне p99,9 означает одну ложную отмену на тысячу запросов. Так поступают в Amazon: сначала выбирают допустимую долю ложных тайм-аутов, например 0,1 %, а затем берут соответствующий перцентиль задержки вызываемого сервиса.

Повторы

Повтор неудачного запроса помогает пережить кратковременный сбой, но при перегрузке усиливает её. Если повторяет каждый уровень цепочки, попытки перемножаются: по две попытки на трёх уровнях дают до \(2^3 = 8\) запросов к последнему сервису, а в примере М. Брукера три попытки на каждом из пяти уровней увеличивают нагрузку на базу в \(3^5 = 243\) раза. Поэтому повторяют в одной точке стека, а число повторов ограничивают. В практике Google на один запрос приходится не больше трёх попыток, а повторы составляют не больше 10 % всех запросов клиента [23]: при полном отказе зависимости нагрузка на неё растёт не почти втрое, а лишь в 1,1 раза.

Пауза между попытками растёт экспоненциально и выбирается случайно в пределах от нуля до текущей границы: иначе клиенты, одновременно получившие отказ, одновременно и повторят запросы, создав новый пик. Повторяют только временные ошибки — тайм-аут, обрыв соединения, коды 429, 502, 503 и 504 с учётом заголовка Retry-After — и только идемпотентные операции, поскольку тайм-аут не означает, что действие не выполнено. Коды состояния HTTP и повтор запросов клиентом requests рассмотрены в главе «Веб-технологии»; здесь запишем повтор с бюджетом в общем виде:

import random
import time


class RetryBudget:
    """Бюджет повторов клиента: повторов не больше доли ratio от запросов."""

    def __init__(self, ratio=0.1, reserve=10):
        self.ratio, self.reserve = ratio, reserve
        self.tokens = reserve                 # запас на редкие сбои

    def request(self):                        # каждый запрос пополняет счёт
        self.tokens = min(self.reserve, self.tokens + self.ratio)

    def allow_retry(self):                    # каждый повтор тратит единицу
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False


def call_with_retries(call, budget=None, attempts=3, base=0.05, cap=1.0):
    """Не больше attempts попыток; пауза случайна в пределах, растущих вдвое."""
    if budget:
        budget.request()
    for attempt in range(attempts):
        try:
            return call()
        except (TimeoutError, ConnectionError):   # только временные ошибки
            last = attempt == attempts - 1
            if last or (budget and not budget.allow_retry()):
                raise
            time.sleep(random.uniform(0, min(cap, base * 2 ** attempt)))

Бюджет представляет собой ведро жетонов: каждый запрос добавляет в него 0,1 жетона, каждый повтор расходует один, а запас в десять жетонов покрывает редкие сбои. Проверим бюджет на зависимости, отказавшей полностью (листинг выше сохранён в файле retry.py):

from retry import RetryBudget, call_with_retries


def outage(budget, n=1000):
    """n запросов к зависимости, отказавшей полностью; число её вызовов."""
    calls = 0

    def broken():
        nonlocal calls
        calls += 1
        raise ConnectionError('зависимость недоступна')

    for _ in range(n):
        try:
            call_with_retries(broken, budget, base=0)
        except ConnectionError:
            pass
    return calls


print('без бюджета:', outage(None))
print('с бюджетом 10 %:', outage(RetryBudget()))
без бюджета: 3000
с бюджетом 10 %: 1109

Без бюджета каждый из 1000 запросов делает три попытки, и зависимость получает 3000 вызовов. С бюджетом повторов 109 — десять из запаса и по одному на каждые десять запросов, — и нагрузка растёт в 1,11 раза, что согласуется с оценкой из практики Google. Бюджет, таким образом, позволяет пережить редкие сбои и не умножает нагрузку при полном отказе. Нулевая пауза (base=0) выбрана только для того, чтобы опыт не длился минутами.

В демонстрации к лекции скрипт backoff.py печатает паузы трёх клиентов перед шестью повторами: граница растёт вдвое от 0,1 до 1,6 с и ограничена 2 с, а пауза выбирается случайно в её пределах, поэтому перед пятым повтором клиенты ждут 0,79, 1,34 и 1,00 с. Скрипт chain.py поднимает цепочку из трёх серверов на Python, названных nginx, gateway и articles, причём articles на каждый запрос отвечает 503. При двух попытках на каждом уровне до articles доходит 8 запросов, при трёх — 27, а если повторяет только gateway — 2.

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

Операция идемпотентна, если повторное выполнение оставляет систему в том же состоянии, что и однократное; ответ на повтор может отличаться: повторный DELETE получает 404. По стандарту HTTP (RFC 9110) идемпотентны методы чтения GET и HEAD, замена ресурса целиком PUT и удаление DELETE, поэтому их можно повторять после обрыва.

ОперацияПовтор безопасен
GET, HEAD — чтениеда
PUT — замена ресурса целикомда
DELETE — удалениеда
POST — создание записи, списаниенет, без ключа операции
«выставить ток 10 А»да
«увеличить ток на 1 А»нет

POST, создающий запись или выполняющий списание, при повторе создаст вторую запись. Чтобы сделать его идемпотентным, достаточно ключа операции: клиент создаёт уникальный ключ один раз на логическую операцию, а не на попытку, и передаёт его во всех попытках, например в заголовке Idempotency-Key, принятом в платёжных API (черновик стандарта IETF с этим заголовком истёк, и стандартом он не стал). Сервер, увидев известный ключ, возвращает сохранённый ответ, не повторяя действия; ключ обеспечивает однократность действия при любом числе попыток. Рассмотрим сервис платежей, у которого каждый третий ответ задержан на секунду, — так моделируется ответ, не дошедший до клиента вовремя:

import asyncio
from typing import Annotated

from fastapi import FastAPI, Header

app = FastAPI()
payments = []                       # выполненные списания
responses = {}                      # ключ операции -> сохранённый ответ
attempts = 0                        # число принятых запросов POST


@app.post('/pay')
async def pay(payment: dict,
              idempotency_key: Annotated[str | None, Header()] = None):
    global attempts
    attempts += 1
    if idempotency_key in responses:
        result = responses[idempotency_key]   # повтор: списания нет
    else:
        payments.append(payment['amount'])
        result = {'payment': len(payments)}
        if idempotency_key:
            responses[idempotency_key] = result
    if attempts % 3 == 0:                     # каждый третий ответ задержан
        await asyncio.sleep(1.0)
    return result


@app.get('/payments')
async def report():
    return {'attempts': attempts, 'count': len(payments),
            'sum': sum(payments)}

FastAPI сопоставляет параметр idempotency_key заголовку Idempotency-Key: подчёркивание заменяется дефисом, а регистр в именах заголовков не различается. Клиент ждёт ответа 0,3 с, повторяет запрос при ошибке транспорта — тайм-ауте или обрыве соединения — со случайной паузой и создаёт ключ один раз на платёж:

import random
import sys
import time
import uuid

import httpx

URL = 'http://127.0.0.1:8010'


def pay(client, payment, use_key=True, attempts=4):
    key = str(uuid.uuid4())                       # один ключ на операцию
    headers = {'Idempotency-Key': key} if use_key else {}
    for attempt in range(attempts):
        try:
            return client.post(f'{URL}/pay', json=payment, headers=headers)
        except httpx.TransportError:              # тайм-аут, обрыв соединения
            time.sleep(random.uniform(0, 0.1 * 2 ** attempt))
    raise RuntimeError('попытки исчерпаны')


use_key = '--no-key' not in sys.argv
with httpx.Client(timeout=0.3) as client:
    for _ in range(10):
        pay(client, {'amount': 100}, use_key)
    time.sleep(1.1)                               # дождаться задержанных ответов
    report = client.get(f'{URL}/payments').json()
print('с ключом: ' if use_key else 'без ключа:', report)

Сервис из первого листинга сохранён в файле pay_api.py, клиент — в pay_client.py. Сервис запускался командой uvicorn pay_api:app --port 8010 заново перед каждым из двух прогонов клиента, python3 pay_client.py --no-key и python3 pay_client.py:

без ключа: {'attempts': 14, 'count': 14, 'sum': 1400}
с ключом:  {'attempts': 14, 'count': 10, 'sum': 1000}

Десять платежей по 100 потребовали 14 попыток. Без ключа сервис записал 14 платежей на сумму 1400: каждый повтор после задержанного ответа списал деньги ещё раз. С ключом при тех же 14 попытках записано 10 платежей на 1000: повтор с известным ключом получил сохранённый ответ. Та же картина получена в демонстрации к лекции со скриптом pay.py. Ответы 4xx и 5xx httpx исключениями не считает, поэтому функция pay повторяет только ошибки транспорта; повтор по кодам 503 и 429 добавляется проверкой status_code.

Словарь responses в листинге — состояние в памяти процесса, и со вторым экземпляром сервиса защита перестала бы работать: повтор, попавший на соседний экземпляр, списал бы деньги снова. В рабочей системе ключ и ответ хранятся в общей базе и записываются в той же транзакции, что и само списание, а уникальность ключа обеспечивает ограничение UNIQUE; тогда и два одновременных повтора не выполнят действие дважды. Ключи хранят ограниченное время, например сутки, за которые повторы заведомо закончатся.

Тот же принцип применим к управлению установкой. Команда «выставить ток квадруполя 10 А» идемпотентна и может безопасно повторяться до подтверждения, а «увеличить ток на 1 А» при потерянном ответе и повторе изменит ток на 2 А. Поэтому программы управления повторяют уставку с проверкой прочитанного значения. В библиотеке SCAUT, рассмотренной в главе «От скрипта к приложению», функция установки мотора записывает уставку, выжидает заданную паузу и читает значение обратно, повторяя запись, пока прочитанное значение не попадёт в допуск, но не больше заданного числа попыток.

Автомат защиты и деградация

Когда вызываемый сервис отказал, повторы и даже обычные запросы мешают ему восстановиться и занимают ресурсы вызывающего ожиданием тайм-аутов. Автомат защиты (circuit breaker) представляет собой шаблон из книги М. Найгарда «Release It!» [25]: он ставится на стороне вызывающего и имеет три состояния. В закрытом состоянии запросы проходят, а неудачи считаются; после нескольких неудач подряд автомат открывается и в течение заданной паузы сразу возвращает ошибку или заглушку, не обращаясь к сервису. По истечении паузы автомат переходит в полуоткрытое состояние и пропускает один пробный настоящий запрос: при успехе закрывается, при неудаче снова открывается.

class Breaker:
    def __init__(self, threshold=3, open_for=1.0):
        self.threshold, self.open_for = threshold, open_for
        self.state, self.failures, self.opened_at = "closed", 0, 0.0

    def call(self, now, func):
        if self.state == "open":
            if now - self.opened_at < self.open_for:
                return "заглушка"                   # быстрый отказ
            self.state = "half-open"                # пробный запрос
        if func(now):
            self.state, self.failures = "closed", 0
            return "ответ"
        self.failures += 1
        if self.state == "half-open" or self.failures >= self.threshold:
            self.state, self.opened_at = "open", now
        return "ошибка"

Время now передаётся явно, что позволяет проверить автомат в модельном времени. Скрипт демонстрации breaker.py с этим классом подаёт на gateway 10 запросов в секунду, а сервис articles недоступен первые 2,5 с; печатаются смены результата и состояния:

0.0 с  ошибка -> closed
0.2 с  ошибка -> open
0.3 с  заглушка -> open
1.2 с  пробный запрос: ошибка -> open
1.3 с  заглушка -> open
2.2 с  пробный запрос: ошибка -> open
2.3 с  заглушка -> open
3.2 с  пробный запрос: ответ -> closed
запросов к gateway 40, до articles дошло 13 (без автомата было бы 40)

После третьей неудачи подряд, в 0,2 с, автомат открывается, и до 1,2 с запросы получают заглушку. Пробные запросы в 1,2 и 2,2 с неудачны, а пробный запрос в 3,2 с успешен, и автомат закрывается. Из 40 запросов до articles дошло 13.

Применение автомата имеет цену. По опыту Amazon он вносит в систему режимы, которые трудно проверить заранее, и может задержать восстановление, поэтому там предпочитают ограничивать повторы локально, ведром жетонов, — именно так устроен бюджет повторов в листинге выше; в AWS SDK такое ограничение действует с 2016 года.

Вместе с автоматом применяют деградацию функциональности: вместо ошибки 504 пользователь получает страницу без отказавшего блока, последнюю сохранённую версию данных или ответ 503 с заголовком Retry-After. Для архива измерений это означает, что при недоступности сервиса построения графиков страница запуска показывает таблицу и последний сохранённый график, а не ошибку.

Данные

Слайды к главе. Репликация и отставание реплик, шардирование, CAP и PACELC, кеширование и инвалидация, очереди и обратное давление изложены также в четвёртой части лекции «Архитектура и системный дизайн» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

Сервис без состояния масштабируется добавлением экземпляров. Базы, кеши и очереди хранят состояние, и с ними так поступить нельзя: копии должны содержать одни и те же данные, а их согласование имеет цену. Подробно эти вопросы рассмотрены у Клеппмана [16].

Репликация

Репликация решает задачу так: запись принимает одна главная база, а изменения передаются репликам, которые обслуживают чтение; в PostgreSQL изменения передаются по журналу упреждающей записи (WAL). Устройство журнала, физическая и логическая репликация и восстановление на момент времени рассмотрены в разделе «Эксплуатация» главы «Работа с базами данных». Реплики позволяют масштабировать чтение: если данные читают в сотни раз чаще, чем пишут, добавление реплик снимает нагрузку с главной базы. Для метаданных архива это означает запись в главную базу, а поиск и графики — с реплик.

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

Шардирование и секционирование

Когда объём данных или поток записи превышает возможности одной машины, данные делят на части — шарды, каждая из которых хранится на своей машине со своими репликами. Шард определяется ключом, и выбор ключа является главным решением.

КлючДостоинствоНедостаток
времяинтервал читается с одного шардався текущая запись — в последний шард
хеш номера запусканагрузка равномернаинтервал времени — со всех шардов
канал, затем времяканал за интервал — на одном шардепопулярный канал перегружает свой шард

Деление по времени удобно для архива измерений: выборка за интервал читается с одного шарда, но вся текущая запись приходится на последний. Хеш номера запуска распределяет нагрузку равномерно, но выборка за интервал времени требует обращения ко всем шардам. Составной ключ «канал, время» размещает данные одного канала вместе, но популярный канал перегружает свой шард. При добавлении шардов данные перераспределяются, и консистентное хеширование ограничивает перенос долей \(1/(N+1)\). Запросы и транзакции, затрагивающие несколько шардов, дороже или вовсе недоступны, а смена ключа означает перенос всех данных, поэтому ключ выбирают заранее, по главным запросам.

К шардированию приходят не сразу. Секционирование таблицы внутри одной базы — PARTITION BY RANGE в PostgreSQL или расширение TimescaleDB для временных рядов — решает многие задачи архива без распределённой системы: оно позволяет запросу за интервал читать только нужные секции, а устаревшие секции удалять целиком. Секционирование временного ряда по месяцам с планом запроса показано в том же разделе «Эксплуатация» главы «Работа с базами данных».

CAP, PACELC и кворумы

Хранилище с несколькими копиями при разрыве сети неизбежно выбирает между согласованностью и доступностью. Теорема CAP, сформулированная Э. Брюером и доказанная С. Гилбертом и Н. Линч, утверждает: при разрыве сети (P), разделяющем копии, система либо отказывает части запросов, сохраняя согласованность (C), либо отвечает на все, рискуя выдать устаревшие данные (A). Разрыв сети является не выбором, а условием эксплуатации, поэтому выбирать приходится между C и A. Расширение PACELC, предложенное Д. Абади, добавляет обычный режим: и без разрыва (E) приходится выбирать между задержкой (L) и согласованностью (C), поскольку синхронное подтверждение от нескольких копий увеличивает задержку, а асинхронное допускает расхождение копий.

Кворумы позволяют настраивать этот выбор. Пусть данные хранятся в N копиях, запись ждёт подтверждения W копий, а чтение опрашивает R копий и берёт новейшее значение. Если \(R + W > N\), множества копий, участвовавших в записи и в чтении, пересекаются хотя бы в одной копии, и чтение видит последнюю подтверждённую запись. При N = 3 и W = R = 2 любые две копии из трёх имеют общую, и система переживает отказ одной копии и при записи, и при чтении. Так устроены Cassandra и подобные системы с уровнями согласованности ONE, QUORUM и ALL.

Таким образом, для каждой части данных архива решают, допустимо ли устаревание и насколько. График канала может отставать на секунду, а номер нового запуска должен быть единственным, поэтому его выдаёт одна главная база.

Кеширование

Кеш хранит результаты дорогих операций ближе к потребителю, и уровней у него несколько: браузер и CDN, кеш nginx, память процесса приложения, общий кеш наподобие Redis. Распространённая схема — cache-aside: приложение сначала ищет значение в кеше, при промахе читает базу и помещает результат в кеш со сроком жизни, а при записи обновляет базу и удаляет ключ из кеша. Так устроены кеш страниц в Redis в разделе «Одна задача в четырёх хранилищах» главы «Работа с базами данных» и сервисный слой в разборе «Асинхронное API для кинотеатра».

Удаление ключа при записи называют инвалидацией, и оно является самым трудным местом схемы: сброс может не дойти, а чтение, начавшееся до записи, может поместить в кеш устаревшее значение уже после сброса. Срок жизни ограничивает время, в течение которого такая ошибка видна. Вторая опасность — лавина промахов: когда истекает срок жизни популярного ключа, все экземпляры одновременно идут за ним в базу. Защитой служит один загрузчик, пока остальные ждут его результата, или выдача устаревшего значения на время обновления; в nginx это директивы proxy_cache_lock и proxy_cache_use_stale. Третья опасность — холодный старт: после перезапуска кеша вся нагрузка приходится на базу, и база должна её выдерживать, иначе кеш прогревают постепенно. Локальный кеш процесса быстрее всех, но у каждого экземпляра он свой и теряется при перезапуске.

Ниже приведён кеш со сроком жизни и одним загрузчиком из демонстрации к лекции:

import threading
import time


class Cache:
    """Кеш со сроком жизни; single_flight — один загрузчик при промахе."""

    def __init__(self, ttl, single_flight=False):
        self.ttl, self.single_flight = ttl, single_flight
        self.data, self.lock = {}, threading.Lock()

    def get(self, key, loader):
        item = self.data.get(key)
        if item and item[1] > time.monotonic():
            return item[0]                          # попадание
        if not self.single_flight:
            value = loader(key)                     # каждый промах — в базу
        else:
            with self.lock:                         # остальные ждут первого
                item = self.data.get(key)
                if item and item[1] > time.monotonic():
                    return item[0]
                value = loader(key)
        self.data[key] = (value, time.monotonic() + self.ttl)
        return value

    def invalidate(self, key):
        self.data.pop(key, None)

При промахе без защиты каждый поток идёт в базу сам; в режиме одного загрузчика потоки ждут замок и после него проверяют кеш повторно, так что в базу идёт только первый. Замок здесь один на весь кеш; в рабочем коде целесообразно заводить замок на каждый ключ, чтобы промахи по разным ключам не ждали друг друга. В демонстрации «база» представлена словарём с током квадруполя Q1, чтение которого занимает 200 мс. Функция с декоратором functools.lru_cache первое чтение выполняет за 200,1 мс, второе — за 0,0 мс. Затем в базу записывается 12,0 А: кеш без сброса продолжает отдавать 10,0 А, а после удаления ключа — 12,0 А. Наконец, срок жизни ключа истекает, и 50 потоков одновременно запрашивают его: без защиты в базу идут все 50, с одним загрузчиком — один, а время в обоих случаях равно 0,21 с.

Очереди

Очередь между частями системы развязывает их во времени: источник отдаёт работу и продолжает свою, обработчики разбирают её в своём темпе, а кратковременный пик нагрузки сглаживается. Между процессами и машинами очередь реализует брокер сообщений — Kafka, RabbitMQ, Redis Streams. Важнейшим параметром очереди является предел длины. Неограниченная очередь не защищает от перегрузки, а откладывает её проявление: если поток стабильно выше ёмкости обработчиков, очередь и время ожидания растут без предела, пока не кончится память.

Полная ограниченная очередь оставляет два допустимых варианта. Обратное давление: источник ждёт места и сам замедляется до ёмкости обработчиков; такой режим обеспечивает сохранность всех данных, но источник должен уметь ждать. Сброс нагрузки: лишнее отклоняется сразу, например ответом 429 или 503, и источник сохраняет темп ценой потерь. В очереди asyncio оба варианта записываются несколькими строками; ниже — фрагмент источника из скрипта демонстрации queue_demo.py:

if shed:
    try:
        q.put_nowait(item)
    except asyncio.QueueFull:
        dropped += 1                    # сброс
else:
    await q.put(item)                   # ждёт места: давление

Скрипт моделирует систему сбора: источник выдаёт 100 измерений в секунду в течение 3 с, два обработчика тратят по 30 мс на измерение.

ОчередьВыдача, сОбработано через, сДлина по секундамНаибольшее ожидание, сСброшено
без предела3,04,836, 71, 1081,740
maxsize=10, давление4,64,810, 10, 100,200
maxsize=10, сброс3,03,210, 9, 100,1698

Оценим ожидаемое. Ёмкость обработчиков равна \(2 / 0{,}03 \approx 67\) измерениям в секунду, поэтому без предела очередь растёт примерно на 33 измерения в секунду и к концу выдачи достигает около ста; в опыте её длина по секундам равна 36, 71 и 108, а последнее измерение обработано через 4,8 с. С ожиданием выдача растягивается до 4,6 с, очередь всё время полна, но ожидание не превышает 0,20 с и ничего не потеряно. Со сбросом источник сохраняет темп, 3,0 с, и короткое ожидание, 0,16 с, ценой 98 сброшенных измерений из 300, что близко к оценке \(300 - 3 \cdot 67 - 10 \approx 90\): за 3 с обработчики принимают около двухсот измерений, а ещё десять помещаются в очередь.

Брокеры обычно гарантируют доставку «хотя бы один раз»: после сбоя сообщение может прийти повторно, поэтому обработчик делают идемпотентным. Для системы сбора данных измерения сбрасывать нельзя — их необходимо хранить в буфере на диске и передавать давление источнику, — а запросы просмотра графиков при перегрузке можно отклонить.

Сопровождение

Слайды к главе. Журналы, метрики и трассировки, монолит и микросервисы, сетка сервисов и защита от перегрузки изложены также в пятой части лекции «Архитектура и системный дизайн» с демонстрацией в терминале; слайды лекции доступны на сайте книги и в PDF.

Работающую систему наблюдают, изменяют и защищают от перегрузки. Общие практики сопровождения — дежурства, разбор инцидентов, chaos engineering — перечислены в разделе «Сопровождение» главы «Жизненный цикл ПО»; здесь рассматривается то, что относится к системе из нескольких частей.

Журналы, метрики и трассировки

Наблюдаемость обеспечивают три вида сигналов.

СигналВопросИнструменты
журналычто случилось с этим запросом?logging, Loki, Elasticsearch
метрикисколько, как быстро, сколько ошибок?Prometheus, Grafana
трассировкигде запрос провёл время?OpenTelemetry, Jaeger

Журналы отвечают на вопрос, что случилось с конкретным запросом, и нужны для расследования. Метрики представляют собой числа во времени — запросы в секунду, доля ошибок, перцентили задержки, заполнение диска; по ним строят графики и оповещения. Трассировки показывают путь одного запроса через все сервисы и время на каждом участке. Связывает сигналы идентификатор запроса: nginx на входе вычисляет переменную $request_id и передаёт её приложению заголовком, и все записи о запросе в журналах всех сервисов содержат одно значение. Стандартный формат передачи контекста трассировки — заголовок traceparent из рекомендации W3C Trace Context, реализуемый библиотеками OpenTelemetry.

log_format rid '$request_id $status $request_time';
server {
    listen 127.0.0.1:8080;
    access_log /var/log/nginx/access.log rid;
    location / {
        proxy_set_header X-Request-ID $request_id;
        proxy_pass http://127.0.0.1:8001;
    }
}

Два запроса к этой конфигурации с экземпляром на порту 8001, возвращающим полученный заголовок, дают ответы

X-Request-ID: 4badfddfc135bb0bca59af9adff8b580
X-Request-ID: cad908e5677252cc248aec77bf34a97b

и строки журнала nginx с теми же идентификаторами:

4badfddfc135bb0bca59af9adff8b580 200 0.004
cad908e5677252cc248aec77bf34a97b 200 0.001

Формату журнала дано собственное имя. В образе nginx:alpine основной файл /etc/nginx/nginx.conf уже объявляет формат main и лишь затем подключает файлы каталога conf.d, поэтому та же конфигурация с именем main не проходит проверку nginx -t:

nginx: [emerg] duplicate "log_format" name "main" in /etc/nginx/conf.d/default.conf:1

Директива log_format только объявляет формат, а журнал пишется в нём, когда формат назван в директиве access_log.

Книга Google SRE рекомендует для каждого сервиса следить за четырьмя сигналами: задержкой, трафиком, ошибками и насыщением [23]. Оповещение должно требовать действия человека и срабатывать по симптому, а не по причине: по скорости расходования бюджета ошибок, а не по загрузке процессора. В качестве начальных порогов рекомендуется вызывать дежурного, если за час израсходовано 2 % бюджета на 30 дней или за шесть часов — 5 %, и заводить заявку, если за трое суток израсходовано 10 % [24]. Определим, что означают эти пороги. В 30 днях 720 ч, и при равномерном расходе за час тратится 1/720 бюджета; 2 % за час соответствуют скорости расходования \(0{,}02 \cdot 720 = 14{,}4\), при которой бюджет иссякнет за 50 ч. Для 5 % за шесть часов скорость равна \(0{,}05 \cdot 120 = 6\), для 10 % за трое суток — \(0{,}1 \cdot 10 = 1\): заявку заводят, когда бюджет расходуется ровно с той скоростью, при которой его хватит лишь до конца окна.

Типы метрик и экспортёры, хранение в Prometheus, трассировка OpenTelemetry, панели и правила оповещения по скорости расходования бюджета с их тестами рассмотрены в главе «Наблюдаемость».

Монолит и микросервисы

МонолитМикросервисы
выкаткацеликомпо сервису
масштабированиецеликомпо сервису
отказ частиобщий процессчастичный, через сеть
данныеодна транзакциясогласование между базами
командыоднанесколько независимых
цена стартанизкаясеть, наблюдаемость, выкатка

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

Распространённый совет — начинать с модульного монолита, в котором модули имеют чёткие интерфейсы, как слои SCAUT в главе «От скрипта к приложению» или слои сервиса в разборе «Асинхронное API для кинотеатра», и выделять сервис, когда граница модуля действительно мешает выкатке или масштабированию. Известен и обратный путь: в 2023 году команда Prime Video перенесла мониторинг качества видео из набора распределённых компонентов в одно приложение и сократила затраты на инфраструктуру более чем на 90 %. Группе из пяти человек с одной установкой достаточно монолита с модулями сбора, хранения и веб-интерфейса.

Сетка сервисов

Когда сервисов десятки, тайм-ауты, повторы, балансировку, шифрование и метрики неудобно реализовывать в каждом из них. Сетка сервисов (service mesh) выносит эти функции из кода в посредника, который запускается рядом с каждым экземпляром, — это уровень данных (data plane). Сервис обращается к соседу через локального посредника, а тот выбирает экземпляр, повторяет запрос, соблюдает тайм-ауты, шифрует соединение взаимным TLS и собирает метрики. Уровень управления (control plane) знает топологию системы и раздаёт посредникам адреса экземпляров, правила и сертификаты; его отказ не останавливает работающих посредников, они продолжают работу с последней полученной конфигурацией.

Первой реализацией был Linkerd в 2016 году, наиболее известная — Istio на основе посредника Envoy, с 2020 года управляемая одним процессом istiod. Цена — два посредника на пути каждого вызова и потребляемые ими ресурсы: по данным проекта Istio для версии 1.24, около 0,2 vCPU и 60 МБ памяти на тысячу запросов в секунду у каждого посредника; режим ambient заменяет посредника у каждого экземпляра общим посредником на узле. Для трёх-пяти сервисов сетка избыточна: для тех же задач достаточно библиотеки клиента и nginx.

Ограничение частоты и сброс нагрузки

Перегрузка приходит не только от атаки: наплыв пользователей после публикации данных, собственный нагрузочный тест, запущенный не на том сервере, скрипт коллеги без паузы между запросами. Ограничение частоты отклоняет запросы сверх заданного темпа до дорогой обработки. В nginx его реализует модуль limit_req по алгоритму «дырявого ведра»: каждый клиент, определяемый ключом, например адресом, получает темп r запросов в секунду и запас b на залп, а с параметром nodelay ведро ведёт себя как ведро жетонов и пропускает залп сразу. По умолчанию nginx отвечает на лишние запросы кодом 503, который клиент примет за отказ сервера, поэтому код меняют на 429 Too Many Requests. Заголовок Retry-After, подсказывающий клиенту, когда повторить, limit_req сам не добавляет: его задаёт директива add_header, а для кода 429 — только с параметром always.

map $status $retry_after {       # заголовок только у отказов 429
    429     1;
    default "";
}
limit_req_zone $binary_remote_addr zone=perip:10m rate=5r/s;
upstream app {
    server 127.0.0.1:8001;
    server 127.0.0.1:8002;
    server 127.0.0.1:8003;
}
server {
    listen 127.0.0.1:8080;
    location / {
        limit_req zone=perip burst=10 nodelay;   # запас на залп — 10
        limit_req_status 429;                    # по умолчанию — 503
        add_header Retry-After $retry_after always;
        proxy_pass http://app;
    }
}

Блок map вычисляет значение заголовка по коду ответа: для 429 — одна секунда, для остальных кодов — пустая строка, а заголовок с пустым значением nginx не добавляет. Из залпа в 12 одновременных запросов к этой конфигурации 11 получили ответ 200 без заголовка, а последний — такой ответ:

HTTP/1.1 429 Too Many Requests
Server: nginx/1.31.6
Date: Sat, 03 Oct 2026 03:50:28 GMT
Content-Type: text/html
Content-Length: 169
Connection: keep-alive
Retry-After: 1

Без параметра always заголовок получили только ответы 200, а ответ 429 остался без него; строка add_header Retry-After 1 always; без блока map добавляет заголовок ко всем ответам, включая успешные.

В демонстрации к лекции та же конфигурация без заголовка ограничивает каждый адрес пятью запросами в секунду с запасом в десять. Из залпа в 30 одновременных запросов 11 получают ответ 200 — один в счёт темпа и десять в счёт запаса, — а 19 получают 429. Поток 20 запросов в секунду в течение 4 с пропускает 15 запросов в первую секунду и ровно по 5 в каждую следующую. Журнал ошибок nginx содержит 69 записей limiting requests — по одной на каждый отклонённый запрос, 19 из залпа и 50 из потока.

Сброс нагрузки применяет тот же принцип внутри сервиса: перегруженный сервис отклоняет часть запросов сразу и вовремя отвечает остальным, а не отвечает всем с задержкой в секунды. От объёмных атак на канал ограничение частоты в nginx не защищает: их отсекают до сервиса — у провайдера, в CDN, распределением адреса через anycast. Межсетевые экраны прикладного уровня отклоняют запросы с признаками атак, а капча отделяет автоматические запросы от людей.

Сопровождаемость

Сопровождаемость — свойство системы, позволяющее изменять её и управлять ею без чрезвычайных усилий. Первое правило — простота: каждый компонент представляет собой ещё одну часть, которая может отказать и которую необходимо обновлять, поэтому кеш, очередь или второй тип базы добавляют, когда без них измеримо хуже, а не на будущее. Второе — знакомые технологии: новую базу или брокер сначала проверяют под нагрузкой на тестовом контуре, а не вводят в работу по одной прочитанной статье. Третье — управляемая выкатка: конвейер CI/CD, постепенная выкатка с оповещениями о её ходе и откат одной командой, рассмотренные в главе «Жизненный цикл ПО». Четвёртое — изоляция контуров: тестовый контур не пишет в рабочую базу, а разные группы пользователей или установки обслуживаются разными экземплярами, чтобы ошибка или наплыв в одном контуре не затрагивали другой.

Зеркалирование рабочих запросов в тестовый контур позволяет проверить новую версию на настоящей нагрузке, но опасно для запросов с побочным действием — писем, списаний, команд оборудованию. Новую версию программы сбора данных поэтому сначала проверяют на стенде с моделью установки, а затем выпускают в смену с готовым откатом.

Проектирование системы сбора данных

Слайды к главе. Проект системы сбора данных установки — требования, оценки, схема, отказы и реакции — изложен также в шестой части лекции «Архитектура и системный дизайн»; слайды лекции доступны на сайте книги и в PDF.

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

Требования и оценки

Функциональные требования: принимать данные с оцифровщиков, хранить их в архиве, выдавать выборку канала за интервал времени и строить графики. Нефункциональные требования записываются числами. Поток: 200 каналов по 10 кГц и 2 байта на отсчёт, непрерывно. Сохранность: ни одно измерение не теряется при отказе одной машины; это требование важнее скорости чтения. Чтение: двадцать сотрудников и их скрипты, до 50 запросов/с в пике, p99 выборки канала меньше секунды, доступность чтения 99,9 % за 30 дней. Стоимость: три машины и диски на год записи.

rate = 200 * 10_000 * 2           # байт/с: каналы × частота × байт на отсчёт
day = rate * 86_400 / 1e12        # ТБ в сутки
year = day * 365                  # ТБ в год
samples = 200 * 10_000 * 86_400   # отсчётов в сутки
print(rate / 1e6, round(day, 2), round(year), f'{samples:.3g}')
4.0 0.35 126 1.73e+11

Оценим следствия. Поток 4 МБ/с даёт около 0,35 ТБ в сутки и 126 ТБ в год, а с двумя копиями архива — 252 ТБ; отсюда требования к дискам и вопрос о сжатии и прореживании старых данных, который тоже относится к требованиям. В сетевых единицах 4 МБ/с составляют 32 Мбит/с, около 3 % пропускной способности сети 1 Гбит/с, так что сеть между машинами даже с учётом копий узким местом не станет. Хранение каждого отсчёта отдельной строкой реляционной таблицы означало бы почти 173 млрд строк в сутки; поэтому отсчёты хранят блоками в файлах, а в базе — только метаданные. Приближённая оценка до выбора технологий отсеивает неподходящие решения.

Схема

Схема разделяет путь записи и путь чтения, потому что требования к ним разные:

Путь записи

Оцифровщики ─4 МБ/с─▶ Приёмники ×2 ─────▶ Очередь ──────────▶ Писатели ×2 ──────▶ Архив
200 каналов           буфер на диске      брокер, 3 узла      файлы по часам      HDF5, 2 копии
                                                                │
                                                                └──▶ Метаданные
                                                                     PostgreSQL и реплика

Путь чтения

Сотрудники ────50 запросов/с────▶ nginx ──────────▶ API ×2 ────▶ Метаданные и архив
скрипты, браузер                  лимит и           FastAPI
                                  балансировка        │
                                                      └──▶ Кеш графиков, Redis

Рассмотрим путь записи. Оцифровщики отправляют данные двум приёмникам. Каждый приёмник сначала пишет поток в буфер на локальном диске, который позволяет пережить кратковременный отказ следующих звеньев, а затем передаёт его в очередь — брокер сообщений на трёх узлах, хранящий сообщения с репликацией. Брокер переживает отказ одного узла: кворум из двух оставшихся продолжает работу. Именно брокер определяет число машин: на двух машинах одна несла бы два узла из трёх, и с её отказом пропал бы кворум, а трёх машин с одним узлом на каждой достаточно. Писатели забирают данные из очереди, складывают их в файлы HDF5 по часам и каналам в архив с двумя копиями и регистрируют каждый файл в метаданных. Запись идемпотентна — например, каждый блок измерений несёт номер, и писатель пропускает блок, уже записанный в архив, — поэтому повторная доставка сообщения не создаёт дубликатов. Метаданные — запуски, каналы, интервалы и пути к файлам — хранятся в PostgreSQL с репликой.

Путь чтения начинается с сотрудников и их скриптов, которые обращаются к nginx. Nginx ограничивает частоту запросов и распределяет их между двумя экземплярами API на FastAPI без состояния. API находит файлы по метаданным, читает нужные интервалы из архива и помещает построенные графики в кеш Redis. Метрики и журналы всех компонентов собираются отдельно и на схеме не показаны.

Отказы и реакции

Последний шаг проектирования — пройти по схеме и для каждого компонента ответить на вопрос, что произойдёт при его отказе и какое требование окажется под угрозой.

ОтказРеакция системыТребование под угрозой
приёмникприём вторым приёмником, буфер в оцифровщикесохранность
узел очередиработа кворума из двух оставшихся узловсохранность
писательожидание данных в очереди, второй писательсохранность
заполнение дискаоповещение за 7 суток по прогнозусохранность
главная базаповышение реплики, пауза регистрации файловдоступность
экземпляр APIисключение в nginx, повтор GET на соседедоступность
наплыв запросовответ 429 от limit_req, графики из кешазадержка

Отказ приёмника не теряет данных: второй приёмник продолжает приём, а оцифровщик хранит данные в буфере на время переключения. Отказ писателя задерживает появление данных в архиве, но не теряет их: они ждут в очереди, пока их не заберёт второй писатель. Заполнение диска является медленным отказом, о котором оповещают заранее, по прогнозу заполнения. Отказ главной базы метаданных приостанавливает регистрацию новых файлов до повышения реплики, а чтение продолжается. Отказ экземпляра API скрывает nginx, повторяя запросы GET на соседнем экземпляре, а наплыв запросов чтения ограничивает limit_req, пока графики отдаются из кеша. Каждая реакция проверяется опытом — нагрузочным тестом, остановкой экземпляра, переполнением очереди, — так же, как в демонстрациях к лекции.

Наблюдаемость и резервная копия

Наблюдаемость замыкает проект. Индикаторами уровня обслуживания служат задержка поступления измерений в архив, длина очереди, доля успешных и доля быстрых чтений, а оповещения срабатывают по расходу бюджета ошибок и по прогнозу заполнения дисков.

Две копии архива защищают от отказа диска и машины, но не от ошибочного удаления или порчи файлов программой: вторая копия повторит ошибку. Поэтому архиву необходима резервная копия на другой площадке с регулярной проверкой восстановления, а требования к ней записывают числами — допустимой потерей данных (RPO, recovery point objective) и временем восстановления (RTO, recovery time objective). Оценим время восстановления годового архива: 126 ТБ по сети 1 Гбит/с передаются за \(126 \cdot 10^{12} \cdot 8 / 10^{9} \approx 1{,}0 \cdot 10^{6}\) с, то есть почти за 12 суток. Если допустимое время восстановления меньше, восстановление планируют по частям — сначала метаданные и данные последних запусков — или закладывают более быстрый канал. Резервные копии базы метаданных и восстановление PostgreSQL на момент времени рассмотрены в разделе «Эксплуатация» главы «Работа с базами данных».

Пройденный порядок универсален: требования, оценки, схема, отказы и наблюдаемость.

Упражнения

  1. Записать SLO 99,5 % для собственного сервиса и вычислить допустимый простой и число неуспешных запросов за 30 дней.
  2. Нагрузить свой сервис генератором с расписанием, найти его ёмкость и выяснить, как растёт задержка за её пределом.
  3. Поставить три экземпляра сервиса за nginx, сравнить round robin с весами и least_conn и остановить один экземпляр сигналом STOP.
  4. Спроектировать архив данных своей установки: оценить поток и объём, нарисовать схему и составить таблицу отказов.

В итоговом проекте курса по той же последовательности удобно описать предполагаемый путь решения в документе первого этапа: требования с числами, оценки нагрузки и объёма данных и схему.

Резюме

Таким образом, проектирование системы начинается с требований, записанных числами: нагрузки в запросах в секунду, перцентиля задержки, цели уровня обслуживания и бюджета ошибок. Масштабирование горизонтально, если сервис не хранит состояние в себе, а алгоритм балансировки выбирают по характеру запросов: round robin — для одинаковых, least connections — для разной длительности, хеш — для привязки. Отказы переживают резервирование с кворумом, проверки работоспособности, согласованные тайм-ауты или передача дедлайна и повторы с бюджетом и случайной паузой; повторяют только идемпотентные операции, а автомат защиты и заглушка дают отказавшей зависимости восстановиться. Реплики, шарды, кеш и очередь решают задачи данных, и каждое решение имеет свою цену: отставание реплики, перенос данных при смене ключа, устаревание кеша, выбор между давлением и сбросом в очереди.

Компонент добавляется тогда, когда расчёт или замер показывает, что без него хуже. Лабораторному сервису обычно достаточно одной программы и одной базы; системный дизайн позволяет заметить момент, когда этого перестаёт хватать, и выбрать следующее решение по числам.

Полезные ссылки