Приложение получило 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 постоянный: клиент обновляет карту слотов.
Cluster: слот 4574 переехал на узел 3, узел 1 отвечает MOVED
Что из этого следует
Redis подтверждает запись до репликации, поэтому при переключении теряется то, что мастер принял и не отдал репликам: в опыте 1208 записей без min-replicas-to-write и 248 с ним, при 7,2 секунды переключения после разрыва сети. Для Cluster потерю при failover здесь не измеряли; она описана в документации. Если данные нельзя потерять, Redis — кэш или очередь с повтором, а не единственное хранилище.
Backlog считается от самого длинного обрыва реплики: 1 МБ хватил на 6 818 байт и не хватил на 5 МБ. Полная синхронизация — BGSAVE на мастере и передача всего набора данных. Hash tag нужен многоключевым командам, но собирает все ключи с тегом в один слот на одном узле.
Чек-лист
- Сравни
master_repl_offsetмастера иslave_repl_offsetреплик вINFO replication; рост расхождения — первый признак того, что реплика уходит из backlog. - Рассчитай
repl-backlog-sizeна самый длинный допустимый обрыв: байты потока в секунду умножь на секунды. - Опиши мастера в конфиге Sentinel IP-адресом; проверь переключение остановкой мастера и найди
+switch-masterв логе. - Реши, что для приложения хуже: потеря записей или
NOREPLICAS. От ответа зависитmin-replicas-to-writeиmin-replicas-max-lag. - Для 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.