replicate hot key, local cache, single-flight, edge cache
В каждом большом системном дизайне рано или поздно вылезает один и тот же эффект: один ключ забирает непропорциональную долю нагрузки и сжигает один шард, пока остальные простаивают. Twitter @elonmusk, Slack #general в крупной организации, Stripe whale-tenant с миллионом RPS, hot product page в Black Friday — везде одна и та же физика.
Sharding и partitioning дают теорию равномерного распределения, но мало говорят, что делать, когда распределение по факту неравномерное. Этот концепт — каталог техник плюс decision tree «когда что применять».
«Hot key = один из твоих ключей в партиционировании несёт >> 1/N всей нагрузки. Все шарды одинаковой мощности — а один сгорает. Решений два: размазать ключ (split/salt) или поглотить запросы до шарда (cache/coalesce).»
Это рамка для всего урока. Любая техника mitigation попадает в одну из двух категорий:
Выбор техники зависит от того, hot read или hot write, и от того, насколько ты готов платить памятью, согласованностью или сложностью чтения.
Канвас собран из четырёх слоёв, общих для всех сценариев:
timeline:@elonmuskshard-3 — несчастный, который ловит весь хешEdges нарисованы между всеми реально возможными парами (app → shard-N, app → singleflight → shard-N, singleflight → origin). Сами по себе они ничего не доказывают — конкретный сценарий выбирает, какие связи активны.
Базовая диагностика. Клиенты равномерно распределены (1, 2, 3, 4, толпа) и каждый просит timeline @elonmusk. App-сервер честно считает hash(@elonmusk), и каждый раз ответ — shard-3. Через пять-десять одинаковых запросов shard-3 flash'ится красным с [HOT] 80% CPU, p99 1.5s, а остальные шарды простаивают на 20%.
В проде ровно так это и выглядит на дашбордах: per-shard CPU разъезжается, p99 на одной партиции в 10× выше, managed-БД (DynamoDB, Bigtable) начинает возвращать throttling на конкретный partition key при общем headroom. Прежде чем чинить — нужно научиться этот симптом видеть.
Простейший mitigation для read-heavy hot keys. На write мы копируем @elonmusk в каждый шард (shard-1..4), а на read клиент идёт в случайный. Канвас показывает, как клиенты 1, 2, 3, 4 разлетаются по разным шардам — никакой shard-3 больше не доминирует.
Это работает только для READS и только если допустима eventual consistency на репликах. Цена: storage × N (один и тот же timeline лежит в N местах) и write amplification × N (каждый апдейт надо распространить во все реплики). Для writes это решение не подходит — туда нужен salting.
Поднимаем защиту ближе к клиенту. Каждый app-сервер держит in-memory L1 cache перед distributed cache. Сценарий показывает, как app-server → app-server рисует self-loop ([L1 HIT]) для большинства запросов, и только раз в TTL (5 секунд) выходит в shard-3 за refresh'ем.
90% запросов на hot key обслуживаются без сетевого хопа вообще. Это паттерн Stripe, Discord, GitHub. Цена: O(hot keys) × RAM на каждый app-сервер плюс отрезок eventual consistency длиной TTL — клиент может видеть устаревший timeline до 5 секунд. Для контента типа «trending tweets» это абсолютно приемлемо; для banking balance — нет.
Crash-test для cold cache. После deploy/restart L1 пустой, distributed cache пустой — и в эту секунду 1000 клиентов одновременно просят @elonmusk. Без защиты все 1000 пробивают до origin DB → cache stampede → origin падает.
Single-flight gate ловит первый запрос как leader, остальные 999 встают в очередь followers. На origin летит ровно 1 fetch, результат заливается в distributed cache и broadcast'ится всем followers. Сценарий показывает 1000 GET'ов внизу и один singleflight → origin едж сверху.
В жизни это golang.org/x/sync/singleflight, nginx proxy_cache_lock on, Cloudflare Cache Lock. Критично именно в момент cold cache — в стабильном режиме single-flight почти не срабатывает.
В ADR-001 на shard-1 зафиксирован основной принцип: не одна серебряная пуля, а слоёная защита. Ключевые трейд-оффы по слоям:
| Техника | Решает | Цена | Когда выбирать |
|---|---|---|---|
| Replicate hot key | Read scale-out | Storage × N, write × N | Read-heavy, eventual consistency ok |
| Local L1 cache | Поглощение до сети | RAM на сервер, TTL stale | Read-heavy, много app-серверов |
| CDN edge cache | Public read scale-out | Cache invalidation сложна | Публичный, не персонализированный контент |
| Single-flight | Cache stampede | Latency followers = leader | Cold cache, дорогой origin fetch |
| Salting (write split) | Write scale-out на counter | Read = scatter-gather + sum | Hot writes на агрегатах |
| Composite key | Write scale-out на entity | Сложнее routing/queries | Hot writes на одном tenant/user |
| Dedicated shard для whale | Изолировать клиента | Capacity provisioning ручной | Enterprise tenant в multi-tenant |
Основное решение в ADR: сначала detect (per-key метрики), потом absorb (L1 + CDN), потом split (replicate/salt), и только в крайнем случае dedicated capacity. Salting на read'ы — антипаттерн (без причины замедляет чтение).
message_id — это snowflake, в который зашит хеш канала. Это размазывает hot channels на write по дизайну схемы.#general в крупном enterprise → dedicated cache layer + ephemeral routing для presence-сообщений. Не пытается размазать — добавляет слой.view_count|video_42|s00..s99, write раскидан по 100 партициям, read = scatter-gather с суммой. Eventually consistent, точный счёт не нужен.Hot key mitigation — это дополнительная сложность. Прежде чем что-то добавлять, спроси:
view_count|video_X хранить агрегаты в Kafka и батчить в БД. Тогда hot key исчезает естественно.И главное: не строй абстрактную защиту от hot keys "на всякий случай". Сначала detect (per-key метрики, top-K sketch, redis-cli --hotkeys), потом mitigation под конкретный измеренный паттерн.
#general)