Распределённые кэши
Одного узла кэша не хватает по памяти и он точка отказа. Шардирование (через консистентное хеширование) разносит ключи по узлам; репликация переживает смерть узла. Но масштабирование кэша вширь зовёт стампиды, горячие ключи и холодный старт — сбои, видимые лишь при масштабе.
Популярная страница товара кэшировалась одним горячим ключом с TTL в 60 секунд. На пике трафика в 50 000 чтений в секунду ключ истёк, и за те несколько сотен миллисекунд, что заняла его регенерация, все 50 000 запросов промахнулись мимо кэша, ничего не нашли и ударили в базу одним и тем же дорогим запросом — разом. База, размеренная под струйку промахов, что обычно просачивается сквозь кэш с 99% попаданий, упала. Страница вернулась, ключ прогрелся, трафик возобновился… и через 60 секунд это повторилось. Никто не написал баг. Кэш работал идеально. Сбой был структурным: при масштабе один истекающий ключ может стать синхронизированным оружием, наведённым на твою базу.
Почему одного узла кэша мало
К концу этого урока, когда кто-то предложит «просто добавить ещё один узел кэша», ты будешь знать, какие именно вопросы нужно задать — и какие тихие режимы отказа они, возможно, не учли.
Один узел кэша упирается в две стены при масштабировании. Ёмкость: рабочий набор перерастает RAM одной машины, и держать всё уже нельзя. Доступность: этот один узел — точка отказа: когда он умирает или перезапускается, hit ratio падает в ноль и (по арифметике прошлого урока) вся нагрузка чтения ложится на базу. Распределённый кэш решает оба двумя ортогональными инструментами: шардирование ради ёмкости и репликация ради выживания.
Шардирование разбивает пространство ключей по N узлам — каждый ключ живёт ровно на одном шарде, так что общая ёмкость = N × ёмкость одного узла. Репликация держит копии шарда более чем на одном узле, так что потеря узла не теряет данные и чтения могут расходиться веером по репликам. Эти двое комбинируются: боевой кэш обычно шардирован и каждый шард реплицирован. Трудный вопрос шардирования — какой ключ на какой узел — и наивные ответы проваливаются жёстко.
Шардирование: почему консистентное хеширование, а не модуло
Очевидное правило шардирования — node = hash(key) % N. Оно работает, пока N не меняется. Добавь или убери один узел — и N меняется, так что hash(key) % N меняется для почти каждого ключа — почти полный ремаппинг, инвалидирующий весь кэш разом и штурмующий базу. Консистентное хеширование (раздел про распределение данных) это чинит: ключи и узлы кладутся на хеш-кольцо, ключ принадлежит следующему узлу по часовой стрелке, и добавление/удаление узла ремаппит только ключи в одной дуге — примерно 1/N пространства — вместо всех. Это свойство — ровно то, что нужно кэшу: масштабирование кластера должно тревожить кусочек кэша, а не уничтожать его.
▸Почему это работает
Почему «двигается лишь 1/N ключей» — это свойство, важнейшее именно для кэша? Потому что в надёжном хранилище ремаппинг значит копирование данных (дорого, но корректно); в кэше ремаппинг значит шторм промахов. Каждый ключ, переезжающий на новый (пустой) узел, теперь гарантированный промах, пока не перезагрузится из базы. С модуло-хешированием масштаб с 4 до 5 узлов ремаппит ~80% ключей, так что ~80% кэша мгновенно промахивается и эта нагрузка бьёт в базу громовой волной — ровно тот сбой, ради избегания которого ты добавлял узлы. Консистентное хеширование ограничивает ущерб переехавшей дугой (~1/N), а виртуальные узлы (много позиций кольца на физический узел) сглаживают нагрузку, чтобы один узел не унаследовал непропорциональную дугу. Шардировать кэш без консистентного хеширования — превратить рутинный scale-up в сбой.
Стампида кэша (thundering herd) и как её остановить
Вступление — это стампида кэша (она же thundering herd или dogpile): горячий ключ истекает, и N параллельных читателей одновременно промахиваются и разом пересчитывают одно и то же значение, умножая нагрузку на бэкенд в N раз. Это сбой только-при-масштабе — невидим на 10 RPS, смертелен на 50 000. Три стандартные починки, по возрастанию изощрённости:
- Коалесцирование запросов (single-flight) — когда много запросов промахиваются по одному ключу одновременно, дай одному из них пересчитать, пока остальные ждут этот единственный результат, а не запускают каждый свой запрос в БД. N параллельных промахов становятся одним вызовом БД. Это рабочая лошадка.
- Ранний/вероятностный пересчёт — не жди полного истечения ключа. По мере приближения к сроку дай одному запросу обновить его заранее в фоне, чтобы горячее значение никогда не отсутствовало. Вероятностное раннее истечение (каждый читатель бросает кубик, всё более вероятный к сроку) значит, что обновляет, как правило, ровно один, без координации.
- Локи / лизы — пересчитывающий запрос берёт короткий лок (или лиз) на ключ; остальные видят лок и отдают слегка устаревшее старое значение или коротко ждут, а не валятся на бэкенд.
Эти механики — тема отдельных уроков трека про кэширование (single-flight, вероятностный ранний пересчёт, stale-while-revalidate); на высоте системного дизайна суть в том, чтобы распознать стампиду как структурный риск любого горячего TTL-ключа при масштабе и знать, что коалесцирование промахов — это ответ.
Горячие ключи, холодный старт и client-side против server-side
Шардирование предполагает, что нагрузка равномерно расходится по ключам — но у реального трафика есть горячие ключи: профиль одной знаменитости, один вирусный товар, одно глобальное значение конфига, что читает каждый запрос. Горячий ключ концентрирует весь свой трафик на единственном шарде-владельце, так что этот узел насыщается, пока остальные простаивают — шардирование не помогает, ведь ключ нельзя разбить. Починки: реплицировать горячий ключ на несколько узлов и читать из случайной реплики, или ещё кэшировать его локально в каждом процессе приложения (маленький client-side/near-кэш), чтобы большинство чтений вообще не доходило до общего кэша. Этот локальный ярус — различие client-side против server-side: server-side кэш (общий кластер Redis) консистентен по всем узлам приложения, но в одном сетевом прыжке; client-side/in-process кэш на прыжок ближе и поглощает горячие ключи, но у каждого узла приложения своя копия, и они могут расходиться — ты снова в по-узловом устаревании.
Холодный старт — другой сбой масштаба: свежеразвёрнутый или перезапущенный кэш пуст, поэтому первые минуты каждое чтение промахивается и база ест всю несмягчённую нагрузку — тот же риск, что стампида, но в масштабе кластера. Смягчения: прогреть кэш до приёма трафика, катить перезапуски постепенно (не все узлы разом) и опереться на консистентное хеширование, чтобы один заменённый узел холодно стартовал лишь свою дугу.
▸Частая ошибка
Тонкая и частая ошибка — считать, что репликация делает твой кэш строго консистентным. Реплики в кэше почти всегда асинхронны: запись в primary-шард распространяется к репликам с лагом, так что чтение с реплики может вернуть слегка старее значение, чем primary. Для кэша это обычно норм — это кэш, устаревание уже на столе — но команды, что читают-с-реплики ради масштаба и потом считают «кэш — источник истины», обжигаются, когда только что записанное значение ещё не на реплике. Кэш никогда не источник истины (это анти-паттерн следующего урока); репликация покупает тебе выживание и пропускную способность чтения, а не гарантию консистентности. Закладывай лаг реплики в свой бюджет свежести, не делай вид, что он нулевой.
Твой кэш шардирует ключи через hash(key) % N. Ты добавляешь узел, идя с 4 до 5, на пике трафика, и база немедленно падает. Почему и что надо было использовать?
Один горячий ключ с TTL 30с отдаёт 40 000 RPS. Каждые 30 секунд база коротко скачет до тысяч идентичных запросов. Что это и какова самая прямая починка?
Чтобы шардировать кэш, не уничтожая его при каждой смене масштаба, ты маршрутизируешь ключи _______ хешированием, которое двигает лишь ~1/N ключей (одну дугу кольца) при добавлении или удалении узла — вместо почти полного ремаппинга модуло, что штурмовал бы базу.
- 01Почему шардировать консистентным хешированием, а не модуло, и что добавляет репликация?
- 02Что такое стампида кэша и каковы три починки?
- 03Объясни горячие ключи, холодный старт и client-side против server-side.
Один узел кэша упирается в стену ёмкости (рабочий набор перерастает RAM) и он точка отказа, поэтому распределённый кэш использует два ортогональных инструмента: шардирование ради ёмкости и репликацию ради выживания. Шардируй консистентным хешированием, не модуло — ведь для кэша ремаппинг это шторм промахов, и модуло ремаппит ~80% ключей при scale-up 4→5, тогда как консистентное хеширование двигает лишь ~1/N ключей в одной дуге кольца (виртуальные узлы выравнивают нагрузку). Репликация держит копии каждого шарда ради выживания при потере узла и веера чтений, но она обычно асинхронна, поэтому реплики лагают — кэш никогда не источник истины. Масштаб вширь зовёт сбои, невидимые в малом: стампида кэша (горячий TTL-ключ истекает, и N параллельных чтений пересчитывают его разом, умножая нагрузку БД в N раз) останавливается коалесцированием / single-flight, вероятностным ранним пересчётом или локами; горячие ключи насыщают свой шард-владелец и хотят репликации или in-process near-кэша; а холодный старт оставляет свежий кэш пустым, смягчаясь прогревом, постепенными перезапусками и по-дуговым охватом консистентного хеширования. Глубокие механики живут в отдельном треке про кэширование; по-настоящему трудная оставшаяся проблема — держать все эти копии честными — это следующий урок, инвалидация. Теперь, когда увидишь рутинное добавление узла в кластер кэша, ты сразу спросишь: какая схема хеширования стоит и какая доля ключей холодно промахнётся в момент, когда этот узел войдёт в кластер?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.