Четырёхшаговый шаблон
Дефект заключён в четырёх последовательных, неатомарных шагах:
// ДЕФЕКТ
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
Шаг 1: проверить кэш. Шаг 2: промах. Шаг 3: вычислить. Шаг 4: сохранить. Все четыре шага: неатомарны. Между шагом 1 и шагом 4 любое количество потоков может выполнить шаг 1 и все увидят null.
Ловушка идемпотентности
Рассуждение, защищающее этот паттерн: «допустимо, если два потока вычислят и сохранят одно и то же значение. Результат идемпотентен. Повреждения данных не происходит».
Это рассуждение: верно с точки зрения корректности. Фатально с точки зрения стоимости.
При 1 000 потоков на промахе кэша: 1 000 потоков каждый выполняет expensiveCompute(key). Если expensiveCompute делает запрос к базе данных, одновременно отправляется 1 000 запросов к БД. Если он вызывает внешний сервис, одновременно отправляется 1 000 HTTP-запросов. Система, выдающая корректные результаты, рушится под нагрузкой их получения.
Три триггера
Thundering Herd срабатывает, когда ключ кэша одновременно переходит из тёплого состояния в холодное у множества потоков:
Холодный старт: сервис перезапускается с пустым кэшем. Первая волна запросов: каждый ключ промахивается. Все вычисления происходят одновременно.
Перезапуск сервиса: rolling restart сбрасывает кэш на всех инстансах. Трафик перераспределяется на холодные инстансы.
Истечение TTL: ключ с высокой нагрузкой истекает. N потоков одновременно проверяют, все получают промах, все начинают вычисление до того, как первый поток сохранит результат.
Все три триггера: коррелируют со всплесками трафика. Стадо срабатывает, когда трафик достигает пика, а кэш холодный. Худшее возможное время.
Пример Elasticsearch EnrichCache
Elasticsearch EnrichCache: комментарий в документации гласит «intentionally non-locking for simplicity...OK if we re-put the same key/value in a race condition». При 10 000 документов в секунду с холодным enrich-кэшем: все 10 000 запросов одновременно обращаются к enrich-индексу. Enrich-индекс, рассчитанный на редкие запросы, получает 10 000 параллельных запросов. Кластер дестабилизируется.
Логика идемпотентности: верна в комментарии к коду. Катастрофична при 10 000 документов в секунду.
Связь с MOAD-0001
MOAD-0001 (Sedimentary Defect) создаёт узкое место O(N²) в высоконагруженных системах. Исправление MOAD-0001 (O(N²) → O(N)) снимает ограничение на этой рабочей станции. Увеличение пропускной способности отправляет больше запросов вниз по потоку. Кэши ниже по потоку, ранее защищённые узким местом MOAD-0001, теперь получают коррелированные всплески трафика. MOAD-0005 срабатывает в кэшах, где раньше не срабатывал. Исправляешь один MOAD — подготавливаешь другой.
Что не так с ловушкой идемпотентности
Комментарий Elasticsearch представляет собой тщательное инженерное рассуждение, применённое к неправильному вопросу. Идемпотентность — реальное свойство, о котором стоит рассуждать. Ловушка — остановка анализа на уровне корректности без продолжения до оценки стоимости.
computeIfAbsent и singleflight
Исправление: сделать проверку и вычисление атомарными. Один поток выполняет вычисление. Все остальные потоки ждут результата.
Java: computeIfAbsent
// ДЕФЕКТ: четыре неатомарных шага
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
// FIX: атомарная проверка-и-вычисление
return cache.computeIfAbsent(key, k -> expensiveCompute(k));
computeIfAbsent: если ключ отсутствует, вычисляет ровно один раз, сохраняет и возвращает. Все остальные потоки, вызывающие computeIfAbsent для того же ключа, ждут завершения первого вычисления. Без N-кратного вычисления. Без stampede.
Go: singleflight.Group
var g singleflight.Group
func getOrCompute(key string) (Value, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
return expensiveCompute(key)
})
return v.(Value), err
}
singleflight: если вычисление для ключа уже выполняется, все вызывающие для того же ключа ждут и делят единый результат. Одно вычисление, N ожидающих, один результат для всех. Абстракция «flight»: дедупликация запросов, находящихся в процессе выполнения.
Lock vs singleflight
Наивная блокировка по ключу сериализует: поток 1 вычисляет, поток 2 ждёт, поток 3 ждёт. После завершения потока 1 поток 2 входит и проверяет кэш (попадание). Поток 3 входит и проверяет кэш (попадание). N-1 захватов блокировки и чтений кэша.
singleflight дедуплицирует: поток 1 вычисляет, потоки 2…N ждут результата потока 1. Нет отдельных захватов блокировки. Нет отдельных чтений кэша. Одно вычисление, один результат, раздаваемый N ожидающим. Меньше операций, чем при блокировке по ключу.
Оба подхода предотвращают «штампед». singleflight предотвращает избыточную работу более полно.
Перепишите паттерн
Примените исправление к конкретному сценарию.
// Кэш профиля пользователя в высоконагруженном Java-сервисе
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // дорогостоящий запрос: 50 мс к БД
profileCache.put(userId, profile);
}
return profile;
}
Сервис перезапускается каждое утро в 2:00. В 8:00 10 000 пользователей одновременно запрашивают свои профили.