Redis Cluster: как я потерял данные, нашёл их обратно и поседел дважды
Есть вещи, которые меняют тебя как специалиста. Первый деплой в прод. Первый 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". Мои главные уроки:
Hash tags планируйте заранее, не когда уже CROSSSLOT в логах
Failover длится 15-30 секунд — ваш код должен это переживать
Circuit Breaker — обязательный паттерн, не опциональный
Мониторьте mem_fragmentation_ratio — без этого потеряете память
Multi-key операции — только в одном слоте — это не баг, это дизайн
Latency в cluster выше, чем в standalone — заложите это в SLA
Если вы тоже занимаетесь highload и Redis — заходите на ithub.uno, там есть живые обсуждения именно таких кейсов. Без теории ради теории, только production experience.
Удачи с кластерами. И пусть ваши слоты всегда будут served. 🔴✅
Recommended Comments