MEASUREMENT
bench/valkey-cluster/boundaries.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/valkey-clustering
- 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
236 lines"""Границы трёх утверждений статьи — блоки 17-19.
ЗАЧЕМ ЭТОТ СКРИПТ. Разбор снаружи указал на три места, где статья сказала
шире, чем измерила. Все три можно не переписать словами, а перемерить, и
здесь они перемерены:
1. «Реплика не повысится НИКОГДА» — это верно при значении
`cluster-replica-validity-factor` по умолчанию. Настройка существует
именно затем, чтобы это менять, и блок 17 показывает, что при нуле ответ
другой.
2. «Кворум выполнен, а переключения нет» — хорошо, но в проде такое надо
уметь проверять ЗАРАНЕЕ, а не по факту. Для этого есть отдельная команда,
и блок 18 показывает, что она отвечает по обе стороны от границы.
3. «Узел данных про топологию не знает ничего» — слишком широко. Про
авторитетное мнение службы он действительно не знает, а вот про свою пару
репликации знает вполне; блок 19 печатает, что именно.
ЗАПУСК: python3 bench/valkey-cluster/boundaries.py
"""
from __future__ import annotations
import os
import re
import time
from common import (
CLUSTER_NODE_TIMEOUT_MS,
Cluster,
cli,
cluster_state,
free_port,
head,
link_status,
note,
preamble,
role_of,
row,
)
SENTINEL_NAME = "cache"
PAD = 40
def replica_of(ports: list[int], master_port: int) -> int | None:
raw = cli(ports[0], "cluster", "nodes")
master_id = None
for line in raw.splitlines():
f = line.split()
if f[1].startswith(f"127.0.0.1:{master_port}@"):
master_id = f[0]
if master_id is None:
return None
for line in raw.splitlines():
f = line.split()
if "slave" in f[2] and f[3] == master_id:
return int(f[1].split("@")[0].split(":")[1])
return None
def block17(cluster: Cluster) -> None:
head(17, "ТО ЖЕ ОКНО, НО С ОТКЛЮЧЁННОЙ ПРОВЕРКОЙ СВЕЖЕСТИ")
# Кластер поднимается с validity-factor 0 и роняется внутри того же окна,
# что и в блоке 12: единственное отличие — одна настройка.
ports = [free_port() for _ in range(6)]
for p in ports:
cluster.start_data(p, cluster=True, extra=["--cluster-replica-validity-factor", "0"])
cluster.form_cluster_raw(ports, replicas=1)
row("cluster-replica-validity-factor",
cli(ports[0], "config", "get", "cluster-replica-validity-factor").split()[-1], pad=PAD)
row("cluster-node-timeout", f"{CLUSTER_NODE_TIMEOUT_MS} мс", pad=PAD)
print()
deadline = time.time() + 60
while time.time() < deadline and not all(cluster_state(p) == "ok" for p in ports):
time.sleep(0.05)
masters = [p for p in ports if role_of(p) == "master"]
victim = masters[0]
rep = replica_of(ports, victim)
row("мастер, который упадёт", f"127.0.0.1:{victim}", pad=PAD)
row("его реплика", f"127.0.0.1:{rep}", pad=PAD)
row("master_link_status реплики", link_status(rep), pad=PAD)
print()
cluster.kill(victim)
print(f" t=0.00 мастер {victim} убит, ждём переключения 40 с")
t0 = time.time()
promoted = False
last = None
while time.time() - t0 < 40:
state = cluster_state(next(p for p in ports if p != victim))
role = role_of(rep)
now = (state, role)
if now != last:
print(f" t={time.time() - t0:5.2f} состояние кластера {state}, роль реплики {role}")
last = now
if role == "master":
promoted = True
break
time.sleep(0.2)
print()
row("реплика повысилась", "да" if promoted else "НЕТ, за 40 с", pad=PAD)
note(
"""
Тот же опыт, что в блоке 12, с единственным отличием в одной строке
конфига — и другой исход. Значит, «никогда» из блока 12 относится не к
Valkey вообще, а к значению `cluster-replica-validity-factor` по
умолчанию: проверку свежести данных реплики можно отключить, и тогда
она согласится повыситься, не завершив ни одной синхронизации.
Это не совет так делать. Проверка стоит там не зря: реплика, никогда
не синхронизировавшаяся с мастером, повышается ПУСТОЙ, и переключение
такого рода лечит доступность ценой данных. Но знать, что поведение
настраивается, надо — иначе блок 12 читается как неустранимое
свойство, а он про настройку.
"""
)
for p in ports:
cluster.kill(p)
def block18(cluster: Cluster) -> None:
head(18, "КАК ПРОВЕРИТЬ КВОРУМ И БОЛЬШИНСТВО ЗАРАНЕЕ, А НЕ ПО ФАКТУ")
master = free_port()
replicas = [free_port(), free_port()]
sentinels = [free_port() for _ in range(5)]
cluster.start_data(master)
for r in replicas:
cluster.start_data(r, replicaof=master)
for s in sentinels:
cluster.start_sentinel(s, master, quorum=2, name=SENTINEL_NAME)
time.sleep(5)
watcher = sentinels[0]
row("sentinel в наборе / кворум", "5 / 2", pad=PAD)
row("живых sentinel", "5", pad=PAD)
row("SENTINEL CKQUORUM cache",
cli(watcher, "sentinel", "ckquorum", SENTINEL_NAME), pad=PAD)
print()
for s in sentinels[2:]:
cluster.kill(s)
time.sleep(8)
row("живых sentinel", "2", pad=PAD)
row("SENTINEL CKQUORUM cache",
cli(watcher, "sentinel", "ckquorum", SENTINEL_NAME), pad=PAD)
note(
"""
Та же граница, что в блоке 13, но увиденная заранее и одной командой.
Пока наблюдателей большинство, ответ утвердительный и в нём названы
оба условия. Когда большинство потеряно, команда отвечает отказом — и
отказывает она именно по второму условию, хотя кворума из настройки
хватает.
Практическая ценность в том, что это можно спрашивать постоянно, а не
выяснять в момент аварии. Ответ на эту команду — готовый признак для
наблюдения: он отвечает на вопрос «переключимся ли мы, если сейчас
упадёт мастер», а не «всё ли хорошо прямо сейчас».
"""
)
for p in [master] + replicas + sentinels[:2]:
cluster.kill(p)
def block19(cluster: Cluster) -> None:
head(19, "ЧТО УЗЕЛ ПОД SENTINEL ВСЁ-ТАКИ ЗНАЕТ О ТОПОЛОГИИ")
master = free_port()
replicas = [free_port(), free_port()]
sentinel = free_port()
cluster.start_data(master)
for r in replicas:
cluster.start_data(r, replicaof=master)
cluster.start_sentinel(sentinel, master, quorum=1, name=SENTINEL_NAME)
time.sleep(4)
info = cli(master, "info", "replication")
print(" мастер про себя (INFO replication):")
for line in info.splitlines():
line = line.strip()
if re.match(r"^(role|connected_slaves|slave\d+):", line):
print(" " + line)
print()
print(" реплика про себя:")
info_r = cli(replicas[0], "info", "replication")
for line in info_r.splitlines():
line = line.strip()
if re.match(r"^(role|master_host|master_port|master_link_status):", line):
print(" " + line)
print()
row("мастер: ROLE (первое поле)", cli(master, "role").splitlines()[0], pad=PAD)
row("мастер: CLUSTER INFO", cli(master, "cluster", "info").splitlines()[0], pad=PAD)
row("мастер: кто мастер по мнению службы",
cli(master, "sentinel", "get-master-addr-by-name", SENTINEL_NAME).splitlines()[0], pad=PAD)
note(
"""
Уточнение к формулировке, которая в первой версии статьи была шире
измеренного. Узел под sentinel знает не «ничего» — он знает свою пару
репликации: мастер перечисляет подключённые реплики, реплика называет
адрес мастера и состояние канала. Это настоящая топологическая
информация, и наблюдению она вполне годится.
Чего он не знает — так это АВТОРИТЕТНОГО ответа на вопрос «кто мастер
этой службы сейчас». Своё мнение у него есть, и в момент переключения
оно бывает устаревшим: ровно это и происходит в блоке 10, где
вернувшийся узел искренне считает мастером себя. Отказ на
`SENTINEL get-master-addr-by-name` в последней строке — не про
отсутствие данных, а про то, что эта команда принадлежит другому
набору и другой службе.
"""
)
def main() -> None:
preamble("три утверждения статьи, проверенные на границах")
cluster = Cluster()
try:
block17(cluster)
block18(cluster)
block19(cluster)
finally:
cluster.stop_all()
_ = os
if __name__ == "__main__":
main()