← Torna al blog

PostgreSQL 18: Indici BTree vs Hash — Un Benchmark

PostgreSQLBenchmarkDatabasePerformance

Ho eseguito benchmark su PostgreSQL 18.4 con 100K righe per tabella, confrontando indici BTree e Hash su tre tipi di colonna (char(1), varchar(10), varchar(1000)) e due distribuzioni di dati: 3 valori distinti (codici di stato) e valori casuali (26–100K distinti). Ogni tabella ha una colonna di padding char(100) per simulare una larghezza di riga realistica. Tutte le query sono con cache calda con 5 iterazioni di warmup + 30 iterazioni misurate; si riporta la mediana (p50).

Archiviazione

Dati Tipo Heap BTree Hash
Stato (3 distinti) char(1) 14 MB 704 KB 6,592 KB
varchar(10) 14 MB 696 KB 6,248 KB
varchar(1000) 112 MB 712 KB 6,064 KB
Casuale (26–100K distinti) char(1) 14 MB 704 KB 5,984 KB
varchar(10) 14 MB 3,104 KB 4,680 KB
varchar(1000) 112 MB 156 MB 4,112 KB

Con 3 valori distinti, la deduplicazione BTree archivia ogni chiave una sola volta con una posting list. Tutti gli indici BTree sono circa 700 KB indipendentemente dalla larghezza della chiave. Gli indici Hash non possono deduplicare — sono 8–9 volte più grandi a 6 MB perché ogni riga ottiene la propria voce nel bucket.

Con valori casuali la situazione si capovolge per chiavi larghe. Il BTree su varchar(1000) è 156 MB (più grande dell'heap), mentre l'indice Hash è solo 4 MB — una riduzione di 38 volte. Hash archivia solo un codice hash di 4 byte per riga indipendentemente dalla larghezza della chiave.

Prestazioni delle query: WHERE status = ?

Dati Tipo BTree (p50) Hash (p50) Scansione BTree Scansione Hash
Stato (3 distinti) char(1) 4.15 ms 7.93 ms Index Only Seq Scan
varchar(10) 4.19 ms 6.41 ms Index Only Seq Scan
varchar(1000) 10.90 ms 21.88 ms Index Only Seq Scan
Casuale (26–100K distinti) char(1) 0.24 ms 1.30 ms Index Only Bitmap
varchar(10) 0.06 ms 0.05 ms Index Only Index Scan
varchar(1000) 0.06 ms 0.05 ms Index Only Index Scan

Per le colonne di stato a bassa cardinalità, il planner ignora completamente l'indice Hash e usa una Seq Scan — con ~33K righe corrispondenti per valore, gli accessi casuali all'heap tramite Hash sarebbero più lenti della lettura sequenziale della tabella da 14 MB. Il BTree deduplicato (~700 KB) abilita le Index Only Scan che non toccano mai l'heap.

Per le ricerche puntuali ad alta cardinalità (trovare 1 riga), BTree e Hash sono appaiati sotto il millisecondo. L'indice Hash su varchar(1000) è 38 volte più piccolo (4 MB contro 156 MB), ma poiché l'intero BTree resta in memoria, l'attraversamento è comunque veloce nei test a cache calda.

WHERE status IN (...) e GROUP BY

Query Tipo BTree (p50) Hash (p50)
IN (2 valori) char(1) 3.3 ms 10.89 ms
varchar(10) 3.36 ms 10.09 ms
GROUP BY char(1) 7.55 ms 14.09 ms
varchar(10) 8.22 ms 14.72 ms

Le query IN e GROUP BY svantaggiano ulteriormente Hash sui dati a bassa cardinalità. Gli indici BTree restituiscono le righe in ordine e restano compatti con la deduplicazione. Gli indici Hash non forniscono ordinamento e, in queste distribuzioni, il planner ricade spesso su piani basati su Seq Scan.

Conclusioni

  • Bassa cardinalità (codici di stato, enumerazioni): Usate sempre BTree. La deduplicazione rende l'indice ~700 KB indipendentemente dalla larghezza della chiave, abilita le Index Only Scan, e il planner non userà un indice Hash qui. char(1) risparmia spazio heap; la dimensione dell'indice è la stessa.
  • Alta cardinalità con chiavi larghe (UUID, stringhe lunghe): Gli indici Hash sono molto più piccoli (4 MB vs 156 MB per varchar(1000)). Per ricerche di uguaglianza pure sono veloci quanto BTree. Ma non supportano ordinamento, range, unicità o Index Only Scan.
  • Alta cardinalità con chiavi corte: BTree e Hash sono simili in dimensione e velocità. BTree vince per generalità — usatelo a meno che non abbiate una ragione specifica per Hash.
  • Non usate mai Hash per colonne di stato: il planner lo ignora in favore di una Seq Scan, sprecando 6 MB di disco e memoria.

Metodologia

  • PostgreSQL: 18.4 (Debian), Docker, shared_buffers=512MB, work_mem=64MB
  • Righe: 100.000 per tabella, 3 valori distinti (stato) o 26/100K distinti (random)
  • Timing: 5 iterazioni di warmup scartate, 30 registrate, p50 riportata
  • Indici: BTree standard (deduplicazione abilitata) vs Hash, fillfactor predefinito
  • Riproducibilità: bench.sh nel repository — completamente automatizzato

Hai un progetto o vuoi discutere un'idea?

Contattami
© 2026 Giacomo Rizzi
Telefono: 351 547 4028
Partita IVA: 05476830269
Via Antica Torre 31, San Polo di Piave, 31020 TV, Italia