- UUID v4 มีความสุ่มสูง ทำให้เกิด ความไม่มีประสิทธิภาพของดัชนีและ I/O ที่มากเกินไป และเมื่อใช้เป็นคีย์หลักใน PostgreSQL จะทำให้ประสิทธิภาพลดลง
- การแทรกข้อมูลแบบสุ่มทำให้เกิด page split และ index fragmentation บ่อยครั้ง รวมถึงทำให้ ขนาด WAL log เพิ่มขึ้น และเกิดความหน่วงในการเขียน
- UUID มีขนาด 16 ไบต์ ซึ่งกินพื้นที่มากกว่า bigint ถึงสองเท่า ส่งผลให้ cache hit rate ลดลงและเกิด การสิ้นเปลืองหน่วยความจำ
- แม้มักถูกเข้าใจผิดว่าเป็นตัวระบุเพื่อความปลอดภัย แต่ตาม RFC 4122 ระบุว่า UUID ไม่ใช่มาตรการความปลอดภัยเพื่อป้องกันการเดา
- สำหรับฐานข้อมูลใหม่ แนะนำให้ใช้ คีย์แบบลำดับชนิดจำนวนเต็ม และหากเลี่ยงไม่ได้ควรใช้ UUID v7 แบบเรียงตามเวลา
ปัญหาด้านประสิทธิภาพของ UUID v4
- ฐานข้อมูลที่ใช้ UUID v4 เป็นคีย์หลัก ใน PostgreSQL ตลอด 10 ปีที่ผ่านมา แสดงให้เห็นอย่างสม่ำเสมอถึง ประสิทธิภาพที่ลดลงและ I/O ที่มากเกินไป
- UUID v4 สร้างแบบสุ่ม 122 บิต ทำให้ไม่สามารถจัดเรียงดัชนีได้
- เมื่อแทรกข้อมูลจะไม่ถูกเก็บลงในหน้าที่เรียงต่อเนื่อง จึงเกิด random access และเมื่อต้องอัปเดตหรือลบก็ยังต้องค้นหาอย่างไม่มีประสิทธิภาพ
- ดัชนี B-Tree ตั้งอยู่บนสมมติฐานว่าข้อมูลมีการจัดเรียง แต่ UUID v4 ไม่มีลำดับ จึงทำให้ ประสิทธิภาพการแทรกต่ำ
- ทุกการแทรกจะถูกเขียนลงหน้าต่าง ๆ แบบสุ่ม ทำให้เกิด การแบ่งหน้ากลางทาง บ่อยครั้ง
- ส่งผลให้เกิด write latency และ WAL เพิ่มขึ้น
โครงสร้างของ UUID และทางเลือก
- UUID เป็นตัวระบุขนาด 128 บิต (16 ไบต์) และใน PostgreSQL จะถูกเก็บเป็น ชนิด binary uuid
- UUID v4 อิงบิตแบบสุ่ม ขณะที่ UUID v7 มี timestamp อยู่ใน 48 บิตแรก จึงมีประสิทธิภาพด้านดัชนีดีกว่า
- PostgreSQL 18 (กำหนดในปี 2025) จะรองรับ UUID v7 เป็นค่าเริ่มต้น
- UUID v7 สามารถเรียงตามเวลาได้ จึงช่วยปรับปรุง ความหนาแน่นของหน้าและประสิทธิภาพของแคช
เหตุผลที่เลือกใช้ UUID และข้อจำกัด
- UUID ถูกใช้เมื่อจำเป็นต้องสร้าง ตัวระบุที่ไม่ชนกัน ในสภาพแวดล้อมแบบหลายไคลเอนต์หรือไมโครเซอร์วิส
- เช่น การสร้าง ID พร้อมกันจากหลายอินสแตนซ์ของฐานข้อมูล
- อย่างไรก็ตาม RFC 4122 ระบุชัดว่า “อย่าสมมติว่า UUID เดาได้ยาก” จึง ไม่เหมาะจะใช้เป็นตัวระบุด้านความปลอดภัย
- ความน่าจะเป็นที่จะชนกันจะอยู่ที่ 50% เมื่อสร้างไป 2.71×10¹⁸ ค่า ซึ่งในทางปฏิบัติโอกาสชนกันต่ำมาก แต่ ต้นทุนด้านประสิทธิภาพสูง
ความไม่มีประสิทธิภาพด้านพื้นที่และ I/O ของ UUID
- UUID ใช้พื้นที่มากกว่า bigint (8 ไบต์) สองเท่า และมากกว่า int (4 ไบต์) สี่เท่า
- ในตารางขนาดใหญ่จะนำไปสู่ การใช้พื้นที่จัดเก็บเพิ่มขึ้น และ เวลาในการสำรอง/กู้คืนที่นานขึ้น
- ผลการทดลองเรื่อง ความหนาแน่นของหน้าดัชนี
- ดัชนี integer: 97.64%
- ดัชนี UUID v4: 79.06%
- ดัชนี UUID v7: 90.09%
- ในการทดสอบของ Cybertec พบว่า การค้นหาดัชนี UUID v4 ต้องเข้าถึงหน้าเพิ่มอีก 8.5 ล้านหน้า และมี I/O เพิ่มขึ้น 31229%
- ภายใต้เงื่อนไขเดียวกัน ดัชนี bigint มีการเข้าถึงบัฟเฟอร์ 27,332 ครั้ง ส่วน UUID v4 มี 8,562,960 ครั้ง
ผลกระทบต่อแคชและหน่วยความจำ
- UUID มีการกระจายแบบสุ่ม ทำให้ buffer cache hit ratio ต่ำ
- ต้องโหลดหน้าจำนวนมากขึ้นเข้าแคช และหน้าที่จำเป็นมักถูก eviction ออกบ่อย
- เมื่อประสิทธิภาพแคชลดลง จะทำให้ query มีความหน่วง และ การใช้หน่วยความจำเพิ่มขึ้น
- เพื่อคงประสิทธิภาพ แนะนำให้ทำ การสร้างดัชนีใหม่เป็นระยะ (
REINDEX CONCURRENTLY) หรือใช้ pg_repack
แนวทางบรรเทาปัญหาด้านประสิทธิภาพ
- เพิ่มหน่วยความจำ: แนะนำให้มี RAM ประมาณ 4 เท่าของขนาดฐานข้อมูล (เช่น DB 25GB → หน่วยความจำ 128GB)
- ปรับ
work_mem: สามารถเพิ่มประสิทธิภาพได้โดยจัดสรรหน่วยความจำให้กับงาน sort มากขึ้น
- ใน สภาพแวดล้อม Rails สามารถตั้งค่า
implicit_order_column เพื่อใช้ฟิลด์ที่จัดเรียงได้ เช่น created_at แทน UUID
- สามารถใช้คำสั่ง CLUSTER เพื่อจัดเรียงตารางใหม่ตามฟิลด์ที่เรียงลำดับได้ แต่ต้องใช้ exclusive lock
แนะนำให้ใช้คีย์จำนวนเต็มและ sequence
- สำหรับฐานข้อมูลใหม่ แนะนำให้ใช้ คีย์แบบ sequence ชนิดจำนวนเต็ม
integer (4 ไบต์) รองรับได้ราว 2 พันล้านค่า ส่วน bigint (8 ไบต์) รองรับค่าที่ไม่ซ้ำได้มากกว่านั้นมาก
- แอปธุรกิจส่วนใหญ่ใช้ integer ก็เพียงพอ ส่วนบริการขนาดใหญ่เหมาะกับการใช้ bigint
- ทางเลือกที่ใช้ได้จริงคือ UUID v7 แทน UUID v4 หรือใช้ ส่วนขยาย sequential_uuids
สรุป
- UUID v4 ก่อให้เกิด ความไม่มีประสิทธิภาพของดัชนี, I/O สูง, และประสิทธิภาพแคชต่ำ จากความสุ่มของมัน
- ไม่สามารถใช้เป็น ตัวระบุด้านความปลอดภัย ได้ และยัง สิ้นเปลืองพื้นที่
- คีย์แบบ sequence ชนิดจำนวนเต็ม เหมาะกับแอปพลิเคชันส่วนใหญ่มากกว่า
- หากจำเป็นต้องใช้ UUID จริง ๆ ควรเลือก UUID v7 แบบเรียงตามเวลา
- ควรหลีกเลี่ยงการใช้
gen_random_uuid() เป็นคีย์หลักใน PostgreSQL
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
นี่เป็นตัวอย่างคลาสสิกของ การปรับแต่งก่อนเวลาอันควร
การใส่ข้อมูลลงในตัวระบุถาวรถือเป็นข้อห้ามในการจัดการข้อมูล
ถ้าใส่วันเกิดไว้ใน ID แบบเลขประจำตัวประชาชนนอร์เวย์ ภายหลังก็อาจเกิดกรณีผู้อพยพที่เดิมรู้วันเกิดผิด หรือมีคนที่ถูกกำหนดให้เกิดวันที่ 1 มกราคมมากเกินไปจนเลขไม่พอ
สมัยตู้บัตรรายการนั้นการผสมข้อมูลกับตัวระบุเพื่อให้ค้นหาได้ถูกลงยังพอเข้าใจได้ แต่ทุกวันนี้เรามีฐานข้อมูลที่ทรงพลังอยู่แล้ว จึงไม่ค่อยมีเหตุผลต้องทำแบบนั้น
ปัญหาอยู่ที่ตั้งวันเกิดที่ไม่รู้เป็นวันที่ 1 มกราคม ไม่ใช่การใส่วันที่ลงในคีย์โดยตัวมันเอง
ถ้าใช้ค่าที่ไม่ใช่วันที่อย่าง 00 หรือ 99 ก็คงไม่ชนกัน
การใส่ timestamp ลงใน UUID ไม่ได้มีไว้เพื่อเพิ่มความหมาย แต่มีจุดประสงค์ด้าน การปรับแต่งประสิทธิภาพ
คีย์ที่เพิ่มขึ้นตามลำดับเวลาช่วยลดต้นทุนการเขียน B-tree ใหม่และเพิ่มประสิทธิภาพการ insert ของฐานข้อมูล
คำว่า “อย่าใส่ข้อมูลลงในตัวระบุถาวร” เป็นเพียงหลักทั่วไป และบางสถานการณ์ก็อาจยอมรับ trade-off นี้ได้
ตัวอย่างเช่น ถ้าใช้ md5 hash เป็น UUID เพื่อทำดัชนี ก็จะมี fragmentation อยู่บ้างแต่ยังอยู่ในระดับที่จัดการได้
การเลือก UUID แบบสุ่มเทียบกับแบบอิงเวลาอาจทำให้ต่างกันด้านประสิทธิภาพในระดับวินาที ไม่ใช่แค่มิลลิวินาที
ในฐานข้อมูลขนาดใหญ่ การทำ sharding และการกระจายข้อมูลเป็นสิ่งจำเป็น ทำให้ UUID อาจทำงานได้ดีกว่า auto-increment
ถ้าฟอร์แมตคือ DDMMYYXXXXX ก็รองรับได้ถึง 100,000 คน อยากรู้ว่าจะเยอะขนาดนั้นจริงไหม
น่าจะเป็นกรณีพิเศษ เช่น มีผู้ลี้ภัยไหลเข้ามาจำนวนมากในบางปี
ไม่ควรใช้ UUID เหมือน security token
การเอาไปใช้เป็นกลไกความปลอดภัยเพียงเพราะเดายากถือว่าเสี่ยง
จุดประสงค์ของค่าที่สุ่มไม่ได้มีแค่กันการเดา แต่ยังช่วยซ่อนความสัมพันธ์ระหว่าง ID ที่ต่อเนื่องกันด้วย
กลยุทธ์ PK จะแตกต่างกันอย่างสิ้นเชิงตามชนิดของฐานข้อมูล
ใน Postgres นั้น PK แบบสุ่มไม่มีประสิทธิภาพ แต่ในฐานข้อมูลกระจายอย่าง Cockroach หรือ Spanner กลับเป็นคีย์ที่เพิ่มขึ้นแบบลำดับซึ่งทำให้เกิดปัญหา hot shard
UUIDv7 มีบิตส่วนบนที่เรียงลำดับได้และบิตส่วนล่างที่เป็นสุ่ม จึงได้ทั้งการกระจายระหว่างโหนดและประสิทธิภาพในการเก็บข้อมูลภายในเครื่อง
ในฐานข้อมูลทั่วไปที่ไม่ได้ sharding คีย์แบบสุ่มจะทำให้ B-tree fragmentation
แต่ถ้ามี range query เยอะ คีย์แบบสุ่มจะเสียเปรียบ
สุดท้ายแล้วต้องเลือกตาม ลักษณะของ workload
ใจความของบทความชี้ข้อเสียของการใช้ UUIDv4 เป็น PK ได้ดี แต่แนวทาง การทำ integer ให้ดูอ่านไม่ออก ที่เสนอมาไม่น่าเหมาะกับบริการจริง
ถ้าเป็นฐานข้อมูลขนาดเล็ก UUIDv7 น่าจะเป็นทางสายกลางที่สมเหตุสมผล
เพราะไม่ต้องการให้เวลาในการสร้างถูกเปิดเผย
ตราบใดที่ข้อมูลยังไม่มากจนความสุ่มของ UUIDv4 กลายเป็นปัญหาด้านประสิทธิภาพ v4 ก็เป็นทางเลือกที่ปลอดภัยกว่า
แม้จะมีข้อมูลหลุดออกไปนิดหน่อย แต่ในเชิง การปฏิบัติการก็ยังคลุมเครือพอ
เช่น แปลงด้วย AES-128 แล้วเข้ารหัส base64 ก็จะทำให้ดูเหมือน YouTube video ID
ผมเห็นหลายบริษัทระหว่างทำ technical due diligence และพบว่า ความสามารถในการ sharding ได้เร็ว เป็นหัวใจของการเติบโตของธุรกิจ
ถ้าใส่ UUID ไว้ในทุกตาราง ก็สามารถขยายระบบตอนทำ sharding ได้โดยไม่ต้องเปลี่ยนโครงสร้าง
มันให้ ข้อดีด้านการขยายระบบ ที่มากกว่าต้นทุนด้านพื้นที่และเวลาที่เสียไปเล็กน้อยมาก
สุดท้ายแล้วโมเดลข้อมูลที่ซับซ้อนทำให้การย้ายระบบยากอยู่ดี ไม่ได้ขึ้นกับมี UUID หรือไม่
แอปของเรา เข้ารหัส integer PK ให้ดูเหมือน UUID
เพราะถ้าเปิดเผย ID แบบลำดับ คนก็อาจประมาณจำนวนลูกค้าหรือทำ dictionary attack ได้
ID ที่เข้ารหัสแล้วช่วยให้ตรวจจับความพยายามสแกนได้ทันทีเมื่อถอดรหัสไม่สำเร็จ
คำว่า “2 พันล้านก็น่าจะพอ” เป็นความคิดที่อันตราย
DBA ทุกคนน่าจะมีอย่างน้อยหนึ่งเรื่องสยองที่เริ่มต้นจากการตัดสินใจแบบนั้น
บทความบอกว่า “ค่าที่สุ่มเรียงลำดับได้ไม่มีประสิทธิภาพ” แต่จริง ๆ แล้ว การเรียงตามลำดับไบต์ ทำได้
เพียงแต่คีย์แบบสุ่มไม่ใช่การ insert แบบเรียงลำดับ จึงทำให้ B-tree ต้อง rebalance บ่อยและประสิทธิภาพตก
integer PK ทำให้ดัชนีพอดีกับหน่วยความจำได้ดีกว่า ส่วน UUIDv4 ต้องเข้าถึงเพจมากกว่า ทำให้ latency สูงขึ้น
บทความนี้ดูเหมือนเป็นกรณี คิดวิธีแก้ก่อนเจอปัญหา มากกว่าจะเป็นปัญหาจริง
UUIDv4 ใช้ได้ดีพอในกรณีส่วนใหญ่
เรื่องประสิทธิภาพควรค่อยพิจารณาเมื่อมันเกิดขึ้นจริง
สรุปคือ ใน Postgres นั้น UUIDv7 ให้ประสิทธิภาพดีกว่า v4 เล็กน้อย
ในเวอร์ชันใหม่ ๆ ก็รองรับ UUIDv7 ได้โดยไม่ต้องใช้ปลั๊กอิน
uuidv7()ในตัว แต่ยังไม่ชัดเจนว่าส่วนขยายต่าง ๆ จะมีความสามารถมากกว่าหรือไม่