เปรียบเทียบ UUID v1·v4·v7 และการออกแบบ Primary Key ของฐานข้อมูล
สรุป (TL;DR)
พูดแบบนี้อาจมีคนเถียง แต่ การใช้ UUID v4 เป็น primary key ของตารางที่ insert ถี่มากใกล้เคียงกับการเหยียบเท้าตัวเอง ผมเคยดู workload PostgreSQL 16 หนึ่งที่ primary key ของตาราง events เป็น uuid v4 (gen_random_uuid()) ทุก INSERT ตกลงใน leaf page สุ่มของ B-tree ทำให้ shared_buffers เย็นลงและ index fragmentation สะสมถึงช่วงเปอร์เซ็นต์ต้นๆ พอเปลี่ยนคอลัมน์เป็น v7 — type uuid 16 byte เดียวกัน index เดียวกัน — INSERT latency เฉลี่ยลดเหลือราว 1/3 และตัวชี้วัด fragmentation ใน pg_stat_user_indexes ก็ stabilize การเลือก UUID version ไม่ใช่ “การตัดสินใจเชิง cryptography” แต่เป็น การตัดสินใจการออกแบบฐานข้อมูล UUID คือค่า 128 bit เขียนแบบ 16 ฐาน 8-4-4-4-12 4 bit เป็น version และ 2–3 bit เป็น variant คงที่ v1 ฝัง timestamp และ node identifier (ในอดีตคือ MAC address) จึง sort ตามเวลาได้คร่าวๆ แต่เปิดเผย host v4 ใช้ random 122 bit ได้ unpredictability ที่แข็งแต่ไม่ sort ได้ v7 เป็น version ที่ถูก standardize ใน RFC 9562 (2024) มี Unix millisecond timestamp ที่ 48 bit บน ที่เหลือเป็น random รวมความปลอดภัยของ v4 กับ index locality ของ v1 สำหรับ public API ID ที่ “ซ่อนว่าสร้างเมื่อไร” สำคัญจริงๆ v4 ยังเป็นค่าเริ่มต้น ที่อื่นเกือบทั้งหมด v7 เป็นตัวเลือกที่ดีกว่า และ v1 เป็น legacy — แม้ DB ยอมรับ อย่าเลือกเป็นค่าเริ่มต้นของการออกแบบใหม่
ภูมิหลังและแนวคิด
UUID คือ 128 bit โดยธรรมเนียมเขียนเป็น hex 32 หลักแบ่งด้วย hyphen xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx ตำแหน่ง M คือ version (1, 4, 7 ฯลฯ) และ bit บนของ N ระบุ variant ที่เหลือความหมายต่างกันตาม version
Version 1 ออกแบบเพื่อให้มีความเป็นเอกลักษณ์ข้ามเครื่องและเวลา timestamp 60 bit ให้ช่วง 100 nanosecond นับจาก 1582-10-15 clock sequence field จับการย้อนนาฬิกา และ node ID 48 bit เดิมเก็บ network MAC address ผลด้าน privacy ตรงไปตรงมา — v1 UUID ที่ออกจาก laptop ฝัง MAC ของ laptop นั้นไว้ และ reverse ได้ด้วย uuid CLI บรรทัดเดียว ไลบรารีสมัยใหม่บางตัว randomize node ID เพื่อเลี่ยง leak แต่ implementation จำนวนมากยังทำตามกฎเดิม
Version 4 คือ random 122 bit + 6 bit คงที่สำหรับ version·variant สมมติ RNG ที่แข็งแรง (crypto.randomUUID() ของเบราว์เซอร์หรือ gen_random_uuid() ของ PostgreSQL) ใน scale จริง collision เป็นไปไม่ได้ — ขอบเขต birthday สร้าง 1 พันล้านตัวก็ collision แค่ระดับ 1 ใน 1 ล้านล้าน ข้อเสียคือ v4 สองตัวที่ออกต่อเนื่องไม่มีความสัมพันธ์กัน ทำให้การ insert index สุ่ม page กระทบ cache locality และ write amplification (WAL·full-page write) ในทางลบ
Version 7 เป็นส่วนหนึ่งของ RFC 9562 ที่ตีพิมพ์ปี 2024 ซึ่ง deprecated RFC 4122 และเพิ่ม v6·v7·v8 v7 มี Unix millisecond timestamp 48 bit ที่ด้านบน แล้วตาม version tag, rand_a ขนาดเล็ก, variant tag, และหาง rand_b 62 bit ผลเชิงปฏิบัติคือ v7 ที่ออกใน millisecond เดียวกันเรียงติดกันในลำดับ และ sort ตามเวลาข้าม millisecond หางสุ่มเพียงอย่างเดียวก็มี entropy พอสำหรับความเป็นเอกลักษณ์ใน millisecond เดียวกัน PostgreSQL 18 มี uuidv7() เนทีฟแล้ว version ก่อนหน้าใช้ extension pg_uuidv7 หรือไลบรารีระดับ application (Node uuid 9.x, Python uuid6) ได้ผลเดียวกัน
variant bit สำคัญเพราะแยก RFC 9562/4122 family จาก legacy UUID ของ Microsoft·Apollo ในบทนี้สมมติ RFC variant (หลักแรกของกลุ่ม N ข้างต้นเป็น 8, 9, a, b)
รูปแบบการเก็บเป็นประเด็นแยก type uuid เนทีฟของ PostgreSQL 16 เก็บค่าเป็น 16 byte MySQL มักใช้ BINARY(16) หรือ CHAR(36) แบบหลังใช้ storage เป็นสองเท่าและเปรียบเทียบเป็นอักขระ การเลือก version กับ storage format เกี่ยวพันกัน sort v7 แบบ binary ถูกและแม่นยำ sort แบบ hex string แม่นยำแต่ช้า v4 ไม่ sort ได้ไม่ว่าจะเก็บอย่างไร
เปรียบเทียบและข้อมูล
| คุณสมบัติ | v1 | v4 | v7 |
|---|---|---|---|
| Input การสร้าง | timestamp + clock sequence + node ID | random 122 bit | Unix ms 48 bit + random tail |
| Privacy | เปิดเผย node ID (มักเป็น MAC) | ไม่มีข้อมูล host·time | เปิดเผยเวลาสร้าง (ms), ไม่มี host |
| Sort ตามเวลา | ทำได้ (แต่ byte order ≠ time order ต้อง rearrange) | ไม่ได้ | ทำได้ — lexicographic order = time order |
| Index locality | กลาง | แย่ (insert สุ่มทั่ว B-tree) | ดี (เกือบ monotonic) |
| การใช้งานตัวแทน | legacy system, COM/Windows ID | public API ID, session token, salt | event log, insert ถี่, time pagination |
| Entropy | ต่ำ (ส่วนใหญ่เป็น time·node) | สูง (ราว 122 bit) | tail สูง (ราว 74 bit), collision ใน ms หายาก |
Mental model คร่าวๆ v4 max unpredictability แลก index performance, v7 รักษา unpredictability เพียงพอสำหรับ application ส่วนใหญ่พร้อมคืน “insert ที่ปลาย” ที่ DB ชอบ, v1 เป็นมรดกประวัติ — ควรรู้ว่ามีอยู่แต่อย่าเลือก
สถานการณ์จริง
สถานการณ์ 1 — event log ที่ append-heavy workload ที่กล่าวเปิดเรื่องรูปร่างนี้เป๊ะ ตารางที่ insert หลายล้านแถวต่อวันและ query ปกติคือ “24 ชั่วโมงล่าสุด เรียงตามเวลา” v7 ได้เปรียบโดยตรง แถวใหม่ติดที่ปลาย primary key index ทำให้ hot page อบอุ่น query time-range map เป็น segment index ติดกัน เปลี่ยน default ของคอลัมน์จาก v4 เป็น v7 โดยไม่ต้องเปลี่ยนโค้ด query ก็ลด write latency และ index fragmentation ได้บ่อย
สถานการณ์ 2 — public user-facing ID share link แบบ /orders/{id} ต้อง unpredictable เพื่อไม่ให้ enumerate คำสั่งของผู้ใช้อื่น v4 เป็นค่าเริ่มต้นที่ปลอดภัย ถ้าต้องการข้อได้เปรียบของ v7 ด้วย จำไว้ว่า v7 เปิดเผยเวลาสร้างระดับ millisecond กับ order อาจยอมรับได้ แต่ในบริบทที่ sensitive กว่า (จำนวนการชำระเงินต่อนาทีเป็น business signal) ถือเป็นการ leak ทางประนีประนอมที่ผมเคยแนะนำทีมคือ dual-ID pattern — ใช้ v7 เป็น primary key ภายใน เปิดเผย v4 หรือ random slug สั้นแยกออกภายนอก — ไม่เสีย index performance และไม่ leak ข้อมูลเวลา
สถานการณ์ 3 — ระบบ multi-region·shard prefix timestamp ของ v7 ทำให้ region ต่างกันออก UUID ใน millisecond เดียวกันก็ผสมตามเวลาได้ธรรมชาติ แต่ภายใน millisecond เดียวกัน ไม่มีการรับประกันลำดับระหว่าง region ถ้าต้องการลำดับเข้มงวดกว่าระหว่าง region ULID (48 bit timestamp + 80 bit random แสดงเป็น Crockford Base32) มีลักษณะเกือบเหมือนกันในรูป text สั้น 26 char ถ้าต้องการรับประกันเข้มกว่านั้น Snowflake-style ID (Twitter ต้นฉบับและ Discord variant เป็น 64 bit ทั้งคู่) รวม machine ID อย่างชัดเจน แต่ต้องเพิ่มภาระของ coordination ในการแจก machine ID
ความเข้าใจผิดที่พบบ่อย
“UUID ช้าใน DB เสมอ” ช้ากว่า int 4 byte จริง แต่ส่วนใหญ่ของต้นทุนจริงคือ fragmentation จาก random insert ของ B-tree index ซึ่ง v7 ตัดทิ้งเกือบหมด เก็บเป็น 16 byte แทน string 36 char ทำให้ index ลดครึ่งและเปรียบเทียบเร็วขึ้น benchmark “UUID ช้า” หลายอันจริงๆ ใกล้เคียง “v4 ที่เก็บ CHAR(36) ใน MySQL ช้า”
“v4 เท่านั้นที่ปลอดภัย” หางสุ่มของ v7 ก็มี entropy pool ใหญ่พอ ใน application ส่วนใหญ่ — session reference, API ID — การ enumerate โดยผู้โจมตีไม่เป็นไปได้ในทางปฏิบัติ ปัญหาไม่ใช่ predictability แต่คือ การ leak timestamp v7 เปิดเผยเวลาที่ row ถูกสร้าง ถ้า leak ยอมรับได้ (ส่วนใหญ่ใช่) v7 สมเหตุสมผลสำหรับ external ID ด้วย
“UUID ต้องเก็บเป็น string” รูป string 36 char (hex 32 + hyphen 4) แต่ binary เพียง 16 byte และ byte order คือ sort order ที่ถูกต้อง เมื่อพิจารณาว่า v1 byte order ไม่ monotonic และ v7 byte order ต้องตรงกับเวลา การเก็บ binary ได้เปรียบ type uuid ของ PostgreSQL เก็บ 16 byte อยู่แล้ว จึงไม่ต้องกังวล conversion
“ไม่มีใครดู MAC leak ของ v1” การ reverse MAC จาก v1 เป็น transformation เปิดเผย และ forensic tool (uuid -d ฯลฯ) ดึงออกมาได้เป็น default ถ้า UUID ปรากฏใน URL·support ticket·log ที่แชร์ภายนอก นี่คือการ leak ข้อมูลจริง
เช็กลิสต์
- ใช้เป็น key ของ index ที่ insert ถี่ไหม ตั้ง v7 เริ่มต้น v4 เฉพาะเมื่อต้องการ unpredictability ของเวลาสร้างจริงๆ
- UUID มองเห็นโดยผู้ใช้·partner ไหม ทำงานได้ทั้งคู่ เช็กว่ายอมรับ timestamp leak ของ v7 ได้ไหม
- Postgres ไหม เก็บเนทีฟ
uuid(16 byte) ใน MySQL ใช้BINARY(16)ถ้าไม่ต้องการ string compatibility จริง - ต้องการรับประกันลำดับระหว่าง generator หลายตัวไหม v7 อย่างเดียวไม่พอ ULID (time prefix เดียวกัน) หรือ Snowflake-style ที่รวม machine ID
- ยังมี v1 ในโค้ดเบสไหม เอกสาร MAC leak ไว้ และวางแผน migrate เมื่อ schema อนุญาต
- สร้างที่ client ไหม ใช้ไลบรารีที่เรียก RNG แข็งแรงเชิง cryptography (v4 เบราว์เซอร์ล่าสุดใช้
crypto.randomUUID()v7 มักห่อ RNG เดียวกัน)
เครื่องมือที่เกี่ยวข้อง
UUID Generator ของ Patrache Studio สร้าง v4 และ v7 ในเครื่อง ค่าที่สร้างไม่เหลือใน log ของบริการที่สาม UUID แทบจะเดินทางใน JSON payload เสมอ ดู ความต่างระหว่าง JSON Formatting·Validation·JSON Schema สำหรับ pattern schema ที่ keep ID พวกนี้ให้ typed เวลาเคลื่อนระหว่างบริการ เมื่อต้องการ text form กระชับเช่น ID สั้น 22 char ที่ derive จาก UUID 16 byte ดู Base64·URL Encoding อธิบาย ว่าทำไมเลือก Base64URL ไม่ใช่ Base64 มาตรฐาน
อ้างอิง
- IETF RFC 9562, “Universally Unique IDentifiers (UUIDs)” — https://datatracker.ietf.org/doc/html/rfc9562
- IETF RFC 4122, “A Universally Unique IDentifier (UUID) URN Namespace” (deprecated by RFC 9562) — https://datatracker.ietf.org/doc/html/rfc4122
- PostgreSQL documentation, “UUID Type” — https://www.postgresql.org/docs/current/datatype-uuid.html
- ULID specification — https://github.com/ulid/spec