ЗАМЕР
bench/stretched-cache/versions.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/system-design/caching/stretched-cache
- Прогон
- Redis 7.0.15, PostgreSQL 16.13, Python 3.11.15, redis-py 8.1.0
- Как запустить
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
Запись прогона
Замеры для статьи «Растянутый кэш на два датацентра»
| Скрипт | Что делает |
|---|---|
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/
Скрипт
292 строк"""Одни и те же опыты на трёх сборках: блок 15.
ЗАЧЕМ. Все числа статьи сняты на Redis 7.0.15. Ветка старая: к сентябрю 2026
актуальная линия Redis Open Source — 8.10.x, а Valkey ушёл в 9.1. Разбор
снаружи указал на это прямо: выводы сверены с текущей документацией, но
измерены на одной старой сборке, и «совпало однажды» не делает свойство
неизменным. Кластерная часть Redis между 7 и 8 менялась заметно, так что
проверять надо именно её.
ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ. Не производительность — она на разных сборках разная по
скучным причинам. Проверяются УТВЕРЖДЕНИЯ статьи, каждое в том виде, в каком
его можно подтвердить или опровергнуть:
1. мастер отвечает клиенту, не дожидаясь реплики;
2. видимость записи на реплике отстаёт примерно на перелёт;
3. WAIT 1 стоит примерно круг;
4. разрыв сразу после залпа записей теряет подтверждённые записи;
5. расклад B (узлы поровну, мастера 2:1) отказывает в половине с
большинством мастеров;
6. `cluster-allow-reads-when-down` решает судьбу чтений в отказавшем
кластере.
7.0.15 остаётся опубликованной основой — на ней сняты все остальные блоки.
Этот блок отвечает на другой вопрос: не разъехалось ли поведение.
ЗАПУСК: python3 bench/stretched-cache/versions.py
Пути к сборкам — в DE_REDIS_BUILDS, через запятую, вида «имя=каталог/bin».
По умолчанию берутся те, что нашлись в системе и в /usr/local.
"""
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 # noqa: E402
import cluster as clu # noqa: E402
import redis # noqa: E402
ONE_WAY_MS = 20.0
WRITES = 20
BURST = 200
PAD = 30
DEFAULT_BUILDS = [
("Redis 7.0.15", "/usr/bin"),
("Redis 8.10.1", "/usr/local/redis8101/bin"),
("Valkey 9.1.2", "/usr/local/valkey912/bin"),
]
def builds() -> list[tuple[str, str, str]]:
"""(подпись, путь к серверу, путь к cli) для каждой найденной сборки."""
raw = os.environ.get("DE_REDIS_BUILDS")
pairs = []
if raw:
for item in raw.split(","):
name, _, path = item.partition("=")
pairs.append((name.strip(), path.strip()))
else:
pairs = DEFAULT_BUILDS
out = []
for name, path in pairs:
for server, cli in (("redis-server", "redis-cli"), ("valkey-server", "valkey-cli")):
s, c = os.path.join(path, server), os.path.join(path, cli)
if os.path.exists(s) and os.path.exists(c):
out.append((name, s, c))
break
return out
def free_port(limit: int = 55535) -> int:
for _ in range(500):
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
if port < limit:
return port
raise RuntimeError("не нашлось свободного порта")
def start(server: str, port: int, dirname: str, extra: list[str] | None = None):
proc = subprocess.Popen([
server, "--port", str(port), "--dir", dirname,
"--save", "", "--appendonly", "no",
"--logfile", os.path.join(dirname, "server.log"),
] + (extra or []))
deadline = time.time() + 20
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"сервер на порту {port} не поднялся")
def stop(*procs) -> None:
for proc in procs:
if proc is None:
continue
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
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 wait_sync(client: redis.Redis, timeout: float = 25.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 pair_checks(server: str) -> dict[str, str]:
"""Пункты 1-4: всё, для чего хватает мастера и реплики за каналом."""
out: dict[str, str] = {}
dir_a, dir_b = tempfile.mkdtemp(prefix="v-a-"), tempfile.mkdtemp(prefix="v-b-")
port_a, port_b = free_port(), free_port()
proc_a = proc_b = None
link = None
try:
proc_a, proc_b = start(server, port_a, dir_a), start(server, port_b, dir_b)
a = redis.Redis(port=port_a, decode_responses=True)
b = redis.Redis(port=port_b, decode_responses=True)
info = a.info("server")
# У Valkey поле redis_version — это версия СОВМЕСТИМОСТИ (7.2.4), а не
# своя. Своя лежит в valkey_version, и печатать надо её, иначе колонка
# врёт ровно про то, ради чего этот блок и поставлен.
out["версия"] = info.get("valkey_version") or info.get("redis_version", "?")
link = DelayedLink(port_a, delay_ms=ONE_WAY_MS)
b.replicaof("127.0.0.1", link.port)
wait_sync(b)
time.sleep(0.6)
a.set("warm", "1")
a.wait(1, 5000)
# 1. Мастер не ждёт реплику: обычная запись стоит доли миллисекунды,
# хотя реплика за двадцатью миллисекундами.
plain = median_ms(lambda i: a.set(f"p:{i}", i), WRITES)
out["обычная запись, мс"] = f"{plain:.2f}"
out["мастер ждёт реплику"] = "нет" if plain < ONE_WAY_MS / 2 else "ДА"
# 2. Видимость записи на реплике.
a.set("vis", "x0")
time.sleep(0.5)
t0 = time.perf_counter()
a.set("vis", "x1")
while time.perf_counter() - t0 < 5:
if b.get("vis") == "x1":
break
time.sleep(0.0005)
out["видна на реплике через, мс"] = f"{(time.perf_counter() - t0) * 1000:.1f}"
# 3. WAIT.
def with_wait(i: int) -> None:
a.set(f"w:{i}", i)
a.wait(1, 5000)
waited = median_ms(with_wait, WRITES)
out["WAIT 1, мс"] = f"{waited:.2f}"
out["WAIT / круг"] = f"{waited / (2 * ONE_WAY_MS):.2f}"
# 4. Потери при разрыве сразу после залпа.
a.flushall()
time.sleep(0.3)
for i in range(BURST):
a.set(f"burst:{i}", i)
link.cut()
time.sleep(1.0)
b.replicaof("no", "one")
on_replica = sum(1 for i in range(BURST) if b.get(f"burst:{i}") is not None)
out[f"потеряно из {BURST} при разрыве"] = str(BURST - on_replica)
finally:
if link is not None:
link.cut()
stop(proc_a, proc_b)
return out
def cluster_checks(server: str, cli: str) -> dict[str, str]:
"""Пункты 5-6: расклад B и судьба чтений в отказавшем кластере."""
out: dict[str, str] = {}
clu.SERVER, clu.CLI = server, cli
for reads_when_down in ("no", "yes"):
c = clu.Cluster()
try:
ports = c.start(6)
# Настройка ставится на все узлы уже поднятого кластера: она
# изменяемая, и отдельный кластер ради неё поднимать не нужно.
for p in ports:
clu.conn(p).config_set("cluster-allow-reads-when-down", reads_when_down)
pairing = clu.pairing_a(ports)
c.form(pairing)
dc1, dc2 = clu.dcs_b(ports, pairing)
cc = redis.RedisCluster(host="127.0.0.1", port=ports[0], decode_responses=True)
keys = [f"k:{i}" for i in range(20)]
for k in keys:
cc.set(k, "v")
cc.close()
c.stop(dc2)
time.sleep(clu.SETTLE_S)
states = [clu.state_of(p) for p in dc1]
answers = [clu.can_read(p, "dc:probe") for p in dc1]
served = sum(1 for p in dc1 for k in keys
if clu.can_read(p, k) == "ответил")
if reads_when_down == "no":
out["расклад B: состояние половины с большинством"] = \
"/".join(sorted(set(states)))
out["расклад B: ответов на GET"] = \
"нет" if all(a == "CLUSTERDOWN" for a in answers) else "есть"
out[f"reads-when-down {reads_when_down}: отдано ключей из 60"] = str(served)
finally:
c.destroy()
return out
def main() -> None:
found = builds()
if not found:
print("не найдено ни одной сборки; укажите DE_REDIS_BUILDS")
return
print("одни и те же опыты на разных сборках; канал между ДЦ "
f"{ONE_WAY_MS:.0f} мс в одну сторону")
print("проверяются утверждения статьи, а не производительность")
print()
print("15. ТЕ ЖЕ УТВЕРЖДЕНИЯ НА ТЕКУЩИХ СБОРКАХ")
print("-" * 66)
results: list[tuple[str, dict[str, str]]] = []
for name, server, cli in found:
data = pair_checks(server)
data.update(cluster_checks(server, cli))
results.append((name, data))
keys: list[str] = []
for _, data in results:
for k in data:
if k not in keys:
keys.append(k)
width = max(len(k) for k in keys) + 2
header = " " * (width + 2) + "".join(f"{name:>16}" for name, _ in results)
print(header)
for k in keys:
line = f" {k:<{width}}" + "".join(f"{data.get(k, '—'):>16}" for _, data in results)
print(line)
print()
print(" Как это читать. Колонки не обязаны совпадать по числам: сборки")
print(" разные, машина одна, и абсолютные миллисекунды тут ничего не")
print(" значат. Значение имеют строки, где написаны СВОЙСТВА: ждёт ли")
print(" мастер реплику, во сколько кругов обходится WAIT, теряются ли")
print(" подтверждённые записи, отказывает ли половина с большинством")
print(" мастеров и меняет ли reads-when-down судьбу чтений.")
print()
print(" Именно на этих строках держатся выводы статьи, и именно их")
print(" разбор просил не считать неизменными только потому, что они")
print(" однажды совпали с документацией.")
if __name__ == "__main__":
main()