UUIDv4 vs ULID vs TSID: Dopad na PostgreSQL B-Tree Indexy po 100M Záznamoch
IDcka sme menili dvakrat, kym sme pochopili realny tradeoff. “Prečo je INSERT taký pomalý? Máme len 100 miliónov riadkov.” Pozriem na tabuľku - primary key je UUIDv4. Index má 15GB a cache hit ratio je 60%. Nájdený vinník.
Náhodné UUID rozptyľujú inserty po celom B-Tree indexe. Výsledok: random I/O, index bloat, a databáza, ktorá nestíha.
Testované na: PostgreSQL 16.1, 32GB RAM, NVMe SSD, 100M riadkov
Prečo Náhodné UUID Bolia
B-Tree Index Štruktúra
B-Tree Index pre Sekvenčné ID:
[Stránka 1: 1-1000]
[Stránka 2: 1001-2000] ← Inserty vždy sem (na koniec)
[Stránka 3: 2001-3000]
B-Tree Index pre Náhodné UUID:
[Stránka 1: 0a... - 0f...] ← Insert sem
[Stránka 2: 10... - 1f...] ← Alebo sem
[Stránka 3: 20... - 2f...] ← Alebo sem
...
[Stránka 1000: f0... - ff...] ← Alebo sem
Dôsledky Náhodných Insertov
- Cache miss: Každý insert potrebuje inú stránku → RAM nestačí
- Random I/O: Disk musí skákať po celom indexe
- Page splits: Stránky sa musia deliť častejšie
- WAL bloat: Viac WAL záznamov kvôli page splits
Alternatívy k UUIDv4
ULID (Universally Unique Lexicographically Sortable Identifier)
Formát: 01ARZ3NDEKTSV4RRFFQ69G5FAV
|------||--------------|
Timestamp Random
- Prvých 48 bitov: millisecond timestamp
- Zvyšných 80 bitov: random
- Lexikograficky sortovateľné
- Kompatibilné s UUID storage (128 bit)
TSID (Time-Sorted Unique Identifier)
Formát: 0HJVCTP5S8N7Z
|----||-----|
Time Random
- 42 bitov: timestamp (millisekundy od epochy)
- 22 bitov: random + node ID
- Kratšie ako UUID (13 znakov vs 36)
- Sortovateľné podľa času
UUIDv7 (nový štandard)
Formát: 018e5e3c-8000-7xxx-xxxx-xxxxxxxxxxxx
|---------| |-------------------|
Timestamp Random
- RFC 9562 (2024)
- Prvých 48 bitov: Unix timestamp (ms)
- Natívna podpora v PostgreSQL 17+
Reprodukovateľný Benchmark
Setup
-- Tabuľky s rôznymi PK stratégiami
CREATE TABLE test_uuid (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
data text,
created_at timestamp DEFAULT now()
);
CREATE TABLE test_bigserial (
id bigserial PRIMARY KEY,
data text,
created_at timestamp DEFAULT now()
);
-- ULID ako UUID (sorted)
CREATE OR REPLACE FUNCTION generate_ulid() RETURNS uuid AS $$
DECLARE
timestamp BYTEA = E'\\000\\000\\000\\000\\000\\000';
unix_time BIGINT;
ulid BYTEA;
BEGIN
unix_time = (EXTRACT(EPOCH FROM CLOCK_TIMESTAMP()) * 1000)::BIGINT;
timestamp = SET_BYTE(timestamp, 0, (unix_time >> 40)::BIT(8)::INTEGER);
timestamp = SET_BYTE(timestamp, 1, (unix_time >> 32)::BIT(8)::INTEGER);
timestamp = SET_BYTE(timestamp, 2, (unix_time >> 24)::BIT(8)::INTEGER);
timestamp = SET_BYTE(timestamp, 3, (unix_time >> 16)::BIT(8)::INTEGER);
timestamp = SET_BYTE(timestamp, 4, (unix_time >> 8)::BIT(8)::INTEGER);
timestamp = SET_BYTE(timestamp, 5, unix_time::BIT(8)::INTEGER);
ulid = timestamp || gen_random_bytes(10);
RETURN ENCODE(ulid, 'hex')::uuid;
END
$$ LANGUAGE plpgsql;
CREATE TABLE test_ulid (
id uuid PRIMARY KEY DEFAULT generate_ulid(),
data text,
created_at timestamp DEFAULT now()
);
Benchmark Script
# pk_benchmark.py
import psycopg2
import time
import os
def benchmark_inserts(conn, table_name, num_inserts):
cur = conn.cursor()
data = "x" * 100 # 100 byte payload
start = time.time()
for i in range(num_inserts):
cur.execute(f"INSERT INTO {table_name} (data) VALUES (%s)", (data,))
if i % 10000 == 0:
conn.commit()
conn.commit()
elapsed = time.time() - start
# Get stats
cur.execute(f"""
SELECT
pg_size_pretty(pg_relation_size('{table_name}'::regclass)) as table_size,
pg_size_pretty(pg_indexes_size('{table_name}'::regclass)) as index_size
""")
sizes = cur.fetchone()
return {
'time': elapsed,
'table_size': sizes[0],
'index_size': sizes[1],
'inserts_per_sec': num_inserts / elapsed
}
conn = psycopg2.connect("postgresql://localhost/testdb")
for table in ['test_uuid', 'test_bigserial', 'test_ulid']:
print(f"\nBenchmarking {table}...")
result = benchmark_inserts(conn, table, 1000000)
print(f" Time: {result['time']:.2f}s")
print(f" Inserts/sec: {result['inserts_per_sec']:.0f}")
print(f" Table size: {result['table_size']}")
print(f" Index size: {result['index_size']}")
Výsledky: 100M Riadkov
| Metrika | UUIDv4 | BIGSERIAL | ULID |
|---|---|---|---|
| Insert rate | 8,500/s | 45,000/s | 38,000/s |
| Index size | 15.2 GB | 2.1 GB | 2.3 GB |
| Table size | 12.8 GB | 12.1 GB | 12.8 GB |
| Cache hit ratio | 62% | 99.8% | 98.5% |
| WAL generated | 48 GB | 18 GB | 21 GB |
Buffer Cache Analysis
-- Koľko indexových stránok je v cache
SELECT
c.relname,
pg_size_pretty(count(*) * 8192) as cached_size,
round(100.0 * count(*) /
(pg_relation_size(c.oid) / 8192), 2) as cache_hit_pct
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = c.relfilenode
WHERE c.relname LIKE 'test_%_pkey'
GROUP BY c.relname, c.oid;
relname | cached_size | cache_hit_pct
---------------------+-------------+--------------
test_uuid_pkey | 1.2 GB | 8%
test_bigserial_pkey | 2.1 GB | 100%
test_ulid_pkey | 2.0 GB | 87%
Index Bloat Porovnanie
-- Po 100M insertov + 10M updates
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
relname,
(pgstatindex(relname)).tree_level as btree_depth,
(pgstatindex(relname)).leaf_pages,
(pgstatindex(relname)).avg_leaf_density
FROM pg_class
WHERE relname LIKE 'test_%_pkey';
| Index | B-Tree Depth | Leaf Pages | Avg Density |
|---|---|---|---|
| test_uuid_pkey | 4 | 1,892,341 | 67% |
| test_bigserial_pkey | 3 | 274,512 | 91% |
| test_ulid_pkey | 3 | 298,456 | 89% |
Implementácia v Aplikácii
Java - ULID
// pom.xml
// <dependency>
// <groupId>com.github.f4b6a3</groupId>
// <artifactId>ulid-creator</artifactId>
// <version>5.2.0</version>
// </dependency>
import com.github.f4b6a3.ulid.UlidCreator;
import java.util.UUID;
public class Order {
// ULID as UUID
private UUID id = UlidCreator.getMonotonicUlid().toUuid();
}
Java - TSID
// <dependency>
// <groupId>io.hypersistence</groupId>
// <artifactId>hypersistence-tsid</artifactId>
// <version>2.1.1</version>
// </dependency>
import io.hypersistence.tsid.TSID;
public class Order {
private long id = TSID.Factory.getTsid().toLong(); // 64-bit!
}
Go - ULID
import "github.com/oklog/ulid/v2"
func NewOrderID() string {
entropy := ulid.Monotonic(rand.Reader, 0)
return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
}
PostgreSQL 17+ - UUIDv7
-- Natívna podpora v PostgreSQL 17
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
...
);
Migration Strategy
Od UUIDv4 k ULID
-- 1. Pridaj nový stĺpec
ALTER TABLE orders ADD COLUMN new_id uuid;
-- 2. Backfill s ULID (pre nové záznamy)
-- Staré záznamy ostanú s UUIDv4
-- 3. Aplikácia začne používať ULID pre nové záznamy
-- (zmena v kóde)
-- 4. Po stabilizácii:
-- CREATE INDEX CONCURRENTLY idx_orders_new_id ON orders(new_id);
-- 5. Swap (s výpadkom):
-- BEGIN;
-- ALTER TABLE orders DROP CONSTRAINT orders_pkey;
-- ALTER TABLE orders ADD PRIMARY KEY (new_id);
-- ALTER TABLE orders DROP COLUMN id;
-- ALTER TABLE orders RENAME COLUMN new_id TO id;
-- COMMIT;
Kedy Použiť Čo
| Scenár | Odporúčanie |
|---|---|
| Interné ID, single DB | BIGSERIAL |
| Distribuované systémy | ULID / TSID |
| Existujúci UUID systém | ULID (UUID compatible) |
| PostgreSQL 17+ | UUIDv7 |
| Krátke URL-friendly ID | TSID |
Checklist
## Primary Key Strategy Checklist
### Analýza
- [ ] Zmeraj aktuálny index bloat
- [ ] Skontroluj cache hit ratio pre PK index
- [ ] Vyhodnoť WAL volume pri insertoch
### Migrácia
- [ ] Vyber vhodný formát (ULID/TSID/UUIDv7)
- [ ] Implementuj v aplikácii
- [ ] Naplánuj backfill stratégiu
- [ ] Otestuj performance pred produkciou
### Monitoring
- [ ] Sleduj index size growth rate
- [ ] Alert na cache hit ratio < 90%
- [ ] Porovnaj insert latency pred/po
Záver
Náhodné UUIDv4 ako primary key je anti-pattern pre veľké tabuľky:
- 5× väčší index oproti sekvenčným ID
- 5× pomalšie inserty kvôli random I/O
- Nízky cache hit ratio - RAM nestačí
Riešenie: Použi ULID, TSID, alebo UUIDv7 - zachováš unikátnosť, ale získaš sekvenčnosť.
Súvisiace články
- PostgreSQL HOT Updates - Optimalizácia updates
- PostgreSQL Autovacuum SLO - Vacuum konfigurácia
Súvisiace články
Index Ktorý Zabil Write Performance: Strata PostgreSQL HOT Updates
Pridanie indexu pre výkon spôsobilo 10x pomalšie zápisy. Kontra-intuitívna príčina: nový index rozbil HOT updaty, meniaci lacné in-place updates na drahé full-row rewrites s masívnym bloatom.
Elasticsearch Hot Shard Problém: Keď Jeden Node Robí Všetku Prácu
5 data nodov ale jeden je na 100% CPU. Nerovnomerné routing kľúče vytvárajú hot shardy. Ukážem ako detekovať skew a opraviť ho pomocou routing stratégií.
Connection Pool Sizing s Little's Law: Matematický Prístup k HikariCP a PgBouncer
Pool size 50 lebo tak to bolo vždy? Ukážem ako použiť Little's Law na výpočet optimálnej veľkosti poolu a dokážem to load testom.
PostgreSQL HOT Updates + FILLFACTOR: Ako Znížiť Index Bloat o 60%
Vacuum beží úspešne, ale disk rastie a cache hit ratio klesá. Ukážem ako kvantifikovať HOT-update eligibility pomocou pgstattuple a optimalizovať fillfactor.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.