Тема 07

Кэширование и Redis

Секция, где очень легко получить «средний» балл и очень трудно — высокий. Все знают, что такое cache-aside и что KEYS нельзя. Отличают тех, кто умеет говорить про гонки на оси времени, про то, что теряется при failover, и про то, почему распределённая блокировка на Redis — это оптимизация, а не гарантия корректности.

Что реально проверяют этой секцией

Понимаешь ли ты, что кэш — это осознанный размен консистентности на латентность, и умеешь ли назвать цену. Кандидат, который говорит «поставим Redis, будет быстрее», и кандидат, который говорит «hit rate будет 85 %, окно рассинхрона — до секунды, при рестарте Redis нас снесёт stampede, поэтому вот jitter и singleflight» — это два разных грейда, даже если код у обоих одинаковый.

7.1Зачем кэш и уровни кэширования

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

Что такое кэш на самом деле

Кэш хранит копию данных ближе и дешевле, чем источник правды, и сознательно не гарантирует, что копия актуальна. Всё прочее уже детали реализации. Держится он на двух свойствах реальной нагрузки:

  • Временна́я локальность. Если ключ запросили сейчас, скорее всего, его запросят ещё раз в ближайшие секунды. Так ведут себя лента, карточка популярного товара, профиль автора, настройки фичефлагов.
  • Перекос распределения (skew). Обращения к ключам почти никогда не бывают равномерными: распределение Zipf-подобное, и 1 % ключей даёт 50–90 % трафика. Поэтому кэша на 2 ГБ и хватает на таблицу в 2 ТБ.

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

Четыре слова, которые дальше встречаются в каждом абзаце

Попадание (hit) значит, что запрошенный ключ нашёлся в кэше и до источника дело не дошло. При промахе (miss) ключа нет: идём в источник правды и обычно кладём результат в кэш. Доля попаданий среди всех обращений называется hit rate. Это главная метрика кэша, и ниже ей отведён отдельный раздел: читают её чаще всего неправильно.

По истечении TTL (time to live, «время жизни») ключ сам исчезает из кэша. Смысл TTL не в экономии памяти: он задаёт верхнюю границу вранья, и TTL 60 секунд означает «мы согласны, что минуту пользователь может видеть устаревшие данные». Выбирают его от допустимого отставания, а не «ну пусть будет пять минут».

Инвалидацией копию объявляют негодной, то есть отвечают на вопрос «когда выбросить из кэша то, что в источнике уже изменилось». Проще всего дождаться истечения TTL; второй способ — явно удалить ключ в момент записи. Как их сочетают и где эта конструкция протекает, разобрано в главе 7.4.

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

Формулировка, которая сразу поднимает уровень ответа

«Кэшем мы осознанно меняем консистентность на латентность и пропускную способность. Мы соглашаемся какое-то время отдавать устаревшие данные, а взамен получаем ответ за доли миллисекунды и снимаем нагрузку с источника. Дальше остаётся один вопрос: какое окно рассинхрона бизнес готов терпеть для этих конкретных данных.»

Чем платим

ЦенаВ чём проявляется
Устаревшие данныеПользователь поменял имя — а в ленте старое ещё TTL секунд. Для профиля терпимо, для остатка на складе перед списанием — нет
Дублирование состоянияПоявляется вторая копия правды, которую надо согласовывать. Все баги инвалидации растут отсюда
Новая точка отказаRedis лёг — и если код не умеет работать «мимо кэша», лежит весь сервис. Кэш обязан быть опциональным
Холодный стартПосле рестарта или деплоя кэш пуст, и вся нагрузка одномоментно уходит в БД, которая на неё не рассчитана
StampedeМассовое истечение TTL или падение кэша даёт всплеск одинаковых запросов в источник (глава 7.5)
Маскировка проблемКэш прячет запрос без индекса. Пока hit rate высокий, всё «летает»; в день, когда кэш промахнулся, БД ложится
Память и деньгиКластер Redis на 64 ГБ стоит ощутимо. Иногда индекс на 200 МБ решает ту же задачу
Сложность отладки«У меня старые данные» — воспроизводится только у одного пользователя, только пока живёт ключ

Уровни кэширования на пути запроса

На пути к диску запрос пользователя проходит через несколько независимых кэшей. На собеседовании их часто просят перечислить — и кандидат почти всегда вспоминает два-три. Хороший ответ идёт сверху вниз и для каждого уровня называет: что там лежит, кто это инвалидирует и во что обходится промах.

Путь одного запроса: сверху вниз. Попадание — ответ сразу, промах — спускаемся уровнем ниже. промах спускается ниже Уровень Что там лежит Чем инвалидируется Ответ Браузер / клиент HTTP-ответы, статика, данные в SPA Cache-Control, ETag, max-age 0 мс CDN / edge статика, публичные GET, картинки TTL + purge по API 5-30 мс Обратный прокси целые HTTP-ответы, nginx proxy_cache proxy_cache_valid, PURGE 1-5 мс In-process приложения готовые Go-структуры, без сериализации короткий TTL, LRU по размеру 50-500 нс Redis / Memcached общее для всех подов состояние TTL, DEL по событию 0,2-1 мс Буферы БД страницы таблиц и индексов, план запроса вытеснение самой СУБД 0,1-1 мс Диск — источник правды сами данные, единственная настоящая копия не кэш, инвалидации нет 5-50 мс Каждый уровень независим и имеет свою инвалидацию. Баг «пользователь видит старое» может жить на любом из них, поэтому первый вопрос при разборе — на каком именно уровне застряла копия. Цифры — порядки величин, не константы.
Лестница кэшей. Чем выше уровень, где случилось попадание, тем дешевле ответ — и тем больше риск отдать устаревшее. Промах на верхнем уровне обходится в полную стоимость всех нижних.
Про кэш БД забывают, а он тоже кэш

shared_buffers в PostgreSQL и буферный пул InnoDB работают как полноценные кэши страниц. Поэтому «повторный запрос выполнился за 0,3 мс вместо 40» часто говорит не о хорошем плане, а о прогретом буфере. Мерять оптимизацию надо EXPLAIN (ANALYZE, BUFFERS), сравнивая shared read с shared hit, иначе будешь «оптимизировать» кэш, а не запрос.

Локальный in-process кэш против распределённого

С этой развилки проектирование и начинается. Локальный кэш живёт внутри процесса: обычная мапа под RWMutex или готовая библиотека (ristretto, otter, golang-lru). Распределённый работает отдельным сервисом, общим для всех реплик.

Локальный (in-process)Распределённый (Redis)
Латентностьдесятки-сотни наносекунд, обращение к памяти0,2–1 мс: сеть + сериализация
Сериализациянет вообще — лежит готовый объектобязательна: JSON, msgpack, protobuf; часто дороже самой сети
Согласованность между репликаминет: у каждого пода своя копия и свой момент истеченияодна копия на всех
Инвалидациясложно: нужен broadcast (pub/sub) на все подыодин DEL
Объёмограничен heap пода, давит на GCдесятки-сотни ГБ, отдельный бюджет памяти
Холодный старткаждый рестарт/деплой — пустой кэш у нового подапереживает деплой приложения
Стоимость промахаидём в Redis или сразу в БДидём в БД
Отказневозможен отдельно от процессаотдельная точка отказа, нужен fallback
Влияние на GCпрямое: миллион живых объектов удлиняет разметкунулевое: память вне процесса
Когда братьмаленькие горячие справочники, конфиги, фичефлаги, результаты, которые не жалко рассинхронить на секундусессии, всё, что должно быть одинаковым у всех подов, большие объёмы

В нагруженных сервисах их комбинируют: L1 держат локально на 1–5 секунд, L2 в Redis на минуты. Локальный слой снимает с Redis «горячий хвост» (тот самый 1 % ключей с половиной трафика) и заодно решает проблему горячего ключа в кластере.

// Двухуровневый кэш: L1 в памяти на 2 с, L2 в Redis на 5 мин.
type TwoLevel struct {
    l1  *ristretto.Cache // локальный, TTL 2 с
    rdb *redis.Client
    db  *sql.DB
}

func (c *TwoLevel) User(ctx context.Context, id int64) (*User, error) {
    key := fmt.Sprintf("user:v1:%d", id)

    if v, ok := c.l1.Get(key); ok {            // ~100 нс, без сети
        return v.(*User), nil
    }
    b, err := c.rdb.Get(ctx, key).Bytes()      // ~0,4 мс
    if err == nil {
        u := new(User)
        if json.Unmarshal(b, u) == nil {
            c.l1.SetWithTTL(key, u, 1, 2*time.Second)
            return u, nil
        }
    }
    if err != nil && err != redis.Nil {
        // Redis недоступен: логируем и идём в БД, а не падаем.
        metrics.CacheErrors.Inc()
    }
    u, err := loadFromDB(ctx, c.db, id)        // ~10 мс
    if err != nil {
        return nil, err
    }
    b, _ = json.Marshal(u)
    c.rdb.Set(ctx, key, b, 5*time.Minute)
    c.l1.SetWithTTL(key, u, 1, 2*time.Second)
    return u, nil
}
Главная ловушка локального кэша

Ты кладёшь в локальный кэш указатель, а не копию. Двадцать горутин получат один и тот же *User, и стоит одной его поменять, как ты получаешь гонку данных и порчу кэша сразу у всех. Правило: в in-process кэше лежат только иммутабельные значения. Либо кладёшь копию при записи, либо возвращаешь копию при чтении, либо прямо пишешь в коде «этот объект менять нельзя». Вторая ловушка в GC: миллион структур с указателями внутри означает миллион объектов, которые сборщик обходит на каждом цикле. Поэтому серьёзные библиотеки хранят значения в []byte-аренах и не плодят указатели.

Локальный кэш: три пода — три независимые копии и три разных момента истечения под A name = Иван под B name = Пётр под C name = Иван PostgreSQL name = Пётр рассинхрон Пользователь переименовался. Под B сходил в БД после записи, A и C — до. Балансировщик раскидывает запросы по подам, и клиент видит то новое имя, то старое — при каждом обновлении страницы. Распределённый кэш: копия одна под A под B под C Redis name = Пётр Все поды видят одно значение. Цена — сеть в каждом запросе и отдельный сервис, который может упасть.
Почему локальный кэш нельзя ставить бездумно. Он размножает состояние по числу реплик. Для фичефлагов с TTL в секунду это нормально, а для данных, которые пользователь только что изменил своими руками, нет.

Метрики: hit rate и что он на самом деле означает

Считают прежде всего hit rate (долю попаданий):

hit_rate = hits / (hits + misses)

На собеседовании часто спрашивают, какой hit rate считается хорошим. Ответ: сам по себе hit rate ничего не значит, значение имеют три производных числа.

1. Эффективная латентность

Промах обходится дороже прямого обращения к БД: сначала мы сходили в кэш за nil и только потом пошли в источник. Формула:

L = h * L_hit + (1 - h) * (L_hit + L_source)

Возьмём L_hit = 0,3 мс и L_source = 20 мс: 90 % попаданий дают 2,3 мс, а 99 % уже 0,5 мс, то есть ответ быстрее почти впятеро. Сама латентность падает по h линейно: каждый процент снимает те же 0,2 мс. Нелинеен выигрыш в разах: при 99 % промахов вдесятеро меньше, чем при 90 %, и каждый следующий процент hit rate даётся дороже предыдущего.

Средняя латентность при L_hit = 0,3 мс и L_source = 20 мс Внизу — сколько запросов в секунду долетит до БД при входящих 10 000 rps. Промахи, а не попадания, определяют нагрузку. 20,3 мс 10,3 мс 4,3 мс 2,3 мс 1,3 мс 0,5 мс 0,32 мс hit 0 % 50 % 80 % 90 % 95 % 99 % 99,9 % в БД, rps 10 000 5 000 2 000 1 000 500 100 10 Рост hit rate с 90 % до 99 % убирает 900 запросов в секунду из БД — это чаще всего и есть настоящая цель кэша. При этом p99 приложения остаётся равным латентности промаха: хвост распределения кэш не лечит, он лечит середину.
Нелинейность hit rate. Слева почти чистая БД, справа почти чистый кэш. Между 90 % и 99 % на глаз «почти одинаково», а нагрузка на источник отличается в десять раз.

2. Абсолютное число промахов в секунду

В БД попадает именно оно, а не проценты. Hit rate 99 % при 10 000 rps пропускает в источник 100 rps; тот же 99 % при 1 000 000 rps пропускает уже 10 000 rps, и БД от такого ляжет. Поэтому в алертах держат misses_per_second, а не только процент.

3. Хвост латентности

С кэшем медиана падает почти до L_hit, а p99 остаётся равным латентности источника — этот 1 % запросов туда и уходит. Если SLA сформулирован по p99, кэш с hit rate 95 % его почти не улучшит. Аргумент сильный, а в разговоре его почти никто не приводит.

Что мерить в проде
  • cache_hits / cache_misses заводят счётчиками с меткой имени кэша, а не одним на весь сервис.
  • cache_errors считают отдельно от промахов: недоступность Redis не должна выглядеть как холодный кэш.
  • Гистограмма латентности раздельно для hit и miss — иначе один график усреднит две разные популяции.
  • evicted_keys и used_memory из INFO Redis: рост вытеснений означает, что кэш меньше, чем рабочее множество, и hit rate будет деградировать сам собой.
  • Возраст отданных данных, если умеешь его считать, прямо меряет окно рассинхрона.

Когда кэш не нужен и когда он вредит

  • Нет повторяемости ключей. Каждый запрос уникален (поиск по произвольным фильтрам, выгрузка отчётов по диапазонам). Hit rate будет 5 %, а каждый запрос получит лишний round-trip.
  • Источник и так быстрый. Выборка по первичному ключу из прогретого буферного пула занимает 0,2–0,5 мс, столько же, сколько поход в Redis по сети. Выигрыша нет, сложность есть.
  • Данные не имеют права быть устаревшими. Баланс перед списанием, остаток товара в момент оформления заказа, права доступа при проверке авторизации, статус блокировки пользователя. Здесь кэш грозит не «немного стухшими данными», а инцидентом безопасности или деньгами.
  • Пишут чаще, чем читают. При соотношении 1 read / 1 write кэш инвалидируется быстрее, чем успевает пригодиться: получаем нагрузку и на БД, и на Redis.
  • Кэш вместо индекса. Классический анти-паттерн: запрос делает seq scan на 3 секунды, его закрывают кэшем — и живут так, пока однажды кэш не окажется пустым.
  • Кэш вместо исправления N+1. Тот же случай: лечат это IN (...) или джойн, а не «закэшируем каждую из 200 подгрузок».
Cache penetration — промах, которого не должно быть

Отдельный класс проблем: запрашивают ключ, которого нет в БД. Кэш промахивается, идём в источник, тот возвращает пусто, класть в кэш нечего — и так на каждый запрос. Если так делает бот, перебирающий id, кэш вообще перестаёт защищать. Лечится это negative caching: кладём маркер «нет такого» с коротким TTL (30–60 с). Для больших пространств ключей перед кэшем ставят фильтр Блума: он отвечает «точно нет» без похода в БД. Термины любят спрашивать отдельно: при penetration ключа нет вообще; при breakdown протух один горячий ключ и все ломанулись за ним; при avalanche протухло сразу много ключей или лёг весь кэш.

Глубже, чем спросят: почему кэш вообще работает

Оптимальную политику вытеснения описывает алгоритм Белади: выкидывать тот элемент, который понадобится позже всех. Реализовать его нельзя, для этого надо знать будущее, но он служит верхней границей: любую реальную политику (LRU, LFU, ARC, W-TinyLFU) сравнивают именно с ним. LRU ставит на то, что недавнее прошлое предсказывает ближайшее будущее, LFU ставит на устойчивость частоты во времени. Ломаются обе ставки на разных нагрузках: LRU не переживает скан (одна выгрузка всей таблицы вымывает весь кэш), а LFU держит «вчерашних чемпионов», которых уже никто не спрашивает. Отсюда гибриды: allkeys-lfu в Redis с затуханием счётчика (lfu-decay-time) или W-TinyLFU в otter; ristretto пускает в кэш по TinyLFU, а вытесняет по случайной выборке.

Вопросы

6
Суть: кэш — это размен консистентности на латентность и на снятие нагрузки с источника. Платим устаревшими данными, второй копией правды, новой точкой отказа и целым классом багов инвалидации.

Работает кэш за счёт двух свойств реальной нагрузки: временно́й локальности (что запросили сейчас, запросят снова) и сильного перекоса в распределении обращений — обычно 1 % ключей даёт больше половины трафика. Поэтому небольшой кэш и закрывает огромную таблицу.

Что мы получаем

  • Латентность: 0,3 мс из Redis вместо 10–40 мс из БД, а из локального кэша вообще сотни наносекунд.
  • Пропускную способность: БД перестаёт быть узким местом, её можно не масштабировать.
  • Защиту источника от пиков: всплеск трафика на популярный контент не доходит до БД.
  • Деньги: реплика Postgres дороже, чем нода Redis той же ёмкости.

Чем платим

  • Окно рассинхрона. Сколько-то секунд пользователи видят старые данные. Это надо явно согласовать с продуктом, а не решать в коде молча.
  • Вторая копия правды. Инвалидация порождает баги, которые воспроизводятся раз в неделю у одного пользователя.
  • Точка отказа. Если сервис не умеет работать при недоступном кэше, ты просто добавил в цепочку ещё один компонент, который может её порвать.
  • Холодный старт и stampede. Пустой кэш после деплоя отправляет всю нагрузку в БД разом.
  • Маскировка. Кэш прячет неоптимизированный запрос до того дня, когда промахнётся.
Чем добить ответ

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

Суть: на пути запроса стоит целая лестница независимых кэшей — браузер, CDN, обратный прокси, in-process, распределённый, буферы БД. У каждого своя инвалидация, и устаревшие данные могут застрять на любом.

Сверху вниз

  • Клиент / браузер. Управляется заголовками Cache-Control, ETag, Last-Modified. Кэш самый дешёвый: запрос вообще не уходит в сеть. И самый опасный: инвалидировать его нельзя, ты уже отдал ответ, поэтому для изменчивых данных ставят no-store или очень короткий max-age, а для статики «вечный» TTL плюс хеш в имени файла.
  • CDN / edge. Тот же HTTP-кэш, но управляемый: есть API для purge. Хорош для статики и публичных GET-ответов, бесполезен для персонализированных.
  • Обратный прокси (nginx proxy_cache, Varnish). Кэширует готовые HTTP-ответы внутри твоего периметра. Хорош тем, что не требует ни строчки в приложении.
  • Приложение, in-process. Мапа или LRU внутри Go-процесса. Нет ни сети, ни сериализации. Но у каждой реплики своя копия.
  • Распределённый кэш (Redis, Memcached). Хранит общее для всех подов состояние: сессии, результаты тяжёлых запросов, счётчики.
  • Уровень БД. Буферный пул (shared_buffers, InnoDB buffer pool), кэш планов и prepared statements, материализованные представления. Кэшем это называют реже, но ведёт себя оно ровно так же.
  • ОС и железо. Page cache файловой системы, кэш контроллера диска.
Практическое правило

Кэшировать надо как можно ближе к пользователю, но ровно настолько близко, насколько позволяет требуемая свежесть. Статику кладут на CDN навсегда, публичный список на прокси на минуту, персональную ленту в Redis на десятки секунд. Баланс не кэшируют вообще.

Суть: локальный кэш быстрее на три порядка, но размножает состояние по числу реплик и почти не поддаётся инвалидации. Redis даёт одну копию на всех ценой сети и сериализации. В нагруженных сервисах их ставят в два уровня.

Локальный

Обращение к мапе занимает 50–500 наносекунд, и сериализации нет: в кэше лежит готовая Go-структура. Никакой сети, никакой отдельной точки отказа. Минусы: у каждого пода свой кэш и свой момент истечения, поэтому пользователь при обновлении страницы может увидеть то новое значение, то старое. Для инвалидации нужна рассылка на все поды (обычно через Redis pub/sub), и атомарной она не будет. Плюс объём ограничен heap-ом, а миллион живых объектов удлиняет цикл GC.

Распределённый

Копия одна, и один DEL инвалидирует её для всех; объём измеряется десятками гигабайт и не влияет на GC приложения. Платишь сетевым round-trip и сериализацией, которая часто дороже самой сети: json.Marshal большой структуры порой занимает больше времени, чем поход в Redis.

Как отвечать

«Локальный я беру для маленького, редко меняющегося и одинакового для всех: справочники, фичефлаги, конфиги, курсы валют. С TTL в единицы секунд, чтобы рассинхрон был заведомо незаметен. Redis нужен для всего, что должно быть согласовано между репликами, и для больших объёмов. Если нагрузка высокая, ставлю оба: L1 локальный на 1–5 секунд поверх L2 в Redis. L1 снимает с Redis горячий хвост и заодно решает проблему горячего ключа в кластере.»

На что ловят

«А что ты кладёшь в локальный кэш: значение или указатель?» Если указатель, то все горутины получают один объект, и любая мутация даёт гонку плюс порчу кэша для всех остальных. В in-process кэше должны лежать только иммутабельные значения.

Суть: hits/(hits+misses). Универсального «хорошего» значения нет — важны абсолютное число промахов в секунду и то, что делает с латентностью именно твой источник.

Эффективная средняя латентность считается как L = L_hit + (1-h) * L_source, потому что промах стоит поход в кэш плюс поход в источник. При L_hit = 0,3 мс, L_source = 20 мс hit rate 90 % даёт 2,3 мс, а 99 % уже 0,5 мс. Последние девять процентов ускоряют ответ почти впятеро: промахов становится вдесятеро меньше.

Почему процент сам по себе бесполезен

  • 99 % при 10 000 rps пропускают в БД 100 запросов в секунду, ерунда. 99 % при миллионе rps пропускают уже 10 000 rps, и БД ляжет. В алертах должно быть misses_per_second.
  • Если источник отвечает за 0,3 мс, то и hit rate 50 % никого не беспокоит.
  • Высокий hit rate бывает и у бесполезного кэша: если кэшируется только один горячий ключ, процент будет 99, а тяжёлые запросы всё равно уходят в БД.

Что ещё важно проговорить

Кэш почти не улучшает p99. Медиана падает до латентности кэша, а хвост остаётся равным латентности источника — туда как раз и уходят промахи. Если SLA сформулирован по p99, кэш с hit rate 95 % его практически не сдвинет, и надо либо чинить источник, либо доводить hit rate до 99,9 %, либо ставить refresh-ahead, чтобы промахов на пользовательском пути не было вообще.

Ориентиры из практики: для кэша объектов по идентификатору нормально 90–99 %; ниже 70 % уже повод проверить, есть ли вообще перекос в ключах; ниже 50 % кэш обычно только добавляет round-trip. Отдельно смотрят evicted_keys: если ключи вытесняются по maxmemory, а не по TTL, значит рабочее множество больше кэша и hit rate будет деградировать.

Суть: когда нет повторяемости ключей, когда источник и так быстрый, когда данные не имеют права быть устаревшими, и когда кэшем закрывают отсутствующий индекс.
  • Нет локальности. Аналитика по произвольным диапазонам, поиск по свободным фильтрам: каждый ключ уникален, hit rate около нуля, а лишний round-trip платят все.
  • Источник быстрее или сопоставим. Чтение по первичному ключу из прогретого буферного пула занимает те же 0,3 мс, что и Redis по сети. Кэш добавит только сложность и рассинхрон.
  • Данные критичны к свежести. Баланс перед списанием, остаток товара при оформлении, проверка прав и статуса блокировки. Здесь устаревшее значение означает не «чуть-чуть неточно», а списанные лишние деньги или доступ у уволенного сотрудника. Такие чтения идут в БД, при необходимости под SELECT ... FOR UPDATE.
  • Запись сравнима с чтением по частоте. Ключ инвалидируется раньше, чем его успевают прочитать: платим и за БД, и за Redis.
  • Кэш как замена оптимизации. Медленный запрос надо чинить планом и индексом. Кэш поверх него превращается в отложенный инцидент: рванёт он в момент, когда кэш окажется пуст, то есть при рестарте, деплое или всплеске новых ключей.
  • Данные слишком большие. Мегабайтный JSON в Redis ради одного поля тратит сеть и блокирует однопоточный сервер (см. большие ключи в 7.5).
Ответ, который производит впечатление

«Пока не понятно, откуда взялась проблема производительности, кэш как её решение ставить нельзя. Сначала EXPLAIN (ANALYZE, BUFFERS) и профиль, потом индекс или переписанный запрос, и только потом кэш — уже не чтобы починить, а чтобы снять нагрузку. Иначе без кэша сервис перестаёт работать, а значит, мы построили систему, которая не переживает собственный рестарт.»

Суть: три разных сценария, когда кэш перестаёт защищать источник: запрашивают несуществующий ключ, протух один горячий ключ, протухло сразу много ключей.

Penetration — «пробой»

Запрашивают ключ, которого нет в БД. Кэш промахивается, источник возвращает пусто, класть нечего, и каждый следующий такой запрос снова идёт в БД. Если кто-то перебирает идентификаторы, кэш просто перестаёт существовать как защита.

Лечение:

  • Negative caching — кладём маркер «нет такого» с коротким TTL (30–60 с). TTL короткий, чтобы вновь созданная сущность быстро стала видна.
  • Фильтр Блума перед кэшем для больших пространств ключей: даёт достоверное «точно нет» и вероятностное «может быть», при этом занимает биты на ключ.
  • Проверка формата идентификатора на входе отсекает мусорный перебор без похода в БД.
const missMarker = "\x00miss"

v, err := rdb.Get(ctx, key).Result()
switch {
case err == nil && v == missMarker:
    return nil, ErrNotFound            // ключа точно нет, в БД не идём
case err == nil:
    return decode(v)
case err == redis.Nil:
    u, err := loadFromDB(ctx, id)
    if errors.Is(err, sql.ErrNoRows) {
        rdb.Set(ctx, key, missMarker, 60*time.Second)
        return nil, ErrNotFound
    }
    ...
}

Breakdown — «прорыв» одного горячего ключа

У очень популярного ключа истёк TTL, и все запросы к нему одновременно ушли в БД. Это частный случай stampede, подробно он разобран в главе 7.5. Лечится singleflight, «вечным» ключом с фоновым обновлением или вероятностным ранним обновлением.

Avalanche — «лавина»

Протухло сразу много ключей (кэш прогрели одним циклом с одинаковым TTL) или упал сам Redis. В БД приходит вся нагрузка целиком. Лечение: jitter в TTL, чтобы истечения размазались во времени; circuit breaker и лимит одновременных запросов к БД; деградированный ответ вместо полного отказа.

Термины разные в разных школах

В англоязычной литературе чаще говорят cache stampede / dog-pile и thundering herd для всей группы. Разделение на penetration / breakdown / avalanche пришло из китайских инженерных статей и часто встречается в вопросах на российских собеседованиях. Полезно знать оба словаря и уметь их связать: «breakdown и avalanche — частные случаи stampede, а penetration стоит отдельно, потому что там кэшировать буквально нечего».

7.2Redis: устройство и почему он быстрый

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

Что такое Redis

Redis (REmote DIctionary Server) — сервер структур данных в памяти. Ключ в нём всегда бинарно-безопасная строка, а значение — одна из встроенных структур: строка, хеш, список, множество, упорядоченное множество, битовая карта, HyperLogLog, поток, geo-индекс, а с Redis 8 ещё vector set, JSON и time series. От «мапы по сети» его отличает главное: сервер выполняет операции над структурой на своей стороне (инкремент, добавление в множество, выборку топ-10 по рангу), поэтому значение не надо тащить к клиенту, менять и класть обратно. Отсюда и производительность, и бесплатная атомарность.

Redis используют как кэш, брокер (pub/sub, Streams), хранилище сессий, счётчик, распределённый лок, лидерборд и очередь. Персистентностью (persistence, «сохраняемость») называют способность пережить перезапуск процесса: данные живут в памяти, но их копия периодически или постоянно уходит на диск. У Redis она есть (два механизма, RDB и AOF), но гарантий уровня СУБД не даёт, подробнее об этом в главе 7.6.

Почему быстрый: пять причин, а не одна

  1. Всё в оперативной памяти. На пути запроса нет ни одного обращения к диску. Даже AOF пишется в буфер и сбрасывается фоново (по умолчанию раз в секунду).
  2. Команды выполняются в одном потоке. Ни мьютексов, ни атомиков, ни переключений контекста, ни борьбы за строки кэша между ядрами. Каждая команда атомарна просто потому, что во время её выполнения ничего другого не происходит. Заодно исчезает целый класс багов, а такты не уходят на синхронизацию.
  3. Мультиплексирование ввода-вывода. Один поток обслуживает десятки тысяч соединений через epoll (Linux), kqueue (BSD/macOS), evport (Solaris) или select как запасной вариант. Никакого «поток на клиента».
  4. Дешёвый протокол RESP. Текстово-бинарный, длины передаются явно, парсер линейный, без разбора грамматики и без рефлексии.
  5. Специализированные компактные кодировки. Маленький хеш хранится не хеш-таблицей, а listpack: это сплошной кусок памяти, который влезает в кэш процессора. Множество целых чисел лежит отсортированным intset, а короткая строка ложится в embstr одним куском вместе с заголовком объекта.

И отдельно: у Redis нет планировщика запросов, нет MVCC, нет журнала транзакций на пути записи, нет проверки ограничений целостности. Он не делает почти ничего из того, на что тратит время СУБД.

Как сформулировать одной фразой

«В Redis время уходит не на работу с данными, а на сеть. Сама команда GET занимает доли микросекунды: хеш от ключа и разыменование указателя. Round-trip по сети внутри дата-центра стоит сотни микросекунд. Поэтому, чтобы ускорить работу с Redis, надо не оптимизировать команды, а сокращать число round-trip: пайплайнинг, MGET, Lua.»

Одна нить, epoll и последовательное выполнение команд клиент 1 клиент 2 клиент 3 клиент 4 клиент N ядро ОС epoll_wait() один системный вызов возвращает список готовых сокетов цикл событий — единственный поток 1. читаем байты из готовых сокетов 2. разбираем RESP, ищем команду в таблице 3. выполняем — атомарно, без блокировок 4. кладём ответ в выходной буфер клиента Ответы порядок ответов равен порядку прихода команд Та же нить во времени: одна O(N)-команда останавливает всех GET SET GET KEYS * GET GET DEL KEYS * по 10 млн ключей — от 1 до 3 секунд полностью в одном потоке все команды всех клиентов стоят в очереди; p99 сервиса улетает в секунды дальше снова микросекунды Фоновые потоки у Redis есть: lazy free (UNLINK), fsync для AOF, BGSAVE через fork, io-threads с 6.0 для чтения и записи в сокеты. Но сама логика команд по-прежнему выполняется строго в одном потоке — это и есть «Redis однопоточный».
Модель выполнения. Сверху показано, как один поток обслуживает тысячи соединений, снизу — чем за это приходится платить: любая длинная команда блокирует голову очереди для всего сервера.

Что именно значит «однопоточный»

Формулировка, которую стоит держать наготове: Redis выполняет команды в один поток; всё остальное может быть многопоточным. По версиям:

ВерсияЧто вынесли из главного потока
всегдаBGSAVE и BGREWRITEAOF — через fork(), то есть отдельный процесс, а не поток
2.4+отдельные потоки под fsync AOF и медленное закрытие файлов
4.0UNLINK и lazyfree-lazy-*: освобождение памяти больших ключей уходит в фоновый поток
6.0io-threads: чтение из сокетов, парсинг RESP и запись ответов можно распараллелить (по умолчанию выключено; до 8.0 чтение включали отдельным флагом). Выполнение команд — нет
7.0sharded pub/sub, Functions; модель исполнения та же

Отсюда вывод: чтобы загрузить 16 ядер, одного процесса Redis мало. Масштабируются либо несколькими инстансами на машине (по одному на ядро, разные порты), либо через Redis Cluster. Вертикально Redis растёт в основном по памяти: лишние ядра займут разве что потоки ввода-вывода.

Где однопоточность стреляет
  • Head-of-line blocking. Любая долгая команда останавливает весь сервер, а не только того клиента, который её послал.
  • Один большой ключ = один большой стоп. DEL множества на миллион элементов освобождает миллион аллокаций синхронно.
  • Lua-скрипт и MULTI/EXEC — тоже атомарные блокировки. Скрипт на секунду = сервер стоит секунду. По умолчанию busy-reply-threshold (раньше lua-time-limit) равен 5 с, после чего Redis начинает отвечать BUSY остальным, а скрипт всё равно продолжает выполняться.
  • Fork на BGSAVE. Сам fork на инстансе с 40 ГБ занимает сотни миллисекунд, и всё это время процесс стоит: копируются таблицы страниц.
  • Дефрагментация и вытеснение тоже отъедают такты того же потока.

Протокол RESP

RESP (REdis Serialization Protocol) выглядит текстовым, но по сути бинарно-безопасен. Первый байт задаёт тип, \r\n завершает элемент, у строк длина передаётся явно, поэтому в значении могут быть любые байты, включая нули и переводы строк. Клиентские библиотеки всегда шлют команду массивом bulk-строк.

Как SET user:1 Ivan выглядит на проводе SET user:1 Ivan команда, как её пишет разработчик *3 массив из трёх элементов $3 дальше строка ровно 3 байта SET имя команды — тоже просто строка $6 длина 6 user:1 ключ $4 длина 4 Ivan значение, любые байты допустимы после каждой строки идут два байта CR LF +OK ответ: простая строка Типы RESP: тип задаётся первым байтом RESP2 — есть с самого начала + простая строка: +OK, +PONG - ошибка: -WRONGTYPE Operation against... : целое: :1000 $ bulk string с явной длиной; $-1 это nil * массив, элементы могут быть вложенными RESP3 — добавлено в Redis 6 _ null отдельным типом, а не $-1 % map: XINFO и CONFIG GET как словарь ~ set — отличим от массива , double, а также big number и verbatim > push: сервер шлёт сам, без запроса
RESP. Формат специально сделан тривиальным: длина известна заранее, парсер линейный, разбора грамматики нет. На типе > из RESP3 держатся уведомления клиентского кэша (CLIENT TRACKING) и pub/sub на том же соединении.
# отправить кадр руками: ровно те байты, что уходят на провод
$ printf '*3\r\n$3\r\nSET\r\n$6\r\nuser:1\r\n$4\r\nIvan\r\n' | nc localhost 6379
+OK
$ printf 'PING\r\n' | nc localhost 6379      # inline-команды тоже принимаются
+PONG

# переключить соединение в RESP3
$ redis-cli HELLO 3

Сложность команд и запрещённые в проде

КомандаСложностьКомментарий
GET, SET, INCR, EXPIRE, HGET, HSET, SADD, SISMEMBER, LPUSH, RPOPO(1)рабочая лошадка, можно всё
ZADD, ZSCORE, ZRANK, ZINCRBYO(log N), у ZSCORE O(1)skiplist; практически бесплатно
ZRANGE, LRANGE, SRANDMEMBER countO(log N + M), у LRANGE O(S + M), у SRANDMEMBER O(M)M — сколько вернули, а не размер ключа
MGET k1..kN, DEL k1..kNO(N) по числу ключейнормально, если N разумный
HGETALL, SMEMBERS, LRANGE key 0 -1O(N) по размеру ключаопасно на большом ключе: и блокировка, и трафик
KEYS patternO(N) по всему keyspaceзапрещено в проде
FLUSHALL / FLUSHDBO(N)без ASYNC до Redis 7.4 освобождал всё синхронно; с 7.4 память освобождает фоновый поток, ждёт только вызвавший клиент
SORTO(N log N)плюс возможные лукапы BY/GET
SUNION, SINTER, ZUNIONSTOREO(N) / O(N*M)считать заранее, не в горячем пути
DEBUG SLEEP, DEBUG OBJECTс Redis 7.0 выключены по умолчанию; если включали, ставить в ACL-запрет вместе с KEYS
Почему KEYS убивает прод

KEYS * проходит всю таблицу ключей, собирает совпадения в один ответ и делает это в главном потоке. На 10 млн ключей это секунды полной остановки: не отвечают health-check, копятся таймауты у всех клиентов, sentinel может решить, что мастер умер, и устроить failover, то есть переключить роль мастера на одну из реплик (механика в главе 7.6). Вдобавок ответ на сотни мегабайт раздувает выходной буфер, и Redis может отключить клиента по client-output-buffer-limit, если лимит для обычных клиентов настроен: по умолчанию он у них выключен. То же касается SMEMBERS на множестве из миллиона элементов и HGETALL на большом хеше.

SCAN: как обходить ключи правильно

SCAN обходит ключи по курсору. Каждый вызов возвращает новый курсор и порцию ключей; обход закончен, когда курсор снова стал 0. Работа идёт маленькими кусками, и между вызовами сервер спокойно обслуживает других клиентов.

# плохо: блокирует сервер целиком
KEYS session:*

# плохо: то же самое из кода
rdb.Keys(ctx, "session:*")
# хорошо: курсор, порциями
SCAN 0 MATCH session:* COUNT 500       # ответ: курсор 17408 и порция ключей
SCAN 17408 MATCH session:* COUNT 500   # ответ: курсор 0, обход завершён
// Итератор go-redis сам крутит курсор и проверяет ctx.
iter := rdb.Scan(ctx, 0, "session:*", 500).Iterator()
var batch []string
for iter.Next(ctx) {
    batch = append(batch, iter.Val())
    if len(batch) == 500 {
        // Удаляем пачками через UNLINK: большие значения освободит фоновый поток.
        if err := rdb.Unlink(ctx, batch...).Err(); err != nil {
            return err
        }
        batch = batch[:0]
    }
}
if err := iter.Err(); err != nil {
    return err
}
if len(batch) > 0 {
    return rdb.Unlink(ctx, batch...).Err()
}
return nil
Гарантии SCAN спрашивают отдельно
  • Ключ, который жил от начала до конца итерации, вернётся хотя бы один раз.
  • Ключ может вернуться несколько раз, так что обработчик обязан быть идемпотентным.
  • Ключи, добавленные или удалённые во время обхода, могут вернуться, а могут нет.
  • COUNT лишь подсказывает, сколько слотов просмотреть, а не задаёт размер ответа. Порция может прийти пустой, и это не значит, что обход закончился: закончен он, только когда вернулся курсор 0.
  • MATCH фильтрует после выборки, так что скорость он не повышает, а только уменьшает трафик.
  • Курсор устойчив к рехешу таблицы: он обходит бакеты «реверсивным двоичным инкрементом», поэтому и рост, и сжатие хеш-таблицы во время итерации не ломают гарантию.
  • Есть HSCAN, SSCAN, ZSCAN для обхода внутри одного большого ключа, и SCAN ... TYPE hash для фильтра по типу.

Блокирующие команды: кого они блокируют

BLPOP, BRPOP, BLMOVE, BLMPOP, BZPOPMIN, XREAD BLOCK и WAIT не блокируют сервер. Redis снимает клиента с обработки, записывает его в список ожидающих на конкретный ключ и продолжает крутить цикл событий. Когда кто-то делает LPUSH, сервер будит ожидающих в порядке очереди сразу после выполнения этого LPUSH. Подвох популярный: «блокирующая команда» звучит страшно, но блокируется только соединение клиента.

// Воркер очереди: висим на BRPOP до 5 секунд, потом повторяем.
// Таймаут нужен, чтобы горутина реагировала на отмену контекста.
for {
    res, err := rdb.BRPop(ctx, 5*time.Second, "queue:jobs").Result()
    if err == redis.Nil {
        continue                       // по таймауту просто повторяем
    }
    if err != nil {
        if ctx.Err() != nil {
            return ctx.Err()           // сервис останавливается
        }
        time.Sleep(200 * time.Millisecond)
        continue
    }
    handle(res[1])                     // res[0] - имя ключа, res[1] - значение
}
Отдельное соединение под блокирующие команды

Соединение, висящее в BRPOP, занято целиком и не может обслуживать другие запросы. Если пул соединений маленький, десяток воркеров с BRPOP исчерпает его, и обычные GET начнут ждать свободного соединения. Поэтому под блокирующие команды и под подписку pub/sub заводят отдельный клиент со своим пулом. В go-redis для подписки это происходит само (Subscribe берёт выделенное соединение), а вот BRPOP берёт коннект из общего пула, и за этим надо следить.

Redis против Memcached

RedisMemcached
Модель данныхстроки, хеши, списки, множества, zset, битмапы, HLL, streams, geoтолько строка (opaque blob)
Потокиодин поток на командычестно многопоточный, масштабируется по ядрам
ПерсистентностьRDB + AOFнет вообще, только память
Репликация / HAасинхронная репликация, Sentinel, Clusterнет; шардирование делает клиент
Атомарные операциилюбые, плюс Lua и транзакцииincr/decr, cas, add, append: простые операции над одним ключом
Вытеснение8 политик maxmemory-policy, с Redis 8.6 — 10slab-аллокатор + LRU внутри класса
Фрагментация памятиjemalloc, есть активная дефрагментацияslab-классы: почти нет фрагментации, но есть внутренние потери
Pub/Sub, очереди, локиестьнет
Когда лучшепочти всегда: нужны структуры, атомарность, персистентность, HAчистый кэш HTML-фрагментов или сериализованных объектов, где хочется линейно масштабироваться по ядрам одной большой машины

Короткий ответ на собеседовании: Memcached — это распределённая мапа, Redis — сервер структур данных. Memcached выигрывает ровно в одном сценарии: когда нужна многопоточность на одной машине при предельно простом доступе. Во всех остальных берут Redis, потому что структуры и атомарные операции на сервере закрывают целые классы задач (счётчики, лидерборды, локи, рейт-лимиты).

Глубже, чем спросят: внутренние кодировки

У каждого значения есть encoding, видимый через OBJECT ENCODING key. Строка бывает int (число до 20 знаков хранится как long, без аллокации буфера), embstr (до 44 байт: заголовок и данные лежат одним куском памяти, аллокация одна; с Redis 8.2 в тот же кусок в 64 байта ложится и ключ, так что ключ и значение вместе должны уложиться примерно в 41 байт) и raw. Хеш и zset начинают жизнь как listpack, плоский массив байтов: поиск в нём линейный, но данные лежат одним сплошным куском памяти, процессор читает их подряд, и на малых размерах это быстрее хеш-таблицы. В «настоящую» структуру хеш переходит, когда превышен hash-max-listpack-entries (512) или hash-max-listpack-value (64 байта); для zset — zset-max-listpack-entries (128), для списка — list-max-listpack-size (по умолчанию 8 КБ на узел). Обратно хеш не возвращается: если он один раз разросся, то останется хеш-таблицей, даже если удалить из него почти всё. Список с Redis 7.2 умеет и назад: quicklist, снова ставший маленьким, превращается в listpack. Множество целых до set-max-intset-entries лежит отсортированным intset, и поиск по нему бинарный. Если спросят, почему Redis экономно расходует память, listpack будет очень выигрышной деталью.

$ redis-cli SET n 12345
$ redis-cli OBJECT ENCODING n            # "int"
$ redis-cli SET s "короткая строка"
$ redis-cli OBJECT ENCODING s            # "embstr"
$ redis-cli HSET h f1 v1
$ redis-cli OBJECT ENCODING h            # "listpack"
$ redis-cli MEMORY USAGE h               # сколько байт реально занимает ключ

# инструменты диагностики, которые стоит назвать на собесе
$ redis-cli --latency-history            # латентность в динамике
$ redis-cli --bigkeys                    # самые большие ключи каждого типа (через SCAN)
$ redis-cli --hotkeys                    # нужна LFU-политика: allkeys-lfu или volatile-lfu
$ redis-cli SLOWLOG GET 10               # команды дольше slowlog-log-slower-than (10 000 мкс)
$ redis-cli INFO commandstats            # усреднённое время на каждую команду

Вопросы

6
Суть: сервер структур данных в памяти. Быстрый за счёт пяти вещей сразу: данные в RAM, команды в одном потоке без синхронизации, мультиплексирование сокетов через epoll, очень дешёвый протокол и компактные кодировки.

Что это

Сервер структур данных, в отличие от «мапы по сети»: ключ — бинарная строка, значение — строка, хеш, список, множество, sorted set, битмап, HyperLogLog, stream или geo-индекс. Операции над структурой сервер выполняет у себя: инкремент, добавление в множество, выборку топ-N. Значит, не надо тащить значение к клиенту и класть обратно, отсюда и скорость, и атомарность.

Почему быстрый

  • Всё в памяти. На пути запроса нет диска. AOF по умолчанию сбрасывается раз в секунду фоновым потоком, а не на каждой записи.
  • Один поток на выполнение команд. Нет мьютексов, атомиков, переключений контекста, нет конкуренции за строки кэша между ядрами. Атомарность каждой команды достаётся бесплатно: во время её выполнения ничего другого не происходит.
  • Мультиплексирование. С epoll на Linux (kqueue на BSD) один поток следит за десятками тысяч сокетов одним системным вызовом, и «поток на клиента» не нужен.
  • Протокол RESP. Длины передаются явно, парсер линейный, разбора грамматики нет.
  • Компактные кодировки. Маленький хеш хранится как listpack, сплошной кусок памяти, который процессор читает подряд; множество целых лежит в intset, короткая строка — в embstr, в одной аллокации с заголовком.
  • И то, чего Redis не делает: нет планировщика запросов, MVCC, журнала транзакций на пути записи, проверок целостности.
Чем добить

«Дорого обходится не работа с данными, а сетевой round-trip. Сама команда занимает доли микросекунды, сеть — сотни. Поэтому работу с Redis почти всегда ускоряют, сокращая число round-trip: MGET вместо цикла GET, пайплайнинг, Lua-скрипт вместо read-modify-write.»

Суть: однопоточное только выполнение команд. Снапшоты давно делает отдельный процесс, а ввод-вывод и освобождение памяти можно отдать фоновым потокам. Проблема — блокировка головы очереди: любая долгая команда останавливает всех.

Где потоки есть

  • BGSAVE и BGREWRITEAOF работают через fork(): отдельный процесс с копированием при записи.
  • С 2.4 появились отдельные потоки под fsync AOF и закрытие файлов.
  • С 4.0 UNLINK и семейство lazyfree-lazy-* отдают освобождение памяти больших ключей фоновому потоку.
  • С 6.0 есть io-threads (по умолчанию выключены): чтение из сокетов, парсинг RESP и запись ответов можно распараллелить. Логика команд по-прежнему идёт в одном потоке.

Чем это хорошо

Каждая команда атомарна по построению; нет ни одной блокировки на пути данных; поведение предсказуемо, а профиль латентности очень узкий, пока все команды O(1).

Чем плохо

  • Head-of-line blocking: KEYS *, SMEMBERS на миллионе элементов, DEL большого ключа, тяжёлый Lua-скрипт — и весь сервер стоит. Причём страдают все клиенты, а не автор команды.
  • Не масштабируется по ядрам. Потоки ввода-вывода снимают с главного потока сеть, но команды всё равно идут в одном, и 32 ядра один инстанс не загрузит. Масштабируются несколькими инстансами на разных портах или через Redis Cluster.
  • Fork на снапшоте на инстансе в десятки гигабайт занимает сотни миллисекунд полной остановки: копируются таблицы страниц.
Добивающий вопрос

«Если Redis однопоточный, зачем ему MULTI/EXEC?» Ответ: отдельная команда атомарна и так, а транзакция нужна, чтобы группа команд выполнилась подряд без чужих команд между ними. При этом транзакция Redis устроена иначе, чем в СУБД: отката нет, ошибка в середине не отменяет уже выполненное, а условную логику делают через WATCH (оптимистическая блокировка) или Lua.

Суть: REdis Serialization Protocol — тип задаётся первым байтом, \r\n разделяет элементы, длины строк передаются явно. Парсится за один линейный проход и при этом читаем глазами.

Клиентская библиотека шлёт команду массивом bulk-строк. SET user:1 Ivan уходит на провод как *3, затем три пары «длина + данные». Явная длина делает протокол бинарно-безопасным: в значении могут быть нули, переводы строк, что угодно. Парсер не ищет разделитель, он просто читает N байт.

Типы RESP2

  • + простая строка (+OK), - ошибка (-WRONGTYPE ...), : целое (:1000), $ bulk string ($-1 — nil), * массив.

Что добавил RESP3 (Redis 6, включается через HELLO 3)

  • _ — настоящий null вместо $-1/*-1.
  • % map, ~ set, , double, big number, verbatim string: клиент теперь понимает семантику ответа, а не «плоский массив, который надо разбирать по соглашению».
  • > push: сервер шлёт сообщения сам, не в ответ на команду. Отсюда два следствия: pub/sub теперь живёт на том же соединении, что и обычные команды, и клиентский кэш (CLIENT TRACKING) получает от Redis уведомления, что закэшированный ключ изменился, прямо в рабочем соединении (в RESP2 для них нужно отдельное соединение с подпиской).

Почему не protobuf? Протокол задумывали так, чтобы он тривиально разбирался на любом языке, отлаживался через telnet и не требовал схемы. Вдобавок сервер принимает inline-команды: можно послать PING\r\n текстом и получить +PONG.

Суть: обе O(N). Первая и сейчас выполняется в единственном потоке — на большом keyspace это секунды полной остановки сервера; вторая так же останавливала сервер до Redis 7.4, а все данные стирает и теперь. Вместо KEYS — курсорный SCAN, вместо DEL больших ключей — UNLINK.

Что именно происходит при KEYS

Redis проходит всю таблицу ключей, накапливает совпадения в один ответ и делает это синхронно. На 10 млн ключей это занимает от одной до трёх секунд. За это время не отвечают health-check, у всех клиентов копятся таймауты, а Sentinel вполне может решить, что мастер мёртв, и запустить failover. Плюс сам ответ на сотни мегабайт раздувает выходной буфер, и клиента могут отключить по client-output-buffer-limit, если для обычных клиентов этот лимит включён (по умолчанию нет).

До Redis 7.4 FLUSHALL и FLUSHDB без опций синхронно освобождали всю память, и это была та же блокировка; с Redis 4 от неё спасал FLUSHALL ASYNC, который отдаёт освобождение фоновому потоку. С 7.4 так работает и обычный вызов, только клиент ждёт окончания, а сервер обслуживает остальных (кроме вызова внутри транзакции или Lua-скрипта). Сервер так же останавливают: SMEMBERS на большом множестве, HGETALL на большом хеше, LRANGE key 0 -1, SORT, DEBUG SLEEP.

Как правильно

SCAN 0 MATCH "session:*" COUNT 500
SCAN 17408 MATCH "session:*" COUNT 500
# ... пока не вернётся курсор 0

И гарантии, о которых надо знать: ключ, живший всю итерацию, вернётся хотя бы раз; ключ может вернуться несколько раз; добавленное или удалённое во время обхода вернётся как повезёт. COUNT служит подсказкой, а не размером ответа: порция может прийти пустой, и это не признак конца. Конец обозначает только курсор 0. MATCH фильтрует после выборки, и скорость от него не растёт.

Что сделать, чтобы не выстрелило

Опасные команды запрещают на сервере, а не договорённостями: ACL SETUSER app on >pass ~app:* +@all -@dangerous или переименовывают через rename-command KEYS "". А лучше вообще не строить приложение вокруг поиска по шаблону: если нужно перечислять ключи, значит, нужен индекс — множество или sorted set, где эти ключи лежат явно.

Суть: нет. Блокируется только соединение клиента: Redis снимает его с обработки, кладёт в список ожидающих на ключ и продолжает крутить цикл событий.

Когда другой клиент делает LPUSH, сервер сразу после выполнения этой команды (а в транзакции — после всей транзакции) находит ожидающих на этом ключе и раздаёт им добавленные элементы: кто дольше ждёт, тот получает первым. Никакого опроса, никакого таймера. Для сервера ожидающий клиент почти бесплатен, это просто запись в списке.

Что действительно блокирует сервер

  • Долгие O(N)-команды: KEYS, SMEMBERS, SORT.
  • Lua-скрипты и MULTI/EXEC: выполняются целиком, не прерываясь.
  • Освобождение большого ключа без lazy free.
  • fork() при BGSAVE на большом датасете.

Практика на стороне Go

Соединение, висящее в BRPOP, занято целиком, поэтому под блокирующие команды и pub/sub заводят отдельный клиент со своим пулом — иначе десяток воркеров выест общий пул, и обычные GET начнут ждать свободного коннекта. Таймаут в BRPOP делают обязательно конечным (например, 5 с), иначе горутина не отреагирует на отмену контекста при остановке сервиса. И держи в голове, что BRPOP даёт at-most-once: если воркер упал между извлечением и обработкой, задача потеряна. Для at-least-once нужен BLMOVE в processing-список или Streams с consumer group и XACK.

Суть: Memcached — распределённая мапа «строка в строку», Redis — сервер структур данных с персистентностью, репликацией и атомарными операциями. Memcached выигрывает ровно в многопоточности на одной машине.

Ключевые различия

  • Модель данных. В Memcached значение хранится непрозрачным блобом, и любое изменение превращается в «прочитать, поменять у себя, записать обратно», то есть в гонку. В Redis есть INCR, SADD, ZADD, HSET: операции выполняются на сервере и атомарны.
  • Потоки. Memcached по-настоящему многопоточный и линейно масштабируется по ядрам одной машины. У Redis один поток на команды, растёт он шардированием.
  • Персистентность и HA. У Memcached их нет вовсе: перезапустил — пусто, реплик нет, шардирование целиком на клиенте. У Redis есть RDB/AOF, репликация, Sentinel, Cluster.
  • Память. Memcached использует slab-аллокатор: почти нет внешней фрагментации, но есть внутренние потери на округление до класса размера, и данные одного класса вытесняют только друг друга. Redis работает на jemalloc с активной дефрагментацией и восемью политиками вытеснения (с Redis 8.6 — десятью).
  • Дополнительно у Redis: pub/sub, Streams, Lua, транзакции, распределённые локи, rate limiting, TTL на отдельные поля хеша (Redis 7.4), геозапросы, вероятностные структуры.

Как отвечать

«В подавляющем большинстве задач берут Redis: структуры и атомарные операции снимают целые классы проблем, а персистентность и репликация нужны почти всегда. Memcached осмыслен, если нужен тупой кэш блобов, объём большой, машина одна и хочется загрузить все её ядра без шардирования. Есть и ещё довод: Memcached предельно прост, и неправильно эксплуатировать его почти невозможно.»

Про лицензию иногда спрашивают

В марте 2024 Redis Ltd. сменила лицензию с BSD на двойную RSALv2/SSPL, и Linux Foundation в ответ форкнула проект в Valkey (его поддерживают AWS, Google, Oracle). В мае 2025 Redis 8 добавил третий вариант, AGPLv3. На практике разработчик пока разницы не заметит — протокол и команды совместимы, go-redis работает с обоими, — но знать про форк полезно: в облаках РФ и за рубежом всё чаще предлагают именно Valkey.

7.3Структуры данных Redis и сценарии

Перечислить пять типов может кто угодно. Сильный ответ к каждому типу приводит реальную задачу, команду и сложность и объясняет, почему здесь нужен именно он, а не соседний.

Карта типов

ТипЧто этоКлючевые командыСложностьТиповой сценарий
stringбайты до 512 МБ; число, если похоже на числоGET, SET, SETNX, INCRBY, MGET, GETEX, GETDELO(1)кэш сериализованного объекта, счётчики, rate limit, лок
hashсловарь поле → значение внутри одного ключаHSET, HGET, HMGET, HINCRBY, HDEL, HSCAN, HEXPIREO(1) на полесессии, объекты, где нужно менять одно поле
listдвусвязный список (quicklist из listpack-узлов)LPUSH, RPOP, BRPOP, LMOVE, LRANGE, LTRIM, LLENO(1) по краям, O(N) в серединуочередь задач, стек, «последние N событий»
setмножество уникальных строк без порядкаSADD, SISMEMBER, SREM, SPOP, SINTER, SINTERCARD, SSCANO(1) добавить/проверитьтеги, множества прав, «общие друзья», дедупликация
sorted setмножество + вещественный score, всегда отсортированоZADD, ZSCORE, ZRANK, ZRANGE, ZRANGEBYSCORE, ZREMRANGEBYSCORE, ZPOPMINO(log N)лидерборд, отложенные задачи, sliding-window лимиты, индекс по времени
bitmapта же строка, адресуемая по битамSETBIT, GETBIT, BITCOUNT, BITPOS, BITOP, BITFIELDO(1) бит, O(N) счётактивность пользователей по дням, компактные флаги
HyperLogLogвероятностный счётчик уникальных, до 12 КБ на любой объёмPFADD, PFCOUNT, PFMERGEO(1)уникальные посетители, уникальные IP, охват
streamappend-only лог с ID и consumer groupXADD, XREAD, XREADGROUP, XACK, XAUTOCLAIM, XTRIMO(1) добавить, O(log N) поисклента событий, очередь с подтверждениями и переигровкой
geoнадстройка над zset: score = geohashGEOADD, GEOSEARCH, GEODISTO(log N)«ближайшие точки к координате»
Внутренние кодировки: маленькие значения лежат компактно и переходят в «настоящую» структуру по порогу Смотреть через OBJECT ENCODING key. Пороги настраиваются в redis.conf. string int 64-битное целое значение не число embstr в одном куске с ключом ключ + значение > 41 Б raw отдельный SDS-буфер list listpack мало и коротких больше 8 КБ quicklist связка listpack-узлов hash listpack плоский массив байт больше 512 полей или поле длиннее 64 Б hashtable dict, доступ за O(1) set intset только целые, сорт. появилась строка listpack до 128 элементов больше 128 hashtable dict без значений zset listpack пары member+score больше 128 пар или элемент > 64 Б skiplist + dict ранг за O(log N) Хеш назад не переключается, даже если удалить почти всё: только пересоздание ключа. Список с Redis 7.2 умеет назад. bitmap и HyperLogLog — это обычные строки с особой интерпретацией байт; stream хранится в radix-дереве (rax).
Компактные кодировки. На них Redis и экономит память на мелких объектах: хеш из десяти полей в listpack занимает в разы меньше полноценной хеш-таблицы.

string: счётчики и rate limit

INCR атомарен и возвращает новое значение, так что распределённый счётчик получается без единой строчки синхронизации. На нём считают просмотры, держат лимиты, генерируют id.

// Счётчик просмотров: одна команда, атомарно, без гонок.
views, err := rdb.Incr(ctx, "post:42:views").Result()

// Пакетный сброс в БД раз в минуту (write-behind, см. 7.4):
// забираем значение и обнуляем одной атомарной операцией
n, _ := rdb.GetDel(ctx, "post:42:views").Int64()   // до Redis 6.2: GETSET key 0

Rate limit: фиксированное окно

Проще всего устроен лимит «не больше N запросов в минуту»: минута в имени ключа, счётчик растёт, а TTL с запасом ставится вместе с каждым инкрементом.

// Ключ вида rl:user:42:29795911: номер минуты от эпохи, TTL чуть больше окна.
key := fmt.Sprintf("rl:%s:%d", userID, time.Now().Unix()/60)

pipe := rdb.TxPipeline()
incr := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 70*time.Second)   // TTL с запасом, чтобы ключ точно умер
if _, err := pipe.Exec(ctx); err != nil {
    return false, err                   // Redis лёг: пропускать или резать, решай сам
}
return incr.Val() <= limit, nil
Проблема границы окна

Фиксированное окно пропускает двойной всплеск на стыке: при лимите 100/мин клиент делает 100 запросов в 14:00:59 и ещё 100 в 14:01:00. Двести запросов за одну секунду, и формально ничего не нарушено. Если это важно (защита от абьюза, а не грубый учёт), берут скользящее окно на sorted set или token bucket на Lua. Вторая типовая ошибка: EXPIRE уходит отдельным запросом без пайплайна. Упадёт процесс между INCR и EXPIRE — ключ останется без TTL и будет лежать в памяти вечно. А в схеме без минуты в имени (rl:user:42 плюс EXPIRE 60) клиент ещё и окажется заблокирован навсегда.

Rate limit: скользящее окно на sorted set

// Один Lua-скрипт вместо четырёх round-trip: удалить старое, посчитать, добавить, продлить.
var slidingWindow = redis.NewScript(`
local key    = KEYS[1]
local now    = tonumber(ARGV[1])   -- текущее время в мс
local window = tonumber(ARGV[2])   -- размер окна в мс
local limit  = tonumber(ARGV[3])
local id     = ARGV[4]             -- уникальный идентификатор запроса

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local used = redis.call('ZCARD', key)
if used >= limit then
  return {0, used}
end
redis.call('ZADD', key, now, id)
redis.call('PEXPIRE', key, window)
return {1, used + 1}
`)

res, err := slidingWindow.Run(ctx, rdb,
    []string{"rl:sw:" + userID},
    time.Now().UnixMilli(), 60_000, 100, uuid.NewString(),
).Slice()

За точность платят памятью: в zset лежит запись на каждый запрос в окне. При лимите 100/мин это копейки, при 100 000/мин на каждого пользователя уже нет. Тогда берут token bucket: в хеше лежат tokens и last_refill, Lua-скрипт доливает токены по прошедшему времени, и память на пользователя остаётся постоянной.

hash: сессии и объекты

Хеш выигрывает у «JSON в строке», когда нужно читать или менять отдельные поля. HINCRBY атомарно увеличивает поле, не читая весь объект, а HMGET достаёт три поля из тридцати и не гоняет по сети остальные.

# объект как JSON в строке
SET user:42 '{"name":"Иван","age":30,"visits":7}'
# поменять visits: GET, распарсить,
# изменить, сериализовать, SET
# - два round-trip и гонка между ними
# объект как hash
HSET user:42 name Иван age 30 visits 7
HINCRBY user:42 visits 1     # атомарно, O(1)
HMGET user:42 name age       # только нужные поля
HGET  user:42 name           # без лишнего трафика
JSON в stringhash
Частичное чтениенет, тянем всёHMGET
Частичное обновлениеread-modify-write: гонка, нужен WATCH или LuaHSET/HINCRBY, атомарно
Вложенные структурылюбыетолько плоские, значения — строки
Память на маленьком объектехуже: JSON многословенлучше: listpack без имён типов
Память на большом объектелучше, если жать (msgpack, gzip)хуже после перехода в hashtable
TTLна весь ключна весь ключ; с Redis 7.4 — и на отдельные поля (HEXPIRE)
Версионирование схемынадо думать при десериализацииновое поле просто добавляется
Сессии: практика

Сессию обычно кладут в hash: HSET sess:<token> uid 42 ip ... ua ... csrf ... плюс EXPIRE sess:<token> 1800. Скользящий срок продлевают через EXPIRE при каждом обращении; сессию-строку читает и продлевает одна GETEX. Обратный индекс SADD user:42:sessions <token> держи обязательно, иначе «разлогинить со всех устройств» превращается в KEYS. И не клади в сессию то, без чего нельзя работать: Redis не источник правды, сессия должна пересоздаваться.

list: очередь задач

Список двусвязный (физически это quicklist из listpack-узлов, а маленький — один listpack), поэтому вставка и удаление по краям стоят O(1), а обращение к середине O(N). Отсюда два честных применения: очередь/стек и «последние N».

# Очередь FIFO: кладём слева, забираем справа
LPUSH  queue:jobs '{"id":1,"type":"email"}'
BRPOP  queue:jobs 5              # ждём до 5 с, блокируется только клиент

# «Последние 100 событий» постоянного размера без внешней чистки
LPUSH  user:42:feed "event"
LTRIM  user:42:feed 0 99         # O(N) по числу удалённых, здесь по одному

# Надёжная очередь: атомарно перекладываем в processing
BLMOVE queue:jobs queue:processing RIGHT LEFT 5
# ... обработали ...
LREM   queue:processing 1 '{"id":1,...}'
BRPOP теряет задачи

BRPOP даёт at-most-once: элемент уже удалён из списка, а воркер ещё ничего не сделал. Упал под нагрузкой или получил OOM-kill — задача исчезла бесследно. Для at-least-once нужен либо BLMOVE в отдельный processing-список с фоновым «сторожем», который возвращает зависшие элементы обратно, либо Streams с consumer group, где PEL и XAUTOCLAIM делают это из коробки. На собесе здесь удобная развилка: «на list очередь делается за пять минут, а если нужны подтверждения и переигровка, берут Streams или нормальный брокер».

set: уникальность и множества

SADD  post:42:likes 7 9 13        # идемпотентно: повтор ничего не меняет
SCARD post:42:likes               # O(1): размер не пересчитывается
SISMEMBER post:42:likes 9         # O(1)
SPOP  raffle:participants 3       # розыгрыш: 3 случайных, сразу удаляются

SINTER  user:1:friends user:2:friends       # общие друзья, O(N*M)
SINTERCARD 2 user:1:friends user:2:friends LIMIT 100   # 7.0: только размер, с ограничением
SRANDMEMBER feed:pool 5           # 5 случайных, не удаляя

Пока в множестве только целые и их не больше set-max-intset-entries, оно лежит отсортированным intset: компактно, поиск бинарный. Так дёшево хранить «кому показали баннер» или «какие id уже обработаны», если идентификаторы числовые.

sorted set: лидерборд, отложенные задачи, индекс по времени

Sorted set держит две структуры одновременно: словарь member → score для ZSCORE за O(1) и список с пропусками (skiplist), упорядоченный по score, для диапазонных запросов и рангов за O(log N). Каждый узел skiplist хранит ещё и span — число элементов, которые перепрыгивает ссылка. По нему ранг считается без прохода по списку.

sorted set внутри: словарь для ZSCORE и skiplist для диапазонов и рангов dict: member -> score "dan" -> 90 "bob" -> 180 "alice" -> 320 "carol" -> 450 ZSCORE, ZINCRBY, ZADD находят элемент за O(1) уровень 3 уровень 2 уровень 1 head head head dan 90 bob 180 bob 180 alice 320 alice 320 alice 320 carol 450 ZRANGEBYSCORE 300 500: идём по верхнему уровню, пока следующий score меньше 300, спускаемся на уровень ниже и продолжаем. Прыжки логарифмические — узел 90 не читается вообще. Высота башни у нового элемента выбирается случайно (с вероятностью 1/4 на следующий уровень, максимум 32) — поэтому skiplist не требует ребалансировки, в отличие от дерева, и хорошо ложится на однопоточную модель. Каждая ссылка хранит span — число перепрыгнутых элементов. Благодаря span ZRANK и ZRANGE по индексу тоже O(log N), а не O(N): ранг считается суммированием span по пути спуска.
Skiplist. Вероятностная структура вместо сбалансированного дерева: код проще, к кэшу процессора она дружелюбнее, а диапазонные запросы даёт «из коробки». Лидерборду нужно ровно это.
# Лидерборд
ZADD  game:lb 1500 "player:7"          # O(log N)
ZINCRBY game:lb 25 "player:7"          # начислили очки
ZREVRANGE game:lb 0 9 WITHSCORES       # топ-10
ZREVRANK  game:lb "player:7"           # моё место, O(log N)
ZCOUNT    game:lb 1000 2000            # сколько игроков в диапазоне

# Отложенные задачи: score = время запуска в миллисекундах
ZADD  jobs:delayed 1756209600000 "job:991"
ZRANGEBYSCORE jobs:delayed 0 <now> LIMIT 0 100    # что пора выполнить
# забирать надо атомарно, иначе двое воркеров возьмут одну задачу.
# ZPOPMIN не годится: заберёт и задачи из будущего. Нужен Lua-скрипт:
# ZRANGEBYSCORE по now, затем ZREM этих же id — одной операцией
Лидерборд на миллион игроков

ZREVRANK работает за O(log N), поэтому «моё место среди миллиона» считается мгновенно. В этом главная ценность zset: в SQL тут нужна оконная функция по всей таблице. Обсудить стоит две вещи. Одинаковые очки сортируются лексикографически по member, и если нужен тайбрейк по времени, его кодируют прямо в score (например, score = очки * 10^10 + (10^10 - timestamp)), помня, что score хранится как double и точных целых в нём только 53 бита. Второе: лидерборд на миллион записей получается большим ключом и в кластере целиком лежит на одном шарде; если он ещё и горячий, шард станет узким местом (см. 7.5).

bitmap и HyperLogLog: считать дёшево

# Битовая карта: активность пользователя по дням года
SETBIT   user:42:active:2026 238 1     # в день 238 заходил
BITCOUNT user:42:active:2026           # сколько дней всего был активен
BITPOS   user:42:active:2026 1         # первый день активности

# DAU: бит на каждого пользователя за день
SETBIT   dau:2026-08-26 42 1
BITCOUNT dau:2026-08-26                # уникальные за день, точно
BITOP AND weekly dau:2026-08-20 dau:2026-08-21   # кто был оба дня
# 10 млн пользователей = 10^7 бит = 1,25 МБ на день. Точно и очень дёшево,
# но только если id плотные и числовые.

# HyperLogLog: уникальные без хранения самих значений
PFADD   uv:2026-08-26 "ip:1.2.3.4" "ip:5.6.7.8"
PFCOUNT uv:2026-08-26                  # до 12 КБ на ключ, ошибка ~0,81 %
PFMERGE uv:week uv:2026-08-20 uv:2026-08-21 uv:2026-08-22

Спрашивают про разницу: bitmap точен, но требует плотных числовых идентификаторов и памяти пропорционально максимальному id. HyperLogLog приблизителен (стандартная ошибка 0,81 %), зато занимает не больше 12 КБ, будь там миллион уникальных или миллиард, и принимает любые строки. Сами элементы из HLL не достать, и вычитать HLL нельзя, только объединять через PFMERGE.

stream: лента событий с подтверждениями

Stream устроен как append-only лог. Каждая запись получает монотонно растущий ID вида <миллисекунды>-<порядковый>. В отличие от pub/sub, данные сохраняются, и новый потребитель может прочитать историю. Самое ценное здесь consumer group: группа помнит, до какого ID дошла, и ведёт PEL (Pending Entries List) — список выданных, но ещё не подтверждённых записей.

Stream: append-only лог, группа потребителей и PEL 1712-0 order.created 1712-1 order.paid 1713-0 order.shipped 1713-1 user.updated 1714-0 ещё не выдано 1715-0 ещё не выдано XADD добавляет справа last-delivered-id группы workers XTRIM MAXLEN ~ 100000 обрезает слева PEL группы workers — выдано, но не подтверждено ID записи потребитель простаивает выдач 1712-1 worker-1 12 с 1 нормально, обрабатывается 1713-0 worker-2 340 с 1 воркер умер: XAUTOCLAIM отдаст запись другому 1713-1 worker-1 3 с 4 четвёртая попытка: пора в dead letter XREADGROUP выдаёт запись и заводит строку в PEL. XACK удаляет её. Всё, что осталось в PEL, будет переиграно — отсюда гарантия at-least-once, а значит, обработчик обязан быть идемпотентным.
Consumer group. Очередью с подтверждениями лог делает PEL: пока запись не подтверждена через XACK, она считается в работе, и её можно отобрать у зависшего потребителя.
# Продюсер: добавляем событие, автоматически обрезая старое
XADD events:orders MAXLEN ~ 100000 * type order.paid id 991 amount 1200

# Группу создаём один раз: $ = только новое, 0 = с начала
XGROUP CREATE events:orders workers $ MKSTREAM

# Консьюмер: читаем новые записи (символ > означает «то, что ещё никому не выдавали»)
XREADGROUP GROUP workers worker-1 COUNT 10 BLOCK 5000 STREAMS events:orders >

# Подтверждаем обработку
XACK events:orders workers 1712-1

# Забираем зависшее у мёртвых воркеров: всё, что простаивает дольше 60 с
XAUTOCLAIM events:orders workers worker-3 60000 0 COUNT 10
XPENDING   events:orders workers          # сводка: сколько и у кого

Как выбирать структуру

ЗадачаСтруктураПочему
Кэш готового ответа / объекта целикомstringодин GET, минимум накладных
Счётчик, лимит, генератор idstring + INCRатомарно, O(1)
Объект, у которого меняют поляhashчастичное чтение и атомарный HINCRBY
Сессияhash + EXPIREполя разной природы, скользящий TTL
Простая очередь задачlist + BRPOPO(1), пять минут работы
Очередь с подтверждениями и переигровкойstream + consumer groupPEL, XACK, XAUTOCLAIM
Последние N событийlist + LTRIMпостоянный размер без чистки
Уникальность, теги, «есть ли в множестве»setSISMEMBER за O(1)
Рейтинг, топ-N, «моё место»sorted setранг и диапазон за O(log N)
Отложенные задачи, планировщикsorted set, score = времяZRANGEBYSCORE + ZREM в Lua
Точный подсчёт уникальных при плотных числовых idbitmap1 бит на пользователя
Приблизительный подсчёт уникальных при любых idHyperLogLogдо 12 КБ на любой объём
Гео-поиск «рядом со мной»geo (поверх zset)GEOSEARCH по радиусу
Глубже, чем спросят: TTL живёт на ключе, а не на элементе

Частая ошибка проектирования: «положим лайки в set и поставим TTL на каждый лайк». Так не выйдет — EXPIRE работает только на весь ключ. У элементов коллекции своего времени жизни нет, и эмулировать его можно только через zset со score = временем и периодический ZREMRANGEBYSCORE. Исключение одно, и появилось оно в Redis 7.4: HEXPIRE/HPEXPIRE ставят, а HPERSIST снимает TTL у полей хеша. Этого годами не хватало для сессий и для кэша с раздельным сроком жизни полей. Второй нюанс: SET key val без KEEPTTL сбрасывает TTL, а HSET, LPUSH, SADD, INCR его не трогают. Отсюда классика: ключ с TTL внезапно стал вечным после безобидного SET.

Вопросы

7
Суть: пять основных плюс bitmap, HyperLogLog, stream и geo; в Redis 8 встроены ещё JSON, time series, вероятностные структуры и vector set, с 8.8 — array. Выбор делается по тому, какая операция должна быть дешёвой: доступ к полю, порядок, уникальность, подтверждения.
  • string хранит байты, до 512 МБ. Если значение похоже на число, атомарно работают INCR/INCRBY. Сценарии: кэш сериализованного объекта, счётчики, rate limit, распределённый лок через SET NX PX.
  • hash держит словарь внутри ключа, поля читаются и меняются по отдельности: HMGET, HINCRBY. На нём делают сессии, объекты с изменяемыми полями, агрегаты (счётчики по разрезам в одном ключе).
  • list, двусвязный список, с краёв работает за O(1). Отсюда очередь (LPUSH/BRPOP), стек, «последние N» через LPUSH+LTRIM.
  • set хранит уникальные строки без порядка, SISMEMBER за O(1). Сценарии: теги, дедупликация, «кому уже показали», операции над множествами (общие друзья через SINTER).
  • sorted set, множество с вещественным score, всегда отсортирован. ZADD/ZRANK/ZRANGEBYSCORE за O(log N). Берут для лидерборда, отложенных задач (score = время), скользящего окна rate limit, индекса по времени.
  • bitmap адресует ту же строку по битам: активность по дням, DAU. На 10 млн пользователей уходит 1,25 МБ в день.
  • HyperLogLog — приблизительный счётчик уникальных, до 12 КБ на любой объём, ошибка 0,81 %.
  • stream даёт append-only лог с consumer group, PEL и XACK, то есть очередь с подтверждениями и переигровкой.
Как выделиться в ответе

Кроме типов, назови внутренние кодировки. Маленький хеш лежит в listpack, плоском куске памяти с линейным поиском, и при превышении 512 полей превращается в хеш-таблицу. Sorted set состоит из словаря и skiplist, поэтому ZSCORE стоит O(1), а ZRANK O(log N). И упомяни, что назад кодировка не откатывается (кроме списков с Redis 7.2).

Суть: счётчик — INCR, он атомарен. Лимит — либо фиксированное окно (INCR + EXPIRE в одном пайплайне), либо скользящее окно на sorted set, либо token bucket в Lua.

Фиксированное окно

INCR   rl:user42:202608261431
EXPIRE rl:user42:202608261431 70

Просто и дёшево: один ключ на пользователя на окно, память постоянная. Ловушек две. Первая: двойной всплеск на границе — 100 запросов в последнюю секунду минуты и ещё 100 в первую секунду следующей формально укладываются в лимит 100/мин. Вторая: EXPIRE отдельным запросом. Если процесс умрёт между командами, ключ останется навсегда и будет занимать память, а в схеме без окна в имени пользователь ещё и окажется заблокирован. Поэтому обе команды шлют одним пайплайном или Lua-скриптом, а с Redis 8.8 хватает одной INCREX key BYINT 1 UBOUND 100 EX 60 ENX: сверх лимита она не прибавляет, а срок ставит только ключу без TTL.

Скользящее окно на zset

В zset кладут по записи на каждый запрос со score = временем. При проверке ZREMRANGEBYSCORE убирает всё старше окна, ZCARD считает оставшееся, и если до лимита не дошли, следуют ZADD и PEXPIRE. Всё это одним Lua-скриптом, иначе между командами вклинятся конкуренты. Точно, но память растёт линейно от лимита.

Token bucket

В хеше лежат tokens и last_refill; скрипт доливает токены по прошедшему времени и списывает один. Память постоянная, всплески в пределах ёмкости ведра проходят, и обычно нужно именно это. Готовый вариант есть в модуле redis-cell: команда CL.THROTTLE (алгоритм GCRA).

Что спросят следом

«А что делать, если Redis недоступен: пускать или резать?» Правильно ответить, что решение продуктовое и принять его надо заранее: fail-open (пускаем, лимит тут вежливость, а не безопасность) или fail-closed (режем, лимит защищает от абьюза). Плюс запасной локальный лимитер: golang.org/x/time/rate в каждом поде с лимитом N/число подов.

Суть: hash — когда читают или меняют отдельные поля; string с сериализованным объектом — когда объект всегда нужен целиком и внутри есть вложенность.

Аргументы за hash

  • Частичное чтение. HMGET user:42 name avatar отдаёт два поля из тридцати, без лишнего трафика и без разбора всего JSON.
  • Атомарное частичное обновление. HINCRBY user:42 visits 1 обходится одной командой. С JSON пришлось бы делать read-modify-write, а это гонка: два процесса прочитают одно и то же, и один перезапишет чужое изменение.
  • Память на маленьких объектах: listpack без повторяющихся кавычек и имён типов.
  • Эволюция схемы: новое поле просто добавляется, старые клиенты его не видят.

Аргументы за string

  • Вложенные структуры и массивы: в hash значения только строки, вложенность придётся кодировать вручную.
  • Один GET вместо HGETALL, и можно сжать (msgpack, gzip, protobuf): на больших объектах выходит сильно меньше памяти и трафика.
  • Версионировать проще целиком: положил новую версию, и всё.

Сессия — конкретно

HSET   sess:abc123 uid 42 ip 1.2.3.4 csrf xyz
EXPIRE sess:abc123 1800              # скользящий TTL, продлевать при обращении
SADD   user:42:sessions abc123       # обратный индекс для «выйти везде»

Обратный индекс обязателен: без него «разлогинить со всех устройств» превращается в KEYS sess:*. И принципиально: Redis не источник правды для сессии. Если он потеряет данные при failover, пользователь должен просто перелогиниться, а не увидеть 500-ю ошибку.

Redis 7.4: TTL на поля хеша

Раньше главным минусом hash был TTL только на весь ключ. В 7.4 появились HEXPIRE, HPEXPIRE, HTTL, HPERSIST со сроком жизни для отдельных полей. Упомяни их: так видно, что ты следишь за версиями, а не пересказываешь статью 2015 года.

Суть: list + BRPOP — это at-most-once, задача теряется при падении воркера. Для at-least-once нужен либо BLMOVE в processing-список со сторожем, либо Streams с consumer group, PEL и XACK.

Наивный вариант

LPUSH queue:jobs "{...}"
BRPOP queue:jobs 5

Работает, дёшево, O(1). Но BRPOP удаляет элемент сразу: если воркер упал между извлечением и концом работы, задача исчезла — и никто об этом не узнает. Нет и ретраев, приоритетов, истории, нескольких независимых групп потребителей.

Reliable queue на списках

BLMOVE queue:jobs queue:processing RIGHT LEFT 5   # атомарно переложили
# ... обработали ...
LREM   queue:processing 1 "{...}"                 # сняли из обработки

Плюс фоновый сторож, который возвращает в основную очередь всё, что провисело в processing дольше N минут. Время взятия задачи при этом надо где-то хранить, поэтому вместо processing-списка обычно берут zset со score = дедлайном.

Streams

Здесь всё это уже встроено. XREADGROUP выдаёт запись и заводит строку в PEL, XACK её убирает, XPENDING показывает зависшее, XAUTOCLAIM отдаёт живому воркеру записи, простаивающие дольше порога, а по счётчику выдач «ядовитое» сообщение перекладывают в dead letter уже сами. Один поток читают несколько независимых групп, и есть история: переиграть можно с любого ID.

Где проходит граница с Kafka и RabbitMQ

Redis Streams хороши, пока очередь помещается в память и потеря последних сотен миллисекунд при failover допустима (репликация асинхронная — см. 7.6). Когда нужны длительное хранение, гарантии на диске, транзакции, сложная маршрутизация или трафик, не влезающий в RAM, берут Kafka или RabbitMQ. Хорошая формулировка: «Streams — это очередь на том, что уже стоит в инфраструктуре; брокер нужен, когда очередь становится частью контракта между сервисами».

Суть: zset — это словарь member → score для ZSCORE за O(1) плюс skiplist по score для рангов и диапазонов за O(log N). Топ-N — ZREVRANGE, «моё место» — ZREVRANK.
ZINCRBY  game:lb 25 player:7        # начислили очки, O(log N)
ZREVRANGE game:lb 0 9 WITHSCORES    # топ-10
ZREVRANK  game:lb player:7          # место игрока, O(log N)
ZCOUNT    game:lb 1000 2000         # сколько в диапазоне очков

Почему skiplist, а не дерево

Skiplist вероятностный: «высоту башни» каждому элементу выбирает случай (следующий уровень с вероятностью 1/4, максимум 32). Ребалансировка не нужна вообще, код проще красно-чёрного дерева, а нижний уровень обходится как обычный связный список, что идеально для диапазонных запросов. Каждая ссылка хранит span — число элементов, которые она перепрыгивает; суммируя span на пути спуска, Redis считает ранг за O(log N), а не за O(N).

Что обсудить дальше

  • Тайбрейк. При равных очках порядок лексикографический по member. Если нужен «кто раньше набрал, тот выше», время кодируют в score. Только score хранится как double, и целые в нём точны лишь до 2^53.
  • Размер. Лидерборд на миллион игроков становится большим ключом и в кластере целиком живёт на одном шарде. Если он ещё и горячий, шард станет узким местом. Помогает локальный кэш топ-100 на 1–2 секунды: топ читают все, а меняется он редко.
  • Сегментация. Лидерборды обычно заводят по периодам: lb:2026-08, lb:2026-w34. Старые просто протухают по TTL, и чистить один вечный ключ не нужно.
  • Ранг вокруг меня. «Показать 5 выше и 5 ниже»: ZREVRANK, затем ZREVRANGE max(0, rank-5) rank+5 (отрицательный индекс Redis считает с конца), два round-trip или один Lua.
Суть: bitmap — точно, но нужны плотные числовые id и память ~ max(id)/8. HyperLogLog — приблизительно (ошибка 0,81 %), до 12 КБ на любой объём, принимает любые строки.

Bitmap

SETBIT   dau:2026-08-26 42 1
BITCOUNT dau:2026-08-26                       # точное число уникальных
BITOP AND both dau:2026-08-25 dau:2026-08-26  # кто был оба дня
BITCOUNT both

10 млн пользователей дают 10^7 бит, то есть 1,25 МБ на день. Дёшево, точно, а главное — работают пересечение и объединение: retention, «был вчера и сегодня», когорты. Ограничение жёсткое: идентификатор должен быть числом, желательно плотным. Если id разреженные, память улетает: строка занимает байты до самого старшего бита, разреженной она не бывает. А snowflake-ID из шардированной БД не влезут вовсе: номер бита не может превышать 2^32 − 1, потому что строка по умолчанию ограничена 512 МБ.

HyperLogLog

PFADD   uv:2026-08-26 user:42 ip:1.2.3.4 fp:abcdef
PFCOUNT uv:2026-08-26
PFMERGE uv:week uv:2026-08-20 uv:2026-08-21   # объединение без потери точности

HLL смотрит на позицию первого единичного бита в хеше значения: чем реже встречается длинная серия нулей, тем меньше мощность множества. Регистров 16384 по 6 бит, отсюда потолок ~12 КБ (маленький HLL хранится разреженно и весит меньше) и стандартная ошибка 0,81 %. Идеально для «сколько уникальных IP за день» на сотнях ключей.

Ограничения HLL, о которых спрашивают

  • Нельзя достать элементы обратно и нельзя спросить «был ли конкретный».
  • Нельзя вычитать: PFMERGE есть, «PFDIFF» нет.
  • Значения приблизительны, и подвох не там, где его обычно ждут. Уже виденный элемент оценку не меняет никогда (PFADD вернёт 0 — ни один регистр не тронут), а вот новый часто тоже её не двигает: его регистр уже держит максимум.
Как выбрать

Для точности и операций над когортами при числовых id бери bitmap. Если нужен просто масштаб охвата по произвольным ключам и 1 % погрешности никого не волнует, хватит HyperLogLog. Точный список даст обычный set, только памяти он съест O(N): миллион строк по 20 байт тянет на десятки мегабайт в одном ключе, а это уже big key.

Суть: append-only лог с монотонными ID <мс>-<порядковый>. Consumer group помнит last-delivered-id и ведёт PEL — список выданных, но неподтверждённых записей. Пока нет XACK, запись можно отобрать и переиграть.

Механика

  • XADD stream * field value добавляет запись, * означает «сгенерируй ID сам». ID монотонно растут, поэтому по ним можно делать диапазонные запросы за O(log N) (внутри radix-дерево).
  • XREAD читает как из лога: каждый читатель сам держит позицию. По сути это «fan-out всем».
  • XREADGROUP GROUP g consumer > читает в составе группы: запись выдаётся одному потребителю группы и попадает в PEL.
  • XACK удаляет запись из PEL, то есть она обработана.
  • XPENDING показывает, что зависло и у кого; XAUTOCLAIM (6.2) переназначает живому потребителю записи, простаивающие дольше порога. По счётчику delivery count «ядовитое» сообщение отправляют в dead letter.
  • XTRIM stream MAXLEN ~ 100000 или MINID обрезают старое. Тильда включает приблизительную обрезку по границе узла, она дешевле точной.

Гарантии

At-least-once: всё неподтверждённое будет выдано снова, поэтому обработчик обязан быть идемпотентным. Exactly-once Redis не даёт. Kafka даёт его только внутри себя, транзакциями «прочитал — обработал — записал»; для внешних эффектов его добиваются идемпотентностью на стороне потребителя. И оговорка: репликация Redis асинхронная, при failover последние записи и подтверждения могут потеряться.

Чем отличается от pub/sub

Pub/sub работает как fire-and-forget: нет истории, нет подтверждений, отключившийся подписчик просто теряет сообщения. Stream хранит данные, помнит позицию группы, подтверждает обработку и умеет переигрывать. На практике pub/sub годится для сигналов «сбрось локальный кэш», а stream для задач, которые нельзя терять.

7.4Стратегии кэширования и инвалидация

Знать слова «cache-aside» и «write-through» мало. Проверяют понимание: кто в каждой схеме ходит в БД, что будет при сбое каждого из двух хранилищ и какая именно гонка ломает консистентность. Лучше всего в этой секции работает разбор гонки на оси времени.

Пять стратегий: кто ходит в источник правды

Все стратегии отличаются ровно двумя вещами: кто ходит в БД при промахе и когда запись доезжает до БД. Всё остальное из этого следует.

СтратегияЧтение при промахеЗаписьКто знает про БД Главный плюсГлавный минус
Cache-aside
lazy loading
приложение само: GET → nil → SELECT → SET приложение пишет в БД, потом удаляет ключ приложение простота, кэш падает — сервис живёт дублирование логики в каждом месте, окно гонки при заполнении
Read-through кэш сам вызывает loader и заполняет себя отдельно (обычно write-through) слой кэша / библиотека логика загрузки в одном месте, легко навесить singleflight нужен кэш-слой с поддержкой loader, кэш становится обязательным
Write-through обычно вместе с read-through синхронно: кэш → БД, ответ после обоих слой кэша кэш всегда согласован с БД, нет момента «в кэше старое» каждая запись платит латентностью двух хранилищ; кэшируется то, что могут и не прочитать
Write-behind
write-back
как read-through в кэш сразу, в БД — асинхронно пачкой слой кэша + фоновый воркер огромная пропускная способность на запись, склейка повторных апдейтов потеря данных при падении кэша, БД временно отстаёт, сложный ретрай
Refresh-ahead промаха почти нет: значение обновляют до истечения независимо фоновый обновлятор стабильная низкая латентность, нет stampede на истечении греет то, что уже не нужно; лишняя нагрузка на БД при плохом предсказании
Кто разговаривает с базой: приложение (слева) или сам кэш (справа) Cache-aside: про БД знает приложение Read-through + write-through: про БД знает кэш App Cache DB App Cache DB ЧТЕНИЕ, промах ЧТЕНИЕ, промах 1 GET user:42 2 nil - промах 3 SELECT ... WHERE id=42 4 строка 5 SET ... EX 300 1 Get(user:42) 2 loader: SELECT 3 строка 4 значение + запись в себя App вообще не знает, был промах или нет ЗАПИСЬ ЗАПИСЬ 1 UPDATE users SET ... 2 DEL user:42 Порядок обязателен: сначала БД, потом удаление. Между шагами 1 и 2 кэш отдаёт старое значение. 1 Set(user:42, v) 2 UPDATE (синхронно) write-through: ответ App только после шага 2. write-behind: ответ после шага 1, БД догоняет пачкой. Практический вывод: cache-aside переживает падение кэша (все идут в БД), write-through и write-behind — нет, для них кэш на пути записи обязателен.
Кто ходит в базу. В cache-aside приложение управляет обоими хранилищами, поэтому за порядок операций отвечает само. В read/write-through это спрятано за интерфейсом кэша: код проще, но кэш становится частью критического пути.

Cache-aside (lazy loading) по шагам

Эта схема стоит в 90 % продакшенов: ей не нужно ничего, кроме клиента Redis.

  1. Чтение. GET key. Есть значение — отдали, конец.
  2. Промах. Клиент возвращает redis.Nil. Идём в БД, к источнику правды.
  3. Заполнение. Сериализуем и SET key value EX ttl. Ошибку записи в кэш глотаем: не закэшировали — просто будет ещё один промах.
  4. Запись. Сначала UPDATE в БД. Только после успеха делаем DEL key.
func (r *UserRepo) Get(ctx context.Context, id int64) (User, error) {
    key := fmt.Sprintf("user:v3:%d", id)

    // 1. Пробуем кэш.
    b, err := r.rdb.Get(ctx, key).Bytes()
    switch {
    case err == nil:
        var u User
        if json.Unmarshal(b, &u) == nil {
            r.m.Hit()
            return u, nil                 // попадание
        }
        // Битые данные в кэше лечим как промах, а не как ошибку.
    case errors.Is(err, redis.Nil):
        r.m.Miss()                        // честный промах
    default:
        // Redis недоступен или таймаут. Это не ошибка запроса:
        // кэш лишь ускоряет, деградируем до чтения из БД.
        r.m.CacheError()
    }

    // 2. Идём в источник правды.
    u, err := r.db.LoadUser(ctx, id)
    if err != nil {
        return User{}, err                // вот это уже настоящая ошибка
    }

    // 3. Заполняем кэш. Ошибку намеренно игнорируем.
    if raw, e := json.Marshal(u); e == nil {
        _ = r.rdb.Set(ctx, key, raw, jitter(5*time.Minute)).Err()
    }
    return u, nil
}

func (r *UserRepo) Rename(ctx context.Context, id int64, name string) error {
    if err := r.db.UpdateName(ctx, id, name); err != nil {
        return err                        // 1. источник правды первым
    }
    // 2. Инвалидация. Ошибку логируем, но пользователю не возвращаем:
    // запись уже зафиксирована, откатывать нечего. Спасёт TTL.
    if err := r.rdb.Del(ctx, fmt.Sprintf("user:v3:%d", id)).Err(); err != nil {
        r.log.Warn("cache invalidate failed", "key", id, "err", err)
    }
    return nil
}
Правило разделения ошибок

Ошибку кэша на чтении считают деградацией, а ошибку БД отказом. Если у тебя Get возвращает ошибку, когда Redis недоступен, ты превратил кэш из ускорителя в новую точку отказа и потерял всю выгоду схемы. Обратная сторона: если Redis отвалился целиком, вся нагрузка мгновенно ложится на БД. На этот случай нужен локальный кэш или ограничитель конкурентности к БД, иначе кэш, падая, утащит БД за собой.

Что происходит при сбоях

СтратегияУпал кэшУпала БД
Cache-aside работает, но вся нагрузка на БД; риск обвалить БД лавиной промахов чтение горячих ключей ещё какое-то время живёт из кэша, запись падает
Read-through то же, если библиотека умеет обходить кэш; иначе — отказ чтения loader возвращает ошибку, кэш не заполняется, старые значения ещё отдаются
Write-through запись невозможна: кэш на критическом пути запись невозможна, кэш откатывать сложно (нужна компенсация)
Write-behind потеря подтверждённых записей — они были только в памяти запись принимается, очередь на слив растёт; нужен предел и backpressure
Refresh-ahead как cache-aside обновление проваливается, значение доживает до TTL и потом промах

Read-through, write-through, write-behind, refresh-ahead

Read-through

Приложение видит один метод cache.Get(key); за промах отвечает сам слой кэша, который вызывает зарегистрированный loader. Redis из коробки так не умеет, этим занимается библиотека или твоя собственная обёртка. Зато логика «промах → загрузка → запись» живёт в одном месте, и туда же удобно повесить singleflight, метрики, ограничение конкурентности и негативное кэширование.

// Минимальный read-through поверх go-redis.
type Cache[T any] struct {
    rdb    *redis.Client
    ttl    time.Duration
    group  singleflight.Group
}

func (c *Cache[T]) GetOrLoad(ctx context.Context, key string,
    load func(context.Context) (T, error)) (T, error) {

    var zero T
    if b, err := c.rdb.Get(ctx, key).Bytes(); err == nil {
        var v T
        if json.Unmarshal(b, &v) == nil {
            return v, nil
        }
    }
    // Промах: ровно один поход в БД на ключ (см. 7.5).
    v, err, _ := c.group.Do(key, func() (any, error) {
        val, err := load(ctx)
        if err != nil {
            return zero, err
        }
        if raw, e := json.Marshal(val); e == nil {
            _ = c.rdb.Set(ctx, key, raw, jitter(c.ttl)).Err()
        }
        return val, nil
    })
    if err != nil {
        return zero, err
    }
    return v.(T), nil
}

Write-through

Запись идёт в кэш, кэш синхронно пишет в БД, и только потом клиенту отвечают «ок». Кэш и БД никогда не расходятся по содержимому, но за это платим латентностью: время записи = время Redis + время БД, и появляется вопрос атомарности (записали в Redis, БД упала — надо откатывать Redis, а транзакции между ними нет). Схема окупается там, где записанное почти наверняка сразу прочитают: профиль пользователя, корзина, настройки. На write-heavy таблицах, которые читают редко, write-through просто тратит память.

Write-behind (write-back)

Клиенту отвечают сразу после записи в кэш, а в БД изменения уезжают асинхронно — пачками и с дедупликацией. Только эта схема пишет быстрее, чем умеет сама БД: тысяча инкрементов счётчика превращается в один UPDATE ... SET n = n + 1000. Цена прямая: подтверждённая пользователю запись может быть потеряна, если кэш умрёт до слива. Плюс в БД временно лежит старое значение, а значит любой сторонний потребитель (аналитика, репликация, другой сервис) читает неправду.

Где write-behind ловит на собесе

Спросят: «а если между кэшем и БД встанет очередь на 200 тысяч записей и Redis перезапустится?» Правильно ответить, что write-behind допустим только там, где потеря части данных приемлема (счётчики просмотров, лайки, last_seen), а для «настоящих» данных вместо него берут transactional outbox: пишем в БД в одной транзакции с событием, а разгребает уже брокер. Write-behind годится для быстрой записи данных, которые не жалко, и только для них.

Refresh-ahead

Значение обновляют до истечения TTL: либо по расписанию для заранее известного списка горячих ключей, либо при обращении, по правилу «если до истечения осталось меньше 20 %, отдай текущее значение и запусти фоновое обновление». Пользователь никогда не платит за промах, а stampede на истечении просто не возникает. Платим за это прогревом того, что может больше не понадобиться, а если горячее множество угадали плохо, БД получает лишнюю постоянную нагрузку.

// Refresh-ahead поверх обычного значения: храним рядом отметку "пора обновлять".
type entry struct {
    Val       json.RawMessage `json:"v"`
    RefreshAt int64           `json:"r"` // unix-время раннего обновления
}

func (c *Cache[T]) getAhead(ctx context.Context, key string, load loader[T]) (T, error) {
    e, err := c.read(ctx, key)
    if err == nil {
        if time.Now().Unix() > e.RefreshAt {
            // Старое отдаём сразу, обновляем в фоне ровно одним потоком.
            go func() {
                bg, cancel := context.WithTimeout(context.Background(), 3*time.Second)
                defer cancel()
                _, _, _ = c.group.Do("refresh:"+key, func() (any, error) {
                    return nil, c.reload(bg, key, load)
                })
            }()
        }
        var v T
        return v, json.Unmarshal(e.Val, &v)
    }
    return c.GetOrLoad(ctx, key, load)
}

Инвалидация: TTL, событие, версия ключа

Инвалидация решает, когда копия перестаёт считаться годной. Способов ровно три, и в живой системе обычно работают все сразу.

1. По TTL

Самый надёжный и самый тупой способ: ключ живёт N секунд и умирает. Не требует ничего от кода записи, переживает пропущенные события, чинит рассинхрон после любого сбоя. Но данные могут устаревать на весь TTL. TTL берут не «по вкусу», а из бизнес-требования: если «карточка товара может отставать на минуту», ставят TTL 60 секунд. Если допустимое отставание сформулировать не могут, кэшировать эту сущность пока рано.

2. По событию

При изменении сущности удаляем ключ явно: DEL user:42. Данные становятся свежими почти сразу. Сложность в полноте: изменение одной строки часто должно инвалидировать несколько производных ключей — карточку, список, агрегат, счётчик, — и забытый среди них ключ неделями живёт со старыми данными. Плюс события теряются: сеть, рестарт, ошибка в ветке кода.

Что запомнить

TTL и событие работают слоями, а не вместо друг друга. Событие даёт свежесть в нормальном режиме, а TTL ограничивает расхождение сверху, когда что-то сломалось. Поставишь ключ без TTL в расчёте только на событие — и после первого же пропущенного события в памяти навсегда останется мусор.

3. Версионирование ключей вместо удаления

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

  • Версия схемы в префиксе: user:v3:42. Поменяли формат структуры или добавили поле — подняли v3 на v4, и весь старый кэш стал недостижим сам собой, без единого DEL и без KEYS. Старые ключи умрут по TTL. При деплое без этого не обойтись: иначе новый код десериализует старый JSON и получит нули в новых полях.
  • Версия сущности: держим user:42:ver = счётчик, а данные — в user:42:v{N}. На запись делаем атомарный INCR user:42:ver за O(1), и все производные ключи этой сущности сразу становятся недостижимыми, даже если их сотня. На чтение нужно два обращения: имя ключа данных зависит от ответа на GET версии, и пайплайном их не склеить. В одно обращение укладывается только Lua. Так удобно закрывать «список постов пользователя», «агрегаты», «рендер страницы».
// Версия сущности: одна атомарная команда сбрасывает сколько угодно
// производных ключей.
func (r *Repo) userView(ctx context.Context, id int64) (View, error) {
    ver, err := r.rdb.Get(ctx, verKey(id)).Int64()
    switch {
    case errors.Is(err, redis.Nil):
        ver = 0 // версии ещё нет, это нормально
    case err != nil:
        return r.loadView(ctx, id) // Redis не ответил: мимо кэша, а не в v0
    }
    key := fmt.Sprintf("uview:%d:v%d", id, ver)
    // ... обычный cache-aside по key ...
}

func (r *Repo) invalidateUser(ctx context.Context, id int64) error {
    // Ни DEL, ни SCAN: старые ключи просто становятся недостижимы и уходят по TTL.
    return r.rdb.Incr(ctx, verKey(id)).Err()
}
Минус версионирования

Старые версии висят в памяти до TTL. При очень частых записях это заметный перерасход, и без maxmemory-policy с вытеснением можно упереться в память. Второе: ключ версии становится горячим (его читают при каждом чтении сущности), а если он ещё и потеряется при failover, версия откатится назад и всплывёт старый кэш. Лечится тем, что версию берут не из счётчика, а из updated_at строки в БД, если он и так читается.

Консистентность кэша и БД: порядок операций и гонки

Большинство валится на двух вопросах: что трогать первым (БД или кэш) и что делать с ключом (удалять или перезаписывать). Ответ короткий: сначала БД, потом удалить ключ. Но и при таком порядке одна дырка остаётся.

Вариант «сначала кэш, потом БД» — сломан

Удаляем ключ, потом делаем UPDATE. Между этими двумя шагами есть окно, в которое влезает читатель: он видит промах, идёт в БД, читает там ещё старое значение и кладёт его в кэш. Наш UPDATE отрабатывает позже, а устаревшее остаётся в кэше навсегда (до TTL). Окно тут широкое: оно длится весь запрос к БД, десятки миллисекунд. А если UPDATE упадёт, валидный кэш мы выбросили зря.

Вариант «сначала БД, потом удалить» — правильный, но не идеальный

Сначала фиксируем истину, потом сбрасываем копию. Окно, в котором кэш отдаёт старое, сжимается до промежутка между коммитом и DEL, а это доли миллисекунды. Но одна гонка остаётся, и именно её просят разобрать на доске.

Единственная гонка, которая переживает правильный порядок: медленный читатель обгоняет писателя t Поток A — чтение user:42 Поток B — запись user:42 состояние t1 GET user:42 --> nil, промах БД v0 · кэш пусто t2 SELECT ... прочитал v0, но ещё не записал БД v0 · кэш пусто t3 UPDATE users SET ... COMMIT (v0 --> v1) БД v1 · кэш пусто t4 DEL user:42 --> 0, удалять нечего БД v1 · кэш пусто t5 SET user:42 = v0 кладёт УСТАРЕВШЕЕ БД v1 · кэш v0 Кэш и БД разошлись, и сами уже не сойдутся: событие инвалидации прошло раньше, чем появился ключ. Расхождение живёт до истечения TTL. Вероятность мала — нужен читатель, зависший между SELECT и SET дольше всей записи. Почему вероятность мала: обычно чтение из БД быстрее, чем цикл «UPDATE + COMMIT + DEL» писателя, так что A успевает записать до t3, и тогда DEL на t4 нормально его сносит. Гонка требует долгой паузы у A ровно в этом окне: GC, вытеснение горутины, сетевой ретрай.
Гонка «инвалидация обогнала заполнение». Читатель прочитал старое значение до записи, а положил его в кэш после инвалидации. Порядок «сначала БД, потом DEL» делает её редкой, но не устраняет. Закрывает её TTL или отложенное второе удаление.

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

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

  1. Гонка двух писателей. Порядок применения в БД и порядок записи в кэш не совпадают, потому что это две независимые операции по сети. В итоге в кэше остаётся значение проигравшего.
  2. Кэшируем то, что не читают. Когда пишут больше, чем читают, мы забиваем память значениями, которые могут не понадобиться ни разу.
  3. Значение в кэше часто не равно строке в БД. Там лежит агрегат, join, отрендеренный кусок, и писатель просто не знает, что именно туда положить. Удаление корректно всегда.
Два писателя: SET в кэш ломается, DEL — нет t Писатель B1 — ставит v1 Писатель B2 — ставит v2 t1 UPDATE ... COMMIT в БД теперь v1 t2 UPDATE ... COMMIT в БД теперь v2 t3 SET user:42 = v2 t4 SET user:42 = v1 пакет B1 пришёл позже Обновляем кэш (SET) БД: v2 · кэш: v1. Порядок в сети не совпал с порядком коммитов. Расхождение до TTL, и никакой ретрай его не заметит. Удаляем ключ (DEL) Оба сделали DEL — итог один и тот же: ключа нет. Следующее чтение поднимет v2 из БД. DEL идемпотентен и коммутативен.
DEL против SET. Для удаления порядок не важен: два удаления в любом порядке дают одно состояние. Для записи значения порядок важен, поэтому двум конкурирующим писателям без внешней синхронизации её делать нельзя.
Формулировка на собес

«Сначала БД, потом удалить ключ. Не обновить — удалить: DEL идемпотентен и не зависит от порядка, а SET зависит, и два конкурирующих писателя оставят в кэше значение того, чей пакет пришёл позже, а не того, кто позже закоммитил. Полностью гонку это не убирает: остаётся окно, когда медленный читатель кладёт значение, прочитанное до записи, уже после инвалидации. Сверху расхождение ограничено TTL».

Delayed double delete и его ограничения

Против гонки с рисунка выше ключ удаляют дважды: сразу после записи и ещё раз с задержкой, заведомо большей, чем время чтения из БД.

func (r *Repo) Update(ctx context.Context, id int64, v Value) error {
    if err := r.db.Update(ctx, id, v); err != nil {
        return err
    }
    key := keyOf(id)
    _ = r.rdb.Del(ctx, key).Err()          // 1. немедленно

    // 2. Второе удаление через задержку снесёт значение, которое медленный
    //    читатель успел положить уже после первого DEL.
    //    Задержку берут больше p99 времени чтения из БД, обычно 300-800 мс.
    r.delayed.After(500*time.Millisecond, func(bg context.Context) {
        _ = r.rdb.Del(bg, key).Err()
    })
    return nil
}

Приём работает — но только вероятностно, гарантии он не даёт. На собесе стоит назвать все четыре ограничения:

  • Задержку невозможно выбрать корректно. Она должна быть больше самого медленного чтения из БД, а хвост распределения не ограничен: GC-пауза, ретрай, залипшая реплика. Ставим больше — дольше держим лишний промах и грузим БД.
  • Второе удаление само может не выполниться. Под упал, Redis моргнул, таймер жил в памяти процесса — и всё. Для надёжности нужна очередь или отдельный планировщик, то есть новая инфраструктура ради заплатки на гонку.
  • Не спасает от репликации БД. Если читатель ходит на реплику, старое значение он может прочитать и через секунду после коммита. Тогда задержку надо брать больше лага репликации, а он не ограничен.
  • Второй DEL бьёт по свежему значению. Между первым и вторым удалением ключ мог быть корректно заполнен уже новыми данными — а мы его выбросим и получим лишний промах на горячем ключе. На пике это ощутимо.
Когда нужна настоящая гарантия

Если расхождение недопустимо в принципе, кэш не латают, а меняют схему: событие инвалидации пишут в одной транзакции с данными (transactional outbox) и разгребают отдельным воркером с ретраями, либо снимают изменения из WAL через CDC (Debezium) и инвалидируют по факту применённого изменения. Так получается «удалим ключ хотя бы раз после коммита», то есть at-least-once, а с идемпотентным DEL этого достаточно. Можно и вовсе не кэшировать эту сущность, а читать её из БД: не всё обязано лежать в кэше.

Cache penetration и кэширование отрицательных ответов

Три похожих слова, которые постоянно путают. Разведём их сразу — на собесе это ценят.

ТерминЧто происходитЛечение
Penetration
проникновение
запрашивают ключ, которого нет и в БД. Кэш не заполняется никогда, каждый запрос доходит до БД. Классическая атака: перебор несуществующих id. кэшировать «не найдено», Bloom-фильтр, валидация формата id
Stampede
dog-pile
истёк один популярный ключ, и сотни запросов одновременно полезли его перестраивать singleflight, блокировка, раннее обновление (глава 7.5)
Avalanche
лавина
истекло или пропало много ключей сразу: одинаковый TTL после массового прогрева, рестарт Redis, деплой с новым префиксом версии jitter в TTL, прогрев, ограничитель конкурентности к БД

Отрицательное кэширование

Ответ БД «такой сущности нет» тоже результат, и его надо запомнить. Кладём маркер-заглушку с коротким TTL: 10–60 секунд вместо обычных пяти минут.

const missMarker = "__miss__" // маркер отрицательного результата

func (r *Repo) Get(ctx context.Context, id int64) (User, error) {
    key := keyOf(id)
    b, err := r.rdb.Get(ctx, key).Bytes()
    switch {
    case err == nil && string(b) == missMarker:
        return User{}, ErrNotFound          // попадание в "отрицательный" кэш
    case err == nil:
        return decode(b)
    }

    u, err := r.db.LoadUser(ctx, id)
    if errors.Is(err, sql.ErrNoRows) {
        // Короткий TTL: если сущность появится, мы отдадим 404 максимум 30 секунд.
        _ = r.rdb.Set(ctx, key, missMarker, 30*time.Second).Err()
        return User{}, ErrNotFound
    }
    if err != nil {
        return User{}, err                  // ошибку БД не кэшируем никогда
    }
    ...
}
Две ошибки в негативном кэшировании

Во-первых, нельзя кэшировать ошибку БД как «не найдено». Таймаут и ErrNoRows означают разное: закэшировав таймаут, ты превратишь секундную аварию в пятиминутный 404 для всех. Во-вторых, опасен длинный TTL у заглушки: пользователь зарегистрировался, а сервис ещё пять минут говорит «нет такого». Поэтому при создании сущности её negative-ключ удаляют явно, а TTL заглушки всё равно держат коротким.

Когда ключей слишком много и заглушками их не закрыть (например, поиск по произвольным строкам), перед кэшем ставят Bloom-фильтр, компактную битовую структуру, которая отвечает «точно нет» или «возможно есть». На «точно нет» отказываем сразу, не трогая ни кэш, ни БД. Ложноположительные срабатывания просто пропускают запрос дальше, и это безопасно. Правда, фильтр надо строить и поддерживать, а удалить из классического Bloom ничего нельзя (нужен counting или периодическая перестройка). В Redis 8 Bloom-фильтр встроен (BF.ADD, BF.EXISTS), в старых версиях это модуль RedisBloom либо своя реализация на SETBIT.

Глубже: инвалидация и локальный кэш

Как только над Redis появляется in-process кэш, инвалидация усложняется вдвое: DEL в Redis ничего не знает про копии в памяти двадцати подов. Помогает очень короткий TTL локального слоя (1–3 секунды, «сходится за секунду и хватит»), широковещательный сигнал через pub/sub канал cache:invalidate (доставка at-most-once, поэтому только как ускорение поверх TTL) либо client-side caching Redis 6+ (CLIENT TRACKING, протокол RESP3): сервер сам присылает инвалидации по ключам, которые этот клиент читал. Последний вариант называют редко, и это козырь.

Вопросы

7
Суть: стратегии различаются двумя вещами — кто ходит в БД при промахе и когда запись доезжает до БД. Cache-aside: ходит приложение, запись сразу в БД. Read/write-through: ходит сам слой кэша. Write-behind: запись в БД откладывается. В 95 % Go-сервисов правильный ответ — cache-aside.

Коротко по каждой

  • Cache-aside (lazy loading). Приложение делает GET, при nil идёт в БД, кладёт результат в кэш. При записи пишет в БД и удаляет ключ. Кэш ничего не знает про БД, БД ничего не знает про кэш. Плюс: если Redis лёг, сервис отвечает медленнее, но работает. Минус: логика размазана по всем местам, где читают сущность; есть окно гонки при заполнении.
  • Read-through. Приложение обращается только к кэшу; сам кэш при промахе вызывает зарегистрированный loader и заполняет себя. Плюс: логика загрузки в одном месте, туда же легко навесить singleflight, метрики, отрицательное кэширование. Минус: нужен слой, который это умеет (в Go это своя обёртка или otter v2 с его Get(ctx, key, loader); «голый» Redis так не умеет), и кэш становится обязательным звеном.
  • Write-through. Запись идёт синхронно в кэш и в БД, клиент получает ответ только после обеих записей. Плюс: кэш и БД согласованы, момента «в кэше устаревшее» нет. Минус: каждая запись платит латентностью двух хранилищ, а кэш забивается тем, что могут ни разу не прочитать. И это не атомарно: если БД записалась, а кэш — нет, ты снова в мире гонок.
  • Write-behind (write-back). Запись подтверждают сразу после кэша, а в БД сбрасывают асинхронно пачкой. Плюс: запись проходит очень быстро, а повторные апдейты одной строки склеиваются в один UPDATE. Минус: вместе с кэшем теряются подтверждённые данные, БД временно отстаёт, а ретраи и порядок применения становятся твоей проблемой.
  • Refresh-ahead. Значение обновляют фоном до истечения TTL, поэтому промаха почти нет, латентность ровная, stampede на истечении не случается. Цена: греется то, что уже никому не нужно, а на БД висит постоянный фон нагрузки.

Как выбирать

СитуацияСтратегияПочему
Обычный CRUD-сервис, чтений сильно больше записейCache-aside проще всего, отказ кэша не роняет сервис
Много разных мест читают одну сущностьRead-through убирает копипасту и даёт одну точку для singleflight
Нельзя показывать устаревшее сразу после записиWrite-through после ответа клиенту в кэше уже новое значение
Счётчики, просмотры, лайки, метрикиWrite-behind потеря последних секунд допустима, а нагрузка на БД падает на порядки
Дорогой отчёт, который смотрят постоянноRefresh-ahead пересчитать заранее дешевле, чем ловить промах в пике
Чем добить ответ

«На практике я почти всегда беру cache-aside и держу его строго в одном слое, в репозитории, чтобы стратегия не размазывалась. Write-behind допускаю только там, где потеря последних секунд не критична: счётчики просмотров, ленты активности. И отдельно проговариваю, что write-through не даёт атомарности: это две операции по сети, и между ними тоже бывает падение.»

Суть: потому что она не делает кэш обязательным. Ломается в трёх местах: stampede на промахе популярного ключа, гонка «читатель обогнал писателя», и расползание логики кэширования по кодовой базе.

Главное достоинство — деградация, а не отказ

В cache-aside путь «мимо кэша» совпадает с обычным путём к БД, который и так есть в коде. Значит, при недоступном Redis сервис продолжает отвечать: медленнее и с большей нагрузкой на БД, но отвечает. Для этого промах и ошибку кэша обрабатывают одинаково:

b, err := r.rdb.Get(ctx, key).Bytes()
switch {
case err == nil:
    return decode(b)
case errors.Is(err, redis.Nil):
    // промах, идём в БД
case err != nil:
    // ошибка кэша тоже ведёт в БД, а не наверх
    r.metrics.CacheErrors.Inc()
    slog.Warn("cache down, falling back to db", "err", err)
}
return r.loadFromDB(ctx, id)   // единственный источник правды

Три места, где ломается

  1. Stampede. Истёк популярный ключ — и сотни горутин разом пошли в БД за одним и тем же. Лечится singleflight или блокировкой (глава 7.5).
  2. Гонка заполнения. Читатель прочитал старое значение из БД, «завис», а в это время писатель закоммитил новое и удалил ключ; читатель просыпается и кладёт в кэш устаревшее. Разбор на оси времени есть выше в этой главе.
  3. Расползание логики. Через полгода в кодовой базе три места, которые кладут user:42 с разным TTL и разной сериализацией, и одно из них забыли обновить при смене схемы. Лечится тем, что кэш живёт только в репозитории и нигде больше.
Типичная ошибка на собеседовании

Сказать «при ошибке Redis возвращаем ошибку пользователю». Так кэш из ускорителя превращается в новую обязательную зависимость со своей доступностью. Правильно наоборот: если фолбэк в БД под полной нагрузкой её убьёт, то защищаться надо ограничителем конкурентности и circuit breaker к БД, а не отказом в обслуживании.

Суть: write-through берут, когда сразу после записи нельзя показать старое, и готовы платить латентностью. Write-behind — когда запись слишком частая для БД и потеря последних секунд допустима. Риск write-behind — потеря подтверждённых данных.

Write-through

Профиль: «пользователь поменял настройку и тут же перечитывает её». Мы платим за каждую запись латентностью двух хранилищ, зато после 200 OK в кэше уже новое значение. Проговорить стоит вот что:

  • Порядок всё равно БД, потом кэш: иначе при падении БД в кэше окажется значение, которого нет в источнике правды.
  • Если запись в кэш упала после успешной записи в БД, ничего не откатываем, просто удаляем ключ (лучше промах, чем расхождение) и логируем.
  • Write-through разумно совмещать с TTL: он остаётся страховкой от расхождения, которое мы не заметили.

Write-behind

Профиль: счётчики просмотров, лайки, «последняя активность пользователя», агрегаты. Тысяча инкрементов в секунду по одной строке убивает БД блокировками, а в Redis это одна команда INCR. Раз в 5–30 секунд воркер снимает накопленное и пишет в БД пачкой.

// Инкремент в Redis и запись id в set "грязных" ключей.
pipe := rdb.TxPipeline()
pipe.HIncrBy(ctx, "views", postID, 1)
pipe.SAdd(ctx, "views:dirty", postID)
_, err := pipe.Exec(ctx)

// Флашер раз в 10 секунд: забрать грязные id, прочитать значения, записать в БД.
// SPOP/HGETDEL атомарно "вынимает" накопленное, чтобы не потерять инкременты,
// пришедшие во время флаша.
Что спросят следом
  • «Что будет, если Redis перезапустится?» — потеряешь всё, что не успело доехать до БД. Отсюда правило: write-behind только для данных, которые не жалко потерять частично, и с включённым AOF, если жалко хоть немного.
  • «А если флашер упал между чтением и записью в БД?» — данные уже вынуты из Redis и ещё не в БД. Поэтому вынимать надо так, чтобы при падении потерялась только одна пачка, а лучше писать в БД идемпотентно (UPSERT ... SET v = v + delta с ключом пачки от дублей).
  • «Читатель увидит несогласованное?» — да: в БД одно число, в кэше другое. Флашер вынимает накопленное, и в Redis остаётся только несброшенная дельта. Значит, показывать надо сумму «БД плюс дельта из Redis» или держать в Redis отдельный полный счётчик, а БД — как долговременное хранилище и источник для аналитики.
Суть: ответ должен быть про сочетание: TTL как страховка всегда, событийная инвалидация как ускорение, версия в ключе там, где инвалидировать надо пачками. И честное признание: инвалидация по событию — at-most-once, поэтому TTL снимать нельзя.

Это вопрос-ловушка: интервьюер ждёт не определения, а твоей практики. Отвечать надо конкретно и по слоям.

Как выглядит хороший ответ

  1. TTL есть у каждого ключа. Без исключений. Это страховка от «мы где-то забыли инвалидировать» и от утечки памяти, а не механизм свежести. Значение берут из требования к свежести: лента 30 секунд, профиль 5 минут, справочник валют час. TTL всегда с jitter: base + rand(0..base/5), чтобы ключи, залитые одной пачкой, не истекли одновременно.
  2. По событию инвалидируют там, где важна реакция. После успешного коммита транзакции репозиторий удаляет затронутые ключи. Именно удаляет, а не перезаписывает, и именно после коммита, а не внутри транзакции: иначе при откате в кэше окажется то, чего в БД нет.
  3. Когда одна запись бьёт по многим ключам, нужны ключи-теги или версия. Изменили товар — протухли карточка, три списка, два агрегата. Перечислять их руками невозможно, поэтому в ключ вшивают версию сущности, а инвалидируют через INCR версии.
  4. Глобальная версия схемы в префиксе. Поменялся формат значения — меняем префикс (v3:user:42). Старые ключи никто не читает, они уходят по TTL. Заодно исчезает вечная проблема «выкатили новый код, а он читает старый формат».
  5. Локальному слою нужен очень короткий TTL. Инвалидировать копии в памяти двадцати подов надёжно нельзя, поэтому ставят 1–3 секунды, а pub/sub-сигнал используют как ускорение, но не как гарантию.
Фраза, которая закрывает вопрос

«Я исхожу из того, что любая событийная инвалидация может не сработать: сеть, рестарт пода между коммитом и DEL, недоступный Redis. Поэтому событие только ускоряет схождение, а гарантию даёт TTL. Если расхождение недопустимо в принципе, я не кэширую эту сущность или беру transactional outbox / CDC, где событие инвалидации доставляется at-least-once.»

Суть: сначала БД (коммит), потом DEL ключа. Обратный порядок ломается всегда, когда БД откатилась. А DEL вместо SET — потому что при SET два параллельных писателя могут оставить в кэше значение проигравшего, и оно там залипнет до TTL.

Порядок

ПорядокЧто ломаетсяВердикт
Кэш → БД если БД упала или транзакция откатилась, в кэше лежит значение, которого нет нигде. Расхождение гарантированное, а не вероятностное. никогда
БД → DEL если DEL не выполнился, старое значение живёт до TTL. Плюс редкая гонка «медленный читатель кладёт устаревшее уже после DEL». стандарт
DEL → БД → DEL delayed double delete: закрывает ту редкую гонку вероятностно, ценой лишнего промаха и невозможности корректно выбрать задержку. по необходимости

Тонкость, о которой забывают

DEL идёт после COMMIT, а не внутри транзакции. Если удалить ключ внутри транзакции, то между DEL и коммитом читатель успеет прочитать из БД ещё старое значение и положить его в кэш — выходит, ты своими руками сделал ту самую гонку детерминированной. В Go это значит: инвалидацию вызывают явно после успешного tx.Commit(). defer в той же функции сработает уже после коммита, но только если коммит в ней и происходит: когда транзакцию коммитят выше по стеку, отложенный DEL успеет раньше.

func (r *Repo) Update(ctx context.Context, u User) error {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil { return err }
    defer tx.Rollback()

    if err := updateUserTx(ctx, tx, u); err != nil { return err }
    if err := tx.Commit(); err != nil { return err }   // только после успешного коммита

    // Инвалидация вне транзакции. Ошибку не возвращаем наверх: данные уже записаны,
    // операция успешна. Логируем и полагаемся на TTL.
    if err := r.rdb.Del(ctx, keyOf(u.ID)).Err(); err != nil {
        slog.Warn("cache invalidation failed", "key", keyOf(u.ID), "err", err)
        r.metrics.InvalidateErrors.Inc()
    }
    return nil
}

Почему DEL, а не SET

  1. Гонка двух писателей. B1 пишет v1, B2 пишет v2; в БД порядок один, а в кэш они попадают по сети в другом порядке, и в кэше остаётся v1 при БД v2. С DEL такого нет: два удаления в любом порядке дают одно и то же состояние (пусто), а следующий читатель возьмёт актуальное из БД. Весь смысл в том, что удаление идемпотентно и коммутативно.
  2. Писатель часто не знает, что класть. В кэше лежит не строка таблицы, а агрегат, join или готовый DTO. Если собирать его в коде записи, придётся дублировать логику чтения.
  3. Не греем то, что не читают. Когда записей больше, чем чтений, SET забивает память значениями, за которыми никто не придёт.
Глубже: единственное исключение

SET вместо DEL оправдан, когда ключ очень горячий, а значение дорого пересобирать: удалишь такой ключ и гарантированно получишь stampede на следующей же миллисекунде. Тогда пишут новое значение, но защищаются от гонки писателей: либо SET через Lua с проверкой версии (if new_ver > cur_ver then set), либо кладут версию прямо в значение и отбрасывают запись более старой версии.

Суть: три классические. (1) Читатель обогнал писателя и положил устаревшее уже после инвалидации. (2) Два писателя переставили местами SET в кэш. (3) Инвалидация не доехала вовсе — падение пода между коммитом и DEL. Первые две вероятностные, третья — обычный отказ.

Гонка 1: «инвалидация обогнала заполнение»

Это единственная гонка, которую не убирает правильный порядок «БД → DEL». Читатель A на t2 прочитал из БД v0 и завис (GC, вытеснение горутины, ретрай). Писатель B на t3 коммитит v1, на t4 делает DEL, но удалять нечего — ключа нет. На t5 просыпается A и кладёт в кэш v0. Кэш и БД разошлись до истечения TTL.

Почему это редко: нужно, чтобы пауза читателя ровно в окне между SELECT и SET оказалась длиннее, чем весь цикл писателя «UPDATE + COMMIT + DEL». Обычно наоборот. Чем закрывают: коротким TTL (главный ответ), delayed double delete (вероятностно), заполнением кэша через SET ... NX плюс версия значения. Если гонка недопустима, эту сущность просто не кэшируют.

Гонка 2: «два писателя переставились»

Возникает, только если инвалидация делает SET. B1 и B2 пишут в БД в порядке v1, v2, а в кэш их SET приходят в порядке v2, v1, ведь сеть не обязана сохранять порядок независимых операций. В кэше остаётся v1 при БД v2, и это залипает до TTL. Лечится инвалидацией через DEL.

Гонка 3: «инвалидация не доехала»

Это не гонка, а обычный сбой, но на собеседовании его ждут в том же списке. Между COMMIT и DEL процесс убили, Redis моргнул, сеть отвалилась. Ретрай в том же процессе не поможет — процесса уже нет. Варианты по возрастанию надёжности:

  • TTL ограничивает расхождение своим сроком. Дёшево, работает всегда, покрывает 99 % практических требований.
  • С transactional outbox событие инвалидации пишется в ту же транзакцию, что и данные, а отдельный воркер читает outbox и удаляет ключи с ретраями. Даёт at-least-once, а DEL идемпотентен, так что повторы безопасны.
  • CDC (Debezium и подобные) снимает изменения прямо с WAL, и инвалидация идёт по факту применённого изменения. Так ловятся даже записи в обход сервиса (миграция, ручной UPDATE в консоли), и это отдельный сильный аргумент.
Как звучит сильный ответ

«Правильный порядок — БД, потом DEL — убирает детерминированные расхождения и оставляет одно вероятностное окно: медленный читатель. Я не пытаюсь закрыть его на 100 % внутри кэша, потому что это в принципе не решается двумя независимыми хранилищами без распределённой транзакции. Я ограничиваю ущерб TTL, а там, где ущерб недопустим, либо не кэширую, либо перехожу на outbox/CDC.»

Суть: перечислять зависимые ключи руками нельзя — забудете. Работают три приёма: версия в ключе (инвалидация = INCR, старые ключи умирают по TTL), теги через set имён ключей, и смена префикса для массового сброса. KEYS pattern + DEL — неправильный ответ.

1. Версия в ключе — основной приём

Вместо удаления N ключей делаем один INCR, и все старые ключи просто перестают быть адресуемыми.

// Ключи собираются из версии сущности:
//   v = GET product:7:ver          -> "12"
//   ключи: p:7:v12:card, p:7:v12:list:top, p:7:v12:reviews:page1
//
// Всё, что зависит от товара 7, инвалидирует одна команда:
func (r *Repo) BumpProduct(ctx context.Context, id int64) error {
    return r.rdb.Incr(ctx, fmt.Sprintf("product:%d:ver", id)).Err()
}
  • Плюсы: O(1) независимо от числа зависимых ключей, атомарно, не бьёт по другим сущностям, гонок нет (новую версию читатели увидят сразу).
  • Минусы: старые значения остаются в памяти до истечения TTL — за удобство платим памятью. А каждое чтение теперь делает два обращения (версия + данные), и пайплайном их не склеить: имя ключа данных зависит от версии. Решают Lua-скриптом (в Cluster оба ключа кладут в один слот через hash tag) или локальным кэшем версии на пару секунд.

2. Теги

Для каждого тега держим set с именами ключей: SADD tag:product:7 p:7:card p:7:list. Инвалидируют так: SMEMBERS + UNLINK пачкой + DEL самого тега. Гибче версии (один ключ может висеть на нескольких тегах), но дороже: set надо поддерживать, он растёт, а между SMEMBERS и UNLINK есть окно, в котором добавились новые ключи. Удаляют через UNLINK, а не DEL: попадётся среди ключей большой список или хеш — DEL будет освобождать его память в главном потоке, а UNLINK отдаст это фоновому.

3. Префикс версии схемы — для массового сброса

Если поменялся формат значений или нужно сбросить весь кэш раздела, инкрементим глобальный префикс: v3:v4:. Мгновенно, без единой команды удаления. Платить придётся холодным кэшем и, скорее всего, stampede, поэтому такой сброс делают вместе с прогревом и singleflight.

Чего делать нельзя

KEYS user:* + DEL блокирует Redis на полный проход по пространству ключей — на миллионе ключей сервер встаёт на сотни миллисекунд. Даже SCAN + DEL выливается в тысячи команд и гонку с параллельными записями. Если тянет написать SCAN для инвалидации, значит, схема ключей спроектирована неправильно: нужна версия или тег.

7.5Cache stampede, горячие и большие ключи

Три способа уронить систему кэшем, а не спасти её. Stampede превращает истечение одного ключа в залп запросов к БД. Горячий ключ загоняет весь трафик в один поток одной ноды. Большой ключ останавливает однопоточный Redis целиком — иногда прямо в момент удаления. Эти три вопроса любят задавать подряд, и проверяют они одно: понимаешь ли ты, что у Redis один поток и одна очередь команд.

Что такое cache stampede (dog-pile)

Ключ, который читают 5000 раз в секунду, истёк. Пока первый запрос идёт в БД, сотни горутин одновременно получают nil, одновременно решают «надо сходить в БД» и одновременно выполняют один и тот же SELECT. На БД обрушивается залп одинаковых запросов, её латентность растёт, из-за этого запросы идут дольше, а залп за это время успевает вырасти ещё. Классическая положительная обратная связь: в конце пул соединений исчерпан, а отказы идут каскадом. Причём падает БД, та самая, которую кэш должен был защищать.

Чем популярнее ключ, тем сильнее удар. Редко читаемый ключ при истечении даст один-два лишних запроса, ключ с главной страницы даст тысячу. Выходит, stampede бьёт ровно туда, где кэш приносил больше всего пользы.

Когда случается

  • Истечение популярного ключа: базовый сценарий, он же cache breakdown.
  • Холодный старт. Подняли новый под или новый инстанс Redis, восстановились после аварии, а кэш пуст, и весь трафик идёт в БД. Промахиваются уже все ключи сразу (avalanche).
  • Деплой со сменой префикса версии. На бумаге операция «безопасная», а на деле она мгновенно сбрасывает весь кэш.
  • Синхронное истечение. Прогрели кэш скриптом с одинаковым TTL — и ровно через N минут всё истекает в одну секунду. Самый обидный вариант: его создают своими руками.
  • Failover Redis. Реплика стала мастером с отставанием или пустой — эффект тот же, что при холодном старте.
  • Массовое вытеснение, когда память упёрлась в maxmemory и политика начинает выбрасывать ключи пачками.

Приёмов защиты дальше будет пять. Диаграмма ниже сразу показывает главный из них — singleflight, который «схлопывает» одновременные одинаковые запросы в один. Как он устроен, разберём в следующем разделе.

Истёк один популярный ключ: что происходит без защиты и с singleflight БЕЗ ЗАЩИТЫ — dog-pile TTL истёк время гор. 1 hit 0.3 мс GET nil SELECT ... 40 мс SET гор. 2 GET nil SELECT ... тот же SET гор. 3 GET nil SELECT ... тот же SET ... и ещё 197 горутин с тем же самым запросом Postgres 200 идентичных SELECT почти разом пул соединений исчерпан, латентность растёт --> лавина С SINGLEFLIGHT TTL истёк время гор. 1 GET nil лидер: SELECT 40 мс SET гор. 2 GET nil wg.Wait() — спит, БД не трогает гор. 3 GET nil wg.Wait() ... 199 горутин ждут на одном WaitGroup, дублей запроса нет результат раздаётся всем ждущим Postgres ровно 1 SELECT вместо 200 Латентность ждущих та же, что у лидера: 40 мс. Singleflight не ускоряет — он убирает дубли работы.
Схлопывание запросов. Singleflight не делает промах быстрее: ждущие получают ту же латентность, что и лидер. Зато он убирает умножение одной и той же работы на число конкурентных читателей, а БД роняет как раз оно.

Решения: пять приёмов от простого к сложному

1. Блокировка на перестроение

Первый, кто получил промах, берёт блокировку и идёт в БД; остальные либо ждут её, либо сразу отдают устаревшее/деградированное значение. Внутри одного процесса хватает sync.Mutex на ключ, между процессами нужна распределённая блокировка на Redis (SET lock:key NX PX, подробности в главе 7.7).

Приём рабочий, но цену стоит назвать сразу. Блокировка на весь кластер добавляет round-trip к Redis на каждый промах, может залипнуть, если держатель упал (лечится TTL), и оставляет вопрос, что делают остальные, пока лидер работает: ждут (и упираются в его латентность и таймауты) или получают ошибку. На практике внутрипроцессный singleflight даёт 95 % пользы за 5 % сложности: при 20 подах залп из 200 запросов схлопывается до 20, а такого БД уже не замечает.

2. Singleflight — стандартный ответ для Go

Singleflight (дословно «один полёт») отвечает за дедупликацию конкурентных вызовов: из N одновременных запросов одной и той же работы выполняется один, остальные подписываются на его результат. При stampede не хватает ровно этого. Скорость похода в БД тут ни при чём — беда в том, что туда ходят двести раз подряд за одним и тем же ответом. Пакет golang.org/x/sync/singleflight так и работает: первый вызов по ключу выполняет функцию, остальные с тем же ключом ждут и получают тот же самый результат.

Тут же напрашивается вопрос: почему не обойтись обычным мьютексом? Один общий sync.Mutex отпадает сразу: он выстроит в очередь запросы к разным ключам и превратит параллельный сервис в последовательный. Мьютекс на каждый ключ уже ближе к делу, но за ним тянутся три вещи, и из них состоит почти весь код singleflight.

  • Мапу «ключ → мьютекс» кто-то должен чистить, иначе она растёт вместе с числом когда-либо запрошенных ключей. И выбросить запись можно не в любой момент: пока на мьютексе кто-то стоит, удалять его нельзя.
  • Мьютекс ничего не возвращает. Каждый, кто дождался очереди, должен сам перепроверить кэш. Забыли перепроверку или значение в кэш не легло (Redis моргнул, запись упала), и все двести запросов всё равно уйдут в БД — только теперь по очереди, а для клиентов это даже хуже залпа. Singleflight раздаёт готовый ответ, забыть тут нечего.
  • Ждать мьютекс с таймаутом или по контексту нельзя: Lock() не умеет отменяться. У singleflight для этого есть DoChan, он возвращает канал.

Если совсем коротко, singleflight устроен как «мьютекс на ключ плюс место, куда лидер кладёт ответ для всех ждущих», и в нём аккуратно решено, сколько живёт эта пара.

import "golang.org/x/sync/singleflight"

type Repo struct {
    rdb *redis.Client
    db  *sql.DB
    sf  singleflight.Group   // один на репозиторий, не создавать на каждый запрос
    metrics *repoMetrics     // счётчики Prometheus
}

func (r *Repo) GetUser(ctx context.Context, id int64) (User, error) {
    key := fmt.Sprintf("user:%d", id)

    // 1. Быстрый путь: при попадании в кэш singleflight не нужен.
    if b, err := r.rdb.Get(ctx, key).Bytes(); err == nil {
        return decodeUser(b)
    } else if !errors.Is(err, redis.Nil) {
        slog.Warn("cache get failed", "err", err) // ошибка кэша == промах
    }

    // 2. Медленный путь: только один поход в БД на ключ.
    v, err, shared := r.sf.Do(key, func() (any, error) {
        // Внутрь не передаём ctx конкретного запроса, см. ловушки ниже.
        cctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
        defer cancel()

        u, err := r.loadUserFromDB(cctx, id)
        if err != nil {
            return nil, err
        }
        b, _ := encodeUser(u)
        ttl := 5*time.Minute + time.Duration(rand.Int63n(int64(60*time.Second))) // jitter
        if err := r.rdb.Set(cctx, key, b, ttl).Err(); err != nil {
            slog.Warn("cache set failed", "err", err) // не роняем запрос из-за кэша
        }
        return u, nil
    })
    if shared {
        r.metrics.SingleflightShared.Inc() // по этой метрике видно силу stampede
    }
    if err != nil {
        return User{}, err
    }
    return v.(User), nil
}

Как singleflight устроен внутри

Реализация занимает примерно сто строк, и пересказать её на собеседовании стоит: так видно, что пакет ты импортировал не вслепую. Внутри всего две вещи: мапа под мьютексом и WaitGroup на каждый ключ.

type call struct {
    wg  sync.WaitGroup   // на нём ждут все дубликаты
    val any              // результат лидера
    err error
    dups int             // сколько вызовов схлопнулось (отсюда shared)
}

type Group struct {
    mu sync.Mutex        // защищает только мапу, не вызов fn
    m  map[string]*call
}

func (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool) {
    g.mu.Lock()
    if g.m == nil {
        g.m = make(map[string]*call)
    }
    if c, ok := g.m[key]; ok {      // полёт уже идёт, присоединяемся
        c.dups++
        g.mu.Unlock()
        c.wg.Wait()                 // ждём, пока лидер закончит
        return c.val, c.err, true
    }
    c := new(call)                  // мы лидер
    c.wg.Add(1)                     // поднимаем, пока держим мьютекс
    g.m[key] = c
    g.mu.Unlock()

    c.val, c.err = fn()             // тяжёлая работа вне мьютекса

    g.mu.Lock()
    delete(g.m, key)                // ключ убираем после вызова
    g.mu.Unlock()
    c.wg.Done()                     // будим всех ждущих

    return c.val, c.err, c.dups > 0
}

Три момента, которые стоит проговорить вслух:

  • Мьютекс защищает только мапу, а не выполнение fn. Иначе конструкция сериализовала бы запросы к разным ключам и стала бы хуже, чем ничего.
  • wg.Add(1) вызывается до g.mu.Unlock(). Если поднять счётчик после, присоединившийся дубликат может позвать Wait() на нулевом WaitGroup и пройти сквозь него, прочитав пустой c.val.
  • Ключ удаляется из мапы после вызова. Значит, singleflight не кэширует: как только лидер завершился, следующий вызов снова пойдёт в БД. Это дедупликатор конкурентных вызовов, и за слой кэша его часто принимают по ошибке.

Настоящий код в x/sync сверх этого обрабатывает панику (её оборачивают в panicError и пробрасывают всем ждущим, чтобы они не зависли навсегда), корректно обрабатывает runtime.Goexit и держит список каналов chans для DoChan.

DoChan и Forget

Do блокирует, и в этом его главная проблема: ждущая горутина не может бросить ожидание по своему контексту. DoChan возвращает канал, а его уже можно слушать в select вместе с ctx.Done():

ch := r.sf.DoChan(key, func() (any, error) { return r.loadUserFromDB(bgCtx, id) })
select {
case res := <-ch:
    if res.Err != nil {
        return User{}, res.Err
    }
    return res.Val.(User), nil
case <-ctx.Done():
    // Наш HTTP-запрос отменили. Лидер доработает в фоне, и остальные
    // ждущие получат результат без второго похода в БД.
    return User{}, ctx.Err()
}

Forget(key) досрочно убирает ключ из мапы: следующий вызов не присоединится к текущему полёту, а начнёт свой. Нужен он в двух случаях: лидер завис, и ты не хочешь, чтобы новые запросы унаследовали его зависание, или результат лидера заведомо устарел (например, пока он работал, пришла инвалидация). Обычно из этого делают «одноразовый» singleflight:

v, err, _ := g.Do(key, func() (any, error) {
    // Отпускаем ключ через 100 мс после старта: те, кто пришёл позже,
    // начнут свой полёт, а не будут ждать зависшего лидера.
    t := time.AfterFunc(100*time.Millisecond, func() { g.Forget(key) })
    defer t.Stop() // иначе таймер отпустит уже следующий полёт
    return load(key)
})
Четыре ловушки singleflight
  • Общая ошибка на всех. Если лидер получил таймаут, ошибку разом получат все 200 ждущих — залп в БД ты убрал, а получил залп 500-х. Лечат ретраем на уровне вызывающего и коротким таймаутом у лидера.
  • Контекст лидера решает за всех. Если внутрь fn передать ctx первого запроса и этот клиент отвалится, отменится работа для всех ждущих. Внутрь передают независимый контекст с собственным таймаутом.
  • Залипание. Do ждёт лидера без ограничения по времени. Один зависший поход в БД без таймаута — и в ожидании копятся все запросы по этому ключу. Спасают DoChan + select или Forget по таймеру.
  • Общий мутируемый результат. Все ждущие получают одно и то же значение any. Если это указатель на структуру или слайс и кто-то его поменяет, поменяется у всех. Возвращай значение или неизменяемую копию.

Об одном ограничении спрашивают почти всегда: singleflight работает внутри одного процесса. При 20 подах 200 запросов схлопнутся до 20, а не до одного. Обычно этого хватает. Если нет, сверху добавляют распределённую блокировку или берут приёмы 3 и 5, которые вообще не дают ключу истечь.

3. Вероятностное раннее обновление (XFetch)

После singleflight одно остаётся нерешённым: кто-то всё равно ждёт. Лидер честно стоит свои 40 мс в БД, и все, кто к нему присоединился, стоят те же 40 мс. На горячем ключе это значит, что раз в TTL сотня пользователей получает ответ в сотню раз медленнее обычного: залпа в БД уже нет, а провал в p99 есть. И чем дороже пересчёт, тем провал глубже. XFetch убирает как раз его.

Идея в том, чтобы не ждать истечения, а обновлять значение заранее и вероятностно. Пока ключ ещё жив, каждый читатель сам бросает монетку: обновить кэш прямо сейчас или отдать то, что есть. Чем ближе истечение и чем дороже обошёлся прошлый пересчёт, тем чаще монетка выпадает в пользу обновления. В итоге обновляет кто-то один и заранее, а остальные по-прежнему получают попадание. Момент, когда ключа нет, просто не наступает — залп невозможен по построению, и сдерживать его не нужно.

Приём описан в работе Vattani, Chierichetti, Lowenstein «Optimal Probabilistic Cache Stampede Prevention» (VLDB 2015). Вместе со значением хранят delta — сколько заняло его вычисление — и абсолютный момент истечения expiry. Обновление запускают, если

now - delta * beta * ln(rand())  >=  expiry

где rand() ~ U(0,1],  ln(rand()) ≤ 0,  beta > 0 (обычно 1)

Читается так: -ln(rand()) даёт экспоненциальную случайную величину со средним 1. Её умножают на стоимость пересчёта, и чем дороже был пересчёт, тем раньше и охотнее начинается обновление. beta крутит агрессивность: при значении больше единицы обновляемся раньше и чаще (меньше промахов, больше фоновой нагрузки), при меньшем — позже.

type entry struct {
    Value  []byte
    Delta  time.Duration // сколько занял пересчёт в прошлый раз
    Expiry time.Time     // абсолютный момент истечения
}

// beta = 1.0 по умолчанию
func shouldRefreshEarly(e entry, beta float64) bool {
    // -math.Log(u) для u из (0,1] распределён экспоненциально
    u := rand.Float64()
    if u == 0 {
        u = math.SmallestNonzeroFloat64
    }
    gap := time.Duration(float64(e.Delta) * beta * -math.Log(u))
    return time.Now().Add(gap).After(e.Expiry)
}

func (r *Repo) Get(ctx context.Context, id int64) (User, error) {
    e, ok := r.readEntry(ctx, id)
    if ok && !shouldRefreshEarly(e, 1.0) {
        return decodeUser(e.Value)          // обычное попадание
    }
    if ok {
        // Значение ещё живое: обновляем в фоне и отвечаем сразу, латентность не страдает.
        go r.refresh(context.WithoutCancel(ctx), id)
        return decodeUser(e.Value)
    }
    return r.loadAndStore(ctx, id)          // настоящий промах
}
Почему это сильный ответ

Из всех приёмов только XFetch убирает stampede без блокировок и без координации между подами: каждый читатель решает локально, а «залпа» не бывает, потому что вероятность срабатывания мала и растёт плавно. К тому же он сам подстраивается под цену: дешёвые ключи обновляются впритык к истечению, дорогие — сильно заранее. Минус в том, что нужен свой формат значения (value + delta + expiry) и обязательный go refresh без привязки к контексту запроса.

4. Jitter в TTL

Самое дешёвое, что можно сделать, и почти всегда — первое. Одинаковый TTL синхронизирует истечение: прогрели тысячу ключей за секунду, и ровно через 5 минут тысяча ключей истечёт за секунду. Разброс эту синхронизацию ломает:

// По умолчанию хватает ±20 %
func jitter(base time.Duration) time.Duration {
    spread := int64(base / 5)                   // 20 %
    return base + time.Duration(rand.Int63n(2*spread)-spread)
}

_ = rdb.Set(ctx, key, val, jitter(5*time.Minute)).Err()

Jitter не решает проблему одного горячего ключа. Он лечит массовое синхронное истечение (avalanche). Эти вещи часто путают, а они разные: против breakdown нужен singleflight, против avalanche — jitter. Отвечать надо обоими.

5. «Вечный» ключ с фоновым обновлением

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

  • Логический TTL внутри значения. В Redis ключ живёт без TTL (или с огромным), а внутри значения лежит поле fresh_until. Если читатель видит, что логический срок вышел, он отдаёт устаревшее значение сразу и запускает фоновое обновление под singleflight. БД не ждёт никто и никогда: промахов по этому ключу просто нет. Цена в том, что читатели какое-то время сознательно получают устаревшие данные (это тот самый паттерн stale-while-revalidate из HTTP).
  • Внешний прогреватель. Отдельный воркер или cron обновляет список известных горячих ключей по расписанию. Это просто и предсказуемо, но заранее надо знать, что горячее, а БД получает постоянную фоновую нагрузку, в том числе от ключей, которые уже никто не читает.
Что ломается у «вечных» ключей

Пропадает главная страховка — TTL. Если обновлятор сломался (упал воркер, изменилась схема, ключ выпал из списка горячих), кэш будет отдавать устаревшее значение бесконечно, и узнаешь ты об этом из жалоб, а не из метрик. Поэтому «вечному» ключу нужен аварийный физический TTL, сильно больше логического (например, логический 1 минута, физический 1 час), и алерт на возраст значения.

Сравнение подходов

ПриёмЧто даётПромах для клиентаОбласть действия СложностьКогда брать
Jitter TTL убирает синхронное истечение пачек остаётсятривиальная всегда, по умолчанию для каждого ключа
singleflight 1 поход в БД на ключ на процесс остаётся, ждут всепроцесснизкая по умолчанию во всех Go-сервисах
Распределённая блокировка 1 поход в БД на весь кластер остаётся, ждут или получают staleкластерсредняя очень дорогой пересчёт (отчёт, ML-фича) или много подов
XFetch обновление до истечения, залпа нет по построению почти исчезаеткластер (без координации)средняя дорогой пересчёт + высокий и ровный RPS по ключу
Вечный ключ + фон
stale-while-revalidate
клиент никогда не ждёт БД неткластервысокая витрины, главная страница, конфиги — где stale допустим, а задержка нет
Прогрев убирает холодный старт нет для прогретыхкластерсредняя деплой, рестарт Redis, известный список горячих ключей
Как отвечать на «как боретесь со stampede»

Не одним словом. «Базово — jitter в TTL у всех ключей и singleflight в репозитории: это закрывает и синхронное истечение, и залп внутри пода. Для по-настоящему дорогих значений беру stale-while-revalidate: отдаём устаревшее и обновляем фоном, клиент БД не ждёт никогда. И отдельно защищаю саму БД ограничителем конкурентности, чтобы даже при полном сбросе кэша к ней шло не больше N параллельных запросов, а остальные быстро отваливались.» Последний пункт называют редко, хотя он важнее остальных: он превращает отказ в деградацию.

Горячие ключи

Горячим (hot key) называют ключ, на который приходится непропорционально большая доля обращений: баннер на главной, товар из рассылки, конфиг фичефлагов, счётчик лайков у поста блогера-миллионника. На одиночном Redis это просто нагрузка, а в кластере уже архитектурная проблема.

Чем плох в кластере

Redis Cluster не держит карту «ключ → нода» (ключей миллионы), а вводит промежуточный уровень: фиксированные 16384 корзины, они называются хеш-слотами (hash slots). Номер слота считается прямо из имени ключа по формуле slot = CRC16(key) mod 16384, а «какой слот на какой ноде» хранит уже маленькая табличка, которую раздают всем клиентам. (Со «слотами» хеш-таблицы, о которых шла речь в SCAN, они никак не связаны, слово просто совпало.) В итоге ключ детерминированно попадает в один слот, слот живёт на одной ноде, а нода обрабатывает команды одним потоком. Значит:

  • Масштабирование не работает. Добавили нод — трафик по горячему ключу не размазался ни на процент: как шёл в один поток, так и идёт.
  • Перекос загрузки. Одна нода на 100 % CPU, остальные на 5 %. По средним метрикам кластер «недогружен», хотя на деле он в отказе. Классическая ловушка мониторинга: смотреть надо p99 по каждой ноде отдельно, а не среднее по кластеру.
  • Сеть. Значение в 20 КБ при 50 000 запросах в секунду даёт гигабайт в секунду с одного сетевого интерфейса. В NIC упереться проще, чем в CPU.
  • Взрывной эффект при потере. Если горячий ключ истёк или вытеснен, получаешь тот самый stampede, только в максимальном масштабе.
Горячий ключ упирается в один поток одной ноды — сколько нод ни добавь БЫЛО: один ключ banner:main 50 000 rps CRC16 mod 16384 slot 3137 нода A CPU 100 % p99 растёт нода B CPU 4 % нода C CPU 5 % Средний CPU по кластеру: 36 % — метрика говорит «всё хорошо». Фактически нода A в отказе, а масштабирование кластера не помогает: ключ один, слот один, поток один. СТАЛО: реплики ключа + локальный кэш banner:main#0…#15 случайный суффикс на каждом чтении нода A CPU ~30 % #3 #7 #10 #14 нода B CPU ~55 % #1 #2 #5 ... нода C CPU ~30 % #0 #4 #8 #13 16 копий --> 16 слотов --> нагрузка размазана, но не поровну. Цена: x16 памяти и инвалидация всех копий (16 UNLINK пайплайном по нодам). Дешевле — локальный кэш на 1-2 секунды: до Redis доходит 1 rps с пода. Порядок выбора: сначала локальный кэш поверх (почти всегда достаточно), затем чтение с реплик, и только потом реплики ключа с суффиксом.
Размазывание горячего ключа. Локальный кэш срезает нагрузку у источника и стоит дёшево. Реплики ключа с суффиксом полностью распределяют его по слотам, но умножают память и усложняют инвалидацию.

Как размазывать

  1. Локальный кэш поверх Redis — первое и лучшее. Держим значение в памяти процесса 1–3 секунды. При 20 подах и TTL в секунду до Redis доходит 20 запросов в секунду вместо 50 000, то есть в тысячи раз меньше. Платишь окном рассинхрона длиной с локальный TTL, но для баннера или фичефлага это допустимо ровно всегда. Взять можно ristretto, otter, hashicorp/golang-lru или просто мапу под RWMutex с полем времени.
  2. Чтение с реплик. В Redis Cluster клиент может читать со слейвов (READONLY, в go-redis это RouteRandomly / ReadOnly: true). Нагрузка на чтение делится на число реплик слота. Реплика асинхронна, так что можно прочитать чуть устаревшее значение, но для горячего кэша это обычно не проблема.
  3. Реплики ключа с суффиксом. Пишем N копий (banner:main#0banner:main#15), читаем случайную. Разные суффиксы дают разные слоты и разные ноды. Это работает всегда, но платить придётся памятью (×N) и инвалидацией: удалять надо все N копий, пайплайном по нодам: Lua-скрипт в кластере работает только с ключами одного слота. И осторожно: если сделать наоборот и загнать копии в один слот через hash tag, приём теряет смысл целиком.
  4. Client-side caching Redis 6+ (CLIENT TRACKING, RESP3): сервер сам присылает инвалидацию по ключам, которые клиент читал. По сути это локальный кэш с автоматической инвалидацией — самый аккуратный вариант, если клиент и версия сервера позволяют.

Как найти горячий ключ

  • redis-cli --hotkeys работает только при maxmemory-policy семейства LFU: он опирается на счётчик обращений в объекте.
  • redis-cli monitor показывает все команды в реальном времени. Мощно и опасно: сильно грузит сервер, так что включают его на секунды и только под нагрузкой, а оставлять нельзя.
  • OBJECT FREQ key при LFU отдаёт счётчик частоты конкретного ключа.
  • Лучше всего работают метрики на стороне приложения: считай обращения по префиксу ключа прямо в репозитории. От Redis ничего не требуется, всё видно в Grafana, и работает это постоянно.

Большие ключи (big keys)

Большим называют ключ со строкой в мегабайты или с коллекцией (hash, list, set, zset) на сотни тысяч элементов. Опасен он из-за однопоточности: пока идёт любая операция над большим ключом, сервер больше никого не обслуживает.

Чем именно плохи

  • Блокировка на чтении. HGETALL по хешу из миллиона полей или LRANGE 0 -1 стоят O(N) в одном потоке, а потом сервер ещё собирает ответ на десятки мегабайт. Остальные клиенты всё это время ждут: head-of-line blocking на весь инстанс.
  • Блокировка на удалении. Самое неочевидное. DEL большой коллекции освобождает память поэлементно, и это тоже O(N) в главном потоке. Ключ, который «просто удалили», может остановить сервер на сотни миллисекунд. Истечение TTL у большого ключа делает то же самое, причём удаление случится само и в самый неудобный момент.
  • Сеть и латентность. Значение в 10 МБ при 100 rps даёт гигабайт в секунду. А на стороне Go такой ответ означает большой буфер в куче, работу GC и рост p99 у всего сервиса.
  • Проблемы в кластере. При классическом решардинге ключи переезжают по одному и атомарно, поэтому большой ключ на время переноса блокирует и источник, и приёмник (с Redis 8.4 слот можно перенести целиком, командой CLUSTER MIGRATION). Ребалансировка кластера с большими ключами превращается в серию микро-аварий.
  • Перекос памяти и вытеснение. С ключом на гигабайт шард на 4 ГБ упрётся в maxmemory раньше остальных и начнёт вытеснять чужие полезные данные.
  • Персистентность. Большой ключ раздувает RDB-снимок и AOF-перезапись, а копирование при записи (copy-on-write) во время BGSAVE может удвоить потребление памяти.

Как искать

# 1. Обзорный проход: SCAN изнутри, безопасно для прода, но всё же I/O.
#    Показывает самый большой ключ каждого типа. Учти: у коллекций --bigkeys
#    считает число элементов, а не байты (у строк — байты).
redis-cli --bigkeys

# 2. Оценка в байтах (Redis 5+). Точнее, но дороже: считает реальный размер.
redis-cli --memkeys

# 3. Точный размер одного ключа со всеми накладными расходами структуры.
redis-cli MEMORY USAGE user:42:feed
redis-cli MEMORY USAGE user:42:feed SAMPLES 0   # 0 = не сэмплировать, точно

# 4. Число элементов, не вытягивая значение: O(1) для всех типов.
redis-cli STRLEN key ; redis-cli HLEN key ; redis-cli LLEN key
redis-cli SCARD  key ; redis-cli ZCARD key ; redis-cli XLEN key

# 5. Свой обход: SCAN курсором + MEMORY USAGE. Даёт полную картину,
#    не блокирует сервер, можно гонять по расписанию из джобы.
redis-cli --scan --pattern 'user:*' | head -100000

# 6. Оффлайн-анализ дампа: никакой нагрузки на прод вообще.
rdb --command memory dump.rdb --bytes 10240   # redis-rdb-tools, дампы до Redis 6.x
Ловушка вопроса

На «как найдёте большой ключ» многие отвечают KEYS * — и на этом разговор заканчивается. В правильном ответе обязательно звучит SCAN (курсорный обход, не блокирует) и уточнение, что --bigkeys у коллекций измеряет количество элементов, а не байты; байты покажут --memkeys или MEMORY USAGE. Плюсом будет оффлайн-анализ RDB, который вообще не трогает продовый инстанс.

Что делать

  1. Удалять через UNLINK, а не DEL. UNLINK мгновенно отцепляет ключ от пространства имён, а память освобождает фоновый поток (lazy free). Для больших коллекций это разница между «сервер встал на 300 мс» и «не заметили». Заодно включают lazyfree-lazy-expire yes, lazyfree-lazy-eviction yes и lazyfree-lazy-server-del yes, чтобы то же самое происходило при истечении TTL и при вытеснении.
  2. Не читать целиком. Вместо HGETALL бери HMGET нужных полей или HSCAN порциями, вместо LRANGE 0 -1 листай постранично через LRANGE start stop, а вместо SMEMBERS используй SSCAN или SISMEMBER, если нужен только факт наличия.
  3. Разбивать на части (шардировать ключ). Хеш из миллиона полей превращают в 100 хешей по 10 000: obj:{42}:part:0obj:{42}:part:99, номер части считают как hash(field) mod 100. Фигурные скобки здесь задают hash tag: он держит все части в одном слоте, если над ними нужны мультиключевые операции. Если же цель в том, чтобы размазать нагрузку, hash tag не ставят.
  4. Ограничивать рост на входе. Список обрезают: LPUSH + LTRIM key 0 999 одной транзакцией. Stream ограничивают через XADD ... MAXLEN ~ 10000. Sorted set периодически чистят ZREMRANGEBYRANK. Это дешевле, чем потом чинить разросшийся ключ.
  5. Пересмотреть модель. Большой ключ часто выдаёт, что в Redis положили то, что должно лежать в БД или в объектном хранилище: полный фид пользователя, вложение, сериализованный отчёт. В кэше держат идентификаторы и небольшие горячие срезы, а не всё целиком.
Ориентиры, которые можно назвать

Практические пороги, которые не стыдно произнести на собеседовании: для строки до 100 КБ (выше уже повод разобраться, а выше 1 МБ почти всегда ошибка модели), для коллекции до 5–10 тысяч элементов. И правило: если операция над ключом принципиально O(N), а N растёт вместе с пользовательскими данными, это будущая авария, какими бы ни были текущие размеры.

Вопросы

8
Суть: истёк популярный ключ — и сотни конкурентных запросов одновременно пошли делать один и тот же тяжёлый запрос в БД. Кэш, который должен был защитить источник, в этот момент превращается в усилитель нагрузки на него.

Механика

От истечения ключа до момента, когда первый читатель положит новое значение, проходит время похода в БД, скажем 40 мс. Всё, что прилетело в это окно, тоже получит промах: в кэше по-прежнему пусто. При 500 rps по ключу это 20 лишних запросов, при 5000 rps — 200. Тут включается обратная связь: БД под залпом отвечает медленнее, окно растёт, в него попадает больше запросов, и залп становится больше. Потом пул соединений исчерпывается, и отказ идёт каскадом.

Когда случается

  • Истечение одного популярного ключа (cache breakdown).
  • Холодный старт: подняли под, перезапустили Redis, восстановились после аварии.
  • Деплой со сменой префикса версии ключей: по сути он мгновенно сбрасывает весь кэш.
  • Одинаковый TTL после массового прогрева: тысяча ключей истекает в одну секунду (avalanche).
  • Failover Redis на пустую или отставшую реплику.
  • Память упёрлась в maxmemory, и политика начинает вытеснять пачками.
Чем добить

«Stampede бьёт тем сильнее, чем полезнее был ключ: холодные ключи дают один лишний запрос, горячие — тысячу. Поэтому защиту вешают не везде подряд, а на дорогие и часто читаемые значения, и обязательно ставят ограничитель конкурентности к БД, чтобы даже при полном сбросе кэша к ней шло не больше N параллельных запросов.»

Суть: пять приёмов. Jitter TTL (всегда), singleflight (по умолчанию в Go), распределённая блокировка (дорогой пересчёт), XFetch (вероятностное раннее обновление), вечный ключ со stale-while-revalidate (клиент не ждёт БД никогда). Плюс отдельно — защита самой БД ограничителем конкурентности.
ПриёмЧто даётОбластьКогда брать
Jitter TTLубирает синхронное истечение пачек всегда, для каждого ключа
singleflight1 запрос в БД на ключ на процесспроцесс по умолчанию
Распределённая блокировка1 запрос на весь кластеркластер очень дорогой пересчёт, много подов
XFetchобновление до истечения, залпа неткластер дорогой пересчёт + ровный высокий RPS
Вечный ключ + фонклиент никогда не ждёт БДкластер витрины и конфиги, где stale допустим

Порядок внедрения

  1. Jitter в TTL. Одна строка кода ломает синхронное истечение. Ставят сразу и везде.
  2. singleflight в репозитории. Ещё десять строк схлопывают залп внутри пода. При 20 подах 200 запросов превращаются в 20 — этого БД уже не замечает.
  3. Ограничитель конкурентности к БД (семафор на N слотов или размер пула соединений как естественный ограничитель) плюс circuit breaker. Только этот механизм работает и при полном сбросе кэша, и при недоступном Redis.
  4. И только если пересчёт действительно дорогой, берут stale-while-revalidate или XFetch.
Частая ошибка в ответе

Назвать только singleflight. Он внутрипроцессный: при сотне подов залп сожмётся до сотни запросов, по одному с пода, но не исчезнет, а при полном сбросе кэша (рестарт Redis, смена префикса) не поможет вообще — там промахи по разным ключам, схлопывать нечего. В сильном ответе всегда есть второй уровень: защита самой БД.

Суть: мапа key -> *call под мьютексом плюс sync.WaitGroup в каждом call. Первый вызов становится лидером и выполняет функцию, остальные находят запись в мапе и блокируются на wg.Wait(). После завершения лидер удаляет ключ из мапы и делает wg.Done() — все ждущие получают один и тот же результат.
type call struct {
    wg   sync.WaitGroup
    val  any
    err  error
    dups int          // сколько вызовов схлопнулось
}

type Group struct {
    mu sync.Mutex     // защищает только мапу
    m  map[string]*call
}

func (g *Group) Do(key string, fn func() (any, error)) (any, error, bool) {
    g.mu.Lock()
    if g.m == nil { g.m = make(map[string]*call) }
    if c, ok := g.m[key]; ok {   // полёт уже идёт
        c.dups++
        g.mu.Unlock()
        c.wg.Wait()              // ждём лидера
        return c.val, c.err, true
    }
    c := new(call)               // мы лидер
    c.wg.Add(1)                  // пока мьютекс ещё взят
    g.m[key] = c
    g.mu.Unlock()

    c.val, c.err = fn()          // работа вне мьютекса

    g.mu.Lock()
    delete(g.m, key)             // ключ снимаем после вызова
    g.mu.Unlock()
    c.wg.Done()                  // будим всех

    return c.val, c.err, c.dups > 0
}

Три детали, которые проверяют

  • Почему мьютекс не держат во время fn. Иначе запросы к разным ключам сериализовались бы друг с другом, и конструкция стала бы хуже, чем её отсутствие. Мьютекс охраняет только доступ к мапе, а это микросекунды.
  • Почему wg.Add(1) до Unlock. Если поднять счётчик после, присоединившийся дубликат успеет позвать Wait() на нулевом WaitGroup, пройти сквозь него и прочитать пустой val. Это гонка на пустом результате.
  • Почему delete после вызова, а не после раздачи. Singleflight не кэш: как только лидер закончил, следующий вызов должен начать новый полёт. Схлопываются только конкурентные вызовы.

Настоящая реализация в golang.org/x/sync/singleflight вдобавок ловит панику (заворачивает её в panicError и пробрасывает всем ждущим, иначе они зависли бы навсегда), обрабатывает runtime.Goexit и держит слайс каналов для DoChan.

Что сказать про третье возвращаемое значение

shared bool равен dups > 0 и показывает, что результат достался кому-то кроме лидера. Из него выходит отличная метрика: доля shared == true и есть измеренная сила stampede в твоём сервисе. Если она заметная, защиту стоит усиливать, если нулевая — singleflight можно было и не ставить.

Суть: общая ошибка достаётся всем ждущим; контекст лидера решает за всех; Do ждёт без таймаута и может залипнуть; общий результат мутируемый. DoChan даёт select с ctx.Done(), Forget досрочно отпускает ключ.

Четыре ловушки

  1. Общая ошибка. Лидер получил таймаут — ошибку получают все 200 ждущих. Вместо залпа в БД клиент получает залп 500-х. Смягчают коротким таймаутом лидера и ретраем на уровне вызывающего.
  2. Контекст лидера. Если внутрь fn передать ctx первого запроса, а этот клиент отвалится, ctx отменится, и работа сорвётся у всех ждущих, хотя их запросы живы. Внутрь передают независимый контекст (context.WithoutCancel или новый context.Background() с собственным таймаутом), а отмену обрабатывают снаружи через DoChan.
  3. Залипание. Do ждёт лидера сколько угодно. Один поход в БД без таймаута — и по этому ключу копятся все запросы сервиса.
  4. Мутируемый общий результат. Все ждущие получают одно и то же значение any. Если это указатель или слайс и кто-то его изменит, изменится у всех. Возвращай значение или копию.

DoChan — управляемое ожидание

ch := g.DoChan(key, func() (any, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    return load(ctx, key)
})

select {
case res := <-ch:
    if res.Err != nil { return zero, res.Err }
    return res.Val.(User), nil
case <-ctx.Done():
    // клиент ушёл; лидер доработает, результат достанется остальным
    return zero, ctx.Err()
}

Forget — досрочно отпустить ключ

Forget(key) убирает запись из мапы, и следующий вызов не присоединится к текущему полёту, а начнёт свой. Нужен в двух случаях: лидер завис и не надо, чтобы новые запросы наследовали его зависание; или пока лидер работал, пришла инвалидация, и его результат уже устарел. Обычно ключ отпускают через фиксированное время после старта:

v, err, _ := g.Do(key, func() (any, error) {
    t := time.AfterFunc(100*time.Millisecond, func() { g.Forget(key) })
    defer t.Stop() // лидер уже вернулся — чужой полёт не трогаем
    return load(key)
})
Главное ограничение

Singleflight живёт в памяти одного процесса. Двадцать подов дают двадцать независимых лидеров, то есть двадцать запросов в БД вместо одного. Чтобы схлопнуть «до одного на кластер», нужна распределённая блокировка на Redis, а с ней сразу всплывают её проблемы: TTL, зависший держатель, лишний round-trip. Обычно 20 запросов вместо 200 уже достаточно, и это стоит прямо сказать.

Суть: вместо ожидания истечения каждый читатель с небольшой вероятностью решает обновить значение заранее. Вероятность растёт по мере приближения к TTL и тем быстрее, чем дороже был прошлый пересчёт. Залп невозможен по построению: обновление запускают единицы читателей, остальные получают попадание.

Формула

обновляем, если:   now - delta * beta * ln(rand())  >=  expiry

delta   — сколько занял прошлый пересчёт значения
expiry  — абсолютный момент истечения ключа
rand()  — равномерное из (0,1], значит ln(rand()) ≤ 0
beta    — агрессивность, обычно 1.0

Величина -ln(rand()) распределена экспоненциально со средним 1. Если умножить её на delta, получится случайное «опережение»: дешёвое значение обновляют впритык к истечению, дорогое — сильно заранее. При beta > 1 обновляемся раньше и чаще (меньше промахов, больше фоновой нагрузки на БД), при beta < 1 наоборот. Формула взята из статьи Vattani, Chierichetti, Lowenstein, VLDB 2015.

type entry struct {
    Value  []byte
    Delta  time.Duration
    Expiry time.Time
}

func shouldRefreshEarly(e entry, beta float64) bool {
    u := rand.Float64()
    if u == 0 { u = math.SmallestNonzeroFloat64 }
    gap := time.Duration(float64(e.Delta) * beta * -math.Log(u))
    return time.Now().Add(gap).After(e.Expiry)
}

// В обработчике: если сработало и значение ещё живое, обновляем в фоне,
// а клиенту сразу отдаём то, что есть.
if ok && shouldRefreshEarly(e, 1.0) {
    go r.refresh(context.WithoutCancel(ctx), id)
}
return decode(e.Value)

Плюсы и минусы

  • Плюс: работает без координации между процессами и без блокировок, каждый читатель решает сам. Единственный приём, который одинаково хорош на одном поде и на ста.
  • Плюс: чувствителен к цене автоматически. Не надо руками подбирать «за сколько до истечения обновлять» для каждого ключа.
  • Минус: нужен свой формат значения (value + delta + expiry), то есть обёртка над сериализацией.
  • Минус: обновление должно уходить в фон и не зависеть от контекста запроса, иначе ты просто переложил ожидание БД на случайного невезучего клиента.
Когда это уместно назвать

Не как «стандартное решение», а как ответ на уточняющий вопрос «а если подов сто и singleflight мало». Формулу наизусть знать не обязательно, хватит объяснить идею: вероятность обновления растёт по мере приближения к TTL и масштабируется стоимостью пересчёта. Этим XFetch и отличается от наивного «обновлять за 10 % до истечения», где все поды сработают одновременно и ты получишь тот же залп, только раньше.

Суть: одинаковый TTL синхронизирует истечение. Ключи, залитые одной пачкой, истекают одной пачкой — и вы получаете avalanche ровно через TTL после каждого прогрева. Jitter в ±10–20 % размазывает истечение во времени и стоит одну строку кода.

Механика

Прогрели кэш скриптом: тысяча ключей записана за секунду с TTL 5 минут. Ровно через 5 минут тысяча ключей истекает за ту же секунду, и вся нагрузка идёт в БД одновременно. Хуже того, после восстановления они снова запишутся почти одновременно — и цикл повторится ещё через 5 минут. На графике нагрузки БД получается устойчивая пила с периодом TTL, и её потом долго ищут.

// Рабочий диапазон: ±20 %
func jitter(base time.Duration) time.Duration {
    spread := int64(base / 5)
    return base + time.Duration(rand.Int63n(2*spread)-spread)
}

_ = rdb.Set(ctx, key, val, jitter(5*time.Minute)).Err()

Чего jitter НЕ решает

Он не помогает против stampede на одном ключе: у одного ключа один момент истечения, разбрасывать нечего. Против breakdown нужен singleflight или раннее обновление. Это две разные проблемы с разными решениями, и на собеседовании ценят, когда кандидат их разделяет:

ПроблемаМасштабРешение
Breakdown / dog-pileодин горячий ключ singleflight, блокировка, XFetch, stale-while-revalidate
Avalancheмного ключей сразу jitter TTL, прогрев, ограничитель конкурентности к БД
Смежный приём

Jitter пригодится и за пределами TTL. Та же логика работает в интервалах фоновых задач, в ретраях (exponential backoff with jitter) и в периодах health-check. Если N клиентов выполняют действие «через одинаковый интервал», рано или поздно они синхронизируются и создают залп. Назовёшь этот общий принцип, и ответ станет сильнее.

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

Почему масштабирование не помогает

slot = CRC16(key) mod 16384. Ключ детерминированно попадает в один слот, слот живёт на одной ноде, нода однопоточная. Утроили кластер, а трафик по горячему ключу не сдвинулся ни на процент. Есть и ещё одна ловушка: средний CPU по кластеру выглядит низким, хотя одна нода в отказе. Смотреть надо p99 и CPU по каждой ноде отдельно.

Способы, по порядку применения

  1. Локальный кэш поверх Redis (1–3 секунды). Его почти всегда достаточно, и почти всегда с него правильно начинать. При 20 подах и TTL в секунду 50 000 rps превращаются в 20 rps до Redis. Платишь окном рассинхрона длиной с локальный TTL.
  2. Чтение с реплик. В кластере это READONLY, в go-redis ReadOnly: true и RouteRandomly. Нагрузка делится на число реплик, но данные бывают чуть более устаревшими (репликация асинхронная).
  3. Реплики ключа с суффиксом. Пишем N копий key#0..key#N-1, читаем случайную: разные суффиксы дают разные слоты. Работает всегда, но стоит памяти ×N и инвалидации всех копий (пайплайном по нодам: одним Lua-скриптом нельзя, копии в разных слотах). Hash tag здесь ставить нельзя: он загонит все копии обратно в один слот и убьёт смысл приёма.
  4. Client-side caching (CLIENT TRACKING, RESP3): локальный кэш, который сервер сам инвалидирует. Самый аккуратный вариант, если версия и клиент позволяют.

Как обнаружить

  • redis-cli --hotkeys работает только при maxmemory-policy семейства LFU (опирается на счётчик обращений в объекте).
  • OBJECT FREQ key при LFU показывает счётчик частоты конкретного ключа.
  • redis-cli monitor выводит все команды в реальном времени, но сильно грузит сервер, так что включают его на секунды.
  • Лучше всего подходят метрики в приложении: считай обращения по префиксу ключа прямо в репозитории. Работает постоянно и ничего не требует от Redis.
Ловушка «горячий ключ на запись»

Горячий ключ на чтение размазывается репликами. Горячий ключ на запись (счётчик просмотров вирусного поста) репликами не лечится: писать надо в мастера слота. Тут шардируют сам счётчик: INCR views:42:shard:N со случайным N от 0 до 15, а сумму собирают при чтении одним пайплайном. Это готовый ответ на уточняющий вопрос, который задают почти всегда.

Суть: строка в мегабайты или коллекция в сотни тысяч элементов. Опасна потому, что Redis однопоточный: любая O(N)-операция над таким ключом останавливает весь сервер — включая обычный DEL и даже истечение TTL. Ищут через --bigkeys/--memkeys/MEMORY USAGE, лечат UNLINK, порционным чтением и разбиением на части.

Чем плох

  • Чтение целиком блокирует всех. HGETALL по миллиону полей или LRANGE 0 -1 занимают единственный поток на O(N), а потом ещё собирается ответ на десятки мегабайт. Итог: head-of-line blocking для всего инстанса.
  • Удаление тоже блокирует. Самый неочевидный пункт: DEL большой коллекции освобождает память поэлементно, это O(N) в главном потоке. То же делает истечение TTL, причём само и в непредсказуемый момент.
  • Сеть и GC. 10 МБ × 100 rps = гигабайт в секунду; на стороне Go это большая аллокация на каждый запрос и рост p99 у всего сервиса.
  • Кластер. Миграция слота переносит ключи по одному и атомарно: большой ключ блокирует и источник, и приёмник на время переноса. Ребалансировка превращается в серию микро-аварий.
  • Перекос памяти. Гигабайтный ключ в шарде на 4 ГБ заставит этот шард упереться в maxmemory раньше остальных и вытеснять чужие полезные данные.
  • Персистентность. Раздувает RDB и AOF-перезапись, увеличивает copy-on-write во время BGSAVE.

Как найти

redis-cli --bigkeys      # обход SCAN-ом; у коллекций элементы, не байты
redis-cli --memkeys      # оценка в байтах (Redis 5+), дороже
redis-cli MEMORY USAGE key SAMPLES 0     # точный размер одного ключа
redis-cli STRLEN/HLEN/LLEN/SCARD/ZCARD key   # число элементов, O(1)
redis-cli --scan --pattern 'user:*'      # курсорный обход, не блокирует
rdb --command memory dump.rdb --bytes 10240  # оффлайн по дампу (до Redis 6.x), без нагрузки

И чего делать нельзя, так это KEYS *. Он проходит всё пространство ключей в главном потоке, и на миллионе ключей сервер полностью встаёт на сотню миллисекунд и дольше.

Что делать

  1. UNLINK вместо DEL. Отцепляет ключ мгновенно, память освобождает фоновый поток. Заодно включают lazyfree-lazy-expire, lazyfree-lazy-eviction, lazyfree-lazy-server-del, чтобы то же происходило при истечении и вытеснении.
  2. Не читать целиком: HMGET/HSCAN вместо HGETALL, постраничный LRANGE вместо LRANGE 0 -1, SISMEMBER/SSCAN вместо SMEMBERS.
  3. Разбить на части: obj:{42}:part:N, где N = hash(field) mod 100. Фигурные скобки ставят как hash tag, если части должны лежать в одном слоте для мультиключевых операций.
  4. Ограничить рост на входе: LPUSH + LTRIM key 0 999 одной транзакцией, XADD ... MAXLEN ~ 10000, периодический ZREMRANGEBYRANK.
  5. Пересмотреть модель. Часто большой ключ означает, что в кэш положили то, чему место в БД или объектном хранилище. В Redis держат идентификаторы и небольшие горячие срезы.
Пороги, которые можно назвать

Строка — до 100 КБ (выше 1 МБ почти всегда ошибка модели), коллекция — до 5–10 тысяч элементов. И общее правило: если операция принципиально O(N), а N растёт от пользовательских данных, это будущая авария независимо от текущих размеров.

7.6Вытеснение, персистентность, отказоустойчивость

У трёх тем один общий вывод: Redis не даёт строгих гарантий. Он может выбросить твой ключ, при падении потерять последнюю секунду записей, а при failover даже подтверждённую запись. Это не баги, а заявленное поведение. Кандидату уровня middle мало знать названия политик: он должен уметь сказать, что именно теряется в каждом сценарии и как приложению к этому подготовиться.

maxmemory и политики вытеснения

maxmemory задаёт верхнюю границу памяти под данные. Что делать, когда она достигнута, решает maxmemory-policy. Её восемь классических вариантов делятся на три семейства (с Redis 8.6 добавились ещё allkeys-lrm и volatile-lrm, они вытесняют по давности последней записи): ничего не выбрасывать, выбрасывать из всех ключей (allkeys-*) или только из тех, у кого выставлен TTL (volatile-*).

ПолитикаЧто делает при упирании в лимитКогда брать
noeviction
по умолчанию
ничего не выбрасывает; команды на запись возвращают ошибку OOM command not allowed, чтение продолжает работать когда Redis — хранилище, а не кэш: очереди, сессии, данные без второй копии. Потерять нельзя, лучше явная ошибка
allkeys-lru выбрасывает давно не используемый ключ из всех дефолт для чистого кэша; хорошо, когда актуальность коррелирует со свежестью обращения
allkeys-lfu
Redis 4+
выбрасывает редко используемый ключ из всех кэш с устойчивым ядром горячих ключей и потоком разовых обращений — лучше LRU
allkeys-random выбрасывает случайный ключ когда все ключи равноценны, а экономия на учёте важна; редко
volatile-lru LRU, но только среди ключей с TTL смешанная нагрузка: часть данных — кэш (с TTL), часть — постоянная (без TTL)
volatile-lfu LFU среди ключей с TTLто же, но с профилем под LFU
volatile-random случайный среди ключей с TTLредко
volatile-ttl выбрасывает тот, которому осталось жить меньше всего когда TTL реально отражает ценность данных
Главная ловушка volatile-*

Если при volatile-* у ключей не окажется TTL, выбрасывать будет нечего — и Redis поведёт себя как noeviction: писать станет невозможно. Авария классическая: поставили volatile-lru, часть кода забыла TTL, память заполнилась «бессмертными» ключами, и сервис лёг с ошибками OOM, хотя политика вытеснения «вроде настроена». Отсюда правило из главы 7.4: TTL у каждого ключа, без исключений.

Что ещё спросят про maxmemory
  • Что входит в лимит. maxmemory ограничивает данные, а буферы клиентов (особенно output buffer у pub/sub и репликации) и фрагментация памяти живут сверх него. Поэтому лимит ставят примерно в 60–70 % физической памяти машины, а не в 95 %.
  • Вытеснение идёт в главном потоке. При активном вытеснении Redis тратит время на выбор жертв и освобождение памяти, и латентность растёт. lazyfree-lazy-eviction yes переносит освобождение в фоновый поток.
  • Как понять, что вытеснение идёт. INFO statsevicted_keys. Ненулевой и растущий счётчик при падающем hit rate прямо говорит, что кэшу мало памяти. На эту метрику стоит повесить алерт.

LRU против LFU

LRU выбрасывает то, к чему дольше всего не обращались, а LFU то, к чему обращались реже всего. Разница видна на конкретном профиле нагрузки: LRU ломается на «сканирующем» трафике, когда поток разовых обращений вытесняет устойчиво горячие ключи.

Один поток обращений, ёмкость кэша — 4 ключа поток A A A A A A B C D E F G A горячий ключ: 6 обращений разовый «сканирующий» трафик: по одному разу снова A LRU — по времени последнего обращения состояние перед последним A (ёмкость 4): D E F G A вытеснен: к нему не обращались дольше всех, пока шли шесть разовых ключей. Последний запрос A --> ПРОМАХ, поход в БД Итог: устойчиво горячий ключ выбит мусорным трафиком. Так ломается любой ночной batch-обход, аналитический запрос или обход всех сущностей краулером. LFU — по частоте обращений состояние перед последним A (ёмкость 4): A · freq 6 E · 1 F · 1 G · 1 A уцелел: его счётчик 6 против 1 у новичков, выбрасывают всегда самого редкого. Последний запрос A --> ПОПАДАНИЕ Цена: счётчик надо затухать (lfu-decay-time), иначе вчерашний хит навсегда вытесняет сегодняшний. И на резкой смене моды LFU перестраивается медленнее.
Где LRU ломается. Хватит одного прохода по холодным данным, и LRU выбьет из кэша всё горячее. LFU устойчив к такому сканированию, но требует затухания счётчиков, иначе становится слишком консервативным.

Как Redis приближает LRU

Настоящий LRU требует двусвязного списка всех ключей: при каждом обращении элемент переносится в голову. Это два указателя на объект (16 байт на ключ только под учёт) плюс работа на каждом GET. Для базы на десятки миллионов ключей такое неприемлемо, поэтому Redis реализует не LRU, а его аппроксимацию.

  • В заголовке каждого объекта есть 24-битное поле lru с «часами»: временем последнего обращения с грубым разрешением. Никакого списка, никаких указателей.
  • Когда нужно вытеснять, Redis берёт случайную выборку из maxmemory-samples ключей (по умолчанию 5) и выбрасывает лучшего кандидата в ней.
  • С Redis 3.0 есть ещё пул кандидатов на 16 элементов: лучшие жертвы из разных выборок копятся между вызовами, и при той же цене результат заметно ближе к идеальному LRU.
  • maxmemory-samples 10 даёт результат, практически неотличимый от точного LRU, но ест больше CPU; 3 работает быстрее и грубее.

LFU устроен похоже, только в тех же 24 битах вместо часов лежат 8-битный логарифмический счётчик и 16 бит времени последнего затухания. Инкремент вероятностный: чем больше счётчик, тем реже он растёт (этим управляет lfu-log-factor), поэтому 8 бит хватает на диапазон до миллионов обращений. Со временем счётчик затухаетlfu-decay-time задаёт, за сколько минут простоя он уменьшается на единицу. Без затухания LFU был бы фатально консервативным: вчерашний хит вечно вытеснял бы сегодняшний.

Как отвечать на «LRU или LFU»

«По умолчанию ставлю allkeys-lru: он предсказуем и хорош, когда актуальность совпадает со свежестью обращения. allkeys-lfu беру, когда есть устойчивое ядро горячих ключей и поток разовых обращений: ночные джобы, обходы, аналитика — они выбивают LRU целиком. И обязательно упомяну, что у Redis это приближение: выборка из пяти случайных ключей плюс пул кандидатов, а не настоящий LRU-список. Поэтому вытеснение почти ничего не стоит по памяти.»

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

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

RDB (Redis Database) делает снимок: всё содержимое памяти целиком сбрасывается в один компактный бинарный файл, как фотография состояния на конкретный момент. AOF (Append Only File, «файл только для дописывания») ведёт журнал: каждая изменяющая команда дописывается в конец файла, а при восстановлении журнал проигрывается с начала. Вся дальнейшая разница между ними вытекает из этих двух слов, «снимок» и «журнал».

RDB (снимок)AOF (журнал команд)
Что пишет бинарный снимок всего датасета целиком в один файл каждую изменяющую команду в текстовом протоколе RESP, дописыванием в конец
Когда пишет по расписанию (save 900 1) или по BGSAVE постоянно, в буфер; на диск — по appendfsync
Потеря при падении всё с момента последнего снимка — минуты зависит от appendfsync: почти ничего при always, до секунды при everysec, десятки секунд при no
Скорость старта быстрая: чтение компактного бинарника медленная: воспроизведение всех команд
Размер файлакомпактный больше, растёт постоянно (нужен rewrite)
Влияние на латентность fork() процесса + copy-on-write: скачок памяти и пауза на fork на больших датасетах постоянная нагрузка на диск; периодический rewrite тоже делает fork
Для чего бэкапы, быстрое восстановление, репликация начального состояния минимальная потеря данных

appendfsync — где именно теряются данные

Запись в AOF мгновенно попадает в буфер ядра, а на диск данные уходят только при fsync. Как часто он вызывается, определяет appendfsync, и она же задаёт размер окна потери.

ЗначениеКогда fsyncТеряется при паденииЦена
alwaysпосле каждой команды записи ничего (в пределе — последняя команда) катастрофическая: пропускная способность падает до сотен операций в секунду, каждая запись ждёт диск
everysec
по умолчанию
раз в секунду, отдельным потоком до 1 секунды записей практически незаметна; разумный компромисс
noрешает ОС (обычно раз в 30 секунд) до десятков секунднулевая
Что говорить про «always»

appendfsync always звучит как «надёжный вариант», но на практике его почти никогда не включают: он превращает Redis из структуры данных в памяти в медленную БД на диске, а тогда проще взять настоящую БД. Правильная формулировка: «если данные настолько ценны, что нужен always, значит, они не должны жить только в Redis». Причём и always полной гарантии не даёт: диск может подтвердить запись, не положив её на пластину (кэш контроллера).

Гибридный режим

С Redis 4.0 есть aof-use-rdb-preamble yes (в современных версиях включён по умолчанию): при перезаписи AOF в начало файла кладётся RDB-снимок, а за ним дописываются команды, пришедшие позже. От RDB берутся компактность и быстрый старт, от AOF минимальная потеря. Поэтому на «что выбрать» правильно отвечать так: оба, в гибридном режиме.

Есть ещё AOF rewrite. Журнал растёт бесконечно (миллион INCR даёт миллион строк), поэтому Redis периодически перезаписывает его в компактную форму, отражающую текущее состояние. Rewrite идёт через fork(), с тем же copy-on-write и тем же риском скачка памяти, что и BGSAVE. Настраивают его через auto-aof-rewrite-percentage и auto-aof-rewrite-min-size.

Глубже: почему fork() опасен на больших инстансах

BGSAVE и AOF rewrite делают fork(). Дочерний процесс поначалу делит страницы памяти с родителем, но каждая изменённая страница копируется. Если за время снимка записей много, скопироваться может большая часть датасета — и потребление памяти вырастет почти вдвое. Это классическая причина OOM-kill на инстансах, где maxmemory выставлен близко к физической памяти. Вдобавок сам fork() на десятках гигабайт занимает сотни миллисекунд (копируется таблица страниц), и всё это время Redis не отвечает. Отсюда практика: maxmemory не больше 60–70 % RAM, vm.overcommit_memory = 1, отключённые transparent huge pages и по возможности снимки с реплики, а не с мастера.

Репликация, Sentinel, Cluster

Репликация — асинхронная

Мастер принимает записи и потоком отправляет их репликам, причём асинхронно: клиенту он отвечает OK, не дожидаясь подтверждения от реплик. Отсюда прямое следствие, которое и хотят услышать: подтверждённая запись может быть потеряна, если мастер упадёт до того, как реплика её получит, а failover поднимет эту реплику.

  • Начальная синхронизация: BGSAVE на мастере, передача RDB, затем поток команд. После разрыва, если реплика отстала не слишком сильно, хватает частичной ресинхронизации по буферу repl-backlog.
  • Реплики по умолчанию доступны только на чтение (replica-read-only yes). Данные на них отстают на величину лага, так что read-your-writes не гарантирован.
  • Команда WAIT numreplicas timeout блокирует до подтверждения записи N репликами. Это не синхронная репликация и не транзакционная гарантия: она не откатывает запись при неуспехе, а лишь сообщает, сколько реплик подтвердили. Полезна как «барьер» перед критичным действием, но строгой durability не даёт.
  • Настройки min-replicas-to-write / min-replicas-max-lag заставляют мастера отказывать в записи, если у него меньше N живых реплик. Так доступность меняют на меньший риск потери, то есть сдвигаются в сторону CP.

Sentinel — автоматический failover

Sentinel состоит из отдельных процессов-наблюдателей (нечётное число, минимум три), которые следят за мастером и репликами. По шагам:

  1. Мониторинг. Каждый sentinel пингует мастера и, если тот молчит дольше down-after-milliseconds, помечает его субъективно недоступным (SDOWN).
  2. Кворум. Кворумом называют минимальное число голосов, при котором решение считается принятым от имени всего кластера. Он нужен, чтобы один потерявший сеть наблюдатель не мог единолично объявить мастера мёртвым. Sentinel опрашивает остальных, и если о недоступности заявили не меньше quorum из них, статус повышается до объективно недоступного (ODOWN).
  3. Выборы лидера. Sentinel-ы выбирают среди себя лидера (вариант Raft), который проведёт failover. Для выборов нужно большинство sentinel-ов — и это важнее кворума: при трёх sentinel-ах нужны два живых.
  4. Промоушен. Лидер выбирает лучшую реплику (по приоритету, объёму полученных данных, run id), делает ей REPLICAOF NO ONE, перенастраивает остальные реплики на неё и обновляет конфигурацию.
  5. Уведомление клиентов. Клиенты спрашивают у Sentinel адрес текущего мастера, а команды шлют уже ему напрямую. В go-redis для этого есть NewFailoverClient.
Что теряется при failover

Всё, что мастер успел подтвердить клиенту, но не успел отправить реплике. Случай не редкий: при обрыве сети мастер ещё какое-то время может принимать записи (пока не сработает min-replicas-to-write), хотя дальше они уже не уходят. Классический split brain: старый мастер жив в изолированном сегменте и принимает записи, в основном поднят новый — когда сеть починят, старый становится репликой и его записи отбрасываются. Поэтому распределённая блокировка на Redis корректности не обеспечивает: после failover два клиента могут одновременно считать себя держателями (глава 7.7).

Redis Cluster и 16384 слота

Sentinel даёт отказоустойчивость, но не горизонтальное масштабирование: весь датасет всё равно лежит на одном мастере. Cluster шардирует данные между несколькими мастерами, у каждого из которых свои реплики.

  • Пространство ключей разбито на 16384 слота. Слот ключа считается как CRC16(key) mod 16384. Каждый мастер владеет диапазоном слотов.
  • Почему 16384, а не 65536: карта слотов входит в каждое сообщение протокола gossip как битовая маска. 16384 бита занимают 2 КБ на сообщение, а 65536 заняли бы уже 8 КБ. При типичном размере кластера в десятки нод такой размер избыточен, а 16384 хватает с запасом.
  • Клиент сам знает карту слотов и ходит сразу на нужную ноду. Прокси нет: шардинг клиентский, и протокол его поддерживает.
slot = CRC16(key) mod 16384, слот закреплён за нодой, клиент знает карту user:42 CRC16 mod 16384 slot 15880 нода C карта слотов нода A слоты 0 - 5460 нода B слоты 5461 - 10922 нода C слоты 10923 - 16383 MOVED — слот навсегда живёт на другой ноде клиент GET user:42 нода A (не тот слот) MOVED 15880 10.0.0.3:6379 Клиент повторяет запрос на ноду C и ОБНОВЛЯЕТ свою карту слотов: следующие запросы по этому слоту сразу идут правильно. ASK — слот прямо сейчас мигрирует клиент GET user:42 нода C (отдаёт слот) ASK 15880 10.0.0.2:6379 Клиент шлёт ASKING + команду на ноду B ОДИН раз и карту НЕ обновляет: перенос ещё идёт, остальные ключи слота пока на прежнем месте. Мультиключевые операции работают, только если все ключи в ОДНОМ слоте: MGET, SUNION, транзакции MULTI/EXEC, Lua-скрипты. Иначе — CROSSSLOT Keys in request don't hash to the same slot. Решение — hash tag: в CRC16 берётся только часть ключа в фигурных скобках. user:{42}:profile и user:{42}:orders --> оба хешируются по "42" --> один слот --> MGET работает
Маршрутизация в кластере. MOVED означает постоянное перераспределение, и клиент обновляет карту. ASK даёт временный редирект во время миграции слота, карту трогать нельзя. Hash tag принудительно сводит связанные ключи в один слот.

Ограничения кластера

  • Мультиключевые операции только внутри слота. MGET, MSET, SUNION, ZUNIONSTORE, транзакции и Lua-скрипты требуют, чтобы все ключи хешировались в один слот, иначе CROSSSLOT. Клиенты вроде go-redis умеют разбивать MGET на несколько запросов по нодам, но атомарности при этом нет.
  • Hash tag режет в обе стороны. {userID} в ключе сводит все данные пользователя в один слот и делает возможными транзакции и Lua. Но если тег выбран неудачно (например, {tenant} у крупного клиента), весь его объём и трафик приземляются на одну ноду, то есть ты своими руками создал горячий слот.
  • Нет баз данных. В кластере доступна только db 0, SELECT не работает.
  • Pub/sub. Классический pub/sub в кластере рассылается по всем нодам (что дорого); с Redis 7 есть шардированный pub/sub SPUBLISH/SSUBSCRIBE, привязанный к слоту канала.
  • Failover внутри кластера обходится без Sentinel: реплики сами инициируют выборы, решение принимает большинство мастеров. Если мастер потерял свои слоты и реплики нет, кластер по умолчанию (cluster-require-full-coverage yes) перестаёт обслуживать все запросы, а не только этот диапазон.

Почему Redis не даёт строгих гарантий

Сведём всё вместе: ради этого вывода и задают вопросы по всей секции.

  1. Данные могут исчезнуть по политике вытеснения. При allkeys-* Redis имеет полное право выбросить любой твой ключ до истечения TTL. Значит, приложение обязано корректно работать без ключа в любой момент.
  2. Данные могут потеряться при падении. При everysec пропадёт до секунды записей, без AOF всё с последнего снимка.
  3. Подтверждённая запись может потеряться при failover. Репликация асинхронная; окно между ответом клиенту и доставкой реплике не защищено ничем.
  4. Возможен split brain. Старый мастер в изолированном сегменте продолжает принимать записи, которые потом будут отброшены.
  5. Транзакции не такие, как в БД. MULTI/EXEC даёт атомарность выполнения и изоляцию (команды идут подряд без чужих между ними), но не даёт отката: если одна команда упала на этапе выполнения, остальные всё равно выполнятся.
Что это значит для приложения
  • Redis служит кэшем и вспомогательным состоянием, а не источником правды. Всё, что нельзя потерять, должно иметь копию в БД или в брокере.
  • Любой промах считается штатной ситуацией, а не ошибкой. Код без ветки «в кэше нет» сломан.
  • Распределённая блокировка на Redis работает как оптимизация (не делать одну работу дважды), а не как гарантия взаимного исключения. Корректность обеспечивают идемпотентностью операции или fencing token — монотонно растущим номером захвата, который проверяет сам защищаемый ресурс и отвергает всё, что меньше уже виденного (разбор в главе 7.7).
  • Если Redis используют как очередь или хранилище (Streams, сессии), политику ставят noeviction, персистентность включают, а потерю секунды данных явно согласуют с продуктом.

Вопросы

9
Суть: поведение задаёт maxmemory-policy. По умолчанию noeviction — записи начинают отвечать ошибкой OOM, чтения работают. Остальные семь (с Redis 8.6 — девять) вариантов выбрасывают ключи: allkeys-* из всех, volatile-* только из тех, у кого есть TTL.
ПолитикаПоведениеКогда
noevictionошибка на запись, чтение живёт Redis как хранилище: очереди, сессии, данные без второй копии
allkeys-lruдавно не использованный из всех дефолт для чистого кэша
allkeys-lfuредко используемый из всех есть ядро горячих ключей + поток разовых обращений
allkeys-randomслучайный из всех все ключи равноценны; редко
volatile-lru / -lfu / -random то же, но только среди ключей с TTL смешанная нагрузка: часть данных постоянная
volatile-ttlтот, кому осталось жить меньше всех когда TTL отражает ценность
Три вещи, которые надо назвать сверх списка
  • Ловушка volatile-*. Если у ключей нет TTL, выбрасывать нечего — Redis фактически ведёт себя как noeviction и перестаёт принимать записи. Частая продовая авария при «настроенной» политике вытеснения.
  • maxmemory считает не всё. Буферы клиентов (особенно output buffer репликации и pub/sub) и фрагментация живут сверх лимита, поэтому maxmemory ставят в 60–70 % физической памяти, иначе процесс убьёт OOM-killer.
  • Вытеснение нагружает главный поток. При активном вытеснении растёт латентность, смягчает это lazyfree-lazy-eviction yes. Следить стоит за evicted_keys в INFO stats: растущий счётчик при падающем hit rate означает, что кэшу мало памяти.
Суть: LRU выбрасывает по времени последнего обращения, LFU — по частоте. LRU ломается на сканирующем трафике: один проход по холодным данным выбивает все горячие ключи. Redis не хранит LRU-список — он берёт выборку из maxmemory-samples (по умолчанию 5) случайных ключей и выбрасывает лучшего кандидата, накапливая кандидатов в пуле на 16 элементов.

Когда LFU лучше

Классический пример: ключ A постоянно читают 6 раз в секунду, а потом приходит ночной batch-обход и читает по одному разу тысячу холодных ключей. LRU выбросит A — к нему «дольше всего не обращались». LFU оставит: у A счётчик набегал долго, а у новичков по одному обращению. В Redis счётчик логарифмический и новый ключ стартует с 5, а не с нуля, но порядок тот же. Так же действуют аналитические запросы, краулеры, обходы «пройтись по всем сущностям».

Когда LRU лучше

Когда актуальность коррелирует со свежестью: сессии, недавно просмотренные товары, лента. И когда профиль обращений быстро меняется, потому что LFU перестраивается медленнее и держится за вчерашних фаворитов. random берут крайне редко. Он дешевле по CPU и никакого учёта не требует, но при перекошенном распределении заметно хуже обоих.

Как устроено приближение

  • Настоящий LRU держит двусвязный список: 16 байт указателей на каждый ключ плюс работа на каждом GET. Для десятков миллионов ключей это неприемлемо.
  • Вместо этого в заголовке объекта лежит 24-битное поле lru с грубыми часами последнего обращения.
  • При вытеснении Redis берёт maxmemory-samples случайных ключей (по умолчанию 5) и выбрасывает лучшего кандидата из выборки. С Redis 3.0 лучшие кандидаты копятся в пуле на 16 элементов между выборками, что заметно приближает результат к идеальному LRU.
  • maxmemory-samples 10 даёт практически точный LRU ценой CPU, 3 работает быстрее и грубее.
  • LFU в тех же битах хранит 8-битный логарифмический счётчик (вероятностный инкремент, lfu-log-factor) и время последнего затухания. Счётчик затухает: lfu-decay-time задаёт, за сколько минут простоя он теряет единицу. Без затухания LFU был бы фатально консервативен.
Чем добить

«У Redis это аппроксимация, а не точный LRU: он сознательно отказался от учёта, зато вытеснение почти ничего не стоит по памяти. Кто в кэше горячий, покажет OBJECT FREQ при LFU или redis-cli --hotkeys, который тоже работает только при LFU.»

Суть: RDB — периодический бинарный снимок всего датасета; компактный, быстро восстанавливается, теряет всё с момента последнего снимка. AOF — журнал всех изменяющих команд; с дефолтным everysec теряет до секунды, но файл больше и старт медленнее. Правильный ответ на «что выбрать» — оба в гибридном режиме.
RDBAOF
Что пишетснимок всего датасетакаждую изменяющую команду
Потеря при падениивсё с последнего снимка — минуты 0–1 секунда (appendfsync)
Стартбыстрый: чтение бинарникамедленный: проигрывание команд
Размеркомпактныйбольше, растёт (нужен rewrite)
Влияние на латентностьfork + copy-on-write, скачок памяти постоянный I/O + периодический rewrite (тоже fork)
Основное применениебэкапы, восстановление, начальная синхронизация реплик минимальная потеря данных

Гибридный режим — то, что применяют на практике

aof-use-rdb-preamble yes (с Redis 4.0, в современных версиях по умолчанию): при перезаписи AOF в начало файла кладётся RDB-снимок, дальше дописываются команды после него. Компактность и быстрый старт от RDB плюс окно потери в одну секунду от AOF.

AOF rewrite

Журнал растёт бесконечно: миллион INCR дают миллион строк. Redis периодически перезаписывает его в компактную форму, отражающую текущее состояние (auto-aof-rewrite-percentage, auto-aof-rewrite-min-size). Rewrite делает fork() со всеми вытекающими рисками по памяти.

Глубже: почему fork опасен

BGSAVE и AOF rewrite делают fork(): дочерний процесс делит страницы с родителем, но каждая изменённая страница копируется. При высокой доле записей потребление памяти может вырасти почти вдвое — типичная причина OOM-kill там, где maxmemory выставлен близко к RAM. Сам fork() на десятках гигабайт занимает сотни миллисекунд (копирование таблицы страниц), и всё это время сервер не отвечает. Практика: maxmemory ≤ 60–70 % RAM, vm.overcommit_memory = 1, выключенные transparent huge pages и снятие снимков с реплики, а не с мастера.

Суть: AOF пишется в буфер мгновенно, на диск попадает только при fsync. always — fsync после каждой записи, теряется почти ничего, но пропускная способность падает на порядки. everysec — раз в секунду, теряется до секунды, цена почти нулевая. no — решает ОС, теряются десятки секунд.
ЗначениеКогда fsyncОкно потериЦена
alwaysпосле каждой команды записи в пределе последняя команда высокая: запись ждёт fsync, параллельные записи сбрасываются одним fsync пачкой, и пропускная способность упирается в диск
everysecраз в секунду, отдельным потоком до 1 секундыпрактически незаметна — дефолт
noпо усмотрению ОС, обычно ~30 с десятки секунднулевая

Правильная позиция по always

always выглядит как «надёжный вариант», но включать его почти никогда не стоит: он превращает Redis из структуры данных в памяти в медленную дисковую БД — а тогда логичнее взять настоящую. Полной гарантии не даёт даже он: диск может подтвердить запись, положив её в кэш контроллера, а не на носитель. Формулировка для собеседования: «если данные настолько ценны, что нужен always, значит, они не должны жить только в Redis».

Уточняющий вопрос, который любят

«А если everysec, но диск подвис?» Тогда поток fsync не успевает, и Redis начинает блокировать запись, чтобы буфер не рос бесконечно (поведение зависит от no-appendfsync-on-rewrite). Выходит, медленный диск может прямо обернуться ростом латентности Redis, хотя тот «в памяти». Хороший аргумент за отдельный быстрый диск под AOF и за снятие персистентности с реплики.

Суть: репликация асинхронная: мастер отвечает клиенту OK, не дожидаясь реплик. Да, подтверждённая запись может потеряться — если мастер упадёт до доставки, а failover поднимет отставшую реплику. Это заявленное поведение, а не баг.

Как работает

  • Начальная синхронизация: мастер делает BGSAVE, передаёт RDB реплике, затем льёт поток команд. После разрыва возможна частичная ресинхронизация из кольцевого буфера repl-backlog, если отставание невелико.
  • Реплики по умолчанию read-only и отстают на величину лага (master_repl_offset против slave_repl_offset в INFO replication). Значит, read-your-writes с реплики не гарантирован.
  • WAIT numreplicas timeout блокирует, пока N реплик не подтвердят получение. Это не синхронная репликация: она ничего не откатывает при неуспехе, а лишь сообщает, сколько подтвердили. Полезна как барьер, строгой durability не даёт.
  • min-replicas-to-write N + min-replicas-max-lag S заставляют мастера отказывать в записи, если живых реплик меньше N. Это сознательный размен доступности на снижение риска потери.

Сценарий потери

  1. Клиент делает SET k v, мастер записал в память и ответил OK.
  2. Мастер падает (или изолируется сетью) до того, как команда ушла реплике.
  3. Sentinel проводит failover, реплика становится мастером — без этой записи.
  4. Старый мастер возвращается, становится репликой нового и отбрасывает свои неотправленные записи.
Как это звучит в сильном ответе

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

Суть: Sentinel — отдельные процессы-наблюдатели (нечётное число, от трёх), которые следят за мастером, договариваются о его недоступности по кворуму, выбирают лидера большинством голосов, промоутят лучшую реплику в мастера и сообщают новый адрес клиентам. Клиент подключается не к Redis, а к Sentinel.

Пять шагов failover

  1. SDOWN. Sentinel не получил ответа дольше down-after-milliseconds — помечает мастера субъективно недоступным.
  2. ODOWN. Опрашивает других sentinel-ов; если согласны не меньше quorum из них, статус становится объективно недоступным.
  3. Выборы лидера. Sentinel-ы выбирают, кто проведёт failover (вариант Raft). Для этого нужно большинство всех sentinel-ов, а не только кворум: при трёх нужны два живых.
  4. Промоушен. Лидер выбирает лучшую реплику (приоритет replica-priority, объём полученных данных, run id), даёт ей REPLICAOF NO ONE и перенастраивает остальные реплики на неё.
  5. Уведомление. Клиенты спрашивают у Sentinel адрес текущего мастера; в go-redis это redis.NewFailoverClient со списком sentinel-адресов и именем мастера.
Разница между quorum и большинством

Их путают почти всегда. quorum говорит, сколько sentinel-ов должны согласиться, что мастер недоступен. Большинство нужно, чтобы провести failover. Поэтому quorum можно поставить равным 1, но failover всё равно не произойдёт, если живо меньше половины sentinel-ов. Отсюда и требование нечётного числа от трёх и размещения их в разных зонах отказа: два sentinel-а в одной стойке отказоустойчивости не дают.

Что теряется при failover

Всё, что мастер подтвердил клиенту, но не успел отправить реплике. Плюс возможен split brain: изолированный старый мастер продолжает принимать записи (пока не сработает min-replicas-to-write), а после восстановления сети становится репликой и отбрасывает их. Из-за этого распределённая блокировка на одном Redis не гарантирует взаимного исключения: после failover два клиента могут одновременно считать себя держателями.

Суть: пространство ключей разбито на 16384 слота, slot = CRC16(key) mod 16384, каждый мастер владеет диапазоном слотов. Клиент сам держит карту слотов и ходит на нужную ноду; прокси нет. MOVED — слот навсегда переехал, надо обновить карту. ASK — слот прямо сейчас мигрирует, редирект разовый и карту трогать нельзя.

Почему именно 16384

Карта владения слотами передаётся в каждом сообщении gossip-протокола как битовая маска. 16384 бита занимают 2 КБ на сообщение, а 65536 заняли бы 8 КБ. При реальных размерах кластера (десятки нод) больше слотов не нужно, а трафик служебного протокола вырос бы вчетверо.

MOVED против ASK

MOVED slot addrASK slot addr
Смыслслот принадлежит другой ноде постоянно слот мигрирует, конкретно этот ключ уже на приёмнике
Действие клиентаповторить на новой ноде и обновить карту слотов послать ASKING + команду на указанную ноду один раз, карту не обновлять
Когдапосле ребалансировки или failover во время активной миграции слота

Почему ASK не обновляет карту: миграция идёт по ключам, и остальные ключи слота пока лежат на исходной ноде. Если бы клиент переключил весь слот, он начал бы промахиваться по ещё не переехавшим ключам. Отсюда и ASKING — одноразовое разрешение обслужить ключ из мигрирующего слота.

Failover внутри кластера

Sentinel не нужен: реплики сами инициируют выборы, решение принимает большинство мастеров. Если мастер потерян и реплики у него нет, при cluster-require-full-coverage yes (умолчание) кластер перестаёт обслуживать все запросы, а не только осиротевший диапазон. Это часто становится сюрпризом.

Глубже: чего нет в кластере

Нет баз данных: доступна только db 0, SELECT не работает. Классический pub/sub рассылается по всем нодам (дорого), поэтому с Redis 7 есть шардированный SPUBLISH/SSUBSCRIBE, привязанный к слоту канала. И нет прозрачных мультиключевых операций между слотами, о них следующий вопрос.

Суть: команда выполняется на одной ноде, а ключи могут лежать на разных — атомарности между нодами в Redis нет. Поэтому MGET, MSET, SUNION, MULTI/EXEC и Lua требуют, чтобы все ключи были в одном слоте, иначе CROSSSLOT. Hash tag {...} заставляет CRC16 считаться только по части ключа — так связанные ключи попадают в один слот.

Ошибка, которую увидишь

> MGET user:42:profile user:43:profile
(error) CROSSSLOT Keys in request don't hash to the same slot

Hash tag

# Без тега слоты разные:
user:42:profile   -> CRC16("user:42:profile") mod 16384
user:42:orders    -> CRC16("user:42:orders")  mod 16384

# С тегом CRC16 берётся только от того, что внутри первых фигурных скобок:
user:{42}:profile -> CRC16("42") mod 16384
user:{42}:orders  -> CRC16("42") mod 16384      # тот же слот
> MGET user:{42}:profile user:{42}:orders       # работает
> EVAL "..." 2 user:{42}:profile user:{42}:orders   # тоже работает

Обратная сторона

  • Неудачный тег = горячий слот. {tenant} при крупном арендаторе сложит весь его объём и трафик на одну ноду. Тег должен быть мелкогранулярным: пользователь, заказ, но не «весь клиент» и не «весь регион».
  • Тег ломает приём с репликами горячего ключа. Если размазываешь ключ по суффиксам key#0..key#15, hash tag загонит все копии обратно в один слот — приём перестанет работать.
  • Клиент может маскировать проблему. go-redis умеет разбивать MGET на несколько запросов по нодам, и код «работает». Но атомарности при этом нет: часть ключей может быть прочитана до чужой записи, часть — после.
Проектное следствие

Схему ключей в кластере проектируют заранее, а не когда упёрлись в CROSSSLOT. Простое правило: если две сущности когда-нибудь придётся читать или менять одной атомарной операцией, общий hash tag им нужен с самого начала. Переехать на теги задним числом значит сменить все ключи, то есть полностью сбросить кэш.

Суть: ключ может быть выброшен вытеснением, до секунды записей теряется при падении, подтверждённая запись теряется при failover, возможен split brain, а MULTI/EXEC не даёт отката. Это осознанный выбор в пользу латентности и доступности, и приложение должно быть к нему готово по умолчанию.

Пять источников «негарантий»

  1. Вытеснение. При allkeys-* Redis вправе выбросить любой ключ до истечения TTL. Значит, ключа может не оказаться в любой момент, и это штатная ситуация.
  2. Падение процесса. С everysec теряется до секунды записей, без AOF всё с последнего снимка.
  3. Failover. Репликация асинхронная: между ответом клиенту и доставкой реплике ничего не защищено.
  4. Split brain. Изолированный старый мастер принимает записи, которые потом будут отброшены.
  5. Транзакции. MULTI/EXEC даёт атомарность выполнения и изоляцию (чужие команды не вклиниваются), но не даёт отката: ошибка одной команды на этапе выполнения не отменяет остальные.

Что из этого следует в коде

  • Redis остаётся кэшем и вспомогательным состоянием, не источником правды. Всё, что нельзя потерять, имеет копию в БД или брокере.
  • Ветку «в кэше нет» обязательно реализуют и тестируют, включая полный отказ Redis, при котором сервис деградирует, а не падает.
  • Распределённая блокировка на Redis нужна как оптимизация, гарантии взаимного исключения она не даёт. Корректность даёт идемпотентность операции или fencing token, проверяемый на стороне ресурса.
  • Если Redis служит хранилищем (Streams, сессии, очередь), нужны политика noeviction, включённая персистентность и явно согласованное с продуктом допустимое окно потери.
Формулировка, которая закрывает секцию

«Redis не БД с ослабленными гарантиями, а структура данных в памяти с дополнительными механизмами сохранности. Он сознательно выбрал латентность и доступность: асинхронная репликация, вытеснение, fsync раз в секунду. Поэтому я проектирую так, чтобы потеря любого ключа или всего инстанса была неприятной, но не критичной — и явно проговариваю это на этапе дизайна, а не выясняю после первого failover.»

7.7Распределённые блокировки и Redis из Go

Здесь неправильный ответ стоит дороже всего. Распределённую блокировку на Redis напишет почти каждый, а понимают, что она не даёт корректности, и могут объяснить почему — единицы. Вторая половина главы прикладная: go-redis, пул, таймауты, пайплайнинг, Lua и redis.Nil. Если позиция подразумевает реальный код, спросят как раз это.

Блокировка на Redis: SET NX PX

Хватает одной команды:

SET lock:job:42 <уникальное-значение> NX PX 10000
  • NX — установить, только если ключа нет. Это и есть атомарный захват: команда либо создаёт ключ и возвращает OK, либо возвращает nil. Однопоточный сервер выполняет проверку и установку как одну операцию, так что гонке между ними взяться неоткуда.
  • PX 10000 — TTL в миллисекундах.
  • В значение кладут случайный UUID держателя.

Зачем TTL

Без TTL блокировка упавшего держателя остаётся навсегда. Процесс убили OOM-killer-ом, под выселили, машина отключилась — defer unlock() не выполнится, потому что выполнять его некому. Ключ висит, работа стоит, чинят руками в три часа ночи. Освободить блокировку без участия упавшего процесса умеет только TTL. Из него же растёт главная проблема таких блокировок, о ней ниже.

Зачем уникальное значение

Чтобы снять свою блокировку, а не чужую. Сценарий: A взял блокировку с TTL 10 секунд, работа затянулась на 12; на десятой секунде ключ истёк, B тут же его взял; на двенадцатой A заканчивает и делает DEL lock:job:42, то есть удаляет блокировку B. Дальше блокировку берёт C, и теперь B и C работают параллельно. Забыл одну деталь, и взаимного исключения больше нет. С уникальным значением блокировку можно снимать с проверкой: «удали, только если там всё ещё моё значение».

Освобождение через Lua

«Проверить и удалить» состоит из двух операций, и между ними ключ может истечь. Классическая гонка check-then-act: GET вернул моё значение, в этот момент TTL истёк, B захватил блокировку, и мой DEL сносит уже его ключ. Атомарность даёт Lua-скрипт: Redis выполняет его целиком, не вклинивая чужие команды.

-- unlock.lua: удалить ключ, только если значение совпадает
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end`)

type Lock struct {
    rdb   *redis.Client
    key   string
    value string        // уникальный идентификатор владения
}

func Acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, error) {
    val := uuid.NewString()
    ok, err := rdb.SetNX(ctx, key, val, ttl).Result()   // SET key val NX PX ttl
    if err != nil {
        return nil, err
    }
    if !ok {
        return nil, ErrLockBusy                          // занято кем-то другим
    }
    return &Lock{rdb: rdb, key: key, value: val}, nil
}

func (l *Lock) Release(ctx context.Context) error {
    // NewScript.Run сначала пробует EVALSHA, а при NOSCRIPT переходит на EVAL.
    n, err := unlockScript.Run(ctx, l.rdb, []string{l.key}, l.value).Int64()
    if err != nil {
        return err
    }
    if n == 0 {
        // Ключа нет или он уже не наш: TTL истёк, пока мы работали.
        // Это не безобидно: значит, кто-то мог работать параллельно с нами.
        return ErrLockLost
    }
    return nil
}
Откуда здесь uuid и что изменилось в Go 1.27

uuid.NewString() взят из github.com/google/uuid, который много лет был стандартом де-факто. С Go 1.27 пакет uuid есть в стандартной библиотеке (RFC 9562), и ради одной случайной строки внешняя зависимость больше не нужна: uuid.New().String() даёт то же самое. В версии из stdlib есть uuid.New(), uuid.NewV4(), uuid.NewV7(), uuid.Parse и тип uuid.UUID = [16]byte; метода NewString в ней нет, вместо него пишут New().String(). Для значения блокировки годится любой вариант: от него требуется только неповторимость. А вот для первичного ключа в БД разница есть — там нужен именно NewV7(), потому что он сортируется по времени и не рвёт страницы B-tree-индекса, в отличие от случайного v4.

Почему нельзя GET + DEL, даже с проверкой в Go

Между GET и DEL проходит сетевой round-trip. Это миллисекунды, за которые тебя могли вытеснить с процессора, а ключ мог истечь. Проверка в коде приложения ничего не даёт: решение принимается по устаревшим данным. Атомарной должна быть сама пара «сравнить и удалить». На стороне Redis это либо Lua, либо встроенный compare-and-delete: с Redis 8.4 есть DELEX key IFEQ value, а продлевают через SET key value IFEQ value PX ttl. На версиях старше остаётся только Lua. С продлением то же самое: TTL продлевают с проверкой значения, иначе продлишь чужую блокировку.

Главная проблема: TTL истёк, а работа идёт

Блокировка с TTL держится ровно TTL — и ни микросекундой больше. А задержать держателя дольше могут пауза GC (в Go редко, но stop-the-world есть), вытеснение потока планировщиком ОС, залипший сетевой вызов, «замороженный» контейнер, миграция виртуальной машины или просто медленный запрос в БД. В любой из этих ситуаций два клиента одновременно считают себя держателями блокировки, и оба уверены, что имеют право писать.

Блокировка истекла, а работа не закончилась: два держателя и что их разнимает t Клиент A Клиент B состояние t1 SET lock NX PX 10000 --> OK, токен 33 лок у A до t1+10 c t2 начал работу (ожидали 3 с) лок у A t3 STW-пауза / потеря сети — 12 с TTL ИСТЁК, лок свободен t4 SET lock NX PX 10000 --> OK, токен 34 лок у B t5 пишет результат, предъявляет токен 34 принято, ресурс запомнил 34 t6 очнулся, «я держу лок», пишет с токеном 33 33 < 34 --> ОТКЛОНЕНО t7 Release: Lua сравнивает значение не наше --> чужой лок цел Без fencing token на t6 запись A прошла бы: результат B затёрт результатом устаревшего A. Никакой TTL, watchdog и Redlock этого не ловят — A физически не может узнать, что он уже не держатель. Пауза может случиться и ПОСЛЕ успешной проверки. Fencing token: монотонно растущее число, выдаваемое при захвате (INCR lock:fence). Ресурс запоминает максимальный виденный токен и отвергает всё меньшее. Корректность обеспечивает РЕСУРС, а не блокировка — в этом весь смысл.
Блокировка даёт оптимизацию, а не гарантию. Держатель не может узнать, что его TTL истёк: пауза может случиться и после проверки. Спасает только проверка монотонного токена на стороне ресурса, который ты защищаешь.

Watchdog — продление TTL

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

var extendScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
    return 0
end`)

// Запускается сразу после захвата, останавливается через cancel при Release.
func (l *Lock) keepAlive(ctx context.Context, ttl time.Duration) {
    t := time.NewTicker(ttl / 3)      // продлеваем втрое чаще, чем истекает
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-t.C:
            ok, err := extendScript.Run(ctx, l.rdb, []string{l.key}, l.value,
                ttl.Milliseconds()).Int64()
            if err != nil || ok == 0 {
                // Блокировку потеряли, значит работу надо прервать, а не продолжать:
                // параллельно с нами уже может идти другой держатель.
                l.lost()          // закрывает канал, по которому работа отменяется
                return
            }
        }
    }
}
Чего watchdog не решает

Он уменьшает вероятность, но не устраняет проблему. Если процесс встал целиком (STW-пауза, заморозка контейнера, потеря сети), watchdog встал вместе с ним — продлевать некому, и TTL истечёт. Вдобавок у watchdog есть свой сценарий отказа: продление ушло, но ответ потерялся; клиент считает, что потерял блокировку, а она продлена. Watchdog нужен для гигиены, а корректность дают только fencing token или идемпотентность операции.

Fencing token

Идея из статьи Клеппмана. При каждом захвате блокировки выдаётся монотонно растущее число (в Redis это INCR lock:{job:42}:fence: в Cluster фигурные скобки сводят лок и счётчик в один слот, иначе скрипт захвата упадёт с CROSSSLOT). Клиент передаёт этот токен в каждый запрос к защищаемому ресурсу, а ресурс помнит самый большой токен, какой видел, и отвергает всё, что меньше. Тогда «воскресший» старый держатель с токеном 33 физически не сможет испортить работу нового с токеном 34. Оговорка: счётчик в Redis монотонен, пока Redis не теряет записи. После failover с асинхронной репликацией он может откатиться назад, поэтому для строгих гарантий токен берут из хранилища с консенсусом или из той же БД, что защищает ресурс.

-- захват с выдачей токена, одним скриптом
if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
    return redis.call("INCR", KEYS[2])   -- монотонный счётчик поколений
else
    return 0
end
Вывод, которого от тебя ждут

Fencing token переносит ответственность за корректность с блокировки на защищаемый ресурс. И тут же всплывает практическая проблема: ресурс должен уметь проверять токен — условный UPDATE ... WHERE fence <= :token в БД или condition в объектном хранилище. Если ресурс этого не умеет (сторонний API, файловая система, отправка письма), то никакая распределённая блокировка не даст корректности, и решение надо строить на идемпотентности: сделать операцию безопасной при повторном выполнении.

Redlock и критика Клеппмана

Алгоритм Redlock Сальваторе Санфилиппо (автор Redis) предложил для блокировки поверх N независимых мастеров Redis (обычно 5), не связанных репликацией. Клиент:

  1. берёт текущее время и последовательно пытается захватить ключ на всех N инстансах с малым таймаутом на каждый;
  2. считает блокировку взятой, если получил её на большинстве (N/2+1) и суммарно потратил меньше TTL;
  3. владеет ею TTL минус потраченное время минус поправка на дрейф часов;
  4. при неуспехе снимает блокировку со всех инстансов, где успел взять.

Смысл: пережить падение отдельных инстансов без Sentinel и без потери блокировки при failover.

Критика Мартина Клеппмана (2016)

Разбор «How to do distributed locking» и ответ Санфилиппо давно стали классикой, их стоит знать по пунктам.

  1. Разделение целей. Клеппман начинает с вопроса, зачем вообще нужна блокировка: для эффективности (не делать одну работу дважды) или для корректности (двойное выполнение недопустимо). Для эффективности сгодится и одиночный Redis. Для корректности Redlock не подходит, и дальше Клеппман объясняет почему.
  2. Опора на часы. Redlock считает истечение по времени, предполагая ограниченный дрейф часов. Но время может скакнуть: NTP-коррекция, живая миграция ВМ, переустановка часов администратором. Время на одном узле прыгнуло вперёд, и блокировка «истекает» раньше, чем думает держатель. Если корректность системы зависит от синхронности часов, в асинхронной модели она небезопасна.
  3. Паузы процесса. Даже с идеальными часами держателя может остановить STW-пауза, своп, вытеснение планировщиком или заморозка контейнера. После паузы он не знает, что блокировку потерял, — и продолжает работать. Никакой алгоритм захвата это не ловит.
  4. Fencing token как единственное лекарство. Отсюда вывод: нужен монотонный токен, проверяемый ресурсом. Redlock не выдаёт такого токена: у него N независимых инстансов, и получить из них согласованно возрастающее число нельзя. А системы с настоящим консенсусом (ZooKeeper с zxid, etcd с revision, Consul) его выдают.
  5. Ответ Санфилиппо. Он возразил, что предположения о часах ограничены и практичны, а про паузы сказал, что fencing token нужен любой блокировке, включая ZooKeeper, так что это не претензия именно к Redlock. Обе стороны частично правы; консенсуса в сообществе нет.
Как ответить про Redlock

«Redlock решает узкую задачу — пережить падение отдельных инстансов Redis. Главную он не решает и решить не может: держатель не может узнать, что его блокировка истекла. Поэтому я сначала спрашиваю, для чего блокировка. Если она нужна для эффективности, беру простой SET NX PX на одном Redis с Lua-освобождением, этого достаточно, и Redlock тут избыточен. Если для корректности, блокировка на Redis не подходит в принципе: нужен либо fencing token, проверяемый ресурсом, либо идемпотентность, либо система с консенсусом (etcd, ZooKeeper, Consul), либо просто уникальный индекс/условный апдейт в самой БД, и чаще всего это самое простое решение.»

Глубже: чаще всего блокировка вообще не нужна

На собеседовании этот совет ценят выше знания Redlock: большинство задач, для которых берут распределённую блокировку, обходятся без неё. «Только один воркер обрабатывает заказ» решается уникальным индексом на order_id в таблице обработок или UPDATE ... WHERE status = 'new' с проверкой числа затронутых строк. Чтобы «не отправить письмо дважды», хватит таблицы идемпотентности с ключом операции. Для «одного экземпляра крона» есть SELECT ... FOR UPDATE SKIP LOCKED или advisory lock в Postgres: там блокировка живёт в той же транзакции, что и данные, и не может «истечь посередине».

Pub/Sub, Streams и полноценный брокер

Передать сообщение через Redis-подобную инфраструктуру можно тремя способами, и главный вопрос ко всем трём один: что происходит с сообщением, если получателя в этот момент нет.

Redis Pub/SubRedis StreamsKafka / RabbitMQ / NATS JS
Хранение нет вообще: сообщение существует только в момент рассылки да, лог в памяти с опциональным MAXLEN; переживает рестарт при AOF/RDB да, на диске, с настраиваемым retention
Гарантия at-most-once: нет подписчика — сообщение потеряно навсегда at-least-once через consumer group и XACK at-least-once, у Kafka — транзакционный exactly-once внутри системы
Подтверждениянет есть: PEL, XACK, XPENDING, XCLAIM/XAUTOCLAIM есть: offset-коммиты, ack/nack
Повторное чтениеневозможно да, по id: XRANGE, XREAD с любого места да, перемотка offset, replay всего лога
Масштабирование потребителей все подписчики получают всё (фанаут) consumer group делит сообщения между потребителями партиции и consumer group, порядок внутри партиции
Порядокв пределах каналастрогий по id строгий внутри партиции
Ценаничего не стоит память под лог, ручное управление PEL и обрезкой отдельный кластер, эксплуатация, задержка

Что именно теряется в pub/sub

  • Нет персистентности. PUBLISH доставляет только тем, кто подписан прямо сейчас. Подписчик отвалился на две секунды, пока переподключался, и сообщений за эти две секунды он не увидит и никогда не узнает об этом.
  • Нет подтверждений. PUBLISH возвращает число подписчиков, которым сообщение отправлено, а не число обработавших. Обработал ли кто-то, отправитель не узнает.
  • Медленный подписчик отбрасывается. Если он не успевает вычитывать, у него растёт output buffer, и когда тот упрётся в client-output-buffer-limit pubsub, Redis рвёт соединение. Для приложения это происходит тихо.
  • В кластере рассылается по всем нодам — это дорого. С Redis 7 есть шардированный SPUBLISH/SSUBSCRIBE, привязанный к слоту канала.

Отсюда единственно корректное применение pub/sub: сигналы-ускорители, потеря которых безопасна. Сюда хорошо ложится инвалидация локальных кэшей: если сигнал не дошёл, ключ всё равно протухнет по TTL через секунду. А бизнес-события («заказ оплачен») на pub/sub гарантированно потеряются, причём тихо и в первый же деплой.

Когда Streams достаточно

  • Умеренный объём, который помещается в память с запасом, и есть MAXLEN.
  • Retention нужен часами, а не неделями: перечитать вчерашний день не потребуется.
  • Redis уже есть в инфраструктуре, и заводить Kafka ради одной очереди задач избыточно.
  • Нужны подтверждения и повторная выдача зависших сообщений — PEL и XAUTOCLAIM это дают.

Когда нужен настоящий брокер

  • Retention днями и неделями, replay. В Redis лог живёт в памяти — терабайт истории там никто держать не станет.
  • Много независимых потребителей одного потока. В Kafka это естественно: каждая consumer group читает свой offset по одному и тому же логу.
  • Строгие требования к durability. Kafka пишет на диск и реплицирует с acks=all; Redis теряет до секунды при everysec и подтверждённые записи при failover.
  • Экосистема: Kafka Connect, Streams/Flink, схемы (Avro/Protobuf + registry), compaction, транзакции. Ничего этого в Redis нет.
  • Сложная маршрутизация уже по части RabbitMQ: exchange, routing key, TTL очередей, DLQ из коробки.
Готовая формулировка

«Pub/sub беру только для сигналов, потеря которых безопасна: инвалидация кэша, уведомление о смене конфига. Streams подходят как нормальная очередь задач, когда объём умеренный и Redis уже есть: там есть consumer group, PEL и XAUTOCLAIM, то есть at-least-once. Kafka нужна, когда требуются долгий retention, replay, много независимых потребителей и настоящая durability. Чаще всего я видел одну ошибку: бизнес-события через pub/sub. Они теряются молча, а замечают это через недели.»

Redis из Go: go-redis

github.com/redis/go-redis/v9 стал стандартом де-факто (второй вариант, rueidis, быстрее за счёт автопайплайнинга по умолчанию, но встречается реже; RESP3 go-redis v9 включает и сам). Клиент потокобезопасен, держит внутри пул соединений и создаётся один на приложение.

rdb := redis.NewClient(&redis.Options{
    Addr:     "redis:6379",
    Password: os.Getenv("REDIS_PASSWORD"),
    DB:       0,

    // --- пул соединений ---
    PoolSize:        50,               // максимум сокетов; по умолчанию 10 * GOMAXPROCS
    MinIdleConns:    10,               // держать прогретыми, чтобы не платить за dial в пике
    MaxIdleConns:    20,
    ConnMaxIdleTime: 30 * time.Minute, // закрывать простаивающие
    ConnMaxLifetime: 0,                // 0 = не пересоздавать по возрасту
    PoolTimeout:     4 * time.Second,  // сколько ждать свободное соединение из пула

    // --- таймауты ---
    DialTimeout:  2 * time.Second,
    ReadTimeout:  500 * time.Millisecond,   // Redis быстрый: секунды тут не нужны
    WriteTimeout: 500 * time.Millisecond,

    // --- ретраи ---
    MaxRetries:      3,
    MinRetryBackoff: 8 * time.Millisecond,
    MaxRetryBackoff: 512 * time.Millisecond,
})

Про пул — то, что спрашивают

  • Одна команда занимает одно соединение на время round-trip. Значит, нужный размер пула ≈ пиковая конкурентность обращений к Redis, а не число ядер. При латентности 0,3 мс одно соединение выдаёт ~3000 команд в секунду — 50 соединений закрывают 150 000 rps.
  • Исчерпание пула даёт не отказ, а ожидание: горутина ждёт свободное соединение до PoolTimeout, потом получает redis: connection pool timeout. Это главный симптом: либо пул мал, либо Redis отвечает медленно (большие ключи, медленные команды). Поэтому метрики rdb.PoolStats() (Timeouts, TotalConns, IdleConns) надо снимать в Prometheus.
  • Блокирующие команды съедают соединение целиком. BLPOP, XREAD BLOCK, подписка pub/sub держат сокет всё время ожидания. Их нельзя гонять через общий пул в большом количестве: заведи для них отдельный клиент или хотя бы помни, что N воркеров навсегда съедят N соединений.
  • MaxRetries и идемпотентность. Ретраи включены по умолчанию. Для GET это безопасно, для INCR уже нет: команда могла выполниться, а ответ потеряться, и повтор посчитает второй раз. Для неидемпотентных операций либо MaxRetries: -1 (выключить), либо операция, устойчивая к повтору.

Контекст и таймауты

Каждая команда принимает ctx, но по умолчанию go-redis v9 его дедлайн на сокет не переносит (ContextTimeoutEnabled: false): контекст учитывается, пока ждёшь соединение из пула и между ретраями, а ответ ждут по ReadTimeout. С ContextTimeoutEnabled: true дедлайн контекста становится дедлайном чтения; оборванное посреди ответа соединение go-redis закрывает, так что массовые таймауты выглядят как шторм переподключений. И в любом случае уже отправленная команда в Redis выполнится. На практике ставят короткий ReadTimeout (порядка сотен миллисекунд) и не рассчитывают, что отмена «остановит работу в Redis».

redis.Nil — не ошибка, а результат

val, err := rdb.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
    // Ключа нет. Это штатная ветка, а не сбой: промах кэша.
    return loadFromDB(ctx, key)
case err != nil:
    // Настоящая ошибка: сеть, таймаут, пул. Тоже обычно ведёт в БД,
    // но с метрикой и логом, иначе не заметишь, что Redis лежит.
    metrics.CacheErrors.Inc()
    return loadFromDB(ctx, key)
default:
    return val, nil
}
Три ошибки вокруг redis.Nil
  • Сравнивать через ==. err == redis.Nil сломается, как только ошибку кто-нибудь обернёт fmt.Errorf("%w"). Всегда errors.Is.
  • Возвращать redis.Nil наверх. Слой кэша обязан транслировать её в доменную ошибку (ErrNotFound) или в промах. Иначе про Redis знает весь сервис.
  • Путать «нет ключа» и «ошибка». redis.Nil означает отсутствие ключа; сетевая ошибка означает, что мы не знаем. Ветки разные: в первой обычный промах, во второй сигнал деградации, и его надо считать в метриках.

Отдельно: redis.Nil прилетает не только из GET. Пустой результат BLPOP по таймауту, ZSCORE несуществующего элемента, HGET отсутствующего поля — всё это redis.Nil. А вот Exists и SetNX возвращают ноль/false без ошибки, и на этом тоже часто путаются.

Пайплайнинг

Пайплайн отправляет N команд одним пакетом и читает N ответов, экономя N−1 round-trip. Это самый дешёвый способ ускорить работу с Redis: при RTT 0,3 мс сто последовательных GET займут 30 мс, а пайплайном — 0,4 мс.

// Pipeline не атомарен: просто пакет команд, между ними могут выполниться чужие.
pipe := rdb.Pipeline()
cmds := make([]*redis.StringCmd, 0, len(ids))
for _, id := range ids {
    cmds = append(cmds, pipe.Get(ctx, keyOf(id)))
}
_, err := pipe.Exec(ctx)
// Exec возвращает ошибку, если упала хотя бы одна команда, включая redis.Nil.
// Результаты всё равно надо разбирать по каждой команде отдельно.
if err != nil && !errors.Is(err, redis.Nil) {
    return nil, err
}
for i, c := range cmds {
    v, err := c.Result()
    if errors.Is(err, redis.Nil) {
        continue                 // ключа нет: промах, не ошибка
    }
    ...
}
// TxPipeline: то же самое, но обёрнуто в MULTI ... EXEC.
// Все команды выполнятся подряд, без чужих между ними.
pipe := rdb.TxPipeline()
incr := pipe.Incr(ctx, "counter")
pipe.Expire(ctx, "counter", time.Hour)
_, err := pipe.Exec(ctx)          // MULTI, INCR, EXPIRE, EXEC одним round-trip
fmt.Println(incr.Val())           // 1 — счётчик после первого INCR
// До Exec поля команд пустые: incr.Val() вернул бы 0. Заполняются они
// в момент Exec, поэтому читать результат раньше — типичная ошибка.
Pipeline против TxPipeline: что отвечать
  • Оба дают один round-trip. Разница только в атомарности.
  • Pipeline только пакует команды: между твоими Redis может выполнить чужие.
  • TxPipeline = MULTI/EXEC: команды выполняются подряд как единый блок. Но отката нет: если одна команда упала на этапе выполнения (например, INCR по строке), остальные всё равно выполнятся. Транзакция Redis значит «выполнить подряд», а не «всё или ничего».
  • Условные операции делают через WATCH + MULTI/EXEC (оптимистическая блокировка: EXEC вернёт nil, если наблюдаемый ключ изменился) или, что почти всегда проще и надёжнее, через Lua-скрипт.
  • В кластере пайплайн разбивается по нодам, а TxPipeline требует все ключи в одном слоте, иначе получишь CROSSSLOT.

Lua: EVAL, EVALSHA и redis.NewScript

Lua-скрипт выполняется на сервере атомарно: пока он работает, ни одна другая команда не выполнится. Только так в Redis получается настоящее «прочитать-проверить-записать». EVAL отправляет текст скрипта каждый раз, EVALSHA — только SHA1-хеш, экономя трафик. go-redis прячет оба за одним вызовом:

// Скрипт объявляется один раз как переменная пакета.
var rateLimit = redis.NewScript(`
local current = redis.call("INCR", KEYS[1])
if current == 1 then
    redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
    return 0
end
return 1`)

// Run сначала пробует EVALSHA; если сервер ответил NOSCRIPT
// (перезапуск, SCRIPT FLUSH), сам переключается на EVAL и кеширует скрипт заново.
allowed, err := rateLimit.Run(ctx, rdb,
    []string{"rl:" + userID},                 // KEYS
    int64(time.Minute/time.Millisecond), 100, // ARGV: окно и лимит
).Int64()
Правила работы с Lua в Redis
  • Скрипт блокирует сервер целиком. Исполнение однопоточное, так что долгий цикл в Lua останавливает всех клиентов. Скрипты держат короткими и с ограниченным числом операций; никаких «пройтись по всем ключам».
  • Все ключи передавай через KEYS, а не жёстко в тексте. Иначе кластер не сможет определить слот и маршрутизировать скрипт, а на одиночном инстансе его нельзя будет переиспользовать.
  • Скрипт должен быть детерминированным. В старых версиях случайность и запись в одном скрипте были запрещены из-за репликации по командам; сейчас реплицируется результат (effects replication), но привычка «никакой недетерминированности» остаётся правильной.
  • Кэш скриптов не вечен. После рестарта или SCRIPT FLUSH сервер не знает твой SHA — поэтому голый EVALSHA без фолбэка однажды вернёт NOSCRIPT. redis.NewScript делает фолбэк сам, а писать EvalSha руками без обработки NOSCRIPT — типичная ошибка.
  • Redis 7 добавил Functions (FUNCTION LOAD) — постоянные именованные библиотеки скриптов, которые переживают рестарт и реплицируются. Упомяни их, это плюс к ответу.

Вопросы

9
Суть: SET lock:key <uuid> NX PX 10000 — атомарный захват одной командой. TTL нужен, чтобы блокировка освободилась, если держатель умер и defer unlock() выполнять некому. Уникальное значение — чтобы при освобождении снять свою блокировку, а не чужую, если TTL успел истечь.

Почему одной командой

NX означает «установить, только если ключа нет». Проверка и установка выполняются как одна операция в однопоточном сервере, поэтому гонки между «посмотрел, что свободно» и «занял» физически не существует. Разделять это на EXISTS + SET — классическая ошибка.

Зачем TTL

Держатель может умереть в любой момент: OOM-kill, выселение пода, отключение машины. Тогда никакого «освобождения» не будет — выполнять его некому. Без TTL блокировка зависает навсегда, работа не делается, чинится руками. TTL остаётся единственным способом освободить блокировку без участия упавшего процесса.

Зачем уникальное значение

  1. A взял блокировку с TTL 10 с, работа затянулась на 12.
  2. На 10-й секунде ключ истёк, B немедленно его взял.
  3. На 12-й A заканчивает, делает DEL lock:key и удаляет блокировку B.
  4. Блокировку берёт C. Теперь B и C работают параллельно: взаимное исключение сломано.

Уникальный UUID в значении позволяет освобождать с проверкой: «удали, только если там всё ещё моё». Продление TTL тоже идёт с проверкой, иначе продлишь чужую блокировку.

Чем добить

«И сразу оговорюсь: SET NX PX годится как блокировка для эффективности (не делать одну работу дважды), а не для корректности. Гарантии взаимного исключения она не даёт, потому что держатель не может узнать, что его TTL истёк. Если двойное выполнение недопустимо, нужен fencing token или идемпотентность.»

Суть: GET и DEL — две операции, между ними сетевой round-trip. За это время TTL может истечь, блокировку захватит другой, и ваш DEL снесёт его ключ. Проверка в коде Go не помогает — она принимается на устаревших данных. Атомарной должна быть сама пара «сравнить и удалить».

Скрипт

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

Redis выполняет Lua-скрипт целиком, не вклинивая чужие команды, поэтому между сравнением и удалением ничего произойти не может.

var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end`)

func (l *Lock) Release(ctx context.Context) error {
    n, err := unlockScript.Run(ctx, l.rdb, []string{l.key}, l.value).Int64()
    if err != nil {
        return err
    }
    if n == 0 {
        // Ключа уже нет или он не наш: TTL истёк, пока мы работали.
        // Это сигнал, что параллельно с нами кто-то мог работать.
        return ErrLockLost
    }
    return nil
}

Гонка на оси времени

  1. A: GET lock → «uuid-A», всё моё.
  2. Между ответом и следующей командой A вытесняют с процессора на 5 мс.
  3. TTL истекает, B делает SET NX и получает блокировку со значением «uuid-B».
  4. A просыпается, выполняет DEL lock и сносит блокировку B.
Что ответить на «а если проверить в Go после GET?»

Проверяя в Go, ты решаешь по данным, которые устарели как минимум на один round-trip. Между получением ответа и отправкой DEL всегда есть окно: планировщик Go, планировщик ОС, сеть. Уменьшить его можно, устранить — нет. Корректно только одно: сравнивать и удалять на сервере, то есть в Lua. Это касается любой конструкции «прочитал → подумал → записал» в Redis: она либо в Lua, либо через WATCH/MULTI/EXEC, либо сломана.

Суть: два клиента одновременно считают себя держателями. Смягчает watchdog — фоновое продление TTL с проверкой значения. Но по-настоящему проблему решает только fencing token: монотонный номер, который проверяет сам защищаемый ресурс, отвергая всё меньшее.

Почему это неизбежно

Держателя может остановить что угодно: пауза GC, вытеснение планировщиком ОС, своп, заморозка контейнера, залипшая сеть, миграция ВМ. Он не получает уведомления «твой TTL истёк» и не может его получить в принципе: даже если он проверит владение перед записью, пауза может случиться сразу после проверки, до самой записи.

Watchdog — уменьшает вероятность

var extendScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
    return 0
end`)

t := time.NewTicker(ttl / 3)            // продлеваем втрое чаще, чем истекает
for {
    select {
    case <-ctx.Done():
        return
    case <-t.C:
        ok, err := extendScript.Run(ctx, rdb, []string{key}, val, ttl.Milliseconds()).Int64()
        if err != nil || ok == 0 {
            l.lost()   // прервать работу, а не продолжать: рядом уже может идти другой
            return
        }
    }
}

Чего watchdog не решает: если процесс встал целиком, watchdog встал вместе с ним. Плюс у него есть собственный сценарий — продление ушло, ответ потерялся, клиент считает, что блокировку потерял, хотя она продлена.

Fencing token — настоящее решение

При каждом захвате выдаётся монотонно растущее число (INCR lock:key:fence). Клиент передаёт его в каждый запрос к ресурсу; ресурс запоминает максимальный виденный токен и отвергает всё меньшее.

-- захват с выдачей токена
if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
    return redis.call("INCR", KEYS[2])
else
    return 0
end
-- проверка на стороне ресурса, например в Postgres
UPDATE jobs SET result = $1, fence = $2 WHERE id = $3 AND fence <= $2;
-- 0 строк --> наш токен устарел, мы больше не держатель, результат отбрасываем
Что из этого следует

Fencing token переносит ответственность за корректность с блокировки на защищаемый ресурс. На практике это значит: если ресурс не умеет проверять токен (сторонний API, файловая система, отправка письма), то никакая распределённая блокировка не даст корректности, и решение придётся строить на идемпотентности операции.

Суть: Redlock — алгоритм захвата блокировки на N (обычно 5) независимых мастерах Redis: блокировка считается взятой при успехе на большинстве и в пределах TTL. Клеппман показал, что он опирается на предположения о часах и не спасает от пауз процесса, а главное — не выдаёт fencing token, поэтому для корректности не годится.

Как работает Redlock

  1. Клиент запоминает время и последовательно пытается захватить ключ на всех N инстансах с малым таймаутом на каждый.
  2. Блокировка взята, если получена на большинстве (N/2+1) и суммарно потрачено меньше TTL.
  3. Эффективный срок владения = TTL − потраченное время − поправка на дрейф часов.
  4. При неуспехе клиент снимает блокировку со всех инстансов, где успел взять.

Redlock нужен, чтобы пережить падение отдельных инстансов без Sentinel и не потерять блокировку при failover (при обычной репликации блокировка, взятая на мастере, может не доехать до реплики, которую промоутят).

Критика (Kleppmann, 2016)

  1. Сначала определи цель. Блокировка для эффективности (не делать работу дважды) или для корректности (двойное выполнение недопустимо)? Для эффективности хватит одиночного Redis, Redlock избыточен. Для корректности он не подходит.
  2. Зависимость от часов. Алгоритм считает истечение по времени и предполагает ограниченный дрейф. Но время может скакнуть: NTP-коррекция, живая миграция ВМ, ручная установка часов. Система, чья корректность зависит от синхронности часов, в асинхронной модели небезопасна.
  3. Паузы процесса. Даже с идеальными часами держателя останавливает STW-пауза, своп или заморозка контейнера. После паузы он не знает, что потерял блокировку. Никакой алгоритм захвата этого не ловит.
  4. Нет fencing token. Лечится это только монотонным токеном, который проверяет ресурс. Redlock не может его выдать: у него N независимых инстансов без общего согласованного счётчика. Системы с консенсусом выдают: ZooKeeper — zxid, etcd — revision.
  5. Ответ Санфилиппо. Предположения о часах ограничены и практичны; проблема пауз относится к любой блокировке, включая ZooKeeper, а не только к Redlock. Консенсуса в сообществе нет до сих пор.
Правильная позиция

«Если блокировка нужна для эффективности, беру простой SET NX PX на одном Redis с Lua-освобождением. Если для корректности, Redis не подходит в принципе, и я выбираю из трёх вариантов: fencing token, проверяемый ресурсом; идемпотентная операция; или механизм в самой БД — уникальный индекс, условный UPDATE, SELECT ... FOR UPDATE SKIP LOCKED, advisory lock. Последнее обычно и проще, и надёжнее, потому что блокировка живёт в той же транзакции, что и данные.»

Суть: pub/sub — at-most-once без хранения: нет подписчика — сообщение исчезло навсегда. Streams — лог с consumer group, PEL и XACK, то есть нормальная at-least-once очередь в памяти. Kafka/RabbitMQ — когда нужен долгий retention, replay, много независимых потребителей и настоящая durability.
Pub/SubStreamsKafka / RabbitMQ
Хранениенетлог в памяти + MAXLENдиск, retention
Гарантияat-most-onceat-least-once (XACK) at-least-once, у Kafka — транзакции
ПодтверждениянетPEL, XACK, XAUTOCLAIMoffset / ack
Replayневозможенпо id (XRANGE)перемотка offset
Масштаб потребителейфанаут: все получают всё consumer group делитпартиции + группы

Что теряется в pub/sub

  • Нет персистентности. Сообщение получают только те, кто подписан прямо сейчас. Подписчик переподключался две секунды, и эти сообщения потеряны, причём он об этом не узнает.
  • Нет подтверждений. PUBLISH возвращает число подписчиков, которым отправлено, а не число обработавших.
  • Медленного подписчика отключают. При переполнении client-output-buffer-limit pubsub Redis рвёт соединение.
  • В кластере классический pub/sub рассылается по всем нодам; с Redis 7 есть шардированный SPUBLISH/SSUBSCRIBE.

Отсюда единственное корректное применение: сигналы, потеря которых безопасна. Так обычно инвалидируют локальные кэши поверх Redis: не дошло, значит ключ протухнет по TTL через секунду. Бизнес-события через pub/sub гарантированно и тихо теряются.

Когда Streams достаточно, а когда нужен брокер

  • Streams: объём помещается в память с запасом и есть MAXLEN; retention нужен часами; Redis уже есть, а заводить Kafka ради одной очереди избыточно; нужны подтверждения и переприсвоение зависших сообщений (XAUTOCLAIM).
  • Kafka: retention днями и неделями, replay, много независимых consumer group по одному логу, дисковая durability с acks=all, экосистема (Connect, схемы, compaction, транзакции).
  • RabbitMQ: когда нужна сложная маршрутизация — exchange, routing key, DLQ и TTL очередей из коробки.
Ошибка, о которой спрашивают чаще всего

«Сделаем очередь на pub/sub». Она будет работать в тестах и молча терять сообщения на каждом деплое, рестарте Redis и сетевом моргании. Если нужна очередь на Redis, бери Streams с consumer group и XACK либо list с BLMOVE в processing-список (чтобы задача не потерялась при падении воркера). Голый BRPOP тоже теряет задачу, если воркер умер между извлечением и обработкой.

Суть: клиент потокобезопасен и создаётся один на приложение — внутри него пул. Ключевые параметры: PoolSize, MinIdleConns, PoolTimeout, Dial/Read/WriteTimeout, MaxRetries. При исчерпании пула горутина ждёт до PoolTimeout и получает redis: connection pool timeout.
rdb := redis.NewClient(&redis.Options{
    Addr: "redis:6379",

    PoolSize:        50,               // дефолт: 10 * GOMAXPROCS
    MinIdleConns:    10,               // прогретые соединения, чтобы не платить за dial в пике
    ConnMaxIdleTime: 30 * time.Minute,
    ConnMaxLifetime: 0,                // 0 = не пересоздавать по возрасту
    PoolTimeout:     4 * time.Second,  // сколько ждать свободное соединение

    DialTimeout:  2 * time.Second,
    ReadTimeout:  500 * time.Millisecond,   // Redis быстрый, секунды тут не нужны
    WriteTimeout: 500 * time.Millisecond,

    MaxRetries:      3,
    MinRetryBackoff: 8 * time.Millisecond,
    MaxRetryBackoff: 512 * time.Millisecond,
})

Как подбирать PoolSize

Одна команда занимает одно соединение на время round-trip. Значит, нужный размер ≈ пиковая конкурентность обращений к Redis, а не число ядер. При латентности 0,3 мс одно соединение выдаёт около 3000 команд в секунду, то есть 50 соединений закрывают ~150 000 rps. Слишком большой пул тоже вреден: тысячи соединений съедают память и нагружают однопоточный сервер.

Что происходит при исчерпании

  • Горутина ждёт свободное соединение до PoolTimeout (по умолчанию ReadTimeout + 1s), затем получает ошибку пула.
  • Это почти всегда симптом, а не причина: либо пул мал, либо Redis отвечает медленно (большие ключи, медленные команды, блокирующая операция, вытеснение).
  • Мониторить через rdb.PoolStats(): Timeouts, TotalConns, IdleConns, StaleConns. На растущий Timeouts ставят алерт.
Две ловушки
  • Блокирующие команды съедают соединение целиком. BLPOP, XREAD BLOCK, подписка pub/sub держат сокет всё время ожидания. Двадцать воркеров на BLPOP навсегда занимают двадцать соединений. Для них заводят отдельный клиент, чтобы не выесть общий пул.
  • Ретраи и идемпотентность. MaxRetries включён по умолчанию. Для GET это безопасно, для INCR — нет: команда могла выполниться, а ответ потеряться, и повтор посчитает второй раз. Для неидемпотентных операций ретраи выключают (MaxRetries: -1) или делают операцию устойчивой к повтору.
Суть: пайплайн отправляет N команд одним пакетом и читает N ответов — экономит N−1 round-trip. Pipeline просто пакует команды и не атомарен: между ними могут выполниться чужие. TxPipeline оборачивает их в MULTI/EXEC — команды идут подряд как единый блок, но отката нет.

Зачем

При RTT 0,3 мс сто последовательных GET занимают 30 мс, и 99 % из них уходит на ожидание сети. Пайплайн обходится одним round-trip, около 0,4 мс. Это самый дешёвый способ ускорить работу с Redis, и его почти всегда забывают.

pipe := rdb.Pipeline()
cmds := make([]*redis.StringCmd, 0, len(ids))
for _, id := range ids {
    cmds = append(cmds, pipe.Get(ctx, keyOf(id)))
}
_, err := pipe.Exec(ctx)
// Exec вернёт ошибку, если упала хотя бы одна команда, включая redis.Nil.
if err != nil && !errors.Is(err, redis.Nil) {
    return nil, err
}
for _, c := range cmds {
    v, err := c.Result()
    if errors.Is(err, redis.Nil) {
        continue          // просто нет ключа
    }
    ...
}
pipe := rdb.TxPipeline()          // MULTI ... EXEC
incr := pipe.Incr(ctx, "counter")
pipe.Expire(ctx, "counter", time.Hour)
_, err := pipe.Exec(ctx)
fmt.Println(incr.Val())           // 1 — счётчик после первого INCR
                                  // (до Exec было бы 0: результаты приезжают в Exec)

Разница по пунктам

  • Оба дают один round-trip — в этом они одинаковы.
  • Pipeline просто собирает пакет команд, и Redis может выполнить между ними чужие.
  • TxPipeline = MULTI/EXEC: выполняются подряд, без чужих между ними. Но отката нет: если одна команда упала на этапе выполнения (например, INCR по строке), остальные всё равно выполнятся. В Redis транзакция означает «выполнить подряд», а не «всё или ничего».
  • Условную логику строят на WATCH + MULTI/EXEC (оптимистическая блокировка: EXEC вернёт nil, если наблюдаемый ключ изменился) или, что проще и надёжнее, на Lua-скрипте.
  • В кластере Pipeline разбивается по нодам автоматически, а TxPipeline требует все ключи в одном слоте — иначе CROSSSLOT.
Ошибка при разборе результатов

Многие проверяют только ошибку Exec и считают, что всё прошло. На деле Exec вернёт ошибку, если упала любая из команд, включая безобидный redis.Nil от отсутствующего ключа. Результат каждой команды надо разбирать отдельно, а ошибку Exec проверять, пропуская redis.Nil. Ещё одна ловушка: значения из команд доступны только после Exec, а до него объекты пусты.

Суть: Lua выполняется на сервере атомарно — это единственный способ получить настоящее «прочитать-проверить-записать». EVAL шлёт текст скрипта каждый раз, EVALSHA — только SHA1, экономя трафик. Если сервер потерял кэш скриптов, он вернёт NOSCRIPT, и нужен фолбэк на EVAL; redis.NewScript(...).Run() делает это автоматически.
// Объявляется один раз, на уровне пакета.
var rateLimit = redis.NewScript(`
local current = redis.call("INCR", KEYS[1])
if current == 1 then
    redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
    return 0
end
return 1`)

// Run: сначала EVALSHA, при NOSCRIPT сам вызывает EVAL и снова кеширует скрипт.
allowed, err := rateLimit.Run(ctx, rdb,
    []string{"rl:" + userID},                  // KEYS
    int64(time.Minute/time.Millisecond), 100,  // ARGV
).Int64()

Правила

  • Скрипт блокирует весь сервер. Redis однопоточный, и пока крутится долгий цикл в Lua, стоят все клиенты. Скрипты держат короткими, без обходов по всем ключам. Есть busy-reply-threshold и SCRIPT KILL, но это уже тушение пожара.
  • Ключи только через KEYS. Если зашить имя ключа в текст, кластер не сможет определить слот и правильно маршрутизировать вызов.
  • Все ключи скрипта должны лежать в одном слоте (hash tag), иначе CROSSSLOT.
  • Детерминированность. В старых версиях случайность вместе с записью запрещалась из-за репликации по командам; сейчас реплицируются эффекты, но привычка остаётся правильной.
  • Кэш скриптов не вечен. После рестарта или SCRIPT FLUSH SHA неизвестен серверу. Голый EvalSha без обработки NOSCRIPT однажды сломается — это классическая ошибка.
  • Redis 7 Functions (FUNCTION LOAD): именованные библиотеки, которые переживают рестарт и реплицируются. Их стоит упомянуть как современную альтернативу.

Где Lua незаменима

  • Освобождение и продление распределённой блокировки с проверкой значения.
  • Rate limiter: INCR + PEXPIRE + сравнение с лимитом одной атомарной операцией.
  • Условная запись в кэш: «положи, только если версия в значении новее текущей».
  • Инвалидация по тегу: прочитать set имён и удалить их одним вызовом.
Суть: redis.Nil — это не ошибка, а результат «ключа нет». Её проверяют через errors.Is, отличают от настоящих ошибок сети и не выпускают за пределы слоя кэша. Обе ветки — промах и ошибка — ведут в БД, но считаются разными метриками.
val, err := rdb.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
    // Ключа нет: штатный промах.
    metrics.CacheMiss.Inc()
    return loadFromDB(ctx, key)
case err != nil:
    // Настоящая ошибка: сеть, таймаут, пул. Мы не знаем, есть ключ или нет.
    metrics.CacheErrors.Inc()
    slog.Warn("cache unavailable", "err", err)
    return loadFromDB(ctx, key)     // деградация, а не отказ
default:
    metrics.CacheHit.Inc()
    return val, nil
}

Ошибки вокруг redis.Nil

  • err == redis.Nil. Сломается, как только кто-нибудь обернёт ошибку через fmt.Errorf("%w", err). Всегда errors.Is.
  • Возвращать redis.Nil наверх. Слой кэша обязан транслировать её в доменную ErrNotFound или просто в промах — иначе про Redis знает весь сервис, включая HTTP-обработчики.
  • Путать «нет ключа» и «не знаю». redis.Nil даёт определённый ответ, а сетевая ошибка оставляет неопределённость. Кэшировать вторую как «не найдено» нельзя.
  • Считать, что redis.Nil бывает только у GET. Её возвращают BLPOP по таймауту, ZSCORE отсутствующего элемента, HGET отсутствующего поля, LPOP пустого списка. А вот Exists и SetNX отдают 0/false без ошибки, что тоже сбивает с толку.

Остальные типовые ошибки go-redis

  1. Создавать клиент на каждый запрос. Клиент содержит пул и потокобезопасен: он создаётся один раз на приложение и закрывается при завершении.
  2. Ошибка кэша роняет запрос. Ошибка Redis должна вести в фолбэк, а не в 500-й ответ пользователю. Иначе кэш из ускорителя превращается в обязательную зависимость.
  3. Не проверять результат каждой команды в пайплайне. Ошибка Exec даёт только общий сигнал, а разбирать надо каждую команду.
  4. Забыть таймауты. Дефолтный ReadTimeout в 3 секунды (с go-redis v9.22 — 5) для Redis огромен: подвисший инстанс на эти секунды съест весь пул горутин обработчика. Ставь порядка сотен миллисекунд.
  5. Ретраить неидемпотентное. Автоматические ретраи go-redis могут удвоить INCR.
  6. Не закрывать pub/sub. PubSub надо явно Close(), иначе соединение и горутина живут вечно.
  7. Надеяться на контекст. По умолчанию go-redis v9 дедлайн ctx на сокет не переносит: ответ ждут по ReadTimeout, пока не включишь ContextTimeoutEnabled. А уже отправленная команда в Redis выполнится в любом случае.
Правило, которое стоит произнести

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