Deep Engineering

ЗАМЕР

bench/valkey-cluster/failover.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/system-design/caching/valkey-clustering
Как запустить
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 из нескольких узлов»

Файл Что делает
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

Стоило поднять одного из погашенных — большинство появилось, и переключение состоялось за две секунды. Кворум при этом не менялся вовсе.

Источники

Скрипт

292 строк
"""Что видит клиент, пока падает мастер — блоки 8-10.

ГЛАВНЫЙ ВОПРОС. «Отказоустойчивость» в обоих режимах есть, но переживает
клиент эти секунды по-разному, и разница не в длительности, а в ТЕКСТЕ, который
он получает. В кластере ему отвечает живой узел — и отвечает так, что клиент
понимает, что делать. Под sentinel ему не отвечает никто: старый адрес просто
молчит, а новый клиент узнает, только если сходит спросить.

ПОЧЕМУ ЗДЕСЬ ОПРАШИВАЕТСЯ СЕРВЕР, А НЕ КЛИЕНТСКАЯ БИБЛИОТЕКА. Библиотека
прячет ровно то, что здесь интересно: она сама повторяет, сама ходит к
sentinel, сама ждёт. Опрос идёт «глупым» клиентом раз в 100 мс, и печатается
дословно то, что пришло по проводу.

ЧТО ЗДЕСЬ ИЗМЕРЯЕТСЯ. Секунды до восстановления — измеряются, но они целиком
про настройки этого замера (узел считается упавшим через секунду молчания
вместо тридцати по умолчанию). Переносится не число, а ПОРЯДОК событий и
текст ответов.

ЗАПУСК: python3 bench/valkey-cluster/failover.py
Занимает пару минут: переключения надо дождаться дважды.
"""

from __future__ import annotations

import time

from common import (
    CLUSTER_NODE_TIMEOUT_MS,
    DOWN_AFTER_MS,
    Cluster,
    cli,
    free_port,
    head,
    link_status,
    master_addr,
    note,
    preamble,
    role_of,
    row,
)

SENTINEL_NAME = "cache"
KEY = "foo"
POLL_S = 0.1
PAD = 30


def owner_of(port: int, key: str) -> str | None:
    """Кому принадлежит слот ключа, по мнению узла port."""
    answer = cli(port, "set", key, "1")
    if answer.startswith("MOVED"):
        return answer.split()[-1]
    if answer == "OK":
        return f"127.0.0.1:{port}"
    return None


def watch(probe_port: int, key: str, seconds: float, label: str) -> list[tuple[float, str]]:
    """Опрашивать один адрес раз в POLL_S и записывать СМЕНЫ ответа.

    Печатать каждый опрос бессмысленно — их сотни. Содержательны моменты,
    когда ответ изменился: именно их и видит клиент как «что-то произошло».
    """
    out: list[tuple[float, str]] = []
    t0 = time.time()
    last = None
    while time.time() - t0 < seconds:
        answer = cli(probe_port, "set", key, "1", timeout=2.0) or "(пусто)"
        answer = answer.splitlines()[0][:46]
        if answer != last:
            out.append((time.time() - t0, answer))
            last = answer
        time.sleep(POLL_S)
    _ = label
    return out


def block8(cluster: Cluster, ports: list[int]) -> None:
    head(8, "ОТКАЗ МАСТЕРА В КЛАСТЕРЕ ГЛАЗАМИ КЛИЕНТА")

    owner = owner_of(ports[0], KEY)
    owner_port = int(owner.split(":")[1])
    # Опрашивается ЖИВОЙ узел, не тот, что упадёт: вопрос статьи не «что
    # отвечает мёртвый адрес» (ничего), а «что узнаёт клиент, добравшийся до
    # живого узла».
    probe = next(p for p in ports if p != owner_port and role_of(p) == "master")

    row("слот ключа принадлежит", owner, pad=PAD)
    row("опрашивается живой узел", f"127.0.0.1:{probe}", pad=PAD)
    row("cluster-node-timeout", f"{CLUSTER_NODE_TIMEOUT_MS} мс", pad=PAD)
    print()

    cluster.kill(owner_port)
    print(f"  t=0.00  узел {owner_port} убит")
    for t, answer in watch(probe, KEY, 20.0, "cluster"):
        print(f"  t={t:5.2f}  {answer}")

    new_owner = owner_of(probe, KEY)
    print()
    row("слот перешёл к", new_owner or "никому", pad=PAD)

    # Вторая половина блока: поднять убитый узел обратно и посмотреть, кем он
    # себя считает. Это прямой ответ на то, что в режиме Sentinel оказалось
    # самым опасным местом (блок 10), и проверяется он здесь, а не берётся на
    # веру.
    print()
    cluster.start_data(owner_port, cluster=True)
    time.sleep(2.0)
    row("вернувшийся узел: его роль", role_of(owner_port), pad=PAD)
    row("вернувшийся узел: SET foo", cli(owner_port, "set", KEY, "1"), pad=PAD)

    note(
        """
        Клиент всё это время получал от живого узла ОСМЫСЛЕННЫЕ ответы: пока
        переключение не произошло — старый адрес слота, после него — новый.
        Гадать не пришлось ни секунды: узел, до которого клиент дозвонился,
        знает состояние всего кластера, потому что узлы непрерывно
        обмениваются им по шине. (Если заглянуть в промежуток поточнее, там
        бывает и CLUSTERDOWN — тоже осмысленный ответ: «слот сейчас не
        покрыт никем».)

        Заметьте, чего здесь НЕ произошло: никто не спрашивал внешнюю
        службу. Реплику повысили сами узлы, большинством голосов мастеров,
        и они же разнесли новость. Кластер сам себе и наблюдатель, и арбитр.

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


def block9(cluster: Cluster, master: int, replicas: list[int], sentinels: list[int]) -> None:
    head(9, "ОТКАЗ МАСТЕРА ПОД SENTINEL ГЛАЗАМИ КЛИЕНТА")

    row("мастер по мнению sentinel", f"127.0.0.1:{master}", pad=PAD)
    row("down-after-milliseconds", f"{DOWN_AFTER_MS} мс", pad=PAD)
    print()

    t0 = time.time()
    cluster.kill(master)
    print(f"  t=0.00  узел {master} убит")

    # Две стороны опрашиваются одновременно по очереди: что отвечает старый
    # адрес и что говорит sentinel.
    seen_addr = None
    last_old = None
    deadline = time.time() + 25
    while time.time() < deadline:
        old = cli(master, "set", KEY, "1", timeout=1.0) or "(молчит)"
        old = old.splitlines()[0][:46]
        if old != last_old:
            print(f"  t={time.time() - t0:5.2f}  старый адрес: {old}")
            last_old = old
        addr = master_addr(sentinels[0], SENTINEL_NAME)
        if addr and addr[1] != master:
            if addr != seen_addr:
                print(f"  t={time.time() - t0:5.2f}  sentinel называет мастером {addr[0]}:{addr[1]}")
                seen_addr = addr
            break
        time.sleep(POLL_S)

    if seen_addr:
        new_port = seen_addr[1]
        time.sleep(1.0)
        row("новый мастер отвечает на запись", cli(new_port, "set", KEY, "1"), pad=PAD)
        other = [r for r in replicas if r != new_port]
        if other:
            row(f"вторая реплика {other[0]}", cli(other[0], "set", KEY, "1"), pad=PAD)

    print()
    print("  что sentinel записал у себя в журнале:")
    for line in cluster.sentinel_log(sentinels[0]):
        for mark in ("+sdown master", "+odown master", "+try-failover", "+vote-for-leader",
                     "+elected-leader", "+promoted-slave", "+failover-state", "+switch-master"):
            if mark in line:
                print("    " + line.split(" # ")[-1].split(" * ")[-1][:64])
                break

    note(
        """
        Разница с предыдущим блоком не в секундах, а в том, ЧТО получил
        клиент. Старый адрес не отвечал вовсе — там никого нет. Ни один
        узел данных не сказал клиенту нового адреса и не мог: узлы под
        sentinel про свою пару не знают, а служба, которая знает, клиенту
        сама не звонит. Клиент обязан спросить сам.

        В журнале видна и вся процедура: сначала один sentinel решает, что
        мастер молчит (+sdown), потом кворум подтверждает (+odown), потом
        выбирается ведущий (+vote-for-leader, +elected-leader) и только он
        повышает реплику. Два шага — «сломалось» и «кто чинит» — здесь
        разнесены намеренно, и в следующем блоке видно, зачем.
        """
    )


def block10(cluster: Cluster, old_master: int, sentinels: list[int]) -> None:
    head(10, "СТАРЫЙ МАСТЕР ВЕРНУЛСЯ: САМОЕ ОПАСНОЕ МЕСТО РЕЖИМА SENTINEL")

    current = master_addr(sentinels[0], SENTINEL_NAME)
    row("мастер сейчас", f"{current[0]}:{current[1]}" if current else "неизвестен", pad=PAD)
    print()

    t0 = time.time()
    cluster.start_data(old_master)
    print(f"  t=0.00  узел {old_master} поднят обратно")

    # Ключевой вопрос: как долго вернувшийся узел считает себя мастером и
    # принимает записи, хотя мастер уже не он.
    demoted_at = None
    last = None
    deadline = time.time() + 30
    while time.time() < deadline:
        role = role_of(old_master)
        write = cli(old_master, "set", "who", "old", timeout=1.0)
        state = f"роль {role}, запись -> {write}"
        if state != last:
            print(f"  t={time.time() - t0:5.2f}  {state}")
            last = state
        if role == "slave":
            demoted_at = time.time() - t0
            break
        time.sleep(POLL_S)

    print()
    if demoted_at is None:
        row("за 30 с в реплику НЕ переведён", "", pad=PAD)
    else:
        row("переведён в реплику через", f"{demoted_at:.2f} с", pad=PAD)
    # Смотреть сразу после перевода нельзя: синхронизация с настоящим мастером
    # ещё идёт, и ключ в этот момент на месте. Ответ на вопрос «что стало с
    # записью» даёт только состояние ПОСЛЕ синхронизации.
    row("сразу после перевода: GET who", cli(old_master, "get", "who") or "(её нет)", pad=PAD)
    deadline = time.time() + 20
    while time.time() < deadline and link_status(old_master) != "up":
        time.sleep(0.2)
    time.sleep(1.0)
    row("после синхронизации: GET who", cli(old_master, "get", "who") or "(её нет)", pad=PAD)

    note(
        """
        Вот окно, ради которого блок и написан. Вернувшийся узел поднимается
        МАСТЕРОМ — он таким себя помнит — и принимает записи. Sentinel
        переводит его в реплику не мгновенно, а обнаружив.

        Две последние строки надо читать вместе, и именно в таком порядке.
        Сразу после перевода запись ещё на месте: узел уже реплика, но
        синхронизация только началась. А после неё ключа нет — данные
        заменены копией настоящего мастера. То есть клиент получил на свою
        запись OK, и запись исчезла; никакой ошибки он при этом не видел.

        Опасность не в самом окне, а в том, кто в него попадает: клиент,
        который держит адрес данных напрямую и не спрашивает sentinel. Для
        него старый адрес снова живой, отвечает OK — и всё идёт в никуда.
        В кластере такого окна нет: вернувшийся узел узнаёт из шины, что
        его слоты уже у другого, ещё до того, как примет первую команду.
        """
    )


def main() -> None:
    preamble("отказ мастера в обоих режимах: что приходит клиенту по проводу")

    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)
        block8(cluster, cports)

        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)
        cli(m, "set", KEY, "1")

        block9(cluster, m, reps, sents)
        block10(cluster, m, sents)
    finally:
        cluster.stop_all()


if __name__ == "__main__":
    main()