Toolora

UUID v4 กับ v7 ต่างกันอย่างไร

ทำไม v7 ถึงเข้ากับ index ได้ดีกว่า v4 มาก เมื่อไรที่ค่าสุ่มล้วนยังเป็นคำตอบที่ถูก และเวลาสร้างที่ v7 บอกออกไปฟรี ๆ

UUID มีขนาด 128 บิต เขียนออกมาเป็นเลขฐานสิบหก 32 ตัว แบ่งเป็นห้ากลุ่มคั่นด้วยขีด รวมแล้ว 36 ตัวอักษร จุดประสงค์ของมันคือให้เครื่องสองเครื่องที่ไม่เคยคุยกันเลย ต่างคนต่างสร้างรหัสของตัวเองได้ โดยมั่นใจว่าจะไม่ชนกัน

ในจำนวนนั้นมี 4 บิตที่บอกเวอร์ชัน และอีก 2 บิตบอก variant เหลือ 122 บิตให้แต่ละเวอร์ชันเอาไปใช้ตามแบบของตัวเอง v4 ใส่ค่าสุ่มลงไปทั้ง 122 บิต ส่วน v7 ใช้ 48 บิตแรกเก็บเวลา ทุกอย่างที่เหลือในบทความนี้เป็นผลพวงจากการตัดสินใจข้อเดียวนี้

v4: ค่าสุ่ม 122 บิต

UUID v4 ไม่มีโครงสร้างอะไรเลย ไม่มีเวลา ไม่มีตัวนับ ไม่มีลำดับ มีแต่ค่าสุ่มจากแหล่งสุ่มระดับ cryptographic

คำถามเรื่องการชนกันถูกถามบ่อยมาก และควรตอบด้วยตัวเลขจริงมากกว่าจะบอกแค่ว่า “ไม่ต้องห่วง” ด้วยค่าสุ่ม 122 บิต คุณต้องสร้าง UUID ราว 2.7 × 10¹⁸ ตัว จึงจะมีโอกาสครึ่งหนึ่งที่จะมีสองตัวใดก็ตามซ้ำกัน ถ้าสร้างวินาทีละพันล้านตัว ต้องใช้เวลาราว 85 ปีติดต่อกัน

ไม่มีระบบไหนที่คุณสร้างจะเข้าใกล้ตัวเลขนั้น ให้ถือว่าการชนกันของ v4 ไม่ใช่ประเด็น แล้วเอาเวลาไปคิดเรื่องอื่นดีกว่า

v7: เวลาก่อน แล้วค่อยสุ่ม

RFC 9562 กำหนดโครงสร้าง v7 ไว้แบบนี้

48 บิต  Unix timestamp หน่วยมิลลิวินาที
 4 บิต  เวอร์ชัน (7)
12 บิต  rand_a — มักใช้เป็นตัวนับภายในมิลลิวินาทีเดียวกัน
 2 บิต  variant
62 บิต  rand_b

เพราะ timestamp อยู่ในบิตที่มีนัยสำคัญสูงสุด และเลขฐานสิบหกเรียงลำดับตรงกับค่าตัวเลขที่มันแทน การเรียง UUID v7 แบบข้อความธรรมดาจึงได้ลำดับตามเวลาทันที ไม่ต้องแกะค่าอะไร ไม่ต้องมีคอลัมน์ created_at ไว้เรียงต่างหาก

ช่อง timestamp ขนาด 48 บิตจะเต็มราวปี ค.ศ. 10889 จึงไม่ใช่เรื่องที่ต้องวางแผนรับมือ

มีรายละเอียดหนึ่งที่ควรรู้ UUID v7 สองตัวที่สร้างในมิลลิวินาทีเดียวกันจะไม่มีลำดับที่แน่นอนระหว่างกัน เว้นแต่ตัวสร้างจะใช้วิธี monotonic counter คือเอา rand_a มาทำเป็นตัวนับภายในมิลลิวินาที ตัวสร้างที่ข้ามขั้นตอนนี้จะให้ชุดข้อมูลที่เรียงมาแค่คร่าว ๆ ซึ่งทำลายจุดขายของ v7 ไปเงียบ ๆ หากคุณสร้างทีละมาก ๆ เครื่องมือสร้าง UUID ของ Toolora ใช้ตัวนับนี้ ชุด 500 ตัวที่สร้างในมิลลิวินาทีเดียวจึงยังเรียงถูกต้อง

ความต่างที่สำคัญจริง: สิ่งที่เกิดขึ้นใน index

นี่คือเหตุผลที่ทั้งสองเวอร์ชันมีอยู่คู่กัน และเป็นส่วนเดียวของการเปรียบเทียบนี้ที่มีต้นทุนวัดได้จริง

B-tree index เก็บ key เรียงตามลำดับกระจายอยู่ในหน้า (page) ขนาดคงที่ ส่วนที่ว่า key ใหม่จะไปลงหน้าไหน ขึ้นอยู่กับค่าของมันล้วน ๆ

กับ v4 ทุกการ insert ไปลงที่สุ่ม ๆ หน้า leaf ที่ key นั้นควรอยู่มักไม่ได้อยู่ใน buffer pool การเขียนหนึ่งครั้งจึงกลายเป็นอ่านก่อนแล้วค่อยเขียน หน้าต่าง ๆ เต็มไม่สม่ำเสมอ และเมื่อหน้าที่เต็มแล้วได้รับแถวใหม่ มันจะแตกออกเป็นสองหน้า เหลือครึ่งว่างทั้งคู่ สุดท้าย index ใหญ่เกินกว่าปริมาณข้อมูลจะสมเหตุสมผล และ index ที่ใหญ่ขึ้นก็ cache ได้แย่ลง ทำให้ insert ครั้งถัดไปยิ่งพลาด cache มากขึ้นไปอีก

กับ v7 การ insert มาถึงเรียงจากน้อยไปมาก และไปลงที่หน้า leaf ขวาสุดเสมอ หน้านั้นอยู่ในหน่วยความจำอยู่แล้วเพราะเพิ่งใช้ไปเมื่อครู่ แต่ละหน้าถูกเติมจนเต็มก่อนจะเปิดหน้าใหม่ ค่า fill factor จึงสูงและการแตกกระจายต่ำ พื้นที่ที่ต้องแตะตอน insert เหลือแค่ไม่กี่หน้าแทนที่จะเป็นทั้ง index

ผลกระทบนี้ชัดที่สุดเมื่อ primary key เป็นแบบ clustered เช่น InnoDB ใน MySQL และ clustered index ใน SQL Server เพราะแถวข้อมูลถูกจัดเก็บเรียงตาม key จริง ๆ key ที่สุ่มจึงกระจายตัวข้อมูลไปด้วย ไม่ใช่แค่ตัว index ส่วน PostgreSQL เก็บแถวไว้ใน heap ตารางจึงไม่ถูกจัดเรียงใหม่ แต่ index ของ primary key ก็ยังเป็น B-tree และยังได้ประโยชน์จากการ insert แบบเรียงลำดับอยู่ดี

มีข้อควรระวังที่ควรพูดตรง ๆ ข้อหนึ่ง key ที่เรียงลำดับทำให้ทุกการ insert ไปกระจุกที่หน้าขวาสุดหน้าเดียว ซึ่งภายใต้ภาระการเขียนพร้อมกันสูงมาก จุดนั้นอาจกลายเป็นคอขวดได้ ขณะที่ key แบบสุ่มกระจายภาระออกไป สำหรับงานส่วนใหญ่ v7 คุ้มกว่า แต่ถ้าระบบของคุณเขียนพร้อมกันหนักจริง ๆ ควรวัดจริงมากกว่าเดา

เมื่อไรที่ v4 ยังเป็นคำตอบที่ถูก

เลือก v4 เมื่อรหัสนั้นจะถูกเปิดเผยออกไป และคุณไม่อยากให้มันบอกอะไรเลย

  • รหัสที่คนภายนอกเห็น และเวลาสร้างเป็นข้อมูลอ่อนไหว เช่น ลิงก์คำเชิญ ลิงก์แชร์ หรืออะไรก็ตามที่คนแปลกหน้าอาจถืออยู่
  • กรณีที่ลำดับเองก็เป็นข้อมูล ถ้ารหัสไปปรากฏในรายการสาธารณะ timestamp ของ v7 จะเผยรูปแบบกิจกรรมของคุณตามช่วงเวลา ว่าช่วงไหนคึกคัก ช่วงไหนเงียบ เปิดตัวเมื่อไร
  • เมื่อไม่ต้องการลำดับอยู่แล้ว ถ้ารหัสนั้นใช้เป็นแค่ key สำหรับค้นหา และไม่มีอะไรเรียงตามมัน การที่ v4 ไม่มีโครงสร้างก็ไม่ได้ทำให้เสียอะไร

สิ่งที่ v7 บอกออกไป

ใครก็ตามที่ถือ UUID v7 อ่านค่ามิลลิวินาทีที่มันถูกสร้างได้ นั่นไม่ใช่ข้อบกพร่อง มันคือการออกแบบทั้งหมดของ v7 แต่เป็นคุณสมบัติที่คุณกำลังเลือกรับมาด้วย

สำหรับ primary key ภายในระบบ ไม่มีใครสนใจ สำหรับเลขที่คำสั่งซื้อใน URL ลูกค้าจะรู้ว่าคำสั่งซื้อของตัวเองเกิดขึ้นเมื่อไร ซึ่งไม่เป็นไร แต่สำหรับ user ID ที่เปิดออกทาง API มันบอกคนที่ไล่เก็บรหัสได้จำนวนหนึ่งว่าแต่ละบัญชีถูกสร้างช่วงไหนโดยประมาณ

ควรตัดสินใจอย่างตั้งใจ และอย่าลืมว่า ทั้งสองเวอร์ชันไม่ใช่ความลับ ค่าสุ่ม 62 บิตทำให้ v7 เดาไม่ได้ในทางปฏิบัติก็จริง แต่ UUID ใน URL เป็นแค่ตัวระบุ ไม่ใช่สิทธิ์การเข้าถึง ถ้าการรู้รหัสคือสิ่งที่ทำให้เข้าถึงข้อมูลได้ นั่นคือช่องโหว่ด้านสิทธิ์ และหากคุณกำลังคิดจะใช้ค่าแฮชมาทำรหัสให้ดูเดายาก นั่นมักเป็นเครื่องมือที่ผิดสำหรับงานนี้

ใช้ UUID หรือเลขรันนิ่งดี

เลขจำนวนเต็มเล็กกว่าและอ่านออกเสียงง่ายกว่า ส่วน UUID แลกมาด้วยสามอย่างที่คุ้มค่า

  • สร้างที่ไหนก็ได้ ฝั่ง client, worker หรืออุปกรณ์ที่ออฟไลน์อยู่ ก็สร้างรหัสได้เองโดยไม่ต้องวิ่งไปถามฐานข้อมูล
  • รวมข้อมูลได้โดยไม่ต้องเปลี่ยนเลขใหม่ ชุดข้อมูลสองชุดที่สร้างแยกกันนำมารวมกันได้ทันที
  • ไม่เผยจำนวนแถว เลขรันนิ่งบอกคนภายนอกได้ว่าคุณมีข้อมูลกี่รายการ และเพิ่มขึ้นเร็วแค่ไหน

ต้นทุนคือขนาด 16 ไบต์ เทียบกับ 4 หรือ 8 ไบต์ และอ่านยากกว่า

เก็บเป็น 16 ไบต์เสมอ

นี่คือความผิดพลาดที่พบบ่อยที่สุดและแพงที่สุดเกี่ยวกับ UUID

UUID มีขนาด 128 บิต หรือ 16 ไบต์ แต่เมื่อเขียนออกมาพร้อมขีดจะเป็น 36 ตัวอักษร การเก็บลงคอลัมน์ประเภทข้อความทำให้ขนาดเพิ่มขึ้นกว่าเท่าตัว และใน MySQL คอลัมน์ CHAR(36) ที่ใช้ utf8mb4 จะจองพื้นที่ถึง 4 ไบต์ต่อหนึ่งตัวอักษร

ใน InnoDB ค่า primary key ถูกคัดลอกเข้าไปอยู่ใน secondary index ทุกตัว ส่วนต่างนั้นจึงถูกคูณเพิ่มตามจำนวน index บนตาราง

ควรใช้ชนิดข้อมูลที่มีมาให้ uuid ใน PostgreSQL, BINARY(16) ใน MySQL และ uniqueidentifier ใน SQL Server

เทียบกันชัด ๆ

v4 v7
โครงสร้าง ค่าสุ่ม 122 บิต timestamp 48 บิต + อีก 74 บิต
เรียงตามเวลาได้ ไม่ได้ ได้
รูปแบบการ insert ลง index สุ่ม เรียงลำดับ
เปิดเผยเวลาที่สร้าง ไม่ ใช่
เหมาะเป็นค่าเริ่มต้นสำหรับ รหัสที่คนภายนอกเห็น primary key ในฐานข้อมูล

สรุปสั้น ๆ ใช้ v7 สำหรับ key ในฐานข้อมูล และใช้ v4 สำหรับรหัสที่คนแปลกหน้าจะได้เห็น ถ้าตัดสินใจไม่ได้จริง ๆ ให้ใช้ v7 เป็นค่าเริ่มต้นสำหรับงานใหม่ เพราะพฤติกรรมของ index เป็นต้นทุนจริงที่เกิดซ้ำทุกวัน ส่วน timestamp ที่รั่วออกไปมักไม่ใช่

สร้าง UUID ได้ทั้งสองเวอร์ชัน ทีละหลายรายการ ทำงานในเบราว์เซอร์ของคุณทั้งหมด