voidlearn
 

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-lfu
Redis 4+
выбрасывает редко используемый ключ из всех кэш с устойчивым ядром горячих ключей и потоком разовых обращений - лучше LRU
allkeys-random выбрасывает случайный ключ когда все ключи равноценны, а экономия на учёте важна; редко
volatile-lru LRU, но только среди ключей с TTL смешанная нагрузка: часть данных - кэш (с TTL), часть - постоянная (без TTL)
volatile-lfu LFU среди ключей с TTLто же, но с профилем под LFU
volatile-random случайный среди ключей с TTLредко
volatile-ttl выбрасывает тот, которому осталось жить меньше всего когда TTL реально отражает ценность данных
Главная ловушка volatile-*

Если при volatile-* у ключей не окажется TTL, выбрасывать будет нечего - и Redis поведёт себя как noeviction: писать станет невозможно. Авария классическая: поставили volatile-lru, часть кода забыла TTL, память заполнилась «бессмертными» ключами, и сервис лёг с ошибками OOM, хотя политика вытеснения «вроде настроена». Отсюда правило из главы 7.4: TTL у каждого ключа, без исключений.

Что ещё спросят про maxmemory
  • Что входит в лимит. maxmemory ограничивает данные, а буферы клиентов (особенно output buffer у pub/sub и репликации) и фрагментация памяти живут сверх него. Поэтому лимит ставят примерно в 60–70 % физической памяти машины, а не в 95 %.
  • Вытеснение идёт в главном потоке. При активном вытеснении Redis тратит время на выбор жертв и освобождение памяти, и латентность растёт. lazyfree-lazy-eviction yes переносит освобождение в фоновый поток.
  • Как понять, что вытеснение идёт. INFO stats → evicted_keys. Ненулевой и растущий счётчик при падающем hit rate прямо говорит, что кэшу мало памяти. На эту метрику стоит повесить алерт.

LRU против LFU

LRU выбрасывает то, к чему дольше всего не обращались, а LFU то, к чему обращались реже всего. Разница видна на конкретном профиле нагрузки: LRU ломается на «сканирующем» трафике, когда поток разовых обращений вытесняет устойчиво горячие ключи.

Один поток обращений, ёмкость кэша - 4 ключа поток A A A A A A B C D E F G A горячий ключ: 6 обращений разовый «сканирующий» трафик: по одному разу снова A LRU - по времени последнего обращения состояние перед последним A (ёмкость 4): D E F G A вытеснен: к нему не обращались дольше всех, пока шли шесть разовых ключей. Последний запрос A --> ПРОМАХ, поход в БД Итог: устойчиво горячий ключ выбит мусорным трафиком. Так ломается любой ночной batch-обход, аналитический запрос или обход всех сущностей краулером. LFU - по частоте обращений состояние перед последним A (ёмкость 4): A · freq 6 E · 1 F · 1 G · 1 A уцелел: его счётчик 6 против 1 у новичков, выбрасывают всегда самого редкого. Последний запрос A --> ПОПАДАНИЕ Цена: счётчик надо затухать (lfu-decay-time), иначе вчерашний хит навсегда вытесняет сегодняшний. И на резкой смене моды LFU перестраивается медленнее.
Где LRU ломается. Хватит одного прохода по холодным данным, и LRU выбьет из кэша всё горячее. LFU устойчив к такому сканированию, но требует затухания счётчиков, иначе становится слишком консервативным.

Как 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 был бы фатально консервативным: вчерашний хит вечно вытеснял бы сегодняшний.

Как отвечать на «LRU или 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 секунд) до десятков секунднулевая
Что говорить про «always»

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.

Глубже: почему fork() опасен на больших инстансах

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 состоит из отдельных процессов-наблюдателей (нечётное число, минимум три), которые следят за мастером и репликами. По шагам:

  1. Мониторинг. Каждый sentinel пингует мастера и, если тот молчит дольше down-after-milliseconds, помечает его субъективно недоступным (SDOWN).
  2. Кворум. Кворум нужен, чтобы один потерявший сеть наблюдатель не мог единолично объявить мастера мёртвым. Sentinel опрашивает остальных, и если о недоступности заявили не меньше quorum из них, статус повышается до объективно недоступного (ODOWN).
  3. Выборы лидера. Sentinel-ы выбирают среди себя лидера (вариант Raft), который проведёт failover. Для выборов нужно большинство sentinel-ов - и это важнее кворума: при трёх sentinel-ах нужны два живых.
  4. Промоушен. Лидер выбирает лучшую реплику (по приоритету, объёму полученных данных, run id), делает ей REPLICAOF NO ONE, перенастраивает остальные реплики на неё и обновляет конфигурацию.
  5. Уведомление клиентов. Клиенты спрашивают у Sentinel адрес текущего мастера, а команды шлют уже ему напрямую. В go-redis для этого есть NewFailoverClient.
Что теряется при failover

Всё, что мастер успел подтвердить клиенту, но не успел отправить реплике. Случай не редкий: при обрыве сети мастер ещё какое-то время может принимать записи (пока не сработает 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 хватает с запасом.
  • Клиент сам знает карту слотов и ходит сразу на нужную ноду. Прокси нет: шардинг клиентский, и протокол его поддерживает.
slot = CRC16(key) mod 16384, слот закреплён за нодой, клиент знает карту user:42 CRC16 mod 16384 slot 15880 нода C карта слотов нода A слоты 0 - 5460 нода B слоты 5461 - 10922 нода C слоты 10923 - 16383 MOVED - слот навсегда живёт на другой ноде клиент GET user:42 нода A (не тот слот) MOVED 15880 10.0.0.3:6379 Клиент повторяет запрос на ноду C и ОБНОВЛЯЕТ свою карту слотов: следующие запросы по этому слоту сразу идут правильно. ASK - слот прямо сейчас мигрирует клиент GET user:42 нода C (отдаёт слот) ASK 15880 10.0.0.2:6379 Клиент шлёт ASKING + команду на ноду B ОДИН раз и карту НЕ обновляет: перенос ещё идёт, остальные ключи слота пока на прежнем месте. Мультиключевые операции работают, только если все ключи в ОДНОМ слоте: MGET, SUNION, транзакции MULTI/EXEC, Lua-скрипты. Иначе - CROSSSLOT Keys in request don't hash to the same slot. Решение - hash tag: в CRC16 берётся только часть ключа в фигурных скобках. user:{42}:profile и user:{42}:orders --> оба хешируются по "42" --> один слот --> MGET работает
Маршрутизация в кластере. MOVED означает постоянное перераспределение, и клиент обновляет карту. ASK даёт временный редирект во время миграции слота, карту трогать нельзя. Hash tag принудительно сводит связанные ключи в один слот.

Ограничения кластера

  • Мультиключевые операции только внутри слота. 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 не даёт строгих гарантий

  1. Данные могут исчезнуть по политике вытеснения. При allkeys-* Redis имеет полное право выбросить любой твой ключ до истечения TTL. Значит, приложение обязано корректно работать без ключа в любой момент.
  2. Данные могут потеряться при падении. При everysec пропадёт до секунды записей, без AOF всё с последнего снимка.
  3. Подтверждённая запись может потеряться при failover. Репликация асинхронная; окно между ответом клиенту и доставкой реплике не защищено ничем.
  4. Возможен split brain. Старый мастер в изолированном сегменте продолжает принимать записи, которые потом будут отброшены.
  5. Транзакции не такие, как в БД. MULTI/EXEC даёт атомарность выполнения и изоляцию (команды идут подряд без чужих между ними), но не даёт отката: если одна команда упала на этапе выполнения, остальные всё равно выполнятся.
Что это значит для приложения
  • Redis служит кэшем и вспомогательным состоянием, а не источником правды. Всё, что нельзя потерять, должно иметь копию в БД или в брокере.
  • Любой промах считается штатной ситуацией, а не ошибкой. Код без ветки «в кэше нет» сломан.
  • Распределённая блокировка на Redis работает как оптимизация (не делать одну работу дважды), а не как гарантия взаимного исключения. Корректность обеспечивают идемпотентностью операции или fencing token (разбор в главе 7.7).
  • Если Redis используют как очередь или хранилище (Streams, сессии), политику ставят noeviction, персистентность включают, а потерю секунды данных явно согласуют с продуктом.

Вопросы

9
Суть: поведение задаёт maxmemory-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 означает, что кэшу мало памяти.
Суть: LRU выбрасывает по времени последнего обращения, LFU - по частоте. LRU ломается на сканирующем трафике: один проход по холодным данным выбивает все горячие ключи. Redis не хранит LRU-список - он берёт выборку из 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.»

Суть: RDB - периодический бинарный снимок всего датасета; компактный, быстро восстанавливается, теряет всё с момента последнего снимка. AOF - журнал всех изменяющих команд; с дефолтным everysec теряет до секунды, но файл больше и старт медленнее. Правильный ответ на «что выбрать» - оба в гибридном режиме.
RDBAOF
Что пишетснимок всего датасетакаждую изменяющую команду
Потеря при падениивсё с последнего снимка - минуты 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() со всеми вытекающими рисками по памяти.

Глубже: почему fork опасен

BGSAVE и AOF rewrite делают fork(): дочерний процесс делит страницы с родителем, но каждая изменённая страница копируется. При высокой доле записей потребление памяти может вырасти почти вдвое - типичная причина OOM-kill там, где maxmemory выставлен близко к RAM. Сам fork() на десятках гигабайт занимает сотни миллисекунд (копирование таблицы страниц), и всё это время сервер не отвечает. Практика: maxmemory ≤ 60–70 % RAM, vm.overcommit_memory = 1, выключенные transparent huge pages и снятие снимков с реплики, а не с мастера.

Суть: AOF пишется в буфер мгновенно, на диск попадает только при 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. Это сознательный размен доступности на снижение риска потери.

Сценарий потери

  1. Клиент делает SET k v, мастер записал в память и ответил OK.
  2. Мастер падает (или изолируется сетью) до того, как команда ушла реплике.
  3. Sentinel проводит failover, реплика становится мастером - без этой записи.
  4. Старый мастер возвращается, становится репликой нового и отбрасывает свои неотправленные записи.
Как это звучит в сильном ответе

«Redis сознательно выбрал доступность и латентность вместо строгой согласованности: репликация асинхронная, поэтому окно между ответом клиенту и доставкой реплике ничем не защищено. Практический вывод: всё, что нельзя потерять, обязано иметь копию за пределами Redis. Снизить риск помогут min-replicas-to-write и WAIT, но они меняют доступность на durability и гарантии не дают.»

Суть: Sentinel - отдельные процессы-наблюдатели (нечётное число, от трёх), которые следят за мастером, договариваются о его недоступности по кворуму, выбирают лидера большинством голосов, промоутят лучшую реплику в мастера и сообщают новый адрес клиентам. Клиент подключается не к Redis, а к Sentinel.

Пять шагов failover

  1. SDOWN. Sentinel не получил ответа дольше down-after-milliseconds - помечает мастера субъективно недоступным.
  2. ODOWN. Опрашивает других sentinel-ов; если согласны не меньше quorum из них, статус становится объективно недоступным.
  3. Выборы лидера. Sentinel-ы выбирают, кто проведёт failover (вариант Raft). Для этого нужно большинство всех sentinel-ов, а не только кворум: при трёх нужны два живых.
  4. Промоушен. Лидер выбирает лучшую реплику (приоритет replica-priority, объём полученных данных, run id), даёт ей REPLICAOF NO ONE и перенастраивает остальные реплики на неё.
  5. Уведомление. Клиенты спрашивают у Sentinel адрес текущего мастера; в go-redis это redis.NewFailoverClient со списком sentinel-адресов и именем мастера.
Разница между quorum и большинством

Их путают почти всегда. quorum говорит, сколько sentinel-ов должны согласиться, что мастер недоступен. Большинство нужно, чтобы провести failover. Поэтому quorum можно поставить равным 1, но failover всё равно не произойдёт, если живо меньше половины sentinel-ов. Отсюда и требование нечётного числа от трёх и размещения их в разных зонах отказа: два sentinel-а в одной стойке отказоустойчивости не дают.

Что теряется при failover

Всё, что мастер подтвердил клиенту, но не успел отправить реплике. Плюс возможен split brain: изолированный старый мастер продолжает принимать записи (пока не сработает min-replicas-to-write), а после восстановления сети становится репликой и отбрасывает их. Из-за этого распределённая блокировка на одном Redis не гарантирует взаимного исключения: после failover два клиента могут одновременно считать себя держателями.

Суть: пространство ключей разбито на 16384 слота, slot = CRC16(key) mod 16384, каждый мастер владеет диапазоном слотов. Клиент сам держит карту слотов и ходит на нужную ноду; прокси нет. MOVED - слот навсегда переехал, надо обновить карту. ASK - слот прямо сейчас мигрирует, редирект разовый и карту трогать нельзя.

Почему именно 16384

Карта владения слотами передаётся в каждом сообщении gossip-протокола как битовая маска. 16384 бита занимают 2 КБ на сообщение, а 65536 заняли бы 8 КБ. При реальных размерах кластера (десятки нод) больше слотов не нужно, а трафик служебного протокола вырос бы вчетверо.

MOVED против ASK

MOVED slot addrASK slot addr
Смыслслот принадлежит другой ноде постоянно слот мигрирует, конкретно этот ключ уже на приёмнике
Действие клиентаповторить на новой ноде и обновить карту слотов послать ASKING + команду на указанную ноду один раз, карту не обновлять
Когдапосле ребалансировки или failover во время активной миграции слота

Почему ASK не обновляет карту: миграция идёт по ключам, и остальные ключи слота пока лежат на исходной ноде. Если бы клиент переключил весь слот, он начал бы промахиваться по ещё не переехавшим ключам. Отсюда и ASKING - одноразовое разрешение обслужить ключ из мигрирующего слота.

Failover внутри кластера

Sentinel не нужен: реплики сами инициируют выборы, решение принимает большинство мастеров. Если мастер потерян и реплики у него нет, при cluster-require-full-coverage yes (умолчание) кластер перестаёт обслуживать все запросы, а не только осиротевший диапазон. Это часто становится сюрпризом.

Глубже: чего нет в кластере

Нет баз данных: доступна только db 0, SELECT не работает. Классический pub/sub рассылается по всем нодам (дорого), поэтому с Redis 7 есть шардированный SPUBLISH/SSUBSCRIBE, привязанный к слоту канала. И нет прозрачных мультиключевых операций между слотами, о них следующий вопрос.

Суть: команда выполняется на одной ноде, а ключи могут лежать на разных - атомарности между нодами в Redis нет. Поэтому 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 им нужен с самого начала. Переехать на теги задним числом значит сменить все ключи, то есть полностью сбросить кэш.

Суть: ключ может быть выброшен вытеснением, до секунды записей теряется при падении, подтверждённая запись теряется при failover, возможен split brain, а MULTI/EXEC не даёт отката. Это осознанный выбор в пользу латентности и доступности, и приложение должно быть к нему готово по умолчанию.

Пять источников «негарантий»

  1. Вытеснение. При allkeys-* Redis вправе выбросить любой ключ до истечения TTL. Значит, ключа может не оказаться в любой момент, и это штатная ситуация.
  2. Падение процесса. С everysec теряется до секунды записей, без AOF всё с последнего снимка.
  3. Failover. Репликация асинхронная: между ответом клиенту и доставкой реплике ничего не защищено.
  4. Split brain. Изолированный старый мастер принимает записи, которые потом будут отброшены.
  5. Транзакции. 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.