7.6Вытеснение, персистентность, отказоустойчивость
Выбери Redis-настройки исходя из роли данных: вытеснение, RDB/AOF и Sentinel/Cluster дают разные свойства. Пойми, какие записи можно потерять при каждом механизме и почему Redis как кэш не должен быть единственной копией критичного состояния.
maxmemory и политики вытеснения
maxmemory задаёт верхнюю границу памяти под данные. Что делать, когда она достигнута,
решает maxmemory-policy. Её восемь классических вариантов делятся на три семейства
(с Redis 8.6 добавились ещё allkeys-lrm и volatile-lrm, они вытесняют по давности
последней записи):
ничего не выбрасывать, выбрасывать из всех ключей (allkeys-*) или только
из тех, у кого выставлен TTL (volatile-*).
| Политика | Что делает при упирании в лимит | Когда брать |
|---|---|---|
noevictionпо умолчанию |
ничего не выбрасывает; команды на запись возвращают ошибку
OOM command not allowed, чтение продолжает работать |
когда Redis - хранилище, а не кэш: очереди, сессии, данные без второй копии. Потерять нельзя, лучше явная ошибка |
allkeys-lru |
выбрасывает давно не используемый ключ из всех | дефолт для чистого кэша; хорошо, когда актуальность коррелирует со свежестью обращения |
allkeys-lfuRedis 4+ |
выбрасывает редко используемый ключ из всех | кэш с устойчивым ядром горячих ключей и потоком разовых обращений - лучше LRU |
allkeys-random |
выбрасывает случайный ключ | когда все ключи равноценны, а экономия на учёте важна; редко |
volatile-lru |
LRU, но только среди ключей с TTL | смешанная нагрузка: часть данных - кэш (с TTL), часть - постоянная (без TTL) |
volatile-lfu |
LFU среди ключей с TTL | то же, но с профилем под LFU |
volatile-random |
случайный среди ключей с TTL | редко |
volatile-ttl |
выбрасывает тот, которому осталось жить меньше всего | когда TTL реально отражает ценность данных |
Если при volatile-* у ключей не окажется TTL, выбрасывать будет нечего - и
Redis поведёт себя как noeviction: писать станет невозможно. Авария
классическая: поставили volatile-lru, часть кода забыла TTL, память заполнилась
«бессмертными» ключами, и сервис лёг с ошибками OOM, хотя политика вытеснения «вроде
настроена». Отсюда правило из главы 7.4: TTL у каждого ключа, без исключений.
- Что входит в лимит.
maxmemoryограничивает данные, а буферы клиентов (особенно output buffer у pub/sub и репликации) и фрагментация памяти живут сверх него. Поэтому лимит ставят примерно в 60–70 % физической памяти машины, а не в 95 %. - Вытеснение идёт в главном потоке. При активном вытеснении Redis тратит
время на выбор жертв и освобождение памяти, и латентность растёт.
lazyfree-lazy-eviction yesпереносит освобождение в фоновый поток. - Как понять, что вытеснение идёт.
INFO stats→evicted_keys. Ненулевой и растущий счётчик при падающем hit rate прямо говорит, что кэшу мало памяти. На эту метрику стоит повесить алерт.
LRU против LFU
LRU выбрасывает то, к чему дольше всего не обращались, а LFU то, к чему обращались реже всего. Разница видна на конкретном профиле нагрузки: LRU ломается на «сканирующем» трафике, когда поток разовых обращений вытесняет устойчиво горячие ключи.
Как Redis приближает LRU
Настоящий LRU требует двусвязного списка всех ключей: при каждом обращении элемент переносится в
голову. Это два указателя на объект (16 байт на ключ только под учёт) плюс работа на каждом
GET. Для базы на десятки миллионов ключей такое неприемлемо, поэтому Redis
реализует не LRU, а его аппроксимацию.
- В заголовке каждого объекта есть 24-битное поле
lruс «часами»: временем последнего обращения с грубым разрешением. Никакого списка, никаких указателей. - Когда нужно вытеснять, Redis берёт случайную выборку из
maxmemory-samplesключей (по умолчанию 5) и выбрасывает лучшего кандидата в ней. - С Redis 3.0 есть ещё пул кандидатов на 16 элементов: лучшие жертвы из разных выборок копятся между вызовами, и при той же цене результат заметно ближе к идеальному LRU.
maxmemory-samples 10даёт результат, практически неотличимый от точного LRU, но ест больше CPU;3работает быстрее и грубее.
LFU устроен похоже, только в тех же 24 битах вместо часов лежат 8-битный логарифмический
счётчик и 16 бит времени последнего затухания. Инкремент вероятностный: чем больше счётчик,
тем реже он растёт (этим управляет lfu-log-factor), поэтому 8 бит хватает на диапазон
до миллионов обращений. Со временем счётчик затухает - lfu-decay-time задаёт,
за сколько минут простоя он уменьшается на единицу. Без затухания LFU был бы фатально
консервативным: вчерашний хит вечно вытеснял бы сегодняшний.
«По умолчанию ставлю allkeys-lru: он предсказуем и хорош, когда актуальность совпадает
со свежестью обращения. allkeys-lfu беру, когда есть устойчивое ядро горячих
ключей и поток разовых обращений: ночные джобы, обходы, аналитика - они выбивают LRU целиком.
И обязательно упомяну, что у Redis это приближение: выборка из пяти случайных ключей
плюс пул кандидатов, а не настоящий LRU-список. Поэтому вытеснение почти ничего не
стоит по памяти.»
Персистентность: RDB и AOF
Redis хранит данные в памяти, а при перезапуске процесса память пуста. Персистентность нужна, чтобы поднять данные обратно после рестарта: копия памяти регулярно уходит на диск. Механизмов два, они независимы, и включать можно любой, оба или ни одного.
RDB делает снимок: всё содержимое памяти целиком сбрасывается в один компактный бинарный файл, как фотография состояния на конкретный момент. AOF ведёт журнал: каждая изменяющая команда дописывается в конец файла, а при восстановлении журнал проигрывается с начала. Вся дальнейшая разница между ними вытекает из этих двух слов, «снимок» и «журнал».
| RDB (снимок) | AOF (журнал команд) | |
|---|---|---|
| Что пишет | бинарный снимок всего датасета целиком в один файл | каждую изменяющую команду в текстовом протоколе RESP, дописыванием в конец |
| Когда пишет | по расписанию (save 900 1) или по BGSAVE |
постоянно, в буфер; на диск - по appendfsync |
| Потеря при падении | всё с момента последнего снимка - минуты | зависит от appendfsync: почти ничего при always, до секунды при
everysec, десятки секунд при no |
| Скорость старта | быстрая: чтение компактного бинарника | медленная: воспроизведение всех команд |
| Размер файла | компактный | больше, растёт постоянно (нужен rewrite) |
| Влияние на латентность | fork() процесса + copy-on-write: скачок памяти и пауза на fork на больших датасетах | постоянная нагрузка на диск; периодический rewrite тоже делает fork |
| Для чего | бэкапы, быстрое восстановление, репликация начального состояния | минимальная потеря данных |
appendfsync - где именно теряются данные
Запись в AOF мгновенно попадает в буфер ядра, а на диск данные уходят только при
fsync. Как часто он вызывается, определяет appendfsync, и она же
задаёт размер окна потери.
| Значение | Когда fsync | Теряется при падении | Цена |
|---|---|---|---|
always | после каждой команды записи | ничего (в пределе - последняя команда) | высокая: ответ уходит только после fsync; записи всех клиентов за один проход цикла событий Redis сбрасывает одним fsync, но пропускная способность всё равно упирается в диск |
everysecпо умолчанию |
раз в секунду, отдельным потоком | до 1 секунды записей | практически незаметна; разумный компромисс |
no | решает ОС (обычно раз в 30 секунд) | до десятков секунд | нулевая |
appendfsync always звучит как «надёжный вариант», но на практике его почти
никогда не включают: он превращает Redis из структуры данных в памяти в медленную БД на диске,
а тогда проще взять настоящую БД. Правильная формулировка: «если данные настолько ценны, что
нужен always, значит, они не должны жить только в Redis». Причём и
always полной гарантии не даёт: диск может подтвердить запись, не положив её на
пластину (кэш контроллера).
Гибридный режим
С Redis 4.0 есть aof-use-rdb-preamble yes (в современных версиях включён по
умолчанию): при перезаписи AOF основой журнала становится RDB-снимок, а команды,
пришедшие позже, дописываются после него. До Redis 7.0 это был один файл: снимок в начале,
команды следом. С 7.0 AOF — это каталог: базовый файл со снимком, файлы с командами после
него и манифест, который задаёт порядок их чтения. От RDB берутся компактность и быстрый старт, от AOF
минимальная потеря. Поэтому на «что выбрать» правильно отвечать так:
оба, в гибридном режиме.
Есть ещё AOF rewrite. Журнал растёт бесконечно (миллион
INCR даёт миллион строк), поэтому Redis периодически перезаписывает его в компактную
форму, отражающую текущее состояние. Rewrite идёт через fork(), с тем
же copy-on-write и тем же риском скачка памяти, что и BGSAVE. Настраивают его через
auto-aof-rewrite-percentage и auto-aof-rewrite-min-size.
BGSAVE и AOF rewrite делают fork(). Дочерний процесс поначалу
делит страницы памяти с родителем, но каждая изменённая страница копируется. Если за время
снимка записей много, скопироваться может большая часть датасета - и потребление памяти
вырастет почти вдвое. Это классическая причина OOM-kill на инстансах,
где maxmemory выставлен близко к физической памяти. Вдобавок сам fork()
на десятках гигабайт занимает сотни миллисекунд (копируется таблица страниц), и всё это
время Redis не отвечает. Отсюда практика: maxmemory не больше 60–70 % RAM,
vm.overcommit_memory = 1, отключённые transparent huge pages и по возможности
снимки с реплики, а не с мастера.
Репликация, Sentinel, Cluster
Репликация - асинхронная
Мастер принимает записи и потоком отправляет их репликам, причём асинхронно:
клиенту он отвечает OK, не дожидаясь подтверждения от реплик. Отсюда
прямое следствие, которое и хотят услышать: подтверждённая запись может быть потеряна,
если мастер упадёт до того, как реплика её получит, а failover поднимет эту реплику.
- Начальная синхронизация:
BGSAVEна мастере, передача RDB, затем поток команд. После разрыва, если реплика отстала не слишком сильно, хватает частичной ресинхронизации по буферуrepl-backlog. - Реплики по умолчанию доступны только на чтение (
replica-read-only yes). Данные на них отстают на величину лага, так что read-your-writes не гарантирован. - Команда
WAIT numreplicas timeoutблокирует до подтверждения записи N репликами. Это не синхронная репликация и не транзакционная гарантия: она не откатывает запись при неуспехе, а лишь сообщает, сколько реплик подтвердили. Полезна как «барьер» перед критичным действием, но строгой durability не даёт. - Настройки
min-replicas-to-write/min-replicas-max-lagзаставляют мастера отказывать в записи, если у него меньше N живых реплик. Так доступность меняют на меньший риск потери, то есть сдвигаются в сторону CP.
Sentinel - автоматический failover
Sentinel состоит из отдельных процессов-наблюдателей (нечётное число, минимум три), которые следят за мастером и репликами. По шагам:
- Мониторинг. Каждый sentinel пингует мастера и, если тот молчит дольше
down-after-milliseconds, помечает его субъективно недоступным (SDOWN). - Кворум. Кворум нужен, чтобы один потерявший сеть наблюдатель
не мог единолично объявить мастера мёртвым. Sentinel опрашивает остальных, и если о
недоступности заявили не меньше
quorumиз них, статус повышается до объективно недоступного (ODOWN). - Выборы лидера. Sentinel-ы выбирают среди себя лидера (вариант Raft), который проведёт failover. Для выборов нужно большинство sentinel-ов - и это важнее кворума: при трёх sentinel-ах нужны два живых.
- Промоушен. Лидер выбирает лучшую реплику (по приоритету, объёму полученных данных,
run id), делает ей
REPLICAOF NO ONE, перенастраивает остальные реплики на неё и обновляет конфигурацию. - Уведомление клиентов. Клиенты спрашивают у Sentinel адрес текущего мастера, а команды
шлют уже ему напрямую. В go-redis для этого есть
NewFailoverClient.
Всё, что мастер успел подтвердить клиенту, но не успел отправить реплике. Случай не
редкий: при обрыве сети мастер ещё какое-то время может принимать записи (пока не сработает
min-replicas-to-write), хотя дальше они уже не уходят. Классический
split brain: старый мастер жив в изолированном сегменте и принимает записи, в основном поднят
новый - когда сеть починят, старый становится репликой и его записи
отбрасываются. Поэтому распределённая блокировка на Redis корректности не обеспечивает:
после failover два клиента могут одновременно считать себя держателями (глава 7.7).
Redis Cluster и 16384 слота
Sentinel даёт отказоустойчивость, но не горизонтальное масштабирование: весь датасет всё равно лежит на одном мастере. Cluster шардирует данные между несколькими мастерами, у каждого из которых свои реплики.
- Пространство ключей разбито на 16384 слота. Слот ключа считается как
CRC16(key) mod 16384. Каждый мастер владеет диапазоном слотов. - Почему 16384, а не 65536: карта слотов входит в каждое сообщение протокола gossip как битовая маска. 16384 бита занимают 2 КБ на сообщение, а 65536 заняли бы уже 8 КБ. При типичном размере кластера в десятки нод такой размер избыточен, а 16384 хватает с запасом.
- Клиент сам знает карту слотов и ходит сразу на нужную ноду. Прокси нет: шардинг клиентский, и протокол его поддерживает.
Ограничения кластера
- Мультиключевые операции только внутри слота.
MGET,MSET,SUNION,ZUNIONSTORE, транзакции и Lua-скрипты требуют, чтобы все ключи хешировались в один слот, иначеCROSSSLOT. Клиенты вроде go-redis умеют разбиватьMGETна несколько запросов по нодам, но атомарности при этом нет. - Hash tag режет в обе стороны.
{userID}в ключе сводит все данные пользователя в один слот и делает возможными транзакции и Lua. Но если тег выбран неудачно (например,{tenant}у крупного клиента), весь его объём и трафик приземляются на одну ноду, то есть ты своими руками создал горячий слот. - Нет баз данных. В кластере доступна только
db 0,SELECTне работает. - Pub/sub. Классический pub/sub в кластере рассылается по всем нодам (что дорого);
с Redis 7 есть шардированный pub/sub
SPUBLISH/SSUBSCRIBE, привязанный к слоту канала. - Failover внутри кластера обходится без Sentinel: реплики сами инициируют выборы,
решение принимает большинство мастеров. Если мастер потерял свои слоты и реплики нет,
кластер по умолчанию (
cluster-require-full-coverage yes) перестаёт обслуживать все запросы, а не только этот диапазон.
Почему Redis не даёт строгих гарантий
- Данные могут исчезнуть по политике вытеснения. При
allkeys-*Redis имеет полное право выбросить любой твой ключ до истечения TTL. Значит, приложение обязано корректно работать без ключа в любой момент. - Данные могут потеряться при падении. При
everysecпропадёт до секунды записей, без AOF всё с последнего снимка. - Подтверждённая запись может потеряться при failover. Репликация асинхронная; окно между ответом клиенту и доставкой реплике не защищено ничем.
- Возможен split brain. Старый мастер в изолированном сегменте продолжает принимать записи, которые потом будут отброшены.
- Транзакции не такие, как в БД.
MULTI/EXECдаёт атомарность выполнения и изоляцию (команды идут подряд без чужих между ними), но не даёт отката: если одна команда упала на этапе выполнения, остальные всё равно выполнятся.
- Redis служит кэшем и вспомогательным состоянием, а не источником правды. Всё, что нельзя потерять, должно иметь копию в БД или в брокере.
- Любой промах считается штатной ситуацией, а не ошибкой. Код без ветки «в кэше нет» сломан.
- Распределённая блокировка на Redis работает как оптимизация (не делать одну работу дважды), а не как гарантия взаимного исключения. Корректность обеспечивают идемпотентностью операции или fencing token (разбор в главе 7.7).
- Если Redis используют как очередь или хранилище (Streams, сессии), политику ставят
noeviction, персистентность включают, а потерю секунды данных явно согласуют с продуктом.
Вопросы
9maxmemory-policy. По умолчанию
noeviction - записи начинают отвечать ошибкой OOM, чтения работают. Остальные семь
(с Redis 8.6 - девять) вариантов выбрасывают ключи: allkeys-* из всех, volatile-* только из
тех, у кого есть TTL.| Политика | Поведение | Когда |
|---|---|---|
noeviction | ошибка на запись, чтение живёт | Redis как хранилище: очереди, сессии, данные без второй копии |
allkeys-lru | давно не использованный из всех | дефолт для чистого кэша |
allkeys-lfu | редко используемый из всех | есть ядро горячих ключей + поток разовых обращений |
allkeys-random | случайный из всех | все ключи равноценны; редко |
volatile-lru / -lfu / -random |
то же, но только среди ключей с TTL | смешанная нагрузка: часть данных постоянная |
volatile-ttl | тот, кому осталось жить меньше всех | когда TTL отражает ценность |
- Ловушка volatile-*. Если у ключей нет TTL, выбрасывать нечего - Redis
фактически ведёт себя как
noevictionи перестаёт принимать записи. Частая продовая авария при «настроенной» политике вытеснения. - maxmemory считает не всё. Буферы клиентов (особенно output buffer репликации и
pub/sub) и фрагментация живут сверх лимита, поэтому
maxmemoryставят в 60–70 % физической памяти, иначе процесс убьёт OOM-killer. - Вытеснение нагружает главный поток. При активном вытеснении растёт
латентность, смягчает это
lazyfree-lazy-eviction yes. Следить стоит заevicted_keysвINFO stats: растущий счётчик при падающем hit rate означает, что кэшу мало памяти.
maxmemory-samples
(по умолчанию 5) случайных ключей и выбрасывает лучшего кандидата, накапливая кандидатов в
пуле на 16 элементов.Когда LFU лучше
Классический пример: ключ A постоянно читают 6 раз в секунду, а потом приходит ночной batch-обход и читает по одному разу тысячу холодных ключей. LRU выбросит A - к нему «дольше всего не обращались». LFU оставит: у A счётчик набегал долго, а у новичков по одному обращению. В Redis счётчик логарифмический и новый ключ стартует с 5, а не с нуля, но порядок тот же. Так же действуют аналитические запросы, краулеры, обходы «пройтись по всем сущностям».
Когда LRU лучше
Когда актуальность коррелирует со свежестью: сессии, недавно просмотренные товары, лента.
И когда профиль обращений быстро меняется, потому что LFU перестраивается медленнее и держится
за вчерашних фаворитов. random берут крайне редко. Он дешевле по CPU и никакого
учёта не требует, но при перекошенном распределении заметно хуже обоих.
Как устроено приближение
- Настоящий LRU держит двусвязный список: 16 байт указателей на каждый ключ плюс работа на
каждом
GET. Для десятков миллионов ключей это неприемлемо. - Вместо этого в заголовке объекта лежит 24-битное поле
lruс грубыми часами последнего обращения. - При вытеснении Redis берёт
maxmemory-samplesслучайных ключей (по умолчанию 5) и выбрасывает лучшего кандидата из выборки. С Redis 3.0 лучшие кандидаты копятся в пуле на 16 элементов между выборками, что заметно приближает результат к идеальному LRU. maxmemory-samples 10даёт практически точный LRU ценой CPU,3работает быстрее и грубее.- LFU в тех же битах хранит 8-битный логарифмический счётчик (вероятностный
инкремент,
lfu-log-factor) и время последнего затухания. Счётчик затухает:lfu-decay-timeзадаёт, за сколько минут простоя он теряет единицу. Без затухания LFU был бы фатально консервативен.
«У Redis это аппроксимация, а не точный LRU: он сознательно отказался от
учёта, зато вытеснение почти ничего не стоит по памяти. Кто в кэше горячий, покажет
OBJECT FREQ при LFU или redis-cli --hotkeys, который тоже
работает только при LFU.»
everysec теряет до секунды, но файл больше и старт медленнее. Правильный
ответ на «что выбрать» - оба в гибридном режиме.| RDB | AOF | |
|---|---|---|
| Что пишет | снимок всего датасета | каждую изменяющую команду |
| Потеря при падении | всё с последнего снимка - минуты | 0–1 секунда (appendfsync) |
| Старт | быстрый: чтение бинарника | медленный: проигрывание команд |
| Размер | компактный | больше, растёт (нужен rewrite) |
| Влияние на латентность | fork + copy-on-write, скачок памяти | постоянный I/O + периодический rewrite (тоже fork) |
| Основное применение | бэкапы, восстановление, начальная синхронизация реплик | минимальная потеря данных |
Гибридный режим - то, что применяют на практике
aof-use-rdb-preamble yes (с Redis 4.0, в современных версиях по умолчанию):
при перезаписи AOF основой журнала становится RDB-снимок, а команды после него дописываются
отдельно: с Redis 7.0 — в свои файлы рядом со снимком, раньше — в тот же файл следом. Компактность и быстрый старт от RDB плюс окно потери в одну секунду от AOF.
AOF rewrite
Журнал растёт бесконечно: миллион INCR дают миллион строк. Redis периодически
перезаписывает его в компактную форму, отражающую текущее состояние
(auto-aof-rewrite-percentage, auto-aof-rewrite-min-size). Rewrite
делает fork() со всеми вытекающими рисками по памяти.
BGSAVE и AOF rewrite делают fork(): дочерний процесс делит
страницы с родителем, но каждая изменённая страница копируется. При высокой доле записей
потребление памяти может вырасти почти вдвое - типичная причина OOM-kill там, где
maxmemory выставлен близко к RAM. Сам fork() на десятках
гигабайт занимает сотни миллисекунд (копирование таблицы страниц), и всё это время сервер
не отвечает. Практика: maxmemory ≤ 60–70 % RAM,
vm.overcommit_memory = 1, выключенные transparent huge pages и снятие
снимков с реплики, а не с мастера.
fsync. always - fsync после каждой записи, теряется почти ничего,
но каждая запись ждёт диск, и пропускная способность упирается в скорость fsync. everysec - раз в секунду, теряется
до секунды, цена почти нулевая. no - решает ОС, теряются десятки секунд.| Значение | Когда fsync | Окно потери | Цена |
|---|---|---|---|
always | после каждой команды записи | в пределе последняя команда | высокая: запись ждёт fsync, параллельные записи сбрасываются одним fsync пачкой, и пропускная способность упирается в диск |
everysec | раз в секунду, отдельным потоком | до 1 секунды | практически незаметна - дефолт |
no | по усмотрению ОС, обычно ~30 с | десятки секунд | нулевая |
Правильная позиция по always
always выглядит как «надёжный вариант», но включать его почти никогда не
стоит: он превращает Redis из структуры данных в памяти в медленную дисковую БД - а тогда
логичнее взять настоящую. Полной гарантии не даёт даже он: диск может подтвердить
запись, положив её в кэш контроллера, а не на носитель. Формулировка для собеседования:
«если данные настолько ценны, что нужен always, значит, они не должны жить
только в Redis».
«А если everysec, но диск подвис?» Тогда поток fsync не успевает, и Redis
начинает блокировать запись, чтобы буфер не рос бесконечно (поведение
зависит от no-appendfsync-on-rewrite). Выходит, медленный диск может прямо
обернуться ростом латентности Redis, хотя тот «в памяти». Хороший аргумент за
отдельный быстрый диск под AOF и за снятие персистентности с реплики.
OK, не дожидаясь реплик. Да, подтверждённая запись может потеряться - если
мастер упадёт до доставки, а failover поднимет отставшую реплику. Это заявленное поведение,
а не баг.Как работает
- Начальная синхронизация: мастер делает
BGSAVE, передаёт RDB реплике, затем льёт поток команд. После разрыва возможна частичная ресинхронизация из кольцевого буфераrepl-backlog, если отставание невелико. - Реплики по умолчанию read-only и отстают на величину лага
(
master_repl_offsetпротивslave_repl_offsetвINFO replication). Значит, read-your-writes с реплики не гарантирован. WAIT numreplicas timeoutблокирует, пока N реплик не подтвердят получение. Это не синхронная репликация: она ничего не откатывает при неуспехе, а лишь сообщает, сколько подтвердили. Полезна как барьер, строгой durability не даёт.min-replicas-to-write N+min-replicas-max-lag Sзаставляют мастера отказывать в записи, если живых реплик меньше N. Это сознательный размен доступности на снижение риска потери.
Сценарий потери
- Клиент делает
SET k v, мастер записал в память и ответилOK. - Мастер падает (или изолируется сетью) до того, как команда ушла реплике.
- Sentinel проводит failover, реплика становится мастером - без этой записи.
- Старый мастер возвращается, становится репликой нового и отбрасывает свои неотправленные записи.
«Redis сознательно выбрал доступность и латентность вместо строгой согласованности:
репликация асинхронная, поэтому окно между ответом клиенту и доставкой реплике ничем не
защищено. Практический вывод: всё, что нельзя потерять, обязано иметь копию за пределами
Redis. Снизить риск помогут min-replicas-to-write и
WAIT, но они меняют доступность на durability и гарантии не дают.»
Пять шагов failover
- SDOWN. Sentinel не получил ответа дольше
down-after-milliseconds- помечает мастера субъективно недоступным. - ODOWN. Опрашивает других sentinel-ов; если согласны не меньше
quorumиз них, статус становится объективно недоступным. - Выборы лидера. Sentinel-ы выбирают, кто проведёт failover (вариант Raft). Для этого нужно большинство всех sentinel-ов, а не только кворум: при трёх нужны два живых.
- Промоушен. Лидер выбирает лучшую реплику (приоритет
replica-priority, объём полученных данных, run id), даёт ейREPLICAOF NO ONEи перенастраивает остальные реплики на неё. - Уведомление. Клиенты спрашивают у Sentinel адрес текущего мастера; в go-redis это
redis.NewFailoverClientсо списком sentinel-адресов и именем мастера.
Их путают почти всегда. quorum говорит, сколько sentinel-ов должны согласиться,
что мастер недоступен. Большинство нужно, чтобы провести failover.
Поэтому quorum можно поставить равным 1, но failover всё равно не произойдёт,
если живо меньше половины sentinel-ов. Отсюда и требование нечётного числа от трёх и
размещения их в разных зонах отказа: два sentinel-а в одной стойке отказоустойчивости не дают.
Всё, что мастер подтвердил клиенту, но не успел отправить реплике. Плюс возможен split
brain: изолированный старый мастер продолжает принимать записи (пока не сработает
min-replicas-to-write), а после восстановления сети становится репликой и
отбрасывает их. Из-за этого распределённая блокировка на одном Redis не гарантирует
взаимного исключения: после failover два клиента могут одновременно считать
себя держателями.
slot = CRC16(key) mod 16384, каждый мастер владеет диапазоном слотов. Клиент сам
держит карту слотов и ходит на нужную ноду; прокси нет. MOVED - слот навсегда
переехал, надо обновить карту. ASK - слот прямо сейчас мигрирует, редирект
разовый и карту трогать нельзя.Почему именно 16384
Карта владения слотами передаётся в каждом сообщении gossip-протокола как битовая маска. 16384 бита занимают 2 КБ на сообщение, а 65536 заняли бы 8 КБ. При реальных размерах кластера (десятки нод) больше слотов не нужно, а трафик служебного протокола вырос бы вчетверо.
MOVED против ASK
MOVED slot addr | ASK slot addr | |
|---|---|---|
| Смысл | слот принадлежит другой ноде постоянно | слот мигрирует, конкретно этот ключ уже на приёмнике |
| Действие клиента | повторить на новой ноде и обновить карту слотов | послать ASKING + команду на указанную ноду один раз,
карту не обновлять |
| Когда | после ребалансировки или failover | во время активной миграции слота |
Почему ASK не обновляет карту: миграция идёт по ключам, и остальные ключи слота
пока лежат на исходной ноде. Если бы клиент переключил весь слот, он начал бы промахиваться
по ещё не переехавшим ключам. Отсюда и ASKING - одноразовое разрешение
обслужить ключ из мигрирующего слота.
Failover внутри кластера
Sentinel не нужен: реплики сами инициируют выборы, решение принимает большинство
мастеров. Если мастер потерян и реплики у него нет, при
cluster-require-full-coverage yes (умолчание) кластер перестаёт обслуживать
все запросы, а не только осиротевший диапазон. Это часто становится сюрпризом.
Нет баз данных: доступна только db 0, SELECT не работает.
Классический pub/sub рассылается по всем нодам (дорого), поэтому с Redis 7 есть
шардированный SPUBLISH/SSUBSCRIBE, привязанный к слоту канала.
И нет прозрачных мультиключевых операций между слотами, о них следующий вопрос.
MGET, MSET,
SUNION, MULTI/EXEC и Lua требуют, чтобы все ключи были в одном
слоте, иначе CROSSSLOT. Hash tag {...} заставляет CRC16
считаться только по части ключа - так связанные ключи попадают в один слот.Ошибка, которую увидишь
> MGET user:42:profile user:43:profile
(error) CROSSSLOT Keys in request don't hash to the same slot
Hash tag
# Без тега слоты разные:
user:42:profile -> CRC16("user:42:profile") mod 16384
user:42:orders -> CRC16("user:42:orders") mod 16384
# С тегом CRC16 берётся только от того, что внутри первых фигурных скобок:
user:{42}:profile -> CRC16("42") mod 16384
user:{42}:orders -> CRC16("42") mod 16384 # тот же слот
> MGET user:{42}:profile user:{42}:orders # работает
> EVAL "..." 2 user:{42}:profile user:{42}:orders # тоже работает
Обратная сторона
- Неудачный тег = горячий слот.
{tenant}при крупном арендаторе сложит весь его объём и трафик на одну ноду. Тег должен быть мелкогранулярным: пользователь, заказ, но не «весь клиент» и не «весь регион». - Тег ломает приём с репликами горячего ключа. Если размазываешь ключ по суффиксам
key#0..key#15, hash tag загонит все копии обратно в один слот - приём перестанет работать. - Клиент может маскировать проблему. go-redis умеет разбивать
MGETна несколько запросов по нодам, и код «работает». Но атомарности при этом нет: часть ключей может быть прочитана до чужой записи, часть - после.
Схему ключей в кластере проектируют заранее, а не когда упёрлись в
CROSSSLOT. Простое правило: если две сущности когда-нибудь придётся читать
или менять одной атомарной операцией, общий hash tag им нужен с самого начала. Переехать
на теги задним числом значит сменить все ключи, то есть полностью сбросить кэш.
MULTI/EXEC не даёт отката. Это осознанный выбор в пользу латентности и
доступности, и приложение должно быть к нему готово по умолчанию.Пять источников «негарантий»
- Вытеснение. При
allkeys-*Redis вправе выбросить любой ключ до истечения TTL. Значит, ключа может не оказаться в любой момент, и это штатная ситуация. - Падение процесса. С
everysecтеряется до секунды записей, без AOF всё с последнего снимка. - Failover. Репликация асинхронная: между ответом клиенту и доставкой реплике ничего не защищено.
- Split brain. Изолированный старый мастер принимает записи, которые потом будут отброшены.
- Транзакции.
MULTI/EXECдаёт атомарность выполнения и изоляцию (чужие команды не вклиниваются), но не даёт отката: ошибка одной команды на этапе выполнения не отменяет остальные.
Что из этого следует в коде
- Redis остаётся кэшем и вспомогательным состоянием, не источником правды. Всё, что нельзя потерять, имеет копию в БД или брокере.
- Ветку «в кэше нет» обязательно реализуют и тестируют, включая полный отказ Redis, при котором сервис деградирует, а не падает.
- Распределённая блокировка на Redis нужна как оптимизация, гарантии взаимного исключения она не даёт. Корректность даёт идемпотентность операции или fencing token, проверяемый на стороне ресурса.
- Если Redis служит хранилищем (Streams, сессии, очередь), нужны политика
noeviction, включённая персистентность и явно согласованное с продуктом допустимое окно потери.
«Redis не БД с ослабленными гарантиями, а структура данных в памяти с дополнительными механизмами сохранности. Он сознательно выбрал латентность и доступность: асинхронная репликация, вытеснение, fsync раз в секунду. Поэтому я проектирую так, чтобы потеря любого ключа или всего инстанса была неприятной, но не критичной - и явно проговариваю это на этапе дизайна, а не выясняю после первого failover.»
Что стоит запомнить
- maxmemory policy определяет, что Redis делает при нехватке памяти; noeviction превращает запись в ошибку вместо вытеснения.
- RDB и AOF различаются формой сохранения и окном потери; репликация сама по себе не равна резервной копии.
- Sentinel и Cluster решают разные задачи управления доступностью и распределением ключей.
Частые ошибки
- Не полагайся на volatile-* политику, если у ключей нет TTL: вытеснять может быть нечего.
- Не считай подтверждение записи репликой гарантией сохранения при failover без учёта асинхронности.
- Не включай AOF/RDB, не определив допустимое окно потери, восстановление и влияние фонового fork на ресурсы.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Что переживёт рестарт
В конфиге Redis задано это. Процесс завершился через 4 минуты после последнего RDB snapshot. Какой диапазон изменений может отсутствовать?
save 300 10
appendonly no
Ответ и разбор
Изменения после последнего успешно созданного snapshot могут отсутствовать, в данном примере примерно до четырёх минут, хотя точное окно зависит от фактического времени завершения и записи файла. RDB не фиксирует каждую команду в момент подтверждения. Выбери persistence по допустимому RPO, а не по названию режима.
Код-ревью
2. Найди ошибку в maxmemory policy
Redis используется как кэш, maxmemory-policy установлен в noeviction. При заполнении памяти SET начал возвращать OOM, а приложение трактует это как cache miss. Что неверно и как исправить поведение?
maxmemory 256mb
maxmemory-policy noeviction
save 300 10
appendonly no
Ответ и разбор
OOM - ошибка записи, не отсутствие ключа; трактовка её как miss скрывает деградацию и кэш перестаёт обновляться. Выбери политику вытеснения, подходящую к TTL и важности ключей, задай разумный memory budget и обработай write error отдельно. Падение/наполнение кэша всё равно требует ограничить нагрузку на источник.
Применение
3. Выбери RPO для сессий и очереди
Сессии могут попросить пользователя войти заново после сбоя; задания оплаты терять нельзя. Объясни, почему можно использовать разную persistence и где хранить источник истины для заданий.
Ответ и разбор
Для сессий потеря последних записей может быть допустимым продуктовым компромиссом, если это явно принято. Платёжное задание нельзя считать сохранённым только потому, что Redis вернул успех: durable source of truth должен быть в БД/outbox или брокере с подходящими гарантиями. Настройки RDB/AOF подбирай под RPO, но не подменяй Redis-кэш критичным журналом.
Применение
4. Определи, что решают Sentinel и Cluster
Команда предлагает Sentinel, чтобы автоматически распределить ключи по нескольким master и масштабировать объём. В чём ошибка в ожидании и какой инструмент относится к распределению?
Ответ и разбор
Sentinel мониторит master/replica и координирует failover; он не шардирует пространство ключей по нескольким master. Для распределения ключей и масштабирования объёма нужен Redis Cluster (или другой выбранный sharding слой), с ценой в виде ограничений multi-key операций и маршрутизации по hash slot.