Подключение к Redis из Go, Java и Rust: что важно кроме простого PING

Практика подключения к Redis из Go, Java и Rust: timeout, pool, multiplexing, reconnect, а главное — как клиенты ведут себя с Sentinel и Cluster при failover и падении отдельных узлов

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

PING проверяет, что процесс жив и до него есть сеть. Он ничего не говорит про то, что будет с вашим сервисом, когда primary уедет в failover, когда узел кластера отвалится посреди запроса, когда пул исчерпается или когда сеть моргнёт на 200 мс. Статья про это: про timeout, pool, multiplexing, reconnect и — главное — про поведение клиентов Go, Java и Rust в топологиях Sentinel и Cluster.

Материал для тех, кто уже решил, что Redis в системе нужен, и теперь выбирает и настраивает клиент. Базовое знакомство с Redis (что такое ключ, TTL, replica) предполагается — оно в вводной статье.

В статье

Для примеров возьмём одну маленькую доменную задачу вместо абстрактного 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, работа продолжается

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 через Sentinel: клиент резолвит primary через Sentinel, при падении получает ошибку, повторно спрашивает адрес и переподключается к промотированной реплике

Важные следствия, о которых легко забыть:

  • Есть окно недоступности. 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 + один повтор к целевому узлу, карту не трогает);
  • клиент периодически (и по триггерам) обновляет топологию, чтобы не ходить по устаревшей карте.
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["ответ + клиент
обновляет карту слотов"]

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["ответ + клиент
обновляет карту слотов"]
Маршрутизация в кластере: ключ → слот по CRC16 → узел-владелец; при переезде слота узел отвечает MOVED и клиент обновляет карту

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.

Три приложения — на Go, Java и Rust — подключаются к топологии Redis: кольцо узлов и наблюдатели за топологией; при падении узла линия запроса перестраивается на реплику

Всё это удобнее один раз увидеть вживую, чем прочитать: рядом со статьёй есть готовый стенд — 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 + кворум + промоушен

Три вывода, общие для обеих топологий:

  1. Reconnect не бесплатен и не мгновенен. Всегда есть окно, в котором часть запросов падает. Приложение должно это переживать — деградацией (отдать данные из БД, вернуть stale-значение, показать «попробуйте позже»), а не паникой.
  2. Топологию клиент должен активно отслеживать. Для Lettuce Cluster это надо включать явно (ClusterTopologyRefreshOptions). go-redis и redis-rs обновляют карту сами, но и им нужны корректные timeout, чтобы вовремя заметить мёртвый узел.
  3. 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.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии