Oszczędzamy kilka groszy przy dużych ilościach w PostgreSQL

Kontynuując temat zapisu dużych strumieni danych, poruszony w poprzednim artykule na temat partycjonowania, w tym przyjrzymy się sposobom, dzięki którym można zmniejszyć „fizyczny” rozmiar przechowywanych w PostgreSQL oraz ich wpływ na wydajność serwera.

Będzie mowa o ustawieniach TOAST i wyrównaniu danych. „Średnio” te metody pozwolą zaoszczędzić niewiele zasobów, ale — całkowicie bez modyfikacji kodu aplikacji.

Oszczędzamy kilka groszy przy dużych ilościach w PostgreSQL
Jednak nasze doświadczenia okazały się dość owocne w tym zakresie, ponieważ przechowalnia prawie każdego monitoringu z natury jest w dużej części append-only z punktu widzenia zapisywanych danych. A jeśli chcesz wiedzieć, jak można nauczyć bazę pisać na dysk z prędkością 200MB/s znacznie mniejszą — zapraszam pod tekst.

Małe sekrety dużych danych

W profilu działania naszej usługi, regularnie wpływają do nas z logów pakiety tekstowe.

A ponieważ kompleks SBiS, których bazy danych monitorujemy, to produkt wielokomponentowy o złożonych strukturach danych, to i zapytania w celu osiągnięcia maksymalnej wydajności okazują się całkiem „wielotomowe” z złożoną logiką algorytmiczną. Dlatego objętość każdego pojedynczego wystąpienia zapytania lub planu wykonania w napływających do nas logach okazuje się „średnio” wystarczająco duża.

Przyjrzyjmy się strukturze jednej z tabel, do której zapisujemy „surowe” dane — czyli oryginalny tekst z zapisu logu:

CREATE TABLE rawdata_orig(
  pack -- PK
    uuid NOT NULL
, recno -- PK
    smallint NOT NULL
, dt -- klucz sekcji
    date
, data -- najważniejsze
    text
, PRIMARY KEY(pack, recno)
);

Typowa taka tabelka (już partycjonowana, oczywiście, dlatego to jest wzór sekcji), gdzie najważniejszy jest tekst. Czasami dość obszerna.

Przypomnijmy, że „fizyczny” rozmiar jednego rekordu w PG nie może zajmować więcej niż jedna strona danych, ale „logiczny” rozmiar — to zupełnie inna sprawa. Aby zapisać w polu obszerne wartości (varchar/text/bytea) używana jest technologia TOAST:

PostgreSQL używa stałego rozmiaru strony (zwykle 8 KB) i nie pozwala na zajmowanie wielu stron przez krotki. Dlatego nie można bezpośrednio przechowywać bardzo dużych wartości pól. Aby przezwyciężyć to ograniczenie, duże wartości pól są kompresowane i/lub dzielone na kilka fizycznych rzędów. Dzieje się to niewidocznie dla użytkownika i na większość kodu serwera wpływa nieznacznie. Ta metoda znana jest jako TOAST …

W rzeczywistości dla każdej tabeli z "potencjalnie dużymi" polami automatycznie tworzona jest partnerująca tabela z "krojeniem" każdego "dużego" rekordu w segmentach po 2KB:

TOAST(
  chunk_id
    integer
, chunk_seq
    integer
, chunk_data
    bytea
, PRIMARY KEY(chunk_id, chunk_seq)
);

Oznacza to, że jeśli musimy zapisać wiersz z "dużą" wartością data, to rzeczywisty zapis nastąpi nie tylko w głównej tabeli i jej kluczu głównym, ale również w TOAST i jej kluczu głównym.

Zmniejszamy wpływ TOAST

Ale większość naszych zapisów jest wciąż nie tak wielka, powinny się zmieścić w 8KB — jak tu zaoszczędzić?..

Tutaj z pomocą przychodzi atrybut STORAGE w kolumnie tabeli:

  • EXTENDED dopuszcza zarówno kompresję, jak i oddzielne przechowywanie. To standardowa opcja dla większości typów danych zgodnych z TOAST. Najpierw podejmowana jest próba wykonania kompresji, a następnie — zapis w zewnętrznej tabeli, jeśli wiersz wciąż jest zbyt duży.
  • MAIN dopuszcza kompresję, ale nie oddzielne przechowywanie. (W rzeczywistości oddzielne przechowywanie i tak zostanie wykonane dla tych kolumn, ale tylko jako ostateczność, gdy nie ma innego sposobu, aby zmniejszyć wiersz tak, aby zmieścił się na stronie.)

W rzeczywistości to dokładnie to, czego potrzebujemy dla tekstu — maksymalnie skompresować, a jeśli już naprawdę się nie zmieści — przenieść do TOAST. Można to zrobić od razu „w locie”, jednym poleceniem:

ALTER TABLE rawdata_orig ALTER COLUMN data SET STORAGE MAIN;

Jak ocenić efekt

Ponieważ każdego dnia strumień danych się zmienia, nie możemy porównywać absolutnych cyfr, ale w relatywnych im mniejszy udział zapisaliśmy w TOAST — tym lepiej. Ale tu jest niebezpieczeństwo — im większa jest nasza „fizyczna” objętość każdego pojedynczego rekordu, tym „szerszy” staje się indeks, ponieważ trzeba pokryć większą ilość stron danych.

Sekcja przed zmianami:

heap  = 37GB (39%)
TOAST = 54GB (57%)
PK    =  4GB ( 4%)

Sekcja po zmianach:

heap  = 37GB (67%)
TOAST = 16GB (29%)
PK    =  2GB ( 4%)

W rzeczywistości zaczęliśmy zapisywać w TOAST 2 razy rzadziej, co odciążyło nie tylko dysk, ale i CPU:

Oszczędzamy kilka groszy przy dużych ilościach w PostgreSQL
Oszczędzamy kilka groszy przy dużych ilościach w PostgreSQL
Zauważam, że zaczęliśmy również "czytać" dysk mniej, nie tylko "zapisywać" — ponieważ przy dodawaniu wpisu do jakiejś tabeli musimy "przeczytać" także część drzewa każdego z indeksów, aby określić jego przyszłą pozycję w nich.

Komu żyje się dobrze na PostgreSQL 11

Po aktualizacji do PG11 postanowiliśmy kontynuować "tuning" TOAST i zwróciliśmy uwagę, że od tej wersji dostępny stał się parametr do konfiguracji toast_tuple_target:

Kod obsługi TOAST działa tylko wtedy, gdy wartość ciągu, która ma być przechowywana w tabeli, jest większa niż TOAST_TUPLE_THRESHOLD bajtów (zwykle to 2 KB). Kod TOAST będzie kompresować i/lub przenosić wartości pola poza tabelę, dopóki wartość ciągu nie spadnie poniżej TOAST_TUPLE_TARGET bajtów (zmienna wielkość, również zwykle 2 KB) lub zmniejszenie objętości nie będzie możliwe.

Postanowiliśmy, że nasze dane zazwyczaj są albo "zupełnie krótkie", albo od razu "bardzo długie", dlatego postanowiliśmy ograniczyć się do minimalnej możliwej wartości:

ALTER TABLE rawplan_orig SET (toast_tuple_target = 128);

Zobaczmy, jak nowe ustawienia wpłynęły na obciążenie dysku po przeregulowaniu:

Oszczędzamy kilka groszy przy dużych ilościach w PostgreSQL
Niezłe! Średnia kolejka do dysku zmniejszyła się około 1.5 razy, a "zajętość" dysku — o 20%! Ale może to miało jakiś wpływ na CPU?

Oszczędzamy kilka groszy przy dużych ilościach w PostgreSQL
Przynajmniej na pewno nie było gorzej. Choć trudno to ocenić, gdy nawet takie obciążenia wciąż nie są w stanie podnieść średniego obciążenia CPU wyżej 5%.

Z zamianą miejsc składników suma... zmienia się!

Jak wiadomo, grosz oszczędza rubla, a przy naszych objętościach przechowywania wynoszących 10TB/miesiąc nawet niewielka optymalizacja może przynieść niezły zysk. Dlatego zwróciliśmy uwagę na fizyczną strukturę swoich danych — jak konkretnie "ulegają" pola wewnątrz zapisu każdej z tabel.

Ponieważ z powodu wyrównania danych to bezpośrednio wpływa na wyniki objętościowe.:

Wiele architektur przewiduje wyrównanie danych do granic słów maszynowych. Na przykład w systemie 32-bitowym x86 liczby całkowite (typ integer, zajmujące 4 bajty) będą wyrównane do granicy 4-bajtowych słów, podobnie jak liczby zmiennoprzecinkowe podwójnej precyzji (typ double precision, 8 bajtów). A w systemie 64-bitowym wartości double będą wyrównane do granicy 8-bajtowych słów. To kolejny powód niekompatybilności.

Ze względu na wyrównanie rozmiar wiersza tabeli zależy od kolejności rozmieszczania pól. Zwykle ten efekt nie jest bardzo widoczny, ale w niektórych przypadkach może prowadzić do znacznego zwiększenia rozmiaru. Na przykład, jeśli wymieszać pola typów char(1) i integer, między nimi zazwyczaj będą tracić 3 bajty.

Zacznijmy od modeli syntetycznych:

SELECT pg_column_size(ROW(
  '0000-0000-0000-0000-0000-0000-0000-0000'::uuid
, 0::smallint
, '2019-01-01'::date
));
-- 48 bajtów

SELECT pg_column_size(ROW(
  '2019-01-01'::date
, '0000-0000-0000-0000-0000-0000-0000-0000'::uuid
, 0::smallint
));
-- 46 bajtów

Skąd wzięły się te dodatkowe bajty w pierwszym przypadku? Wszystko jest proste — 2-bajtowy smallint jest wyrównywany do granicy 4-bajtowej przed następnym polem, a gdy stoi na końcu — nie ma czego wyrównywać i nie ma potrzeby.

Teoretycznie — wszystko jest w porządku i można przestawiać pola według uznania. Sprawdźmy to na rzeczywistych danych na przykładzie jednej z tabel, której sekcja dzienna zajmuje 10-15GB.

Początkowa struktura:

CREATE TABLE public.plan_20190220
(
-- Dziedziczone z tabeli plan:  pack uuid NOT NULL,
-- Dziedziczone z tabeli plan:  recno smallint NOT NULL,
-- Dziedziczone z tabeli plan:  host uuid,
-- Dziedziczone z tabeli plan:  ts timestamp with time zone,
-- Dziedziczone z tabeli plan:  exectime numeric(32,3),
-- Dziedziczone z tabeli plan:  duration numeric(32,3),
-- Dziedziczone z tabeli plan:  bufint bigint,
-- Dziedziczone z tabeli plan:  bufmem bigint,
-- Dziedziczone z tabeli plan:  bufdsk bigint,
-- Dziedziczone z tabeli plan:  apn uuid,
-- Dziedziczone z tabeli plan:  ptr uuid,
-- Dziedziczone z tabeli plan:  dt date,
  CONSTRAINT plan_20190220_pkey PRIMARY KEY (pack, recno),
  CONSTRAINT chck_ptr CHECK (ptr IS NOT NULL),
  CONSTRAINT plan_20190220_dt_check CHECK (dt = '2019-02-20'::date)
)
INHERITS (public.plan)

Sekcja po zmianie kolejności kolumn — dokładnie te same pola, tylko inna kolejność:

CREATE TABLE public.plan_20190221
(
-- Dziedziczone z tabeli plan:  dt date NOT NULL,
-- Dziedziczone z tabeli plan:  ts timestamp with time zone,
-- Dziedziczone z tabeli plan:  pack uuid NOT NULL,
-- Dziedziczone z tabeli plan:  recno smallint NOT NULL,
-- Dziedziczone z tabeli plan:  host uuid,
-- Dziedziczone z tabeli plan:  apn uuid,
-- Dziedziczone z tabeli plan:  ptr uuid,
-- Dziedziczone z tabeli plan:  bufint bigint,
-- Dziedziczone z tabeli plan:  bufmem bigint,
-- Dziedziczone z tabeli plan:  bufdsk bigint,
-- Dziedziczone z tabeli plan:  exectime numeric(32,3),
-- Dziedziczone z tabeli plan:  duration numeric(32,3),
  CONSTRAINT plan_20190221_pkey PRIMARY KEY (pack, recno),
  CONSTRAINT chck_ptr CHECK (ptr IS NOT NULL),
  CONSTRAINT plan_20190221_dt_check CHECK (dt = '2019-02-21'::date)
)
INHERITS (public.plan)

Całkowity rozmiar sekcji określany jest przez liczbę „faktów” i zależy tylko od procesów zewnętrznych, dlatego podzielmy rozmiar heap (pg_relation_size) na liczbę zapisów w niej — więc otrzymamy średni rozmiar rzeczywistego zapisanego rekordu:

Oszczędzamy kilka groszy przy dużych ilościach w PostgreSQL
Minus 6% objętości, świetnie!

Ale to wszystko, oczywiście, nie jest tak różowe — ponieważ w indeksach nie możemy zmienić kolejności pól, a więc „ogólnie” (pg_total_relation_size)…

Oszczędzamy kilka groszy przy dużych ilościach w PostgreSQL
… mimo wszystko i tutaj zaoszczędziliśmy 1,5%, nie zmieniając ani jednej linijki kodu. No tak!

Oszczędzamy kilka groszy przy dużych ilościach w PostgreSQL

Zauważam, że powyższa wersja rozmieszczenia pól — nie jest pewne, że jest najbardziej optymalna. Bo niektóre bloki pól nie chcemy „przerywać” już ze względów estetycznych — na przykład para (pack, recno), która jest PK dla tej tabeli.

Ogólnie jednak, określenie „minimalnego” rozmieszczenia pól — to dość proste zadanie „przeglądowe”. Dlatego możesz na swoich danych uzyskać wyniki jeszcze lepsze niż nasze — spróbuj!

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster