Deep Engineering

MEASUREMENT

bench/stretched-cache/relaycheck.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/system-design/caching/stretched-cache
Run on
Redis 7.0.15, PostgreSQL 16.13, Python 3.11.15, redis-py 8.1.0
How to run it
python3 bench/stretched-cache/replication.py   # блоки 1-3
python3 bench/stretched-cache/cluster.py       # блоки 4-6
python3 bench/stretched-cache/latency.py       # блок 7
python3 bench/stretched-cache/readsdown.py     # блок 8
python3 bench/stretched-cache/offsets.py       # блок 9
python3 bench/stretched-cache/waitcost.py      # блок 10
python3 bench/stretched-cache/relaycheck.py    # блоки 11-14
python3 bench/stretched-cache/versions.py      # блок 15

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для статьи «Растянутый кэш на два датацентра»

Скрипт Что делает
link.py канал между датацентрами: ретранслятор в пользовательском коде, который задерживает байты и умеет обрываться. Два варианта: конвейерный DelayedLink и сериализующий SerializingLink — разница между ними измерена в блоке 13. Общий модуль, сам ничего не печатает
replication.py мастер в одном ДЦ, реплика в другом: отставание, потеря подтверждённых записей при переключении, цена WAIT — блоки 1–3
cluster.py кластер из шести узлов, три раскладки по датацентрам, разрыв канала — блоки 4–6
latency.py кэш в чужом ДЦ против базы в своём — блок 7
readsdown.py что решает cluster-allow-reads-when-down — блок 8
offsets.py чем измеряется окно потерь: отставание против круга — блок 9
waitcost.py из чего складывается цена WAIT, на трёх расстояниях — блок 10
relaycheck.py проверка самого стенда: во что он обходится при нулевой задержке, сколько раз задержка применяется к операции, сериализующий канал против конвейерного, калибровка — блоки 11–14
versions.py те же утверждения на Redis 7.0.15, Redis 8.10.1 и Valkey 9.1.2 — блок 15
python3 bench/stretched-cache/replication.py   # блоки 1-3
python3 bench/stretched-cache/cluster.py       # блоки 4-6
python3 bench/stretched-cache/latency.py       # блок 7
python3 bench/stretched-cache/readsdown.py     # блок 8
python3 bench/stretched-cache/offsets.py       # блок 9
python3 bench/stretched-cache/waitcost.py      # блок 10
python3 bench/stretched-cache/relaycheck.py    # блоки 11-14
python3 bench/stretched-cache/versions.py      # блок 15

Нужен redis-server в PATH, для кластера ещё и redis-cli, для latency.py — работающий PostgreSQL (DSN в DE_TEST_DATABASE_URL). Другую сборку можно подсунуть через REDIS_SERVER_BIN и REDIS_CLI_BIN; versions.py берёт список из DE_REDIS_BUILDS вида «имя=каталог/bin» через запятую. Узлы поднимаются во временных каталогах на случайных портах и убиваются в finally; после себя скрипты не оставляют ни процессов, ни файлов.

Чем изображается разрыв, и почему двумя разными способами

В replication.py, latency.py, offsets.py, waitcost.py и relaycheck.py — ретранслятором link.py. Он слушает порт, соединяется с настоящим Redis и переносит байты, задерживая каждую порцию на заданное время. tc netem был бы честнее, но в ядре этой среды его попросту нет: CONFIG_NET_SCH_NETEM не собран, и tc qdisc add ... netem отвечает Error: Specified qdisc kind is unknown. Права тут ни при чём.

И у этого ретранслятора нашлось два дефекта — оба измерены и устранены. Первый: на его двух соединениях не был выставлен TCP_NODELAY, и пара «алгоритм Нейгла — отложенное подтверждение» добавляла сорок миллисекунд к каждой операции, где по каналу шло несколько мелких сообщений подряд. Ровно эти сорок миллисекунд первая версия статьи опубликовала как «слагаемое неизвестной природы» в цене WAIT. Второй: задержка ставилась между recv и sendall в одном потоке, поэтому порции шли по очереди, а не параллельно, и операция стоила на один лишний перелёт дороже. Пока первый дефект был на месте, второй был невидим — он вчетверо меньше. Подробности печатает relaycheck.py.

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

В cluster.py — сигналом STOP дальней половине. Здесь ретранслятор не годится: узлы кластера общаются друг с другом по шине напрямую, а не через клиентский порт, и ставить ретранслятор пришлось бы на каждую пару. Процесс, остановленный SIGSTOP, жив и держит порт открытым, но не отвечает и не шлёт heartbeat — для оставшейся половины он неотличим от узла за оборванным каналом. Убить узлы было нельзя: вторую половину надо было опросить тоже, и а каждая половина наблюдается на СВОЁМ свежем кластере с той же, заданной руками топологией. Это исправление: раньше обе половины проверялись по очереди на одном кластере, и повышение реплики в первой проверке могло изменить топологию для второй. Две последовательные проверки — не две стороны одного разрыва.

Что здесь важно прочитать правильно

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

Номера портов в блоках 4–6 случайны и в каждом прогоне свои. Читаются не они, а роли узлов и столбец состояния. Именно поэтому в блоке 5 стоит проследить пальцем, чья реплика где: в этом весь результат.

Блоки 5 и 6 различаются одной перестановкой реплики. Деление узлов по датацентрам в них одинаковое; меняется только то, чью реплику оставили дома. Разница в исходе — между «отказали обе половины» и «одна работает».

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

Опубликованная основа — Redis 7.0.15, но она не единственная проверка. Блок 15 (versions.py) прогоняет те же утверждения на Redis 8.10.1 и Valkey 9.1.2: ни одно не разошлось. Числа блоков 1–10 при этом на новые версии не переносятся и не заменяются ими — вопрос блока 15 другой: не разъехалось ли поведение.

Что получилось (Redis 7.0.15, PostgreSQL 16.13, Python 3.11.15, redis-py 8.1.0)

Мастер и реплика, канал 20 мс в одну сторону:

что значение
реплика в том же ДЦ: запись видна через 1,1 мс
реплика в другом ДЦ: запись видна через 20,1 мс

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

Переключение на реплику, 200 подтверждённых клиенту записей:

когда оборвали оказалось на реплике потеряно
сразу после записи 107 93 (46,5 %)
через секунду после записи 200 0

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

Цена WAIT 1:

что значение
обычная запись 0,24 мс
запись с WAIT 1 41,73 мс
во сколько раз дороже ×171,6

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

Кластер из шести узлов, разрыв канала между датацентрами:

раскладка ДЦ-1 ДЦ-2
A: все мастера в ДЦ-1, все реплики в ДЦ-2 работает CLUSTERDOWN
B: узлы поровну, мастера 2 и 1 CLUSTERDOWN CLUSTERDOWN
C: то же деление, реплика чужого мастера дома работает CLUSTERDOWN

Главный результат — строка B. У ДЦ-1 большинство мастеров, и голосов на повышение реплики ему хватает, а кэша всё равно нет: реплика мастера, оставшегося в ДЦ-2, тоже осталась в ДЦ-2, и слоты этого мастера не покрыты никем.

Задержка, медиана из 200 замеров:

откуда читаем медиана p95
кэш в своём ДЦ 0,10 мс 0,14 мс
база в своём ДЦ 0,09 мс 0,13 мс
кэш в чужом ДЦ 41,09 мс 41,36 мс

Выводы, которые пришлось исправить

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

«Наблюдаемое поведение шире документации» — неверно. Было сказано: раз cluster-require-full-coverage описан через записи, а GET тоже получил CLUSTERDOWN, значит поведение шире написанного. На самом деле за чтения отвечает отдельная описанная настройка cluster-allow-reads-when-down, по умолчанию no. readsdown.py меряет обе: с no двадцать ключей из двадцати получают CLUSTERDOWN; с yes узел отдаёт только ключи своих слотов — четырнадцать из двадцати, — остальные перенаправляет за оборванный канал, а записи отказаны при обоих значениях.

«Под угрозой всё, что записано за последний круг» — неверно. Круг совпал с потерей случайно. offsets.py меняет один только темп записи на том же канале и получает 104, 3 и 0 потерянных записей при отставании 3548, 99 и 0 байт. Мерой служит разница смещений потока репликации, а не круг.

«82 мс — это два круга» — неверно, и заменившее его объяснение тоже. waitcost.py показал, что фиксированным числом кругов цена не описывается, и статья честно написала, что сверх круга остаётся слагаемое в 42–44 мс неизвестной природы. Природа оказалась в приборе: relaycheck.py включает и выключает TCP_NODELAY на ретрансляторе при НУЛЕВОЙ задержке и получает 44,00 и 0,70 мс. После исправления цена WAIT — ровно один круг: 10,90, 41,52 и 81,40 мс при отношении 1,09, 1,04 и 1,02.

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

«Большинство плюс реплика каждого потерянного мастера» — условие неполное. Третья часть — свежесть реплики: отставшая сверх cluster-replica-validity-factor выборы не начнёт. В этих опытах она не мешала, и это теперь напечатано, а не предполагается.

Источники

Script

402 lines
"""Проверка самого стенда: чем на деле был канал между ДЦ — блоки 11-14.

ОТКУДА ВЗЯЛСЯ ЭТОТ СКРИПТ. Разбор снаружи задал вопрос уровнем ниже всех
прежних. Прежние спрашивали «что мы заключили из замера»; этот спрашивает «что
замер на самом деле моделировал». Претензия была конкретная: задержка в
`link.py` ставилась на каждую порцию, которую вернул `recv()`, а TCP границ
сообщений не хранит — значит, измерялось не «Redis плюс фиксированная задержка
распространения», а «Redis плюс задержка, умноженная на то, на сколько порций
разбился поток».

ПОЧЕМУ НЕ `tc netem`, КАК ПРОСИЛ РАЗБОР. Ядро песочницы собрано без
`CONFIG_NET_SCH_NETEM`; `tc qdisc add dev veth0 root netem delay 20ms` отвечает
`Error: Specified qdisc kind is unknown`. Права тут ни при чём — дисциплины
очереди в ядре просто нет, и достать её неоткуда. Сверить с настоящим
эмулятором пакетов в этой среде нельзя, поэтому сверяемся с тем, что доступно:
измеряем сам прибор.

ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ.

 11. Сколько стоит стенд, если задержку выкрутить в ноль. Это отделяет цену
     ретранслятора, петлевого TCP и планировщика потоков Python от цены
     расстояния. Здесь же выясняется происхождение слагаемого в 42-44 мс,
     которое блок 10 нашёл, но объяснить не смог.
 12. Сколько РАЗ задержка применяется к одной операции. Счётчик порций в
     ретрансляторе и есть ответ; контрольное окно без операций отделяет
     служебный трафик репликации от нашего.
 13. Сериализующий ретранслятор против конвейерного на одних и тех же
     операциях. Разница между ними — цена ошибки, на которую указал разбор.
 14. Калибровка: какую задержку канал даёт на простом «запрос — ответ».

ЗАПУСК: python3 bench/stretched-cache/relaycheck.py
Нужен redis-server в PATH (или в REDIS_SERVER_BIN).
"""

from __future__ import annotations

import os
import socket
import statistics
import subprocess
import sys
import tempfile
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from link import DelayedLink, SerializingLink  # noqa: E402

import redis  # noqa: E402

SERVER = os.environ.get("REDIS_SERVER_BIN", "redis-server")
DELAYS_MS = (5.0, 20.0, 40.0)
WRITES = 20
PAD = 44


def free_port() -> int:
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]
    s.close()
    return port


def start_redis(port: int, dirname: str) -> subprocess.Popen:
    proc = subprocess.Popen([
        SERVER, "--port", str(port), "--dir", dirname,
        "--save", "", "--appendonly", "no",
        "--logfile", os.path.join(dirname, "redis.log"),
    ])
    deadline = time.time() + 15
    while time.time() < deadline:
        try:
            c = redis.Redis(port=port, socket_timeout=0.5)
            if c.ping():
                c.close()
                return proc
        except Exception:
            time.sleep(0.05)
    raise RuntimeError(f"redis на порту {port} не поднялся")


def wait_sync(client: redis.Redis, timeout: float = 20.0) -> None:
    deadline = time.time() + timeout
    while time.time() < deadline:
        if client.info("replication").get("master_link_status") == "up":
            return
        time.sleep(0.05)
    raise RuntimeError("реплика не синхронизировалась")


def median_ms(f, n: int) -> float:
    samples = []
    for i in range(n):
        t0 = time.perf_counter()
        f(i)
        samples.append((time.perf_counter() - t0) * 1000.0)
    return statistics.median(samples)


def row(label: str, value, pad: int = PAD) -> None:
    print(f"  {label:<{pad}}{value}")


def note(text: str) -> None:
    print()
    for line in text.strip("\n").splitlines():
        print("  " + line.strip() if line.strip() else "")
    print()


def head(number: int, title: str) -> None:
    print()
    print(f"{number}. {title}")
    print("-" * 66)


class Pair:
    """Мастер и реплика; реплику можно переподключать через разные каналы."""

    def __init__(self) -> None:
        self.dir_a = tempfile.mkdtemp(prefix="rc-a-")
        self.dir_b = tempfile.mkdtemp(prefix="rc-b-")
        self.port_a, self.port_b = free_port(), free_port()
        self.proc_a = start_redis(self.port_a, self.dir_a)
        self.proc_b = start_redis(self.port_b, self.dir_b)
        self.a = redis.Redis(port=self.port_a, decode_responses=True)
        self.b = redis.Redis(port=self.port_b, decode_responses=True)
        self.link = None

    def attach(self, link_cls=None, delay_ms: float = 0.0, nodelay: bool = True):
        """Подключить реплику к мастеру напрямую или через канал."""
        self.detach()
        if link_cls is None:
            self.b.replicaof("127.0.0.1", self.port_a)
        else:
            self.link = link_cls(self.port_a, delay_ms=delay_ms, nodelay=nodelay)
            self.b.replicaof("127.0.0.1", self.link.port)
        wait_sync(self.b)
        time.sleep(0.6)
        # Прогрев: первая запись после синхронизации несёт лишнее.
        self.a.set("warm", "1")
        self.a.wait(1, 5000)
        return self.link

    def detach(self) -> None:
        try:
            self.b.replicaof("no", "one")
        except Exception:
            pass
        if self.link is not None:
            self.link.cut()
            self.link = None
        time.sleep(0.2)

    def plain_and_wait(self, tag: str) -> tuple[float, float]:
        plain = median_ms(lambda i: self.a.set(f"{tag}:p:{i}", i), WRITES)

        def with_wait(i: int) -> None:
            self.a.set(f"{tag}:w:{i}", i)
            self.a.wait(1, 5000)

        return plain, median_ms(with_wait, WRITES)

    def close(self) -> None:
        self.detach()
        for proc in (self.proc_a, self.proc_b):
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()


def block11(pair: Pair) -> None:
    head(11, "ЧТО СТОИТ САМ СТЕНД, ЕСЛИ ЗАДЕРЖКУ ВЫКРУТИТЬ В НОЛЬ")

    pair.attach(None)
    direct_plain, direct_wait = pair.plain_and_wait("d")
    row("без канала вовсе: обычная запись, мс", f"{direct_plain:.2f}")
    row("без канала вовсе: запись с WAIT 1, мс", f"{direct_wait:.2f}")
    print()

    # Ровно одна разница между этими двумя опытами — флаг TCP_NODELAY на двух
    # соединениях ретранслятора. Задержка в обоих нулевая.
    pair.attach(SerializingLink, 0.0, nodelay=False)
    old_plain, old_wait = pair.plain_and_wait("o")
    print("  канал 0 мс, БЕЗ TCP_NODELAY (стенд, на котором снят блок 10):")
    row("    обычная запись, мс", f"{old_plain:.2f}")
    row("    запись с WAIT 1, мс", f"{old_wait:.2f}")
    print()

    pair.attach(SerializingLink, 0.0, nodelay=True)
    new_plain, new_wait = pair.plain_and_wait("n")
    print("  канал 0 мс, С TCP_NODELAY (исправленный стенд):")
    row("    обычная запись, мс", f"{new_plain:.2f}")
    row("    запись с WAIT 1, мс", f"{new_wait:.2f}")
    print()

    row("цена прежнего стенда на одной записи, мс", f"{old_wait - new_wait:+.2f}")

    note(
        f"""
        Вот и ответ на вопрос, который блок 10 оставил открытым, — и ответ
        неприятный. Там нашлось слагаемое в 42-44 мс сверх круга, одинаковое
        на всех трёх расстояниях, и было честно сказано, что замер его
        происхождения не устанавливает. Устанавливает этот: слагаемое целиком
        принадлежит ИЗМЕРИТЕЛЬНОМУ ПРИБОРУ.

        Три строки читаются подряд. Без канала вовсе `SET` + `WAIT 1` стоит
        {direct_wait:.2f} мс. Через канал с НУЛЕВОЙ задержкой, но без
        TCP_NODELAY — {old_wait:.2f} мс. Тот же канал, та же нулевая задержка,
        флаг включён — {new_wait:.2f} мс. Расстояния в опыте нет ни в одной из
        трёх строк.

        Механизм известный. Redis ставит TCP_NODELAY на своих соединениях сам;
        ретранслятор создаёт ДВА НОВЫХ соединения, и на них флага не было.
        Дальше работает пара «алгоритм Нейгла на отправителе — отложенное
        подтверждение на получателе»: мелкая порция придерживается до
        подтверждения предыдущей, а подтверждение придерживается до сорока
        миллисекунд. Отсюда и ровно сорок с небольшим, и их независимость от
        расстояния, и то, что они появлялись только там, где по каналу шло
        несколько мелких сообщений подряд.

        ЧТО ЭТО ЗНАЧИТ ДЛЯ СТАТЬИ. Числа блока 10 (51,99 / 84,05 / 124,01 мс)
        измеряли Redis плюс расстояние плюс залипание в сорок миллисекунд на
        собственном стенде. Вывод «с расстоянием цена растёт на один круг, а
        не на два» устоял — он держится на разностях, а слагаемое было
        постоянным, — но сами числа и «слагаемое неизвестной природы» надо
        заменить. Ниже они пересняты.
        """
    )


def block12(pair: Pair) -> None:
    head(12, "СКОЛЬКО РАЗ ЗАДЕРЖКА ПРИМЕНЯЕТСЯ К ОДНОЙ ОПЕРАЦИИ")

    delay = 20.0
    for name, cls in (("сериализующий", SerializingLink), ("конвейерный", DelayedLink)):
        link = pair.attach(cls, delay)

        # Контрольное окно: репликация переговаривается и без нас (REPLCONF ACK
        # раз в секунду), и без контроля наш трафик от фонового не отличить.
        link.reset_stats()
        time.sleep(1.0)
        idle = (link.stats.chunks_down, link.stats.chunks_up)

        link.reset_stats()
        t0 = time.perf_counter()
        for i in range(WRITES):
            pair.a.set(f"c:{name}:{i}", i)
            pair.a.wait(1, 5000)
        elapsed = (time.perf_counter() - t0) * 1000.0
        busy = (link.stats.chunks_down, link.stats.chunks_up)

        print(f"  канал {name}, {delay:.0f} мс в одну сторону, {WRITES} раз SET+WAIT:")
        row("    порций мастер → реплика вхолостую", idle[0])
        row("    порций реплика → мастер вхолостую", idle[1])
        row("    порций мастер → реплика с нагрузкой", busy[0])
        row("    порций реплика → мастер с нагрузкой", busy[1])
        total = busy[0] + busy[1] - idle[0] - idle[1]
        row("    порций сверх фона, всего", total)
        row("    то же на одну операцию", f"{total / WRITES:.2f}")
        row("    задержка применена, мс на операцию", f"{total / WRITES * delay:.1f}")
        row("    измерено на операцию, мс", f"{elapsed / WRITES:.2f}")
        print()

    note(
        """
        Вот в чём была претензия разбора, выраженная числом. Задержка ставится
        не на операцию, а на ПОРЦИЮ, и порций на одну операцию три: сама
        запись, запрос подтверждения и подтверждение. Число это не
        гарантировано: TCP границ сообщений не хранит, и соседние сообщения
        могут слиться в одну порцию или разойтись по двум.

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

        Разница между каналами ровно в том, складываются ли задержки. У
        сериализующего поток останавливается на время каждого полёта, поэтому
        запись и запрос подтверждения, ушедшие бы вместе, идут по очереди — и
        операция стоит на один перелёт дороже. Насколько именно, печатает
        следующий блок.
        """
    )


def block13(pair: Pair) -> None:
    head(13, "СЕРИАЛИЗУЮЩИЙ КАНАЛ ПРОТИВ КОНВЕЙЕРНОГО НА ТРЁХ РАССТОЯНИЯХ")

    print("  в одну сторону    круг   сериализующий   конвейерный   разница")
    rows = []
    for one_way in DELAYS_MS:
        pair.attach(SerializingLink, one_way)
        _, ser = pair.plain_and_wait(f"S{one_way:.0f}")
        pair.attach(DelayedLink, one_way)
        _, pipe = pair.plain_and_wait(f"P{one_way:.0f}")
        rt = 2 * one_way
        rows.append((one_way, ser, pipe))
        print(f"  {one_way:>10.0f} мс {rt:>7.0f} мс {ser:>13.2f} мс "
              f"{pipe:>10.2f} мс {ser - pipe:>+8.2f}")

    print()
    print("  сверх круга (цена минус круг):")
    for one_way, ser, pipe in rows:
        rt = 2 * one_way
        row(f"    при {one_way:>4.0f} мс в одну сторону",
            f"сериализующий {ser - rt:>7.2f} · конвейерный {pipe - rt:>7.2f}")

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

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

        Числа надо публиковать по конвейерному каналу: у него цена `WAIT` —
        это круг плюс около полутора миллисекунд, то есть ровно то, чем она и
        должна быть по устройству команды.

        Чего этот блок НЕ доказывает: что конвейерный ретранслятор равен
        `netem`. Задержка здесь по-прежнему применяется к порциям потока, а не
        к пакетам; нет ни очередей, ни потерь, ни джиттера, ни ограничения
        полосы. На нагрузке, где порций много, расхождение с настоящей сетью
        вырастет, и проверить его в этой среде нечем.
        """
    )


def block14(pair: Pair) -> None:
    head(14, "КАЛИБРОВКА: КАКУЮ ЗАДЕРЖКУ КАНАЛ ДАЁТ НА «ЗАПРОС — ОТВЕТ»")

    direct = median_ms(lambda i: pair.a.get("warm"), 40)
    row("GET напрямую, мс", f"{direct:.2f}")
    print()
    print("  через канал, GET мимо ретранслятора к тому же мастеру:")
    print("  в одну сторону    круг   сериализующий   конвейерный")
    for one_way in DELAYS_MS:
        out = []
        for cls in (SerializingLink, DelayedLink):
            link = cls(pair.port_a, delay_ms=one_way)
            try:
                c = redis.Redis(port=link.port, decode_responses=True, socket_timeout=10)
                c.get("warm")  # первое обращение несёт установку соединения
                out.append(median_ms(lambda i: c.get("warm"), 20))
                c.close()
            finally:
                link.cut()
        print(f"  {one_way:>10.0f} мс {2 * one_way:>7.0f} мс "
              f"{out[0]:>13.2f} мс {out[1]:>10.2f} мс")

    note(
        """
        На простом «запрос — ответ» оба канала дают одно и то же, и дают ровно
        круг плюс около миллисекунды: одна порция туда, одна обратно,
        складывать нечего. Это граница применимости стенда, названная замером,
        а не оговоркой.

        Читать её так. Там, где за операцию по каналу проходит одна порция в
        каждую сторону, ретранслятор — хорошее приближение расстояния, и числа
        блоков 7 и 1 можно брать как есть. Там, где по каналу идёт несколько
        сообщений подряд — репликация, конвейер команд, `WAIT`, — прибор
        начинает добавлять своё, и добавлял: сорок миллисекунд залипания и
        лишний перелёт на сериализации. Оба слагаемых теперь измерены и
        убраны, но третьего рода погрешность — отсутствие очередей, потерь,
        джиттера и ограничения полосы — остаётся, и снять её в этой среде
        нечем.

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


def main() -> None:
    pair = Pair()
    try:
        version = pair.a.info("server")["redis_version"]
        print(f"Redis {version} · один хост, два процесса")
        print("проверяется не Redis, а измерительный стенд: ретранслятор,")
        print("который во всех остальных замерах изображает канал между ДЦ")
        block11(pair)
        block12(pair)
        block13(pair)
        block14(pair)
    finally:
        pair.close()


if __name__ == "__main__":
    main()