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

un

gość
1 / ?
powrót do lekcji

Wzorzec czterech kroków

Defekt tkwi w czterech sekwencyjnych, nieatomowych krokach:

// DEFECT
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;

Krok 1: sprawdź pamięć podręczną. Krok 2: chybienie. Krok 3: oblicz. Krok 4: zapisz. Wszystkie cztery kroki: nieatomowe. Pomiędzy krokiem 1 a 4 dowolna liczba wątków może wykonać krok 1 i wszystkie zobaczą null.

Pułapka idempotencji

Rozumowanie chroniące ten wzorzec: „to OK, jeśli dwa wątki obliczą i zapiszą tę samą wartość. Wynik jest idempotentny. Nie dochodzi do uszkodzenia danych.”

To rozumowanie: poprawne pod względem poprawności. Fatalne pod względem kosztu.

Przy 1 000 wątkach przy chybieniu w pamięci podręcznej: 1 000 wątków wykonuje expensiveCompute(key). Jeśli expensiveCompute odpytuje bazę danych, jednocześnie uruchamianych jest 1 000 zapytań do bazy. Jeśli wywołuje usługę zewnętrzną, jednocześnie wysyłanych jest 1 000 żądań HTTP. System, który zwraca poprawne wyniki, załamuje się pod ciężarem ich generowania.

Trzy wyzwalacze

Thundering Herd uruchamia się, gdy klucz pamięci podręcznej przechodzi ze stanu ciepłego do zimnego jednocześnie w wielu wątkach:

Zimny start: usługa uruchamia się ponownie z pustą pamięcią podręczną. Pierwsza fala żądań: każdy klucz chybia. Wszystkie obliczenia odbywają się jednocześnie.

Restart usługi: restart kroczący resetuje pamięć podręczną na wszystkich instancjach. Ruch jest przekierowywany na zimne instancje.

Wygaśnięcie TTL: klucz o wysokim ruchu wygasa. N wątków sprawdza, wszystkie chybiają i wszystkie obliczają, zanim pierwszy wątek zapisze wynik.

Wszystkie trzy wyzwalacze: skorelowane ze skokami ruchu. Stado uruchamia się, gdy ruch osiąga szczyt, a pamięć podręczna jest zimna. Najgorszy możliwy moment.

Przykład Elasticsearch EnrichCache

Elasticsearch EnrichCache: komentarz w dokumentacji brzmi „celowo bez blokad dla prostoty… OK, jeśli w warunkach wyścigu ponownie wstawimy ten sam klucz/wartość”. Przy 10 000 dokumentów na sekundę i zimnej pamięci podręcznej wzbogacania: wszystkie 10 000 żądań trafia jednocześnie do indeksu wzbogacania. Indeks wzbogacania, zaprojektowany do okazjonalnych zapytań, musi obsłużyć 10 000 równoczesnych zapytań. Klaster staje się niestabilny.

Rozumowanie idempotentności: poprawne w komentarzu kodu. Katastrofalne przy 10 000 dokumentów na sekundę.

Powiązanie z MOAD-0001

MOAD-0001 (Defekt Osadowy) tworzy wąskie gardło O(N²) w systemach o wysokiej przepustowości. Naprawienie MOAD-0001 (z O(N²) do O(N)) odblokowuje tę stację roboczą. Szybsza przepustowość wysyła więcej żądań dalej. Pamięci podręczne downstream, wcześniej chronione przez wąskie gardło MOAD-0001, otrzymują teraz skorelowane skoki ruchu. MOAD-0005 uruchamia się w pamięciach podręcznych, które nigdy wcześniej go nie wyzwalały. Napraw jeden MOAD; przygotuj następny.

Co jest nie tak z pułapką idempotentności

Komentarz Elasticsearch reprezentuje staranne rozumowanie inżynierskie zastosowane do niewłaściwego pytania. Idempotentność: rzeczywista właściwość, o której warto myśleć. Pułapka: zatrzymanie analizy na poziomie poprawności bez kontynuowania do kosztu.

Dlaczego rozumowanie „to OK, jeśli dwa wątki zapiszą ten sam wynik” prowadzi do błędu? Co jest w nim poprawne, a co pomija?

computeIfAbsent & singleflight

Rozwiązanie: uczyń operację sprawdź-i-oblicz atomową. Jeden wątek wykonuje obliczenie. Wszystkie pozostałe wątki czekają na wynik.

Java: computeIfAbsent

// DEFECT: cztery nieatomowe kroki
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;

// POPRAWKA: atomowe sprawdzenie-i-obliczenie
return cache.computeIfAbsent(key, k -> expensiveCompute(k));

computeIfAbsent: jeśli klucz nie istnieje, oblicza dokładnie raz, zapisuje i zwraca. Wszystkie inne wątki wywołujące computeIfAbsent dla tego samego klucza czekają na pierwsze obliczenie. Brak N-krotnego obliczenia. Brak efektu 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: jeśli obliczenie dla klucza jest już w toku, wszyscy wywołujący ten sam klucz czekają i współdzielą pojedynczy wynik. Jedno obliczenie, N oczekujących, jeden wynik współdzielony. Abstrakcja „flight”: deduplikacja żądań w locie.

Lock vs singleflight

Naiwna blokada na klucz serializuje: wątek 1 oblicza, wątek 2 czeka, wątek 3 czeka. Po zakończeniu wątku 1, wątek 2 wchodzi i sprawdza pamięć podręczną (trafienie). Wątek 3 wchodzi i sprawdza pamięć podręczną (trafienie). N-1 akwizycji blokady i odczytów pamięci podręcznej.

singleflight deduplikuje: wątek 1 oblicza, wątki 2 do N czekają na wynik wątku 1. Brak osobnych akwizycji blokady. Brak osobnych odczytów pamięci podręcznej. Jedno obliczenie, jeden wynik, rozprowadzony do N oczekujących. Mniejsza liczba operacji niż przy blokadzie na klucz.

Oba mechanizmy zapobiegają burzy. singleflight zapobiega zbędnej pracy w większym stopniu.

Przepisz wzorzec

Zastosuj poprawkę do konkretnego scenariusza.

// Pamięć podręczna profilu użytkownika w usłudze Java o wysokim ruchu
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId);  // kosztowne: zapytanie do bazy 50 ms
profileCache.put(userId, profile);
}
return profile;
}

Usługa restartuje się codziennie o 2:00 rano. O 8:00 rano 10 000 użytkowników jednocześnie żąda swoich profili.

Zidentyfikuj defekt, podaj moment jego wystąpienia i przepisz kod używając computeIfAbsent.