Overpartitioning in PostgreSQL

Il partitioning consente di gestire in modo efficace tabelle di grandi dimensioni sui piu' moderni database relazionali.
Ma se il partizionamento non e' utilizzato correttamente le prestazioni anziche' migliorare possono peggiorare anche in modo molto significativo. In questa paginetta vediamo quali sono gli effetti dell'overpartitioning in PostgreSQL.

Dopo una breve introduzione vedremo gli effetti dell'overpartitioning, la diagnosi, come prevenire il problema, come correggerlo e come agire nei casi clinici peggiori!

Introduzione

Il partizionamento di una relazione in pratica consiste nell'utilizzare strutture fisiche separate per ogni partizione sia per la tabella che per gli indici. Una tabella definita come partizionata in PostgreSQL non contiene alcun dato: i dati sono mantenuti nelle partizioni che sono normali relazioni . I vantaggi del partizionamento sono sopratutto due:

Ulteriori vantaggi si hanno nella gestione perche' le strutture dati sono piu' piccole e quindi piu' facilmente trattabili nelle normali attivita' di manutenzione (eg. vacuum).
Il partizionamento va utilizzato con attenzione perche' vi sono limiti e controindicazioni. Ad esempio il partizionamento e' utile solo con tabelle di grandi dimensioni (eg. tabelle con oltre un milione di record oppure con dimensioni >>2GB), le partizioni debbono essere in numero ragionevole (eg. tra 10 e 200) e bilanciate tra loro, e' opportuno utilizzare uno solo livello di partizionamento o al massimo due, le query debbono essere eseguite utilizzando sempre la chiave di partizionamento per sfruttare il pruning e la modalita' di svecchiamento ottimale e' il DROP o il DETACH di una partizione.

Ulteriori informazioni sul partizionamento in PostgreSQL si possono trovare nelle paginette su partizionamento nativo, partizionamento using inheritance, buoni consigli e cattivi esempi, lock con il partizionamento. Ora vedremo qualche ulteriore dettaglio per meglio comprendere l'overpartitioning.

Pruning

Le query che vengono lanciate sulle tabelle partizionate cercano di eseguire i passi dell'execution plan sul numero minore possibile di partizioni. Questa tecnica viene chiamata partition pruning ed e' abilitata per default [Nda parametro enable_partition_pruning].
La verifica che il pruning venga effettuato e' molto importante e si esegue con l'EXPLAIN:

explain analyze select count(*) from measurement where logtime between '2022-01-01' and '2022-01-31';

                                                                           QUERY PLAN                                                                            
-----------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=14.83..14.84 rows=1 width=8) (actual time=0.009..0.010 rows=1313 loops=1)
   ->  Bitmap Heap Scan on measurement_202201 measurement  (cost=4.24..14.81 rows=9 width=0) (actual time=0.007..0.008 rows=1313 loops=1)
         Recheck Cond: ((logtime >= '2022-01-01 00:00:00'::timestamp without time zone) AND (logtime <= '2022-01-31 00:00:00'::timestamp without time zone))
         ->  Bitmap Index Scan on measurement_202201_logtime_idx  (cost=0.00..4.24 rows=9 width=0) (actual time=0.003..0.003 rows=1313 loops=1)
               Index Cond: ((logtime >= '2022-01-01 00:00:00'::timestamp without time zone) AND (logtime <= '2022-01-31 00:00:00'::timestamp without time zone))
 Planning Time: 0.157 ms
 Execution Time: 0.045 ms

Nell'esempio la query viene eseguita su un'unica partizione perche' la condizione utilizzata consente di determinare dove sono memorizzati i dati.

E' pero' importante distinguere due casi:

In entrambe i casi le partizioni escluse non vengono fisicamente lette, tuttavia nel caso del pruning dinamico il piano contiene comunque tutte le partizioni e sono comunque elevati l'overhead di planning ed il consumo di memoria. Con il pruning dinamico, i sotto-nodi corrispondenti alle partizioni scartate a runtime vengono saltati durante l'esecuzione, ma il costo ed i lock necessari per generare l'intero albero nel piano rimane presente.

Overpartitioning

In PostgreSQL, a differenza di altri DBMS, ogni partizione e' a tutti gli effetti una tabella indipendente con i propri file su disco, catalogo di sistema, indici e metadati. L'overpartitioning in PostgreSQL puo' causare un degrado prestazionale e problemi amministrativi particolarmente significativi.

Ecco un elenco delle criticità e dei problemi tipici dell'overpartitioning, organizzati per area di impatto:

  1. Planning
    L'impatto principale dell'overpartitioning si manifesta direttamente sull'optimizer.
    1. Overhead della fase di Planning: Prima che una query venga eseguita, il planner deve processare tutte le partizioni esistenti. Anche con il partition pruning attivo, il planner deve scorrere la gerarchia del catalogo per determinare quali partizioni escludere. Con migliaia di partizioni, la sola fase di planning può passare da pochi millisecondi a diversi secondi.
    2. Inutilità dei Prepared Statements (PREPARE): Per query generiche su tabelle overpartitioned, il planner spesso rifiuta i generic plan o spende troppo tempo a ri-pianificare ogni esecuzione (custom plan), vanificando il riuso del piano.
    3. Contesa sulla memoria del Planner: Ogni partizione analizzata richiede strutture dati in memoria (work_mem, memoria di sessione). Pianificare query con JOIN tra piu' tabelle overpartitioned può causare picchi estremi di consumo di memoria.
  2. Esecuzione
    Trattare centinaia di partizioni aumenta i tempi esecuzione.
    1. Assenza di pruning: Se una query non sfrutta la chiave di partizionamento, il motore deve eseguire uno scan (o un index scan) su tutte le sotto-tabelle. L'overhead di apertura e scansione di centinaia di file aumenta drasticamente il tempo di esecuzione rispetto a una singola scansione su tabella non partizionata.
  3. Catalogo di Sistema e Lock
    Un numero eccessivo di oggetti sovraccarica il catalogo di sistema (pg_class, pg_inherits, pg_attribute, pg_depend) ed aumenta il numero ed il rischio di attese sui lock.
    1. Bloat e lentezza delle system views: Le query sulle viste di catalogo (usate anche dai client SQL come dBeaver, pgAdmin o dai tool di monitoraggio) diventano estremamente lente.
    2. DDL Lock Amplification: Una qualsiasi operazione di maintenance (es. VACUUM, ANALYZE, ALTER TABLE sulla tabella padre) deve acquisire lock (anche se leggeri) su tutte le child partition.
    3. DML Lock Amplification: Una query che non riesce ad applicare il pruning statico acquisirà lock in lettura su ogni singola partizione.
    4. LW Lock Storm: Gli LWLock sono lock interni di brevissima durata che servono a proteggere le strutture interne del database. Sono utilizzati per esempio quando deve essere riservato un blocco tra gli shared_buffers o quando vengono costruite le strutture delle Relcache e Syscache. Se pero' vengono richieste quasi contemporaneamente da centinaia di sessioni su oggetti con migliaia di componenti si puo' formare un accodamento che rallenta pesantemente l'istanza e non puo' piu' risolversi autonomamente.
  4. Risorse di Sistema (OS e Postgres)
    PostgreSQL gestisce gli oggetti a livello di file system e processi background, creando colli di bottiglia a livello SO.
    1. File Descriptor Limit (max_files_per_process): Ogni partizione e ciascuno dei suoi indici corrispondono a file fisici su disco. Query che leggono molte partizioni o operazioni globali rischiano di saturare il limite di file descriptor aperti dal processo.
    2. Cache Miss e Shared Buffers: Avere un numero spropositato di file distribuisce la cache di sistema su troppi blocchi piccoli, riducendo l'efficacia del caching e aumentando l'I/O su disco.
    3. Dimensione degli Indici e B-Tree overhead: Invece di pochi indici bilanciati di medie dimensioni, si ottengono centinaia/migliaia di piccolissimi indici B-Tree. Il costo di metadati globale per contenere le radici e le foglie dei singoli B-Tree supera spesso il beneficio di avere indici piccoli.
  5. Processi di Maintenance e Background
    I processi interni di gestione dell'istanza subiscono un forte rallentamento.
    1. Autovacuum Bloat e Throttling: L'autovacuum deve iterare su un numero elevatissimo di tabelle fisiche. Anche se le partizioni sono vuote o piccolissime, l'overhead di check continuo rallenta l'intervento sulle partizioni che realmente ne avrebbero bisogno.
    2. Metadati di Connessione e Cache Invalidation: Ogni modifica o accesso alle tabelle invalida la relcache (relation cache) delle sessioni backend attive. Ricostruire la cache per migliaia di partizioni a ogni sessione o modifica degrada la throughput complessiva.
    3. Backup e Restore Lenti: pg_dump e pg_restore devono generare istruzioni DDL e gestire metadati per ogni singolo oggetto. Il tempo di backup/restore cresce linearmente con il numero di oggetti, indipendentemente dal volume dei dati contenuti.

Diagnosi

I sintomi dell'overpartitioning si manifestano sia a livello database che a livello del sistema operativo. Spesso casi le prestazioni degradano lentamente e solo su alcune query (e.g. quelle che non effettuano il pruning), ma sono frequenti anche casi in cui il degrado e' molto piu' grave ed improvviso.

Dal punto di vista dell'SQL, un indicatore immediato si ottiene confrontando il Planning Time con l'Execution Time mediante EXPLAIN ANALYZE: se la fase di planning richiede centinaia di millisecondi (o persino secondi) per una query che poi viene eseguita in pochi millisecondi, si e' in presenza di overhead da overpartitioning.

Tuttavia, l'impatto piu' devastante si osserva a livello di Kernel. Analizzando il processo backend di PostgreSQL con strace durante l'esecuzione di una query su una tabella con migliaia di partizioni, si può notare una situazione di questo tipo:

# strace -p 424242 -c
strace: Process 424242 attached
^Cstrace: Process 424242 detached
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 99.07    9.369864        1503      6234           close
  0.74    0.070419          11      6234           openat
  0.19    0.017729           2      6234           lseek
  0.00    0.000040           5         7         4 futex
  0.00    0.000020           3         6           epoll_wait
  0.00    0.000013           0        26           sendto
  0.00    0.000013           1        11         3 recvfrom
  0.00    0.000000           0         2           read
  0.00    0.000000           0         2           write
  0.00    0.000000           0         2           pwrite64
  0.00    0.000000           0         6           kill
  0.00    0.000000           0         2           fdatasync
------ ----------- ----------- --------- --------- ----------------
100.00    9.458098                 18766         7 total

La traccia evidenzia come il processo spenda oltre il 99% del tempo ad eseguire chiamate di sistema close() ed openat().
Questo accade perche' PostgreSQL imposta un limite di file aperti per processo (parametro max_files_per_process, default 1000). Quando una query coinvolge centinaia o migliaia di partizioni ed indici separati, il backend supera la soglia di cache dei file descriptor aperti ed e' costretto a continuare ad aprire, consultare e chiudere continuamente i file fisici su disco, generando un degrado prestazionale fortissimo. In questo caso il rallentamento e' di due ordini di grandezza e l'effetto e' piu' vicino a quello di un blocco che di un semplice degrado prestazionale.

Si arriva veramente ad un crash quando si manifestano problemi di esaurimento memoria (OOM Killer), che rappresentano l'effetto collaterale piu' subdolo e difficile da diagnosticare.
La proliferazione incontrollata di oggetti impatta la memoria allocata da ogni singolo processo:

La conseguenza e' che il consumo complessivo di RAM del server cresce in modo invisibile fino a saturare il sistema. A quel punto interviene l'OOM Killer del sistema operativo che termina un processo PostgreSQL arbitrario o quasi. Poiche' PostgreSQL adotta un'architettura a processi con memoria condivisa, la terminazione improvvisa (SIGKILL) di un qualsiasi backend costringe il postmaster a riavviare immediatamente l'intera istanza ed entrare in recovery mode per garantire la consistenza dei dati.
Il risultato e' un crash generale del database che appare totalmente casuale nei log, ma la cui causa primaria risiede esclusivamente nell'eccesso di partizioni.

Meglio prevenire che curare

E' sempre meglio prevenire che curare!

Gli effetti dell'overpartitioning possono essere improvvisi e drammatici portando praticamente al blocco della base dati: un rallentamento di 100 volte nei tempi di risposta rende non utilizzabile un servizio, bisogna intervenire prima.
Verificare la presenza di un partizionamento eccessivo e' semplice: la presenza di partizioni vuote o di piccole dimensioni, tabelle con piu' di 100 partizioni, piu' di 20.000 oggetti a livello di istanza, ... sono controlli semplici.
Possono essere utili le seguenti query:

SELECT r.rolname,
       n.nspname,
       t.relname,
       COUNT(DISTINCT p.oid) + COUNT(DISTINCT c2.oid) AS total_partitions,
       COUNT(DISTINCT COALESCE(c2.oid, p.oid)) FILTER (WHERE (COALESCE(c2.relkind, p.relkind) = 'r')) AS leaf_partitions,
       COUNT(DISTINCT COALESCE(c2.oid, p.oid)) FILTER (WHERE (COALESCE(c2.relkind, p.relkind) = 'r')
                                                         AND COALESCE(c2.reltuples, p.reltuples, 0) <1000) AS few,
       COUNT(DISTINCT COALESCE(c2.oid, p.oid)) FILTER (WHERE (COALESCE(c2.relkind, p.relkind) = 'r') 
                                                         AND COALESCE(c2.relpages, p.relpages, 0) < 2) AS small,
       COUNT(DISTINCT COALESCE(c2.oid, p.oid)) FILTER (WHERE (COALESCE(c2.relkind, p.relkind) = 'r') 
                                                         AND COALESCE(c2.reltuples, p.reltuples, 0) = 0) AS empty,
       COUNT(DISTINCT COALESCE(c2.oid, p.oid)) FILTER (WHERE (COALESCE(c2.relkind, p.relkind) = 'r') 
                                                         AND COALESCE(c2.reltuples, p.reltuples, -1) < 0) AS unanalyzed
  FROM pg_class t
  JOIN pg_inherits i ON i.inhparent = t.oid
  JOIN pg_class p ON p.oid = i.inhrelid
  LEFT JOIN pg_inherits i2 ON i2.inhparent = p.oid
  LEFT JOIN pg_class c2 ON c2.oid = i2.inhrelid
  JOIN pg_roles r ON t.relowner = r.oid
  JOIN pg_namespace n ON t.relnamespace = n.oid
 WHERE p.relkind IN ('r','p')
 GROUP BY r.rolname, n.nspname, t.relname
 HAVING COUNT(*) > 50
 ORDER BY total_partitions DESC;

select nspname as schema_matrix, rolname as owner,
 sum(case when relkind='r' THEN 1 ELSE 0 end) as table,
 sum(case when relkind='i' THEN 1 ELSE 0 end) as index,
 sum(case when relkind='p' THEN 1 ELSE 0 end) as p_tab,
 sum(case when relkind='I' THEN 1 ELSE 0 end) as p_idx,
 sum(case when relkind='v' THEN 1 ELSE 0 end) as view,
 sum(case when relkind='S' THEN 1 ELSE 0 end) as seq,
 sum(case when relkind='c' THEN 1 ELSE 0 end) as comp,
 sum(case when relkind='f' THEN 1 ELSE 0 end) as foreing,
 sum(case when relkind='t' THEN 1 ELSE 0 end) as toast,
 sum(case when relkind='m' THEN 1 ELSE 0 end) as mat_view,
 count(*) as TOTAL,
 sum(case when relkind in ('r','p') THEN case when relispartition then 1 else 0 end else 0 end) as part,
 sum(case when relkind in ('r','p') THEN case when relispartition then 0 else 1 end else 0 end) as n_part,
 sum(case when relpersistence='u' THEN 1 ELSE 0 end) as unlog,
 sum(case when relpersistence='t' THEN 1 ELSE 0 end) as temp
from pg_class, pg_roles, pg_namespace
where relowner=pg_roles.oid
  and relnamespace=pg_namespace.oid
  and rolname not in ('postgres', 'rdsadmin', 'enterprisedb')
group by rolname, nspname
order by nspname, rolname;

Come rimediare: granularità e consolidamento

Quando ci si trova di fronte a una situazione di overpartitioning, la soluzione definitiva e' ridurre il numero totale di oggetti nel database consolidando le partizioni esistenti.

Granularità

Prima di modificare la struttura, e' fondamentale individuare la dimensione corretta per le nuove partizioni. In PostgreSQL valgono alcune regole generali:

Strategia di migrazione e raggruppamento

Per convertire, ad esempio, molte partizioni giornaliere in una singola partizione mensile senza fermare il database, la strategia migliore prevede l'uso di una tabella temporanea di staging e l'istruzione DETACH PARTITION CONCURRENTLY (introdotta in PostgreSQL 12).

I passaggi operativi sono:

  1. Creare la nuova partizione mensile: Definire la nuova sotto-tabella con il range dell'intero mese (es. measurement_2024_01).
  2. Spostare i dati dalle vecchie partizioni: Copiare i dati dalle singole partizioni giornaliere nella nuova partizione mensile oppure in una tabella di staging.
  3. Staccare le partizioni giornaliere (DETACH): Sganciare le partizioni giornaliere dalla tabella padre in modo concorrente per evitare lock bloccanti sulle query in corso:
    ALTER TABLE measurement DETACH PARTITION measurement_2024_01_01 CONCURRENTLY;
    ALTER TABLE measurement DETACH PARTITION measurement_2024_01_02 CONCURRENTLY;
    -- ... per tutti i giorni del mese
    
  4. Agganciare la nuova partizione mensile (ATTACH): Aggiungere la partizione consolidata alla tabella padre:
    ALTER TABLE measurement ATTACH PARTITION measurement_2024_01 
        FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
    
  5. Eliminare le vecchie partizioni: Una volta verificato che i dati sono correttamente visibili nella nuova partizione mensile, eseguire il DROP TABLE delle vecchie tabelle giornaliere disaccoppiate per liberare spazio e ripulire il catalogo.

In questo modo si ottiene un riallineamento drastico del catalogo di sistema, una sensibile diminuzione dei tempi di planning e la risoluzione dei problemi di memoria legati all'overpartitioning.

Quando il gioco si fa duro: i DBA iniziano a giocare!

Ridurre il numero di partizioni tramite il consolidamento e' e rimane la soluzione primaria. Tuttavia, quando su un VLBD il volume dei dati raggiunge dimensioni enterprise (decine di Terabyte o Petabyte), mantenere centinaia di partizioni (es. da 200 fino a 1000) può diventare un requisito fisico inevitabile per garantire le finestre di manutenzione e lo svecchiamento.

In questi contesti limite, dove il partizionamento e' corretto ma spinto al massimo, un DBA deve mettere in campo accorgimenti specifici per consentire a PostgreSQL di gestire l'elevata cardinalità dell'albero delle partizioni:

Partizionamento Gerarchico (Sub-partitioning)

Invece di creare un singolo livello con 1.000 partizioni figlie dirette sotto la tabella padre, e' preferibile distribuire l'albero su due livelli (es. Livello 1: Anno/Mese, Livello 2: Regione o Giorno).
Il planner analizza la gerarchia ad albero. Durante il pruning statico, se la query filtra per anno, il planner esclude immediatamente intere ramificazioni di sotto-partizioni senza doverle analizzare una per una, riducendo drasticamente il numero di oggetti caricati in memoria durante la pianificazione. Attenzione: per ottenere vantaggi con il sottopartizionamento e' fondamentale che venga utilizzato il pruning statico.

Tuning dei parametri di Planning e Connessioni

Se il numero di oggetti e' fisicamente elevato, e' necessario adattare le risorse dell'istanza:

Ottimizzazione delle Query per forzare il Pruning Statico

Quando le partizioni sono molte, il pruning dinamico non basta a salvare la fase di planning. e' necessario "aiutare" il planner dall'applicazione:

Aggiornamento delle versioni

Dall'introduzione del partizionamento nativo con la versione 10 sono state molte le ottimizzazioni introdotte per migliorare la gestione di tabelle con un numero elevato di partizioni. L'utilizzo di una major version recente permette di sfruttare tutti i miglioramenti introdotti nel tempo.

Manutenzione Selettiva ed Esclusione dell'Autovacuum

Sulle tabelle con centinaia di partizioni, l'Autovacuum predefinito rischia di spendere troppo tempo a controllare partizioni storiche che non cambiano mai (dati read-only):

Selezione e Tuning degli Strumenti di Monitoraggio

Uno degli errori piu' insidiosi sui VLDB con centinaia di partizioni riguarda i tool di monitoraggio e gestione (es. Telegraf, Nagios, Zabbix, o plugin dedicati). La maggior parte dei template standard esegue periodicamente (es. ogni 1 o 5 minuti) query che interrogano il catalogo di sistema per conteggiare la dimensione delle tabelle, il numero di indici o le statistiche di bloat.
Su una tabella overpartitioned, una semplice query di controllo sul catalogo può richiedere l'apertura e la scansione di migliaia di oggetti. E' opportuno seguire queste indicazioni:

Le soluzioni sono tattiche: escludere le sotto-partizioni dalle metriche di dettaglio, monitorare solo la tabella padre, oppure diradare drasticamente la frequenza delle query di inventory/size (es. da ogni 5 minuti a una volta al giorno).

Hardening e Tuning del Sistema Operativo e dell'Istanza

Con tabelle di grandissime dimensioni e centinaia di partizioni, i parametri di configurazione di default (sia di PostgreSQL che di Linux) possono risultare inadeguati. Diventa indispensabile intervenire su parametri del kernel e dell'istanza che normalmente non vengono toccati in installazioni standard:

Varie ed eventuali

Ulteriori informazioni si possono trovare nelle pagine Partizionamento in PostgreSQL, Lock con il partizionamento in PostgreSQL.
Un documento utile sul tuning e' Ottimizzazione SQL in PostgreSQL.

Tutti i dettagli sul partizionamento si trovano ovviamente sulla documentazione ufficiale.


Titolo: Overpartitioning in PostgreSQL
Livello: Intermedio (2/5)
Data: 14 Febbraio 2026 ❤️
Versione: 1.0.1 - 24 Agosto 2026 ☀️
Autore: mail [AT] meo.bogliolo.name