Продвинутое кэширование в высоконагруженных системах

Курс посвящен проектированию эффективного слоя кэширования для радикального снижения Latency. Вы изучите стратегии наполнения кэша, механизмы обеспечения консистентности и способы борьбы с типичными аномалиями в распределенных системах на Go.

Иерархия кэширования: от локального sync.Pool до распределенных Redis и Memcached

Иерархия кэширования: от локального sync.Pool до распределенных Redis и Memcached

В предыдущем модуле мы шардировали базу данных, размазав нагрузку по десяткам серверов. Кажется, проблема решена? Не совсем. Даже идеально оптимизированный запрос к PostgreSQL по сети занимает 151 \dots 5 миллисекунд. Если для сборки главной страницы приложения нужно сделать 20 таких запросов, мы уже потеряли 100 миллисекунд только на ожидании базы данных. В мире High-Load самый быстрый запрос — это тот, который мы вообще не отправили в базу.

Кэширование радикально снижает задержки (Latency), но это не просто «поставить Redis рядом с приложением». Это многоуровневая архитектура, где каждый слой предлагает свой компромисс между скоростью, объемом и консистентностью.

Пирамида задержек: ближе к процессору

Иерархию кэширования можно представить как слоистую структуру. Чем ближе данные к процессору, тем быстрее к ним доступ, но тем меньше данных мы можем сохранить и тем сложнее синхронизировать их между серверами.

Эту пирамиду можно разделить на три концептуальных уровня:

  1. Аппаратный кэш (L1/L2/L3): Встроен в сам процессор. Доступ занимает единицы наносекунд, но объем ограничен мегабайтами. Мы не управляем им напрямую, но оптимизируем код (например, избегая лишних аллокаций в куче), чтобы процессор реже обращался к основной оперативной памяти.
  2. Локальный кэш (In-memory): Данные хранятся в оперативной памяти (RAM) самого приложения (в нашем случае — внутри процесса Go). Доступ занимает десятки наносекунд.
  3. Распределенный кэш (Distributed): Данные вынесены в отдельный кластер (Redis, Memcached). Доступ идет по сети, что добавляет сетевую задержку (Round Trip Time, RTT) — обычно это 0.510.5 \dots 1 миллисекунда.

Рассмотрим два последних уровня, которыми управляет архитектор.

Локальный кэш: максимальная скорость и изоляция

Самый быстрый способ получить данные — сохранить их прямо в памяти запущенного Go-сервиса. Нам не нужно открывать сетевые соединения, сериализовать данные в JSON или Protobuf, мы просто берем готовый объект из памяти.

В экосистеме Go локальное кэширование решает две разные задачи:

1. Кэширование аллокаций (переиспользование памяти) Как мы разбирали в модуле про профилирование, создание новых объектов нагружает Garbage Collector. Для кэширования пустых структур и буферов используется sync.Pool. Он не хранит бизнес-данные (например, профили пользователей), он хранит контейнеры для них, чтобы сэкономить такты CPU на выделении памяти.

2. Кэширование бизнес-данных (In-memory cache) Для хранения результатов вычислений или ответов из БД используются структуры вида ключ-значение. Простейший вариант — стандартная sync.Map. Для более сложных сценариев (с ограничением по размеру и времени жизни) применяют библиотеки вроде BigCache или FreeCache. Они написаны так, чтобы хранить миллионы записей, скрывая их от Garbage Collector (чтобы не вызывать долгие Stop-The-World паузы).

Локальный кэш идеален для иммутабельных (неизменяемых) справочников: списка стран, категорий товаров, конфигураций. Того, что редко меняется, но читается при каждом запросе.

Однако у локального кэша есть фатальный архитектурный недостаток в распределенных системах.

Вспомним концепцию Scale-Out (горизонтального масштабирования) и балансировку Round Robin. Представьте, что у вас 10 инстансов Go-приложения. Пользователь запрашивает профиль, балансировщик направляет его на Инстанс №1. Инстанс идет в БД, достает профиль и сохраняет в свой локальный кэш. Через секунду тот же пользователь обновляет страницу. Балансировщик направляет его на Инстанс №2. Для Инстанса №2 этот профиль неизвестен — происходит Cache Miss, и он снова идет в БД.

Локальный кэш делает наши узлы Stateful (хранящими состояние). Чем больше серверов мы добавляем для обработки трафика, тем ниже становится общая эффективность кэша (Hit Rate), так как каждый сервер вынужден прогревать свою собственную копию данных.

Распределенный кэш: единая точка истины

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

Теперь все 10 инстансов Go-приложения обращаются к одному кластеру кэша. Если Инстанс №1 положил данные в кэш, Инстанс №2 мгновенно сможет их прочитать.

Мы идем на осознанный архитектурный компромисс (Trade-off):

  • Минус: Мы добавляем сетевую задержку. Чтение из локальной памяти занимает 50\approx 50 наносекунд. Чтение из Redis по сети — 500000\approx 500 000 наносекунд (0.50.5 мс). Мы замедлили доступ в 10 000 раз!
  • Плюс: Мы разгрузили базу данных и обеспечили консистентность. Задержка в 0.50.5 мс — это все еще в 10 раз быстрее, чем выполнение тяжелого SELECT с JOIN в PostgreSQL (55 мс).

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

Memcached: многопоточная простота

Memcached создавался с одной целью — максимально быстро отдавать значения по ключу. Его архитектура базируется на многопоточности (Multi-threading). Memcached может утилизировать все ядра процессора на сервере, обрабатывая множество сетевых соединений параллельно.

Он работает только с простыми строками или бинарными данными (blobs). Если вы хотите закэшировать профиль пользователя, вам придется сериализовать его в JSON на стороне Go, отправить в Memcached как единый кусок байт, а при чтении — скачать весь JSON и десериализовать обратно. Вы не можете обновить только одно поле (например, возраст) внутри Memcached — придется перезаписывать объект целиком.

Redis: однопоточный швейцарский нож

Redis (Remote Dictionary Server) пошел другим путем. В своей основе (для обработки команд) Redis — строго однопоточный (Single-threaded).

Как один поток может держать High-Load? Redis использует неблокирующий I/O и паттерн Event Loop (мультиплексирование, подобно тому, как работает планировщик горутин в Go). Поскольку поток один, в Redis принципиально не бывает состояний гонки (Race Conditions) при модификации данных. Каждая команда выполняется атомарно.

Главная сила Redis — богатые структуры данных. Он хранит не просто байты, а списки (Lists), множества (Sets), хэш-таблицы (Hashes) и сортированные множества (Sorted Sets).

Это меняет подход к разработке:

  • Вместо того чтобы скачивать весь JSON профиля, вы можете сохранить его как Hash и командой HINCRBY profile:123 age 1 увеличить возраст прямо в памяти Redis, не гоняя данные по сети.
  • Вы можете реализовать глобальный Rate Limiter или таблицу лидеров (Leaderboard) в реальном времени, используя Sorted Sets, за одну операцию с алгоритмической сложностью O(logN)O(\log N).

Что выбрать?

Сегодня Redis является индустриальным стандартом по умолчанию. Его однопоточной производительности (до 100 000 RPS на одном ядре) хватает для 99% задач, а богатые структуры данных позволяют строить сложные паттерны (очереди, счетчики, гео-поиск).

Memcached остается нишевым инструментом для сверхвысоких нагрузок, где требуется кэшировать огромные объемы статичных HTML-фрагментов или предрасчитанных JSON, утилизируя многоядерные серверы без необходимости настраивать кластеризацию.

Синтез иерархии

В реальных высоконагруженных системах эти уровни не конкурируют, а работают вместе. Архитектор может закэшировать тяжелый SQL-запрос в Redis (чтобы спасти БД), а самые горячие ключи из Redis дополнительно сохранить в локальный кэш Go-приложения (чтобы спасти сеть).

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

Стратегии наполнения кэша: Cache-Aside, Read-Through и Write-Through

Стратегии наполнения кэша: Cache-Aside, Read-Through и Write-Through

Внедрение распределенного кэша вроде Redis дает нам хранилище, способное отдавать данные за доли миллисекунды. Но пустой кэш бесполезен. Возникает архитектурный вопрос: кто именно, в какой момент и по какому алгоритму должен помещать данные из медленной базы данных (PostgreSQL) в быстрый кэш?

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

Cache-Aside: приложение как главный оркестратор

Самый распространенный паттерн в High-Load системах — Cache-Aside (или Lazy Loading, ленивая загрузка). В этой модели кэш и база данных ничего не знают друг о друге. Приложение выступает главным дирижером и общается с ними по отдельности.

Логика работы на чтение выглядит так:

  1. Приложение получает запрос на чтение профиля пользователя.
  2. Приложение идет в Redis. Если данные есть (Cache Hit) — сразу возвращает их клиенту.
  3. Если данных нет (Cache Miss) — приложение идет в PostgreSQL.
  4. Получив данные из БД, приложение самостоятельно записывает их в Redis.
  5. Данные возвращаются клиенту.

Главное преимущество Cache-Aside — кэшируются только те данные, которые реально запрашиваются пользователями (Working Set). Память не забивается "мертвым" грузом исторических записей, к которым никто не обращается.

Однако за эту ленивость мы платим архитектурным компромиссом: штрафом за Cache Miss.

При промахе кэша итоговая задержка (Latency) для клиента складывается из времени обращения к кэшу и времени обращения к базе данных: Latency=RTTcache+RTTdb+ProcessingLatency = RTT_{cache} + RTT_{db} + Processing

Если Redis отвечает за 1 мс, а тяжелый запрос к PostgreSQL выполняется 50 мс, то при промахе клиент будет ждать 51 мс. В системах с глубокой иерархией микросервисов каскадные промахи кэша могут приводить к существенным спайкам p99 Latency.

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

Read-Through: кэш как фасад

Чтобы избавить код бизнес-логики от рутины Cache-Aside, применяется паттерн Read-Through.

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

На практике Redis или Memcached "из коробки" не умеют ходить в реляционные базы данных. Поэтому паттерн Read-Through обычно реализуется двумя путями:

  1. Толстый клиент / Библиотека: В Go-приложении пишется обертка (Data Access Object), которая инкапсулирует логику Cache-Aside внутри себя. Для бизнес-логики это выглядит как userRepository.GetByID(), и она не знает, откуда реально пришли данные.
  2. Инфраструктурный прокси: Использование промежуточных решений, которые умеют прозрачно маршрутизировать запросы между кэшем и БД.

Read-Through делает код чище (Separation of Concerns), но не решает проблему задержки при Cache Miss — физику сетевых вызовов обмануть нельзя, кто бы их ни делал: приложение или прокси-библиотека.

Write-Through: синхронная запись для строгой консистентности

До сих пор мы говорили о чтении. Но что происходит при изменении данных?

Представьте, что пользователь обновляет аватарку. Если мы просто запишем новый URL в PostgreSQL, то в Redis останется старый URL. При следующем чтении (Cache Hit) система отдаст устаревшие данные.

Паттерн Write-Through решает эту проблему радикально: данные всегда пишутся сначала в кэш, а затем синхронно в базу данных (или наоборот, но в рамках одной транзакции/вызова). Приложение получает подтверждение об успехе только тогда, когда данные сохранены в обоих хранилищах.

Преимущество очевидно: данные в кэше никогда не устаревают. Мы получаем строгую консистентность (Strong Consistency) между слоем кэширования и слоем персистентного хранения.

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

При стратегии Write-Through операция записи считается успешной только после подтверждения от двух сетевых узлов: WriteLatency=RTTcache+RTTdbWrite Latency = RTT_{cache} + RTT_{db}

Если запись в Redis занимает 1 мс, а коммит транзакции в PostgreSQL — 10 мс, то клиент будет ждать минимум 11 мс. Для систем с преобладанием операций чтения (Read-Heavy, например, лента новостей) это приемлемо. Но для Write-Heavy систем (сбор IoT-метрик, логирование кликов) двойная сетевая задержка на каждой операции записи быстро приведет к исчерпанию пула соединений и снижению пропускной способности (Throughput).

Сравнение стратегий

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

Характеристика Cache-Aside Read-Through Write-Through
Кто управляет кэшем? Приложение Библиотека / Прокси Приложение / Прокси
Задержка при промахе (Read Miss) Высокая Высокая -
Задержка записи (Write Latency) Низкая (пишем только в БД) Низкая Высокая (пишем в оба места)
Риск устаревших данных Высокий Высокий Минимальный
Потребление памяти кэша Экономное (Working Set) Экономное Может расти (пишем даже то, что не читают)

В реальных High-Load системах на Go редко используется чистый Write-Through из-за высоких задержек на запись. Чаще всего архитекторы выбирают Cache-Aside для чтения, а проблему устаревания данных при записи решают через механизмы инвалидации (удаления старых ключей) или асинхронного обновления. Как именно безопасно инвалидировать кэш и не положить базу данных при массовом удалении ключей — разберем в следующей главе.

Проблема инвалидации и консистентности: TTL, Write-Around и CDC

Проблема инвалидации и консистентности: TTL, Write-Around и CDC

«В информатике есть только две сложные проблемы: инвалидация кэша и придумывание названий».

Фил Карлтон (Phil Karlton), архитектор Netscape

В прошлой главе мы выяснили, что стратегия Write-Through решает проблему устаревших данных, синхронно обновляя и базу, и кэш. Но за эту надежность мы платим двойной сетевой задержкой при каждой записи. В высоконагруженных системах, где важна скорость ответа, приложение часто пишет данные только в базу. И здесь возникает главная проблема кэширования: как кэш узнает, что данные в БД изменились, и перестанет отдавать клиентам устаревшую копию?

Процесс удаления или обновления устаревших данных называется инвалидацией. Рассмотрим три эволюционных шага решения этой проблемы: от пассивного таймера до потоковой репликации логов БД.

TTL: Пассивная инвалидация и «самовосстановление»

Самый простой способ не отдавать старые данные вечно — ограничить срок их жизни. TTL (Time To Live) — это таймер обратного отсчета, который прикрепляется к ключу в момент его сохранения в кэш (например, в Redis через команду EXPIRE). Когда время истекает, кэш автоматически удаляет ключ. При следующем запросе случится Cache Miss, и приложение загрузит свежую версию из БД.

Это пассивная стратегия: приложение никак не уведомляет кэш об изменениях в базе. Мы просто миримся с тем, что в течение времени TTLTTL пользователи могут видеть неактуальную информацию (окно неконсистентности).

Если мы кэшируем количество лайков под видео с TTL=60TTL = 60 секунд, то при добавлении нового лайка в БД кэш не изменится. Максимальное время, в течение которого счетчик на экране пользователя будет отставать от реальности, составит ровно 60 секунд.

Главное преимущество TTL — гарантия самовосстановления (Self-healing). В распределенных системах сети моргают, пакеты теряются, а серверы перезагружаются. Любой сложный механизм инвалидации может дать сбой. TTL выступает фундаментальной страховкой: даже если все системы уведомлений сломаются, данные в кэше обновятся максимум через заданное время. Поэтому TTL рекомендуется использовать всегда, даже в комбинации с более сложными стратегиями.

Write-Around: Активное удаление

Если бизнес-требования не допускают, чтобы пользователь видел старые данные (например, баланс кошелька или статус заказа), пассивного ожидания по TTL недостаточно. Нам нужна активная инвалидация.

Стратегия Write-Around работает в паре с уже знакомым нам Cache-Aside:

  1. Приложение получает запрос на изменение данных.
  2. Приложение синхронно записывает новые данные в базу данных.
  3. Приложение отправляет команду на удаление (например, DEL в Redis) соответствующего ключа из кэша.

Обратите внимание: мы именно удаляем ключ, а не перезаписываем его новым значением.

Удаление ключа (инвалидация) — это идемпотентная и легковесная операция. Если два параллельных запроса попытаются обновить один и тот же профиль, порядок их записи в БД гарантируется транзакциями. Но по пути к кэшу сетевые пакеты могут обогнать друг друга, и старое значение затрет новое. При удалении ключа этой проблемы нет: кто бы ни пришел последним, ключ будет удален, и следующий читатель безопасно загрузит консистентный срез из БД (Cache-Aside).

Проблема частичного отказа (Partial Failure)

У Write-Around есть архитектурная уязвимость. Операция состоит из двух сетевых вызовов:

  1. UPDATE users SET ... (Успех)
  2. DEL user:123 (Ошибка сети / Redis недоступен)

Если второй шаг завершится ошибкой, база данных обновится, а в кэше навсегда (или до истечения TTL) останется старое значение. Мы не можем обернуть БД и Redis в единую распределенную транзакцию (Two-Phase Commit, 2PC) — это убьет производительность и нарушит принципы High-Load, которые мы разбирали в модуле про базы данных.

Нам нужен механизм, который гарантированно доставит команду инвалидации в кэш после успешного коммита в БД, даже если приложение-писатель упадет сразу после ответа от базы.

CDC: Асинхронная консистентность через лог БД

В курсе по масштабированию баз данных мы изучали WAL (Write-Ahead Log) — бинарный журнал, через который Leader-узел транслирует все изменения своим Follower-репликам. Что если кэш притворится такой репликой и будет читать этот лог?

Этот паттерн называется CDC (Change Data Capture). Это инфраструктурный подход, при котором изменения отслеживаются на уровне самой базы данных, а не в коде приложения.

Как это работает:

  1. Приложение пишет данные только в БД и сразу возвращает ответ клиенту (максимально быстро).
  2. СУБД фиксирует транзакцию и записывает изменения в WAL.
  3. Отдельный процесс (например, Debezium) непрерывно читает WAL и превращает каждую операцию (INSERT, UPDATE, DELETE) в событие.
  4. Событие отправляется в надежную очередь сообщений (например, Apache Kafka).
  5. Консьюмер (Cache Updater) читает события из очереди и обновляет или удаляет ключи в Redis.

Архитектурные преимущества CDC

  1. Гарантия доставки (Eventual Consistency). Если Redis временно недоступен, события накопятся в Kafka. Как только кэш поднимется, консьюмер прочитает очередь и накатит все пропущенные инвалидации. Частичный отказ, убивающий Write-Around, здесь не страшен.
  2. Развязка (Decoupling). Код приложения становится предельно простым: он вообще ничего не знает о кэше при записи. Вся логика инвалидации вынесена в инфраструктурный слой.
  3. Единый источник истины. База данных диктует состояние. Исключены ситуации, когда приложение обновило кэш, но транзакция в БД откатилась (Rollback).

Компромиссы CDC

Главный недостаток CDC — Replication Lag (отставание репликации). От момента коммита в БД до обновления кэша проходит время (обычно десятки миллисекунд). В это короткое окно клиент может сделать запрос на чтение и получить из кэша старое значение. Это классическое проявление Eventual Consistency.

Кроме того, CDC требует сложной инфраструктуры. Разворачивать Debezium и Kafka только ради инвалидации кэша в небольшом проекте — это оверинжиниринг.

Синтез: что выбрать?

Выбор стратегии зависит от требований к свежести данных и допустимой сложности системы:

Стратегия Как работает Когда применять Риски
Только TTL Кэш сам удаляет данные по таймеру Счетчики, аналитика, ленты новостей, где задержка в минуты не критична. Пользователь гарантированно видит устаревшие данные до истечения таймера.
Write-Around Приложение пишет в БД и явно удаляет ключ из кэша Профили пользователей, настройки, корзины. Базовый стандарт для большинства систем. Рассинхронизация при сетевой ошибке между БД и кэшем (Partial Failure).
CDC Кэш обновляется асинхронно из WAL базы данных Финансовые транзакции, микросервисы с жесткими требованиями к консистентности. Сложность инфраструктуры; наличие окна отставания (Replication Lag).

В реальных высоконагруженных системах стратегии комбинируют. Например, используют CDC для надежной инвалидации, но обязательно оставляют фоновый TTL (например, 24 часа) для защиты от логических ошибок в консьюмерах и «утечек» памяти в кэше.

Однако даже при использовании Write-Around или CDC система может столкнуться с аномалиями конкурентного доступа, когда множество читателей и писателей приходят одновременно. О том, как возникают состояния гонки при обновлении кэша и как с ними бороться, мы поговорим в следующей главе.

Борьба с аномалиями: Cache Aside Race, Thundering Herd и Cache Stampede

Борьба с аномалиями: Cache Aside Race, Thundering Herd и Cache Stampede

Вы внедрили стратегию Cache-Aside, настроили инвалидацию через Write-Around и защитили данные с помощью TTL. Система работает идеально на 100 RPS. Но как только нагрузка возрастает до 10 000 RPS, происходят необъяснимые вещи: пользователи периодически видят устаревшие данные, которые не обновляются часами, а база данных внезапно «ложится» со 100% загрузкой CPU, хотя 99% запросов должны обслуживаться из кэша.

Причина кроется в конкурентности. Распределенный кэш и база данных — это две независимые системы. Когда тысячи горутин пытаются одновременно читать и писать в них, возникают специфические аномалии High-Load, разрушающие консистентность и доступность.

Cache Aside Race: гонка состояний

Даже если вы используете строгую инвалидацию (например, удаляете ключ из кэша при записи в БД), стратегия Cache-Aside остается уязвимой к состоянию гонки (Race Condition) при одновременном чтении и записи.

Рассмотрим классический сценарий, в котором участвуют две горутины: Поток А (читает данные) и Поток Б (обновляет данные).

Механика возникновения аномалии:

  1. Поток А запрашивает данные из кэша, получает Cache Miss и идет в БД. Он считывает текущее значение (например, balance = 100).
  2. Происходит переключение контекста. В этот момент приходит Поток Б с запросом на обновление. Он записывает в БД новое значение (balance = 200) и успешно удаляет ключ из кэша (инвалидация Write-Around).
  3. Поток А возобновляет работу. Он берет значение, которое прочитал на шаге 1 (balance = 100), и записывает его в кэш.

Итог: в базе данных актуальное значение 200, а в кэше — устаревшее 100. Инвалидация от Потока Б сработала «вхолостую», потому что Поток А записал старые данные после того, как Поток Б очистил кэш. Это значение останется в кэше до тех пор, пока не истечет его TTL, что нарушает консистентность.

Полностью устранить эту гонку в рамках чистого Cache-Aside крайне сложно. Архитектурные компромиссы для минимизации риска включают:

  • Короткие TTL: ограничивают время жизни неконсистентности.
  • Отложенная инвалидация (Deferred Deletion): Поток Б удаляет ключ, ждет пару секунд (пока завершатся все зависшие чтения) и удаляет ключ еще раз. Замедляет систему, но снижает риск.
  • Переход на Write-Through или CDC: если кэш обновляется строго одним фоновым процессом (например, через Debezium из WAL), гонка между читателями и писателями на уровне приложения исключается.

Cache Stampede: эффект растоптанного кэша

Если Cache Aside Race бьет по консистентности, то следующая аномалия уничтожает доступность.

Вспомним проблему Thundering Herd (Громовое стадо), которую мы разбирали при изучении примитивов синхронизации. В контексте кэширования она принимает форму Cache Stampede (Массовый промах кэша).

Представьте высоконагруженный эндпоинт, который отдает главную страницу магазина (ключ main_page_data). Ключ имеет TTL = 5 минут. При нагрузке 5000 RPS этот ключ запрашивается 5000 раз в секунду.

Пока ключ жив, база данных отдыхает. Но как только TTL истекает, ключ удаляется из Redis. В следующую же миллисекунду сотни горутин одновременно обращаются к кэшу, получают Cache Miss и все вместе идут в базу данных выполнять тяжелый запрос для генерации главной страницы.

База данных мгновенно исчерпывает пул соединений, CPU уходит в полку, запросы начинают отваливаться по таймауту. Хуже того, те горутины, которые все же дождались ответа от БД, начинают одновременно писать огромный JSON обратно в Redis, перегружая сеть.

Singleflight: объединение запросов

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

Использовать глобальный sync.RWMutex нельзя — это убьет пропускную способность всей системы (мы видели это при профилировании монолита). Нам нужна блокировка на уровне конкретного ключа. Этот архитектурный паттерн называется Request Coalescing (Объединение запросов), а в экосистеме Go он известен под именем Singleflight.

Механика Singleflight:

  1. Первая горутина, получившая Cache Miss для ключа X, создает локальную блокировку для этого ключа и уходит в БД.
  2. Все последующие горутины, запрашивающие ключ X, видят, что запрос уже выполняется. Они не идут в БД, а «подписываются» на ожидание результата первой горутины.
  3. Когда первая горутина возвращает данные и кладет их в кэш, она транслирует этот же ответ всем ожидающим горутинам в памяти приложения.

Паттерн Singleflight радикально срезает пики нагрузки на БД, превращая 5000 конкурентных запросов в 1.

Probabilistic Early Expiration (Алгоритм XFetch)

Singleflight — мощный инструмент, но у него есть архитектурный изъян. Горутины, ожидающие результата первого запроса, простаивают. Если тяжелый запрос в БД выполняется 2 секунды, то при 5000 RPS у вас скопится 10 000 спящих горутин. Это увеличивает потребление памяти (риск OOM) и гарантированно портит метрику p99 Latency для этих пользователей.

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

Алгоритм Probabilistic Early Expiration (также известный как XFetch) решает эту задачу элегантным математическим путем. Идея в том, чтобы искусственно «состарить» TTL для небольшой доли запросов по мере приближения к реальному времени протухания.

Вместо обычной проверки currentTime>expirationTimecurrentTime > expirationTime, алгоритм использует формулу с элементом случайности:

currentTimeΔ×ln(rand(0,1))>expirationTimecurrentTime - \Delta \times \ln(rand(0, 1)) > expirationTime

Где:

  • Δ\Delta — время, необходимое для пересчета кэша (например, 2 секунды).
  • rand(0,1)rand(0, 1) — случайное число от 0 до 1.
  • ln\ln — натуральный логарифм. Выражение ln(rand(0,1))-\ln(rand(0, 1)) дает экспоненциальное распределение: чаще всего это числа близкие к 0, но иногда могут быть больше 1.

Как это работает на практике: Допустим, TTL ключа истекает в 12:00:00. В 11:59:50 (за 10 секунд до конца) формула почти всегда будет выдавать false. Все клиенты получают данные из кэша мгновенно. В 11:59:58 (за 2 секунды до конца) вероятность того, что формула выдаст true, возрастает. Один из запросов, попавший на true, становится «избранным». Приложение немедленно отдает этому клиенту старые данные из кэша (без задержек!), но параллельно запускает асинхронную горутину, которая идет в БД, вычисляет новое значение и обновляет кэш.

К моменту реального наступления 12:00:00 кэш уже свежий, и его TTL сброшен. Ни один клиент не испытал задержки Cache Miss.

В реальных высоконагруженных системах эти подходы часто комбинируют: XFetch используется для поддержания горячих ключей всегда свежими без просадки Latency, а Singleflight выступает страховкой на случай полного отсутствия ключа (например, при холодном старте системы или вытеснении ключа по нехватке памяти).

Паттерны высокой доступности кэша: Репликация, Шардирование и Client-side Consistent Hashing

Паттерны высокой доступности кэша: Репликация, Шардирование и Client-side Consistent Hashing

Один инстанс Redis способен обрабатывать сотни тысяч запросов в секунду. Эта производительность создает иллюзию неуязвимости, которая разрушается в момент отказа сервера. Если кэш, принимающий на себя 100 000 RPS, внезапно исчезает, весь этот трафик мгновенно обрушивается на базу данных. База, спроектированная держать максимум 5 000 RPS, ложится за секунды. Быстрый кэш бесполезен, если он является единой точкой отказа (SPOF).

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

Репликация кэша: смена приоритетов

Архитектурный паттерн Leader-Follower применяется в кэшировании так же, как и в базах данных: Leader принимает операции записи, а Followers обслуживают чтение. Однако приоритеты здесь кардинально отличаются.

В реляционных БД репликация нужна в первую очередь для сохранности данных (Durability). В распределенном кэше потеря данных не фатальна — это всего лишь временный Cache Miss, который приложение умеет обрабатывать. Главная цель репликации кэша — сохранение пропускной способности на чтение (Availability).

Если падает Leader-узел БД, мы готовы пожертвовать временем (секундами или минутами) на аккуратный Failover, чтобы не допустить Split-Brain и потери транзакций. Если падает узел кэша, Failover должен быть практически мгновенным. Например, в экосистеме Redis за это отвечает компонент Redis Sentinel, который непрерывно мониторит узлы и автоматически переключает трафик на Follower при отказе лидера.

Репликация кэша защищает базу данных от лавинообразного трафика, обеспечивая непрерывность обслуживания горячих чтений даже при аппаратных сбоях.

Шардирование кэша: преодоление лимитов одного узла

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

Когда объем горячих данных превышает разумные пределы одного сервера, применяется шардирование.

В отличие от баз данных, где шардирование усложняется необходимостью распределенных транзакций (2PC) и Cross-shard Joins, шардирование кэша реализуется значительно проще. Кэш — это плоское Key-Value хранилище. Ключи независимы друг от друга, поэтому данные идеально поддаются горизонтальному разделению.

Главный архитектурный вопрос при шардировании кэша: кто именно решает, на какой шард отправить запрос?

Client-side Consistent Hashing: умный клиент

В классической архитектуре баз данных маршрутизацией часто управляет промежуточный Smart Router (например, PgBouncer или Envoy), чтобы скрыть топологию от приложения. Для кэша каждый дополнительный сетевой прыжок (Network Hop) — это потеря драгоценных долей миллисекунды.

Поэтому в высоконагруженных системах (особенно при использовании Memcached) исторически стандартом де-факто стало Client-side шардирование.

Логика маршрутизации встраивается прямо в библиотеку клиента (например, в Go-приложение). Клиент хранит в своей оперативной памяти карту кластера. Когда коду нужно выполнить Get("user:123"), клиент локально вычисляет хэш от ключа, определяет нужный IP-адрес шарда и отправляет запрос напрямую на него.

Для распределения ключей используется алгоритм Consistent Hashing (хэш-кольцо). Клиент размещает на виртуальном кольце все доступные узлы кэша (используя vNodes для равномерности), а затем проецирует на это же кольцо хэш запрашиваемого ключа.

Преимущества такого подхода очевидны:

  1. Нулевые накладные расходы на сеть: приложение общается с нужным шардом напрямую.
  2. Отсутствие SPOF в маршрутизации: нет единого балансировщика, который мог бы стать узким местом.

Обработка отказов при Client-side шардировании

Главное испытание для Client-side архитектуры — изменение топологии кластера. Что происходит, когда один из шардов выходит из строя?

Если бы клиент использовал наивное деление по модулю (ID(modN)ID \pmod N), падение одного узла изменило бы знаменатель NN. Это привело бы к изменению адресации для подавляющего большинства ключей. Результат — почти 100% промах кэша по всему кластеру и неминуемый отказ базы данных.

Благодаря Consistent Hashing, при удалении узла из кольца перераспределяется только 1/N\approx 1/N ключей. Те ключи, которые ранее принадлежали упавшему узлу, теперь алгоритмически «сдвигаются» по часовой стрелке к следующему живому узлу на кольце.

Клиентское приложение просто начинает запрашивать эти ключи у нового узла. Естественно, новый узел ничего не знает об этих данных (произойдет Cache Miss), приложение сходит в базу данных и запишет результат в новый узел кэша. Остальные (N1)/N(N-1)/N ключей кластера останутся на своих местах и продолжат отдаваться из кэша мгновенно.

Эволюция: от умного клиента к умному кластеру

Client-side шардирование отлично работает, когда вся система написана на одном языке (например, только на Go) и использует одну и ту же клиентскую библиотеку.

Но в микросервисной среде с полиглотной архитектурой (Go, Python, Node.js) поддержание идентичного хэш-кольца во всех клиентах становится проблемой. Если Go-клиент и Python-клиент по-разному реализуют алгоритм хэширования или имеют разный список активных узлов, они начнут искать один и тот же ключ на разных шардах. Это приведет к дублированию данных и снижению Hit Rate.

Для решения этой проблемы был создан Redis Cluster, который переносит логику маршрутизации на сторону сервера, используя концепцию Hash Slots. Клиенту больше не нужно строить кольцо — он может отправить запрос на любой узел кластера, а кластер сам перенаправит его куда нужно.

Однако, независимо от того, где принимается решение о маршрутизации (на клиенте или в кластере), распределенный кэш неизбежно сталкивается с физическим ограничением памяти. Когда шарды заполняются на 100%, система должна уметь элегантно избавляться от старых данных.

Прогрев кэша и стратегии вытеснения: LRU, LFU и TinyLFU в условиях High-Load

Прогрев кэша и стратегии вытеснения: LRU, LFU и TinyLFU в условиях High-Load

В прошлой главе мы обсуждали, как Client-side Consistent Hashing спасает систему при отказе одного из узлов кэша. Оставшиеся узлы берут нагрузку на себя, а ключи перераспределяются. Но давайте посмотрим на ситуацию с другой стороны: что происходит, когда в кластер вводится новый, абсолютно пустой узел?

В этот момент на него обрушивается его доля трафика (например, 10 000 RPS). Поскольку узел пуст, Hit Rate равен нулю. Все эти 10 000 запросов превращаются в Cache Miss и отправляются напрямую в базу данных. База, не рассчитанная на такой резкий скачок прямого чтения, может мгновенно исчерпать пул соединений и лечь. Это классическая проблема холодного старта.

Прогрев кэша (Cache Warming)

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

В High-Load системах используют два основных подхода к прогреву:

  1. Скриптовый прогрев (Background Worker). При старте приложение или отдельный воркер делает тяжелый аналитический запрос к БД (например, «топ-10000 самых просматриваемых товаров за сутки») и принудительно складывает их в кэш. Только после завершения этого скрипта узел помечается как Ready (например, через Kubernetes Readiness Probe) и балансировщик начинает лить на него трафик.
  2. Теневой трафик (Traffic Shadowing). Балансировщик дублирует реальные запросы пользователей на новый пустой узел в асинхронном режиме. Ответы от пустого узла игнорируются, но сам факт запроса заставляет его сходить в БД (или к соседним узлам кэша) и сохранить данные. Через несколько минут Hit Rate нового узла достигает приемлемых 80-90%, и его можно переводить в синхронный режим обслуживания.

Но прогрев решает лишь проблему старта. Рано или поздно физическая оперативная память узла (например, лимит maxmemory в Redis) заполнится на 100%. В этот момент кэш должен решить, какие старые данные удалить, чтобы освободить место для новых.

Стратегии вытеснения: от простого к сложному

Алгоритм, принимающий решение об удалении данных при нехватке памяти, называется Eviction Policy (стратегия вытеснения). Идеальный алгоритм должен предсказывать будущее: удалять то, что больше никогда не запросят. Поскольку это невозможно, алгоритмы опираются на прошлое.

LRU (Least Recently Used)

Самый известный и распространенный алгоритм. Его логика: если к элементу давно не обращались, вероятность обращения к нему в будущем минимальна.

Технически классический LRU реализуется через комбинацию хэш-таблицы (для доступа за O(1)O(1)) и двусвязного списка. При каждом чтении или записи элемент перемещается в «голову» списка (самые свежие). Когда память заканчивается, алгоритм просто удаляет элемент из «хвоста» (самые старые).

Уязвимость LRU: Алгоритм абсолютно беззащитен перед полным сканированием (Full Scan). Представьте, что ночью запускается скрипт резервного копирования или аналитики, который последовательно читает миллионы редко используемых записей из БД через слой кэширования. Каждая прочитанная запись попадет в «голову» LRU-списка. В результате этот скрипт вытеснит из кэша все реально горячие данные (например, профили активных пользователей). Утром, когда придут пользователи, система получит 0% Hit Rate и упадет.

LFU (Least Frequently Used)

Чтобы решить проблему сканирования, был придуман LFU. Его логика: важно не то, когда обращались к элементу, а то, как часто это делали.

LFU хранит счетчик обращений для каждого ключа. При нехватке памяти удаляется элемент с наименьшим значением счетчика. Ночной аналитический скрипт обратится к миллиону записей ровно по одному разу (счетчик = 1). Горячие профили пользователей имеют счетчики в тысячи хитов. Поэтому скрипт не сможет вытеснить горячие данные.

Уязвимость LFU: Проблема «исторического багажа». Допустим, у вас новостной сайт. Статья о громком событии за день набирает 500 000 просмотров (счетчик = 500 000). На следующий день новость устаревает, и ее перестают читать. Но из-за огромного накопленного счетчика LFU будет вечно хранить эту статью в кэше, вытесняя свежие, но еще не набравшие популярность новости.

W-TinyLFU: Современный стандарт

Инженеры долго искали компромисс. LRU отлично адаптируется к новым трендам, но боится сканирований. LFU защищен от сканирований, но не умеет забывать прошлое. Кроме того, классический LFU требует слишком много памяти для хранения счетчиков (по 4-8 байт на каждый ключ).

Решением стал алгоритм Window TinyLFU (W-TinyLFU), который сейчас используется в самых высокопроизводительных in-memory кэшах, таких как Caffeine (Java) и Ristretto (Go).

W-TinyLFU решает проблемы предшественников с помощью трех элегантных механизмов.

1. Count-Min Sketch для экономии памяти

Вместо того чтобы хранить точный счетчик для каждого ключа, TinyLFU использует вероятностную структуру данных — Count-Min Sketch. Она работает похоже на фильтр Блума, но вместо битов использует небольшие счетчики (обычно 4 бита, что позволяет считать до 15).

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

2. Механизм Decay (Затухание)

Чтобы решить проблему «исторического багажа» LFU, TinyLFU использует периодическое затухание. Как только общее количество обращений ко всему кэшу достигает определенного предела (например, размера кэша), все счетчики в Count-Min Sketch делятся пополам (сдвиг вправо на 1 бит).

Вчерашняя новость с частотой 15 превратится в 7, затем в 3, 1 и 0. Таким образом, старые тренды быстро «забываются», уступая место новым.

3. Admission Window (Окно допуска)

Это главная инновация W-TinyLFU, объединяющая LRU и LFU. Кэш физически разделен на две зоны:

  • Window Cache (Окно допуска): Крошечный LRU-кэш (обычно 1% от общей памяти). Сюда попадают абсолютно все новые элементы. Это защищает систему от проблемы LFU, давая новым элементам время набрать частоту.
  • Main Cache (Основной кэш): Большой кэш (99% памяти), защищенный алгоритмом SLRU (Segmented LRU).

Как происходит вытеснение (Admission): Когда Window Cache переполняется, элемент из его «хвоста» (кандидат А) пытается попасть в Main Cache. Но Main Cache тоже полон, и у него в хвосте есть свой кандидат на вылет (кандидат Б).

Здесь вступает в игру Count-Min Sketch. Алгоритм запрашивает историческую частоту обоих кандидатов:

  • Если частота нового кандидата А больше, чем у старого кандидата Б, то Б удаляется навсегда, а А занимает его место в Main Cache.
  • Если частота А меньше, то А (новый элемент) отбрасывается, не успев попасть в основной кэш, а Б остается жить.

Именно этот механизм спасает от ночного сканирования: миллионы новых записей от аналитического скрипта попадут в Window Cache, но при попытке проникнуть в Main Cache они проиграют соревнование горячим данным (так как их частота по Count-Min Sketch равна 1), и будут мгновенно отброшены.

Понимание того, как кэш управляет памятью, критически важно для настройки in-memory решений в Go. В следующей, финальной главе мы перейдем к практике: напишем собственный слой кэширования на Go, реализуем паттерн Singleflight для защиты от Cache Stampede и разберем особенности работы с распределенным Redis Cluster.

Практика на Go: реализация паттерна Singleflight и работа с Redis Cluster

Практика на Go: реализация паттерна Singleflight и работа с Redis Cluster

В момент массового промаха кэша счет идет на миллисекунды. Если кэш пуст, а система обрабатывает 10 000 RPS, у вас есть доля секунды до того, как тысячи горутин одновременно откроют соединения с базой данных и вызовут исчерпание ресурсов. Архитектурные паттерны защиты от этой аномалии требуют точной реализации на уровне кода приложения и правильной топологии самого кэша.

В этой статье мы спустимся на уровень кода: реализуем объединение запросов в Go, чтобы защитить базу данных, и разберем, как Redis Cluster решает проблемы маршрутизации, с которыми не справляется клиентское шардирование.

Singleflight: объединение запросов под капотом

Для защиты от Cache Stampede экосистема Go предоставляет готовый примитив в пакете golang.org/x/sync/singleflight. Его задача — гарантировать, что для множества конкурентных вызовов с одинаковым ключом реальная функция выполнится ровно один раз.

Основной инструмент пакета — структура singleflight.Group. Она работает как потокобезопасная хэш-таблица активных вызовов.

Когда горутина вызывает метод Do(key, fn), происходит следующее:

  1. singleflight проверяет, есть ли уже активный вызов для key.
  2. Если нет — блокирует ключ, запускает fn в текущей горутине и сохраняет результат.
  3. Если вызов уже идет — горутина блокируется и ждет завершения чужого fn.
  4. Когда fn завершается, результат раздается всем ожидающим горутинам, и ключ удаляется из активных.

Ловушка контекста: почему Do() опасен в production

Наивная реализация выглядит просто:

var sg singleflight.Group

func getUser(ctx context.Context, id string) (User, error) {
    // Ключ объединения
    key := "user_" + id

    v, err, _ := sg.Do(key, func() (interface{}, error) {
        // Идем в БД
        return db.FetchUser(ctx, id)
    })

    if err != nil {
        return User{}, err
    }
    return v.(User), nil
}

В High-Load системах этот код содержит критическую уязвимость, связанную с context.Context.

Представьте ситуацию: 100 запросов хотят получить профиль пользователя. Первый запрос инициирует поход в БД и передает свой контекст в db.FetchUser. Остальные 99 запросов ждут. Внезапно клиент, инициировавший первый запрос, отваливается по таймауту (или нажимает «Отмена»). Контекст первого запроса отменяется. Функция FetchUser прерывается с ошибкой context.Canceled.

Итог: все 100 запросов получают ошибку, хотя 99 клиентов все еще готовы ждать результат. Хуже того, если FetchUser не поддерживает отмену по контексту, а первая горутина просто уходит, остальные 99 могут зависнуть навсегда.

Для безопасной работы с контекстами необходимо использовать метод DoChan, который возвращает канал для асинхронного ожидания результата.

func getUserSafe(ctx context.Context, id string) (User, error) {
    key := "user_" + id

    // DoChan возвращает канал, в который прилетит результат
    ch := sg.DoChan(key, func() (interface{}, error) {
        // Важно: используем фоновый контекст для БД,
        // чтобы отмена одного клиента не убила запрос для всех
        return db.FetchUser(context.Background(), id)
    })

    select {
    case <-ctx.Done():
        // Текущий клиент отвалился, уходим, не дожидаясь ответа.
        // Запрос в БД продолжает выполняться для остальных!
        return User{}, ctx.Err()
    case res := <-ch:
        // Результат получен
        if res.Err != nil {
            return User{}, res.Err
        }
        return res.Val.(User), nil
    }
}

От клиентского шардирования к Redis Cluster

Singleflight защищает БД на уровне одного инстанса приложения (App Node). Но чтобы кэш выдержал огромный объем данных и трафика, сам слой кэширования должен быть распределенным.

Ранее мы выяснили, что клиентское шардирование (Client-side Sharding) с консистентным хэшированием отлично работает, пока топология статична. Но в полиглотной микросервисной среде поддерживать идентичные хэш-кольца в клиентах на Go, Python и Node.js при добавлении новых узлов кэша становится архитектурным кошмаром.

Redis Cluster решает эту проблему, перенося логику распределения данных на сторону самого хранилища, но при этом не превращаясь в единую точку отказа (как это делают классические прокси-серверы).

Архитектура Hash Slots

Вместо классического хэш-кольца Redis Cluster использует концепцию Hash Slots (хэш-слотов). Весь диапазон возможных ключей жестко разбит ровно на 16 384 слота.

Когда вы записываете ключ, Redis вычисляет его слот по формуле: Slot=CRC16(key)(mod16384)Slot = CRC16(key) \pmod{16384}

Каждый Master-узел в кластере отвечает за свой непрерывный диапазон слотов. Например, в кластере из трех узлов:

  • Узел A хранит слоты от 0 до 5460.
  • Узел B хранит слоты от 5461 до 10922.
  • Узел C хранит слоты от 10923 до 16383.

Если нужно добавить новый сервер (Узел D), кластер просто переносит часть слотов (вместе с данными) с существующих узлов на новый. Формула вычисления слота при этом не меняется, меняется только таблица соответствия «Слот \rightarrow Узел».

Умная маршрутизация и MOVED Redirect

Как Go-приложение понимает, на какой именно IP-адрес отправлять запрос для ключа user_123?

Redis Cluster использует концепцию Smart Client (умного клиента). Клиентская библиотека (например, go-redis/redis/v9) при старте подключается к любому узлу кластера и скачивает актуальную карту слотов (командой CLUSTER SLOTS). Клиент кэширует эту карту в памяти Go-приложения.

Далее маршрутизация происходит локально:

  1. Приложение хочет прочитать user_123.
  2. Go-клиент вычисляет CRC16("user_123")(mod16384)=12055CRC16("user\_123") \pmod{16384} = 12055.
  3. Клиент смотрит в локальную карту: слот 12055 находится на Узле C.
  4. Запрос летит напрямую на Узел C.

Но что произойдет, если кластер прямо сейчас масштабируется, и слот 12055 уже переехал на Узел D, а Go-клиент еще не успел обновить свою локальную карту?

Запрос прилетит на Узел C. Узел C вычислит слот, поймет, что больше им не владеет, и не будет проксировать запрос на Узел D. Вместо этого он вернет клиенту специальную ошибку: -MOVED 12055 192.168.1.15:6379

Получив ответ MOVED, Go-клиент понимает, что его локальная топология устарела. Он прозрачно для разработчика:

  1. Обновляет адрес для слота 12055 на новый IP.
  2. Фоном запрашивает полную новую карту кластера.
  3. Повторяет исходный запрос уже на правильный узел.

Архитектурный синтез: Singleflight + Redis Cluster

Теперь объединим оба механизма в единый монолитный слой доступа к данным. Это классический паттерн для High-Load сервисов на Go.

type UserCache struct {
    rdb *redis.ClusterClient
    sg  *singleflight.Group
    db  *Database
}

func (c *UserCache) GetUser(ctx context.Context, id string) (User, error) {
    cacheKey := "user:" + id

    // 1. Быстрая проверка кэша (Redis Cluster сам выберет нужный шард)
    val, err := c.rdb.Get(ctx, cacheKey).Result()
    if err == nil {
        return parseUser(val), nil
    }
    if err != redis.Nil {
        return User{}, err // Сетевая ошибка кэша
    }

    // 2. Cache Miss. Защищаем БД через Singleflight
    ch := c.sg.DoChan(cacheKey, func() (interface{}, error) {
        // Идем в БД с фоновым контекстом
        user, err := c.db.FetchUser(context.Background(), id)
        if err != nil {
            return nil, err
        }

        // Сохраняем в кэш перед возвратом (Write-Around / Lazy Load)
        c.rdb.Set(context.Background(), cacheKey, serialize(user), time.Hour)
        return user, nil
    })

    // 3. Ждем результат с учетом таймаута текущего запроса
    select {
    case <-ctx.Done():
        return User{}, ctx.Err()
    case res := <-ch:
        if res.Err != nil {
            return User{}, res.Err
        }
        return res.Val.(User), nil
    }
}

В этой архитектуре:

  • redis.ClusterClient берет на себя вычисление слотов и обработку MOVED редиректов, обеспечивая горизонтальное масштабирование кэша без единой точки отказа.
  • singleflight.Group с методом DoChan гарантирует, что даже при истечении TTL (Cache Stampede) в базу данных уйдет строго один запрос, а отмена контекста нетерпеливым клиентом не сломает логику для остальных ожидающих горутин.

Такой слой данных способен выдерживать колоссальные всплески трафика, сохраняя предсказуемый Latency и защищая медленные дисковые хранилища от перегрузки.