Redis почти всегда — первый ответ на вопрос «чем ускорить». Здесь он именно в этой роли — ускоритель перед источником правды, а не предмет изучения сам по себе: как Redis устроен внутри — persistence, память и вытеснение, однопоточный event loop, Lua и streams, репликация и Cluster/Sentinel — на живых цифрах разобрано в соседней серии deep-dive, начиная с общего введения. Эта статья намеренно про другое: паттерны кэширования (cache-aside, write-through, инвалидация, защита от cache stampede) и структуры данных Redis (sorted set, HyperLogLog) как готовый инструмент, а не как повод объяснять, почему Redis так работает внутри.
Это вторая статья серии «Вычисления в оперативной памяти», продолжение первой, обзорной статьи. Все числа ниже — из живого стенда redis-cache: пять сценариев (cache-aside, write-through, stampede, leaderboard, HLL) гоняются против PostgreSQL на одном и том же датасете серии — 200 000 товаров, 100 000 просмотров, зипфово распределённых так, что хотя бы один просмотр вообще получили только 12 367 из 200 000 товаров.
В статье
- Cache-aside: паттерн и его цена
- Write-through и инвалидация: порядок операций решает
- Cache stampede: лавина промахов и защита от неё
- Sorted set: leaderboard и rate-limit
- HyperLogLog: приблизительный count с управляемой погрешностью
- Чем платим
- Что дальше
Cache-aside: паттерн и его цена
Классический ленивый паттерн: приложение сначала читает из Redis; на промахе — идёт в PostgreSQL, кладёт результат в кэш с TTL и только затем отвечает клиенту. В стенде это ровно такой цикл, без ничего сверх:
for _, pid := range sequence {
unique[pid] = struct{}{}
key := cacheKey(pid)
val, err := rdb.Get(ctx, key).Result()
switch {
case err == redis.Nil:
misses++
p, qerr := queryOriginProduct(ctx, pool, pid, &originQueries)
if qerr != nil {
log.Fatalf("origin query product_id=%d: %v", pid, qerr)
}
data, _ := json.Marshal(p)
if err := rdb.Set(ctx, key, data, ttl).Err(); err != nil {
log.Fatalf("SET %s: %v", key, err)
}
case err != nil:
log.Fatalf("GET %s: %v", key, err)
default:
hits++
_ = val
}
}Реальный смысл в паттерне даёт неравномерность спроса (зипфов профиль датасета, разобранный в первой статье), а не сам факт наличия кэша. На полной последовательности 100 000 просмотров из views cache-aside дал:
- hit rate = 0,876 (87 633 попадания из 100 000 запросов);
- 12 367 уникальных запрошенных товаров — ровно все товары, у которых вообще есть хоть один просмотр в датасете;
- отношение запросов в PostgreSQL «без кэша» к «с кэшем» = 8,086 (100 000 / 12 367).
Последнее число легко процитировать как «Redis снял восьмикратную нагрузку с БД» — это будет неточно. TTL ключей — 5 минут, а весь прогон укладывается в десятки секунд: ни один ключ физически не успевает истечь за время теста, поэтому число промахов совпадает с числом уникальных товаров по построению. Отношение 8,086 — это свойство зипфова распределения датасета (12 367 уникальных товаров на 100 000 просмотров), а не измерение эффекта кэша: точно такое же число дала бы обычная map в памяти процесса без всякого Redis, потому что TTL в этом отношении ни разу не участвовал. Единственное, что реально демонстрирует сценарий, — при сильно неравномерном спросе Redis успешно ловит повторные обращения к горячим ключам (hit rate 0,876); за какое время они устареют и когда TTL реально включится в работу — вопрос к производственному профилю трафика, не к этому стенду.
Write-through и инвалидация: порядок операций решает
Write-through пишет в кэш той же операцией, что и в источник — в коде запись в Redis идёт сразу вслед за записью в PostgreSQL. Это не общая транзакция: PostgreSQL и Redis — два независимых хранилища, и между их записями нет ничего, что гарантировало бы атомарность обеих. Стенд проверяет успешный путь без сбоя между операциями: цена обновляется в PostgreSQL и сразу перезаписывается в Redis, и последующее чтение из кэша отдаёт новое значение (cached_price == new_price совпадает побитово — детерминировано конструкцией, 1 прогон).
Отдельно проверяется не сама запись, а инвалидация — и здесь порядок двух операций (запись в PG, удаление ключа) решает всё. Правильный порядок: сначала пишем в PostgreSQL, затем удаляем ключ, не пытаясь заранее угадать новое значение.
priceC2 := priceC1 + 500
if _, err := pool.Exec(ctx, `UPDATE products SET price_cents=$1, updated_at=now() WHERE id=$2`, priceC2, idC); err != nil {
log.Fatalf("update price idC: %v", err)
}
must(rdb.Del(ctx, cacheKey(idC)).Err())После такой операции ключа в кэше нет (EXISTS=0), следующее чтение — честный cache-aside промах, который в этом прогоне взял свежую цену из PostgreSQL. Но «правильный порядок» — это не гарантия консистентности, а лишь уменьшение окна рассогласования по сравнению с обратным: ниже — что именно он не закрывает.
Обратный порядок — сначала DEL, потом запись в PostgreSQL — открывает окно между этими двумя операциями, в которое конкурентный читатель успевает промахнуться мимо уже пустого ключа, сходить в PostgreSQL за ещё старой ценой и заново заполнить кэш устаревшим значением, которое переживёт саму операцию записи:
priceAfter := priceBefore + 500
writerInvalidated := make(chan struct{})
readerDone := make(chan struct{})
// Читатель стартует ровно в момент, когда писатель уже удалил ключ, но
// ЕЩЁ НЕ записал новую цену в PG — это и есть окно неправильного порядка.
go func() {
defer close(readerDone)
<-writerInvalidated
p, err := queryOriginProduct(ctx, pool, idB, &dummy)
if err != nil {
log.Fatalf("reader reload idB: %v", err)
}
d, _ := json.Marshal(p)
must(rdb.Set(ctx, cacheKey(idB), d, ttl).Err())
}()
// Писатель, ОШИБОЧНЫЙ порядок: сначала инвалидация, потом запись в PG.
must(rdb.Del(ctx, cacheKey(idB)).Err())
close(writerInvalidated)
// Даём читателю гарантированно завершить re-populate до того, как мы
// зафиксируем новую цену в PG — форсированный, а не случайный интервал,
// потому что демонстрируем факт рассогласования, а не время окна.
time.Sleep(50 * time.Millisecond)
if _, err := pool.Exec(ctx, `UPDATE products SET price_cents=$1, updated_at=now() WHERE id=$2`, priceAfter, idB); err != nil {
log.Fatalf("update price idB: %v", err)
}
<-readerDoneСтенд форсирует это окно явно — каналом синхронизации и time.Sleep(50ms) между DEL и UPDATE, чтобы читатель гарантированно успел вклиниться. Итог: рассогласование наблюдалось в 12 из 12 прогонов (2 у исполнителя стенда, 10 — при независимой проверке ревьюером). Это не значит «рассогласование происходит в проде в 12 случаях из 12» и тем более не «в N% запросов» — стенд ничего не говорит о частоте или ширине окна в реальном трафике без форсированной синхронизации: он лишь доказывает, что при обратном порядке операций окно вообще существует и приложение обязано с ним считаться, если выбирает такой порядок записи.
Честная модель согласованности здесь — не «кэш и источник всегда синхронны», а «кэш сходится к источнику за время TTL, а до этого может врать». PostgreSQL и Redis — два раздельных хранилища без общей транзакции, и даже при правильном порядке (UPDATE → DEL) остаются минимум два окна рассогласования — этот стенд их не воспроизводил и не измерял, но они существуют по конструкции:
- Сбой между операциями. Запись в PostgreSQL прошла, а
DEL/SETв Redis не выполнился — процесс упал, оборвалась сеть. Кэш держит старое значение до истечения TTL, и ничего, кроме TTL, это само по себе не исправит. - Гонка stale repopulation. Конкурентный читатель промахнулся мимо кэша и успел прочитать ещё старое значение из PostgreSQL ДО коммита писателя, но записать его в Redis ПОСЛЕ
DELписателя — кэш заново заполняется устаревшим значением, которое переживёт саму операцию записи. Это окно тоньше первого и важнее: оно существует именно при правильном порядке операций — ровно там, где легко понадеяться на полную свежесть.
Что реально сужает оба окна в проде — не сам по себе порядок операций записи, а один из трёх механизмов. Именно сужает: ни один из них не закрывает окно полностью, и это стоит держать в голове, выбирая между ними. TTL как последняя страховка (кэш неверен ограниченное время, а не бесконечно); версионирование или CAS при записи в кэш (запись побеждает, только если она новее уже лежащего значения); транзакционный outbox или CDC, если рассогласование в принципе недопустимо для бизнес-логики. У каждого механизма своя граница, и её стоит назвать прямо: TTL ограничивает длительность рассогласования, но не устраняет его; версионирование отсекает запись устаревшего значения, но не гарантирует, что в кэше лежит самое свежее; outbox и CDC делают доставку инвалидации надёжной, но обычно оставляют окно eventual consistency между коммитом и её применением. Если чтение действительно не терпит устаревших данных, его нельзя обслуживать из такого кэша без дополнительного протокола — например, чтения из источника с версией и проверкой на стороне приложения. Порядок UPDATE → DEL остаётся предпочтительным — обратный порядок хуже, и это показано выше живьём, — но это сужение окна, а не его закрытие.
Cache stampede: лавина промахов и защита от неё
Cache stampede — ситуация, когда популярный ключ истекает или ещё не был заполнен, и множество параллельных читателей одновременно видят промах и все разом идут в источник вместо того, чтобы промах обработал кто-то один. Стенд воспроизводит это буквально: 200 горутин одновременно читают один и тот же холодный ключ.
Без защиты — GET, промах, сразу SELECT в PostgreSQL, без какой-либо координации между горутинами. За 20 прогонов подряд число запросов, дошедших до PostgreSQL, колебалось в диапазоне 134–200 (медиана 186) — почти все 200 горутин почти всегда добираются до источника одновременно; диапазон, а не одна цифра, потому что момент, в который горутины реально попадают в гонку, зависит от планировщика и не детерминирован.
С защитой — на промахе горутина берёт распределённую блокировку (SETNX) со своим уникальным токеном, и только победитель идёт в PostgreSQL; проигравшие ждут появления ключа. Освобождает лок не безусловный DEL, а Lua-скрипт compare-and-delete:
var unlockScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`)func getOrLoadProtected(ctx context.Context, pool *pgxpool.Pool, rdb *redis.Client, id int64, counter *int64) {
key := cacheKey(id)
// ...GET, промах (redis.Nil) — идём дальше за локом.
lockKey := "lock:" + key
token := randomLockToken()
ok, err := rdb.SetNX(ctx, lockKey, token, 10*time.Second).Result()
if err != nil {
log.Printf("protected SETNX error: %v", err)
return
}
if ok {
// Освобождение — ТОЛЬКО через compare-and-delete со своим токеном
// (см. unlockScript выше), не безусловный DEL: иначе эта горутина
// рискует удалить чужую блокировку, взятую после истечения нашего TTL.
defer func() {
if _, err := unlockScript.Run(ctx, rdb, []string{lockKey}, token).Result(); err != nil {
log.Printf("protected unlock error: %v", err)
}
}()
// Двойная проверка: между начальным Get этой горутины (miss) и
// получением лока настоящий победитель мог успеть отработать целиком
// (запрос в PG → SET → return → defer Del лока) и уже снять лок —
// тогда следующая горутина в очереди на SetNX тоже получает ok=true
// на уже свободный лок и без этой проверки сходила бы в PG повторно.
if _, err := rdb.Get(ctx, key).Result(); err == nil {
return
}
p, qerr := queryOriginProduct(ctx, pool, id, counter)
if qerr != nil {
log.Printf("protected origin query error: %v", qerr)
return
}
data, _ := json.Marshal(p)
_ = rdb.Set(ctx, key, data, ttl).Err()
return
}
// ...проигравшие поллингом ждут появления ключа с таймаутом (полный код — в стенде).
}Токен и compare-and-delete — не для красоты, а для правильности: до этого фикса победитель гонки брал лок общим значением "1" и снимал его безусловным rdb.Del(ctx, lockKey) в defer. Если бы загрузка данных из источника пережила TTL блокировки (в этом стенде 10 секунд), TTL истёк бы сам, лок взяла бы другая горутина — а первая по своему defer удалила бы уже ЧУЖУЮ, новую блокировку победителя, и вторая горутина осталась бы без защиты. Ровно этот сценарий прямо описан в документации Redis по распределённым блокировкам, в разделе про безопасное освобождение: redis.io/docs/latest/develop/clients/patterns/distributed-locks. На датасете этого стенда дефект не проявлялся числами origin_queries — загрузка занимает миллисекунды и до TTL=10с не доходит, — но это не делало код правильным: getOrLoadProtected в статье цитируется дословно как образец паттерна защиты от stampede, а читатель копирует код из статьи, не из стенда. Уникальный per-acquisition токен (crypto/rand, 16 байт, hex) даёт compare-and-delete, которому есть что сравнивать; на общем значении "1" сравнивать нечего — все владельцы писали бы одно и то же, и лок выродился бы обратно в тот же небезопасный безусловный DEL.
Важно понимать и то, чего токен не чинит. Он гарантирует, что владелец не снимет чужую блокировку, — но не удерживает саму блокировку. Если загрузка из источника переживает TTL, блокировка истекает сама, её берёт следующая горутина, и в источник уходит параллельный запрос: взаимное исключение действует ровно столько, сколько живёт запись блокировки, и ни секундой дольше. Практический вывод: TTL должен быть заведомо больше времени загрузки, а если это не гарантируется — нужен механизм продления (lease с периодическим обновлением, пока владелец жив), о чём документация Redis говорит отдельно. Стенд работает в режиме, где загрузка — миллисекунды против десяти секунд TTL, и до этого сценария не доходит; проверять его живьём он не пытался.
Комментарий в коде — не теория: именно двойная проверка кэша чинит реальную TOCTOU-гонку, найденную живыми прогонами, а не придуманную заранее. Горутина успевает увидеть промах ДО того, как победитель заполнил кэш, но добирается до SETNX ПОСЛЕ того, как победитель уже снял лок, — и без повторной проверки кэша ушла бы в PostgreSQL на уже бесполезную операцию. До фикса это давало origin_queries=2 в 6 из 12 прогонов. После фикса TOCTOU-гонки защищённый сценарий даёт origin_queries=1 на 20 из 20 прогонов; после отдельного фикса токена и compare-and-delete — ещё 20 из 20 на пересобранном стенде, без единого отклонения.
Незащищённая сторона в коде на этом фиксе не менялась, но число дрейфует между сериями прогонов: 134–200 (медиана 186) в первой серии из 20 прогонов против 129–200 (медиана 170) в независимой проверке после фикса лока — разброс объясняется недетерминированностью планировщика горутин между сериями, а не эффектом фикса. Отношение «без защиты / с защитой» на этом стенде — от 129 к 1 до 200 к 1 (объединяя обе серии), диапазон, не точка: одна распределённая блокировка на промахе снижает число параллельных обращений к источнику с сотен до одного, но конкретная экономия внутри диапазона плавает от прогона к прогону.
Sorted set: leaderboard и rate-limit
ZSET — готовая структура для рейтингов: ZINCRBY на каждое событие, ZREVRANGE для топа. Стенд прогоняет через ZINCRBY все 100 000 просмотров (батчами по 1000 через pipeline) и сравнивает получившийся топ-10 с тем же самым топ-10, посчитанным SQL-агрегатом в PostgreSQL:
pipe := rdb.Pipeline()
const batchSize = 1000
buffered := 0
for rows.Next() {
var pid int64
if err := rows.Scan(&pid); err != nil {
log.Fatalf("scan: %v", err)
}
pipe.ZIncrBy(ctx, key, 1, strconv.FormatInt(pid, 10))
buffered++
if buffered >= batchSize {
if _, err := pipe.Exec(ctx); err != nil {
log.Fatalf("pipeline exec: %v", err)
}
buffered = 0
}
}
if buffered > 0 {
if _, err := pipe.Exec(ctx); err != nil {
log.Fatalf("pipeline exec (tail): %v", err)
}
}redisTop, err := rdb.ZRevRangeWithScores(ctx, key, 0, 9).Result()
if err != nil {
log.Fatalf("ZREVRANGE: %v", err)
}Проверка тут не про скорость, а про корректность: топ-10 из Redis совпал с топ-10 из SELECT product_id, count(*) c FROM views GROUP BY product_id ORDER BY c DESC LIMIT 10 по всем 10 позициям и всем счётчикам. Смысл сценария — показать, что вынесенная в Redis агрегация не расходится с прямым пересчётом в источнике, а не что она быстрее (абсолютная latency в этой серии не измеряется — см. первую статью).
Тот же ZSET лежит в основе скользящего окна rate-limit: запросы кладутся в множество с меткой времени как score, устаревшие срезаются по границе окна (ZREMRANGEBYSCORE), а текущее число в окне даёт ZCARD. Структурный приём тот же, что у leaderboard, только score здесь время, а не счётчик.
Но выписывать эти три команды подряд как готовый рецепт нельзя, и это стоит проговорить, а не оставить читателю в качестве сюрприза. У наивной последовательности три отдельные проблемы:
- member обязан быть уникальным на каждый запрос. Если писать
ZADD key now user123, повторный запрос того же пользователя не добавит элемент, а обновит score существующего — счётчик не вырастет, и лимит не сработает вовсе. Нужен уникальный member: идентификатор запроса, счётчик, случайный суффикс. - Три команды должны выполняться атомарно. Между
ZREMRANGEBYSCORE,ZADDиZCARDвклинивается конкурентный клиент, и оба проходят проверку, хотя лимит допускал одного. Здесь нужен Lua-скрипт или Redis Function — ровно как в защите от stampede выше, и по той же причине. - Время должно быть одно. Если score берётся с часов клиента, расхождение часов между приложениями сдвигает границу окна у каждого по-своему; серверное время (
TIMEвнутри скрипта) снимает вопрос.
Этот стенд rate-limit отдельным сценарием не проверял и живых чисел по нему не публикует. Поэтому здесь — только структурная идея и её ограничения, без кода, который выглядел бы проверенным рецептом, не будучи им.
HyperLogLog: приблизительный count с управляемой погрешностью
Для точного числа уникальных пользователей на популярный товар классический путь — SADD/SCARD: множество, растущее линейно с числом уникальных элементов. HyperLogLog (PFADD/PFCOUNT) даёт приближённый ответ почти в постоянной памяти. Сама вероятностная конструкция внутри — оценка кардинальности через хеш-биты, sparse/dense-кодировки — тема отдельной статьи про вероятностные структуры данных, HLL там разобран как один из примеров того же класса компромисса: точность за счёт памяти. Здесь — только результат сравнения на реальных данных.
urows, err := pool.Query(ctx, `SELECT DISTINCT user_id FROM views WHERE product_id=$1`, t.ID)
if err != nil {
log.Fatalf("SELECT DISTINCT user_id: %v", err)
}
pipe := rdb.Pipeline()
buffered := 0
const batchSize = 500
for urows.Next() {
var uid int64
if err := urows.Scan(&uid); err != nil {
log.Fatalf("scan uid: %v", err)
}
us := strconv.FormatInt(uid, 10)
pipe.PFAdd(ctx, hllKey, us)
pipe.SAdd(ctx, exactKey, us)
buffered++
if buffered >= batchSize {
if _, err := pipe.Exec(ctx); err != nil {
log.Fatalf("pipeline exec: %v", err)
}
buffered = 0
}
}
if buffered > 0 {
if _, err := pipe.Exec(ctx); err != nil {
log.Fatalf("pipeline exec (tail): %v", err)
}
}exact, err := rdb.SCard(ctx, exactKey).Result()
if err != nil {
log.Fatalf("SCARD: %v", err)
}
approx, err := rdb.PFCount(ctx, hllKey).Result()
if err != nil {
log.Fatalf("PFCOUNT: %v", err)
}
hllMem, err := rdb.MemoryUsage(ctx, hllKey).Result()
if err != nil {
log.Fatalf("MEMORY USAGE hll: %v", err)
}
exactMem, err := rdb.MemoryUsage(ctx, exactKey).Result()
if err != nil {
log.Fatalf("MEMORY USAGE exact: %v", err)
}
relErr := math.Abs(float64(approx)-float64(exact)) / float64(exact)Для топ-10 товаров по числу просмотров стенд считает оба множества параллельно (через pipeline) и сравнивает:
- относительная погрешность HLL против точного счёта — 0,06–1,54% (1,54% — максимум по топ-10, остальные товары точнее);
- HLL занимает меньше памяти, чем точное множество, в 4,9–25,2 раза — на кардинальностях от 1090 до 4931 уникальных пользователей на товар, неравномерно по товарам (переключение HLL между sparse- и dense-кодировкой внутри самой структуры сдвигает экономию — что это за переключение и почему, разбирается в статье про вероятностные структуры, не здесь).
Чем платим
Ни один из паттернов выше не бесплатен:
- Согласованность. Кэш перед источником правды — второй источник правды по конструкции, а не его зеркало: PostgreSQL и Redis обновляются двумя разными операциями, и это верно даже при правильном порядке (write PG → DEL, выше). Правильный порядок сужает окно рассогласования по сравнению с обратным — но не закрывает его целиком; для более сильной гарантии, чем «кэш сходится к источнику за время TTL», нужны версионирование/CAS при записи или транзакционный outbox/CDC. Выбор порядка и механизма — не деталь реализации, а осознанное архитектурное решение.
- Лишний round-trip на промахе. Cache-aside на промахе делает не один запрос, а два последовательных — сначала в Redis, затем в источник, — прежде чем ответить клиенту; при высоком hit rate это редкий случай, но не исчезающий (в сценарии выше — 12 367 промахов из 100 000 запросов).
- Стоимость RAM. Весь резидентный набор в Redis занимает столько же памяти, сколько занимал бы в любой другой in-memory структуре — экономика этого трейдофа на конкретных байтах разобрана в первой статье серии, а что происходит, когда набор перерастает
maxmemory(вытеснение, политики LRU/LFU), — в deep-dive про память и eviction. - Однопоточная модель исполнения команд. Ни один из пяти сценариев этой статьи её не задевает — все операции здесь однострочные (
GET/SET/ZINCRBY/PFADD) и быстрые сами по себе. Но у модели есть цена, когда команды перестают быть дешёвыми, — это отдельная тема deep-dive про event loop.
Общая карта, где Redis-как-кэш стоит рядом с остальными подходами к хранению данных, — в хабе «Данные: карта хранилищ и подходов».
Что дальше
Следующая статья серии переносит вычисления не в кэш перед источником, а прямо в источник: Статья 3 — Tarantool и Picodata: данные и вычисления рядомготовится, с 23 сентября — Lua stored procedures внутри процесса базы, персистентность через WAL и snapshot вместо моделей RDB/AOF, и Picodata, где та же идея живёт иначе: PostgreSQL-протокол снаружи и плагины на Rust внутри.
Если нужны основы клиентов, антипаттернов и внутреннего устройства Redis, которые эта статья сознательно обошла стороной, — начать стоит с введения в Redis и статьи про клиентов; структуры данных и их кодировки разобраны глубже в отдельной статье, персистентность — в RDB/AOF, Lua и streams — в соответствующей статье, репликация и отказоустойчивость — в Cluster/Sentinel.
Комментарии