Dobre praktyki odnośnie zmian na bazie danych
Spis treści
[WSTĘP] [DBA do wsparcia] [tl & dr] [Zasady ogólne:] [Lock] [Ogólne dobre praktyki] [Zmiany na strukturze] [Zasady ogólne:] [Dodanie tabeli] [Usuniecie tabeli] [Modyfikacja tabeli - ALTER TABLE] [Dodanie constraint] [PRIMARY KEY / UNIQUE] [DEFAULT] [NOT NULL] [FOREIGN KEY] [Dodanie indeksu / usunięcie indeksu / REINDEX] [Dodanie kolumny] [Usunięcie kolumny] [Zmiany na danych] [UPDATE] [DELETE] [TRUNCATE] [Klonowanie i migracja bazy pomiędzy datastore’ami.]
WSTĘP
Poniższe porady dotyczą PostgreSQL 12+, jeśli pracujesz na starszej wersji zacznij od upgrade silnika - często upgrade i inne prace można połączyć.
Artykuł dotyczy typowych zmian - modyfikacji tabel i danych. Przed wykonaniem operacji (a tak naprawdę przed planowaniem ich zakresu) zapoznaj się przynajmniej z sekcją ogólną oraz właściwą dla tego, co chcesz zrobić. Jeśli masz jakiekolwiek wątpliwości, czy Twoje działanie jest bezpieczne lub potrzebujesz zrobić coś, o czym nie jest tu napisane, skontaktuj się DBA.
DBA do wsparcia
Aktualnie po wsparcie w zakresie DBA uderzaj do: (kolejność alfabetyczna)
tl & dr
Skontaktuj się z DBA. Serio - nawet jeśli nie załatwi Ci tematu “od ręki”, lepiej poczekać niż “wybuchnąć“ bazę.
Zasady ogólne:
Lock
Podczas pracy z bazą powinieneś mieć z tyłu głowy, że każda operacja (tak, SELECT też) na bazie danych powoduje locka - czyli zablokowanie jakiegoś obiektu przed innymi operacjami. Jest to wypadkowa dwóch kwestii:
każda operacja w postgresie odbywa się w transakcji (nawet jeśli użytkownik nie wywołał komendy
BEGIN),transakcje chcemy przetwarzać współbieżnie.
Postgres posiada bardzo dobrą dokumentację (cześć dotycząca tematu tutaj), w telegraficznym skrócie:
Locki mogą pojawić się na różnych obiektach, Ciebie najbardziej będą interesować te na tabelach/rekordach.
Skoro każda operacja powoduje pojawienie się locków, same w sobie nie stanowią problemu. Problem pojawia się, kiedy dwie transakcje mają konfliktowe locki - potrzebują dostępu do tego samego obiektu.
Powyższe prowadzi nas do konkluzji, że kluczowy jest czas trwania locka:
lock spada z końcem transakcji - oznacza to, że jeśli np. otworzysz transakcje, wykonasz update, który potrwa 10 ms, ale potem wykonasz 8 innych operacji, które potrwają łącznie 2 min, to łącznie zalokujesz wiersze z pierwszej komendy na 2 min 10 ms.
Ogólne dobre praktyki
Najbezpieczniejszym miejscem na sprawdzanie, co w bazie siedzi, jest slave (replika bazy). Nie wykonasz na nim omyłkowego usunięcia danych i Twój SELECT niczego nie wybuchnie.
Zazwyczaj zmiany na bazie wprowadza się w ramach jakichś zmian w aplikacji. Jako mądry i rozsądny człowiek zapewne przetestowałeś swoją pracę, ale nie miałeś gdzie i jak np. oszacować ile zajmie wprowadzenie zmian na produkcyjnych danych. Przy dużych modyfikacjach do takich celów możemy powołać klona bazy i tam przeprowadzić testy.
Zaplanuj zmianę z wyprzedzeniem.
Zastanów się, czy do wdrożenia nie jest Ci potrzebny ktoś, kto w jakiś sposób będzie monitorował Twoje prace (możesz o asystę poprosić DBA) - szczególnie warto o tym pomyśleć przy zmianach dużych lub gdy są one wynikiem awarii.
Uproszczony algorytm każdej operacji bazodanowej:

DOBRY MOMENT oznacza, że :
planowane przez Ciebie zmiana nie koliduje czasowo z innymi zmianami / operacjami cyklicznymi,
baza jest w dobrej formie - ma niski load, ze szczególnym uwzględnieniem stanu dysków i ramu,
Ty jesteś w dobrej formie - wyspany i przytomny - rollback zmian bazodanowych często jest trudny a zazwyczaj bolesny,
piątki i godziny między 17 a 9 nie są dobrym momentem na prace ad hoc,
pamiętaj, że to kiedy wykonasz operację, jest tak samo istotne jak sama operacja. Często prace na bazach wymagają zaplanowanie okienka i przeprowadzenia ich w godzinach o niskim natężeniu ruchu (np. wieczornych lub pogańsko porannych).
Uwzględnij “obleganie“ tabeli - czyli to, jak bardzo jest ona używana w procesach systemowych. Pamiętaj, że tabela nie musi być duża, żeby jej niedostępność była bardzo bolesna - wystarczy wyobrazić sobie mikroskopijna tabelkę z konfiguracją systemu, która zniknie.
Jedną z “rzeczy”, bez której nie osiągniesz nic na bazie, jest… sieć. Szczególnie przy dłuższych pracach, dobrą praktyką jest połączenie się po ssh z serwerem bazodanowym, tam otwarcie screena i dopiero w nim uruchomienie właściwego skryptu.
Jeśli wbrew sugestii z punktu 2 postanowiłeś działać bez asysty, monitoruj swoje działania:
poniższe zapytanie ustaw w pętli 1s (jeśli pracujesz z psql użyj \watch 1)
WITH locks AS (SELECT DISTINCT pg_locks.pid, 1 AS blocking, datname, state, query_startFROM pg_locks LEFT JOIN pg_stat_activity ON pg_locks.pid = pg_stat_activity.pidWHERE granted = TRUE AND pg_locks.pid NOT IN (SELECT pid FROM pg_locks WHERE NOT granted) AND pg_locks.pid != pg_backend_pid()UNIONSELECT DISTINCT pg_locks.pid, 0 AS blocking, datname, state, query_startFROM pg_locks LEFT JOIN pg_stat_activity ON pg_locks.pid = pg_stat_activity.pidWHERE NOT granted)SELECTsum(blocking) AS blocking_cnt,sum(CASE WHEN blocking = 0 THEN 1 ELSE 0 END) AS blocked_cnt,sum(CASE WHEN blocking = 1 AND state = 'idle in transaction' THEN 1 ELSE 0 END) AS blocking_idle_in_transaction_cnt,FLOOR(max(EXTRACT(EPOCH FROM now() - COALESCE(query_start, now())))) AS max_lock_durationFROM locksWHERE datname = '{#DBNAME}'; --{#DBNAME} - zamień na nazwę bazyJeśli któraś z wartości zacznie skokowo rosnąć lub blocked_cnt > 0 - musisz przerwać swoje działania.
pod kątem zasobów (htop):
OOM killer nie zawsze jest taki mądry, żeby wiedzieć, że to Twój proces można ubić,
Twoje zapytanie może bardzo mocno wykorzystywać powierzchnię dyskową (nagromadzenie WALi, pliki tymczasowe lub naturalnie - INSERT / UPDATE wielu rekordów). Gdy silnikowi braknie powierzchni dyskowej, gasi się w trybie immediately i kasuje wszystkie otwarte transakcje.
WALe są archiwizowane poprzez gzip - im więcej zmian , tym więcej WALi, a w konsekwencji większe obciążenie dla CPU.
Po każdej pracy z tabelą (tabelami) powinniśmy użyć polecenia
ANALYZE [nazwa_tabeli];. Zbiera ono statystyki obiektu (np. szacuje ilość żywych i martwych krotek), a poprzez to optymalizuje pracę planera. Nigdy nie szkodzi, a po wielu zabawach jest konieczny.Bazy produkcyjne mają repliki:
w przypadku replik binarnych, duże operacje mogą sprawić, że będą one wymagały synchronizacji,
repliki logiczne będą bardzo wrażliwe na zmiany struktury, zadbaj o to, żeby Twoja zmiana dotyczyła także ewentualnych subscriberów.
Jeśli zapytanie
SELECT * FROM pg_stat_replication;zwróci NULL lub kolumnastatebędzie miała inną wartość niż'streaming', zgłoś to DBA.
DROP TABLE / DELETE / TRUNCATE - zasada jest prosta: łatwiej dane usunąć niż je odzyskać, dlatego przygotuj się do tego wcześniej - więcej w szczegółach każdej z operacji.
Zawsze traktuj środowisko produkcyjne jak żywy, myślący i… dość mściwy organizm.
Zmiany na strukturze
Zasady ogólne:
Zmiany muszą być przeprowadzane za pomocą odpowiedniego narzędzia CI/CD - aktualnie jest to Flyway.
Zanim trafią na produkcję, zmiany muszą być wprowadzone na wszystkich niższych środowiskach.
Zmiany muszą być przetestowane.
Niestety, nie ma reguły, która by mówiła co jest lepsze - modyfikacja istniejącej tabeli, czy dodanie nowej. Jest natomiast zaskakująca zależność między konsultowaniem zmian a wybuchaniem baz - im szersze konsultacje tym rzadsze wybuchy.
Dodanie tabeli
Przedyskutuj w zespole / z DBA celowość takiego działania - szczególnie, jeśli planujesz dodać tabelę do bazy innej niż podstawowe źródło danych Twojego systemu.
Sama operacja krótkofalowo nie zrobi nic złego, jednak w dłuższej perspektywie może mieć poważne konsekwencje. Jeśli na tabeli może pojawić się duży przyrost danych, skonsultuj to z DBA - możliwe, że będzie trzeba dodać zasobów lub zmodyfikować monitoring.
P.S. Pomyślałeś o partycjach?
Usuniecie tabeli
Bezpieczne wykonanie tej operacji trzeba rozłożyć na etapy:
zmień nazwę tabeli dodając sufix np.: *to_remove,
wykonaj HOT backup lub poproś o to DBA (pamiętaj jednak, że pg_dump agresywnie lockuje tabele),
odczekaj ~2 tygodnie,
usuń tabelę,
zarchiwizuj backup.
Modyfikacja tabeli - ALTER TABLE
W pewnym uproszczeniu i dla bezpieczeństwa możemy uznać, że wszystkie operacje tego typu spowodują lock na poziomie całej tabeli w trybie ACCESS EXCLUSIVE. Jak nazwa wskazuje, podczas trwania tej operacji żadne inne zapytanie nie będzie miało dostępu do tej tabeli (SELECT też nie).
Czas trwania operacji, a zatem i locka zależny jest od rozmiaru tabeli - ilość wierszy - i typu kolumn (oraz oczywiście od zasobów jakie ma przydzielone baza). Małe jednak nie znaczy nieważne - zalockowanie tabeli, która ma nawet 10 rekordów, ale bierze udział w kilku procesach, może bazę “wybuchnąć“.
Po tych operacjach również konieczne jest wykonanie ANALYZE.
Dodanie constraint
Pamiętaj, że taka zmiana zwiększa narzut operacyjny na zapytaniach modyfikujących dane. Źle ulokowany constraint może powoli doprowadzić do niewydolności systemu, którą będzie bardzo ciężko wykryć, ponieważ rzadko kiedy o problemie dowiadujemy się w bezpośrednio po wprowadzeniu zmiany.
Klauzula NOT VALID to taki chytry trick - powoduje on, że trakcie zakładania constrainta silnik nie sprawdza istniejących już danych, natomiast sprawdza je dla tych, które będą dodawane po utworzeniu constraintu. Daje nam to tyle, że sprawdzenie możemy wykonać oddzielenie, poprawić ewentualne wyjątki i znacząco skrócić czas wykonywania zapytania dodającego (a tym samym locka na tabeli). Niestety, nie w każdym przypadku możemy użyć tej sztuczki.
PRIMARY KEY / UNIQUE
Tu zawsze konieczne są szerokie konsultacje. Nie tylko dlatego, że silnik zawsze będzie sprawdzał, czy wszystkie wartości z kolumny spełniają to kryterium i nie ma na to żadnego obejścia (a to potrwa). Zwróć uwagę, że PRIMARY KEY wymaga wartości unikalnej i niebędącej nullem, UNIQUE wartości unikalnej - same w sobie nie mówią nic o wartości domyślnej. Jeśli zatem jest coś, co pisze do tej tabeli i nie wie o takiej zmianie, zacznie sypać błędami. Aby tego uniknąć, trzeba użyć SET DEFAULT.
DEFAULT
Pamiętaj, że użycie DEFAULT spowoduje masowy UPDATE, na całej tabeli. Taki zabieg trzeba podzielić na etapy:
dodać kolumnę,
w batchach uzupełnić dane,
dodać DEFAULT
pozbyć się bloatu.
NOT NULL
W teorii zamiast składni [...] SET NOT NULL [...] można użyć [...] CHECK [...] i wówczas możemy zastosować NOT VALID. Tu potrzebna jest jednak analiza każdego przypadku i testy, ponieważ constraint z CHECK jest mniej wydajny.
FOREIGN KEY
Wydawało by się, że jest sympatyczniejszy od poprzedników, bo powoduje “tylko“ SHARE ROW EXCLUSIVE locka, jednak trzeba wiedzieć, że robi to na obu tabelach - i tej, na której dodajesz klucz i tej do której się odwołujesz. Dobra informacja jest taka, że w tym wypadku trick z NOT VALID działa. Jak to dobrze zrobić:
Zrób SELECT (na slave’ie bazy), który sprawdzi, czy nie ma niespójności
jeśli takie wystąpią, napraw je (przeczytaj sekcję o UPDATE) - gdybyś wykonał od razu zapytanie tworzące klucz bez NOT VALID zwróciłoby ono błąd.
Na końcu zapytania dodaj NOT VALID
HINT: Utworzenie klucza obcego NIE tworzy żadnego indeksu. Warto jednak, aby taki powstał chociaż na tabeli do której tworzysz referencję - raczej będziesz ją joinował.
Dodanie indeksu / usunięcie indeksu / REINDEX
Na działającej bazie zawsze buduj / odświeżaj indeksy w trybie CONCURENTLY (indeksy unikalne również). Choć może się zakończyć niepowodzeniem i będzie trzeba powtórzyć, spowoduje to “złagodzenie locka“ do SHARE (tak, usunięcie indeksu tez powinno być CONCURENTLY).
Zwróć uwagę, że:
Dołożenie indeksu naturalnie spowoduje zwiększenie zajętości dyskowej, dodatkowo trzeba pamiętać, że indeks będzie rósł wraz z przyrostem danych.
Do wykonania reindeksacji musimy mieć zapewnioną przestrzeń 2.5 razy większa niż największy procesowany indeks.
Bazy produkcyjne mają repliki, REINDEX może sprawić, że będą one wymagały synchronizacji. Jeśli zapytanie
SELECT * FROM pg_stat_replication;zwróci NULL lub kolumnastatebędzie miała inną wartość niż'streaming', zgłoś to DBA.
Dodanie kolumny
Niestety nie ma tu żadnego kruczka, który ułatwiłby życie.
Jeśli dodawana kolumna posiada constraint podlega wszystkim regułom opisanym powyżej. Przeczytaj sekcję powyżej - ponoć szczególnie często kuszące jest dodanie kolumny z DEFAULT w jednym zapytaniu, ale trzeba się oprzeć tej pokusie.
Usunięcie kolumny
Tu również nie ma sztuczek i to również powinno odbywać się etapami:
zmiana nazwy kolumny
sleep na ~ 2 tygodnie
archiwizacja backupu
usunięcie kolumny
Paradoksalnie, o ile masz odpowiednio przygotowany backup, dużo szybciej (i znacznie łatwiej) odzyskać całą tabelę niż jej kolumnę.
Zmiany na danych
Jak już zostało wspomniane, modyfikacje danych również powodują locki - lżejsze niż modyfikacja struktury, ponieważ generalnie lokują rekord (wyjątek to TRUNCATE). I tu ma miejsce pewien pozorny paradoks. Z jednej strony 1 operacja na 1000 rekordów jest szybsza niż sumarycznie 1000 operacji na 1 rekordzie, z drugiej im więcej rekordów będziemy jednocześnie przetwarzać, tym dłużej potrwa transakcja (a zatem i lock na rekordach) i tym więcej zasobów do ich przetworzenia będzie potrzebnych. Sprawa jest dość prosta w wypadku zmian w oknie serwisowym (ze zgłoszoną niedostępnością systemu), na działającym systemie rozwiązanie to odpowiednie dobranie rozmiaru batcha, to jednak będzie różny dla różnych baz.
Przy dużych operacjach:
INSERT / UPDATE - pamiętaj o zabezpieczeniu powierzchni dyskowej - dojdą nowe dane, odłoży się więcej WALi i jest bardzo prawdopodobne, że zapytanie utworzy pliki tymczasowe.
UPDATE / DELETE / TRUNCATE - pamiętaj o zrobieniu HOT kopii modyfikowanych danych - np. poprzez utworzenie kopii tych rekordów poprzez CREATE tmp_table AS SELECT * FROM table WHERE […]
bazy produkcyjne mają repliki, duże operacje mogą sprawić, że będą one wymagały synchronizacji. Jeśli zapytanie
SELECT * FROM pg_stat_replication;zwróci NULL lub kolumna state będzie miała inną wartość niż 'streaming', zgłoś to DBA.
UPDATE
To najbardziej zasobożerna operacja na danych. Update, znów w pewnym uproszczeniu i skrócie, to oznaczenie rekordu jako do usunięcia i utworzenie nowego. W praktyce powoduje tzw. bloat, spuchnięcie tabeli i indeksu, poprzez nagromadzenie martwych krotek:

Im więcej bloatu tym wolniej baza działa, najbardziej poszkodowane są indeksy. Po każdym większym UPDATE trzeba przeprowadzić REINDEX oraz wykonać VACUUM ANALYZE na tabeli (na wielu bazach ten proces jest monitorowany, trzeba wiec przynajmniej zgłosić, że coś takiego będzie miało miejsce).
Przy mniejszej ilości rekordów zadziała proces autovacuum, który usunie dane z dysku, ale nie odzyska wolnej przestrzeni i nie zaopiekuje się indeksami, ponieważ nie lockuje tabeli w sposób agresywny (nie chcielibyśmy przecież, żeby baza sama wybierała sobie czas okna serwisowego). Przy większych operacjach istnieje kilka sposobów na poradzenie sobie z tym problemem - od VACUUM FULL z oknem serwisowym, przez rozszerzenia, po zestawienie replikacji logicznej.
DELETE
Jak zostało wspomniane, usuwanie danych również wymaga konsultacji.
Delete nie usuwa fizycznie danych - działa podobnie jak UPDATE i również powoduje bloat.
Jeśli potrzebujesz usunąć większość danych z tabeli szybciej niż DELETE zadziała:
CREATE tmp_table AS SELECT * FROM table WHERE [warunki dla rekordów, które mają zostać - tu warto sobie stworzyć na nie indeks]. Tą cześć możemy wykonać przy działającym systemie, jeśli:
mamy 100% pewność, że aplikacja nie modyfikuje danych w tej tabeli,
modyfikacji jest naprawdę bardzo mało - wówczas trzeba założyć trigger after update, który będzie odkładał zmiany w trakcie działania powyższego SELECTa
TRUNCATE table - tę część realizujemy w oknie serwisowym
INSERT INTO table SELECT * FROM tmp_table - tę część na ogół również
Zastosowanie takiego rozwiązania daje nam dodatkową korzyść w postaci braku bloatu.
TRUNCATE
Jak zostało wspomniane, usuwanie danych również wymaga konsultacji.
Podchwytliwe polecenie - w teorii robi dokładnie to samo, co DELETE bez klauzuli WHERE, w praktyce jednak zupełnie inaczej to realizuje. Zamiast oznaczać rekordy, wykonuje kopię struktury tabeli, usuwa tabelę źródłową i udostępnia kopię pod starą nazwą. Z tego powodu używa innych (tych najbardziej agresywnych) locków, ale za to nie zostawia bloatu.
Klonowanie i migracja bazy pomiędzy datastore’ami.
Klonowanie maszyny wirtualnej/Migracja pomiędzy DataStore'ami