Кэширование и 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 МБ решает ту же задачу |
| Сложность отладки | «У меня старые данные» — воспроизводится только у одного пользователя, только пока живёт ключ |
Уровни кэширования на пути запроса
На пути к диску запрос пользователя проходит через несколько независимых кэшей. На собеседовании их часто просят перечислить — и кандидат почти всегда вспоминает два-три. Хороший ответ идёт сверху вниз и для каждого уровня называет: что там лежит, кто это инвалидирует и во что обходится промах.
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-аренах и не плодят указатели.
Метрики: 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 даётся дороже предыдущего.
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изINFORedis: рост вытеснений означает, что кэш меньше, чем рабочее множество, и hit rate будет деградировать сам собой.- Возраст отданных данных, если умеешь его считать, прямо меряет окно рассинхрона.
Когда кэш не нужен и когда он вредит
- Нет повторяемости ключей. Каждый запрос уникален (поиск по произвольным фильтрам, выгрузка отчётов по диапазонам). Hit rate будет 5 %, а каждый запрос получит лишний round-trip.
- Источник и так быстрый. Выборка по первичному ключу из прогретого буферного пула занимает 0,2–0,5 мс, столько же, сколько поход в Redis по сети. Выигрыша нет, сложность есть.
- Данные не имеют права быть устаревшими. Баланс перед списанием, остаток товара в момент оформления заказа, права доступа при проверке авторизации, статус блокировки пользователя. Здесь кэш грозит не «немного стухшими данными», а инцидентом безопасности или деньгами.
- Пишут чаще, чем читают. При соотношении 1 read / 1 write кэш инвалидируется быстрее, чем успевает пригодиться: получаем нагрузку и на БД, и на Redis.
- Кэш вместо индекса. Классический анти-паттерн: запрос делает seq scan на 3 секунды, его закрывают кэшем — и живут так, пока однажды кэш не окажется пустым.
- Кэш вместо исправления N+1. Тот же случай: лечат это
IN (...)или джойн, а не «закэшируем каждую из 200 подгрузок».
Отдельный класс проблем: запрашивают ключ, которого нет в БД. Кэш промахивается, идём в
источник, тот возвращает пусто, класть в кэш нечего — и так на каждый запрос. Если так делает бот,
перебирающий 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. Пустой кэш после деплоя отправляет всю нагрузку в БД разом.
- Маскировка. Кэш прячет неоптимизированный запрос до того дня, когда промахнётся.
«Перед тем как ставить кэш, я смотрю на профиль обращений: есть ли перекос по ключам и какое отношение чтений к записям. Если перекоса нет или пишут почти так же часто, как читают, кэш принесёт сложность без выигрыша — и правильнее сначала посмотреть план запроса.»
Сверху вниз
- Клиент / браузер. Управляется заголовками
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 на десятки секунд. Баланс не кэшируют вообще.
Локальный
Обращение к мапе занимает 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.
Почему быстрый: пять причин, а не одна
- Всё в оперативной памяти. На пути запроса нет ни одного обращения к диску. Даже AOF пишется в буфер и сбрасывается фоново (по умолчанию раз в секунду).
- Команды выполняются в одном потоке. Ни мьютексов, ни атомиков, ни переключений контекста, ни борьбы за строки кэша между ядрами. Каждая команда атомарна просто потому, что во время её выполнения ничего другого не происходит. Заодно исчезает целый класс багов, а такты не уходят на синхронизацию.
- Мультиплексирование ввода-вывода. Один поток обслуживает десятки тысяч соединений через
epoll(Linux),kqueue(BSD/macOS),evport(Solaris) илиselectкак запасной вариант. Никакого «поток на клиента». - Дешёвый протокол RESP. Текстово-бинарный, длины передаются явно, парсер линейный, без разбора грамматики и без рефлексии.
- Специализированные компактные кодировки. Маленький хеш хранится не хеш-таблицей, а
listpack: это сплошной кусок памяти, который влезает в кэш процессора. Множество целых чисел лежит отсортированнымintset, а короткая строка ложится вembstrодним куском вместе с заголовком объекта.
И отдельно: у Redis нет планировщика запросов, нет MVCC, нет журнала транзакций на пути записи, нет проверки ограничений целостности. Он не делает почти ничего из того, на что тратит время СУБД.
«В Redis время уходит не на работу с данными, а на сеть. Сама команда GET занимает
доли микросекунды: хеш от ключа и разыменование указателя. Round-trip по сети внутри дата-центра
стоит сотни микросекунд. Поэтому, чтобы ускорить работу с Redis, надо не оптимизировать команды,
а сокращать число round-trip: пайплайнинг, MGET, Lua.»
Что именно значит «однопоточный»
Формулировка, которую стоит держать наготове: Redis выполняет команды в один поток; всё остальное может быть многопоточным. По версиям:
| Версия | Что вынесли из главного потока |
|---|---|
| всегда | BGSAVE и BGREWRITEAOF — через fork(), то есть отдельный процесс, а не поток |
| 2.4+ | отдельные потоки под fsync AOF и медленное закрытие файлов |
| 4.0 | UNLINK и lazyfree-lazy-*: освобождение памяти больших ключей уходит в фоновый поток |
| 6.0 | io-threads: чтение из сокетов, парсинг RESP и запись ответов можно распараллелить (по умолчанию выключено; до 8.0 чтение включали отдельным флагом). Выполнение команд — нет |
| 7.0 | sharded 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-строк.
> из 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, RPOP | O(1) | рабочая лошадка, можно всё |
ZADD, ZSCORE, ZRANK, ZINCRBY | O(log N), у ZSCORE O(1) | skiplist; практически бесплатно |
ZRANGE, LRANGE, SRANDMEMBER count | O(log N + M), у LRANGE O(S + M), у SRANDMEMBER O(M) | M — сколько вернули, а не размер ключа |
MGET k1..kN, DEL k1..kN | O(N) по числу ключей | нормально, если N разумный |
HGETALL, SMEMBERS, LRANGE key 0 -1 | O(N) по размеру ключа | опасно на большом ключе: и блокировка, и трафик |
KEYS pattern | O(N) по всему keyspace | запрещено в проде |
FLUSHALL / FLUSHDB | O(N) | без ASYNC до Redis 7.4 освобождал всё синхронно; с 7.4 память освобождает фоновый поток, ждёт только вызвавший клиент |
SORT | O(N log N) | плюс возможные лукапы BY/GET |
SUNION, SINTER, ZUNIONSTORE | O(N) / O(N*M) | считать заранее, не в горячем пути |
DEBUG SLEEP, DEBUG OBJECT | — | с Redis 7.0 выключены по умолчанию; если включали, ставить в ACL-запрет вместе с 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
- Ключ, который жил от начала до конца итерации, вернётся хотя бы один раз.
- Ключ может вернуться несколько раз, так что обработчик обязан быть идемпотентным.
- Ключи, добавленные или удалённые во время обхода, могут вернуться, а могут нет.
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
| Redis | Memcached | |
|---|---|---|
| Модель данных | строки, хеши, списки, множества, zset, битмапы, HLL, streams, geo | только строка (opaque blob) |
| Потоки | один поток на команды | честно многопоточный, масштабируется по ядрам |
| Персистентность | RDB + AOF | нет вообще, только память |
| Репликация / HA | асинхронная репликация, Sentinel, Cluster | нет; шардирование делает клиент |
| Атомарные операции | любые, плюс Lua и транзакции | incr/decr, cas, add, append: простые операции над одним ключом |
| Вытеснение | 8 политик maxmemory-policy, с Redis 8.6 — 10 | slab-аллокатор + 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Что это
Сервер структур данных, в отличие от «мапы по сети»: ключ — бинарная строка, значение — строка, хеш, список, множество, 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 появились отдельные потоки под
fsyncAOF и закрытие файлов. - С 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.
\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.
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, где эти ключи лежат явно.
Когда другой клиент делает 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 есть
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, GETDEL | O(1) | кэш сериализованного объекта, счётчики, rate limit, лок |
| hash | словарь поле → значение внутри одного ключа | HSET, HGET, HMGET, HINCRBY, HDEL, HSCAN, HEXPIRE | O(1) на поле | сессии, объекты, где нужно менять одно поле |
| list | двусвязный список (quicklist из listpack-узлов) | LPUSH, RPOP, BRPOP, LMOVE, LRANGE, LTRIM, LLEN | O(1) по краям, O(N) в середину | очередь задач, стек, «последние N событий» |
| set | множество уникальных строк без порядка | SADD, SISMEMBER, SREM, SPOP, SINTER, SINTERCARD, SSCAN | O(1) добавить/проверить | теги, множества прав, «общие друзья», дедупликация |
| sorted set | множество + вещественный score, всегда отсортировано | ZADD, ZSCORE, ZRANK, ZRANGE, ZRANGEBYSCORE, ZREMRANGEBYSCORE, ZPOPMIN | O(log N) | лидерборд, отложенные задачи, sliding-window лимиты, индекс по времени |
| bitmap | та же строка, адресуемая по битам | SETBIT, GETBIT, BITCOUNT, BITPOS, BITOP, BITFIELD | O(1) бит, O(N) счёт | активность пользователей по дням, компактные флаги |
| HyperLogLog | вероятностный счётчик уникальных, до 12 КБ на любой объём | PFADD, PFCOUNT, PFMERGE | O(1) | уникальные посетители, уникальные IP, охват |
| stream | append-only лог с ID и consumer group | XADD, XREAD, XREADGROUP, XACK, XAUTOCLAIM, XTRIM | O(1) добавить, O(log N) поиск | лента событий, очередь с подтверждениями и переигровкой |
| geo | надстройка над zset: score = geohash | GEOADD, GEOSEARCH, GEODIST | O(log N) | «ближайшие точки к координате» |
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 в string | hash | |
|---|---|---|
| Частичное чтение | нет, тянем всё | HMGET |
| Частичное обновление | read-modify-write: гонка, нужен WATCH или Lua | HSET/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 даёт 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 — число
элементов, которые перепрыгивает ссылка. По нему ранг считается без прохода по списку.
# Лидерборд
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) — список выданных, но ещё не подтверждённых записей.
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, минимум накладных |
| Счётчик, лимит, генератор id | string + INCR | атомарно, O(1) |
| Объект, у которого меняют поля | hash | частичное чтение и атомарный HINCRBY |
| Сессия | hash + EXPIRE | поля разной природы, скользящий TTL |
| Простая очередь задач | list + BRPOP | O(1), пять минут работы |
| Очередь с подтверждениями и переигровкой | stream + consumer group | PEL, XACK, XAUTOCLAIM |
| Последние N событий | list + LTRIM | постоянный размер без чистки |
| Уникальность, теги, «есть ли в множестве» | set | SISMEMBER за O(1) |
| Рейтинг, топ-N, «моё место» | sorted set | ранг и диапазон за O(log N) |
| Отложенные задачи, планировщик | sorted set, score = время | ZRANGEBYSCORE + ZREM в Lua |
| Точный подсчёт уникальных при плотных числовых id | bitmap | 1 бит на пользователя |
| Приблизительный подсчёт уникальных при любых id | HyperLogLog | до 12 КБ на любой объём |
| Гео-поиск «рядом со мной» | geo (поверх zset) | GEOSEARCH по радиусу |
Частая ошибка проектирования: «положим лайки в 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- 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
- Частичное чтение.
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-ю ошибку.
Раньше главным минусом hash был TTL только на весь ключ. В 7.4 появились
HEXPIRE, HPEXPIRE, HTTL, HPERSIST со сроком
жизни для отдельных полей. Упомяни их: так видно, что ты следишь за
версиями, а не пересказываешь статью 2015 года.
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.
Redis Streams хороши, пока очередь помещается в память и потеря последних сотен миллисекунд при failover допустима (репликация асинхронная — см. 7.6). Когда нужны длительное хранение, гарантии на диске, транзакции, сложная маршрутизация или трафик, не влезающий в RAM, берут Kafka или RabbitMQ. Хорошая формулировка: «Streams — это очередь на том, что уже стоит в инфраструктуре; брокер нужен, когда очередь становится частью контракта между сервисами».
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
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.
<мс>-<порядковый>. 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 (lazy loading) по шагам
Эта схема стоит в 90 % продакшенов: ей не нужно ничего, кроме клиента Redis.
- Чтение.
GET key. Есть значение — отдали, конец. - Промах. Клиент возвращает
redis.Nil. Идём в БД, к источнику правды. - Заполнение. Сериализуем и
SET key value EX ttl. Ошибку записи в кэш глотаем: не закэшировали — просто будет ещё один промах. - Запись. Сначала
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. Цена
прямая: подтверждённая пользователю запись может быть потеряна, если кэш умрёт до слива.
Плюс в БД временно лежит старое значение, а значит любой сторонний потребитель (аналитика,
репликация, другой сервис) читает неправду.
Спросят: «а если между кэшем и БД встанет очередь на 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, а это доли миллисекунды. Но одна гонка
остаётся, и именно её просят разобрать на доске.
Почему удалять ключ, а не перезаписывать его новым значением
Соблазн понятен: раз мы уже знаем новое значение, положим его в кэш сразу — и промаха не будет. Три причины так не делать.
- Гонка двух писателей. Порядок применения в БД и порядок записи в кэш не совпадают, потому что это две независимые операции по сети. В итоге в кэше остаётся значение проигравшего.
- Кэшируем то, что не читают. Когда пишут больше, чем читают, мы забиваем память значениями, которые могут не понадобиться ни разу.
- Значение в кэше часто не равно строке в БД. Там лежит агрегат, join, отрендеренный кусок, и писатель просто не знает, что именно туда положить. Удаление корректно всегда.
«Сначала БД, потом удалить ключ. Не обновить — удалить: 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 (lazy loading). Приложение делает
GET, приnilидёт в БД, кладёт результат в кэш. При записи пишет в БД и удаляет ключ. Кэш ничего не знает про БД, БД ничего не знает про кэш. Плюс: если Redis лёг, сервис отвечает медленнее, но работает. Минус: логика размазана по всем местам, где читают сущность; есть окно гонки при заполнении. - Read-through. Приложение обращается только к кэшу; сам кэш при промахе вызывает
зарегистрированный loader и заполняет себя. Плюс: логика загрузки в одном месте, туда
же легко навесить singleflight, метрики, отрицательное кэширование. Минус: нужен слой,
который это умеет (в Go это своя обёртка или
otterv2 с его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 не даёт атомарности: это две операции по сети, и между ними тоже бывает падение.»
Главное достоинство — деградация, а не отказ
В 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) // единственный источник правды
Три места, где ломается
- Stampede. Истёк популярный ключ — и сотни горутин разом пошли в БД за одним и тем же. Лечится singleflight или блокировкой (глава 7.5).
- Гонка заполнения. Читатель прочитал старое значение из БД, «завис», а в это время писатель закоммитил новое и удалил ключ; читатель просыпается и кладёт в кэш устаревшее. Разбор на оси времени есть выше в этой главе.
- Расползание логики. Через полгода в кодовой базе три места, которые кладут
user:42с разным TTL и разной сериализацией, и одно из них забыли обновить при смене схемы. Лечится тем, что кэш живёт только в репозитории и нигде больше.
Сказать «при ошибке Redis возвращаем ошибку пользователю». Так кэш из ускорителя превращается в новую обязательную зависимость со своей доступностью. Правильно наоборот: если фолбэк в БД под полной нагрузкой её убьёт, то защищаться надо ограничителем конкурентности и circuit breaker к БД, а не отказом в обслуживании.
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 есть у каждого ключа. Без исключений. Это страховка от «мы где-то забыли
инвалидировать» и от утечки памяти, а не механизм свежести. Значение
берут из требования к свежести: лента 30 секунд, профиль 5 минут, справочник валют час. TTL всегда с jitter:
base + rand(0..base/5), чтобы ключи, залитые одной пачкой, не истекли одновременно. - По событию инвалидируют там, где важна реакция. После успешного коммита транзакции репозиторий удаляет затронутые ключи. Именно удаляет, а не перезаписывает, и именно после коммита, а не внутри транзакции: иначе при откате в кэше окажется то, чего в БД нет.
- Когда одна запись бьёт по многим ключам, нужны ключи-теги или версия. Изменили
товар — протухли карточка, три списка, два агрегата. Перечислять их руками невозможно,
поэтому в ключ вшивают версию сущности, а инвалидируют через
INCRверсии. - Глобальная версия схемы в префиксе. Поменялся формат значения — меняем префикс
(
v3:user:42). Старые ключи никто не читает, они уходят по TTL. Заодно исчезает вечная проблема «выкатили новый код, а он читает старый формат». - Локальному слою нужен очень короткий 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
- Гонка двух писателей. B1 пишет v1, B2 пишет v2; в БД порядок один, а в кэш они
попадают по сети в другом порядке, и в кэше остаётся v1 при БД v2. С
DELтакого нет: два удаления в любом порядке дают одно и то же состояние (пусто), а следующий читатель возьмёт актуальное из БД. Весь смысл в том, что удаление идемпотентно и коммутативно. - Писатель часто не знает, что класть. В кэше лежит не строка таблицы, а агрегат, join или готовый DTO. Если собирать его в коде записи, придётся дублировать логику чтения.
- Не греем то, что не читают. Когда записей больше, чем чтений,
SETзабивает память значениями, за которыми никто не придёт.
SET вместо DEL оправдан, когда ключ очень горячий, а значение
дорого пересобирать: удалишь такой ключ и гарантированно получишь stampede на следующей же
миллисекунде. Тогда пишут новое значение, но защищаются от гонки писателей: либо
SET через Lua с проверкой версии (if new_ver > cur_ver then set),
либо кладут версию прямо в значение и отбрасывают запись более старой версии.
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, который «схлопывает» одновременные одинаковые запросы в один. Как он устроен, разберём в следующем разделе.
Решения: пять приёмов от простого к сложному
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)
})
- Общая ошибка на всех. Если лидер получил таймаут, ошибку разом получат все 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, известный список горячих ключей |
Не одним словом. «Базово — 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, только в максимальном масштабе.
Как размазывать
- Локальный кэш поверх Redis — первое и лучшее. Держим значение в памяти процесса
1–3 секунды. При 20 подах и TTL в секунду до Redis доходит 20 запросов в секунду вместо
50 000, то есть в тысячи раз меньше. Платишь окном рассинхрона длиной с локальный TTL, но
для баннера или фичефлага это допустимо ровно всегда. Взять можно
ristretto,otter,hashicorp/golang-lruили просто мапу подRWMutexс полем времени. - Чтение с реплик. В Redis Cluster клиент может читать со слейвов (
READONLY, в go-redis этоRouteRandomly/ReadOnly: true). Нагрузка на чтение делится на число реплик слота. Реплика асинхронна, так что можно прочитать чуть устаревшее значение, но для горячего кэша это обычно не проблема. - Реплики ключа с суффиксом. Пишем N копий (
banner:main#0…banner:main#15), читаем случайную. Разные суффиксы дают разные слоты и разные ноды. Это работает всегда, но платить придётся памятью (×N) и инвалидацией: удалять надо все N копий, пайплайном по нодам: Lua-скрипт в кластере работает только с ключами одного слота. И осторожно: если сделать наоборот и загнать копии в один слот через hash tag, приём теряет смысл целиком. - 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, который вообще не трогает продовый инстанс.
Что делать
- Удалять через
UNLINK, а неDEL.UNLINKмгновенно отцепляет ключ от пространства имён, а память освобождает фоновый поток (lazy free). Для больших коллекций это разница между «сервер встал на 300 мс» и «не заметили». Заодно включаютlazyfree-lazy-expire yes,lazyfree-lazy-eviction yesиlazyfree-lazy-server-del yes, чтобы то же самое происходило при истечении TTL и при вытеснении. - Не читать целиком. Вместо
HGETALLбериHMGETнужных полей илиHSCANпорциями, вместоLRANGE 0 -1листай постранично черезLRANGE start stop, а вместоSMEMBERSиспользуйSSCANилиSISMEMBER, если нужен только факт наличия. - Разбивать на части (шардировать ключ). Хеш из миллиона полей превращают в 100 хешей
по 10 000:
obj:{42}:part:0…obj:{42}:part:99, номер части считают какhash(field) mod 100. Фигурные скобки здесь задают hash tag: он держит все части в одном слоте, если над ними нужны мультиключевые операции. Если же цель в том, чтобы размазать нагрузку, hash tag не ставят. - Ограничивать рост на входе. Список обрезают:
LPUSH+LTRIM key 0 999одной транзакцией. Stream ограничивают черезXADD ... MAXLEN ~ 10000. Sorted set периодически чистятZREMRANGEBYRANK. Это дешевле, чем потом чинить разросшийся ключ. - Пересмотреть модель. Большой ключ часто выдаёт, что в 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 | 1 запрос в БД на ключ на процесс | процесс | по умолчанию |
| Распределённая блокировка | 1 запрос на весь кластер | кластер | очень дорогой пересчёт, много подов |
| XFetch | обновление до истечения, залпа нет | кластер | дорогой пересчёт + ровный высокий RPS |
| Вечный ключ + фон | клиент никогда не ждёт БД | кластер | витрины и конфиги, где stale допустим |
Порядок внедрения
- Jitter в TTL. Одна строка кода ломает синхронное истечение. Ставят сразу и везде.
- singleflight в репозитории. Ещё десять строк схлопывают залп внутри пода. При 20 подах 200 запросов превращаются в 20 — этого БД уже не замечает.
- Ограничитель конкурентности к БД (семафор на N слотов или размер пула соединений как естественный ограничитель) плюс circuit breaker. Только этот механизм работает и при полном сбросе кэша, и при недоступном Redis.
- И только если пересчёт действительно дорогой, берут 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
досрочно отпускает ключ.Четыре ловушки
- Общая ошибка. Лидер получил таймаут — ошибку получают все 200 ждущих. Вместо залпа в БД клиент получает залп 500-х. Смягчают коротким таймаутом лидера и ретраем на уровне вызывающего.
- Контекст лидера. Если внутрь
fnпередатьctxпервого запроса, а этот клиент отвалится,ctxотменится, и работа сорвётся у всех ждущих, хотя их запросы живы. Внутрь передают независимый контекст (context.WithoutCancelили новыйcontext.Background()с собственным таймаутом), а отмену обрабатывают снаружи черезDoChan. - Залипание.
Doждёт лидера сколько угодно. Один поход в БД без таймаута — и по этому ключу копятся все запросы сервиса. - Мутируемый общий результат. Все ждущие получают одно и то же значение
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 уже достаточно, и это стоит прямо сказать.
Формула
обновляем, если: 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 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 по каждой ноде отдельно.
Способы, по порядку применения
- Локальный кэш поверх Redis (1–3 секунды). Его почти всегда достаточно, и почти всегда с него правильно начинать. При 20 подах и TTL в секунду 50 000 rps превращаются в 20 rps до Redis. Платишь окном рассинхрона длиной с локальный TTL.
- Чтение с реплик. В кластере это
READONLY, в go-redisReadOnly: trueиRouteRandomly. Нагрузка делится на число реплик, но данные бывают чуть более устаревшими (репликация асинхронная). - Реплики ключа с суффиксом. Пишем N копий
key#0..key#N-1, читаем случайную: разные суффиксы дают разные слоты. Работает всегда, но стоит памяти ×N и инвалидации всех копий (пайплайном по нодам: одним Lua-скриптом нельзя, копии в разных слотах). Hash tag здесь ставить нельзя: он загонит все копии обратно в один слот и убьёт смысл приёма. - 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, а сумму
собирают при чтении одним пайплайном. Это готовый ответ на уточняющий
вопрос, который задают почти всегда.
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 *. Он проходит всё пространство
ключей в главном потоке, и на миллионе ключей сервер полностью встаёт на сотню миллисекунд и дольше.
Что делать
UNLINKвместоDEL. Отцепляет ключ мгновенно, память освобождает фоновый поток. Заодно включаютlazyfree-lazy-expire,lazyfree-lazy-eviction,lazyfree-lazy-server-del, чтобы то же происходило при истечении и вытеснении.- Не читать целиком:
HMGET/HSCANвместоHGETALL, постраничныйLRANGEвместоLRANGE 0 -1,SISMEMBER/SSCANвместоSMEMBERS. - Разбить на части:
obj:{42}:part:N, гдеN = hash(field) mod 100. Фигурные скобки ставят как hash tag, если части должны лежать в одном слоте для мультиключевых операций. - Ограничить рост на входе:
LPUSH+LTRIM key 0 999одной транзакцией,XADD ... MAXLEN ~ 10000, периодическийZREMRANGEBYRANK. - Пересмотреть модель. Часто большой ключ означает, что в кэш положили то, чему место в БД или объектном хранилище. В 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-lfuRedis 4+ |
выбрасывает редко используемый ключ из всех | кэш с устойчивым ядром горячих ключей и потоком разовых обращений — лучше LRU |
allkeys-random |
выбрасывает случайный ключ | когда все ключи равноценны, а экономия на учёте важна; редко |
volatile-lru |
LRU, но только среди ключей с TTL | смешанная нагрузка: часть данных — кэш (с TTL), часть — постоянная (без TTL) |
volatile-lfu |
LFU среди ключей с TTL | то же, но с профилем под LFU |
volatile-random |
случайный среди ключей с TTL | редко |
volatile-ttl |
выбрасывает тот, которому осталось жить меньше всего | когда TTL реально отражает ценность данных |
Если при volatile-* у ключей не окажется TTL, выбрасывать будет нечего — и
Redis поведёт себя как noeviction: писать станет невозможно. Авария
классическая: поставили volatile-lru, часть кода забыла TTL, память заполнилась
«бессмертными» ключами, и сервис лёг с ошибками OOM, хотя политика вытеснения «вроде
настроена». Отсюда правило из главы 7.4: TTL у каждого ключа, без исключений.
- Что входит в лимит.
maxmemoryограничивает данные, а буферы клиентов (особенно output buffer у pub/sub и репликации) и фрагментация памяти живут сверх него. Поэтому лимит ставят примерно в 60–70 % физической памяти машины, а не в 95 %. - Вытеснение идёт в главном потоке. При активном вытеснении Redis тратит
время на выбор жертв и освобождение памяти, и латентность растёт.
lazyfree-lazy-eviction yesпереносит освобождение в фоновый поток. - Как понять, что вытеснение идёт.
INFO stats→evicted_keys. Ненулевой и растущий счётчик при падающем hit rate прямо говорит, что кэшу мало памяти. На эту метрику стоит повесить алерт.
LRU против LFU
LRU выбрасывает то, к чему дольше всего не обращались, а LFU то, к чему обращались реже всего. Разница видна на конкретном профиле нагрузки: LRU ломается на «сканирующем» трафике, когда поток разовых обращений вытесняет устойчиво горячие ключи.
Как 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 был бы фатально
консервативным: вчерашний хит вечно вытеснял бы сегодняшний.
«По умолчанию ставлю 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 секунд) | до десятков секунд | нулевая |
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.
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 состоит из отдельных процессов-наблюдателей (нечётное число, минимум три), которые следят за мастером и репликами. По шагам:
- Мониторинг. Каждый sentinel пингует мастера и, если тот молчит дольше
down-after-milliseconds, помечает его субъективно недоступным (SDOWN). - Кворум. Кворумом называют минимальное число голосов, при котором решение
считается принятым от имени всего кластера. Он нужен, чтобы один потерявший сеть наблюдатель
не мог единолично объявить мастера мёртвым. Sentinel опрашивает остальных, и если о
недоступности заявили не меньше
quorumиз них, статус повышается до объективно недоступного (ODOWN). - Выборы лидера. Sentinel-ы выбирают среди себя лидера (вариант Raft), который проведёт failover. Для выборов нужно большинство sentinel-ов — и это важнее кворума: при трёх sentinel-ах нужны два живых.
- Промоушен. Лидер выбирает лучшую реплику (по приоритету, объёму полученных данных,
run id), делает ей
REPLICAOF NO ONE, перенастраивает остальные реплики на неё и обновляет конфигурацию. - Уведомление клиентов. Клиенты спрашивают у Sentinel адрес текущего мастера, а команды
шлют уже ему напрямую. В go-redis для этого есть
NewFailoverClient.
Всё, что мастер успел подтвердить клиенту, но не успел отправить реплике. Случай не
редкий: при обрыве сети мастер ещё какое-то время может принимать записи (пока не сработает
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 хватает с запасом.
- Клиент сам знает карту слотов и ходит сразу на нужную ноду. Прокси нет: шардинг клиентский, и протокол его поддерживает.
Ограничения кластера
- Мультиключевые операции только внутри слота.
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 не даёт строгих гарантий
Сведём всё вместе: ради этого вывода и задают вопросы по всей секции.
- Данные могут исчезнуть по политике вытеснения. При
allkeys-*Redis имеет полное право выбросить любой твой ключ до истечения TTL. Значит, приложение обязано корректно работать без ключа в любой момент. - Данные могут потеряться при падении. При
everysecпропадёт до секунды записей, без AOF всё с последнего снимка. - Подтверждённая запись может потеряться при failover. Репликация асинхронная; окно между ответом клиенту и доставкой реплике не защищено ничем.
- Возможен split brain. Старый мастер в изолированном сегменте продолжает принимать записи, которые потом будут отброшены.
- Транзакции не такие, как в БД.
MULTI/EXECдаёт атомарность выполнения и изоляцию (команды идут подряд без чужих между ними), но не даёт отката: если одна команда упала на этапе выполнения, остальные всё равно выполнятся.
- Redis служит кэшем и вспомогательным состоянием, а не источником правды. Всё, что нельзя потерять, должно иметь копию в БД или в брокере.
- Любой промах считается штатной ситуацией, а не ошибкой. Код без ветки «в кэше нет» сломан.
- Распределённая блокировка на Redis работает как оптимизация (не делать одну работу дважды), а не как гарантия взаимного исключения. Корректность обеспечивают идемпотентностью операции или fencing token — монотонно растущим номером захвата, который проверяет сам защищаемый ресурс и отвергает всё, что меньше уже виденного (разбор в главе 7.7).
- Если Redis используют как очередь или хранилище (Streams, сессии), политику ставят
noeviction, персистентность включают, а потерю секунды данных явно согласуют с продуктом.
Вопросы
9maxmemory-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 означает, что кэшу мало памяти.
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.»
everysec теряет до секунды, но файл больше и старт медленнее. Правильный
ответ на «что выбрать» — оба в гибридном режиме.| RDB | AOF | |
|---|---|---|
| Что пишет | снимок всего датасета | каждую изменяющую команду |
| Потеря при падении | всё с последнего снимка — минуты | 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() со всеми вытекающими рисками по памяти.
BGSAVE и AOF rewrite делают fork(): дочерний процесс делит
страницы с родителем, но каждая изменённая страница копируется. При высокой доле записей
потребление памяти может вырасти почти вдвое — типичная причина OOM-kill там, где
maxmemory выставлен близко к RAM. Сам fork() на десятках
гигабайт занимает сотни миллисекунд (копирование таблицы страниц), и всё это время сервер
не отвечает. Практика: maxmemory ≤ 60–70 % RAM,
vm.overcommit_memory = 1, выключенные transparent huge pages и снятие
снимков с реплики, а не с мастера.
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. Это сознательный размен доступности на снижение риска потери.
Сценарий потери
- Клиент делает
SET k v, мастер записал в память и ответилOK. - Мастер падает (или изолируется сетью) до того, как команда ушла реплике.
- Sentinel проводит failover, реплика становится мастером — без этой записи.
- Старый мастер возвращается, становится репликой нового и отбрасывает свои неотправленные записи.
«Redis сознательно выбрал доступность и латентность вместо строгой согласованности:
репликация асинхронная, поэтому окно между ответом клиенту и доставкой реплике ничем не
защищено. Практический вывод: всё, что нельзя потерять, обязано иметь копию за пределами
Redis. Снизить риск помогут min-replicas-to-write и
WAIT, но они меняют доступность на durability и гарантии не дают.»
Пять шагов failover
- SDOWN. Sentinel не получил ответа дольше
down-after-milliseconds— помечает мастера субъективно недоступным. - ODOWN. Опрашивает других sentinel-ов; если согласны не меньше
quorumиз них, статус становится объективно недоступным. - Выборы лидера. Sentinel-ы выбирают, кто проведёт failover (вариант Raft). Для этого нужно большинство всех sentinel-ов, а не только кворум: при трёх нужны два живых.
- Промоушен. Лидер выбирает лучшую реплику (приоритет
replica-priority, объём полученных данных, run id), даёт ейREPLICAOF NO ONEи перенастраивает остальные реплики на неё. - Уведомление. Клиенты спрашивают у Sentinel адрес текущего мастера; в go-redis это
redis.NewFailoverClientсо списком sentinel-адресов и именем мастера.
Их путают почти всегда. quorum говорит, сколько sentinel-ов должны согласиться,
что мастер недоступен. Большинство нужно, чтобы провести failover.
Поэтому quorum можно поставить равным 1, но failover всё равно не произойдёт,
если живо меньше половины sentinel-ов. Отсюда и требование нечётного числа от трёх и
размещения их в разных зонах отказа: два sentinel-а в одной стойке отказоустойчивости не дают.
Всё, что мастер подтвердил клиенту, но не успел отправить реплике. Плюс возможен split
brain: изолированный старый мастер продолжает принимать записи (пока не сработает
min-replicas-to-write), а после восстановления сети становится репликой и
отбрасывает их. Из-за этого распределённая блокировка на одном Redis не гарантирует
взаимного исключения: после failover два клиента могут одновременно считать
себя держателями.
slot = CRC16(key) mod 16384, каждый мастер владеет диапазоном слотов. Клиент сам
держит карту слотов и ходит на нужную ноду; прокси нет. MOVED — слот навсегда
переехал, надо обновить карту. ASK — слот прямо сейчас мигрирует, редирект
разовый и карту трогать нельзя.Почему именно 16384
Карта владения слотами передаётся в каждом сообщении gossip-протокола как битовая маска. 16384 бита занимают 2 КБ на сообщение, а 65536 заняли бы 8 КБ. При реальных размерах кластера (десятки нод) больше слотов не нужно, а трафик служебного протокола вырос бы вчетверо.
MOVED против ASK
MOVED slot addr | ASK slot addr | |
|---|---|---|
| Смысл | слот принадлежит другой ноде постоянно | слот мигрирует, конкретно этот ключ уже на приёмнике |
| Действие клиента | повторить на новой ноде и обновить карту слотов | послать ASKING + команду на указанную ноду один раз,
карту не обновлять |
| Когда | после ребалансировки или failover | во время активной миграции слота |
Почему ASK не обновляет карту: миграция идёт по ключам, и остальные ключи слота
пока лежат на исходной ноде. Если бы клиент переключил весь слот, он начал бы промахиваться
по ещё не переехавшим ключам. Отсюда и ASKING — одноразовое разрешение
обслужить ключ из мигрирующего слота.
Failover внутри кластера
Sentinel не нужен: реплики сами инициируют выборы, решение принимает большинство
мастеров. Если мастер потерян и реплики у него нет, при
cluster-require-full-coverage yes (умолчание) кластер перестаёт обслуживать
все запросы, а не только осиротевший диапазон. Это часто становится сюрпризом.
Нет баз данных: доступна только db 0, SELECT не работает.
Классический pub/sub рассылается по всем нодам (дорого), поэтому с Redis 7 есть
шардированный SPUBLISH/SSUBSCRIBE, привязанный к слоту канала.
И нет прозрачных мультиключевых операций между слотами, о них следующий вопрос.
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 им нужен с самого начала. Переехать
на теги задним числом значит сменить все ключи, то есть полностью сбросить кэш.
MULTI/EXEC не даёт отката. Это осознанный выбор в пользу латентности и
доступности, и приложение должно быть к нему готово по умолчанию.Пять источников «негарантий»
- Вытеснение. При
allkeys-*Redis вправе выбросить любой ключ до истечения TTL. Значит, ключа может не оказаться в любой момент, и это штатная ситуация. - Падение процесса. С
everysecтеряется до секунды записей, без AOF всё с последнего снимка. - Failover. Репликация асинхронная: между ответом клиенту и доставкой реплике ничего не защищено.
- Split brain. Изолированный старый мастер принимает записи, которые потом будут отброшены.
- Транзакции.
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.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 проходит сетевой 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 есть), вытеснение потока планировщиком ОС, залипший сетевой вызов, «замороженный» контейнер, миграция виртуальной машины или просто медленный запрос в БД. В любой из этих ситуаций два клиента одновременно считают себя держателями блокировки, и оба уверены, что имеют право писать.
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
}
}
}
}
Он уменьшает вероятность, но не устраняет проблему. Если процесс встал целиком (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), не связанных репликацией. Клиент:
- берёт текущее время и последовательно пытается захватить ключ на всех N инстансах с малым таймаутом на каждый;
- считает блокировку взятой, если получил её на большинстве (N/2+1) и суммарно потратил меньше TTL;
- владеет ею TTL минус потраченное время минус поправка на дрейф часов;
- при неуспехе снимает блокировку со всех инстансов, где успел взять.
Смысл: пережить падение отдельных инстансов без Sentinel и без потери блокировки при failover.
Критика Мартина Клеппмана (2016)
Разбор «How to do distributed locking» и ответ Санфилиппо давно стали классикой, их стоит знать по пунктам.
- Разделение целей. Клеппман начинает с вопроса, зачем вообще нужна блокировка: для эффективности (не делать одну работу дважды) или для корректности (двойное выполнение недопустимо). Для эффективности сгодится и одиночный Redis. Для корректности Redlock не подходит, и дальше Клеппман объясняет почему.
- Опора на часы. Redlock считает истечение по времени, предполагая ограниченный дрейф часов. Но время может скакнуть: NTP-коррекция, живая миграция ВМ, переустановка часов администратором. Время на одном узле прыгнуло вперёд, и блокировка «истекает» раньше, чем думает держатель. Если корректность системы зависит от синхронности часов, в асинхронной модели она небезопасна.
- Паузы процесса. Даже с идеальными часами держателя может остановить STW-пауза, своп, вытеснение планировщиком или заморозка контейнера. После паузы он не знает, что блокировку потерял, — и продолжает работать. Никакой алгоритм захвата это не ловит.
- Fencing token как единственное лекарство. Отсюда вывод: нужен монотонный токен,
проверяемый ресурсом. Redlock не выдаёт такого токена: у него N независимых инстансов, и
получить из них согласованно возрастающее число нельзя. А системы с настоящим консенсусом
(ZooKeeper с
zxid, etcd сrevision, Consul) его выдают. - Ответ Санфилиппо. Он возразил, что предположения о часах ограничены и практичны, а про паузы сказал, что fencing token нужен любой блокировке, включая ZooKeeper, так что это не претензия именно к 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/Sub | Redis Streams | Kafka / 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
}
- Сравнивать через
==.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, поэтому читать результат раньше — типичная ошибка.
- Оба дают один 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 останавливает всех клиентов. Скрипты держат короткими и с ограниченным числом операций; никаких «пройтись по всем ключам».
- Все ключи передавай через
KEYS, а не жёстко в тексте. Иначе кластер не сможет определить слот и маршрутизировать скрипт, а на одиночном инстансе его нельзя будет переиспользовать. - Скрипт должен быть детерминированным. В старых версиях случайность и запись в одном скрипте были запрещены из-за репликации по командам; сейчас реплицируется результат (effects replication), но привычка «никакой недетерминированности» остаётся правильной.
- Кэш скриптов не вечен. После рестарта или
SCRIPT FLUSHсервер не знает твой SHA — поэтому голыйEVALSHAбез фолбэка однажды вернётNOSCRIPT.redis.NewScriptделает фолбэк сам, а писатьEvalShaруками без обработкиNOSCRIPT— типичная ошибка. - Redis 7 добавил Functions (
FUNCTION LOAD) — постоянные именованные библиотеки скриптов, которые переживают рестарт и реплицируются. Упомяни их, это плюс к ответу.
Вопросы
9SET lock:key <uuid> NX PX 10000 — атомарный
захват одной командой. TTL нужен, чтобы блокировка освободилась, если держатель умер и
defer unlock() выполнять некому. Уникальное значение — чтобы при освобождении
снять свою блокировку, а не чужую, если TTL успел истечь.Почему одной командой
NX означает «установить, только если ключа нет». Проверка и установка
выполняются как одна операция в однопоточном сервере, поэтому гонки между «посмотрел, что
свободно» и «занял» физически не существует. Разделять это на EXISTS +
SET — классическая ошибка.
Зачем TTL
Держатель может умереть в любой момент: OOM-kill, выселение пода, отключение машины. Тогда никакого «освобождения» не будет — выполнять его некому. Без TTL блокировка зависает навсегда, работа не делается, чинится руками. TTL остаётся единственным способом освободить блокировку без участия упавшего процесса.
Зачем уникальное значение
- A взял блокировку с TTL 10 с, работа затянулась на 12.
- На 10-й секунде ключ истёк, B немедленно его взял.
- На 12-й A заканчивает, делает
DEL lock:keyи удаляет блокировку B. - Блокировку берёт 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
}
Гонка на оси времени
- A:
GET lock→ «uuid-A», всё моё. - Между ответом и следующей командой A вытесняют с процессора на 5 мс.
- TTL истекает, B делает
SET NXи получает блокировку со значением «uuid-B». - A просыпается, выполняет
DEL lockи сносит блокировку B.
Проверяя в Go, ты решаешь по данным, которые устарели как минимум на один round-trip. Между
получением ответа и отправкой DEL всегда есть окно: планировщик Go,
планировщик ОС, сеть. Уменьшить его можно, устранить — нет. Корректно только одно:
сравнивать и удалять на сервере, то есть в Lua. Это касается любой конструкции
«прочитал → подумал → записал» в Redis: она либо в Lua, либо через
WATCH/MULTI/EXEC, либо сломана.
Почему это неизбежно
Держателя может остановить что угодно: пауза 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 инстансах с малым таймаутом на каждый.
- Блокировка взята, если получена на большинстве (N/2+1) и суммарно потрачено меньше TTL.
- Эффективный срок владения = TTL − потраченное время − поправка на дрейф часов.
- При неуспехе клиент снимает блокировку со всех инстансов, где успел взять.
Redlock нужен, чтобы пережить падение отдельных инстансов без Sentinel и не потерять блокировку при failover (при обычной репликации блокировка, взятая на мастере, может не доехать до реплики, которую промоутят).
Критика (Kleppmann, 2016)
- Сначала определи цель. Блокировка для эффективности (не делать работу дважды) или для корректности (двойное выполнение недопустимо)? Для эффективности хватит одиночного Redis, Redlock избыточен. Для корректности он не подходит.
- Зависимость от часов. Алгоритм считает истечение по времени и предполагает ограниченный дрейф. Но время может скакнуть: NTP-коррекция, живая миграция ВМ, ручная установка часов. Система, чья корректность зависит от синхронности часов, в асинхронной модели небезопасна.
- Паузы процесса. Даже с идеальными часами держателя останавливает STW-пауза, своп или заморозка контейнера. После паузы он не знает, что потерял блокировку. Никакой алгоритм захвата этого не ловит.
- Нет fencing token. Лечится это только монотонным токеном, который проверяет
ресурс. Redlock не может его выдать: у него N независимых инстансов без общего
согласованного счётчика. Системы с консенсусом выдают: ZooKeeper —
zxid, etcd —revision. - Ответ Санфилиппо. Предположения о часах ограничены и практичны; проблема пауз относится к любой блокировке, включая ZooKeeper, а не только к Redlock. Консенсуса в сообществе нет до сих пор.
«Если блокировка нужна для эффективности, беру простой SET NX PX на одном
Redis с Lua-освобождением. Если для корректности, Redis не подходит в принципе, и я выбираю из
трёх вариантов: fencing token, проверяемый ресурсом; идемпотентная операция; или
механизм в самой БД — уникальный индекс, условный UPDATE,
SELECT ... FOR UPDATE SKIP LOCKED, advisory lock. Последнее обычно и проще,
и надёжнее, потому что блокировка живёт в той же транзакции, что и данные.»
XACK, то есть
нормальная at-least-once очередь в памяти. Kafka/RabbitMQ — когда нужен долгий retention,
replay, много независимых потребителей и настоящая durability.| Pub/Sub | Streams | Kafka / RabbitMQ | |
|---|---|---|---|
| Хранение | нет | лог в памяти + MAXLEN | диск, retention |
| Гарантия | at-most-once | at-least-once (XACK) | at-least-once, у Kafka — транзакции |
| Подтверждения | нет | PEL, XACK, XAUTOCLAIM | offset / ack |
| Replay | невозможен | по id (XRANGE) | перемотка offset |
| Масштаб потребителей | фанаут: все получают всё | consumer group делит | партиции + группы |
Что теряется в pub/sub
- Нет персистентности. Сообщение получают только те, кто подписан прямо сейчас. Подписчик переподключался две секунды, и эти сообщения потеряны, причём он об этом не узнает.
- Нет подтверждений.
PUBLISHвозвращает число подписчиков, которым отправлено, а не число обработавших. - Медленного подписчика отключают. При переполнении
client-output-buffer-limit pubsubRedis рвёт соединение. - В кластере классический 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) или делают операцию устойчивой к повтору.
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, а до него объекты пусты.
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 FLUSHSHA неизвестен серверу. Голый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
- Создавать клиент на каждый запрос. Клиент содержит пул и потокобезопасен: он создаётся один раз на приложение и закрывается при завершении.
- Ошибка кэша роняет запрос. Ошибка Redis должна вести в фолбэк, а не в 500-й ответ пользователю. Иначе кэш из ускорителя превращается в обязательную зависимость.
- Не проверять результат каждой команды в пайплайне. Ошибка
Execдаёт только общий сигнал, а разбирать надо каждую команду. - Забыть таймауты. Дефолтный
ReadTimeoutв 3 секунды (с go-redis v9.22 — 5) для Redis огромен: подвисший инстанс на эти секунды съест весь пул горутин обработчика. Ставь порядка сотен миллисекунд. - Ретраить неидемпотентное. Автоматические ретраи go-redis могут удвоить
INCR. - Не закрывать pub/sub.
PubSubнадо явноClose(), иначе соединение и горутина живут вечно. - Надеяться на контекст. По умолчанию go-redis v9 дедлайн
ctxна сокет не переносит: ответ ждут поReadTimeout, пока не включишьContextTimeoutEnabled. А уже отправленная команда в Redis выполнится в любом случае.
«Весь код работы с Redis у меня живёт в одном слое — в репозитории. Наружу он отдаёт только доменные типы и доменные ошибки, и у него ровно два поведения при проблемах: промах и деградация. Так я гарантирую, что отказ Redis никогда не превращается в отказ сервиса, а стратегия кэширования не расползается по кодовой базе.»