Blog JSystems - uwalniamy wiedzę!
Blog JSystems - uwalniamy wiedzę!
PostgreSQL
Ktoś usunął dane na produkcji, a replika od razu to powtórzyła - opóźniona replika (recovery_min_apply_delay)
Ten problem dotyczy każdego zespołu, który ma replikę strumieniową i traktuje ją jak zabezpieczenie danych. Replika chroni przed awarią serwera, ale nie przed pomyłką człowieka, bo powtarza każdą zatwierdzoną zmianę niemal od razu. Pokażemy, jak działa replika opóźniona, czyli standby, który celowo odtwarza zmiany z poślizgiem, i jak odzyskać z niej dane po DELETE bez WHERE albo po TRUNCATE. Scenariusz sprawdziliśmy na PostgreSQL 16, razem z pułapkami, które w stresie łatwo przeoczyć.
Na primary ktoś uruchamia polecenie bez warunku i je zatwierdza, a zwykła replika powtarza je niemal od razu. Na replice opóźnionej o minutę ten sam SELECT, wykonany kilka sekund później, nadal widzi wszystkie wiersze:
-- primary
shop=# DELETE FROM orders;
DELETE 1000
-- replika z recovery_min_apply_delay = 1min
shop=# SELECT count(*) FROM orders;
count
-------
1000
(1 row)
shop=# SELECT now() - pg_last_xact_replay_timestamp() AS replay_delay;
replay_delay
-----------------
00:00:36.757274
(1 row)Ostatnia funkcja pokazuje, ile czasu minęło od commita ostatniej odtworzonej transakcji. Inaczej bywa po TRUNCATE albo DROP TABLE. Gdy w czasie opóźnienia przed błędem nikt inny niczego nie zatwierdził, proces odtwarzania od razu zakłada na tej tabeli blokadę AccessExclusiveLock i zapytania o nią się zawieszają. W naszym teście pg_locks pokazał tę blokadę przy procesie startup, a SELECT z statement_timeout = '5s' skończył się komunikatem ERROR: canceling statement due to statement timeout. Przy ciągłym ruchu na primary ta sama tabela była na replice czytelna jeszcze przez prawie całe opóźnienie.
Zobacz też: Szkolenie Zaawansowana administracja PostgreSQL: High Availability, Disaster Recovery
Replikacja strumieniowa przesyła na replikę wpisy WAL, czyli dziennika zmian primary, i odtwarza je od razu. Parametr recovery_min_apply_delay zmienia to zachowanie: replika odtwarza zatwierdzenie (commit) transakcji dopiero wtedy, gdy jej zegar wskazuje co najmniej zadany czas po commicie zapisanym przez primary. Opóźnienie dotyczy tylko rekordów commit. Pozostałe wpisy WAL replika odtwarza bez dodatkowego czekania, ale po kolei, więc nie wyprzedzają wcześniejszego commita, który jeszcze czeka. Ich skutków nie widać, bo zasady MVCC (wielowersyjności wierszy) ukrywają je do chwili odtworzenia commita. Dlatego skasowane wiersze są na replice widoczne przez całe opóźnienie. Blokada z TRUNCATE albo DROP nie podlega MVCC: od chwili, gdy replika do niej dojdzie, tabela jest zablokowana aż do odtworzenia commita.
Parametr ma kontekst sighup, więc zmienisz go przez reload, bez restartu. Dokumentacja opisuje też koszty: pliki WAL czekające na odtworzenie zajmują miejsce w pg_wal repliki, informacja zwrotna hot_standby_feedback dociera z opóźnieniem i może powodować puchnięcie tabel (bloat) na primary, a gdy ta replika jest synchroniczna przy synchronous_commit = remote_apply, każdy COMMIT czeka na odtworzenie na niej. Okno ratunku jest równe opóźnieniu. Pomyłkę zauważoną później naprawisz już tylko z backupu i odtwarzania do punktu w czasie (PITR).
ALTER SYSTEM SET recovery_min_apply_delay = '1h'; i SELECT pg_reload_conf();. Opóźnienie dobierz tak, żeby zdążyć zauważyć błąd. Nie kieruj na tę replikę zapytań aplikacji, bo z założenia pokazuje stan sprzed godziny.SELECT pg_wal_replay_pause();, a potem sprawdź SELECT pg_get_wal_replay_pause_state();. Wynik pause requested oznacza, że replika czeka na upływ opóźnienia i zatrzyma się przed odtworzeniem najbliższego commita. W naszym teście DELETE nie został odtworzony, a po upływie opóźnienia stan zmienił się na paused, z wpisem recovery has paused w logu.SELECT pg_promote(); w stanie pause requested replika odtworzyła zaległy DELETE i tabela była pusta.\copy orders TO '/tmp/orders_restore.csv' WITH (FORMAT csv), na primary: \copy orders FROM '/tmp/orders_restore.csv' WITH (FORMAT csv). Gdy usunięto tylko część wierszy, eksportuj zapytanie \copy (SELECT ...) TO z tym samym warunkiem co błędny DELETE, żeby nie dublować kluczy. Replika w pauzie pokazuje jednak stan sprzed mniej więcej opóźnienia: w osobnym teście nie miała 100 wierszy dopisanych 20 s przed DELETE. Gdy zmiany z tego okna są Ci potrzebne, idź drogą z kroku 5.ALTER SYSTEM SET recovery_target_time = '2026-10-19 18:08:26+02';, ALTER SYSTEM SET recovery_target_action = 'promote'; i ALTER SYSTEM SET recovery_min_apply_delay = 0;, a potem zrestartuj ją (np. pg_ctl restart), bo oba parametry celu mają kontekst postmaster. Zerujemy opóźnienie, bo działa ono także przy odtwarzaniu do celu: u nas przy 60 s opóźnienia awans nastąpił po 56 s, a po wyzerowaniu po 0,5 s. Czas podaj ze strefą czasową i raczej za wczesny niż za późny, bo cel po błędzie odtworzy także błąd. Replika zapisze w logu recovery stopping before commit of transaction ... i otworzy się do zapisu z kompletną tabelą. Stamtąd skopiujesz dane jak w kroku 4.SELECT pg_wal_replay_resume();. Replikę awansowaną w kroku 5 zbuduj od nowa (np. pg_basebackup), bo ma już własną oś czasu (timeline) i nie odtwarza dalej zmian z primary.Zanim wznowisz odtwarzanie, porównaj stan obu serwerów:
-- primary po imporcie
shop=# SELECT count(*) FROM orders;
count
-------
1000
(1 row)
-- replika opóźniona
shop=# SELECT pg_get_wal_replay_pause_state();
pg_get_wal_replay_pause_state
-------------------------------
paused
(1 row)Po pg_wal_replay_resume() replika odtworzy najpierw zaległy DELETE, a po upływie opóźnienia Twój import. W naszym teście z opóźnieniem 60 sekund replika zaraz po wznowieniu pokazała 0 wierszy, a minutę później znowu 1000. Taki przebieg oznacza, że primary i replika opóźniona znów mają te same dane.
Wróć do listy: 112 najczęstszych pytań i problemów z PostgreSQL
Komentarze (0)
Brak komentarzy...