Jump to content
View in the app

A better way to browse. Learn more.

T.M.I IThub

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.

Redis Cluster: как я потерял данные, нашёл их обратно и поседел дважды

(0 reviews)

Есть вещи, которые меняют тебя как специалиста. Первый деплой в прод. Первый incident report, который ты пишешь в 3 ночи. И первый раз, когда ты видишь в логах Redis: CLUSTERDOWN Hash slot not served. Вот это последнее — особенное. После такого начинаешь иначе смотреть на жизнь, на архитектуру и на документацию, которую ты "почти дочитал".

Сегодня расскажу про Redis Cluster в highload-продакшне. Без прикрас, без маркетинговых буклетов. Только боль, инсайты и несколько команд, которые спасли мне карьеру.


Контекст: зачем вообще Redis Cluster

К тому моменту мы уже года полтора успешно жили на одном Redis-инстансе с репликой. Всё было хорошо: 50GB данных, ~80,000 ops/sec в пике, latency стабильно под 1ms. Идиллия.

Потом случился бизнес. Нас купили, влили денег, пользователей стало в пять раз больше. Нагрузка выросла до 380,000 ops/sec. Один Redis задыхался. CPU на инстансе — 94% (Redis однопоточный в плане основного event loop, напоминаю). Latency поползла вверх — 8ms, 15ms, 40ms...

Решение очевидное: Redis Cluster. Шардирование данных по hash slots (всего 16384 слота) на несколько нод. Я читал документацию. Я смотрел туториалы. Я думал, что готов.

Я не был готов.


Первая попытка: наивная

Поднял кластер из 3 мастеров + 3 реплик. Конфигурация нод:

redis1 (master) — слоты 0-5460
redis2 (master) — слоты 5461-10922
redis3 (master) — слоты 10923-16383
redis4 (replica) — реплицирует redis1
redis5 (replica) — реплицирует redis2
redis6 (replica) — реплицирует redis3

Всё прекрасно работало на стейджинге. В прод переехали ночью. Первые два часа — тишина и красивые графики.

Потом началось.

Наш код активно использовал MGET, MSET и пайплайны. И вот тут — сюрприз из документации, которую я "почти дочитал": в Redis Cluster мульти-ключевые операции работают только если все ключи находятся в одном hash slot.

CROSSSLOT Keys in request don't hash to the same slot

Это сообщение я запомнил навсегда. Потому что половина нашего кода сыпала им как из ведра.


Погружение в hash tags

Redis Cluster использует CRC16 от имени ключа для определения слота. Но если в ключе есть фигурные скобки {}, то для вычисления слота используется только содержимое скобок — это называется hash tag.

# Эти ключи попадут в РАЗНЫЕ слоты:
user:1:profile → slot 7638
user:1:settings → slot 2892
user:1:cart → slot 6899

# А эти — в ОДИН, потому что hash tag {user:1}:
{user:1}:profile → slot 7638
{user:1}:settings → slot 7638
{user:1}:cart → slot 7638

Казалось бы, просто добавить фигурные скобки и всё. Но у нас было 200+ мест в коде с генерацией ключей. И это был PHP-монолит, переписанный на CI4 модули. Кайф.

Мы написали специальный класс-обёртку:

<?php

namespace App\Libraries\Cache;

class ClusterAwareCacheKey
{
    public static function userScoped(int $userId, string $suffix): string
    {
        return sprintf('{user:%d}:%s', $userId, $suffix);
    }

    public static function sessionScoped(string $sessionId, string $suffix): string
    {
        return sprintf('{session:%s}:%s', $sessionId, $suffix);
    }

    public static function globalKey(string $key): string
    {
        // Глобальные ключи не группируем — пусть шардируются равномерно
        return $key;
    }
}

И CI4 Cache Driver, который умеет работать с Cluster:

<?php

namespace App\Libraries\Cache;

use CodeIgniter\Cache\Handlers\RedisHandler;

class RedisClusterHandler extends RedisHandler
{
    protected \RedisCluster $redis;

    public function initialize(): void
    {
        $config = config('Cache');

        $this->redis = new \RedisCluster(
            null,
            $config->redisClusterNodes,
            $config->redisTimeout,
            $config->redisReadTimeout,
            true, // persistent
            $config->redisAuth
        );

        $this->redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_IGBINARY);
    }

    public function getMultiple(array $keys, mixed $default = null): array
    {
        // Группируем ключи по слотам для batch-операций
        $slotGroups = $this->groupBySlot($keys);
        $result = [];

        foreach ($slotGroups as $slotKeys) {
            $values = $this->redis->mget($slotKeys);
            foreach ($slotKeys as $i => $key) {
                $result[$key] = $values[$i] !== false
                    ? $this->unserialize($values[$i])
                    : $default;
            }
        }

        return $result;
    }

    private function groupBySlot(array $keys): array
    {
        $groups = [];
        foreach ($keys as $key) {
            $slot = $this->calculateSlot($key);
            $groups[$slot][] = $key;
        }
        return array_values($groups);
    }

    private function calculateSlot(string $key): int
    {
        // Извлекаем hash tag если есть
        if (preg_match('/\{([^}]+)\}/', $key, $matches)) {
            $key = $matches[1];
        }
        return crc32($key) & 0x3FFF; // 16384 слота
    }
}

Второй уровень ада: failover

Мы починили CROSSSLOT. Живём. Счастливы. Три недели тишины.

Потом у нас умер сервер. Не виртуалка, не под — физический сервер. Просто взял и умер в 14:22 в среду. На нём жили redis2 (мастер) и redis5 (реплика redis1, то есть другого мастера).

Вот тут начался настоящий thriller.

Redis Cluster должен автоматически делать failover: реплика замечает, что мастер недоступен, объявляет выборы, становится новым мастером. Это работает. Но есть нюанс, который я знал теоретически и совершенно недооценил практически.

Время failover — от 15 до 30 секунд.

Пятнадцать секунд, в течение которых слоты 5461-10922 не обслуживаются. При 380,000 ops/sec. Можете себе представить, как выглядел error rate в эти 15 секунд?

Error rate: 0.1% → 34% → 67% → 89% → 67% → 12% → 0.3%

Наш PHP-код в CI4 при ошибке Redis просто падал с exception. Никакого graceful degradation. Никакого fallback на БД. Просто 500-е ответы.

Вот это был момент просветления.


Решение: Circuit Breaker для Redis

Мы реализовали паттерн Circuit Breaker специально для Redis. В CI4 это элегантно делается через Service Container:

<?php

namespace App\Libraries\Cache;

class ResilientCacheService
{
    private const FAILURE_THRESHOLD = 5;
    private const RECOVERY_TIMEOUT = 30;
    private const HALF_OPEN_MAX_CALLS = 3;

    private string $state = 'closed'; // closed | open | half-open
    private int $failureCount = 0;
    private int $lastFailureTime = 0;
    private int $halfOpenCalls = 0;

    public function __construct(
        private readonly RedisClusterHandler $redis,
        private readonly \CodeIgniter\Cache\CacheInterface $fallback
    ) {}

    public function get(string $key, mixed $default = null): mixed
    {
        if ($this->isOpen()) {
            return $this->fallback->get($key, $default);
        }

        try {
            $value = $this->redis->get($key);
            $this->onSuccess();
            return $value ?? $default;
        } catch (\Throwable $e) {
            $this->onFailure($e);
            return $this->fallback->get($key, $default);
        }
    }

    public function set(string $key, mixed $value, int $ttl = 0): bool
    {
        if ($this->isOpen()) {
            return $this->fallback->set($key, $value, $ttl);
        }

        try {
            $result = $this->redis->save($key, $value, $ttl);
            $this->onSuccess();
            return $result;
        } catch (\Throwable $e) {
            $this->onFailure($e);
            return $this->fallback->set($key, $value, $ttl);
        }
    }

    private function isOpen(): bool
    {
        if ($this->state === 'open') {
            if (time() - $this->lastFailureTime > self::RECOVERY_TIMEOUT) {
                $this->state = 'half-open';
                $this->halfOpenCalls = 0;
                return false;
            }
            return true;
        }
        return false;
    }

    private function onSuccess(): void
    {
        if ($this->state === 'half-open') {
            $this->halfOpenCalls++;
            if ($this->halfOpenCalls >= self::HALF_OPEN_MAX_CALLS) {
                $this->state = 'closed';
                $this->failureCount = 0;
            }
        } elseif ($this->state === 'closed') {
            $this->failureCount = max(0, $this->failureCount - 1);
        }
    }

    private function onFailure(\Throwable $e): void
    {
        $this->lastFailureTime = time();
        $this->failureCount++;

        if ($this->state === 'half-open' || $this->failureCount >= self::FAILURE_THRESHOLD) {
            $this->state = 'open';
            log_message('critical', 'Redis Circuit Breaker OPEN: ' . $e->getMessage());
        }
    }
}

Регистрируем в Services:

// app/Config/Services.php
public static function resilientCache(bool $getShared = true): ResilientCacheService
{
    if ($getShared) {
        return static::getSharedInstance('resilientCache');
    }

    return new ResilientCacheService(
        new RedisClusterHandler(),
        new FileHandler() // filesystem как fallback
    );
}

Теперь при падении Redis кластера приложение продолжало работать — медленнее, с filesystem кэшем, но без 500-х ошибок. Error rate во время следующего (учинённого намеренно!) failover теста: 0.8%. Против 89% до.


Третий круг: Memory fragmentation

Это тихий убийца. Redis работает, данные записываются и читаются, но потихоньку mem_fragmentation_ratio ползёт вверх.

redis-cli --cluster call all-nodes INFO memory | grep mem_fragmentation_ratio

Через полгода работы я увидел значение 2.47. Норма — от 1.0 до 1.5. Значение 2.47 означает, что Redis использует в 2.47 раза больше памяти, чем реально нужно для данных. На наших 80GB инстансах это ~50GB впустую.

Причина — интенсивные операции записи/удаления ключей с разным TTL, что приводит к фрагментации heap у jemalloc.

Решение — Active defragmentation:

redis-cli CONFIG SET activedefrag yes
redis-cli CONFIG SET active-defrag-ignore-bytes 100mb
redis-cli CONFIG SET active-defrag-threshold-lower 10
redis-cli CONFIG SET active-defrag-threshold-upper 100
redis-cli CONFIG SET active-defrag-cycle-min 25
redis-cli CONFIG SET active-defrag-cycle-max 75

Через сутки mem_fragmentation_ratio опустился до 1.18. Мы вернули ~45GB памяти. Без перезапуска. В прод.


Итог и выводы

Redis Cluster — мощнейший инструмент, но он требует понимания на уровне "читал исходники, а не только README". Мои главные уроки:

  1. Hash tags планируйте заранее, не когда уже CROSSSLOT в логах

  2. Failover длится 15-30 секунд — ваш код должен это переживать

  3. Circuit Breaker — обязательный паттерн, не опциональный

  4. Мониторьте mem_fragmentation_ratio — без этого потеряете память

  5. Multi-key операции — только в одном слоте — это не баг, это дизайн

  6. Latency в cluster выше, чем в standalone — заложите это в SLA

Если вы тоже занимаетесь highload и Redis — заходите на ithub.uno, там есть живые обсуждения именно таких кейсов. Без теории ради теории, только production experience.

Удачи с кластерами. И пусть ваши слоты всегда будут served. 🔴


0 Comments

Recommended Comments

There are no comments to display.

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.