W przypadku złożonego przetwarzania dużych zbiorów danych (różne : importy, konwersje i synchronizacje z zewnętrznym źródłem) często pojawia się potrzeba tymczasowego "zapamiętania" i szybkiego przetworzenia czegoś obszernego.
Typowe zadanie tego rodzaju brzmi zazwyczaj mniej więcej tak: "Tu ostatnie otrzymane płatności, musimy je szybko załadować na stronę i powiązać z rachunkami"
Jednak gdy objętość tego "czegoś" zaczyna być mierzona w setkach megabajtów, a usługa musi nadal działać w trybie 24×7, pojawia się wiele skutków ubocznych, które będą przeszkadzać w pracy.

Aby sobie z nimi poradzić w PostgreSQL (i nie tylko tam), można wykorzystać pewne możliwości optymalizacji, które pozwolą przetwarzać wszystko szybciej i przy mniejszym zużyciu zasobów.
1. Gdzie ładować?
Na początku ustalmy, gdzie możemy załadować dane, które chcemy "przetworzyć".
1.1. Tabele tymczasowe (TEMPORARY TABLE)
W zasadzie dla PostgreSQL tabele tymczasowe to te same tabele jak wszystkie inne. Dlatego błędne są przesądy typu "wszystko tam jest przechowywane tylko w pamięci, a ona może się skończyć". Ale są również pewne istotne różnice.
Własna "przestrzeń nazw" dla każdego połączenia z bazą danych
Jeśli dwa połączenia spróbują jednocześnie wykonać CREATE TABLE x, to ktoś na pewno dostanie błąd unikalności obiektów bazy danych.
Jeśli jednak obie próby zostaną wykonane UTWÓRZ Tymczasowa Tabela x, to obie się powiodą, a każdy otrzyma swój egzemplarz tabeli. Nie będzie między nimi nic wspólnego.
"Autodestrukcja" przy rozłączeniu
Po zamknięciu połączenia wszystkie tabele tymczasowe są automatycznie usuwane, dlatego nie ma sensu wykonywać DROP TABLE x , oprócz...
Jeśli pracujesz przez pgbouncer w trybie transakcyjnym, to baza nadal uważa, że to połączenie jest aktywne, a w nim tabela tymczasowa nadal istnieje.
Dlatego próba ponownego jej utworzenia z innego połączenia do pgbouncer spowoduje błąd. Ale można to obejść, korzystając z UTWÓRZ TYMCHASOWĄ TABELĘ JEŚLI NIE ISTNIEJE x.
Jednak lepiej tego nie robić, bo później można "nagle" znaleźć dane pozostałe po "poprzednim właścicielu". Zamiast tego znacznie lepiej przeczytać dokumentację i zobaczyć, że przy tworzeniu tabeli istnieje możliwość dodania PRZY ZATWIERDZENIU DROP — czyli po zakończeniu transakcji tabela zostanie automatycznie usunięta.
Brak replikacji
Ze względu na to, że należy tylko do określonego połączenia, tabele tymczasowe nie są replikowane. Z kolei pozwala to uniknąć podwójnego zapisu danych w heap + WAL, dlatego INSERT/UPDATE/DELETE do niej jest znacznie szybsze.
Jednak ponieważ tabela tymczasowa to wciąż „prawie zwykła” tabela, nie można jej również utworzyć na replikacji. Przynajmniej na razie, chociaż odpowiednia łatka jest już od dawna przygotowywana.
1.2. Tabele niezarejestrowane (UNLOGGED TABLE)
Ale co zrobić na przykład, jeśli masz jakiś ciężki proces ETL, którego nie da się zrealizować w ramach jednej transakcji, a jednak masz pgbouncer w trybie transakcyjnym?..
Lub strumień danych jest na tyle duży, że nie wystarcza przepustowości jednego połączenia z bazą danych (czytaj, jednego procesu na CPU)?..
Lub część operacji przebiega asynchronicznie w różnych połączeniach?..
Jest tylko jedna opcja — tymczasowo stworzyć tabelę zwykłą. Kalambur, tak. To znaczy:
- stwórz „swoje” tabele z maksymalnie losowymi nazwami, aby się z nikim nie zderzyć
- Ekspert: załadowałem w nie dane zewnętrznego źródła
- Transformuj: przekształciłem, wypełniłem kluczowe powiązane pola
- Ładowanie: załadowałem gotowe dane do docelowych tabel
- usunąłem „swoje” tabele
A teraz — łyżka dziegciu. Właściwie, cały zapis w PostgreSQL odbywa się dwukrotnie — , a następnie w ciała tabel/indeksów. Wszystko to zostało zrobione w celu wsparcia ACID i poprawnej widoczności danych między COMMITtransakcjami 'wnętrznymi' i ROLLBACKtransakcjami 'zewnętrznymi'.
Ale to nie jest nam potrzebne! Cały proces przeszedł całkowicie pomyślnie lub nie.Nie ma znaczenia, ile w nim będzie pośrednich transakcji — nie interesuje nas „kontynuowanie procesu z połowy”, zwłaszcza kiedy nie wiadomo, gdzie to było.
W tym celu deweloperzy PostgreSQL już w wersji 9.1 wprowadzili coś takiego jak :
Z tym wskazaniem tabela jest tworzona jako niezarejestrowana. Dane zapisywane w tabelach niezarejestrowanych nie przechodzą przez dziennik przedzapisowy (patrz rozdział 29), w wyniku czego takie tabele działają znacznie szybciej niż zwykłe. Jednak nie są chronione przed awarią; w przypadku awarii lub nagłego wyłączenia serwera tabela niezarejestrowana zostaje automatycznie ucięta.. Ponadto, zawartość niezarejestrowanej tabeli nie jest replikowana. na serwery podrzędne. Wszelkie indeksy tworzone dla tabeli niezapisanej w dzienniku stają się automatycznie niezapisane w dzienniku.
Krótko mówiąc, będzie znacznie szybsze, ale jeśli serwer bazy danych „padnie” — to będzie niewygodne. Ale jak często to się zdarza i czy Twój proces ETL umie to poprawnie naprawić „z połowy” po „ożywieniu” bazy danych?..
Jeśli nie, a powyższy przypadek przypomina Twój — użyj UNLOGGED, ale nigdy nie włączaj tego atrybutu w rzeczywistych tabelach, z których dane są dla Ciebie cenne.
1.3. ON COMMIT { DELETE ROWS | DROP }
Ta konstrukcja umożliwia podczas tworzenia tabeli określenie automatycznego zachowania po zakończeniu transakcji.
O PRZY ZATWIERDZENIU DROP Już pisałem wyżej, generuje DROP TABLE, ale w przypadku PRZY ZATWIERDZENIU USUŃ WIERSZE sytuacja jest ciekawsza — tutaj generowane jest TRUNCATE TABLE.
Ponieważ cała infrastruktura przechowywania metadanych tabeli tymczasowej jest dokładnie taka sama, jak w przypadku zwykłej, to ciągłe tworzenie i usuwanie tabel tymczasowych prowadzi do znacznego „rozrostu” tabel systemowych pg_class, pg_attribute, pg_attrdef, pg_depend,…
Wyobraź sobie teraz, że masz robota na bezpośrednim połączeniu z bazą danych, który co sekundę otwiera nową transakcję, tworzy, wypełnia, przetwarza i usuwa tymczasową tabelę… Śmieci w tabelach systemowych nagromadzi się w nadmiarze, a to powoduje dodatkowe opóźnienia przy każdej operacji.
Ogólnie rzecz biorąc, nie rób tak! W takim przypadku znacznie skuteczniej CREATE TEMPORARY TABLE x ... ON COMMIT DELETE ROWS wyciągnąć poza cykl transakcji — wtedy na początku każdej nowej transakcji tabele już będą istnieć (oszczędzamy wywołanie UTWÓRZ), ale będą puste, dzięki TRUNCATE (jego wywołanie też zaoszczędziliśmy) po zakończeniu poprzedniej transakcji.
1.4. LIKE… INCLUDING …
Wspomniałem na początku, że jednym z typowych przypadków użycia tabel tymczasowych są różnego rodzaju importy — a programista zmęczony kopiowaniem i wklejaniem listy pól tabeli docelowej w deklaracji swojej tabeli tymczasowej…
Ale lenistwo jest motorem postępu! Dlatego utworzenie nowej tabeli „na podstawie” można wykonać znacznie łatwiej:
CREATE TEMPORARY TABLE import_table(
LIKE target_table
);Ponieważ później można wlać do tej tabeli naprawdę dużo danych, przeszukiwanie jej stanie się niezbyt szybkie. Ale jest na to tradycyjne rozwiązanie — indeksy! I tak, tabela tymczasowa również może mieć indeksy.
Ponieważ często wymagane indeksy pokrywają się z indeksami tabeli docelowej, można po prostu napisać LIKE target_table W tym indeksy.
Jeśli potrzebujesz również DOMYŚLNY-wartości (np. do wypełnienia wartości klucza podstawowego), można skorzystać LIKE target_table W TYM DOMYŚLNE. Albo po prostu — LIKE target_table W TYM WSZYSTKO — skopiuje domyślne wartości, indeksy, constraints,…
Ale tu już trzeba zrozumieć, że jeśli tworzyłeś import-tabelę od razu z indeksami, to ładowanie danych będzie trwało dłużej, niż jeśli najpierw wszystko załadujesz, a potem dorobisz indeksy — zobacz jako przykład, jak to robi .
Ogólnie, !
2. Jak pisać?
Powiem krótko — używaj -strumienia zamiast „partii” INSERT, . Można nawet bezpośrednio z wcześniej przygotowanego pliku.
3. Jak przetwarzać?
Zatem, niech nasz przypadek wygląda mniej więcej tak:
- masz w bazie tabelę z danymi klientów na 1M rekordów
- każdego dnia klient przesyła Ci nowy pełny „obraz”
- z doświadczenia wiesz, że z każdym razem zmienia się nie więcej niż 10K rekordów
Klasycznym przykładem takiej sytuacji jest — jest ogromna liczba adresów, ale w każdej cotygodniowej wyeksportowanej wersji zmian (zmiany nazw miejscowości, łączenie ulic, pojawianie się nowych domów) jest zaledwie kilka nawet na skalę całego kraju.
3.1. Algorytm pełnej synchronizacji
Dla uproszczenia przyjmij, że nie musisz nawet restrukturyzować danych — wystarczy przekształcić tabelę do odpowiedniego formatu, to znaczy:
- usunąć wszystko, czego już nie ma
- aktualizować wszystko, co już było i należy zaktualizować
- wstawić wszystko, czego jeszcze nie było
Dlaczego właśnie w takiej kolejności należy wykonywać operacje? Ponieważ w ten sposób rozmiar tabeli wzrośnie minimalnie ().
DELETE FROM dst
Nie, oczywiście można się obejść tylko dwoma operacjami:
- usunąć (
USUŃ) w zasadzie wszystkie - wstawić wszystkie z nowego obrazu
Ale przy tym, dzięki MVCC, rozmiar tabeli zwiększy się dokładnie dwukrotnie! Uzyskać +1M obrazów rekordów w tabeli z powodu aktualizacji 10K — to dość marnotrawstwo…
TRUNCATE dst
Bardziej doświadczony programista wie, że cały cały stół można wystarczająco tanio wyczyścić:
- wyczyszczenia (
TRUNCATE) tabelę całkowicie - wstawić wszystkie z nowego obrazu
Metoda skuteczna, , ale jest pewien problem… Wlewanie 1M rekordów zajmie nam sporo czasu, więc nie możemy pozwolić sobie na umieszczenie tabeli w pustej formie przez cały ten czas (jak by się stało bez opakowania w pojedynczą transakcję).
A zatem:
- rozpoczynamy długą transakcję
TRUNCATEnakłada AccessExclusive-blokadę- długo robimy wstawki, a wszyscy inni w tym czasie nawet nie mogą
SELECT
Coś nie tak wychodzi…
ALTER TABLE… RENAME… / DROP TABLE …
Można na przykład wgrać wszystko do osobnej nowej tabeli, a następnie po prostu zmienić jej nazwę na starą. Kilka drobnych, uciążliwych rzeczy:
- także AccessExclusive, chociaż znacznie mniej czasochłonnie
- wszystkie plany zapytań/statystyki tej tabeli zostaną zresetowane,
- wszystkie klucze obce (FK) do tabeli
Był WIP-patch od Simona Riggsa, który proponował ALTER-operację do podmiany treści tabeli na poziomie plików, bez dotykania statystyk i FK, ale nie uzyskał wymaganego kworum.
DELETE, UPDATE, INSERT
Więc decydujemy się na nieblokującą opcję z trzech operacji. Prawie trzech… Jak to zrobić w najbardziej efektywny sposób?
-- wszystko robimy w ramach transakcji, aby nikt nie widział "pośrednich" stanów
BEGIN;
-- tworzymy tymczasową tabelę z importowanymi danymi
CREATE TEMPORARY TABLE tmp(
LIKE dst INCLUDING INDEXES -- według wzoru, łącznie z indeksami
) ON COMMIT DROP; -- po zakończeniu transakcji nie jest nam potrzebna
-- szybko wlewamy nową wersję przez COPY
COPY tmp FROM STDIN;
-- ...
-- .
-- usuwamy brakujące
DELETE FROM
dst D
USING
dst X
LEFT JOIN
tmp Y
USING(pk1, pk2) -- pola klucza głównego
WHERE
(D.pk1, D.pk2) = (X.pk1, X.pk2) AND
Y IS NOT DISTINCT FROM NULL; -- "antijoin"
-- aktualizujemy pozostałe
UPDATE
dst D
SET
(f1, f2, f3) = (T.f1, T.f2, T.f3)
FROM
tmp T
WHERE
(D.pk1, D.pk2) = (T.pk1, T.pk2) AND
(D.f1, D.f2, D.f3) IS DISTINCT FROM (T.f1, T.f2, T.f3); -- nie ma potrzeby aktualizowania zgodnych
-- wstawiamy brakujące
INSERT INTO
dst
SELECT
T.*
FROM
tmp T
LEFT JOIN
dst D
USING(pk1, pk2)
WHERE
D IS NOT DISTINCT FROM NULL;
COMMIT;
3.2. Postprocessing importu
W tym samym KLADe, wszystkie zmienione rekordy należy dodatkowo przepuścić przez postprocessing — znormalizować, wydobyć słowa kluczowe, dostosować do potrzebnych struktur. Ale jak dowiedzieć się — co dokładnie się zmieniało, nie komplikując przy tym kodu synchronizacji, najlepiej w ogóle go nie dotykając?
Jeśli w momencie synchronizacji tylko twoje procesy mają dostęp do zapisu, można skorzystać z triggera, który zbierze dla nas wszystkie zmiany:
-- tabele docelowe
CREATE TABLE kladr(...);
CREATE TABLE kladr_house(...);
-- tabele z historią zmian
CREATE TABLE kladr$log(
ro kladr, -- tutaj znajdują się pełne obrazy rekordów starych/nowych
rn kladr
);
CREATE TABLE kladr_house$log(
ro kladr_house,
rn kladr_house
);
-- ogólna funkcja logowania zmian
CREATE OR REPLACE FUNCTION diff$log() RETURNS trigger AS $$
DECLARE
dst varchar = TG_TABLE_NAME || '$log';
stmt text = '';
BEGIN
-- sprawdzamy konieczność logowania przy aktualizacji rekordu
IF TG_OP = 'UPDATE' THEN
IF NEW IS NOT DISTINCT FROM OLD THEN
RETURN NEW;
END IF;
END IF;
-- tworzymy rekord logu
stmt = 'INSERT INTO ' || dst::text || '(ro,rn)VALUES(';
CASE TG_OP
WHEN 'INSERT' THEN
EXECUTE stmt || 'NULL,$1)' USING NEW;
WHEN 'UPDATE' THEN
EXECUTE stmt || '$1,$2)' USING OLD, NEW;
WHEN 'DELETE' THEN
EXECUTE stmt || '$1,NULL)' USING OLD;
END CASE;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Teraz możemy nałożyć (lub włączyć przez) wyzwalacze przed rozpoczęciem synchronizacji ALTER TABLE ... ENABLE TRIGGER ...):
CREATE TRIGGER log
AFTER INSERT OR UPDATE OR DELETE
ON kladr
FOR EACH ROW
EXECUTE PROCEDURE diff$log();
CREATE TRIGGER log
AFTER INSERT OR UPDATE OR DELETE
ON kladr_house
FOR EACH ROW
EXECUTE PROCEDURE diff$log();
A potem spokojnie wyciągamy wszystkie potrzebne nam zmiany z tabel log, a następnie uruchamiamy dodatkowe przetwarzacze.
3.3. Import powiązanych zestawów
Powyżej omówiliśmy przypadki, w których struktury danych źródła i odbiorcy są zgodne. Ale co zrobić, jeśli eksport z zewnętrznego systemu ma format różniący się od struktury przechowywania w naszej bazie?
Weźmy za przykład przechowywanie klientów i związanych z nimi faktur, klasyczny przypadek „wiele-do-jednego”:
CREATE TABLE client(
client_id
serial
PRIMARY KEY
, inn
varchar
UNIQUE
, name
varchar
);
CREATE TABLE invoice(
invoice_id
serial
PRIMARY KEY
, client_id
integer
REFERENCES client(client_id)
, number
varchar
, dt
date
, sum
numeric(32,2)
);A oto eksport z zewnętrznego źródła przychodzi do nas w formie „wszystko w jednym”:
CREATE TEMPORARY TABLE invoice_import(
client_inn
varchar
, client_name
varchar
, invoice_number
varchar
, invoice_dt
date
, invoice_sum
numeric(32,2)
);Oczywiście dane dotyczące klientów mogą być zdublowane w takim wariancie, a podstawowym rekordem jest „faktura”:
0123456789;Wania;A-01;2020-03-16;1000.00
9876543210;Petia;A-02;2020-03-16;666.00
0123456789;Wania;B-03;2020-03-16;9999.00
Dla modelu po prostu wstawimy nasze dane testowe, ale pamiętajmy — COPY efektywniej!
INSERT INTO invoice_import
VALUES
('0123456789', 'Wania', 'A-01', '2020-03-16', 1000.00)
, ('9876543210', 'Petia', 'A-02', '2020-03-16', 666.00)
, ('0123456789', 'Wania', 'B-03', '2020-03-16', 9999.00);Najpierw wyodrębnijmy te „kategorie”, na które nasze „fakty” się odnoszą. W naszym przypadku faktury odnoszą się do klientów:
UTWÓRZ TYMCZASOWĄ TABELĘ client_import AS
WYBIERZ UNIKATOWE NA(client_inn)
-- można po prostu WYBRAĆ UNIKATOWE, jeśli dane są z góry nieprzeczące
client_inn inn
, client_name "name"
Z FROM
invoice_import;Aby poprawnie powiązać faktury z identyfikatorami klientów, musimy najpierw poznać lub wygenerować te identyfikatory. Dodamy dla nich pola:
ALTER TABLE invoice_import DODAJ KOLUMNĘ client_id integer;
ALTER TABLE client_import DODAJ KOLUMNĘ client_id integer;Skorzystamy z opisanego powyżej sposobu synchronizacji tabel z niewielką poprawką — nie będziemy nic aktualizować ani usuwać w docelowej tabeli, ponieważ import klientów jest dla nas "tylko do dodawania":
-- wstawiamy w tabeli importu ID już istniejących rekordów
UPDATE
client_import T
USTAW
client_id = D.client_id
Z
client D
GDZIE
T.inn = D.inn; -- unikalny klucz
-- wstawiamy brakujące rekordy i ustawiamy ich ID
WITH ins AS (
WSTAW DO client(
inn
, name
)
WYBIERZ
inn
, name
Z
client_import
GDZIE
client_id IS NULL -- jeśli ID nie było ustawione
ZWRÓĆ *
)
UPDATE
client_import T
USTAW
client_id = D.client_id
Z
ins D
GDZIE
T.inn = D.inn; -- unikalny klucz
-- ustawiamy ID klientów dla rekordów faktur
UPDATE
invoice_import T
USTAW
client_id = D.client_id
Z
client_import D
GDZIE
T.client_inn = D.inn; -- klucz aplikacyjny
Właściwie to wszystko — w invoice_import teraz mamy wypełnione pole powiązania client_id, z którym wstawimy fakturę.
Źródło: habr.com
