Extended statistics in PostgreSQL

Niedoceniany Postgres: CREATE STATISTICS (extended statistics)

Extended statistics pomagają PostgreSQL poprawić błędne estymacje liczby wierszy i wybrać lepsze plany praktycznie bez dodatkowego kosztu storage.

Chris Engelbert 22 min

Krótka wersja: gdy zapytania są wolne, większość ludzi najpierw myśli o indeksach. PostgreSQL ma ich sporo: multi-column, partial, expression indexes. Problem w tym, że każdy dodatkowy indeks kosztuje storage, zapis i utrzymanie. Tymczasem wiele osób ignoruje CREATE STATISTICS. Extended statistics potrafią poprawić plan przy niemal zerowym koszcie pamięci.

W wielu złych planach prawdziwy problem nie polega na tym, że planner jest leniwy. Problem polega na tym, że pracuje na złym obrazie danych. Gdy kilka predykatów jest skorelowanych, statystyki per kolumna przestają wystarczać. I właśnie tu wchodzą extended statistics.

Krótka wersja

CREATE STATISTICS pozwala Postgresowi zebrać dodatkowe informacje, których zwykłe statystyki kolumnowe nie potrafią dobrze oddać.

W praktyce pomaga to szczególnie w czterech sytuacjach:

  • skorelowane filtry na wielu kolumnach,
  • wielokolumnowe estymacje DISTINCT,
  • mocno skośne kombinacje wartości,
  • wyrażenia, dla których normalnie kusiłby nas expression index.
-- Alternative to index on expressions
CREATE STATISTICS stats_expr
ON (date_trunc(day, created_at))
FROM events;
 
--- What people normally mean by
--- extended statistics
CREATE STATISTICS stats_multi
(dependencies, ndistinct, mcv)
ON plan_tier, region, billing_status
FROM tenants;

Pierwsza forma przydaje się np. dla date_trunc albo ekstrakcji JSONB. Druga to właśnie to, co większość ludzi ma na myśli, mówiąc o extended statistics.

Najważniejsze typy to dependencies, ndistinct i mcv. I jak zwykle, CREATE STATISTICS tylko definiuje obiekt, a ANALYZE dopiero zbiera dane.

Dlaczego extended statistics?

Domyślnie PostgreSQL przechowuje przede wszystkim statystyki per kolumna. To działa dobrze, gdy filtry można traktować niezależnie. Problem zaczyna się wtedy, gdy zależności między kolumnami są istotne.

WHERE plan_tier = 'enterprise'
AND region = 'eu-central'
AND billing_status = 'active'

W realnych danych takie predykaty są często skorelowane. Bez dodatkowych statystyk PostgreSQL zachowuje się jednak tak, jakby każda klauzula niezależnie zmniejszała zbiór wynikowy. Skutek: matematycznie schludne, operacyjnie absurdalne estymacje.

Fixing the execution plan with extended statistics

A kiedy pierwsza estymacja jest zła, wszystko dalej zaczyna się chwiać: kolejność joinów, metoda joina, paralelizm, rozmiary hashy, strategia sortowania i ryzyko spillu.

Co Postgres wie domyślnie

Przy każdym ANALYZE PostgreSQL zapisuje dla każdej kolumny takie informacje jak udział nulli, histogramy, częste wartości czy liczby distinct. To bardzo przydatne, ale nadal tylko wiedza lokalna dla pojedynczej kolumny.

Załóżmy tabelę z milionem wierszy i taki rozkład:

  • plan_tier = 'enterprise' trafia w 6 %
  • region = 'eu-central' trafia w 25 %
  • billing_status = 'active' trafia w 80 %
1,000,000 * 0.06 * 0.25 * 0.80 = 12,000 rows

Problem polega na tym, że w realnym świecie te kolumny rzadko są niezależne. Prawdziwa odpowiedź może wynosić 40 000, 60 000 albo 5 000 wierszy. Zwykłe statystyki kolumnowe nie potrafią tego uchwycić.

Benchmark extended statistics

Żeby to pokazać konkretnie, zbudowałem syntetyczny, ale celowo realistyczny workload analityczny typu SaaS z czterema tabelami: tenants, tenant_entitlements, usage_rollups i analytics_events.

Dane zostały ukształtowane tak, by odsłonić ślepe punkty plannera: korelacje między planem, regionem i statusem billingowym, koncentrację premium entitlements i nierandomowe kombinacje (tenant, feature, day).

To dane syntetyczne, ale syntetyczne w tym samym sensie co tunel aerodynamiczny: kontrolowane po to, by wyizolować realne zjawisko.

Izolowanie problemu przez kształt danych

Tabela: tenants

Kolumny segmentujące są skorelowane w sposób typowy dla realnych danych SaaS.

Tabela: tenant_entitlements

Premium features jak sso, audit_logs czy workflows są częściej skupione po stronie enterprise tenantów.

Tabela: usage_rollups

Ta tabela modeluje realistyczny fanout czasowy dla kombinacji tenant-feature.

Tabela: analytics_events

To tu następuje eksplozja kardynalności. Na każdą pasującą kombinację tenant-feature może przypadać wiele surowych zdarzeń.

Zapytanie raportowe

Pytanie biznesowe brzmi prosto: „które premium features generują najwięcej ostatniej aktywności wśród aktywnych enterprise tenantów w ważnym regionie?”

SELECT
e.feature_name,
count(*) AS total_events,
sum(r.request_count) AS rolled_requests
FROM tenants t
JOIN tenant_entitlements te
ON te.tenant_id = t.id
JOIN usage_rollups r
ON r.tenant_id = t.id
AND r.feature_name = te.entitlement
JOIN analytics_events e
ON e.tenant_id = t.id
AND e.feature_name = r.feature_name
WHERE t.plan_tier = 'enterprise'
AND t.region = 'eu-central'
AND t.billing_status = 'active'
AND te.entitlement IN ('sso', 'audit_logs')
AND r.rollup_day >= CURRENT_DATE - 30
AND e.event_type IN ('view', 'click', 'run')
GROUP BY e.feature_name
ORDER BY rolled_requests DESC;

Dla wielu regionów mamy też nieco rozszerzoną wersję z CTE i końcową agregacją.

WITH regional_feature_totals AS (
SELECT
t.region,
e.feature_name,
count(*) AS total_events,
sum(r.request_count) AS rolled_requests
FROM tenants t
JOIN tenant_entitlements te
ON te.tenant_id = t.id
JOIN usage_rollups r
ON r.tenant_id = t.id
AND r.feature_name = te.entitlement
JOIN analytics_events e
ON e.tenant_id = t.id
AND e.feature_name = r.feature_name
WHERE t.plan_tier = 'enterprise'
AND t.billing_status = 'active'
AND t.region IN ('eu-central', 'ap-southeast', 'us-east')
AND te.entitlement IN ('sso', 'audit_logs', 'workflows')
AND r.rollup_day >= CURRENT_DATE - 30
AND e.event_type IN ('view', 'click', 'run')
GROUP BY t.region, e.feature_name
)
SELECT region, feature_name, total_events, rolled_requests
FROM (
SELECT
region,
feature_name,
total_events,
rolled_requests,
row_number() OVER (
PARTITION BY region
ORDER BY rolled_requests DESC,
total_events DESC,
feature_name
) AS regional_rank
FROM regional_feature_totals
) ranked
WHERE regional_rank <= 3
ORDER BY region, regional_rank;

Rozbijmy kształt tej kwerendy

Etap 1: zdefiniowanie tenant slice

WHERE t.plan_tier = 'enterprise'
AND t.billing_status = 'active'
AND t.region IN ('eu-central', 'ap-southeast', 'us-east')

Jeśli PostgreSQL pomyli się już tutaj, to cały dalszy plan opiera się na złej populacji wejściowej.

Etap 2: ograniczenie do dozwolonych features

JOIN tenant_entitlements te
ON te.tenant_id = t.id
...
AND te.entitlements IN (...)

Tabela entitlementów działa tu jednocześnie jako cel joina i skorelowany filtr feature’ów.

Etap 3: związanie ostatniego usage z dozwolonymi features

JOIN usage_rollups r
ON r.tenant_id = t.id
AND r.feature_name = te.entitlement
...
AND r.rollup_day >= CURRENT_DATE - 30

W tym miejscu zaczyna się mnożenie. Jeden tenant może mieć wiele pasujących entitlementów, a jedna kombinacja tenant-feature może mieć wiele ostatnich dni rollupów.

Etap 4: rozszerzenie do raw events

JOIN analytics_events e
ON e.tenant_id = t.id
AND e.feature_name = r.feature_name
...
AND e.event_type IN (...)

Na tym etapie złe estymacje zaczynają naprawdę boleć. Nested Loops mogą wyglądać tanio tylko na papierze.

Etap 5: ponowne zwinięcie eksplozji do małego wyniku

GROUP BY e.feature_name
ORDER BY rolled_requests DESC
GROUP BY t.region, e.feature_name

Końcowy wynik jest mały, ale droga do niego wcale nie jest mała. To właśnie czyni tę kwerendę świetną demonstracją plannera.

Etap 6: ranking w wersji multi-region

row_number() OVER (
PARTITION BY region
ORDER BY rolled_requests DESC,
                 total_events DESC,
                 feature_name
) AS regional_rank
...
--- followed by
WHERE regional_rank <= 3

Tu dochodzą dodatkowe sorty i większa presja na temp I/O. To już nie tylko problem joinów i agregacji, ale również rankingu.

Extended statistics na ratunek

Naturalnym odruchem przy takich problemach jest dorzucenie kolejnych indeksów multi-column, partial albo expression. Tutaj zamiast tego tworzymy trzy obiekty statystyczne, które uderzają w podstawowe błędne założenia plannera.

  • skorelowane kolumny tenantów,
  • kombinacje tenant-entitlement,
  • kombinacje rollupów.

Po utworzeniu oczywiście trzeba uruchomić ANALYZE.

Typ: dependencies

Statystyki zależności funkcyjnych pomagają PostgreSQL zrozumieć, że pewne kolumny przewidują inne na tyle silnie, że założenie niezależności jest błędne.

  • Kraj często silnie przewiduje walutę.
  • Stan silnie przewiduje strefę czasową.
  • Segment tenantów silnie przewiduje rodziny entitlementów.
CREATE STATISTICS stats_tl_segment_dependencies (dependencies)
ON id, plan_tier, region, billing_status
FROM tenants;
WHERE plan_tier = 'enterprise'
AND region = 'eu-central'
AND billing_status = 'active'

Prosty skrót myślowy: dependencies naprawia „te filtry nie są niezależne”.

Typ: mcv

MCV oznacza „most common values”, ale przy extended statistics lepiej myśleć o tym jako o „najczęstszych kombinacjach”.

CREATE STATISTICS stats_tl_segment_mcv (mcv)
ON plan_tier, region, billing_status
FROM tenants;

Dzięki temu PostgreSQL uczy się, że pewne kombinacje są wyjątkowo częste, wyjątkowo rzadkie albo wręcz niemożliwe. Skrót myślowy: mcv naprawia „ta konkretna kombinacja zachowuje się inaczej niż średnia”.

Typ: ndistinct

ndistinct mówi PostgreSQL-owi, ile różnych kombinacji wielu kolumn rzeczywiście istnieje, zamiast znać tylko distinct dla każdej kolumny osobno.

CREATE STATISTICS stats_tel_entitlement_ndistinct (ndistinct)
ON tenant_id, entitlement
FROM tenant_entitlements;
 
CREATE STATISTICS stats_url_feature_ndistinct (ndistinct)
ON tenant_id, feature_name, rollup_day
FROM usage_rollups;

Prosty skrót: ndistinct naprawia „tych kombinacji group-by albo joinów nie ma aż tyle, ile planner myśli”.

Co PostgreSQL faktycznie przechowuje

Tu robi się naprawdę ciekawie. Wszystkie opisane wyżej problemy można by też próbować rozwiązywać dodatkowymi indeksami. Tyle że indeksy są drogie zarówno w utrzymaniu, jak i w storage.

Extended statistics są z kolei bardzo tanie. Ich definicje żyją w pg_statistic_ext, a zebrane dane w pg_statistic_ext_data.

  • pg_statistic_ext mówi, jakie obiekty statystyczne istnieją.
  • pg_statistic_ext_data mówi, jakie dane naprawdę zostały zebrane.

W tym benchmarku całkowity footprint obiektów statystycznych wyniósł około 2 059 bajtów:

  • Tenant-Segment mcv: ~1 049 bajtów
  • Tenant-Segment dependencies: ~518 bajtów
  • Entitlement ndistinct: ~220 bajtów
  • Rollup ndistinct: ~272 bajty

I o to właśnie chodzi: to nie są wielkie „cienie indeksów”, tylko małe metadane, które sprawiają, że planner mniej się myli.

Naprawianie execution plans

Kiedy zaczynałem pisać ten tekst, chciałem przede wszystkim pokazać lepsze czasy wykonania. Owszem, poprawiły się. Ale dużo ważniejsze okazało się to, że poprawiły się estymacje.

ScenarioAverage TimeWorst EstimateAdditional Storage
Extended statistics2,857.896 ms48.27x2.0 KiB
Baseline + Composite Join-Key Indexes5,882.659 ms106.48x37.64 MiB
Baseline + Events (feature_name, event_type, tenant_id)4,781.551 ms108.7x31.45 MiB
Baseline + Partial Hot-Path Indexes4,765.351 ms106.22x30.20 MiB
Extended Statistics + Partial Hot-Path Indexes4,876.187 ms46.65x30.21 MiB

Każde zapytanie uruchomiono pięć razy. Pełne plany i wyniki znajdują się w repozytorium.

Zapytanie dla jednego regionu

Schema bazowe używa indeksów pojedynczych kolumn. To pozwala uniknąć pełnego skanu sekwencyjnego, ale planner nadal musi zgadywać przecięcia skorelowanych predykatów na tenants, entitlements, rollups i events.

MetricValue
Average execution time14,168.174 ms
Average planning time4.134 ms
Execution samples31,093.955 ms,
29,065.788 ms,
3,564.811 ms,
3,570.114 ms,
3,546.200 ms
Planning samples5.058 ms,
3.952 ms,
8.969 ms,
1.899 ms,
0.793 ms
Join chainNested Loop:Inner -> Nested Loop:Inner -> Nested Loop:Inner
Scan nodesIndex Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan, Index Scan
Top-level rows7 estimated / 2 actual
Worst estimateNested Loop: 178,389 estimated / 18,774,720 actual (105.25x)
Worst estimate pathSort -> Aggregate -> Nested Loop

Z extended statistics wygląda to dużo lepiej:

MetricValue
Average execution time2,857.896 ms
Average planning time3.025 ms
Execution samples4,318.854 ms,
2,612.261 ms,
2,396.848 ms,
2,467.702 ms,
2,493.816 ms
Planning samples6.719 ms,
2.067 ms,
2.128 ms,
2.243 ms,
1.970 ms
Join chainHash Join:Inner -> Nested Loop:Inner -> Merge Join:Inner
Scan nodesBitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan
Top-level rows7 estimated / 2 actual
Worst estimateHash: 12,966 estimated / 625,824 actual (48.27x)
Worst estimate pathSort -> Aggregate -> Gather Merge -> Sort -> Aggregate -> Hash Join -> Hash

Planner przestaje zachowywać się tak, jakby wolumeny pośrednie były malutkie. Ważna subtelność: szacowany koszt całkowity rośnie. To nie regresja, tylko sygnał, że PostgreSQL wreszcie uwierzył w bardziej realistyczny model danych.

Zapytanie multi-region

W wersji multi-region różnica w czasie wykonania jest jeszcze bardziej widoczna.

Baseline:

MetricValue
Average execution time24,933.705 ms
Average planning time3.648 ms
Execution samples84,815.859 ms,
12,696.190 ms,
11,819.487 ms,
8,482.936 ms,
6,854.054 ms
Planning samples6.125 ms,
5.681 ms,
2.067 ms,
2.181 ms,
2.187 ms
Join chainMerge Join:Inner -> Nested Loop:Inner -> Merge Join:Inner
Scan nodesSubquery Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan
Top-level rows28 estimated / 5 actual
Worst estimateSort: 53,580 estimated / 17,164,496 actual (320.35x)
Worst estimate pathIncremental Sort -> Subquery Scan -> WindowAgg -> Incremental Sort -> Aggregate -> Gather Merge -> Sort -> Aggregate -> Merge Join -> Sort

Z extended statistics:

MetricValue
Average execution time7,289.319 ms
Average planning time2.875 ms
Execution samples7,503.924 ms,
8,261.715 ms,
7,433.035 ms,
8,181.565 ms,
5,066.357 ms
Planning samples9.244 ms,
0.932 ms,
1.039 ms,
1.801 ms,
1.359 ms
Join chainHash Join:Inner -> Nested Loop:Inner -> Merge Join:Inner
Scan nodesSubquery Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan
Top-level rows28 estimated / 5 actual
Worst estimateNested Loop: 68,387 estimated / 1,113,566 actual (16.28x)
Worst estimate pathIncremental Sort -> Subquery Scan -> WindowAgg -> Incremental Sort -> Aggregate -> Gather Merge -> Sort -> Aggregate -> Hash Join -> Hash -> Nested Loop

To nie jest drobny tuning. To jest PostgreSQL wracający mniej więcej do tego samego kodu pocztowego co rzeczywistość.

Czy zapytania faktycznie przyspieszyły?

Czasem tak, czasem nie. Właśnie dlatego warto podchodzić do tego tematu ostrożnie.

Runtime dla jednego regionu

  • Średnia baseline: 14,168.174 ms
  • Średnia z extended statistics: 2,857.896 ms

Na pierwszy rzut oka wygląda to spektakularnie, ale baseline jest mocno zaburzony przez dwa ekstremalnie wolne pierwsze uruchomienia. Ostrożniejsza teza brzmi więc: lepsza wiedza plannera przełożyła się tu na lepsze zachowanie w stanie ustalonym.

Runtime multi-region

  • Tylko statistics: 7,289.319 ms
  • Najlepsza strategia tylko z indeksami: 6,886.473 ms

To nie osłabia argumentu za CREATE STATISTICS. Po prostu go doprecyzowuje. Statystyki poprawiają estymacje, a indeksy tworzą fizyczne ścieżki dostępu.

Dlaczego porównanie z indeksami ma znaczenie

Jednym z ciekawszych wyników było to, że scenariusze bogate w indeksy potrafiły wyglądać atrakcyjnie pod względem kosztu czy runtime, a jednocześnie pozostawały znacznie gorsze pod względem jakości estymacji.

  • Extended statistics: około 2.0 KiB
  • Alternatywy oparte o indeksy: około 30.20 MiB do 37.64 MiB

Difference in storage usage between extended statistics and indexes

I właśnie to jest niedocenione: za około 2 KiB metadanych PostgreSQL staje się dużo mniej błędny, podczas gdy alternatywy z indeksami potrafią zużyć dziesiątki MiB więcej i nadal nie rozwiązać problemu plannera.

Dlaczego „użyj obu” nie wygrało automatycznie

Oczywiście sprawdziłem też najbardziej oczywiste pytanie: czy połączenie extended statistics i dobrych indeksów daje automatycznie najlepszy wynik?

Nie zawsze.

Single-region:

  • Tylko statistics: 2,857.896 ms
  • Statistics + Partial Hot-Path Indexes: 4,876.187 ms

Multi-region:

  • Tylko statistics: 7,289.319 ms
  • Statistics + Partial Hot-Path Indexes: 8,950.207 ms

Więcej indeksów nie tworzy tylko nowych możliwości. Zmienia też przestrzeń wyszukiwania plannera. „Użyj obu” nie jest więc prawem natury, tylko kolejnym pytaniem benchmarkowym.

Praktyczne caveaty

Warto jasno powiedzieć o kilku ograniczeniach:

  • Extended statistics działają tylko w obrębie jednej relacji.
  • PostgreSQL nie używa ich bezpośrednio do estymacji selectivity joinów między tabelami.
  • Nie każdy workload skorzysta. Jeśli kolumny są naprawdę bliskie niezależności, efekt może być mały.
  • Nie zastępują dobrego projektu zapytań, sensownych indeksów, partycjonowania, rozsądnych ustawień pamięci ani szybkiego storage.

To funkcja informacyjna dla plannera, a nie uniwersalny talizman wydajnościowy.

Kiedy sięgać po CREATE STATISTICS

Praktyczna reguła jest prosta: jeśli plan wygląda źle, a źródłem problemu wydaje się wspólne filtrowanie lub grupowanie wielu kolumn w sposób, którego planner źle modeluje, pomyśl o extended statistics zanim odruchowo zbudujesz kolejny indeks.

Dobre kandydatury to:

  • silnie skorelowane kolumny filtrujące,
  • atrybuty wymiarów napędzane segmentem,
  • group-by workloads z ewidentnie błędną kardynalnością,
  • problemy multi-column fanout,
  • silnie skośne kombinacje częstych wartości,
  • plany, w których jedna zła estymacja pojedynczej relacji zatruwa całą resztę.

Słabsi kandydaci to zapytania z brakującą ścieżką dostępu, workloady z oczywistym bottleneckiem I/O albo przypadki, w których kolumny są faktycznie prawie niezależne.

Użyj EXPLAIN (ANALYZE, VERBOSE, BUFFERS). Jeśli estimated rows i actual rows mocno się rozjeżdżają, extended statistics mogą być dokładnie tym, czego potrzeba.

Prawda jest lepsza niż sztuczki

Najlepsza rzecz w CREATE STATISTICS polega na tym, że daje plannerowi więcej prawdy zamiast dokładania kolejnych struktur na dane.

Benchmarki nie mówią „extended statistics zawsze wygrywają”. Mówią coś bardziej użytecznego:

  • sprawiły, że PostgreSQL był dużo mniej błędny,
  • zmieniły kształt planu w sensowny sposób,
  • kosztowały prawie nic w storage,
  • i często trafiały w problem plannera bardziej bezpośrednio niż dziesiątki MiB dodatkowych indeksów.

Benchmark, kwerendy i wyniki są na GitHubie. Możesz też sam przetestować je w Vela Postgres Sandbox.

A jeśli kiedykolwiek patrzyłeś na plan i myślałeś: „nie ma świata, w którym ten join powinien być zagnieżdżony trzy razy i udawać dorosłego w prochowcu”, to bardzo możliwe, że właśnie szukałeś extended statistics.