Files
hse-2026/materials/08-scaling/seminar/load-balancing-lab/Lab.ipynb
T
2026-11-04 19:23:39 +03:00

1.5 MiB

Балансировщики нагрузки: лабораторная работа

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

In [1]:
from IPython.display import Image
Image(filename='media/balancers.png') 
Out [1]:

Все балансировщики распределяют запросы по одному общему набору серверов. Это дискретно-событийная симуляция на Rust с библиотекой simcore: серверы и балансировщики моделируются обработчиками событий внутри одного процесса.

  • Запрос имеет числовой ID отправителя и длительность обслуживания. На медленном сервере длительность умножается на коэффициент замедления.
  • Каждый сервер обслуживает запросы последовательно, остальные ждут в очереди.
  • Входящий запрос попадает на случайный балансировщик. Состав серверов в течение эксперимента не меняется.
  • Раз в sync_interval секунд балансировщик обновляет снимок числа незавершённых запросов на каждом сервере, включая обслуживаемый запрос. Между обновлениями снимок может устареть.

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

На всех графиках по оси X отложено начало окна t, по оси Y — p99 времени ответа для запросов, пришедших в интервал [t, t + 60) секунд. Время ответа включает ожидание и обслуживание. Чтобы получить p99, длительности сортируют и берут элемент с номером ceil(0.99 × n) при нумерации с единицы. Например, для 100 запросов это 99-е значение, а не максимум. Ось Y логарифмическая.

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

Вспомогательный код

In [2]:
import json
import matplotlib.pyplot as plt
import numpy as np
import subprocess

from pathlib import Path

# Сначала соберите образ по инструкции в readme.md.
# Для локальной сборки и запуска через Cargo задайте False.
USE_DOCKER = True
DOCKER_IMAGE = 'distsys-load-balancing-lab:local'


def run_experiment(name, n_balancers=1, settings=None, balancers=None,
                   sync_interval=0.01, seed=123):
    settings = list(settings or [])
    if balancers is None:
        balancers = ['Hash', 'Random', 'RoundRobin', 'LeastLoaded']
    config_path = Path('.tmp-config.json')
    result_path = Path('.tmp-result.txt')
    configs = [{'balancer': balancer, 'n_balancers': n_balancers,
                'sync_interval': sync_interval} for balancer in balancers]
    config_path.write_text(json.dumps(configs), encoding='utf-8')
    args = ['--servers', '100', '--seed', str(seed), '--dump', result_path.name,
            '--config', config_path.name, *settings]
    try:
        if USE_DOCKER:
            command = ['docker', 'run', '--rm', '--mount',
                       f'type=bind,source={Path.cwd()},target=/lab',
                       DOCKER_IMAGE, *args]
        else:
            command = ['cargo', 'run', '--locked', '--release', '--', *args]
        subprocess.run(command, check=True)
        results = result_path.read_text(encoding='utf-8').splitlines()
    finally:
        config_path.unlink(missing_ok=True)
        result_path.unlink(missing_ok=True)

    plt.figure(figsize=(12, 5))
    plt.title(name)
    plt.yscale('log')
    plt.ylabel('p99 времени ответа, с (окно 60 с)')
    plt.xlabel('Начало окна t, с')
    for i in range(0, len(results), 3):
        times = np.fromstring(results[i + 1], sep=',')
        response_times = np.fromstring(results[i + 2], sep=',')
        plt.plot(times, response_times, label=results[i])
    plt.legend(loc='upper left')
    plt.tight_layout()
    plt.show()

Эксперимент 1: централизованный балансировщик

Начнём с четырёх алгоритмов:

  • Hash закрепляет отправителя за сервером. В симуляции это sender_id % num_servers — упрощённая модель детерминированного выбора по клиенту.
  • Random каждый раз выбирает случайный сервер.
  • RoundRobin перебирает серверы по кругу.
  • LeastLoaded выбирает сервер с наименьшим числом незавершённых запросов по последнему снимку. Это не Least Connections: TCP-соединения здесь не моделируются.

Сначала работает один балансировщик с обновлением снимков раз в 10 мс. Сравниваем p99 времени ответа, определённый выше. Меньшее значение означает меньшую задержку на границе самых медленных 1% запросов.

Запустим эксперимент на 100 одинаковых серверах и 200 отправителях с сопоставимой интенсивностью запросов. При одинаковых параметрах нагрузки и seed все запуски используют одинаковый входной поток; генераторы выбора балансировщика и бэкенда отделены от генератора нагрузки.

In [3]:
run_experiment('balanced workload')

Уменьшим число отправителей до 10, сохранив 100 серверов. Сколько серверов сможет использовать Hash при таком выборе? Нужны ли коллизии, чтобы нагрузка оказалась неравномерной?

In [4]:
run_experiment('balanced workload (10 senders)', settings=['--senders', '10'])

Теперь вернём 200 отправителей, но один из них будет посылать запросы в 50 раз чаще остальных. Проследите, куда попадёт его нагрузка при разных алгоритмах.

In [5]:
run_experiment('skewed workload', settings=['--preset', 'skewed'])
  • Почему закрепление отправителя за сервером ухудшает задержки в этих двух случаях?
  • Когда такая привязка полезна, например для локального кеша или состояния сессии?
  • Чем равномерное распределение числа отправителей отличается от равномерного распределения нагрузки?

Пока все серверы были одинаковыми. Вернём равномерный поток от 200 отправителей и замедлим 5% серверов в пять раз.

In [6]:
run_experiment('balanced workload (different servers)', settings=['--different-servers'])
  • Почему алгоритмы, не учитывающие очереди, накапливают запросы на медленных серверах?
  • Насколько LeastLoaded помогает в этой ситуации и как на результат влияет период обновления снимка?

Эксперимент 2: независимые балансировщики

Теперь несколько балансировщиков используют общий набор серверов. У каждого свой снимок состояния, который обновляется раз в 5 секунд. В этой модели обновления происходят одновременно, поэтому все балансировщики видят одинаковые снимки. Запросы по-прежнему распределяются между балансировщиками случайно.

По умолчанию у экземпляров Random и PowerOfK разные воспроизводимые последовательности случайных чисел, а у RoundRobin — случайные начальные позиции. Параметр seed позволяет воспроизвести эксперимент.

По сравнению с первым экспериментом меняются и число балансировщиков, и период обновления. Сначала посмотрим на один балансировщик с периодом 5 секунд, чтобы отделить эти эффекты.

In [7]:
run_experiment('balanced workload (1 balancer, updates every 5 s)', sync_interval=5)
  • Зачем распределять входящий поток между несколькими балансировщиками?
  • Что изменилось при переходе от обновлений раз в 10 мс к обновлениям раз в 5 секунд при одном балансировщике?
In [8]:
run_experiment('balanced workload (5 balancers)', n_balancers=5, sync_interval=5)
  • Почему LeastLoaded направляет много запросов на один сервер между обновлениями? Почему при одинаковых снимках и входном потоке его кривая не меняется с числом балансировщиков?
  • Как изменяется p99 у Random и RoundRobin? Не ожидайте одинакового порядка кривых при любых параметрах нагрузки.

Эксперимент 3: Power of Two Choices и Power of k Choices

С устаревшими снимками глобальный минимум может привлекать слишком много запросов от разных балансировщиков. Попробуем выбирать минимум только среди двух случайных серверов — Power of Two Choices. Более общий вариант с k кандидатами называется Power of k Choices.

Это не гарантия улучшения для любой нагрузки: результат зависит от актуальности снимков, длительности запросов и различий между серверами. Проверим алгоритм на том же наборе из пяти балансировщиков. Подробнее о методе: The Power of Two Choices in Randomized Load Balancing.

In [12]:
exp3_balancers = ['Hash', 'Random', 'RoundRobin', 'LeastLoaded', 'PowerOfK[k=2]']
In [13]:
run_experiment('power of two choices', n_balancers=5, sync_interval=5, balancers=exp3_balancers)

Увеличим число кандидатов k. Больший выбор повышает шанс найти свободный сервер, но при устаревших снимках может усилить концентрацию запросов на одних и тех же кандидатах. Какой эффект виден на этих графиках? Повторите эксперимент с другим периодом обновления или seed.

In [14]:
run_experiment('power of K choices', n_balancers=5, sync_interval=5, balancers=['PowerOfK[k=2]', 'PowerOfK[k=3]', 'PowerOfK[k=5]', 'PowerOfK[k=10]'])

PowerOfK выбирает по периодически обновляемому снимку. Вариант PowerOfKWithUpdates сравнивает актуальные очереди только у выбранных k серверов перед каждым решением. В симуляции это прямое чтение состояния: стоимость и задержка таких проверок не моделируются. Сравните варианты и обсудите, какой дополнительный обмен потребовался бы в реальной системе.

In [15]:
run_experiment('do updates matter?', n_balancers=5, sync_interval=5, balancers=['PowerOfK[k=2]', 'PowerOfKWithUpdates[k=2]'])

Повторим сравнение при наличии медленных серверов. Сохраняется ли преимущество алгоритмов, учитывающих очередь? Как на результаты влияют выбранный seed и частота обновления состояния?

In [16]:
run_experiment('power of two choices', n_balancers=5, sync_interval=5, balancers=exp3_balancers, settings=['--different-servers'])

Дополнительно: коррелированные выборы балансировщиков

Этот эксперимент можно выполнить после основной части. Сравните его с запуском balanced workload (5 balancers) из эксперимента 2: число балансировщиков, поток запросов и период обновления снимков остаются прежними.

Флаг --correlated-selectors задаёт всем балансировщикам одинаковый seed выбора сервера. Поэтому у экземпляров Random совпадают последовательности случайных чисел, а у RoundRobin — начальные позиции. Это отличается от общего параметра seed: без флага он воспроизводит эксперимент, но экземпляры получают разные последовательности выбора.

In [11]:
run_experiment('correlated selectors (5 balancers)', n_balancers=5, sync_interval=5,
               settings=['--correlated-selectors'])
  • Как изменились p99 у Random и RoundRobin по сравнению с независимой инициализацией? Почему одинаковые последовательности могут создавать перекосы нагрузки?
  • Почему результаты Hash и LeastLoaded не изменились? Используют ли они генератор случайных чисел при выборе сервера?

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