PostgreSQL 18 tặng phe UUID hai miếng bánh. Một bánh vẽ, và một bánh vẽ khác

Mình đổi EvoBright LMS sang UUID vì lý do bảo mật. Rồi tự chạy benchmark 60 triệu dòng và đọc RFC. Cả hai đều nói mình sai ở chỗ mình không ngờ.

PostgreSQL 18 tặng phe UUID hai miếng bánh. Một bánh vẽ, và một bánh vẽ khác

Ba tiếng bốn mươi ba phút.

Đó là thời gian một câu INSERT chạy trên con VPS dùng để benmark của mình (4 vCPU, 8 GB Ram). Cùng một câu lệnh, cùng sáu mươi triệu dòng, cùng cái đĩa đó, chỉ khác mỗi kiểu khoá chính: bản bigint xong sau hai mươi phút, còn bản uuid thì mình đóng laptop đi ngủ, sáng dậy nó vẫn đang bò.

Chi tiết làm mình khó chịu không phải con số. Mà là việc UUIDv4 mà mình đang dùng là một sự ngu dốt.


Hồi đầu mình dựng EvoBright LMS bằng Django, để nguyên BigAutoField mặc định, chạy êm, không nghĩ ngợi gì. Sau một thời gian mình quyết định migrate toàn bộ sang UUID vì hai lý do mà lúc đó mình thấy rất rõ ràng.

Thứ nhất, ID tuần tự lộ ra rủi ro bảo mật. Ai cũng biết chuyện này: /api/materials/1, /2, /3, và bạn có một vòng lặp for. Chỉ bằng việc sử dụng UUID bạn giảm 1 tỉ lần impact khi có lỗ hổng IDOR nào đó.

Thứ hai, mình chạy multi-tenant, và mình sợ cùng một Materials với id = 100 sẽ bị hiểu sai giữa các tenant khác nhau. Khi collect Logs tập trung mình phải phân biệt ID 100 của tenant A, B, C ... Trong khi UUID cho mình một cái ID Unique giữa tất cả các tenant.

Hai lý do đó đủ để mình viết migration và đổi khoá chính của cả hệ thống. Và mình đã yên tâm suốt từ đó tới lúc ngồi viết bài này.

Giờ thì mình phải nói ra một chuyện hơi ngượng: lý do thứ hai của mình sai. Không phải sai về mức độ quan trọng. Sai theo nghĩa đen, nghĩa là điều mình sợ không thể xảy ra trong kiến trúc mình đang dùng.

LMS của mình dùng một bảng materials dùng chung, phân biệt tenant bằng cột tenant_id. Một bảng, một sequence, một không gian ID duy nhất.

Trong kiến trúc đó, nextval() cấp phát mỗi giá trị đúng một lần. id = 100 tồn tại ở đúng một hàng, thuộc đúng một tenant. Nó không bao giờ trùng được. Điều mình lo sợ và bỏ công đi chữa là một chuyện vốn dĩ không tồn tại.

Nỗi lo đó có thật, nhưng ở một kiến trúc khác. Nếu mình dùng django-tenants, tức là mỗi tenant một PostgreSQL schema riêng [1], thì mỗi schema có sequence riêng của nó [2], và tenant_a.materials với tenant_b.materials sẽ cùng có một hàng id = 100 trỏ tới hai thứ hoàn toàn khác nhau. Ở đó, nỗi sợ của mình là chính xác.

Bên trái, kiến trúc bảng chung với cột tenant_id: một sequence duy nhất cấp phát id cho cả bảng materials, nên id=100 chỉ tồn tại ở đúng một hàng thuộc đúng một tenant. Bên phải, kiến trúc schema riêng cho mỗi tenant: schema tenant_a và schema tenant_b mỗi bên có bảng materials và sequence riêng, nên id=100 tồn tại đồng thời ở cả hai và trỏ tới hai hàng khác nhau

Mình đã mang nỗi sợ của một kiến trúc mình không dùng, để ra quyết định cho kiến trúc mình đang dùng.

Khi bắt đầu viết bài này, mình định chữa cháy bằng một cách nghe rất gọn. Kiểu: "ừ thì ID không trùng, nhưng lý do thật còn mạnh hơn cơ. Với ID tuần tự, một query quên WHERE tenant_id = ? sẽ âm thầm trả về dữ liệu của tenant khác. Với UUID thì cùng cái bug đó trả về 404. Fail-open so với fail-closed."

Nghe hay quá. Mình suýt viết nó thành một section riêng.

Nhưng nó sai về cơ chế, và mình mất một lúc mới nhìn ra chỗ sai. Câu SELECT * FROM materials WHERE id = '<uuid>' thiếu filter tenant thì vẫn trả về hàng đó bình thường nếu người ta biết cái UUID. Database không từ chối gì hết. Nó không hề "fail" theo bất kỳ nghĩa nào. Thứ đang bảo vệ mình chỉ đơn giản là không ai đoán được UUID để mà thử.

Đó là enumeration resistance. Nó không phải fail-closed. Và khác biệt này quan trọng, vì enumeration resistance sụp đổ ngay khoảnh khắc một UUID rò ra ngoài: qua share link, qua HTTP referrer, qua log, qua file export, qua một cái ticket support có screenshot.

Bên trái là UUID: câu truy vấn thiếu filter tenant bị chặn bởi một hàng rào đứt nét ghi "không đoán được UUID", nhưng một mũi tên chấm chấm ghi "một link bị rò thì vẫn vào được" vòng qua hàng rào và database trả về đúng hàng dữ liệu. Bên phải là RLS: cùng câu truy vấn đó đâm vào một bức tường liền ghi CREATE POLICY ngay tại ranh giới database, và kết quả trả về là 0 hàng

OWASP nói thẳng chuyện này, và mình trích nguyên văn vì nó là câu quan trọng nhất của cả phần này:

UUID là defense-in-depth cho IDOR, không phải cách sửa IDOR

https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html

"Additionally, use complex identifiers as a defense-in-depth measure, but remember that access control is crucial even with these identifiers."

Và ở phần kiểm tra: "Verify that the application denies unauthorized access regardless of whether the object identifier is predictable or unguessable."

Ảnh chụp OWASP IDOR Prevention Cheat Sheet với hai đoạn tô vàng. Đoạn trên nằm trong danh sách các bước kiểm thử: "Verify that the application denies unauthorized access regardless of whether the object identifier is predictable or unguessable". Đoạn dưới nằm trong mục Mitigation: "Additionally, use complex identifiers as a defense-in-depth measure, but remember that access control is crucial even with these identifiers"

Cùng tài liệu đó định nghĩa IDOR bằng ba thành phần: một object, một reference tới object (ID, UUID, token, slug), và một access check bị thiếu [3]. UUID nằm ở thành phần thứ hai. Nguyên nhân nằm ở thành phần thứ ba. Nghĩa là IDOR vẫn xảy ra ngon lành với UUID.

Trong tài liệu API Security Top 10, OWASP có nhắc tới việc dùng GUID, nhưng nó nằm ở gạch đầu dòng thứ ba, sau hai gạch đầu dòng về authorization [4]. Thứ tự đó không phải ngẫu nhiên.

Và đây là chỗ đau nhất. Thứ mình thật sự muốn mua, cái cảm giác "hệ thống tự chặn khi code mình quên", có tên riêng và OWASP chỉ đích danh nó cho bài toán multi-tenant: Row-Level Security [5]. PostgreSQL đã có RLS từ phiên bản 9.5, năm 2016. Nó bật bằng vài dòng CREATE POLICY. Nó chặn ở tầng database, tức là chặn cả những query mà ORM sinh ra sau lưng mình.

Mình chưa bật nó.

Mình đã đổi kiểu khoá chính của toàn bộ hệ thống để giải một bài toán mà kiểu khoá không giải được, trong khi công cụ giải được nó nằm sẵn trong cùng cái database, tắt, chờ.

Hai món quà của PostgreSQL 18

Đó là bối cảnh lúc mình mở release notes của PostgreSQL 18 và thấy hai dòng nằm gần nhau: hàm uuidv7() native, và asynchronous I/O [6].

Phản xạ đầu tiên của mình khá là dễ chịu. Kiểu: à, vậy là cái giá mình đang trả cho UUID sắp được giảm. AIO làm random I/O rẻ đi, mà random I/O chính là toàn bộ án tử của UUIDv4. Còn uuidv7() thì cho mình UUID có thứ tự thời gian mà không cần thư viện ngoài. Hai món quà, đúng lúc, đúng chỗ.

Rồi mình đọc kỹ phần AIO và khựng lại ở một danh sách.

Món thứ nhất là bánh vẽ

Async I/O của PostgreSQL 18 phủ đúng ba thứ: sequential scan, bitmap heap scan, và vacuum [6].

Ảnh chụp release notes PostgreSQL 18, mục E.5.3.1.3 General Performance, phần mô tả async I/O subsystem. Hai cụm được tô vàng: "queue multiple read requests" và "sequential scans, bitmap heap scans, vacuums, etc.". Cụm "read requests" cho thấy đây là tính năng nằm ở đường đọc
Hai vòng tròn nằm cạnh nhau và không hề giao nhau. Vòng bên trái là những gì async I/O của PostgreSQL 18 phủ: sequential scan, bitmap heap scan, vacuum, kèm ghi chú chỉ áp dụng cho đường đọc. Vòng bên phải là những chỗ UUIDv4 thật sự đau: page split khi ghi vào B-tree, WAL full-page writes, index nuốt buffer cache. Giữa hai vòng là một khoảng trống ghi "không giao nhau"

Đọc lại danh sách đó ba lần rồi đối chiếu với chỗ UUIDv4 thật sự đau, mình thấy chúng gần như không giao nhau. UUIDv4 đau ở page split khi ghi vào B-tree, đau ở WAL full-page writes, và đau ở việc index chính chiếm dụng buffer cache. Cái đầu và cái thứ hai nằm ở write path, mà AIO thì chỉ đọc. Cái thứ ba là vấn đề dung lượng cache, không phải độ trễ I/O.

Còn một chuyện nữa mà mình nghĩ ít người để ý: B-tree descent về bản chất không prefetch được. Muốn biết tầng thứ ba của cây nằm ở block nào thì phải đọc xong tầng thứ hai đã. Đó là pointer chasing, và không có async I/O nào phá được ràng buộc nhân quả đó. Cái tính năng thật sự sẽ giúp là index prefetching thì đến giờ vẫn chưa vào bản chính thức nào.

Thêm chi tiết dễ hụt chân: io_method mặc định là worker, không phải io_uring [7]. Muốn io_uring thì bản build phải có --with-liburing. Rất nhiều người sẽ đọc changelog, gật gù "PG18 có io_uring rồi", rồi chạy production trên worker suốt đời mà không biết.

Giả thuyết của mình:

Đến đây mình có một giả thuyết, và mình biết giả thuyết thì rẻ. Ai cũng có thể nhìn số liệu xong bảo "tôi biết trước rồi mà".

Nên mình viết dự đoán ra file, kèm cả điều kiện để chứng minh mình sai, rồi commit lên git trước khi chạy benchmark ở quy mô thật [8]. Dự đoán chính: chuyển io_method từ sync sang io_uring sẽ cải thiện TPS ghi của UUIDv4 dưới 10%, và không kéo được UUIDv4 về gần bigint. Mình viết luôn cả câu này vào file: nếu io_uring làm TPS tăng trên 25% thì mình sai, và bài viết sẽ đổi thành "mình đã đoán sai về AIO, đây là vì sao".

Cấu hình: VPS 4 core, 7.8 GB RAM, PostgreSQL 18.4. Sáu mươi triệu dòng, dataset 11 tới 16 GB, tức gấp hai rưỡi tới ba lần rưỡi lượng RAM còn trống. Ba lần chạy mỗi cấu hình, lấy trung vị, xoá sạch OS page cache trước mỗi lần.

Kết quả: dự đoán đúng. Ở workload ghi, io_uring không kéo UUIDv4 lên được chút nào, ba khoảng đo của sync, workerio_uring chồng lên nhau gần hết.

Nhưng thứ mình thích hơn kết quả là mình đo được cơ chế, chứ không chỉ đo được hệ quả. Đây là EXPLAIN ANALYZE của chính câu query trong benchmark:

Nested Loop (actual time=3.467..478.105 rows=203 loops=1)
  -> Bitmap Heap Scan on parent p (actual time=2.041..20.660 rows=204 loops=1)
  -> Index Scan using child_parent_id_idx on child c
       (actual time=2.232..2.234 rows=1 loops=204)
Execution Time: 479.421 ms

Bitmap heap scan là thứ duy nhất trong plan này được AIO phủ. Nó tốn 20.7ms trên tổng 479ms, tức 4.3%. Chín mươi lăm phần trăm thời gian còn lại nằm ở inner Index Scan chạy 204 vòng lặp: pointer chasing thuần tuý.

Async I/O của PostgreSQL 18 không hề kém. Nó chỉ đơn giản là không có mặt ở nơi mà chín mươi lăm phần trăm thời gian bị tiêu.

Kết quả đo

Trong khi TPS thì nhiễu và khó đọc, có những con số đo một lần là xong, không phụ thuộc lịch CPU, không cần lấy trung vị. Đây là những con số mình thấy đáng nhớ nhất trong cả cuộc benchmark:

bigint uuidv7 uuidv4
Nạp 60 triệu dòng 20.4 phút 28.4 phút 223.5 phút
Mật độ trang lá của index 90.04% 89.99% 69.75%
Phân mảnh 0.00 0.00 49.86
WAL sinh ra, so với bigint 1.00x 1.03x 1.85x
Index chính chiếm bao nhiêu buffer cache 0.18% 0.30% 27.53%

UUIDv4 nạp chậm hơn bigint mười một lần. Và điều đáng nói không phải con số mười một, mà là: ở bài chạy thử với ba triệu dòng, khi dataset vừa RAM, tỉ lệ đó chỉ là 1.87 lần.

Cùng một phép so sánh, cùng một cặp kiểu dữ liệu. Vừa RAM thì chênh 1.87 lần. Vượt RAM thì chênh 11 lần. Đó không phải một con dốc mà bạn leo dần. Đó là một vách đá, và bạn không biết mình đang ở đâu so với mép của nó.

Dòng cuối cùng của bảng mới là dòng mình ngồi nhìn lâu nhất. Index chính của UUIDv4 to hơn bigint 1.81 lần trên đĩa, nhưng nó nuốt 153 lần buffer cache. Lý do thì hiển nhiên khi đã nhìn ra: khoá ngẫu nhiên nghĩa là mỗi lần insert chạm vào một chỗ ngẫu nhiên trong cây, nên gần như cả cây bị kéo vào RAM và giữ ở đó. Còn khoá tuần tự chỉ chạm vào mép phải, nên vài trang là đủ.

Bên trái là khoá tuần tự bigint: cây B-tree ba tầng, mọi insert dồn vào đúng trang lá ngoài cùng bên phải, sáu trang lá đều được tô kín đặc, mật độ 90 phần trăm và không có page split. Bên phải là khoá ngẫu nhiên uuidv4: cùng cây đó nhưng các insert toả ra sáu hướng chạm vào sáu trang lá rải rác, mỗi trang chỉ được lấp một phần và có một vết nứt răng cưa chạy dọc thể hiện page split, mật độ còn 70 phần trăm và phân mảnh 50

Đây mới là cơ chế thật khiến UUIDv4 làm chậm cả hệ thống, chứ không chỉ làm chậm cái insert đó. Nó ăn mất RAM của mọi bảng khác trong cùng database.

Thấy đau chưa, sao không buông?

Ở thì... vì hệ của mình còn nhỏ. Chưa chạm mép vách đá.

Đồ thị minh hoạ hình dạng của cái vách đá. Trục ngang là kích thước dữ liệu so với RAM, trục dọc là thời gian nạp so với bigint. Đường bigint nằm ngang ở mức 1x suốt cả chiều rộng. Đường uuidv4 chạy ngang ở mức 1.87 lần trong vùng dữ liệu vừa RAM, rồi bẻ dựng đứng ngay sau vạch đứt nét ghi "vách đá" và vọt lên 11 lần khi dữ liệu vượt RAM

Hình trên là hình vẽ minh hoạ, không phải đồ thị đo. Mình chỉ đo đúng hai điểm: vừa RAM thì 1.87 lần, vượt RAM thì 11 lần. Cái mình không đo được, và cũng là cái làm mình khó chịu nhất, là đoạn nối giữa hai điểm đó dốc ở chỗ nào.

Nhưng benchmark còn cho mình một câu trả lời khác, và nó giải thích một chuyện làm mình bực bội từ lâu: vì sao cuộc tranh cãi này trên internet không bao giờ ngã ngũ.

Ở chế độ dataset vừa RAM, TPS ghi của bốn kiểu khoá mà mình đo được là như sau, ba lần chạy mỗi loại:

  • bigint: 654.7 / 597.0 / 539.2
  • uuidv7: 707.1 / 664.0 / 637.3
  • uuidv4: 678.7 / 735.6 / 582.3

Bốn khoảng chồng lên nhau gần hết. Nhìn vào bảng này, bạn không thể kết luận được gì. Lý do là workload ghi trên máy này bị nghẽn ở fsync chứ không phải ở index: mỗi transaction tốn khoảng 6 tới 7ms chỉ để chờ WAL chạm đĩa, và trên nền 6ms đó thì chênh lệch vài chục micro giây giữa các kiểu khoá biến mất hoàn toàn.

Nghĩa là: rất nhiều người đã chạy benchmark trên máy của họ, với dataset vừa RAM, thấy "UUID chả chậm hơn gì", và viết điều đó lên Reddit.

Và họ đúng. Ở máy của họ, với dữ liệu của họ, kết luận đó chính xác.

Cái giá của UUID không nằm ở TPS tức thời. Nó nằm ở ba thứ mà không benchmark nhanh nào bắt được: nạp chậm gấp mười một lần, WAL nhiều gấp 1.85 lần, và index nuốt 153 lần buffer cache. Cả ba chỉ cắn khi vượt RAM. Đó là lý do hai người cùng thật thà, cùng có số liệu, vẫn cãi nhau mãi không xong: họ đang đứng ở hai phía khác nhau của cái vách đá.

Món thứ hai là một lời thú nhận

Còn uuidv7() thì sao? Nó giúp thật. Số liệu của mình xác nhận: mật độ trang lá 89.99%, gần như y hệt bigint. WAL bằng 1.03 lần bigint. Nạp 28.4 phút so với 20.4 phút.

Nhưng hãy nhìn kỹ vào thứ vừa xảy ra. UUIDv7 giải quyết vấn đề của UUID bằng cách làm cho UUID hành xử y hệt một con số tự tăng. Nó nhét timestamp vào 48 bit đầu để các khoá sinh gần nhau về thời gian thì nằm gần nhau trong cây.

Đó chính xác là điều mà bigserial đã làm từ những năm bảy mươi, bằng cách không làm gì cả.

Ba thanh ngang so sánh bố cục bit của ba kiểu khoá. Thanh bigint ngắn nhất, một khối liền, 64 bit tuần tự. Thanh uuidv4 dài gấp đôi, cũng một khối liền, 122 bit ngẫu nhiên. Thanh uuidv7 dài bằng uuidv4 nhưng bị chia làm hai: một phần ba đầu là timestamp 48 bit, phần còn lại là 74 bit ngẫu nhiên. Dấu ngoặc dưới phần timestamp ghi chú rằng chính nó mua được locality và cũng chính nó làm lộ thời điểm tạo bản ghi

Nên khi trừ đi hết mọi thứ, UUIDv7 bằng bigint, cộng thêm tám byte mỗi khoá, cộng thêm quyền sinh ID ở ngoài database. Index của nó trong benchmark của mình vẫn lớn hơn bigint 40%. Nếu bạn không thật sự cần cái quyền sinh ID ở ngoài database kia, bạn đang trả thuế cho một kiến trúc bạn không có.

Gần như mọi bài mình đọc về uuidv7() trong sáu tháng qua đều kể nó như một chiến thắng của phe UUID. Nhìn kỹ thì đó là một cuộc rút lui, được viết lại thành thông cáo báo chí.

Mình trốn dưới gầm giường của của những người viết ra Postgres ... không ai cãi nhau chuyện này

Đây là chỗ mình thấy thú vị nhất trong cả quá trình tìm hiểu, và mình chưa thấy ai viết về nó.

Thread trên pgsql-hackers để đưa UUIDv7 vào PostgreSQL kéo dài hai năm, 234 message, 36 người tham gia [9]. Mình tải toàn bộ về và grep.

Từ bigint xuất hiện đúng hai lần trong toàn bộ thread. Cả hai nằm gọn trong một câu, của đúng một người.

Họ không tranh luận có nên dùng UUID thay số tự tăng hay không. Cuộc tranh luận đó, thứ chiếm hàng nghìn comment Reddit và hàng trăm bài blog, đơn giản là không tồn tại trong phòng. Họ tranh luận về việc bit nào đi đâu, counter bao nhiêu bit, monotonicity dùng method mấy của RFC.

Có đúng một người mang cuộc chiến của internet vào phòng: đề nghị tài liệu PostgreSQL tuyên bố uuidv7 nên là lựa chọn mặc định thay cho autoincrement. Câu trả lời nhận được ngắn gọn:

"We don't make these judgements in the documentation, typically."
Ảnh chụp thread pgsql-hackers "UUID v7" trên postgresql.org. Ba đoạn được tô vàng. Đoạn đầu là câu duy nhất trong thread có nhắc tới bigint: "UUID needs to be altered if you begin comparing UUID to bigint - you need to add some content to bigint too". Đoạn giữa là đề nghị tuyên bố uuidv7 thành lựa chọn mặc định thay cho autoincrement. Đoạn cuối là câu trả lời: "We don't make these judgements in the documentation, typically"

Từ hôm đọc được đoạn đó, mình thay đổi cách nhìn về toàn bộ chủ đề này. Cuộc chiến bigint chống UUID là cuộc chiến của chúng ta, không phải của những người viết ra cái database.

Họ không nói về uuidv7

Đọc thread đó xong mình đi đọc source. Và tìm ra ba thứ mà tài liệu chính thức im lặng.

Thứ nhất: uuidv7 làm lộ thời điểm tạo bản ghi. Đây là hệ quả trực tiếp của thiết kế, không phải bug. Hàm uuidv7() trong PostgreSQL 18 nhận một tham số tuỳ chọn shift interval [10], và tham số đó ra đời chính vì mối lo này. Nhưng tài liệu không nói một chữ nào về lý do nó tồn tại. Bạn đọc doc sẽ thấy một tham số kỳ lạ không rõ để làm gì.

Trong thread, có người đã viết sẵn một cảnh báo cho tài liệu, đại ý: nếu việc lộ timestamp gây hệ quả không chấp nhận được về bảo mật hoặc về thông tin kinh doanh thì nên dùng uuidv4(). Cảnh báo đó bị cắt vì bị chê là ngôn ngữ mơ hồ. Họ cắt, và không thay bằng gì cả.

Thứ hai: có một bug chưa được sửa. Bốn ngày sau khi patch được commit, một người chỉ ra rằng nếu đồng hồ server nhảy về tương lai rồi bị NTP kéo về, mọi backend đang sống sẽ tiếp tục sinh UUIDv7 mang timestamp tương lai sai, mỗi lần gọi tăng đúng một tick, cho tới khi backend đó chết. Tác giả patch trả lời rằng đó là tính năng chứ không phải lỗi.

Mình không định tin ai trong hai người. Mình đi đọc uuid.c trong nhánh REL_18_STABLE [11]. Đoạn code bảo đảm bước nhảy tối thiểu của timestamp vẫn y nguyên, không có điều kiện kiểm tra chiều ngược lại. Tính tới hôm nay, bug đó vẫn ở đó.

Thứ ba, và cái này mình thấy khó chịu nhất vì nó âm thầm: hằng số quyết định độ phân giải monotonic của uuidv7 có giá trị khác nhau giữa các hệ điều hành. Trên macOS và Windows là 10 bit, trên Linux là 12 bit [11]. Nghĩa là dev test trên MacBook và production chạy trên Linux đang có hành vi sinh khoá khác nhau ở tầng dưới cùng. Không tài liệu nào nhắc tới chuyện này.

Ảnh chụp source uuid.c nhánh REL_18_STABLE, dòng 53 tới 74. Khối comment phía trên giải thích rằng một số hệ chỉ có độ chính xác micro giây nên chỉ còn 10 bit sub-millisecond, và nêu đích danh macOS cùng MSVC. Ba dòng được tô vàng: điều kiện "#if defined(darwin) || defined(_MSC_VER)", rồi "#define SUBMS_MINIMAL_STEP_BITS 10", và sau nhánh #else là "#define SUBMS_MINIMAL_STEP_BITS 12"

Nhân tiện, con số "UUIDv7 nhanh gấp sáu lần UUIDv4" mà bạn thấy trong gần như mọi bài blog về chủ đề này đến từ một benchmark chạy trên MacBook Air. Mình không nói nó sai. Mình chỉ nói rằng chính tác giả patch cũng đã viết trong thread, khi từ chối đưa benchmark vào tài liệu: "benchmarks are tricky. You can prove anything with benchmarks."

Câu đó áp dụng cho cả benchmark của mình ở phần trên. Mình chạy trên một con VPS bốn core với đĩa chậm. Con số của bạn sẽ khác.

Đường thoát có thật, và nó đóng với đúng mình

Đến đây thì đường đi có vẻ rõ ràng. Mình đang dùng default=uuid.uuid4, đúng cột đắt nhất của cái bảng bên trên. Vậy thì đổi sang uuidv7 thôi.

Và đường đó có thật, rõ ràng, làm được hôm nay. Python 3.14 đã có uuid.uuid7() ngay trong stdlib [12], sau một issue mở từ 2021 [13]. Còn nếu muốn để PostgreSQL tự sinh thì Django 5.0 đã có db_default [14], và Paolo Melchiorre đã viết sẵn pattern kèm SQL xác nhận nó hoạt động [15]:

class UUIDv7(models.Func):
    function = "uuidv7"
    output_field = models.UUIDField()

class Material(models.Model):
    id = models.UUIDField(db_default=UUIDv7(), primary_key=True)

Không cần thư viện ngoài nào cả. Django core thì vẫn chưa có API sẵn cho chuyện này, ticket mở từ 2021 và vẫn chưa merge [16], nhưng hai đường trên đủ dùng.

Mình đã gần như quyết định làm. Rồi mình đọc RFC 9562, phần Security Considerations:

RFC định nghĩa UUIDv7 khuyến nghị dùng UUIDv4 nếu UUID phục vụ mục đích bảo mật

https://www.rfc-editor.org/rfc/rfc9562.html

"If UUIDs are required for use with any security operation within an application context in any shape or form, then UUIDv4 (Section 5.4) SHOULD be utilized."

Và ở đầu cùng section: "Implementations SHOULD NOT assume that UUIDs are hard to guess. For example, they MUST NOT be used as security capabilities (identifiers whose mere possession grants access)."

Ảnh chụp RFC 9562 mục 8 Security Considerations trên rfc-editor.org. Hai đoạn được tô vàng. Đoạn đầu section: "Implementations SHOULD NOT assume that UUIDs are hard to guess. For example, they MUST NOT be used as security capabilities (identifiers whose mere possession grants access)". Đoạn cuối section: "If UUIDs are required for use with any security operation within an application context in any shape or form, then UUIDv4 (Section 5.4) SHOULD be utilized"

Mình đọc lại câu đó vài lần.

Mình chọn UUID vì lý do bảo mật. Mình đang ở uuid4. Và cái RFC định nghĩa ra uuidv7 nói thẳng rằng nếu lý do của bạn là bảo mật thì hãy dùng v4.

Nghĩa là món quà lớn nhất mà PostgreSQL 18 tặng cho phe UUID không dành cho mình. Nó dành cho người chọn UUID vì lý do phân tán. Buildkite là ví dụ sạch nhất: họ migrate sang UUIDv7 và nói rõ lý do là để shard database lớn nhất của họ, không phải vì bảo mật [17].

Và đây là chỗ trớ trêu mà mình nghĩ đáng viết nhất trong cả bài: PostgreSQL 18 và Python 3.14 vừa cùng lúc đầu tư rất lớn vào UUIDv7 vì lý do hiệu năng, trong khi bản RFC sinh ra UUIDv7 thì bảo đừng dùng nó cho bảo mật. Hai thế giới đi ngược chiều nhau, và không tài liệu nào đứng ở giữa nói cho bạn biết điều đó.

74 bit ngẫu nhiên của uuidv7 vẫn là quá đủ, không ai brute-force nổi, nên cứ đổi đi cho rẻ

Đúng, và mình đã tự nói câu này với mình vài ngày. Hai mũ bảy mươi tư là một con số không đoán nổi theo bất kỳ nghĩa thực tế nào, nên tính chống enumeration của mình vẫn còn nguyên nếu đổi.

Cái mất không phải là khả năng đoán. Cái mất là: ai cầm một ID sẽ biết bản ghi đó được tạo lúc nào, chính xác tới mili giây. Với LMS thì đó là lộ thời điểm, không phải lộ số lượng, và có lẽ mình chấp nhận được.

Nhưng "có lẽ chấp nhận được" phải là một câu mình nói ra sau khi đã cân, chứ không phải hệ quả mình phát hiện sáu tháng sau. Điều mình phản đối không phải việc đổi sang v7. Là việc đổi mà không biết mình vừa bán đi cái gì.

Hai câu hỏi mình sẽ mang theo

Sau tất cả những thứ trên, mình nghĩ "bigint hay UUID" là một câu hỏi sai. Nó sai vì nó hỏi về kiểu dữ liệu, trong khi thứ quyết định lại nằm ở hai chỗ khác.

Câu thứ nhất: ai sinh ra cái khoá này? Nếu database sinh, bạn không cần UUID. Nếu client hoặc một service khác sinh nó trước khi có kết nối tới database, bạn cần. Đây là câu hỏi về kiến trúc, và nó không liên quan gì tới hiệu năng.

Câu thứ hai: cái khoá này sẽ bị hash hay bị sort? Ở đây mình cũng đã sai, và đáng nói vì mình sai theo hướng ngược hẳn. Mình từng tin rằng hệ phân tán chia shard bằng hash sẽ bị hot partition nếu khoá tuần tự. Thực ra hash chính là thuốc giải: băm một số tăng dần cho ra giá trị rải đều, id = 1000id = 1001 bay đi hai node khác nhau. Thủ phạm là range sharding.

Bên trái là hash sharding: bốn khoá tuần tự id=1000 tới id=1003 đi qua một hàm băm rồi toả đều ra bốn node, mỗi node có thanh tải bằng nhau, chú thích là rải đều. Bên phải là range sharding: cũng bốn khoá tuần tự đó nhưng tất cả đều dồn vào node 4 với một thanh tải cao vọt, trong khi node 1 tới node 3 có thanh tải rỗng, chú thích là điểm nóng dồn vào node cuối

MongoDB là bằng chứng đối chứng đẹp nhất, vì nó hỗ trợ cả hai kiểu và nói ngược nhau về cùng một loại khoá: hashed sharding thì bảo khoá tăng đơn điệu là lý tưởng [18], còn ranged sharding thì đòi khoá không được tăng đơn điệu [19]. CockroachDB, vốn chia theo range, thì nói thẳng là không khuyến khích index trên khoá tuần tự [20]. Spanner giải thích cơ chế: khoá tăng dần thì mọi write dồn về cuối bảng, và một server nhận hết [21].

Trả lời được hai câu đó thì kiểu khoá tự lộ ra. Trả lời sai thì không có phiên bản UUID nào cứu được bạn.

Nếu bạn muốn một ví dụ về việc người ta đã trả lời hai câu đó như thế nào, hãy mở file migration đầu tiên của Supabase Auth [22]. Trong cùng một file, auth.users dùng uuid, còn auth.refresh_tokens dùng bigserial. Cùng một schema, hai lựa chọn ngược nhau, vì hai bài toán khác nhau. users.id chui vào JWT và lộ ra client nên phải là uuid. refresh_tokens là bảng chỉ ghi thêm, nội bộ, không ai nhìn, nên bigserial rẻ hơn và chặt hơn.

Ảnh chụp file migration 00_init_auth_schema.up.sql của Supabase Auth trên GitHub. Dòng 5, nằm trong CREATE TABLE của auth.users, được tô vàng: "id uuid NOT NULL UNIQUE". Cách đó ba mươi dòng, dòng 35 nằm trong CREATE TABLE của auth.refresh_tokens cũng được tô vàng: "id bigserial NOT NULL". Hai lựa chọn ngược nhau nằm trong cùng một file

Còn mình thì đang ở đâu sau tất cả chuyện này?

Vẫn ở uuid4. Mình chưa đổi gì cả, và mình nghĩ trong vài tháng tới mình cũng chưa đổi. Việc mình sẽ làm trước là bật RLS, vì đó mới là thứ mình tưởng mình đã mua khi migrate. Và nếu giữa Django với Postgres của bạn có một connection pooler chạy transaction mode, như cái PgBouncer mình từng viết, thì policy dựa vào biến session sẽ cần thêm một lần suy nghĩ nữa, vì transaction pooling không giữ session cho bạn. Còn cái vách đá kia thì hệ của mình còn cách nó xa, và thành thật mà nói mình không biết bao giờ mới tới, hay có tới không.

Điều mình biết chắc là lần tới khi mình gõ models.UUIDField(default=uuid.uuid4), mình sẽ dừng lại một nhịp và tự hỏi mình đang mua cái gì, và cái đó có bán ở chỗ nào rẻ hơn không.

Nếu bạn cũng đang chạy một hệ multi-tenant với UUID và thấy mình đang nhầm ở đâu đó trong bài này, mình muốn nghe bạn cãi lại. Đặc biệt là phần RLS, vì đó là phần mình mới đọc chứ chưa làm.

Bình

Phụ lục: Citations (22)

  1. django-tenants documentation. Trien khai kien truc shared database, separate schemas. Moi tenant mot PostgreSQL schema.
  2. PostgreSQL 18 Documentation: Schemas. PostgreSQL Global Development Group. Cung mot ten object dung duoc o nhieu schema khac nhau, moi schema co sequence rieng.
  3. OWASP Insecure Direct Object Reference Prevention Cheat Sheet. OWASP. UUID la defense-in-depth, khong phai fix. Fix la access control check.
  4. OWASP API Security Top 10 2023 - API1:2023 Broken Object Level Authorization. OWASP. GUID xep thu 3 trong How To Prevent, sau hai item ve authorization.
  5. OWASP Multi-Tenant Application Security Cheat Sheet. OWASP. Use database-level isolation (RLS, schemas) as defense in depth.
  6. PostgreSQL 18 Release Notes. PostgreSQL Global Development Group. Asynchronous I/O phu sequential scan, bitmap heap scan va vacuum. io_method mac dinh la worker.
  7. PostgreSQL 18 Documentation: Resource Consumption (io_method, io_workers). PostgreSQL Global Development Group.
  8. Nguyen Anh Binh. Benchmark PostgreSQL 18: bigint vs UUID (repo kem bai viet). 2026-07. 60M rows, PG 18.4, sweep io_method sync/worker/io_uring. Du doan pre-register commit a5b0f39 truoc khi co du lieu.
  9. pgsql-hackers thread: UUID v7. PostgreSQL mailing list archives. 234 message, 2 nam, 36 nguoi. Tu bigint xuat hien dung 2 lan trong toan bo thread, ca hai trong cung mot cau cua mot message. Dem lai tren flat view ngay 2026-07-18.
  10. PostgreSQL 18 Documentation: UUID Functions. PostgreSQL Global Development Group. uuidv7([shift interval]), uuidv4(), uuid_extract_timestamp(). Doc khong giai thich vi sao shift ton tai.
  11. PostgreSQL source: src/backend/utils/adt/uuid.c (REL_18_STABLE). PostgreSQL Global Development Group. get_real_time_ns_ascending() thieu dieu kien previous_ns <= ns. SUBMS_MINIMAL_STEP_BITS = 10 tren macOS/Windows, 12 tren Linux.
  12. Python 3.14 Documentation: uuid - UUID objects according to RFC 9562. Python Software Foundation. uuid7() them vao stdlib tu Python 3.14. Nhung khuyen dung uuid4() cho security context.
  13. CPython issue gh-89083: Support UUIDv6, UUIDv7, and UUIDv8 from RFC 9562. CPython. PR #121119 cho uuid7, tac gia Benedikt Tran, merged 2025-03-04.
  14. Django 5.0 release notes: Database-computed default values. Django Software Foundation. Field.db_default them tu Django 5.0. Neu dat ca db_default lan default thi default (phia Python) thang.
  15. Paolo Melchiorre. How to use UUIDv7 in Python, Django and PostgreSQL. 2025-11-14. Pattern subclass models.Func voi function = uuidv7, kem SQL output xac nhan.
  16. Django ticket #32577: Add support for UUIDAutoField DEFAULT_AUTO_FIELD. Django Software Foundation. Accepted, assigned, chua merge. Mo tu 2021.
  17. Goodbye to sequential integers, hello UUIDv7!. Buildkite. Ly do migrate duoc neu ro la sharding, khong phai security. Bao cao giam WAL rate.
  18. MongoDB Manual: Hashed Sharding. MongoDB. Hashed keys are ideal for shard keys with fields that change monotonically.
  19. MongoDB Manual: Ranged Sharding. MongoDB. Yeu cau Non-Monotonically Changing Shard Keys. Nguoc voi hashed sharding cho cung mot loai khoa.
  20. CockroachDB: Hash-sharded indexes. Cockroach Labs. We discourage indexing on sequential keys.
  21. Cloud Spanner: Schema design best practices. Google Cloud. Khoa tang dan don ghi vao cuoi bang, mot server nhan toan bo write - hotspot.
  22. Supabase Auth migration: 00_init_auth_schema.up.sql. Supabase. Same file defines users.id as uuid NOT NULL UNIQUE and refresh_tokens.id as bigserial NOT NULL. Verified 2026-07-18.