Matteo
Vincenti
Menu

UUIDv7 o bigint in PostgreSQL: conta anche l’ordine di arrivo

Confronto in Docker tra bigint, UUIDv4 e UUIDv7: inserimenti, indici, WAL e ordine di arrivo. Dati e codice per ripetere la prova.

Due cassetti verdi contengono schede ordinate in una fila e schede distribuite con spazi irregolari.

Un’applicazione può chiedere al database un numero quando salva una riga. Un’altra deve assegnare l’identificatore prima, magari su più servizi o su un dispositivo che lavorerà offline. Queste due esigenze portano a scelte diverse già prima di parlare di prestazioni.

Per questo partirei da dove nasce l’ID. Un bigint con identity è una scelta semplice quando l’assegnazione passa da un database. Un UUID consente di generare identificatori anche altrove, con una probabilità di collisione molto bassa. Poi viene la domanda fisica: quanto costa mantenerli nell’indice?

Nel laboratorio di questo articolo, con 500.000 righe, l’indice UUIDv7 occupava circa 15 MiB, contro quasi 19 per UUIDv4 e meno di 11 per bigint. Ma il risultato più utile è arrivato cambiando soltanto l’ordine di consegna degli stessi UUIDv7: l’indice è tornato vicino ai 19 MiB. È un buon motivo per guardare il flusso dei dati prima di cambiare il tipo di una colonna.

Il tipo di dato e il generatore sono due scelte

La primary key identifica una riga e impedisce duplicati o valori nulli. PostgreSQL crea anche l’indice che permette di cercare quella chiave. In questo confronto è un B-tree.

bigint e uuid sono tipi di dato; identity, UUIDv4 e UUIDv7 descrivono come ottenere i valori.

Il tipo di dato e il generatore sono due scelte
SceltaSpazio del valoreCome nasce
bigint identity8 byteUna sequenza del database assegna il numero.
UUIDv4 nel tipo uuid16 byteUn generatore produce un UUID casuale.
UUIDv7 nel tipo uuid16 byteIl valore contiene un prefisso temporale, seguito dagli altri bit previsti dal formato.

La rappresentazione testuale di un UUID ha normalmente 36 caratteri, ma il tipo nativo PostgreSQL conserva 128 bit. Usare una colonna di testo introdurrebbe un altro confronto, che qui non serve.

PostgreSQL 18 offre uuidv4() e uuidv7() senza estensioni per la generazione. Il secondo usa timestamp, frazione di millisecondo e casualità. Lo stesso tipo uuid può conservare entrambe le versioni. Funzioni UUID

Nel laboratorio bigint usa GENERATED BY DEFAULT AS IDENTITY: permette sia al database di assegnare il numero, sia al test di fornire un ID già preparato. La primary key resta presente in entrambi i casi. Identity columns

Che cosa cambia dentro il B-tree

Un B-tree conserva le chiavi ordinate in pagine. Con nuovi valori crescenti, gli inserimenti si concentrano verso la parte destra dell’indice. Chiavi casuali distribuiscono il lavoro su pagine diverse; quando una pagina non ha spazio, PostgreSQL la divide.

Conta anche quanto restano piene quelle pagine. Il fillfactor predefinito del B-tree è 90: indica un obiettivo di riempimento del 90% delle pagine foglia, quelle che contengono le voci dell’indice. PostgreSQL lo usa nella costruzione iniziale e nell’estensione sul lato destro. La percentuale effettiva dopo molti inserimenti dipende dal carico. CREATE INDEX

UUIDv7 aiuta quando il suo ordine temporale assomiglia all’ordine con cui i valori arrivano al database. Pensa invece a un’importazione che mescola eventi raccolti per giorni, oppure a dispositivi che sincronizzano dati in ritardo: il prefisso temporale rimane, ma la sequenza degli inserimenti cambia.

L’effetto della località è già descritto nella RFC 9562 e analizzato, anche a livello di pagine, da Josef Machytka. Qui mi interessa collegarlo a una scelta concreta e rendere il confronto facile da ripetere.

La prova: stesse righe, chiavi diverse

Il carico è un inserimento in blocco, eseguito da una sola sessione PostgreSQL 18.6 in Docker. Ogni tabella contiene la chiave, un numero progressivo seq e lo stesso payload ASCII di 128 caratteri. La primary key esiste prima del caricamento.

Le due scale sono 100.000 e 500.000 righe. Per ciascuna il test segue due percorsi: ID preparati prima della misura e ID generati dal default durante l’inserimento. Ogni combinazione viene ripetuta tre volte su una tabella nuova; l’ordine dei 36 casi è mescolato con un seed registrato.

L’ambiente della prova è un Mac con Apple M2 e 16 GiB di RAM, Docker Engine 29.9.2 e immagine ufficiale PostgreSQL 18.6 ARM64. Il container ha due CPU, 2 GiB di memoria e shared_buffers=128MB. I dati risiedono su un volume ext4 nella macchina virtuale di Docker Desktop. fsync, full_page_writes e synchronous_commit sono attivi.

Il tempo riportato è quello dell’istruzione INSERT SELECT nel server: comprende la lettura della sorgente, esclude il successivo flush del commit e il viaggio client-server. Prima di ogni caso viene eseguito un checkpoint; dopo, VACUUM ANALYZE. Il laboratorio conserva i dettagli nel README e nei risultati grezzi.

Sono tabelle piccole rispetto alla memoria disponibile e tutti gli indici entrano nei 128 MiB di shared buffers. Questa prova descrive caricamenti sequenziali e lookup caldi. Concorrenza, join, chiavi esterne e letture da disco a cache fredda richiedono un carico diverso.

I risultati, con gli ID già preparati

La mediana è il valore centrale delle tre esecuzioni; l’intervallo mostra la più breve e la più lunga. MiB indica 1.048.576 byte.

I risultati, con gli ID già preparati
RigheChiaveInserimento medianoIntervalloIndice
100.000bigint116,5 ms98,7–171,3 ms2,16 MiB
100.000UUIDv4132,0 ms128,4–134,1 ms4,30 MiB
100.000UUIDv7111,0 ms101,3–130,5 ms3,03 MiB
500.000bigint474,1 ms470,7–575,4 ms10,73 MiB
500.000UUIDv4767,6 ms750,2–774,3 ms18,81 MiB
500.000UUIDv7496,7 ms493,0–548,1 ms15,05 MiB

A 100.000 righe gli intervalli si sovrappongono. Anche a 500.000, bigint e UUIDv7 non offrono una separazione convincente nei tempi di questa piccola prova. UUIDv4 richiede invece più tempo in tutte e tre le esecuzioni a quella scala.

Lo spazio racconta una differenza più stabile: a 500.000 righe l’indice v7 è circa il 20% più piccolo del v4, pur usando lo stesso tipo a 16 byte. Le foglie risultano piene all’89,98% contro il 72,05%. Bigint conserva un ulteriore vantaggio di spazio, perché la chiave è più stretta.

Anche il WAL cambia. È il registro che PostgreSQL usa per ricostruire le modifiche: durante l’istruzione di caricamento, il valore mediano è circa 131,1 MiB per bigint, 146,1 per v4 e 139,3 per v7. Il laboratorio misura la differenza fra posizioni logiche di inserimento nel WAL su un’istanza dedicata; non sta misurando tutto il traffico su disco della transazione. Funzioni di misura

Includendo la generazione degli ID, a 500.000 righe le mediane diventano 550,9 ms, 1.061,1 ms e 833,8 ms. Il risultato v7 varia da 805,7 a 1.157,2 ms. Separare i due percorsi rende visibile quanto cambi il lavoro misurato; sottrarre i tempi e chiamare la differenza “costo del generatore” sarebbe troppo sbrigativo.

Sulle letture il quadro è molto meno interessante. Dopo un giro di riscaldamento, 2.000 lookup per chiave richiedono mediane di 4,33 ms, 4,43 ms e 4,45 ms. Sono eseguiti in un ciclo nel server, senza chiamate di rete. Gli intervalli sono 4,227–4,371 ms per bigint, 4,407–4,485 per v4 e 4,425–5,757 per v7: si sovrappongono soltanto i due UUID. Con tre esecuzioni, differenze assolute così piccole e questo carico interamente caldo, non sceglierei una chiave per accelerare le letture.

Gli stessi UUIDv7, consegnati fuori ordine

Il secondo test prende 500.000 UUIDv7 già generati e ne cambia soltanto l’ordine di arrivo. Prima segue la sequenza originale; poi usa una permutazione deterministica. I valori e i payload sono gli stessi.

Gli stessi UUIDv7, consegnati fuori ordine
Arrivo dei 500.000 UUIDv7Dimensione indiceDensità media delle foglie
Ordine originale15,05 MiB89,98%
Ordine mescolato18,84 MiB71,93%

Il risultato si ripete nelle tre esecuzioni per ciascun ordine. Con l’ordine mescolato, l’indice occupa quasi quanto quello UUIDv4. Mescolare gli stessi valori UUIDv7 ha eliminato la vicinanza tra le chiavi inserite consecutivamente.

Questo è il controllo che porterei in una discussione su una migrazione. Prima guarderei se l’applicazione genera e salva subito gli ID o se accumula, ritenta e importa eventi fuori ordine. Il formato della colonna da solo non descrive quel comportamento.

Ripetere il laboratorio in Docker

Servono Docker avviato, Python 3 e una shell POSIX. Per riprodurre l’ambiente misurato usa un host ARM64; esecuzioni su altre architetture vanno considerate un altro ambiente. Non serve installare PostgreSQL sul computer.

Scarica ed estrai il laboratorio. Contiene README.md, run_benchmark.py, run_order_control.py e un manifest degli hash. Tutti i comandi seguenti si eseguono dalla cartella estratta.

postgres-uuid-keys-lab.zip ↓

Avvia l’istanza isolata. Il nome è riservato a questa prova: se esiste già, controlla a chi appartiene prima di intervenire.

bash
docker run -d --name blog-pg-uuid-20261025 \
  --label blog.editorial.task=pg-uuid-20261025 \
  --network none --cpus=2 --memory=2g --memory-swap=2g --shm-size=256m \
  -e POSTGRES_PASSWORD=local-benchmark-only -e POSTGRES_DB=uuid_lab \
  postgres@sha256:74935e72241653ca55e0414067e6d8763aceb8a810eb51b452253ec3dcfc4336 \
  -c shared_buffers=128MB -c max_wal_size=1GB -c checkpoint_timeout=15min \
  -c jit=off -c max_parallel_workers_per_gather=0 -c track_io_timing=on

docker exec blog-pg-uuid-20261025 pg_isready -U postgres -d uuid_lab

Attendi la risposta accepting connections, ripetendo il secondo comando se necessario. Il container non espone porte e usa un volume anonimo. La password mostrata serve soltanto a questa istanza locale senza rete.

bash
python3 run_benchmark.py
python3 run_order_control.py

Il primo script deve terminare con Verified 36 cases. Verifica il numero di righe, la lunghezza dei payload letti e registra un piano effettivo di ricerca per ogni caso. La primary key controlla l’unicità durante il caricamento. Il secondo produce sei risultati e verifica che i due ordini contengano gli stessi ID.

Gli script generano i file SQL e gli output: non devi completarli a mano. In raw.ndjson trovi ambiente, impostazioni e 36 risultati; in order-control.ndjson il controllo sull’arrivo. Il README contiene anche il comando per calcolare mediane e intervalli dai tuoi dati. Queste sono le 36 osservazioni della prova riportata sopra:

postgres-uuid-keys-results.csv ↓

Quando hai conservato i risultati, elimina il container della prova e il suo volume:

bash
docker rm -f -v blog-pg-uuid-20261025

L’immagine resta disponibile. Se era già usata da altri progetti, conservarla evita download e rimozioni inutili.

La scelta che farei prima di una migrazione

Con un solo database che assegna gli identificatori e nessun requisito di generarli prima del salvataggio, partirei da bigint identity. Il laboratorio mostra perché resta una buona base: chiave stretta e indice compatto.

Quando l’ID deve nascere in più punti, prenderei in considerazione UUIDv7 e guarderei l’ordine reale con cui arriva al database. Conserverei UUIDv4 se i suoi requisiti di casualità e assenza di un prefisso temporale sono utili e il carico attuale non mostra un problema che giustifichi il cambiamento.

Un’altra possibilità è bigint per le relazioni interne e un UUID distinto per l’identificatore pubblico. Ha un costo: una colonna e un indice univoco in più, oltre alla conversione tra i due identificatori. Quel costo merita una misura propria, soprattutto se il motivo della scelta era risparmiare spazio.

Il timestamp dentro UUIDv7 rivela una componente temporale, ma non certifica quando una transazione è stata confermata né quando è accaduto un evento di business. Terrei una data esplicita quando quella distinzione conta. E i controlli di accesso restano necessari qualunque identificatore si esponga.

Nel pezzo su EAV e PostgreSQL il primo problema era ottenere le righe giuste. Qui la domanda successiva è più fisica: quanto costa mantenerle e trovarle. Se il tuo sistema soffre per join o scritture concorrenti, porta quelli nel prossimo test. Per scegliere una chiave, il carico che manca conta più di un’altra cifra decimale in questa tabella.

Condividi l’articolo

Hai una domanda o un’osservazione sull’articolo?

Scrivimi su LinkedIn
Tutti gli articoli