Карточка товара собирает данные из нескольких источников:

  • описание;

  • итоговую цену;

  • остаток;

  • условия покупки;

  • отзывы;

  • рекомендации.

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

В эксперименте ниже около 99% одиночных вызовов вместе с обработкой агрегатора занимают не больше 200 мс.

При ожидании 20 независимых вызовов за тот же срок выходит уже около 19% запросов к карточке. Ни одна зависимость при этом не стала медленнее: изменилась процедура сборки ответа.


Я Иннокентий Бодров, Product Leader с опытом более 18 лет в технологиях.

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

Отдельно проверю предположение о независимости задержек: без него простая формула может дать неверный прогноз.

Сценарий и распределение задержек моделируемые. Код считает виртуальное время, не отправляет HTTP-запросы и не измеряет производительность сервиса. Опубликованные результаты Google приведены отдельно и имеют собственную атрибуцию.

Последний ответ определяет время карточки

В микросервисной архитектуре fan-out возникает, когда один входящий запрос порождает несколько исходящих. Это может быть обращение к разным сервисам, нескольким репликам или шардам одного хранилища. Число вызовов и число сервисов не обязаны совпадать.

Для начала примем простую схему: все обращения стартуют одновременно, независимы по данным и не мешают друг другу ресурсами.

Агрегатор ждёт каждое, затем тратит 10 мс на сборку ответа.

Время карточки:

T = 10 мс + max(L1, L2, ..., Ln)

Здесь Li — время одного исходящего вызова со стороны агрегатора. В рабочей трассе в него могут входить соединение, сеть, обработка и ожидание ответа.

Если обращения стартуют в разное время, сравнивать нужно моменты их завершения: start_i + Li.

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

Для иллюстрации возьмём пять ответов: 22, 29, 34, 41 и 280 мс.

Параллельная сборка займёт 290 мс.

Четыре быстрых ответа не компенсируют пятый. Последовательная сборка тех же данных заняла бы 416 мс, так что параллельность всё ещё полезна.

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

Формула для независимых вызовов

Пусть F(t) — вероятность, что один вызов завершится за время не больше t. Если все n задержек независимы и имеют одинаковое распределение, вероятность дождаться всех за это время равна F(t)^n:

P(T > 200 мс) = 1 - F(190 мс)^n

Из 200 мс мы вычли 10 мс сборки. Если у каждого вызова вероятность выйти за оставшиеся 190 мс составляет 1%, получим:

Число обязательных вызовов

Вероятность опоздания карточки

1

1,00%

5

4,90%

20

18,21%

100

63,40%

Это расчёт для заданной вероятности 1%, а не измерение нашего сервиса. Для разных независимых зависимостей вместо степени нужен продукт их вероятностей: F1(t) × F2(t) × ... × Fn(t).

Частая ошибка в обсуждении такого графика: «У каждого сервиса p99 равен 200 мс, значит, p99 карточки тоже будет около 200 мс». Перцентиль p99 означает порог, который не превышают как минимум 99% наблюдений. Агрегатору нужны все ответы одного запроса. У него другое распределение, и его p99 надо измерять отдельно.

Опубликованное измерение: Google The Tail at Scale

Джеффри Дин и Луис Андре Баррозу в The Tail at Scale, 2013 привели измерения сервиса Google с большим деревом вызовов. В таблице 1 полного текста p99 завершения одного случайного обращения к листовому серверу составляет 10 мс. Для завершения 95% обращений p99 равен 70 мс, для всех — 140 мс. Ожидание последних 5% обращений дало половину итоговой задержки на этом перцентиле.

Это данные конкретного сервиса, измеренные от корня дерева. Они подтверждают, что ожидание последних ответов может определять хвост. Значения 10, 70 и 140 мс нельзя переносить на нашу карточку товара: у неё другое число вызовов, распределение и правила полноты ответа.

Эксперимент: одна задержка и несколько политик ожидания

Зададим время каждого вызова логнормальным распределением: логарифм задержки имеет среднее ln(30) и стандартное отклонение 0,8. Медиана отдельного вызова равна 30 мс, но иногда он отвечает существенно дольше. Параметры выбраны для демонстрации и не подогнаны под продакшен.

В каждой группе будет 100 тысяч входящих запросов. Сравним 1, 5, 20 и 100 параллельных обращений. Для группы из 20 вызовов дополнительно посчитаем:

  • последовательное выполнение тех же задержек;

  • полную зависимость задержек: все 20 вызовов одного запроса получают одно общее значение;

  • четыре обязательных вызова и 16 необязательных, для которых ожидание ограничено 80 мс от старта.

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

Код работает на стандартной библиотеке Python. Random с фиксированным начальным значением делает запуск повторяемым в той же среде. Перцентили считаем методом ближайшего ранга: после сортировки берём элемент с номером ceil(q × N).

from math import ceil, exp, log
from random import Random
from statistics import NormalDist, mean

SAMPLES = 100_000
OVERHEAD_MS = 10
DEADLINE_MS = 200
MU, SIGMA = log(30), 0.8


def percentile(sorted_values, q):
    return sorted_values[ceil(q * len(sorted_values)) - 1]


def report(name, values):
    ordered = sorted(values)
    slow = sum(t > DEADLINE_MS for t in values) / len(values)
    print(f"{name:18s} mean={mean(values):6.1f} "
          f"p50={percentile(ordered, .50):6.1f} "
          f"p95={percentile(ordered, .95):6.1f} "
          f"p99={percentile(ordered, .99):6.1f} "
          f">200ms={slow:6.2%}")


for n in (1, 5, 20, 100):
    rng = Random(20261015 + n)
    parallel, sequential, common, partial = [], [], [], []
    full_optional = 0
    for _ in range(SAMPLES):
        calls = [rng.lognormvariate(MU, SIGMA) for _ in range(n)]
        parallel.append(OVERHEAD_MS + max(calls))
        if n == 20:
            sequential.append(OVERHEAD_MS + sum(calls))
            common.append(OVERHEAD_MS + calls[0])
            required = max(calls[:4])
            optional = max(calls[4:])
            partial.append(OVERHEAD_MS + max(required, min(optional, 80)))
            full_optional += optional <= 80
    report(f"parallel {n}", parallel)
    if n == 20:
        report("sequential 20", sequential)
        report("common-delay 20", common)
        report("4 required + 16 opt", partial)
        print(f"all optional by 80ms: {full_optional / SAMPLES:.2%}")
        assert all(p <= s for p, s in zip(parallel, sequential))
        assert all(d <= p for d, p in zip(partial, parallel))

cdf = NormalDist(MU, SIGMA)
p = 1 - cdf.cdf(log(DEADLINE_MS - OVERHEAD_MS))
for n in (1, 5, 20, 100):
    theoretical_p50 = OVERHEAD_MS + exp(cdf.inv_cdf(.5 ** (1 / n)))
    print(f"theory {n:3d}: >200ms={1 - (1 - p) ** n:.2%}, "
          f"p50={theoretical_p50:.1f}ms")

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

Результат: p50 карточки меняется вместе с шириной fan-out

Один запуск приведённого кода дал следующие значения. В таблице учтены 10 мс сборки ответа.

Схема

Среднее, мс

p50, мс

p95, мс

p99, мс

Доля дольше 200 мс

1 параллельный вызов

51,6

40,1

122,8

203,9

1,08%

5 параллельных вызовов

98,7

84,2

202,8

308,9

5,24%

20 параллельных вызовов

156,7

139,0

290,5

427,6

18,95%

100 параллельных вызовов

247,8

224,9

424,9

599,2

65,25%

20 последовательных вызовов

836,1

815,0

1151,9

1348,1

100,00%

Для 20 обращений параллельность сократила p50 с 815 до 139 мс относительно последовательной сборки тех же данных. Однако примерно каждый пятый запрос всё ещё выходит за 200 мс. Ускорение относительно старой реализации и соблюдение пользовательского срока нужно проверять по отдельности.

В нашем распределении вероятность задержки одного вызова больше 190 мс равна примерно 1,052%.

Формула даёт для 20 обращений 19,06% опозданий, для 100 — 65,27%. Симуляция дала 18,95% и 65,25% соответственно.

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

Рост p50 до 224,9 мс при 100 вызовах не требует общего сбоя. Достаточно чаще попадать хотя бы в один редкий медленный ответ. Для решения о релизе полезна прямая доля запросов, уложившихся в согласованный срок, вместе с p95 и p99 полного маршрута.

Зависимые задержки меняют расчёт

Теперь оставим распределение каждой ветки прежним, но зададим всем 20 вызовам внутри одного запроса одинаковую задержку.

В коде это common: вместо 20 независимых значений используем calls[0] для всех веток. Это предельный пример полной зависимости, а не модель любой общей базы данных.

В таком варианте p50 карточки равен 40,0 мс, p99 равен 203,6 мс, доля опозданий составляет 1,06%. Максимум 20 одинаковых значений совпадает с одним значением, поэтому эффект F(t)^20 исчезает.

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

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

Такие данные нельзя восстановить из отдельных p99. Для оценки нужны длительности и моменты старта вызовов в рамках одного входящего запроса.

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

Частичный ответ сокращает ожидание ценой полноты

На том же наборе из 20 вызовов политика «четыре обязательных плюс 16 необязательных до 80 мс» дала p50 90,0 мс, p95 188,9 мс, p99 292,0 мс. За 200 мс вышли 4,14% запросов вместо 18,95% при ожидании всех.

У этого улучшения есть цена: все 16 необязательных ответов успели за свои 80 мс лишь в 15,40% запросов. Значит, около 85% ответов хотя бы частично теряют необязательные данные.

Называть такой режим успешной оптимизацией только по задержке нельзя. Срок 80 мс выбран для эксперимента; подобрать его для продукта можно лишь вместе с допустимой долей неполных ответов и ценностью отсутствующих блоков.

Что страница может не дождаться

Какое поведение нужно согласовать

Отзывы

Показать отдельное состояние загрузки или недоступности, затем догрузить блок.

Рекомендации

Открыть карточку без них; измерять долю пропусков и влияние на переходы.

Итоговая цена или ограничение покупки

Не считать карточку готовой к покупке без корректных обязательных данных.

Часть предложений в сравнении продавцов

Явно определить полноту списка; пропуск может изменить выбор самого дешёвого варианта.

Частичный ответ меняет контракт продукта. HTTP 200 с пустыми отзывами должен отличаться от подтверждённого отсутствия отзывов.

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

Возможность отдавать сокращённый результат под нагрузкой описана и в Google SRE, Handling Overload.

Код моделирует момент, когда агрегатор прекращает ожидание. Он не моделирует освобождение ресурсов.

Если клиент закрыл ожидание, удалённый сервис ещё может продолжать работу. Нужно проверить, что используемый протокол передаёт отмену, а обработчик и начатые им операции её учитывают.

Тайм-аут ветки должен помещаться в оставшееся время запроса

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

Если карточке выделено 200 мс, до исходящих обращений уже прошло 35 мс, а на сборку и отправку зарезервировано ещё 15 мс, зависимостям остаётся не больше 150 мс. Тайм-аут 200 мс, который каждая ветка начинает заново, нарушает этот бюджет. В него также должны поместиться получение соединения из пула и другие этапы на критическом пути.

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

В документации gRPC о deadlines описана передача оставшегося времени во вложенные вызовы. Там же оговорена ответственность приложения за остановку работы, которую оно породило. Для конкретного клиента нужно проверить охват тайм-аута и поведение отмены, включая DNS, соединение и чтение тела ответа.

Если просто обрезать длительности по 200 мс, p99 на графике станет не выше 200. Это ещё не означает, что пользователи получили нужные данные. Поэтому рядом с длительностью считаем успешные полные ответы, допустимые частичные ответы, тайм-ауты и ошибки. Google SRE рекомендует различать задержки успешных и ошибочных запросов.

Повторы и дублирующие запросы требуют запаса ресурсов

Повтор после ошибки может помочь при временном сбое, но расходует оставшийся срок и добавляет нагрузку. Если на каждом из трёх уровней разрешены две попытки, один верхний запрос способен породить до восьми нижних попыток по этой цепочке. Fan-out дополнительно умножает число обращений на ширину ветвления. До настройки повторов нужно проверить политику всех слоёв, включая SDK.

Amazon Builders’ Library разбирает риск усиления перегрузки повторами и рекомендует ограничивать их число, выбирать место повторов и учитывать идемпотентность операции. Случайный разброс пауз, jitter, помогает распределить повторы во времени. Он не создаёт свободную мощность у перегруженной зависимости.

Hedged request запускает дополнительную копию ещё не завершившегося запроса к другой реплике и принимает первый подходящий ответ. В The Tail at Scale авторы описали тест чтения 1000 ключей с 100 серверов: дублирование после 10 мс сократило p99,9 с 1800 до 74 мс при 2% дополнительных запросов. Это результат конкретного теста, а не обещание такого же выигрыша для любого API.

Для карточки такая техника требует безопасного чтения, подходящей альтернативной реплики, лимита дополнительных попыток и проверки фактической нагрузки.

Копия запроса в ту же перегруженную очередь может усилить причину задержки. Для операции с побочным эффектом, например оформления заказа, прежде нужна явная гарантия безопасного повторного выполнения.

Что измерить и проверить перед релизом

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

Для сравнения двух версий пригодится такой отчёт:

Измерение

Какой вопрос закрывает

p50, p95, p99 и доля успешных ответов до срока

Что получает пользователь на полном маршруте?

Распределение числа веток и повторных попыток

Изменилась ли работа, которую порождает один запрос?

Доля и состав частичных ответов

Какой контент потеряли ради скорости?

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

Куда переместилась нагрузка?

Отменённые обращения и работа после отмены

Освобождает ли система ресурсы после истечения срока?

Сначала сравниваем одинаковые типы карточек, регионы и условия кеша. Затем повторяем проверку под ожидаемой нагрузкой и при замедлении одной зависимости.

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

График p99 по инстансам нельзя усреднять для получения p99 всего сервиса. Нужно объединять совместимые распределения или исходные наблюдения. Документация Prometheus отдельно объясняет, почему готовые квантили нельзя агрегировать как обычные средние. Даже правильно объединённые длительности исходящих вызовов не заменяют отдельное измерение времени карточки.

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

Откат также должен возвращать согласованное поведение. Если версия перестала ждать рекомендации, вернуть прежнюю политику ожидания можно флагом, но одновременно вернётся и её нагрузка. Поэтому в испытании нужно проверить обе политики и переключение между ними при текущем объёме трафика.

Выводы

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

  • Для независимых одинаково распределённых веток работает формула 1 - F(t)^n. В нашем эксперименте 20 вызовов дали около 19% ответов дольше 200 мс. Для зависимых задержек нужны совместные наблюдения, а не отдельные p99.

  • Ограниченное ожидание необязательных данных снизило долю опозданий до 4,14%, но около 85% ответов потеряли хотя бы часть этих данных. Скорость и полноту нужно принимать вместе.

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


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

Разобраться с этими задачами помогут бесплатные открытые уроки:

  • 2 ноября в 19:00. «Оптимизация Nginx и Angie под высокие нагрузки». Записаться

  • 5 ноября в 20:00. «Стенды для нагрузочного тестирования». Записаться

  • 9 ноября в 20:00. «Монолит или микросервисы? Руководство для архитекторов, которые ценят свои нервы». Записаться

Комментарии (0)