SQLprogramowanieStarszy inżynier baz danych

Jakie konkretną cechę blokad doradczych PostgreSQL pozwala na sesyjnie zakresowane mutexy zapobiegające duplikowaniu kluczy biznesowych bez tworzenia kontencji na poziomie wierszy lub nadmiaru tabeli?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie.

Historia pytania.

Blokady doradcze po raz pierwszy pojawiły się w PostgreSQL 8.2 jako lekkie, aplikacyjne prymitywy synchronizacji, które działają poza systemem widoczności krotki MVCC. Zostały zaprojektowane do przepływów pracy takich jak przetwarzanie kolejek i idempotentne ładowanie, gdzie blokowanie na poziomie tabeli byłoby semantycznie niewłaściwe lub nieefektywne. W przeciwieństwie do blokad na poziomie wierszy, które są związane z określonymi krotkami tabeli i są zapisywane w systemowej kolumnie xmax, blokady doradcze całkowicie mieszczą się w menedżerze blokad pamięci współdzielonej, oferując mechanizm do zarządzania dostępem do abstrakcyjnych zasobów, nie generując martwych krotek ani ruchu WAL.

Problem.

W wysokowydajnych idempotentnych pipeline'ach ładowania, egzekwowanie unikalności kluczy biznesowych (np. zewnętrzne UUID) za pomocą tradycyjnego INSERT ... ON CONFLICT lub SELECT FOR UPDATE tworzy poważne wąskie gardła. Podejścia na poziomie wierszy wymagają zapisu do sterty, aby ustawić bity blokady, co prowadzi do nadmiaru tabeli, przyspiesza presję VACUUM i powoduje gorące punkty w unikalnych indeksach podczas rozwiązywania konfliktów. Wyzwanie polega na zapewnieniu wzajemnego wykluczenia dla podmiotów logicznych – takich jak zhaszowany klucz biznesowy – bez dotykania warstwy magazynowej, jednocześnie zapewniając, że błędy blokady nie prowadzą do wycieku zasobów do trwałych pul połączeń.

Rozwiązanie.

Krytyczną cechą jest to, że blokady doradcze są przechowywane wyłącznie w tabeli haszy LOCKTAG w pamięci współdzielonej, używając LOCKMETHOD_ADVISORY, a zatem nigdy nie modyfikują podległych stron relacji. Używając pg_advisory_xact_lock(hashtext(business_key)), aplikacja zdobywa mutex na poziomie transakcji, który automatycznie zwalnia się po COMMIT lub ROLLBACK, zapobiegając wyciekom blokady związanej z poziomem sesji pg_advisory_lock. To podejście eliminuje nadmiar tabeli i kontencję indeksów, ponieważ blokada istnieje tylko jako lekki wpis w pamięci, co pokazano poniżej:

BEGIN; -- Zdobycie blokady związanej z transakcją na zhaszowanym kluczu biznesowym SELECT pg_advisory_xact_lock(hashtext('a1b2c3d4')); -- Bezpieczne wstawienie; brak kontencji unikalnego indeksu, jeśli inna sesja trzyma blokadę INSERT INTO events (business_key, payload) VALUES ('a1b2c3d4', '{"event":"click"}') ON CONFLICT (business_key) DO NOTHING; COMMIT;

Sytuacja z życia

Zespół platformy danych w firmie telemetrycznej potrzebował zapewnić przetwarzanie dokładnie raz dla 50 000 zdarzeń na sekundę, które były wprowadzane z Kafka do PostgreSQL, gdzie każde zdarzenie miało klienta generowanego UUID, który służył jako klucz idempotentności. Wstępne testy obciążeniowe używające INSERT ... ON CONFLICT DO NOTHING na unikalnej kolumnie UUID spowodowały poważne opóźnienia ze względu na kontencję spinlocków w unikalnym indeksie B-drzewa oraz szybko narastająca nadmiar z powodu niepowodzeń aktualizacji HOT. Stopa generacji WAL podwoiła się podczas godzin szczytowych, zagrażając opóźnieniu replikacji i pojemności magazynowej.

Jedno z proponowanych rozwiązań polegało na wcześniejszym sprawdzeniu istnienia klucza za pomocą SELECT * FROM events WHERE business_key = $1 FOR UPDATE, a następnie wstawieniu tylko wtedy, gdy wynik był pusty. Chociaż zapobiegało to duplikatom, zmuszało każdego zapisywacza do zdobycia blokady wiersza na istniejącym wierszu lub surrogatowym wierszu rezerwacyjnym, co tworzyło ogromny punkt gorący na stronach tabeli rezerwacji. Podejście to generowało znaczny nadmiar tabeli, wymagając VACUUM do odzyskania martwych krotek co piętnaście minut, i nie mogło zapobiec warunkom wyścigu pomiędzy sprawdzeniem a wstawieniami bez utrzymywania blokady przez cały czas trwania transakcji, znacznie ograniczając przepustowość.

Zespół architektów zasugerował przeniesienie koordynacji do zewnętrznej pamięci podręcznej Redis za pomocą operacji SETNX, aby zablokować wstawienia. To eliminuje nadmiar bazy danych i zmniejsza obciążenie PostgreSQL, ale wprowadza krytyczne tryby awaryjne: podziały sieciowe między klastrem Redis a bazą danych mogą pozwolić na duplikaty wstawień, gdy blokada Redis wygasa, ale transakcja PostgreSQL jeszcze nie została zatwierdzona. Ponadto utrzymanie spójności w dwóch rozproszonych systemach dodaje złożoności operacyjnej i wymaga implementacji algorytmów takich jak Redlock, co zwiększa opóźnienia o około 5 milisekund na operację.

Wybrany projekt wykorzystał natywne blokady doradcze PostgreSQL poprzez pg_advisory_xact_lock(hashtext(business_key)), zdobywając blokadę związaną z transakcją na zhaszowanym UUID przed próbą wstawienia. Ponieważ te blokady żyją tylko w pamięci współdzielonej i nie dotykają sterty, nie nakładają żadnych kosztów magazynowych i automatycznie zwalniają się po zakończeniu transakcji, zapobiegając wyciekom blokady obserwowanym przy blokadach na poziomie sesji. Aby uniknąć niezauważalnych zakleszczeń, warstwa aplikacyjna posortowała wszystkie UUID w każdej partii według ich zhaszowanej wartości całkowitej przed zdobyciem blokad, zapewniając globalny protokół porządkowy wśród wszystkich równoległych pracowników.

Blokady doradcze zostały wybrane, ponieważ zapewniały najniższe opóźnienie (zdobijanie w sub-milisekundach) i zerowe skutki uboczne dotyczące magazynu, jednocześnie utrzymując ścisłą poprawność bez zewnętrznych zależności. W przeciwieństwie do podejścia Redis, czas życia blokady był związany z transakcją bazy danych, co gwarantowało atomowość pomiędzy zdobyciem blokady a zatwierdzeniem wstawienia. W przeciwieństwie do SELECT FOR UPDATE, nie generowano nadmiaru tabeli, a w przeciwieństwie do surowych ON CONFLICT, unikalny indeks nigdy nie był obciążany konfliktującymi równoczesnymi wstawieniami, ponieważ serializacja miała miejsce przed dostępem do sterty.

Po wdrożeniu, pipeline ładowania utrzymywał 80 000 zdarzeń na sekundę przy opóźnieniu p99 poniżej 10 milisekund, w porównaniu do wcześniejszych szczytów 200 ms podczas okresów kontencji. Nadmiar tabeli spadł do poziomów zaniedbywalnych, pozwalając na uruchomienie autovacuum tylko w godzinach poza szczytem, a objętość WAL zmniejszyła się o 40%, znacząco obniżając koszty archiwizacji i opóźnienia replikacji. System utrzymywał semantykę dokładnie raz przez wiele restartów bazy danych i zamieszania w pulach połączeń bez żadnych duplikatów zdarzeń ani timeoutów spowodowanych zakleszczeniem.

Co często umykają kandydatom

Dlaczego korzystanie z pg_advisory_lock (na poziomie sesji) zamiast pg_advisory_xact_lock stanowi ryzyko wyczerpania puli połączeń i duplikacji wstawek w architekturze pracowników o wysokiej przepustowości?

Kandydaci często nie zdają sobie sprawy, że pg_advisory_lock utrzymuje się aż do wyraźnego odblokowania lub rozłączenia sesji, nawet jeśli transakcja zostanie przerwana. W środowisku z pulami, gdzie pracownicy ponownie wykorzystują długoterminowe połączenia, błąd logiczny lub wyjątek, który pomija wywołanie odblokowania, pozostawia blokadę w stanie trzymania na czas nieokreślony, co powoduje, że kolejni pracownicy przetwarzający ten sam klucz biznesowy czekają wiecznie. Zamiast tego należy używać pg_advisory_xact_lock, ponieważ wiąże czas życia blokady z granicą transakcji, zapewniając automatyczne zwolnienie przy ROLLBACK i zapobiegając wyciekom mutexów, które w przeciwnym razie wygłodzą pulę pracowników i zatrzymają pipeline ładowania.

Jak brak gwarancji całkowitego porządku przy zdobywaniu wielu blokad doradczych prowadzi do niezauważalnych zakleszczeń i jaki konkretny wzorzec aplikacji eliminuje to zagrożenie?

W przeciwieństwie do zakleszczeń na poziomie wierszy, które detektor deadlock_timeout w PostgreSQL rozwiązuje poprzez zabić transakcję ofiary, zakleszczenia blokad doradczych są niewidoczne dla silnika, ponieważ zachodzą w zdefiniowanych przez użytkownika przestrzeniach nazw. Jeśli Pracownik A zablokuje zasób X, a następnie Y, podczas gdy Pracownik B zablokuje Y, a następnie X, obie sesje czekają bez końca bez błędu. Obowiązkowym wzorcem jest sortowanie wszystkich identyfikatorów zasobów (np. wartości hashtext(uuid)) w ścisłym monotonicznym porządku (rosnąco lub malejąco) w całej aplikacji przed wydaniem jakichkolwiek żądań blokady. Taki globalny porządek zapewnia, że grafy czekania pozostają acykliczne, eliminując ryzyko okrężnych zależności i niezauważalnych zawieszeń.

Jakie ograniczenia pamięci współdzielonej ograniczają liczbę blokad doradczych, które jedna transakcja może posiadać, i jak przekroczenie max_locks_per_transaction objawia się w porównaniu z wyczerpaniem blokad na poziomie wierszy?

Wielu kandydatów zakłada, że blokady doradcze są nieskończone, ale zużywają wpisy w współdzielonej tabeli blokad rządzonej parametrem konfiguracyjnym max_locks_per_transaction (domyślnie 64). Posiadanie większej liczby blokad niż ten limit w jednej transakcji podnosi ERROR: out of shared memory (SQLSTATE 53200), natychmiast przerywając transakcję. W przeciwieństwie do blokad na poziomie wierszy, gdzie przekroczenie limitów zazwyczaj prowadzi do aktualizacji blokady lub czekania w zależności od lock_timeout, ale nie wyczerpuje stałej puli pamięci współdzielonej. Łagodzenie tego problemu polega na grupowaniu operacji w mniejsze subtransakcje lub łączeniu wielu zasobów logicznych pod jednym kluczem blokady doradczej za pomocą złożonego hashowania, zamiast próbować blokować tysiące pojedynczych kluczy jednocześnie.