Справочник DBA: базы данных изнутри читается по порядку

Статья 8.7 · раздел 8 «Репликация и высокая доступность» · 61 из 109

Redis: репликация и backlog, Sentinel, Cluster на 16 384 слотах и окна потери записей

опубликовано
читать
≈ 10 мин
автор
среда и версия
Redis 8.10.3 (redis_version из INFO server) в контейнерах redis:8, по 256 МБ памяти, одна сеть Docker; мастер и две реплики, три процесса Sentinel (quorum 2), три мастера Cluster; до 5700 ключей, до 5 МБ данных; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Приложение получило OK на каждый из 1500 INCR, сеть мастера порвалась, Sentinel за семь секунд выбрал нового, и 1208 подтверждённых инкрементов исчезли. Redis подтверждает запись, не дожидаясь реплик: реплика получает поток команд после ответа клиенту (в PostgreSQL то же поведение по умолчанию, но там у реплики есть LSN и слот, а здесь только один offset и кольцевой буфер). Sentinel автоматизирует переключение, а Cluster делит ключи по 16 384 слотам, и ни тот ни другой не меняют асинхронности. Ниже на Redis 8.10.3 измерено: когда реплика догоняет по backlog и когда нужна полная копия, сколько длится переключение Sentinel, сколько записей теряется при разрыве сети с min-replicas-to-write и без него, как Cluster отвечает MOVED, CROSSSLOT и ASK. Данных мало (до 5 МБ), нагрузка — сотни запросов в секунду.

Стенд: сеть dba-redis-replication-sentinel-cluster-net, контейнеры redis:8 с --memory 256m; INFO server показывает redis_version:8.10.3, по этому тегу redis/redis взят код.

Репликация: offset и backlog

Мастер ведёт master_repl_offset — число байт потока команд — и держит последние байты потока в repl-backlog-size. Реплика при обрыве запоминает replid:offset и просит PSYNC. Если мастер знает этот replid и нужный offset ещё лежит в backlog, он досылает только недостающие байты. Иначе — полная синхронизация: BGSAVE, передача RDB, загрузка.

Мастер и две реплики; backlog маленький (1 МБ), repl-timeout 5 секунд, чтобы обрыв замечался быстро:

P=dba-redis-replication-sentinel-cluster; N=$P-net
docker network create $N; for r in m a b; do docker run -d --name $P-$r --network $N --memory 256m \
  redis:8 redis-server --appendonly no --repl-backlog-size 1mb; done
for r in a b; do docker exec $P-$r redis-cli REPLICAOF $P-m 6379; done
for r in m a b; do docker exec $P-$r redis-cli CONFIG SET repl-timeout 5
  docker exec $P-$r redis-cli CONFIG SET repl-ping-replica-period 1; done
docker exec $P-m redis-benchmark -q -t set -n 1000 -d 100 -r 1000
docker exec $P-m redis-cli INFO replication

Вывод (отобраны строки):

slave0:ip=192.168.48.3,port=6379,state=online,offset=144149,lag=1,io-thread=0
slave1:ip=192.168.48.4,port=6379,state=online,offset=144149,lag=1,io-thread=0
master_repl_offset:144149
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:144149

Offset мастера и обеих реплик совпадает: 144 149 байт потока. Теперь реплика a теряет сеть на 7 секунд, а на мастер за это время приходят 200 команд SET:

docker network disconnect $N $P-a
for i in $(seq 1 200); do docker exec $P-m redis-cli SET short$i x >/dev/null; done
sleep 7
docker exec $P-m redis-cli INFO replication | grep -E 'connected_slaves|slave[01]|master_repl_offset'
docker exec $P-a redis-cli INFO replication | grep -E 'master_link_status|slave_repl_offset'
docker network connect $N $P-a

Вывод:

connected_slaves:1
slave0:ip=192.168.48.4,port=6379,state=online,offset=150939,lag=1,io-thread=0
master_repl_offset:150953
master_link_status:down
slave_repl_offset:144149

Мастер ушёл на 150 953, a застряла на 144 149: расхождение 6 804 байта. После network connect в логах:

реплика a: Trying a partial resynchronization (request cc674c45d6e3a3a26d79bca5e2b06389fc39db1a:144150).
реплика a: Successful partial resynchronization with master.
мастер:    Partial resynchronization request from 192.168.48.3:6379 accepted. Sending 6818 bytes of backlog starting from offset 144150.

В INFO stats мастера sync_partial_ok вырос с 0 до 1, sync_full остался 2 (две первичные синхронизации). Реплика попросила байт 144 150, следующий после своего offset, и получила 6 818 байт из backlog вместо RDB.

Теперь обрыв длинный: мастер получает 5000 записей по 1 КБ, около 5 МБ при backlog в 1 МБ:

docker network disconnect $N $P-a
docker exec $P-m redis-benchmark -q -t set -n 5000 -d 1024 -r 100000
docker exec $P-m redis-cli INFO replication | grep -E 'master_repl_offset|repl_backlog_(size|first|histlen)'
docker network connect $N $P-a

Вывод:

master_repl_offset:5496149
repl_backlog_size:1048576
repl_backlog_first_byte_offset:4435481
repl_backlog_histlen:1060669

Backlog хранит байты с 4 435 481 по 5 496 149, а реплике нужен 151 066-й. Мастер отвечает отказом и запускает полную синхронизацию:

Unable to partial resync with replica 192.168.48.3:6379 for lack of backlog (Replica request was: 151066).
Full resync requested by replica 192.168.48.3:6379 (rdb-channel)
Starting BGSAVE for SYNC with target: replicas sockets (rdb-channel)
BGSAVE done, 5700 keys saved, 0 keys skipped, 5179956 bytes written.

sync_full вырос с 2 до 3, реплика загрузила 5 700 ключей (RDB 5 179 956 байт), DBSIZE на обоих узлах 5700. Условие отказа — в masterTryPartialResynchronization, тег 8.10.3: запрошенный offset вне окна [offset, offset + histlen] backlog.

    if (!server.repl_backlog ||
        psync_offset < server.repl_backlog->offset ||
        psync_offset > (server.repl_backlog->offset + server.repl_backlog->histlen))
    {
        serverLog(LL_NOTICE,
            "Unable to partial resync with replica %s for lack of backlog (Replica request was: %lld).", replicationGetSlaveName(c), psync_offset);
...
        goto need_full_resync;
    }

Строки 1052–1055 в пропуске только предупреждают, если реплика ушла дальше самого мастера.

Sentinel: кто объявляет мастера мёртвым

Sentinel — отдельные процессы. Каждый опрашивает мастера PING-ом; не получив ответа дольше down-after-milliseconds, помечает его +sdown (субъективно недоступен). Когда quorum Sentinel согласны, мастер получает +odown. Для самого переключения нужен голос большинства всех Sentinel: сначала выбирается лидер, потом он повышает реплику. Выбор лидера — собственный протокол Sentinel с эпохами и одним голосом на эпоху (+new-epoch, +vote-for-leader в логе). Он похож на выборы Raft, но журнала, который большинство подтверждает до ответа клиенту, у Sentinel нет: он выбирает мастера, а не реплицирует записи. Проверка кворума для +odown в sentinelCheckObjectivelyDown:

    if (master->flags & SRI_S_DOWN) {
        /* Is down for enough sentinels? */
        quorum = 1; /* the current sentinel. */
        /* Count all the other sentinels. */
        dictInitIterator(&di, master->sentinels);
        while((de = dictNext(&di)) != NULL) {
            sentinelRedisInstance *ri = dictGetVal(de);

            if (ri->flags & SRI_MASTER_DOWN) quorum++;
        }
        dictResetIterator(&di);
        if (quorum >= master->quorum) odown = 1;

Три процесса redis-sentinel в отдельных контейнерах, quorum 2, адрес мастера — IP:

# /tmp/s.conf в каждом из трёх контейнеров, запуск: redis-sentinel /tmp/s.conf
sentinel monitor mymaster 192.168.48.2 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 10000
docker stop -t 0 $P-m

Остановка мастера в 00:20:12.305. Журнал Sentinel-лидера (docker logs $P-s2), события по порядку:

00:20:17.232 # +sdown master mymaster 192.168.48.2 6379
00:20:17.291 # +odown master mymaster 192.168.48.2 6379 #quorum 2/2
00:20:17.355 # +elected-leader master mymaster 192.168.48.2 6379
00:20:17.889 # +promoted-slave slave 192.168.48.4:6379 192.168.48.4 6379 @ mymaster 192.168.48.2 6379
00:20:18.961 # +failover-end master mymaster 192.168.48.2 6379
00:20:18.961 # +switch-master mymaster 192.168.48.2 6379 192.168.48.4 6379

От остановки до +switch-master прошло 6,7 секунды: 4,9 секунды до +sdown (это down-after-milliseconds), 1,7 секунды на +odown, выборы лидера, повышение реплики и переподключение второй. Клиент берёт адрес мастера у SENTINEL get-master-addr-by-name mymaster.

Первая попытка была с именем контейнера вместо IP в sentinel monitor (с sentinel resolve-hostnames yes). После docker stop имя перестало разрешаться, и Sentinel ушёл в режим TILT (Failed to resolve hostname, затем +tilt #tilt mode entered в 00:19:33). Остановка была в 00:19:29, и за 19 секунд (до 00:19:48) в журнале не появилось ни одного +sdown. Если мастер описан именем, которое не разрешается при падении узла, failover не начнётся.

Сколько записей теряется

Теперь не остановка, а разрыв сети: мастер жив и продолжает принимать записи, но Sentinel и реплики его не видят. Писатель работает внутри контейнера мастера (отрезан вместе с ним) и делает 1500 раз INCR counter с паузой 10 мс:

docker exec $P-b redis-cli SET counter 0
docker exec $P-b redis-cli -r 1500 -i 0.01 INCR counter > /dev/null &
sleep 3; docker network disconnect $N $P-b; wait
docker exec $P-b redis-cli GET counter
docker exec $P-m redis-cli GET counter    # новый мастер, m

Сеть мастера b отрезана в 00:21:00.725; +sdown в 00:21:06.665, +switch-master в 00:21:07.876 (7,2 секунды после разрыва). Все 1500 команд получили OK, ошибок нет. Значения счётчика:

b (старый мастер, отрезан): 1500
m (новый мастер):            292

Потеряно 1500 − 292 = 1208 подтверждённых клиенту инкрементов. Старый мастер принимал инкременты ещё 12 секунд после разрыва, реплики получили только то, что ушло за первые три секунды. Когда сеть вернулась, Sentinel отправил b команду REPLICAOF 192.168.48.2 6379, та выполнила полную синхронизацию и отбросила свои данные:

b: 292 role:slave master_link_status:up
m: 292

Теперь на всех узлах CONFIG SET min-replicas-to-write 1 и CONFIG SET min-replicas-max-lag 2; тот же писатель, тот же разрыв (на этот раз отрезан m):

Мастер отказывает в записи, когда реплик со свежим подтверждением меньше одной (счёт в refreshGoodSlavesCount: реплика считается «хорошей» при lag <= server.repl_min_slaves_max_lag, где lag — время с последнего REPLCONF ACK). Результат на 1500 команд:

ok: 540
err: 960   NOREPLICAS Not enough good replicas to write.
старый мастер: 540
новый мастер:  292

Принято 540 записей, 960 отклонено с NOREPLICAS; потеряно 540 − 292 = 248 вместо 1208. Окно потери не исчезло: пока от реплик не прошло min-replicas-max-lag секунд с последнего REPLCONF ACK, мастер принимает записи. Настройка ограничивает потерю окном ценой NOREPLICAS на время разрыва; ответ OK по-прежнему не ждёт реплик.

Cluster: слоты, MOVED, CROSSSLOT, ASK

Cluster убирает отдельный процесс Sentinel и делит ключи на 16 384 слота. Слот ключа — младшие 14 бит CRC16; если в ключе есть {...}, хешируется только содержимое скобок (keyHashSlot, в 8.10.3 функция живёт в cluster.h, а не в cluster_legacy.c):

static inline unsigned int keyHashSlot(const char *key, int keylen) {
    int s, e; /* start-end indexes of { and } */

    for (s = 0; s < keylen; s++)
        if (key[s] == '{') break;

    /* No '{' ? Hash the whole key. This is the base case. */
    if (likely(s == keylen)) return crc16(key,keylen) & 0x3FFF;
...
    /* If we are here there is both a { and a } on its right. Hash
     * what is in the middle between { and }. */
    return crc16(key+s+1,e-s-1) & 0x3FFF;

Строки 68–75 в пропуске ищут } и хешируют весь ключ, если её нет или скобки пусты. Три мастера без реплик:

for c in 1 2 3; do docker run -d --name $P-c$c --network $N --memory 256m \
  redis:8 redis-server --cluster-enabled yes --cluster-node-timeout 5000 --appendonly no; done
docker exec $P-c1 redis-cli --cluster create <IP1>:6379 <IP2>:6379 <IP3>:6379 --cluster-replicas 0 --cluster-yes
docker exec $P-c1 redis-cli CLUSTER NODES
for k in user:1 order:7 '{u1}.a'; do echo "$k $(docker exec $P-c1 redis-cli CLUSTER KEYSLOT "$k")"; done

Вывод (отобраны слоты узлов и результаты KEYSLOT):

192.168.48.2:6379@16379 myself,master 0-5460
192.168.48.3:6379@16379 master 5461-10922
192.168.48.4:6379@16379 master 10923-16383
user:1 10778
order:7 6368
{u1}.a 4574

Слот 10778 (user:1) у узла 2, поэтому узел 1 отвечает перенаправлением; слот 4574 ({u1}.a) его собственный. MSET по ключам из разных слотов отклоняется, с общим тегом проходит:

> SET user:1 x          (узел 1)
MOVED 10778 192.168.48.3:6379
> SET {u1}.a x
OK
> MSET user:1 a order:7 b
CROSSSLOT Keys in request don't hash to the same slot
> MSET {u1}.a 1 {u1}.b 2
OK

Слоты 10778 и 6368 оба в диапазоне 5461–10922, то есть ключи на одном узле, но CROSSSLOT всё равно возвращается: ограничение действует на слот, а не на узел.

Теперь перенос слота 4574 с узла 1 на узел 3 (c1, c3 — docker exec $P-c1 redis-cli и docker exec $P-c3 redis-cli):

ID1=$(c1 CLUSTER MYID); ID3=$(c3 CLUSTER MYID)
c1 MSET '{u1}.a' A '{u1}.b' B
c3 CLUSTER SETSLOT 4574 IMPORTING $ID1
c1 CLUSTER SETSLOT 4574 MIGRATING $ID3
c1 MIGRATE 192.168.48.4 6379 '{u1}.a' 0 5000

Ключ {u1}.a уже на узле 3, слот числится за узлом 1:

-- узел 1: GET {u1}.a (ключ уже перенесён)
ASK 4574 192.168.48.4:6379
-- узел 3: GET {u1}.a без ASKING
MOVED 4574 192.168.48.2:6379
-- узел 3: ASKING, затем GET {u1}.a
OK
A

ASK — разовое перенаправление: слот ещё у узла 1, ключ уже у узла 3, и перед запросом к нему нужна команда ASKING; без неё узел 3 отвечает MOVED обратно. Когда ключи перенесены, владелец меняется на всех узлах:

c1 MIGRATE 192.168.48.4 6379 '{u1}.b' 0 5000
for n in 1 2 3; do docker exec $P-c$n redis-cli CLUSTER SETSLOT 4574 NODE $ID3; done
c1 GET '{u1}.a'     # вывод: MOVED 4574 192.168.48.4:6379

После этого MOVED постоянный: клиент обновляет карту слотов.

DBA / 01

Cluster: слот 4574 переехал на узел 3, узел 1 отвечает MOVED

Cluster: слот 4574 переехал на узел 3, узел 1 отвечает MOVEDТри мастера Cluster: узел 1 держит слоты 0–4573 и 4575–5460, узел 2 слоты 5461–10922, узел 3 слот 4574 и слоты 10923–16383. Клиент отправляет GET {u1}.a на узел 1, получает MOVED 4574 192.168.48.4:6379 и повторяет запрос на узле 3.узел 1192.168.48.20–4573, 4575–54605460 слотовузел 2192.168.48.35461–109225462 слотаузел 3192.168.48.44574, 10923–163835462 слотаклиентGET {u1}.aMOVED 4574 192.168.48.4повтор GET {u1}.a на узле 301 / слот 4574 числится за узлом 3: узел 1 отвечает MOVED 4574 192.168.48.4:637902 / клиент обновляет карту слотов и повторяет запрос на узле 3
После переноса слот 4574 принадлежит узлу 3, и узел 1 перенаправляет туда запрос постоянным MOVED. Пока перенос идёт, вместо MOVED отвечает ASK, и клиент спрашивает узел 3 после ASKING.

Что из этого следует

Redis подтверждает запись до репликации, поэтому при переключении теряется то, что мастер принял и не отдал репликам: в опыте 1208 записей без min-replicas-to-write и 248 с ним, при 7,2 секунды переключения после разрыва сети. Для Cluster потерю при failover здесь не измеряли; она описана в документации. Если данные нельзя потерять, Redis — кэш или очередь с повтором, а не единственное хранилище.

Backlog считается от самого длинного обрыва реплики: 1 МБ хватил на 6 818 байт и не хватил на 5 МБ. Полная синхронизация — BGSAVE на мастере и передача всего набора данных. Hash tag нужен многоключевым командам, но собирает все ключи с тегом в один слот на одном узле.

Чек-лист

  1. Сравни master_repl_offset мастера и slave_repl_offset реплик в INFO replication; рост расхождения — первый признак того, что реплика уходит из backlog.
  2. Рассчитай repl-backlog-size на самый длинный допустимый обрыв: байты потока в секунду умножь на секунды.
  3. Опиши мастера в конфиге Sentinel IP-адресом; проверь переключение остановкой мастера и найди +switch-master в логе.
  4. Реши, что для приложения хуже: потеря записей или NOREPLICAS. От ответа зависит min-replicas-to-write и min-replicas-max-lag.
  5. Для Cluster проверь, что клиент понимает MOVED и ASK, а ключи многоключевых команд имеют общий hash tag.

Убрать стенд: docker rm -f -v $(docker ps -aq --filter name=dba-redis-replication-sentinel-cluster) и docker network rm dba-redis-replication-sentinel-cluster-net.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru