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

un

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

Чотириетапний шаблон

Дефект міститься в чотирьох послідовних, неатомарних кроках:

// DEFECT
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 спрацьовує, коли ключ кешу одночасно переходить зі стану warm у cold у багатьох потоках:

Холодний старт: сервіс перезапускається з порожнім кешем. Перша хвиля запитів: кожен ключ промахується. Усі обчислення відбуваються одночасно.

Перезапуск сервісу: rolling restart скидає кеш на всіх інстансах. Трафік перерозподіляється на холодні інстанси.

Закінчення TTL: ключ з високим трафіком завершує свій 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 документів на секунду з холодним кешем збагачення: усі 10 000 запитів одночасно звертаються до індексу збагачення. Індекс збагачення, розрахований на періодичні запити, отримує 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;

// ВИПРАВЛЕННЯ: атомарна перевірка-і-обчислення
return cache.computeIfAbsent(key, k -> expensiveCompute(k));

computeIfAbsent: якщо ключа немає, обчислює рівно один раз, зберігає та повертає. Усі інші потоки, що викликають computeIfAbsent для того ж ключа, чекають на перше обчислення. Без N-кратного обчислення. Без штампеду.

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

Наївний per-key lock серіалізує: потік 1 обчислює, потік 2 чекає, потік 3 чекає. Після завершення потоку 1 потік 2 входить і перевіряє кеш (hit). Потік 3 входить і перевіряє кеш (hit). N-1 захоплень блокування та зчитувань кешу.

singleflight усуває дублікати: потік 1 обчислює, потоки 2–N очікують результату потоку 1. Без окремих захоплень блокування. Без окремих зчитувань кешу. Одне обчислення, один результат, розданий N очікуючим. Менше операцій, ніж при per-key lock.

Обидва запобігають stampede. 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.