MEASUREMENT
bench/stretched-cache/cluster.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 выборы не начнёт. В этих опытах она не
мешала, и это теперь напечатано, а не предполагается.
Источники
- Redis. Redis replication — https://redis.io/docs/latest/operate/oss_and_stack/management/replication/
- Redis. Redis cluster specification — https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/
- Redis. Scale with Redis Cluster (
cluster-require-full-coverage) — https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/ - Valkey. Replication — https://valkey.io/topics/replication/
- Valkey. Cluster specification — https://valkey.io/topics/cluster-spec/
- PostgreSQL. High Availability, Load Balancing, and Replication — https://www.postgresql.org/docs/current/high-availability.html
- PostgreSQL. Replication configuration parameters — https://www.postgresql.org/docs/current/runtime-config-replication.html
- Redis. Active-Active geo-distributed databases — https://redis.io/docs/latest/operate/rs/databases/active-active/
- Amazon. Caching challenges and strategies — https://aws.amazon.com/builders-library/caching-challenges-and-strategies/
Script
426 lines"""Один кластер на два датацентра: что делает каждая половина при разрыве.
ПОЧЕМУ ЭТО ГЛАВНЫЙ ЗАМЕР СТАТЬИ. Просьба звучит как «разложите узлы поровну
между ДЦ, чтобы пережить потерю любого из них». Звучит симметрично и потому
убедительно. Замер показывает, что именно симметрия и убивает: решение о
том, кто продолжает работать, кластер принимает большинством голосов
мастеров, а у ровно поделённого пополам кластера большинства нет НИ У КОГО.
ЧТО ИМЕННО ПРОВЕРЯЕТСЯ. Три расклада на шести узлах:
A. 3 мастера в ДЦ-1, 3 реплики в ДЦ-2 — «реплики про запас»;
B. по 3 узла в каждом ДЦ так, что мастера поделены 2:1, и реплика третьего
мастера осталась при нём;
C. то же деление узлов, но реплика третьего мастера перенесена в ДЦ-1.
КАЖДАЯ СТОРОНА МЕРЯЕТСЯ НА СВОЁМ КЛАСТЕРЕ, И ЭТО ИСПРАВЛЕНИЕ. В первой версии
замера обе половины опрашивались по очереди на одном кластере: остановили
дальнюю, спросили ближнюю, отпустили, остановили ближнюю, спросили дальнюю.
Разбор снаружи указал на дыру, и указал верно. После первой проверки топология
не обязана вернуться к исходной: в раскладе C первая половина успевает
ПОВЫСИТЬ реплику, сменить configEpoch и переназначить слоты, — и вторая
половина меряется уже на другом кластере, чем задумано. Две последовательные
проверки нельзя называть двумя сторонами одного разрыва.
Поэтому здесь на каждую сторону поднимается свой кластер с той же топологией,
и топология печатается ДО разрыва и ПОСЛЕ него — чтобы смену ролей было видно,
а не приходилось предполагать.
ТОПОЛОГИЯ ЗАДАЁТСЯ РУКАМИ, А НЕ `--cluster-replicas`. Иначе расклады B и C
неповторимы: кто чья реплика, решает утилита, и на втором кластере решение
может быть другим. Здесь слоты раздаются через `CLUSTER ADDSLOTS`, а пары —
через `CLUSTER REPLICATE`, поэтому «тот же расклад» означает буквально тот же.
ЗАПУСК: python3 bench/stretched-cache/cluster.py
Нужны redis-server и redis-cli в PATH (или в REDIS_SERVER_BIN/REDIS_CLI_BIN).
Занимает несколько минут: кластеров поднимается шесть, и каждому надо дать
время на выборы (cluster-node-timeout ниже).
"""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import redis
# Какой сервер запускать. По умолчанию тот, что в PATH; REDIS_SERVER_BIN и
# REDIS_CLI_BIN позволяют прогнать тот же замер на другой сборке — так снята
# таблица регрессии на Redis 8.10.1 и smoke-прогон на Valkey.
SERVER = os.environ.get("REDIS_SERVER_BIN", "redis-server")
CLI = os.environ.get("REDIS_CLI_BIN", "redis-cli")
NODE_TIMEOUT_MS = 2000
SETTLE_S = 8.0
SLOT_RANGES = ((0, 5460), (5461, 10922), (10923, 16383))
PROBE = "dc:probe"
def free_port() -> int:
"""Свободный порт НИЖЕ 55535 — и это не придирка.
В режиме кластера узел слушает два порта: обычный и шинный, ровно на
десять тысяч больше. Порт из эфемерного диапазона (на этой машине он
доходит до 60999) даёт шинный порт больше 65535, и узел не поднимается.
Поймано именно так: замер падал на «узел не поднялся», а в журнале узла
стояло `Could not create server TCP listening socket`.
"""
for _ in range(500):
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
if port < 55535:
return port
raise RuntimeError("не нашлось свободного порта ниже 55535")
def conn(port: int, timeout: float = 2.0) -> redis.Redis:
return redis.Redis(port=port, socket_timeout=timeout, decode_responses=True)
class Cluster:
"""Шесть узлов с топологией, заданной вызывающим."""
def __init__(self) -> None:
self.tmp = tempfile.mkdtemp(prefix="cluster-")
self.ports: list[int] = []
self.procs: dict[int, subprocess.Popen] = {}
def start(self, count: int = 6) -> list[int]:
for _ in range(count):
port = free_port()
d = os.path.join(self.tmp, str(port))
os.makedirs(d, exist_ok=True)
proc = subprocess.Popen([
SERVER,
"--port", str(port),
"--dir", d,
"--cluster-enabled", "yes",
"--cluster-config-file", f"nodes-{port}.conf",
"--cluster-node-timeout", str(NODE_TIMEOUT_MS),
"--save", "",
"--appendonly", "no",
"--logfile", os.path.join(d, "redis.log"),
])
deadline = time.time() + 15
while time.time() < deadline:
try:
c = conn(port, 0.5)
if c.ping():
c.close()
break
except Exception:
time.sleep(0.05)
else:
raise RuntimeError(f"узел {port} не поднялся")
self.ports.append(port)
self.procs[port] = proc
return self.ports
def form(self, pairing: list[tuple[int, int]]) -> None:
"""pairing — список пар (мастер, его реплика), ровно три."""
masters = [m for m, _ in pairing]
first = self.ports[0]
for p in self.ports[1:]:
conn(first).execute_command("CLUSTER", "MEET", "127.0.0.1", p)
self._await(lambda: all(
len(conn(p).execute_command("CLUSTER", "NODES").strip().splitlines()) == len(self.ports)
for p in self.ports), 30, "узлы не увидели друг друга")
for port, (lo, hi) in zip(masters, SLOT_RANGES):
conn(port).execute_command("CLUSTER", "ADDSLOTS", *range(lo, hi + 1))
ids = {p: conn(p).execute_command("CLUSTER", "MYID") for p in self.ports}
self._await(lambda: all(
len(conn(p).execute_command("CLUSTER", "SLOTS")) == 3 for p in self.ports),
30, "слоты не разошлись по узлам")
for master, replica in pairing:
conn(replica).execute_command("CLUSTER", "REPLICATE", ids[master])
# ЖДЁМ ДВА ПРИЗНАКА, А НЕ ОДИН. `cluster_state:ok` означает, что слоты
# покрыты; `master_link_status:up` — что реплике есть чем повышаться.
# Между ними несколько секунд, и мастер, упавший внутри этого окна, не
# заменяется вовсе. Если ждать только первый признак, замер измеряет
# это окно, а не расклад.
self._await(lambda: all(state_of(p) == "ok" for p in self.ports),
60, "кластер не собрался")
replicas = [r for _, r in pairing]
self._await(lambda: all(
conn(p).info("replication").get("master_link_status") == "up" for p in replicas),
60, "реплики не подключились к мастерам")
@staticmethod
def _await(cond, timeout: float, message: str) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
if cond():
return
except Exception:
pass
time.sleep(0.2)
raise RuntimeError(message)
def stop(self, ports: list[int]) -> None:
for p in ports:
subprocess.run(["kill", "-STOP", str(self.procs[p].pid)], check=False)
def destroy(self) -> None:
for proc in self.procs.values():
subprocess.run(["kill", "-CONT", str(proc.pid)], check=False)
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
shutil.rmtree(self.tmp, ignore_errors=True)
def state_of(port: int) -> str:
try:
return conn(port, 1.0).cluster("INFO")["cluster_state"]
except Exception:
return "недоступен"
def can_read(port: int, key: str) -> str:
"""Что получает клиент на обычный GET: значение, отказ или молчание."""
try:
value = conn(port, 1.5).get(key)
return "ответил" if value is not None else "ответил (пусто)"
except redis.exceptions.ClusterDownError:
return "CLUSTERDOWN"
except (redis.exceptions.MovedError, redis.exceptions.AskError):
# Слот не на этом узле. Для нашего вопроса это «жив и отвечает»:
# клиент кластера пойдёт по указанному адресу.
return "отдал слот другому узлу"
except redis.exceptions.ResponseError as exc:
return str(exc).split()[0]
except Exception:
return "таймаут"
def topology(ports: list[int], view_from: int | None = None) -> dict[int, str]:
"""Кто мастер, а кто чья реплика — по идентификаторам узлов кластера."""
c = conn(view_from if view_from is not None else ports[0])
raw = c.execute_command("CLUSTER", "NODES")
by_id: dict[str, int] = {}
rows = []
for line in raw.strip().splitlines():
f = line.split()
node_id, addr, flags, master_id = f[0], f[1], f[2], f[3]
port = int(addr.split("@")[0].split(":")[1])
by_id[node_id] = port
rows.append((port, flags, master_id))
out = {}
for port, flags, master_id in rows:
if "master" in flags:
out[port] = "мастер"
else:
out[port] = f"реплика {by_id.get(master_id, '?')}"
return out
def roles(ports: list[int]) -> dict[int, str]:
out = {}
for p in ports:
try:
out[p] = conn(p, 1.0).info("replication")["role"]
except Exception:
out[p] = "?"
return out
def eligibility(replicas: list[int]) -> None:
"""Право реплики повыситься — то, чего мало кто проверяет заранее.
Условие из спецификации («большинство мастеров плюс доступная реплика
каждого потерянного») необходимо, но само по себе не достаточно: реплика
ещё должна быть достаточно свежей. Свежесть проверяется настройкой
`cluster-replica-validity-factor`, и здесь печатается то, из чего эта
проверка считается.
"""
for p in replicas:
try:
c = conn(p)
info = c.info("replication")
factor = c.config_get("cluster-replica-validity-factor")
factor = list(factor.values())[0] if factor else "?"
print(f" узел {p}: канал {info.get('master_link_status')}, "
f"смещение {info.get('slave_repl_offset')}, "
f"validity-factor {factor}")
except Exception as exc: # pragma: no cover — диагностика
print(f" узел {p}: не опрошен ({exc})")
def observe(label: str, observed: list[int], probe: str) -> None:
r = roles(observed)
alive = sum(1 for v in r.values() if v == "master")
print(f" {label}: {alive} мастеров из {len(observed)} узлов")
for p in observed:
print(f" узел {p}: состояние {state_of(p):<12} GET -> {can_read(p, probe)}")
def one_side(block_ports_factory, pairing_factory, dc_factory, observed_name: str,
observed_index: int) -> None:
"""Поднять свежий кластер, оборвать канал и опросить ОДНУ половину."""
cluster = Cluster()
try:
ports = cluster.start(6)
pairing = pairing_factory(ports)
cluster.form(pairing)
dcs = dc_factory(ports, pairing)
observed, stopped = dcs[observed_index], dcs[1 - observed_index]
topo = topology(ports)
print(f" ── опыт на свежем кластере, наблюдается {observed_name}")
print(f" ДЦ-1: {dcs[0]}")
for p in dcs[0]:
print(f" {p}: {topo[p]}")
print(f" ДЦ-2: {dcs[1]}")
for p in dcs[1]:
print(f" {p}: {topo[p]}")
# Кладём настоящий ключ, чтобы «GET -> ответил» означало «отдал
# данные», а не «слот пуст». Делается ДО опроса реплик: иначе
# смещение репликации у всех нулевое и смотреть на него бессмысленно.
cc = redis.RedisCluster(host="127.0.0.1", port=ports[0], decode_responses=True)
cc.set(probe_key(), "value")
# Ещё немного ключей, чтобы данные достались всем трём мастерам: иначе
# у двух реплик из трёх смещение репликации нулевое, и смотреть на него
# в строке про право повышаться попросту не на что.
for i in range(20):
cc.set(f"spread:{i}", i)
cc.close()
time.sleep(0.5)
replicas_here = [p for p in observed if topo[p] != "мастер"]
if replicas_here:
print(" право реплик наблюдаемой половины повыситься, до разрыва:")
eligibility(replicas_here)
# КАК ИЗОБРАЖАЕТСЯ РАЗРЫВ. Узлы дальней половины останавливаются
# сигналом STOP. Это не то же самое, что их убить: процесс жив, порт
# открыт, файл состояния на месте — но узел не отвечает и не шлёт
# heartbeat. Для оставшейся половины он неотличим от узла за
# оборванным каналом, а именно это и надо изобразить. Настоящее
# разделение сети было бы точнее, но требует правил файрвола или
# сетевых пространств имён, которых у замера может не быть.
cluster.stop(stopped)
time.sleep(SETTLE_S)
observe(observed_name, observed, probe_key())
after = topology(ports, view_from=observed[0])
changed = [(p, topo[p], after[p]) for p in observed if topo[p] != after[p]]
if changed:
print(" что изменилось в ролях за время разрыва:")
for p, before, now in changed:
print(f" узел {p}: {before} -> {now}")
else:
print(" роли на наблюдаемой половине не изменились")
print()
finally:
cluster.destroy()
def probe_key() -> str:
return PROBE
def pairing_a(ports: list[int]) -> list[tuple[int, int]]:
"""Мастера — первые три, каждая реплика при своём мастере."""
return [(ports[0], ports[3]), (ports[1], ports[4]), (ports[2], ports[5])]
def dcs_a(ports, pairing):
return ([ports[0], ports[1], ports[2]], [ports[3], ports[4], ports[5]])
def dcs_b(ports, pairing):
# ДЦ-1: два мастера и реплика первого. ДЦ-2: третий мастер и его реплика,
# плюс реплика второго мастера. То есть реплика третьего — в ДЦ-2.
return ([ports[0], ports[1], ports[3]], [ports[2], ports[4], ports[5]])
def dcs_c(ports, pairing):
# То же деление по числу узлов, но домой берём реплику ТРЕТЬЕГО мастера.
return ([ports[0], ports[1], ports[5]], [ports[2], ports[3], ports[4]])
def main() -> None:
probe = Cluster()
try:
probe.start(1)
version = conn(probe.ports[0]).info("server")["redis_version"]
finally:
probe.destroy()
print(f"Redis {version} · кластер из шести узлов, 3 мастера и 3 реплики")
print(f"cluster-node-timeout {NODE_TIMEOUT_MS} мс; после разрыва ждём "
f"{SETTLE_S:.0f} с на выборы")
print("каждая половина меряется на СВОЁМ кластере с той же топологией")
print()
print("4. РАСКЛАД A: ВСЕ МАСТЕРА В ОДНОМ ДЦ, РЕПЛИКИ ВО ВТОРОМ")
print("-" * 66)
one_side(None, pairing_a, dcs_a, "ДЦ-1 (мастера)", 0)
one_side(None, pairing_a, dcs_a, "ДЦ-2 (реплики)", 1)
print(" ДЦ-1 работает, ДЦ-2 не отвечает ничем. Реплики не стали")
print(" мастерами: для повышения нужен голос большинства мастеров, а")
print(" все мастера остались по ту сторону обрыва. То есть вторая")
print(" половина не запасная — при потере первой она не заменит её.")
print()
print("5. РАСКЛАД B: УЗЛЫ ПОРОВНУ, МАСТЕРА ПОДЕЛЕНЫ 2 И 1")
print("-" * 66)
one_side(None, pairing_a, dcs_b, "ДЦ-1 (2 мастера)", 0)
one_side(None, pairing_a, dcs_b, "ДЦ-2 (1 мастер)", 1)
print(" ОТКАЗАЛИ ОБЕ ПОЛОВИНЫ, и это главный результат замера. Теперь это")
print(" два независимых опыта на двух свежих кластерах, а не две проверки")
print(" подряд на одном: вторая половина не могла достаться второму опыту")
print(" в изменённом виде, потому что первого опыта он не видел.")
print()
print(" У ДЦ-1 большинство мастеров, и голосов на повышение реплики")
print(" ему хватает. Но повышать некого: реплика мастера, оставшегося")
print(" в ДЦ-2, тоже осталась в ДЦ-2 — смотрите карту выше. Слоты")
print(" этого мастера не покрыты никем, а кластер по умолчанию")
print(" отказывается работать с неполным покрытием слотов целиком, а")
print(" не только по недостающим ключам.")
print()
print(" Отсюда правило, ради которого замер и поставлен: большинство")
print(" голосов необходимо, но НЕ достаточно. Нужна ещё и реплика")
print(" каждого потерянного мастера на своей стороне обрыва — и она")
print(" должна быть достаточно свежей, чтобы ей разрешили повышаться.")
print(" Свежесть в этих опытах не мешала: канал у всех реплик `up` ещё")
print(" до разрыва, что и напечатано выше. Но условие из спецификации")
print(" без этой третьей части неполно.")
print()
print("6. РАСКЛАД C: ТО ЖЕ ДЕЛЕНИЕ, НО РЕПЛИКА ЧУЖОГО МАСТЕРА ДОМА")
print("-" * 66)
one_side(None, pairing_a, dcs_c, "ДЦ-1 (2 мастера + реплика третьего)", 0)
one_side(None, pairing_a, dcs_c, "ДЦ-2 (1 мастер)", 1)
print(" Вот теперь ДЦ-1 выживает: и голосов хватило, и было кого")
print(" повысить. Строка «что изменилось в ролях» показывает это прямо:")
print(" реплика, привезённая домой, стала мастером.")
print()
print(" Но симметрии не появилось — в ДЦ-2 кэша по-прежнему нет. Такой")
print(" раскладкой выбирают, какой датацентр переживёт разрыв; сделать")
print(" так, чтобы пережили оба, нельзя.")
print()
print(" И ещё одно, ради чего понадобились свежие кластеры. В прежней")
print(" версии замера обе половины проверялись подряд на одном кластере,")
print(" и повышение реплики в первой проверке меняло топологию для")
print(" второй. Здесь этого не может быть по устройству опыта, и разница")
print(" между блоками 5 и 6 — ровно одна перестановка реплики, а не она")
print(" же плюс последствия предыдущего опыта.")
print()
if __name__ == "__main__":
sys.exit(main())