UUID v7: ID có thứ tự thời gian, cứu B-tree index khỏi fragmentation — hoatq.dev

cat blog/.md

UUID v7: ID có thứ tự thời gian, cứu B-tree index khỏi fragmentation

date: tags: database, postgresql, performance, backend, design-pattern

Có một quyết định mà mình nghĩ là “vô hại” — chọn UUID v4 làm primary key cho tất cả bảng — đến khi bảng orders lên 80 triệu dòng, insert latency p99 nhảy từ 8ms lên 90ms, và VACUUM thì cứ chạy mãi không xong. Database vẫn còn nhiều RAM, CPU rảnh, disk SSD ngon. Vấn đề ở chỗ khác: B-tree index của primary key đang bị xé tan tành vì UUID v4 random tuyệt đối.

Đây là một bài học mà mình hơi tiếc là biết hơi muộn. Và lời giải hiện đại là UUID v7 — đã chính thức được ratified trong RFC 9562 (thay thế RFC 4122 cũ). Bài này mình kể lại lý do v4 chậm trên DB lớn, v7 fix thế nào, và khi nào nên dùng cái nào.

Tại sao UUID v4 lại hại index

UUID v4 là 128 bit hoàn toàn ngẫu nhiên (trừ 6 bit cố định để đánh dấu version + variant). Hai UUID v4 sinh liên tiếp nhau không có quan hệ gì về thứ tự.

v4 sample (4 cái sinh liên tiếp):
  e1d5...   <-- nằm đâu đó giữa bảng index
  3a87...   <-- đầu bảng
  c012...   <-- gần cuối
  72b9...   <-- giữa

B-tree index của Postgres (và MySQL InnoDB còn nặng hơn vì primary key là clustered) lưu các key theo thứ tự sắp xếp. Khi bạn insert một row với UUID random, Postgres phải:

  1. Tìm trang (page) chứa khoảng key đó — random, gần như chắc chắn không nằm trong shared buffer
  2. Đọc page từ disk (cache miss)
  3. Chèn key mới — nếu page đầy thì page split, copy nửa key sang page mới
  4. Cập nhật các parent node

Nếu bạn insert 1 triệu UUID v4, bạn động đến gần như mọi page của index. Index cache trong RAM trở nên vô nghĩa. Mỗi insert là một disk seek mới. Và page split liên tục tạo ra fragmentation — index càng ngày càng phình to, không reuse được free space cho đến khi VACUUM.

So sánh với BIGSERIAL (auto-increment): mọi insert đều đi vào page cuối cùng của index. Page đó luôn nóng trong cache, không có random I/O, không có page split bên trong. Đó là lý do BIGSERIAL nhanh hơn UUID v4 rất nhiều ở scale lớn.

UUID v7 trông như thế nào

UUID v7 (RFC 9562, công bố May 2024) chia 128 bit thành:

| 48 bit timestamp (ms từ epoch) | 4 bit ver | 12 bit rand | 2 bit var | 62 bit rand |

48 bit đầu là Unix timestamp millisecond — đủ dùng đến năm 10889. Vì timestamp ở vị trí cao nhất, hai UUID v7 sinh cách nhau 1ms sẽ luôn có v7-sau lớn hơn v7-trước khi so sánh dạng byte. Ví dụ thực tế:

v7 sample (sinh trong cùng 1 giây):
  018f4b2a-1234-7abc-9def-0123456789ab
  018f4b2a-12c8-7012-8345-0789abcdef01
  018f4b2a-1356-7fff-bbbb-ccccccccdddd
  018f4b2a-13e2-7000-aaaa-111122223333
                  ^
                  prefix giống nhau, chỉ phần sau khác

Tức là insert tuần tự gần như BIGSERIAL — chỉ đụng vào page cuối của index, cache hit cao, không page split nội bộ. Bonus: bạn có thể sort theo id để có thứ tự thời gian xấp xỉ chính xác đến millisecond, mà không cần thêm cột created_at để sort.

Sinh UUID v7 trong Python

Python 3.14 đã có uuid.uuid7() built-in. Với version cũ hơn, dùng package uuid6:

# pip install uuid6
from uuid6 import uuid7

print(uuid7())
# 018f4b2a-1234-7abc-9def-0123456789ab

Hoặc tự cài (không khó):

import os
import time
import uuid

def uuid7() -> uuid.UUID:
    ts_ms = int(time.time() * 1000)
    rand = os.urandom(10)
    b = bytearray(16)
    # 48 bit timestamp
    b[0:6] = ts_ms.to_bytes(6, "big")
    # version 7 ở 4 bit cao của byte 6
    b[6] = (0x70) | (rand[0] & 0x0F)
    b[7] = rand[1]
    # variant 10xx ở 2 bit cao của byte 8
    b[8] = (0x80) | (rand[2] & 0x3F)
    b[9:16] = rand[3:10]
    return uuid.UUID(bytes=bytes(b))

Lưu vào Postgres dùng kiểu uuid chuẩn — không cần migrate cột:

from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import UUID

class Order(Base):
    __tablename__ = "orders"
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid7)
    ...

Postgres native: uuidv7() từ phiên bản 18

Postgres 18 (release tháng 9/2025) đã thêm hàm uuidv7() chạy native, nhanh hơn sinh từ application:

CREATE TABLE orders (
    id uuid PRIMARY KEY DEFAULT uuidv7(),
    buyer_id uuid NOT NULL,
    total_amount numeric(12, 2) NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO orders (buyer_id, total_amount) VALUES (gen_random_uuid(), 100);
SELECT id FROM orders LIMIT 1;
-- 018f4b2a-1234-7abc-9def-0123456789ab

Nếu chưa lên được Postgres 18 thì dùng extension pg_uuidv7 hoặc sinh ở application — kết quả lưu trữ vẫn y hệt.

Số đo thực tế: v4 vs v7

Mình chạy một benchmark đơn giản trên Postgres 16: chèn 5 triệu row vào bảng có một index unique trên cột uuid. Máy: laptop M2, SSD, shared_buffers = 256MB.

MetricUUID v4UUID v7
Tổng thời gian insert8m 14s2m 41s
Insert latency p9914ms3ms
Index size sau khi xong412 MB287 MB
Buffer cache hit ratio38%94%

UUID v7 nhanh hơn ~3x cho workload insert-heavy, và index nhỏ hơn ~30% vì không bị page split fragmentation. Sự khác biệt lớn nhất ở cache hit ratio: v4 chạm gần như mọi page nên cache vô dụng, v7 chỉ chạm vài page cuối nên cache cực hot.

Một điểm nữa ít ai nói: pg_dump + pg_restore của bảng dùng v7 cũng nhanh hơn, vì khi restore data sắp xếp theo id, index được build tuần tự — không cần external sort.

Khi nào KHÔNG nên dùng UUID v7

UUID v7 không phải free lunch. Có ba trường hợp mình cân nhắc dùng v4 (hoặc thứ khác):

1. Bạn cần ID không tiết lộ thời gian tạo. Vì v7 nhúng timestamp công khai, ai có ID là biết record đó tạo lúc nào. Với một số API public (vd: invite token, share link), điều này có thể là leak metadata. Dùng v4 hoặc opaque token riêng.

2. Bạn lo về ID enumeration tăng dần. v7 không enumerable kiểu 1, 2, 3 như BIGSERIAL, nhưng nếu attacker thu thập được nhiều ID liên tiếp họ có thể đoán được approximate range. Không nguy hiểm bằng auto-increment, nhưng nếu authz của bạn dựa vào “khó đoán ID” thay vì check ownership thì v4 an toàn hơn (mà thực ra bạn nên fix authz, chứ không phải fix ID).

3. High-frequency burst insert trong cùng 1 millisecond. Hai UUID v7 sinh trong cùng millisecond chỉ khác nhau ở phần random — vẫn ordered đúng, nhưng nếu bạn cần thứ tự strict monotonic trong từng nanosecond (vd: log sequence), UUID v7 không guarantee. Trường hợp này dùng Snowflake hoặc ULID có counter sub-ms.

So sánh nhanh với các lựa chọn khác

LoạiOrderingSizeCó timestamp?Cần coordinator?
BIGSERIALStrict8 byteKhôngCó (DB sequence)
SnowflakeStrict8 byteCó (worker id)
UUID v4Random16 byteKhôngKhông
UUID v7~ms16 byteKhông
ULID~ms16 byte (26 char base32)Không

Mình thấy UUID v7 thắng ở chỗ: format uuid chuẩn nên mọi ORM, driver, tool đã hỗ trợ — không cần custom column type như ULID, không cần worker registry như Snowflake. Migrate từ v4 sang v7 chỉ cần đổi default của column, không cần đổi schema.

Migrate sang v7 trên hệ thống đang chạy

Bạn không cần đổi tất cả dữ liệu cũ — data cũ vẫn là UUID hợp lệ, vẫn lookup được. Chỉ cần đảm bảo ID mới sinh từ giờ là v7:

-- Postgres 18+
ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7();

-- Hoặc đổi ở application-side default

Index sẽ dần “tự lành” theo thời gian: phần đuôi index (chứa ID mới) sẽ tăng tuần tự, phần đầu (ID cũ random) vẫn fragmented nhưng không tăng thêm. Sau vài tháng insert, phần lớn hoạt động ghi sẽ dồn vào nhóm page cuối — cache hit ratio tăng dần. Nếu nóng vội, chạy REINDEX CONCURRENTLY ngoài giờ peak để dọn dứt điểm.

Kết

Nếu bạn đang start project mới và đang phân vân chọn primary key — không phải lăn tăn nữa: UUID v7. Bạn được mọi cái lợi của UUID (không cần coordinator, không leak count, dễ generate ở client), trừ đi cái hại lớn nhất là index fragmentation.

Nếu bạn đang chạy với UUID v4 và bảng còn nhỏ (< 5 triệu row) — không vội, đừng tối ưu sớm. Nhưng nếu p99 insert đã bắt đầu tăng theo size bảng, và VACUUM đang ngốn nhiều thời gian hơn bình thường, hãy đo và cân nhắc migrate. Quy tắc của mình bây giờ: mặc định v7, chỉ dùng v4 khi có lý do bảo mật cụ thể.

// reactions


cat comments.log


hoatq@dev : ~/blog $