← назад к разделу

ДРУГИЕ ХРАНИЛИЩА Redis в production

Обязательно

Redis: персистентность, память и репликация

Java · Go · Node · Python

Как RDB и AOF сохраняют данные, выбрать вытеснение, пережить отказ узла и не остановить сервер большой командой.

1. RDB быстрый снимок, AOF короткое окно

1 / 5

RDB сохраняет состояние на момент снимка и быстро поднимается. AOF записывает команды и теряет примерно секунду.

  • AOF периодически перезаписывается.
  • Реплика помогает чтению и резерву, Sentinel переключает.
  • Репликация не отменяет снапшоты и восстановление.
RDBснимок на моментAOFжурнал командПотеря между RDB и секунда AOF

2. maxmemory оставляет запас

2 / 5

Обычно 60-70% памяти машины: нужны копирование страниц при снимке и клиентские буферы.

  • allkeys-lru вытесняет кэш.
  • allkeys-lfu сохраняет часто используемые.
  • noeviction для незаменимых данных: отказ записи становится аварией.
Redis 60-70%запасcopy + clientsПолитика вытеснения зависит от роли

3. Cluster делит 16384 слота

3 / 5

Номер слота считается CRC16 от ключа. Кластер нужен при нехватке памяти или записи одного узла.

  • Минимум три мастера.
  • Команды разных слотов дают CROSSSLOT, хеш-тег {id} собирает ключи.
  • Переезд слотов планируется отдельно.
user:{42}:profileuser:{42}:sessionодин слотнет CROSSSLOTCRC16(key) % 16384

4. Большая команда останавливает всех

4 / 5

KEYS * и DEL большого ключа блокируют сервер. SCAN идёт порциями, UNLINK убирает ключ в фоне.

  • SLOWLOG и --latency показывают задержку.
  • Попадания ниже 80% - повод разобраться.
  • repl-backlog-size защищает частичную пересинхронизацию, sync_full и partial_ok контролируют.
KEYS + DELвсе ждутSCAN + UNLINKпорции и фонSLOWLOG показывает виновных

5. Отставание реплики - отдельная метрика

5 / 5

Репликация масштабирует чтение и помогает при отказе, но отставание нужно видеть. Переполнение backlog вызывает полную пересинхронизацию.

  • Частичная синхронизация спасает короткий разрыв.
  • sync_full и sync_partial_ok показывают здоровье.
  • Управляемый Redis снимает переключения, но память и команды остаются вашими.
primaryreplicalag наблюдаетсяrepl-backlog-size

Redis — это хранилище в оперативной памяти. Казалось бы, процесс упал — данные пропали. На самом деле Redis умеет сохраняться на диск, выдерживать отказ сервера и масштабироваться на несколько машин. Разберём, как это работает и какие настройки выбрать.

клиент считает слот сам и идёт сразу на нужный узел команда клиента SET user:7 GET user:100 GET user:100 повтор после сбоя CRC16(ключ) % 16384 слот 2780 слот 9308 слот 9308 узел A слоты 0–5460 узел B слоты 5461–10922 узел C слоты 10923–16383 replica A replica B replica C узел Aслоты 0–5460 узел Bслоты 5461–10922 узел B не отвечает replica B → primaryслоты 5461–10922кластер сам повысил replica, слоты те же

Пространство ключей разрезано на 16384 слота, и каждый слот закреплён за узлом. Номер слота считается из имени ключа, поэтому клиент знает адрес заранее и ходит на один узел, а не опрашивает все. Когда узел отвечать перестаёт, его слоты целиком переезжают на replica — диапазон не меняется, меняется только машина, которая за него отвечает.

Персистентность: RDB и AOF

По умолчанию Redis хранит данные только в памяти. Если сервер перезапустится — всё пропадёт. Чтобы этого не было, есть два механизма сохранения.

RDB (Redis Database) — это снапшот: Redis делает полный «снимок» данных на диск в двоичном формате (dump.rdb). Снапшот можно делать по расписанию или вручную командой SAVE / BGSAVE. BGSAVE запускает дочерний процесс и не блокирует Redis.

# Сохранить снапшот вручную (асинхронно, не блокирует)
BGSAVE

# Посмотреть, когда было последнее сохранение
LASTSAVE

Конфигурация в redis.conf:

# Сохранять снапшот если за 60 секунд изменилось хотя бы 1000 ключей
save 60 1000
# Файл снапшота
dbfilename dump.rdb
dir /var/lib/redis

Плюс RDB: компактный файл, быстрый старт после перезапуска. Минус: если сервер упал между снапшотами — данные за этот промежуток теряются.

Есть и второй минус, менее очевидный и куда более болезненный. «Не блокирует Redis» не значит «бесплатно». Дочерний процесс получает не копию данных, а те же самые страницы памяти, и копируются они по мере изменения: пока идёт снимок, каждая запись в Redis отщепляет ещё одну страницу. При активной записи потребление памяти за время снимка может вырасти почти вдвое. Именно так Redis чаще всего и погибает: снимок стартовал в час пик, памяти не хватило, ядро убило процесс. Отсюда практическое правило — держать maxmemory заметно ниже объёма памяти машины, с запасом на снимок.

И ещё одна строка, о которой вспоминают только в момент аварии: по умолчанию включён режим stop-writes-on-bgsave-error yes. Если очередной снимок не удался — кончилось место на диске, не хватило прав на каталог, — Redis начинает отказывать во всех записях, пока снимок не пройдёт успешно. Со стороны приложения это выглядит загадочно: кэш вдруг отвечает ошибкой на каждый SET, хотя память свободна и сервер жив. Причина при этом лежит в журнале Redis одной строкой про неудавшийся BGSAVE.

AOF (Append Only File) — это журнал команд: каждая команда записи дописывается в файл (appendonly.aof). При рестарте Redis просто «воспроизводит» весь файл заново.

# Включить AOF
appendonly yes
appendfilename "appendonly.aof"

# Политика fsync (сброс на диск):
# always  — после каждой команды (максимальная надёжность, медленнее)
# everysec — раз в секунду (хороший баланс, потеря максимум 1 с данных)
# no      — ОС решает сама (быстро, ненадёжно)
appendfsync everysec

Плюс AOF: теряете максимум одну секунду данных. Минус: файл растёт, старт медленнее. Redis умеет периодически «сжимать» AOF (BGREWRITEAOF), убирая лишние команды. С Redis 7.0 журнал лежит не одним файлом: базовый снимок и добавочные хранятся в каталоге appendonlydir, а appendfilename задаёт лишь основу их имён.

Короткая формула: для важных данных включайте и RDB, и AOF одновременно — Redis умеет работать с обоими. При старте приоритет у AOF.

RDB снимок 12:00 записи мимо диска падение 12:05 потеряно 5 мин AOF журнал открыт каждая в журнал падение 12:05 потеряна 1 с

Одна и та же авария в двух режимах: смотрите на правый столбец, там вся разница между снимком раз в пять минут и журналом с ежесекундным сбросом.

Память и политики вытеснения

Redis живёт в RAM, значит нужно задать лимит — иначе он съест всю память сервера.

# Лимит памяти (например, 512 мегабайт)
maxmemory 512mb

# Что делать когда лимит достигнут — политика вытеснения
maxmemory-policy allkeys-lru

Политики вытеснения — что Redis делает, когда памяти не осталось (по умолчанию noeviction):

ПолитикаПоведение
noevictionОтказывать в записи ошибкой (данные не трогает)
allkeys-lruВытеснять любые ключи по принципу «давно не использовался»
volatile-lruВытеснять только ключи с TTL, по LRU
allkeys-lfuВытеснять любые ключи по принципу «реже всего запрашивался»
volatile-lfuТо же, но только среди ключей с TTL
allkeys-randomВытеснять любые ключи случайно
volatile-ttlВытеснять ключи с наименьшим оставшимся TTL
volatile-randomВытеснять ключи с TTL случайно

Когда что выбирать:

  • Redis как кэш (данные можно потерять) → allkeys-lru. Самый распространённый выбор.
  • Если в кэше есть заметно «горячие» ключи, которые нужны постоянно, → allkeys-lfu: он смотрит не на время последнего обращения, а на частоту.
  • Redis как кэш, но только часть ключей с TTL → volatile-lru.
  • Redis как основное хранилище (нельзя терять данные) → noeviction плюс контроль памяти со стороны приложения.

Рядом со значением Redis держит две пометки: когда к ключу обращались в последний раз и как часто. Первую смотрит LRU, вторую — LFU:

живой пример

import java.util.LinkedHashMap;
import java.util.Map;

public class EvictionDemo {
    record Stat(long lastUsed, long hits) {}

    public static void main(String[] args) {
        Map<String, Stat> keys = new LinkedHashMap<>();
        String[] calls = {"user:42", "user:42", "user:42", "user:42", "user:42",
                "report:jan", "report:feb", "report:jan"};
        for (int tick = 1; tick <= calls.length; tick++) {
            Stat was = keys.getOrDefault(calls[tick - 1], new Stat(0, 0));
            keys.put(calls[tick - 1], new Stat(tick, was.hits() + 1));
        }
        String lru = null, lfu = null;
        for (String key : keys.keySet()) {
            if (lru == null || keys.get(key).lastUsed() < keys.get(lru).lastUsed()) lru = key;
            if (lfu == null || keys.get(key).hits() < keys.get(lfu).hits()) lfu = key;
        }
        keys.forEach((key, stat) -> System.out.println(
                key + ": обращений " + stat.hits() + ", последнее в такт " + stat.lastUsed()));
        System.out.println("allkeys-lru выбросит " + lru);
        System.out.println("allkeys-lfu выбросит " + lfu);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import "fmt"

type stat struct{ lastUsed, hits int }

func main() {
	calls := []string{"user:42", "user:42", "user:42", "user:42", "user:42", "report:jan", "report:feb", "report:jan"}
	var order []string
	keys := map[string]stat{}
	for tick, key := range calls {
		was, seen := keys[key]
		if !seen {
			order = append(order, key)
		}
		keys[key] = stat{tick + 1, was.hits + 1}
	}
	lru, lfu := order[0], order[0]
	for _, key := range order {
		if keys[key].lastUsed < keys[lru].lastUsed {
			lru = key
		}
		if keys[key].hits < keys[lfu].hits {
			lfu = key
		}
	}
	for _, key := range order {
		fmt.Printf("%s: обращений %d, последнее в такт %d\n", key, keys[key].hits, keys[key].lastUsed)
	}
	fmt.Println("allkeys-lru выбросит", lru)
	fmt.Println("allkeys-lfu выбросит", lfu)
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const calls = ["user:42", "user:42", "user:42", "user:42", "user:42", "report:jan", "report:feb", "report:jan"];
const keys = new Map();
calls.forEach((key, i) => {
  const was = keys.get(key) ?? { lastUsed: 0, hits: 0 };
  keys.set(key, { lastUsed: i + 1, hits: was.hits + 1 });
});
let lru = null, lfu = null;
for (const key of keys.keys()) {
  if (lru === null || keys.get(key).lastUsed < keys.get(lru).lastUsed) lru = key;
  if (lfu === null || keys.get(key).hits < keys.get(lfu).hits) lfu = key;
}
for (const [key, stat] of keys) console.log(`${key}: обращений ${stat.hits}, последнее в такт ${stat.lastUsed}`);
console.log("allkeys-lru выбросит", lru);
console.log("allkeys-lfu выбросит", lfu);
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

calls = ["user:42", "user:42", "user:42", "user:42", "user:42", "report:jan", "report:feb", "report:jan"]
keys = {}
for tick, key in enumerate(calls, start=1):
    last_used, hits = keys.get(key, (0, 0))
    keys[key] = (tick, hits + 1)

lru = min(keys, key=lambda k: keys[k][0])
lfu = min(keys, key=lambda k: keys[k][1])
for key, (last_used, hits) in keys.items():
    print(f"{key}: обращений {hits}, последнее в такт {last_used}")
print("allkeys-lru выбросит", lru)
print("allkeys-lfu выбросит", lfu)
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

LRU выбросит user:42 — к нему обращались давно, хотя чаще всех; LFU выбросит report:feb и оставит горячий ключ. Частоту Redis считает приблизительно и постепенно обнуляет (lfu-log-factor, lfu-decay-time), иначе ключ, популярный год назад, не вытеснился бы никогда.

Одну вещь эта программа приукрашивает, и её стоит проговорить. Она перебирает все ключи и находит худший честно — настоящий Redis так не делает. Перебирать миллионы ключей на каждой нехватке памяти слишком дорого, поэтому он берёт случайную горстку (по умолчанию пять штук, настройка maxmemory-samples) и выбрасывает худший из неё. Значит, и LRU, и LFU здесь — приближения: вылетит действительно холодный ключ, но не обязательно самый холодный из всех. Увеличенная выборка даёт результат ближе к идеальному и стоит дороже по процессору.

Обёртка кэша в приложении (@Cacheable в Spring, cached в Go, cache.wrap в NestJS, @cached в Python) о вытеснении не знает: при следующем обращении к выброшенному ключу значение просто вычислится заново.

Сколько памяти заложить

Число в maxmemory — не размер машины, и разница между ними существенна.

Запас на снимок. Копирование изменяемых страниц при снимке, описанное выше, на нагруженной записью базе может удвоить расход памяти, поэтому maxmemory ставят заметно ниже физической памяти: обычно 60–70 % от неё, а не 90 %.

Буферы клиентов. Каждое соединение имеет буфер вывода, и он не входит в учёт данных. Опасен здесь не обычный клиент, а реплика: её буфер (client-output-buffer-limit replica) должен вмещать поток изменений за время первичной синхронизации. Если он переполнится, сервер разорвёт соединение с репликой, та начнёт синхронизацию заново — и так по кругу, бесконечной чередой полных перекачек. Симптом узнаваемый: реплика никогда не догоняет, а в журнале повторяются сообщения о начале полной синхронизации.

Фрагментация. Отношение занятой процессом памяти к учтённым данным (mem_fragmentation_ratio в INFO memory): около 1,0–1,5 — норма, 2,0 и выше — память тратится впустую, помогает включение фоновой дефрагментации (activedefrag yes). Значение меньше 1,0 означает, что часть памяти ушла в подкачку, и это уже авария: Redis с подкачкой перестаёт быть быстрым.

Частичная пересинхронизация

Разрыв связи с репликой не обязан означать полную перекачку данных. Мастер держит кольцевой буфер последних изменений (repl-backlog-size), и если реплика вернулась, пока её позиция ещё в буфере, она догоняет частично — получает только пропущенное.

Отсюда практический смысл настройки: буфер должен покрывать типичное время разрыва. При потоке записи в десять мегабайт в секунду и желании пережить минутный разрыв нужно порядка шестисот мегабайт — значение по умолчанию (единицы мегабайт) переживает лишь секунды. Проверяется это по счётчикам sync_full и sync_partial_ok в INFO stats: если полных синхронизаций много, буфер мал.

Кластер: чего не умеет и что придётся делать руками

Три вещи, которые определяют жизнь с кластером.

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

Команды на несколько ключей не работают между слотами. MGET a b c, SINTER, транзакция и скрипт на Lua, если ключи попали в разные слоты, отвечают ошибкой CROSSSLOT Keys in request don't hash to the same slot. Лечится хеш-тегом: часть ключа в фигурных скобках определяет слот, поэтому user:{42}:profile и user:{42}:cart гарантированно лежат вместе. Планировать это надо заранее: переименовать ключи в работающей системе дороже, чем придумать схему сразу.

Переезд слотов — отдельная операция. Добавление узла не перераспределяет данные само: слоты переносят командой перебалансировки, и это онлайновый процесс (ключи мигрируют пачками, клиенты получают перенаправления). Он безопасен, но не мгновенен и нагружает кластер, поэтому его планируют, а не делают в час пик.

Пороги для мониторинга

Поля INFO полезны с числами, а не сами по себе.

Коэффициент попаданий (keyspace_hits к сумме с keyspace_misses): для кэша ниже 80 % — повод разбираться, ниже 50 % — кэш скорее мешает, чем помогает.

Вытеснения (evicted_keys): нули при политике вытеснения означают, что памяти хватает. Постоянный поток вытеснений — это работающая политика; тревожит не сам факт, а рост темпа и одновременное падение попаданий: значит, из памяти выбрасывается то, что ещё нужно.

Отказы по памяти (рост ошибок записи OOM command not allowed) при политике noeviction — авария: приложение уже не может писать.

Заблокированные клиенты (blocked_clients) — те, кто ждёт на блокирующих командах. Небольшое постоянное число нормально для очередей на списках; рост означает, что обработчики не справляются.

Задержка. SLOWLOG GET показывает последние медленные команды с их аргументами — первое место, куда смотрят при жалобе на подтормаживания; redis-cli --latency измеряет задержку самого сервера, --latency-history показывает её во времени.

Отставание реплики (master_repl_offset против позиции реплики, lag в INFO replication) — метрика, по которой ставят оповещение: отставание в секунды означает, что чтение с реплики отдаёт устаревшие данные.

Свой Redis, управляемый сервис или Valkey

Вопрос «что именно вы эксплуатируете» стоит первым. Свой экземпляр на своей машине — полный контроль и полная ответственность: обновления, безопасность, переключение при отказе, резервные копии, дежурство. Управляемый сервис снимает это за деньги и ограничивает: часть команд запрещена, настройки доступны не все, версия обновляется по расписанию провайдера.

Отдельный вопрос — какой именно продукт. После смены лицензии Redis появился форк Valkey под свободной лицензией, поддержанный крупными игроками; он совместим по протоколу и командам, и часть облаков предлагает именно его. Практический вывод: при выборе между «Redis» и «Valkey» смотрят не на возможности (они совпадают), а на то, что поддерживает ваш провайдер и под какой лицензией вам можно это использовать.

Репликация: primary и replica

Репликация позволяет держать несколько копий данных на разных серверах. Один сервер — primary (принимает записи), остальные — replica (только чтение, синхронизируются с primary).

# На replica в redis.conf (или в CLI):
REPLICAOF 192.168.1.10 6379

Replica постоянно получает поток изменений от primary. При первом подключении primary делает BGSAVE, отправляет снапшот и досылает накопленные команды — так replica догоняет.

Что даёт репликация:

Само по себе replica не переключается в режим primary при сбое — для этого нужен Sentinel.

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

Sentinel: отказоустойчивость

Redis Sentinel — это набор процессов-наблюдателей, которые следят за primary и replica. Если primary стал недоступен, Sentinel голосованием выбирает новый primary из replica и оповещает приложения.

# sentinel.conf — минимальная конфигурация
sentinel monitor mymaster 192.168.1.10 6379 2
# «mymaster» — имя кластера
# 2 — кворум: сколько Sentinel должны согласиться, что primary недоступен
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000

Типичная схема: 3 узла Sentinel (нечётное число для кворума), 1 primary, 1-2 replica. Приложение подключается к Sentinel, тот сообщает актуальный адрес primary.

В этой схеме кворум и большинство случайно совпали, и из-за этого их часто путают. Это разные числа с разными задачами. Кворум отвечает только на вопрос «считаем ли primary упавшим»: столько наблюдателей должны увидеть его недоступным, чтобы объявить отказ. А чтобы кто-то из них действительно провёл переключение, нужно согласие большинства всех Sentinel — и это большинство считается от их общего числа, настройкой его не понизить. Поэтому кворум меньше большинства ничего не ускоряет: при трёх наблюдателях и кворуме 1 отказ заметит один, а переключать всё равно будут двое. И поэтому же двух Sentinel недостаточно: стоит одному из них самому оказаться недоступным — большинства уже не собрать, и переключения не будет.

primary молчит нет ответа 5 с отказ признан кворум 2 из 3 выбран лидер согласие большинства новый primary Sentinel даёт адрес

Три кадра переключения подряд: обратите внимание, что кворум только признаёт отказ, а переключение проводит большинство всех наблюдателей.

Клиенту перечисляют адреса Sentinel и имя мастера, текущий primary он находит сам.

В Spring Boot это application.yml:

spring:
  data:
    redis:
      sentinel:
        master: mymaster
        nodes:
          - sentinel1:26379
          - sentinel2:26379
          - sentinel3:26379

В go-redis — NewFailoverClient:

rdb := redis.NewFailoverClient(&redis.FailoverOptions{
	MasterName:    "mymaster",
	SentinelAddrs: []string{"sentinel1:26379", "sentinel2:26379", "sentinel3:26379"},
})

В node-redis — createSentinel:

const client = createSentinel({
  name: 'mymaster',
  sentinelRootNodes: [
    { host: 'sentinel1', port: 26379 },
    { host: 'sentinel2', port: 26379 },
    { host: 'sentinel3', port: 26379 },
  ],
});
await client.connect();

В redis-py — Sentinel:

sentinel = Sentinel([("sentinel1", 26379), ("sentinel2", 26379), ("sentinel3", 26379)])
redis = sentinel.master_for("mymaster")

Cluster: шардирование

Redis Cluster нужен когда данных слишком много для одного сервера или нагрузка на запись слишком высокая. Cluster автоматически распределяет ключи по нескольким primary-узлам — это и есть шардирование.

Cluster делит пространство ключей на 16384 хэш-слота. Каждый primary отвечает за свой диапазон слотов.

Номер слота — это CRC16 от имени ключа по модулю 16384; клиент считает его сам и идёт сразу на нужный узел. Если в имени есть фигурные скобки, хешируется только то, что внутри: {user:42}:cart и {user:42}:orders гарантированно попадут в один слот. Это важно, потому что операция сразу над несколькими ключами (MGET, транзакция, скрипт) в кластере работает только внутри одного слота.

# Минимальный кластер: 3 primary + 3 replica (6 узлов)
# Запуск через redis-cli:
redis-cli --cluster create \
  192.168.1.10:6379 192.168.1.11:6379 192.168.1.12:6379 \
  192.168.1.13:6379 192.168.1.14:6379 192.168.1.15:6379 \
  --cluster-replicas 1

Для кластера клиенту дают несколько стартовых узлов, карту слотов он получит сам.

В Spring Boot это тоже application.yml:

spring:
  data:
    redis:
      cluster:
        nodes:
          - 192.168.1.10:6379
          - 192.168.1.11:6379
          - 192.168.1.12:6379

В go-redis — NewClusterClient:

rdb := redis.NewClusterClient(&redis.ClusterOptions{
	Addrs: []string{"192.168.1.10:6379", "192.168.1.11:6379", "192.168.1.12:6379"},
})

В node-redis — createCluster:

const client = createCluster({
  rootNodes: [
    { url: 'redis://192.168.1.10:6379' },
    { url: 'redis://192.168.1.11:6379' },
    { url: 'redis://192.168.1.12:6379' },
  ],
});
await client.connect();

В redis-py — RedisCluster:

redis = RedisCluster(host="192.168.1.10", port=6379)

Клиенты (Lettuce в Spring Data Redis, go-redis, node-redis, redis-py) умеют работать с кластером прозрачно: сам считает слот и идёт сразу на нужный узел.

Только карту слотов он запрашивает при подключении и по умолчанию больше не обновляет. Узлы переключились, слоты переехали — а клиент продолжает ходить по старой карте, ловить перенаправления и тратить на них лишний круг, пока приложение не перезапустят. Поэтому в кластере обновление карты включают явно: по расписанию и по событию перенаправления.

ClusterTopologyRefreshOptions.builder()
    .enablePeriodicRefresh(Duration.ofMinutes(1))
    .enableAllAdaptiveRefreshTriggers()
    .build();
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
    if err := cluster.ReloadState(ctx); err != nil {
        log.Warn().Err(err).Msg("обновление топологии Redis Cluster")
    }
}
const cluster = new Redis.Cluster(startupNodes, {
  slotsRefreshInterval: 60_000,
  slotsRefreshTimeout: 2_000,
});
redis = RedisCluster(
    host="192.168.1.10",
    port=6379,
    reinitialize_steps=1,
)

В Go ReloadState перечитывает состояние кластера по таймеру, в Node slotsRefreshInterval делает то же самое внутри клиента. В redis-py reinitialize_steps=1 обновляет всю топологию после каждого ответа MOVED, а не по расписанию: выбирайте его осознанно, чтобы частые перенаправления не превратились в постоянные перестроения пула.

Что выбрать: RDB, Sentinel или Cluster

Порядок выбора простой: одному серверу с некритичными перезапусками хватает RDB; нужна отказоустойчивость — Sentinel; данные не помещаются на один сервер — Cluster, и переключение при сбое у него своё, Sentinel рядом с ним не ставят.

Мониторинг и типичные проблемы

Команда INFO

INFO возвращает подробную статистику о состоянии сервера:

# Общая информация
INFO

# Только раздел памяти
INFO memory

# Только статистика репликации
INFO replication

Ключевые метрики:

  • used_memory — сколько памяти занято
  • connected_clients — число подключений
  • keyspace_hits / keyspace_misses — попадания и промахи кэша
  • rdb_last_bgsave_status — статус последнего снапшота
  • role — primary или replica

Медленные команды

Redis выполняет команды в один поток, поэтому команда, чьё время пропорционально размеру данных, — это пауза для всех клиентов сразу. Классика — KEYS *: перебор всего пространства ключей одним неделимым вызовом, на миллионах ключей это секунды простоя. Замена — SCAN: курсорный обход порциями, между вызовами сервер обслуживает остальных; в ответ могут попасть дубли, а вызывать его нужно до нулевого курсора. Тот же приём у родственников: вместо SMEMBERS на огромном множестве — SSCAN, FLUSHALL — с ASYNC. Долгий Lua-скрипт блокирует сервер точно так же: атомарность скриптов не бесплатна.

Чтобы такие команды не искать вручную, Redis ведёт журнал медленных:

# Настроить порог в redis.conf (в микросекундах, 10000 = 10 мс)
slowlog-log-slower-than 10000
slowlog-max-len 128

Порог живёт в конфигурации, а журнал смотрят уже из клиента:

# Посмотреть медленные команды
SLOWLOG GET 10

# Итерировать ключи без блокировки (вместо KEYS)
SCAN 0 MATCH user:* COUNT 100

Большие ключи

Огромное значение в одном ключе (например, список из миллиона элементов) — частая проблема. Такой ключ:

  • долго сериализуется при сохранении в RDB/AOF;
  • блокирует сервер при операциях вроде DEL (удаление большого списка — синхронная операция).

Такие ключи удаляют UNLINK вместо DEL — освобождение памяти уходит в фон.

# Найти большие ключи (встроенный сканер)
redis-cli --bigkeys

# Асинхронное удаление большого ключа
UNLINK huge_list_key
Дополнительно: при первом чтении можно пропустить

Глубже: свой Redis или управляемый сервисрасширенное

Вся статья выше про redis.conf, Sentinel и Cluster, и честный вопрос после неё: кто это будет обслуживать. В 2026 году у большинства команд ответ «облако», и полезно понимать, что при этом меняется, а что остаётся вашим.

Управляемый сервис (ElastiCache и MemoryDB у AWS, Memorystore у Google, Managed Redis у российских облаков) берёт на себя то, что в этой статье про эксплуатацию: установку и обновление версий, реплику и переключение при отказе, снимки по расписанию, метрики в общей консоли. По умолчанию облака предлагают Valkey, а не Redis, из-за истории с лицензией, о которой сказано в статье про основы, и для приложения это ничего не меняет. Наценка за управление обычно в полтора-два раза к цене тех же виртуальных машин, и её сравнивают не с железом, а с часами инженера на ночные переключения.

Что остаётся вашим при любом варианте. Размер памяти и политика вытеснения: облако даст выбрать, но не подскажет, что noeviction уронит запись, когда память кончится. Устройство ключей и большие ключи: ни один провайдер не спасёт от HGETALL на миллионе полей. Медленные команды и число поездок, то есть всё из статей про структуры и про основы. Режим отказоустойчивости: реплика в другой зоне доступности стоит денег и включается явно. И разделение сред: один экземпляр на кэш и на очереди задач это привычная экономия, которая при первом инциденте кэша роняет и очереди.

Что остаётся недоступным: часть настроек redis.conf, модули, которые провайдер не включил, и точная версия, к которой вы привыкли. Своя установка оправдана, когда нужен именно Redis Stack с модулями, когда трафик такой, что наценка облака становится заметной статьёй, или когда данные по требованию регулятора не могут покинуть контур. Тогда всё, что в этой статье, становится вашей работой, и вопрос «кто дежурит» задают до установки, а не после.

То же самое относится к ClickHouse: ClickHouse Cloud и управляемые версии у облаков снимают ZooKeeper и Keeper, реплики и резервные копии, но оставляют вам порядок сортировки, размер вставок и число партиций, потому что это свойства ваших данных, а не сервиса.

Коротко

  • RDB — снапшот на диск, быстрый старт, возможна потеря данных между снапшотами.
  • AOF — журнал команд, теряете максимум секунду, файл нужно периодически перезаписывать.
  • maxmemory ставят на 60–70 % памяти машины (запас на копирование страниц при снимке и на буферы клиентов), а политику выбирают по роли: allkeys-lru для кэша, allkeys-lfu для горячих ключей, noeviction для хранилища, где данные нельзя терять.
  • Репликация даёт резервную копию и масштабирование чтения; Sentinel добавляет автоматическое переключение при сбое.
  • Cluster нужен при нехватке памяти или записи на одном узле: 16384 слота, номер слота — CRC16 от ключа.
  • KEYS * блокирует сервер — используйте SCAN; DEL большого ключа тоже блокирует — используйте UNLINK.
  • Управляемый Redis снимает переключения, снимки и обновления за наценку в полтора-два раза; память, вытеснение, ключи и медленные команды остаются вашими при любом варианте.
  • Переполнение буфера реплики даёт бесконечную полную пересинхронизацию; короткий разрыв закрывает частичная, её ёмкость задаёт repl-backlog-size, а проверяется счётчиками sync_full и sync_partial_ok.
  • Кластеру нужно минимум три мастера, команды на ключи из разных слотов отвечают CROSSSLOT (лечится хеш-тегом), а переезд слотов — отдельная плановая операция.
  • Пороги: попадания ниже 80 % — разбираться, отказ записи при noeviction — авария, SLOWLOG и --latency показывают задержку, отставание реплики выносят в оповещение.

Что почитать дальше