Späť na blog

UUIDv4 vs ULID vs TSID: Dopad na PostgreSQL B-Tree Indexy po 100M Záznamoch

|
| postgresql, uuid, ulid, tsid, performance, indexing

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

  1. Cache miss: Každý insert potrebuje inú stránku → RAM nestačí
  2. Random I/O: Disk musí skákať po celom indexe
  3. Page splits: Stránky sa musia deliť častejšie
  4. 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

MetrikaUUIDv4BIGSERIALULID
Insert rate8,500/s45,000/s38,000/s
Index size15.2 GB2.1 GB2.3 GB
Table size12.8 GB12.1 GB12.8 GB
Cache hit ratio62%99.8%98.5%
WAL generated48 GB18 GB21 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';
IndexB-Tree DepthLeaf PagesAvg Density
test_uuid_pkey41,892,34167%
test_bigserial_pkey3274,51291%
test_ulid_pkey3298,45689%

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árOdporúčanie
Interné ID, single DBBIGSERIAL
Distribuované systémyULID / TSID
Existujúci UUID systémULID (UUID compatible)
PostgreSQL 17+UUIDv7
Krátke URL-friendly IDTSID

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:

  1. 5× väčší index oproti sekvenčným ID
  2. 5× pomalšie inserty kvôli random I/O
  3. 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

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "UUIDv4 vs ULID vs TSID: Dopad na PostgreSQL B-Tree Indexy po 100M Záznamoch". https://www.michal-drozd.com/sk/blog/uuid-ulid-tsid-postgresql/ (Publikované 14. októbra 2025).