Прикладное продолжение вводной статьи про Redis. Там разбирали, где Redis как класс решений уместен, а где только усложняет систему. Здесь — слой ниже: как к нему подключаться из приложения и какие проблемы начинаются сразу после первого успешного PING.
PING проверяет, что процесс жив и до него есть сеть. Он ничего не говорит про то, что будет с вашим сервисом, когда primary уедет в failover, когда узел кластера отвалится посреди запроса, когда пул исчерпается или когда сеть моргнёт на 200 мс. Статья про это: про timeout, pool, multiplexing, reconnect и — главное — про поведение клиентов Go, Java и Rust в топологиях Sentinel и Cluster.
Материал для тех, кто уже решил, что Redis в системе нужен, и теперь выбирает и настраивает клиент. Базовое знакомство с Redis (что такое ключ, TTL, replica) предполагается — оно в вводной статье.
В статье
- Почему
PING— это ещё не интеграция - Общие правила, одинаковые для всех языков
- Go: go-redis
- Java: Lettuce и Jedis
- Rust: redis-rs
- Timeout, retry и reconnect
- Sentinel: подключение и поведение при failover
- Cluster: подключение и особенности
- Что происходит при падении узла
- Типичные ошибки при интеграции
- Production-checklist
Для примеров возьмём одну маленькую доменную задачу вместо абстрактного SET/GET: read-through кэш профиля пользователя — GET user:{id}, при промахе читаем из БД и кладём обратно с TTL. Этого достаточно, чтобы показать всё важное про соединение, и не превратить статью в разбор трёх SDK.
Почему PING — это ещё не интеграция
Первый пример из любого README выглядит одинаково: создать клиент, дёрнуть PING, получить PONG, обрадоваться. Проблема в том, что этот путь не проходит ни через один из режимов, в которых Redis реально ломается:
- соединение живёт долго — а значит, его надо переиспользовать, а не открывать заново на каждый запрос, и уметь пересоздавать, когда оно порвётся;
- сеть не мгновенная и не идеальная — без явных timeout запрос к зависшему узлу висит до TCP-таймаута ОС (десятки секунд), утаскивая за собой goroutine/поток/задачу;
- топология меняется — primary становится replica и наоборот, слоты кластера переезжают между узлами, и клиент должен это заметить;
- под нагрузкой всё это происходит одновременно — failover случается ровно тогда, когда запросов больше всего.
PING не проверяет ничего из этого. Поэтому дальше — не про то, как подключиться, а про то, как подключиться так, чтобы пережить деградацию.
Общие правила, одинаковые для всех языков
Независимо от языка и библиотеки работают одни и те же принципы:
- Клиент создаётся один раз на весь процесс и переиспользуется. Почти все зрелые клиенты внутри держат пул или мультиплексируют соединение — создавать новый клиент на запрос значит терять весь этот механизм.
- Timeout задаются явно и на всё: установка соединения (dial/connect), чтение, запись, а на уровне приложения — ещё и общий deadline на операцию (context/
CompletableFuture/timeout). Дефолты часто либо бесконечны, либо не те, что вам нужны. - Модель соединения выбирается осознанно: пул отдельных соединений (blocking), одно мультиплексированное (async), или пул мультиплексированных. Это не деталь — от неё зависит, как клиент ведёт себя под нагрузкой и при обрыве.
- Поведение при обрыве и топологии продумывается заранее, а не после первого ночного инцидента: сколько раз повторять, с какой задержкой, что делать с не-идемпотентными командами, как быстро подхватывать нового primary.
Дальше — как эти принципы выглядят в каждом из трёх стеков, а затем то, ради чего статья и написана: Sentinel, Cluster и поведение при падении узлов.
Go: go-redis
Практический стандарт в Go — go-redis (пакет github.com/redis/go-redis/v9). Пул соединений встроен, API — контекстное: context.Context первым аргументом у каждой команды, и именно через него удобно ставить общий deadline поверх сетевых timeout.
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: os.Getenv("REDIS_PASSWORD"),
DB: 0,
// Сетевые timeout — обязательно. Без них мёртвый узел вешает запрос надолго.
DialTimeout: 2 * time.Second,
ReadTimeout: 500 * time.Millisecond,
WriteTimeout: 500 * time.Millisecond,
// Чтобы deadline из context реально ограничивал операцию, а не только
// сетевые timeout, в go-redis/v9 это надо включить явно:
ContextTimeoutEnabled: true,
// Пул: подберите по конкурентности, а не по числу CPU.
PoolSize: 20,
MinIdleConns: 5,
})
defer rdb.Close()
// Deadline на всю операцию — поверх сетевых timeout.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
val, err := rdb.Get(ctx, "user:42").Result()
switch {
case err == redis.Nil:
// промах кэша — читаем из БД и кладём обратно
val = loadUserFromDB(ctx, 42)
rdb.Set(ctx, "user:42", val, 5*time.Minute)
case err != nil:
return fmt.Errorf("redis get: %w", err)
}Ключевой момент go-redis — redis.Nil это не ошибка соединения, а «ключа нет». Их всегда надо различать: redis.Nil — штатный промах кэша, любая другая ошибка — проблема с Redis или сетью.
Пул восстанавливается сам: битое соединение выбрасывается и заменяется новым при следующем запросе. Для Sentinel и Cluster используются отдельные конструкторы (NewFailoverClient, NewClusterClient) — к ним ниже.
Java: Lettuce и Jedis
В Java два живых клиента, и разница между ними — принципиальная, потому что это разные модели соединения.
Lettuce — на Netty, асинхронный. Одно соединение потокобезопасно и мультиплексируется: тысячи конкурентных команд идут через один TCP-канал. Для обычной работы пул соединений не нужен вообще — он требуется только под блокирующие команды (BLPOP), транзакции (MULTI/EXEC) и всё, что требует эксклюзивного соединения. Авто-reconnect включён по умолчанию (ConnectionWatchdog).
RedisClient client = RedisClient.create("redis://127.0.0.1:6379");
// Timeout на команды — по умолчанию 60с, почти всегда надо уменьшать.
client.setOptions(ClientOptions.builder()
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
.build());
// Одно соединение на весь сервис — потокобезопасно, разделяется всеми потоками.
StatefulRedisConnection<String, String> connection = client.connect();
RedisCommands<String, String> sync = connection.sync();
String val = sync.get("user:42"); // null == промах кэшаJedis — классика, синхронный, блокирующий I/O. Одно соединение не потокобезопасно, поэтому Jedis обязательно работает через пул (JedisPool на базе commons-pool2): каждый вызов берёт соединение из пула и возвращает обратно. С Jedis 4+ появился JedisPooled, скрывающий работу с пулом, а в свежих версиях — новый билдер-API RedisClient (RedisClient.builder()...build()). Пример ниже — на JedisPool как на классической, узнаваемой модели пула; в новом коде смотрите в сторону RedisClient.
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(20);
poolConfig.setMaxIdle(5);
// connectTimeout и soTimeout (чтение) — раздельно.
try (JedisPool pool = new JedisPool(poolConfig, "127.0.0.1", 6379,
2000 /* connectTimeout, ms */, 500 /* soTimeout, ms */)) {
try (Jedis jedis = pool.getResource()) {
String val = jedis.get("user:42");
}
}Практическое правило: новый сервис — берите Lettuce (мультиплексирование, async, поддержка adaptive topology refresh для кластера). Jedis оправдан, когда кодовая база уже на нём или нужна предельно простая синхронная модель. Разница особенно заметна на Cluster и Sentinel — у Lettuce поддержка топологии зрелее.
Rust: redis-rs
Основной клиент — крейт redis (redis-rs). Он даёт и синхронный, и async (tokio/async-std) режимы. Важно с самого начала выбрать правильный тип соединения:
Connection— синхронное, одно соединение, само по себе не переподключается;MultiplexedConnection— async, мультиплексирует команды по одному TCP-каналу (аналог модели Lettuce);ConnectionManager— обёртка над мультиплексированным соединением с авто-reconnect и backoff. Для сервиса — то, что нужно.
// Cargo.toml — версии сверяйте, API async/cluster ещё развивается:
// redis = { version = "1", features = ["tokio-comp", "connection-manager"] }
let client = redis::Client::open("redis://127.0.0.1:6379/")?;
// ConnectionManager сам переподключается при обрыве, мультиплексирует запросы.
let mut conn = client.get_connection_manager().await?;
// Промах кэша — это Option::None, а не ошибка.
let val: Option<String> = redis::cmd("GET")
.arg("user:42")
.query_async(&mut conn)
.await?;Timeout в redis-rs традиционно ставятся снаружи — оборачиванием future в tokio::time::timeout (см. статью про async в Rust), а также через параметры соединения в URL/ConnectionInfo. Для пула поверх redis-rs используют bb8 или deadpool, но с ConnectionManager отдельный пул часто не нужен: мультиплексирование уже размазывает нагрузку по одному соединению. Кластер — отдельная feature, ниже.
Timeout, retry и reconnect
Три вещи, которые чаще всего забывают настроить — и которые определяют поведение сервиса при деградации.
Timeout — на нескольких уровнях. Мало поставить один: нужен timeout на установку соединения (dial/connect), на чтение, на запись — и поверх всего общий deadline операции на уровне приложения (context в Go, TimeoutOptions в Lettuce, tokio::time::timeout в Rust). Без сетевых timeout запрос к зависшему (не упавшему, а именно зависшему — «чёрная дыра») узлу висит до таймаута TCP в ОС, а это десятки секунд, за которые исчерпается пул и встанет весь сервис.
Retry — с оглядкой на идемпотентность. Автоповтор — обоюдоострый. go-redis по умолчанию повторяет команды (MaxRetries, по умолчанию 3, с экспоненциальным backoff). Это спасает от разовых сетевых сбоев, но опасно для не-идемпотентных команд: если INCR или LPUSH успел выполниться на сервере, но ответ потерялся в оборванном соединении, повтор выполнит операцию дважды. Идемпотентные (GET, SET key val) повторять безопасно; счётчики и добавления в списки/потоки — нет. Держите это в голове и при необходимости отключайте авто-retry для критичных не-идемпотентных операций.
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
MaxRetries: 3, // -1 полностью отключает retry
MinRetryBackoff: 8 * time.Millisecond,
MaxRetryBackoff: 512 * time.Millisecond,
})Reconnect — уже встроен, но у него есть цена. Пул go-redis, ConnectionWatchdog Lettuce и ConnectionManager redis-rs переподключаются автоматически. Но переподключение не бесплатно: пока соединение восстанавливается, команды либо ждут (до своего timeout), либо падают с ошибкой. Поэтому reconnect всегда идёт в паре с timeout и retry — сам по себе он не делает сервис устойчивым, он лишь возвращает соединение в строй после сбоя.
Sentinel: подключение и поведение при failover
Sentinel — это HA для топологии один primary + реплики. Отдельные процессы-наблюдатели (для надёжного развёртывания документация рекомендует ≥3) следят за primary, при кворуме признают его мёртвым, промотируют одну из реплик и сообщают клиентам новый адрес.
Принципиально: клиент должен уметь работать с Sentinel. Он подключается не к Redis напрямую, а сначала спрашивает у Sentinel «кто сейчас primary для mymaster», и уже потом идёт к нему. При failover клиент, получив ошибку соединения со старым primary, повторно опрашивает Sentinel и переключается на нового.
rdb := redis.NewFailoverClient(&redis.FailoverOptions{
MasterName: "mymaster",
SentinelAddrs: []string{
"10.0.0.1:26379", "10.0.0.2:26379", "10.0.0.3:26379",
},
DialTimeout: 2 * time.Second,
ReadTimeout: 500 * time.Millisecond,
WriteTimeout: 500 * time.Millisecond,
ContextTimeoutEnabled: true, // как в первом примере — уважать deadline из context
})
// NewFailoverClusterClient — то же, но с раскладкой чтений на реплики.RedisURI uri = RedisURI.Builder
.sentinel("10.0.0.1", 26379, "mymaster")
.withSentinel("10.0.0.2", 26379)
.withSentinel("10.0.0.3", 26379)
.build();
RedisClient client = RedisClient.create(uri);
// Lettuce сам резолвит primary через Sentinel и переключается при failover.
StatefulRedisConnection<String, String> connection = client.connect();В Rust Sentinel поддерживается через feature sentinel (SentinelClient); API менее вылизан, чем cluster/single, — проверяйте актуальную версию крейта.
Что реально происходит при падении primary:
sequenceDiagram
participant C as Клиент
participant S as Sentinel (≥3)
participant M as Primary
participant R as Replica
C->>S: где primary «mymaster»?
S-->>C: адрес Primary
C->>M: команды (запись/чтение)
Note over M: Primary падает
S->>S: кворум — primary мёртв
S->>R: promote → новый Primary
C-xM: ошибка соединения
C->>S: повторный запрос адреса
S-->>C: адрес нового Primary
C->>R: reconnect, работа продолжается
Важные следствия, о которых легко забыть:
- Есть окно недоступности. Failover не мгновенный:
down-after-milliseconds(сколько ждать, прежде чем счесть primary мёртвым) + время на кворум и промоушен. В это окно записи в affected primary падают с ошибкой — приложение должно это переживать, а не падать. - Подтверждённые записи могут потеряться. Репликация асинхронная: записи, которые primary принял (и ответил
OK), но не успел реплицировать до падения, при промоушене реплики теряются. Даже вернувшийся старый primary станет репликой нового и отбросит свои разошедшиеся данные.min-replicas-to-writeснижает риск, но не убирает его. Redis тут не источник истины для того, что нельзя терять. - In-flight команды всё равно ошибутся. Ни один клиент не «телепортирует» запрос, который был в полёте в момент обрыва, — он либо падает с ошибкой, либо (для идемпотентных) повторяется. Это ответственность приложения.
Cluster: подключение и особенности
Redis Cluster — это горизонтальное масштабирование через шардирование, а не «просто больше надёжности». Пространство ключей разбито на 16384 слота; слот ключа — CRC16(key) mod 16384; каждый слот принадлежит какому-то primary, у которого могут быть реплики.
Cluster-aware клиент устроен иначе, чем обычный:
- при старте он по любому из seed-адресов вытягивает всю карту топологии (
CLUSTER SLOTS/SHARDS) — какой узел держит какие слоты; - каждую команду он направляет на узел-владелец слота нужного ключа;
- если слот переехал, узел отвечает
MOVED(постоянное перемещение — клиент обновляет карту) илиASK(временно, во время resharding — клиент делаетASKING+ один повтор к целевому узлу, карту не трогает); - клиент периодически (и по триггерам) обновляет топологию, чтобы не ходить по устаревшей карте.
= slot 12182"] H --> A["узел A
слоты 0–5460"] A -->|"MOVED 12182 → C"| C["узел C
слоты 10923–16383"] C --> V["ответ + клиент
обновляет карту слотов"]
flowchart LR
K["ключ user:42"] --> H["CRC16(key) mod 16384
= slot 12182"]
H --> A["узел A
слоты 0–5460"]
A -->|"MOVED 12182 → C"| C["узел C
слоты 10923–16383"]
C --> V["ответ + клиент
обновляет карту слотов"]
Go — NewClusterClient:
rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{ // достаточно нескольких seed-узлов, остальное клиент найдёт сам
"10.0.0.1:6379", "10.0.0.2:6379", "10.0.0.3:6379",
},
DialTimeout: 2 * time.Second,
ReadTimeout: 500 * time.Millisecond,
WriteTimeout: 500 * time.Millisecond,
MaxRedirects: 3, // лимит MOVED/ASK-редиректов (redirect'ы cluster-клиент обрабатывает сам)
ContextTimeoutEnabled: true, // как в первом примере — уважать deadline из context
RouteRandomly: true, // распределять read-only команды по master/replica
})Java — Lettuce RedisClusterClient с явным topology refresh (это критично — см. ниже):
RedisClusterClient client = RedisClusterClient.create(List.of(
RedisURI.create("10.0.0.1", 6379),
RedisURI.create("10.0.0.2", 6379)));
ClusterTopologyRefreshOptions topology = ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(Duration.ofSeconds(30)) // регулярно
.enableAllAdaptiveRefreshTriggers() // + по MOVED/ASK/reconnect
.build();
client.setOptions(ClusterClientOptions.builder()
.topologyRefreshOptions(topology)
.build());
StatefulRedisClusterConnection<String, String> conn = client.connect();
conn.setReadFrom(ReadFrom.REPLICA_PREFERRED); // чтения со репликПо умолчанию Lettuce не обновляет топологию сам — без явных ClusterTopologyRefreshOptions клиент после failover может продолжать ходить по устаревшей карте и упираться в ошибки. Это одна из самых частых ловушек с Lettuce Cluster.
Rust — feature cluster-async:
// Cargo.toml:
// redis = { version = "1", features = ["cluster-async", "tokio-comp"] }
use redis::cluster::ClusterClient;
use redis::cluster_read_routing::RandomReplicaStrategy;
let nodes = vec![
"redis://10.0.0.1:6379/",
"redis://10.0.0.2:6379/",
];
let client = ClusterClient::builder(nodes)
// читать со реплик; read_from_replicas() в redis-rs 1.x deprecated
.read_routing_strategy(RandomReplicaStrategy)
.build()?;
let mut conn = client.get_async_connection().await?;Отдельная особенность кластера: multi-key операции работают только внутри одного слота. MGET user:1 user:2 разлетится по разным узлам и упадёт с CROSSSLOT. Решение — hash tags: часть ключа в фигурных скобках {...} берётся для расчёта слота, поэтому user:{42}:profile и user:{42}:sessions гарантированно лягут в один слот. Это надо проектировать заранее, а не обнаруживать в проде.
Что происходит при падении узла
Самая частая причина «непонятных» инцидентов — неверные ожидания от поведения клиента при падении узла. Сведём в таблицу, отдельно для Cluster и Sentinel.
Всё это удобнее один раз увидеть вживую, чем прочитать: рядом со статьёй есть готовый стенд — digital-cookbook → redis/client-resilience. Он поднимает настоящий Cluster и Sentinel в Docker и запускает клиент на Go, Java или Rust; дальше вы просто роняете узел (docker kill) и смотрите в логах окно ошибок и последующий reconnect.
| Redis Cluster | Sentinel (primary + replica) | |
|---|---|---|
| Кто чинит топологию | Сам кластер: реплика упавшего primary промотируется (нужен кворум мастеров, окно cluster-node-timeout) |
Sentinel: кворум признаёт primary мёртвым и промотирует реплику |
| Что видит клиент в момент сбоя | Ошибки соединения, CLUSTERDOWN, TRYAGAIN (во время миграции слота) |
Ошибка соединения со старым primary |
| Как клиент восстанавливается | Обновляет карту слотов (по триггеру/периодически), перенаправляет запросы на нового владельца | Повторно опрашивает Sentinel, переподключается к новому primary |
| Если у primary нет реплики | Слоты недоступны; при cluster-require-full-coverage yes (по умолчанию) весь кластер перестаёт обслуживать запросы к непокрытым слотам |
Failover невозможен — некого промотировать, primary недоступен до восстановления |
| Потеря данных | Незреплицированные записи упавшего primary теряются | Незреплицированные подтверждённые записи теряются (async-репликация) |
| Окно недоступности | cluster-node-timeout + промоушен |
down-after-milliseconds + кворум + промоушен |
Три вывода, общие для обеих топологий:
- Reconnect не бесплатен и не мгновенен. Всегда есть окно, в котором часть запросов падает. Приложение должно это переживать — деградацией (отдать данные из БД, вернуть stale-значение, показать «попробуйте позже»), а не паникой.
- Топологию клиент должен активно отслеживать. Для Lettuce Cluster это надо включать явно (
ClusterTopologyRefreshOptions). go-redis и redis-rs обновляют карту сами, но и им нужны корректные timeout, чтобы вовремя заметить мёртвый узел. - Redis не гарантирует ноль потерь. Асинхронная репликация означает, что при failover подтверждённые, но не реплицированные записи теряются. Если данные нельзя терять — Redis не источник истины, это обсуждалось во вводной статье.
Типичные ошибки при интеграции
- Новый клиент на каждый запрос. Убивает пул и мультиплексирование, добавляет латентность на установку соединения. Клиент — синглтон на процесс.
- Отсутствие timeout. Самая дорогая ошибка: зависший узел без сетевых timeout вешает запрос на десятки секунд и исчерпывает пул.
nil/null/Noneтрактуется как ошибка. Промах кэша (redis.Nil,null,Option::None) — штатная ситуация, а не сбой; путать их с ошибками соединения нельзя.- Авто-retry на не-идемпотентных командах. Повтор
INCR/LPUSHпосле потерянного ответа удваивает эффект. Для критичных счётчиков retry отключают или делают операцию идемпотентной. - Lettuce Cluster без topology refresh. После failover клиент ходит по устаревшей карте слотов — включайте периодический и адаптивный refresh явно.
- Multi-key операции в кластере без hash tags.
MGET/транзакции по ключам из разных слотов падают сCROSSSLOT; слот-локальность проектируется заранее. - Ожидание, что failover бесшовный. Окно недоступности есть всегда; приложение должно деградировать, а не падать.
Production-checklist
- Клиент — один на процесс, переиспользуется.
- Заданы явные timeout: connect, read, write + общий deadline операции.
- Осознанно выбрана модель: пул (Jedis), мультиплексирование (Lettuce/
ConnectionManager), или пул мультиплексированных. - Промах кэша отличается в коде от ошибки соединения.
- Retry настроен с учётом идемпотентности команд.
- Для Cluster: включён и проверен topology refresh (для Lettuce — явно).
- Для Cluster: multi-key операции покрыты hash tags, где нужна атомарность/
MGET. - Для Sentinel: клиент подключается через Sentinel-адреса, не напрямую к primary.
- Проверено поведение под failover — реальным отключением узла на стенде, а не в проде.
- Приложение деградирует при недоступности Redis, а не падает.
- Учтён риск потери незреплицированных записей; критичные данные не только в Redis.
Комментарии