English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

гость
1 / ?
назад к урокам

Четырёхшаговый шаблон

Дефект заключён в четырёх последовательных, неатомарных шагах:

// ДЕФЕКТ
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 пользователей одновременно запрашивают свои профили.

Определите дефект, назовите момент его проявления и перепишите код с использованием computeIfAbsent.