MEASUREMENT
bench/valkey-cluster/discovery.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.
- How to run it
python3 bench/valkey-cluster/discovery.py # блоки 1-4 python3 bench/valkey-cluster/apidiff.py # блоки 5-7 python3 bench/valkey-cluster/failover.py # блоки 8-10 python3 bench/valkey-cluster/notready.py # блоки 11-12 python3 bench/valkey-cluster/quorum.py # блок 13 python3 bench/valkey-cluster/databases.py # блоки 14-16 python3 bench/valkey-cluster/boundaries.py # блоки 17-19
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
Замеры для статьи «Два способа собрать Valkey из нескольких узлов»
| Файл | Что делает |
|---|---|
common.py |
подъём узлов в обоих режимах, разговор с ними, печать блоков. Общий модуль, сам ничего не печатает |
discovery.py |
как клиент находит узел: MOVED против SENTINEL get-master-addr-by-name, и что клиент делает на самом деле — блоки 1–4 |
apidiff.py |
какие команды в кластере перестают работать и почему — блоки 5–7 |
failover.py |
отказ мастера в обоих режимах глазами клиента, и что бывает, когда старый мастер возвращается — блоки 8–10 |
notready.py |
окно между «кластер собран» и «кластер переживёт отказ» — блоки 11–12 |
quorum.py |
кворум против большинства: почему одно число не заменяет другое — блок 13 |
databases.py |
нумерованные базы в кластере: два кластера с разным cluster-databases — блоки 14–16 |
boundaries.py |
три утверждения статьи, проверенные на границах — блоки 17–19 |
python3 bench/valkey-cluster/discovery.py # блоки 1-4
python3 bench/valkey-cluster/apidiff.py # блоки 5-7
python3 bench/valkey-cluster/failover.py # блоки 8-10
python3 bench/valkey-cluster/notready.py # блоки 11-12
python3 bench/valkey-cluster/quorum.py # блок 13
python3 bench/valkey-cluster/databases.py # блоки 14-16
python3 bench/valkey-cluster/boundaries.py # блоки 17-19
Нужен собранный Valkey — путь к нему берётся из VALKEY_BIN (по умолчанию
/usr/local/valkey912/bin). Узлы поднимаются во временных каталогах на
случайных портах и убиваются в finally; после себя скрипты не оставляют ни
процессов, ни файлов. Весь набор идёт около шести минут: переключений надо
дождаться несколько раз, и торопить их нечем.
Откуда здесь Valkey 9.1.2
В системных пакетах лежит 7.2.13 — ветка, к которой половина того, что стоит
писать про кластер, уже не относится. Обычный путь за свежей версией закрыт:
dl.google.com и сайт проекта недоступны из этой среды. Зато доступен GitHub, а
Valkey собирается из исходников одной командой:
git clone --depth 1 --branch 9.1.2 https://github.com/valkey-io/valkey.git /tmp/valkey912
cd /tmp/valkey912 && make -j2 BUILD_TLS=no
install -D /tmp/valkey912/src/valkey-{server,cli,benchmark} -t /usr/local/valkey912/bin
Отдельного двоичного файла для sentinel нет и не нужно: это тот же
valkey-server, запущенный с конфигом и флагом --sentinel. Свойство, а не
деталь сборки, — и в блоке 7 видно, чем режимы при этом отличаются.
Что здесь важно прочитать правильно
Абсолютные секунды не переносятся никуда. Узел считается упавшим через
секунду молчания (down-after-milliseconds 1000) вместо тридцати по умолчанию,
а cluster-node-timeout стоит 2000 мс вместо 15 000. Это сделано ровно затем,
чтобы набор укладывался в минуты. Содержательны порядок событий, текст ответов
сервера и знак разницы — но не число секунд.
Порты выбираются ниже 55535, и это не придирка. Узел кластера слушает два порта: обычный и шинный, ровно на десять тысяч больше. Порт из эфемерного диапазона даёт шинный порт больше 65535, и узел не поднимается вовсе.
Опрос идёт «глупым» клиентом. Везде, где печатается ответ сервера, он снят
через valkey-cli без флага -c. Библиотека-клиент прячет ровно то, что здесь
интересно: сама ходит за картой слотов, сама повторяет запрос, сама спрашивает
sentinel. Настоящие клиентские библиотеки используются только в блоках 3 и 4 —
там вопрос как раз в том, что делает клиент, и считают это сами узлы.
Что получилось
Как клиент находит узел
| нативный кластер | Sentinel | |
|---|---|---|
| кто знает топологию | каждый узел данных | отдельная служба |
| что клиент спрашивает | любой узел, обычной командой | SENTINEL get-master-addr-by-name |
| как узнаёт про чужой ключ | MOVED 12182 127.0.0.1:43425 |
никак: вопрос не к узлу |
| что нужно в конфиге клиента | один живой адрес узла | адреса sentinel и имя набора |
В блоке 3 видно, что клиент кластера, получив ОДИН адрес из шести, сходил за
CLUSTER SLOTS и дальше писал напрямую нужному узлу. В блоке 4 порядок
обратный: сначала вопрос службе, потом команда данным.
Что ломается в кластере из привычного
| команда | в кластере | под sentinel |
|---|---|---|
MSET foo 1 bar 2 |
CROSSSLOT Keys in request don't hash to the same slot |
OK |
MSET {u}:a 1 {u}:b 2 |
работает: общая скобка кладёт ключи в один слот | OK |
SELECT 1 |
ERR DB index is out of range — но см. ниже |
OK |
SWAPDB 0 1 |
ERR SWAPDB is not allowed in cluster mode |
OK |
Две формы отказа различаются не случайно: SWAPDB запрещён как таковой, а
SELECT 1 — выход за границу. За какую именно границу, блок 6 сам по себе не
говорит, и на этом первая версия статьи ошиблась.
Нумерованные базы: настройка, а не режим
Из SELECT 1 → ERR DB index is out of range в первой версии был сделан вывод
«в кластере база ровно одна». Это правило Redis и Valkey до 9.0; в Valkey 9.0
нумерованные базы в кластере появились, а их число задаёт неизменяемая
настройка cluster-databases со значением по умолчанию 1. Блоки 14–16
поднимают два кластера, отличающихся ровно этой строкой:
| кластер А | кластер Б | |
|---|---|---|
cluster-databases |
1 | 16 |
SELECT 1 |
ERR DB index is out of range |
OK |
SELECT 15 |
ERR DB index is out of range |
OK |
SELECT 16 |
ERR DB index is out of range |
ERR DB index is out of range |
Номер базы в адресацию не входит: один ключ в базах 0 и 1 держит разные
значения при одном слоте 12182. А вот чего базы не дают — общего на кластер
представления о себе: DBSIZE и SCAN считают только ту часть базы, что лежит
на узле, куда пришла команда, SWAPDB остаётся запрещён.
Границы трёх утверждений
Блоки 17–19 перемеряют места, где статья сказала шире измеренного:
«никогда» из блока 12 снимается настройкой cluster-replica-validity-factor 0
(реплика повышается за четыре секунды, но пустой); границу кворума и
большинства заранее показывает SENTINEL CKQUORUM; узел под sentinel знает не
«ничего», а свою пару репликации — не знает он только авторитетного ответа
службы.
Отказ мастера
| нативный кластер | Sentinel | |
|---|---|---|
| кто замечает | сами узлы, по шине | отдельные наблюдатели |
| кто решает | большинство мастеров | большинство sentinel |
| что получает клиент в этот момент | CLUSTERDOWN, потом MOVED на новый адрес |
молчание старого адреса |
| как клиент узнаёт новый адрес | из ответа любого живого узла | только спросив sentinel |
| вернувшийся старый мастер | сразу реплика, отдаёт MOVED |
мастер на 10 с, принимает записи |
Последняя строка — самое опасное место режима Sentinel и содержание блока 10.
Вернувшийся узел поднимается мастером, отвечает OK на записи, а после
перевода в реплику полная синхронизация их стирает. Клиент при этом не видит ни
одной ошибки.
Два состояния готовности
Блоки 11 и 12 — про находку, ради которой стоило ронять узлы вручную. Между
«слоты розданы» (cluster_state:ok) и «есть кому повышаться»
(master_link_status:up у реплик) прошло 6,07 секунды. Мастер, упавший
внутри этого окна, не заменяется НИКОГДА: у реплики, ни разу не подключавшейся,
отметка «когда упал канал» осталась нулевой, возраст её данных считается от
начала эпохи Unix, и она отказывается повышаться:
Currently unable to failover: Disconnected from primary for longer than
allowed. Please check the 'cluster-replica-validity-factor' configuration
option.
Условие видно в исходнике (src/cluster_legacy.c):
if (server.repl_state == REPL_STATE_CONNECTED) {
data_age = (mstime_t)(server.unixtime - server.primary->last_interaction) * 1000;
} else {
data_age = (mstime_t)(server.unixtime - server.repl_down_since) * 1000;
}
Практический вывод про эксплуатацию: «кластер поднялся» и «кластер держит
отказ» — разные проверки, и вторая смотрит на master_link_status каждой
реплики.
Кворум и большинство
Пять sentinel, кворум 2, погашены три. Кворум выполнен — и переключения всё равно нет:
+odown master cache 127.0.0.1 48147 #quorum 2/2
+try-failover master cache 127.0.0.1 48147
+vote-for-leader c92cd70b21a9dd26ec40e67185371e78ee502f9a 1
-failover-abort-not-elected master cache 127.0.0.1 48147
Стоило поднять одного из погашенных — большинство появилось, и переключение состоялось за две секунды. Кворум при этом не менялся вовсе.
Источники
- Valkey. Cluster specification — https://valkey.io/topics/cluster-spec/
- Valkey. High availability with Valkey Sentinel — https://valkey.io/topics/sentinel/
- Valkey. Sentinel client spec — https://valkey.io/topics/sentinel-clients/
- Valkey. Atomic slot migration — https://valkey.io/topics/atomic-slot-migration/
- Valkey. Numbered Databases in Valkey 9.0 — https://valkey.io/blog/numbered-databases/
- Redis. CLUSTER MIGRATION — https://redis.io/docs/latest/commands/cluster-migration/
- Исходники Valkey 9.1.2 — https://github.com/valkey-io/valkey/tree/9.1.2
Script
304 lines"""Как клиент находит узел: два совершенно разных механизма — блоки 1-4.
ГЛАВНЫЙ ВОПРОС ЭТОГО СКРИПТА. «Куда отправить команду» — в двух режимах на
него отвечают разные стороны. В нативном кластере отвечает САМ УЗЕЛ ДАННЫХ:
клиент приходит с ключом, а узел, если ключ не его, возвращает не данные, а
адрес. В режиме Sentinel узел данных не отвечает на этот вопрос вообще — ни
да, ни нет; адрес знает отдельная служба, к которой надо сходить заранее.
ПОЧЕМУ ЭТО ВАЖНЕЕ ВСЕХ ОСТАЛЬНЫХ ОТЛИЧИЙ. Из него следует и разный протокол
подключения, и разное поведение при отказе, и разный набор доступных команд.
Все три разбираются дальше, в других блоках; здесь показан корень.
ЧТО ЗДЕСЬ ИЗМЕРЯЕТСЯ, А ЧТО ПРОСТО ПЕЧАТАЕТСЯ. Дословные ответы сервера —
печатаются (это поведение, а не измерение, и воспроизводится точно). А блоки 3
и 4 именно МЕРЯЮТ: по счётчикам самих узлов видно, сколько обращений и к кому
сделал настоящий клиент, пока выполнял одну-единственную команду.
ЗАПУСК: python3 bench/valkey-cluster/discovery.py
Нужен собранный Valkey (см. README), около минуты времени.
"""
from __future__ import annotations
import re
import time
import redis
from redis.cluster import RedisCluster
from redis.sentinel import Sentinel
from common import (
Cluster,
cli,
free_port,
head,
master_addr,
note,
preamble,
role_of,
row,
)
SENTINEL_NAME = "cache"
def commands_processed(port: int) -> int:
"""Сколько команд узел обработал с запуска.
Берётся из INFO stats, а не из commandstats, по одной причине: у sentinel
нет команды CONFIG, а значит и RESETSTAT, — зато INFO есть у обоих. Раз
счётчик нельзя обнулить, считается РАЗНИЦА до и после.
"""
out = cli(port, "info", "stats")
m = re.search(r"total_commands_processed:(\d+)", out)
return int(m.group(1)) if m else -1
def named_commands(port: int) -> dict[str, int]:
"""Какие именно команды узел получил — по именам, из INFO commandstats."""
out = cli(port, "info", "commandstats")
res: dict[str, int] = {}
for line in out.splitlines():
m = re.match(r"cmdstat_([\w|\-]+):calls=(\d+)", line.strip())
if m:
res[m.group(1)] = int(m.group(2))
return res
def block1(cluster: Cluster, ports: list[int]) -> None:
head(1, "КЛАСТЕР ОТВЕЧАЕТ НЕ ДАННЫМИ, А АДРЕСОМ")
# Один и тот же ключ спрашивается у ВСЕХ шести узлов по очереди. Флага -c
# нет: клиент нарочно «глупый», он никуда не переходит и показывает то,
# что сервер сказал.
key = "foo"
slot = cli(ports[0], "cluster", "keyslot", key)
row(f"CLUSTER KEYSLOT {key}", slot)
print()
for port in ports:
answer = cli(port, "set", key, "1")
role = "мастер" if role_of(port) == "master" else "реплика"
row(f"узел {port} ({role}): SET {key} 1", answer)
note(
"""
Пять узлов из шести ответили одинаково и не данными. MOVED — это не
ошибка в обычном смысле: узел не отказал, а сказал, КУДА идти, и
назвал слот. Клиент кластера на таком ответе чинит свою карту слотов
и повторяет команду по названному адресу; «глупый» клиент, как
здесь, просто видит текст.
Адресация тут вообще не про узлы, а про слоты: ключ отображается в
один из 16 384 слотов, а уже слот принадлежит узлу. Поэтому
переезд данных между узлами не меняет ни одного ключа — меняет
владельца слота.
"""
)
def block2(cluster: Cluster, master: int, replica: int, sentinels: list[int]) -> None:
head(2, "В РЕЖИМЕ SENTINEL УЗЕЛ ДАННЫХ ПРО ТОПОЛОГИЮ НЕ ЗНАЕТ НИЧЕГО")
row(f"узел данных {master}: CLUSTER INFO", cli(master, "cluster", "info").splitlines()[0])
row(f"узел данных {master}: роль", role_of(master))
row(f"узел данных {replica}: роль", role_of(replica))
print()
row(f"sentinel {sentinels[0]}: SENTINEL get-master-addr-by-name",
cli(sentinels[0], "sentinel", "get-master-addr-by-name", SENTINEL_NAME).replace("\n", ":"))
row(f"sentinel {sentinels[0]}: сколько реплик знает",
str(len([1 for line in cli(sentinels[0], "sentinel", "replicas", SENTINEL_NAME).splitlines()
if line.strip() == "name"])))
row(f"sentinel {sentinels[0]}: сколько других sentinel знает",
str(len([1 for line in cli(sentinels[0], "sentinel", "sentinels", SENTINEL_NAME).splitlines()
if line.strip() == "name"])))
print()
row(f"sentinel {sentinels[0]}: CONFIG RESETSTAT", cli(sentinels[0], "config", "resetstat"))
note(
"""
Первая строка — весь ответ на вопрос «а нельзя ли спросить у самого
узла». Нельзя: узел данных вне кластера про кластер не знает и
честно об этом говорит. Он не знает и того, что за ним кто-то
наблюдает: роль он покажет свою (master или slave), а вот кто
мастер С ТОЧКИ ЗРЕНИЯ СИСТЕМЫ — вопрос не к нему.
Отвечает на него отдельная служба, и она не хранит данных. Последняя
строка показывает, насколько отдельная: у sentinel даже нет команды
CONFIG. Это не урезанный узел, это другой набор команд на том же
двоичном файле.
"""
)
def observe(ports: list[int], seconds: float, action=None) -> dict[int, tuple[int, list[str]]]:
"""Что узлы получили за окно времени: сколько команд и какие именно.
Окно, а не «до и после команды», — потому что в обоих режимах есть
постоянный фон: узлы кластера шлют друг другу heartbeat по шине, sentinel
опрашивает данные и друг друга. Без контрольного окна той же длины
клиентские команды не отличить от фоновых.
"""
before = {p: commands_processed(p) for p in ports}
named_before = {p: named_commands(p) for p in ports}
started = time.time()
if action is not None:
action()
left = seconds - (time.time() - started)
if left > 0:
time.sleep(left)
out: dict[int, tuple[int, list[str]]] = {}
for port in ports:
delta = commands_processed(port) - before[port]
names = named_commands(port)
added = sorted(
f"{k}×{v - named_before[port].get(k, 0)}"
for k, v in names.items()
if v - named_before[port].get(k, 0) > 0 and not k.startswith("info")
)
out[port] = (delta, added)
return out
WINDOW_S = 2.0
def block3(cluster: Cluster, ports: list[int]) -> None:
head(3, "ЧТО ДЕЛАЕТ НАСТОЯЩИЙ КЛИЕНТ КЛАСТЕРА ЗА ОДНУ КОМАНДУ")
seed = ports[-1]
def act() -> None:
# Клиенту даётся РОВНО ОДИН адрес из шести — и не мастер ключа.
rc = RedisCluster(host="127.0.0.1", port=seed, socket_timeout=5)
rc.set("foo", "1")
rc.close()
idle = observe(ports, WINDOW_S)
work = observe(ports, WINDOW_S, act)
row("клиенту дан один адрес из шести", f"127.0.0.1:{seed}", pad=40)
row("окно наблюдения", f"{WINDOW_S:.0f} с, и столько же вхолостую для контроля", pad=40)
print()
row("узел", "вхолостую с клиентом что пришло за второе окно", pad=24)
for port in ports:
mark = " (адрес клиенту)" if port == seed else ""
row(f"{port}{mark}",
f"{idle[port][0]:>9} {work[port][0]:>10} {' '.join(work[port][1])}", pad=24)
note(
"""
Считают сами узлы: это их счётчик обработанных команд. Контрольное
окно вхолостую нужно потому, что узлы кластера всё время говорят друг
с другом по шине; клиентское видно только на его фоне.
Видно, что клиент сделал на самом деле: сходил к единственному
известному адресу за картой слотов (cluster|slots) и уже по карте
отправил запись туда, куда надо (set). Команды hello и
client|setinfo — это клиент представляется; они к поиску узла
отношения не имеют.
Два лишних на первый взгляд следа стоит прочитать правильно. Пара
replconf — это разговор мастера с его репликой, он идёт и без
клиента. А select с set на ЕЩЁ ОДНОМ узле — это та же самая запись,
доехавшая до реплики по репликации: клиент к этому узлу не
подключался вовсе. Команды в счётчике узла — не обязательно команды
от клиента, и здесь это видно прямо.
Отсюда свойство, которое обычно и имеют в виду под «кластер сам
разбирается»: знать все узлы клиенту не нужно, достаточно одного
живого. Карту он получит от него и дальше будет ходить напрямую —
посредника между клиентом и данными в этом режиме нет вообще.
"""
)
def block4(cluster: Cluster, master: int, replicas: list[int], sentinels: list[int]) -> None:
head(4, "ЧТО ДЕЛАЕТ НАСТОЯЩИЙ КЛИЕНТ SENTINEL ЗА ОДНУ КОМАНДУ")
watched = [master] + replicas + sentinels
def act() -> None:
# Клиенту даётся адрес ОДНОГО sentinel и имя набора — и ни одного
# адреса данных. Это и есть весь конфиг такого клиента в жизни.
sen = Sentinel([("127.0.0.1", sentinels[0])], socket_timeout=5)
conn = sen.master_for(SENTINEL_NAME, socket_timeout=5)
conn.set("foo", "1")
conn.close()
idle = observe(watched, WINDOW_S)
work = observe(watched, WINDOW_S, act)
row("клиенту дан один sentinel и имя набора",
f"127.0.0.1:{sentinels[0]} / {SENTINEL_NAME}", pad=40)
row("окно наблюдения", f"{WINDOW_S:.0f} с, и столько же вхолостую для контроля", pad=40)
print()
row("узел", "вхолостую с клиентом что пришло за второе окно", pad=32)
for port in watched:
kind = ("sentinel" if port in sentinels else
"мастер" if port == master else "реплика")
mark = " (адрес клиенту)" if port == sentinels[0] else ""
row(f"{kind} {port}{mark}",
f"{idle[port][0]:>9} {work[port][0]:>10} {' '.join(work[port][1])}", pad=32)
note(
"""
Порядок обратный третьему блоку: сначала вопрос СЛУЖБЕ, потом
команда данным. Узел данных не участвовал в поиске самого себя и не
мог бы: у него для этого нет ни команды, ни сведений.
Фон здесь заметнее, чем в кластере, и в нём же видно устройство
службы. Sentinel непрерывно опрашивает данные (ping) — отсюда
колонка «вхолостую». А publish на узлах ДАННЫХ — это как раз то, чем
sentinel находит других sentinel: они публикуют себя в канал самого
узла и там же друг друга слышат. Отдельного реестра у них нет;
общая точка встречи — наблюдаемый узел.
Имён команд у sentinel нет ни в одной колонке по той же причине, что
и CONFIG в блоке 2: у него нет commandstats, только общий счётчик.
Практическое следствие, которое из этого растёт: в режиме Sentinel
клиент обязан ходить через библиотеку, умеющую sentinel, и держать
адреса sentinel в конфиге. Если он подключится прямо к адресу
данных — он будет работать ровно до первого переключения, а потом
молча начнёт писать не туда. Что именно при этом происходит, видно
в блоке про возвращение старого мастера.
"""
)
def main() -> None:
preamble("нативный кластер (6 узлов) и связка Sentinel (1 мастер + 2 реплики + 3 sentinel)")
cluster = Cluster()
try:
# --- нативный кластер ---
cports = [free_port() for _ in range(6)]
for p in cports:
cluster.start_data(p, cluster=True)
cluster.form_cluster(cports, replicas=1)
# --- связка Sentinel ---
m = free_port()
reps = [free_port(), free_port()]
sents = [free_port() for _ in range(3)]
cluster.start_data(m)
for r in reps:
cluster.start_data(r, replicaof=m)
for s in sents:
cluster.start_sentinel(s, m, quorum=2, name=SENTINEL_NAME)
time.sleep(3)
# Оба хозяйства подняты сразу, и блоки печатаются по порядку: файл
# прогона читается сверху вниз, а не в порядке, удобном скрипту.
block1(cluster, cports)
block2(cluster, m, reps[0], sents)
block3(cluster, cports)
block4(cluster, m, reps, sents)
finally:
cluster.stop_all()
if __name__ == "__main__":
main()