บทความ › เซิร์ฟเวอร์และ DevOps
PostgreSQL 18: ใช้ uuidv7() แทน UUID v4 และเปิด async I/O ให้ถูก
PostgreSQL 18 มี uuidv7() ที่เรียงตามเวลา ทำให้ B-tree index เล็กและร้อนน้อยกว่า v4 และมี async I/O ผ่าน io_method เลือก worker หรือ io_uring พร้อมตัวอย่างคำนวณขนาด index
- uuidv7() ใส่เวลาเป็นมิลลิวินาทีไว้ 48 บิตแรก ค่าใหม่จึงต่อท้าย index เสมอ แทนที่จะกระจายสุ่มแบบ v4
- เลขคณิตบอกว่า primary key 10 ล้านแถวแบบ v7 ใช้ leaf ราว 298 MB เทียบ v4 ราว 389 MB และ v7 แตะหน้าร้อนหน้าเดียวตอน insert
- async I/O ใน 18 เร่งการอ่าน (seq scan, bitmap heap scan, vacuum) ค่าเริ่มต้น io_method=worker ส่วน io_uring ต้องเป็น Linux ที่ build ด้วย liburing
- เปลี่ยน default ของคอลัมน์ id จาก v4 เป็น v7 ได้ทันที แถวเดิมไม่ต้องแก้
PostgreSQL 18 ออกเมื่อ 25 กันยายน 2025 มีสองอย่างที่แอปทั่วไปได้ประโยชน์โดยแทบไม่ต้องแก้โค้ด ฟังก์ชัน uuidv7() ที่ทำให้ primary key แบบ UUID ไม่ทำร้าย index อีก และระบบ asynchronous I/O (AIO) ที่ทำให้การอ่านจากดิสก์ส่งคำขอพร้อมกันหลายตัวแทนที่จะรอทีละตัว บทความนี้อธิบายว่าทำงานยังไง ตั้งค่าอะไร และคำนวณให้ดูว่าต่างกันแค่ไหน
ข้อมูลตรวจกับเอกสาร PostgreSQL 18 (ฟังก์ชัน UUID, runtime config, release notes) และ RFC 9562 ณ ตุลาคม 2026 ตัวเลขขนาด index เป็นเลขคณิตที่แสดงวิธีคิดไว้ ไม่ใช่ benchmark
UUID v4 ทำอะไรกับ B-tree
gen_random_uuid() (ใน 18 มีชื่อเล่น uuidv4()) สุ่มทั้ง 122 บิต ค่าใหม่แต่ละตัวจึงไปลงตรงไหนก็ได้ใน index primary key ผลคือ
- ทุก insert แตะหน้า leaf สุ่ม ถ้า index ใหญ่กว่า
shared_buffersหน้านั้นมักไม่อยู่ในแรม ต้องอ่านจากดิสก์ก่อนเขียน - หน้าเต็มก็แยกครึ่ง (page split) หน้าส่วนใหญ่จึงไม่เคยเต็ม index ใหญ่กว่าที่ควร
- WAL โตขึ้น ค่าเริ่มต้น
full_page_writes = onการแก้หน้าใดครั้งแรกหลัง checkpoint ต้องเขียนทั้งหน้า 8 KB ลง WAL ยิ่งแตะหน้าต่างกันมาก ยิ่งเขียนทั้งหน้าบ่อย
RFC 9562 เขียนตรง ๆ ว่า UUID ที่ไม่เรียงตามเวลาอย่าง v4 มี database-index locality ไม่ดี
uuidv7() ต่างตรงไหน
โครงของ UUIDv7 ตาม RFC 9562
| บิต | ข้อมูล |
|---|---|
| 0-47 | Unix timestamp เป็นมิลลิวินาที (48 บิต) |
| 48-51 | version = 7 |
| 52-63 | 12 บิต (PostgreSQL ใช้เป็นเวลาระดับต่ำกว่ามิลลิวินาที) |
| 64-65 | variant |
| 66-127 | สุ่ม 62 บิต |
เพราะเวลาอยู่หน้าสุด ค่าที่สร้างทีหลังจึงมากกว่าค่าก่อนหน้า ค่าใหม่ไปต่อท้าย index ที่หน้า leaf ขวาสุด หน้าเดียวที่ร้อนและอยู่ในแรมเสมอ
-- PostgreSQL 18
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
customer text NOT NULL,
total_satang bigint NOT NULL
);
INSERT INTO orders (customer, total_satang) VALUES ('A', 125000) RETURNING id;
-- อ่านเวลาที่ฝังอยู่ และเวอร์ชันของ UUID
SELECT id, uuid_extract_timestamp(id), uuid_extract_version(id) FROM orders;
uuidv7() รับพารามิเตอร์ shift interval ได้ด้วย เช่น uuidv7(interval '-1 hour') เลื่อนเวลาที่ฝัง ใช้ตอนเติมข้อมูลย้อนหลังให้ id เรียงตามเวลาจริงของข้อมูล (เงินเก็บเป็นสตางค์แบบ bigint ตามเหตุผลในบทความเงินห้ามใช้ float)
ตัวอย่างคำนวณ: primary key 10 ล้านแถว
คิดเฉพาะหน้า leaf ของ index บน uuid (หน้าละ 8,192 ไบต์)
ขนาดต่อรายการ
- header ของ index tuple 8 ไบต์ + ค่า uuid 16 ไบต์ = 24 ไบต์ (ลงตัว 8 ไบต์พอดี)
- line pointer 4 ไบต์ → รวม 28 ไบต์ต่อรายการ
ที่ว่างต่อหน้า
- 8,192 − 24 (page header) − 16 (ข้อมูลพิเศษของ B-tree) = 8,152 ไบต์
- เต็ม 100% ได้ 8,152 ÷ 28 ≈ 291 รายการ
v7 (ต่อท้ายเสมอ) เอกสารบอกว่า B-tree เติมหน้า leaf ถึง fillfactor เมื่อเพิ่มค่าที่มากที่สุดทางขวา ค่าเริ่มต้น 90
- 291 × 0.90 ≈ 262 รายการต่อหน้า
- 10,000,000 ÷ 262 ≈ 38,168 หน้า × 8 KB ≈ 298 MB
v4 (สุ่ม) ทฤษฎี B-tree ที่ insert แบบสุ่มแล้วแยกหน้าครึ่ง ได้ความเต็มเฉลี่ยราว 69% (ln 2)
- 291 × 0.69 ≈ 201 รายการต่อหน้า
- 10,000,000 ÷ 201 ≈ 49,751 หน้า × 8 KB ≈ 389 MB
ต่างกันราว 91 MB หรือ v4 ใหญ่กว่า ~30% แต่ตัวที่หนักกว่าขนาดคือ หน้าที่ต้องอยู่ในแรม ถ้า shared_buffers เป็นค่าเริ่มต้น 128 MB v7 ต้องการแค่หน้าขวาสุดที่ร้อน ส่วน v4 ทุก insert สุ่มหน้าจาก 389 MB โอกาสเจอหน้าใน shared_buffers จึงไม่เกิน 128 ÷ 389 ≈ 33% ที่เหลือต้องไปหาจาก page cache ของ OS หรือดิสก์
วัดกับเครื่องตัวเองได้ด้วย pgstattuple
CREATE EXTENSION IF NOT EXISTS pgstattuple;
CREATE TABLE t4 (id uuid PRIMARY KEY DEFAULT uuidv4(), n int);
CREATE TABLE t7 (id uuid PRIMARY KEY DEFAULT uuidv7(), n int);
INSERT INTO t4 (n) SELECT g FROM generate_series(1, 1000000) g;
INSERT INTO t7 (n) SELECT g FROM generate_series(1, 1000000) g;
SELECT 'v4' AS kind, leaf_pages, avg_leaf_density FROM pgstatindex('t4_pkey')
UNION ALL
SELECT 'v7', leaf_pages, avg_leaf_density FROM pgstatindex('t7_pkey');
ตามเลขคณิตข้างบน 1 ล้านแถวควรได้ leaf ราว 3,800 หน้าสำหรับ v7 และราว 5,000 หน้าสำหรับ v4 ถ้าตัวเลขของคุณต่างไปมาก ดูว่ามี index อื่นหรือ fillfactor ที่ตั้งไว้ไหม
ย้ายจาก v4 มา v7
ไม่ต้องแก้แถวเดิม UUID ทุกเวอร์ชันเก็บในชนิด uuid เดียวกัน เปลี่ยนแค่ค่า default
ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7();
คำสั่งนี้แก้แค่ metadata ไม่เขียนแถวใหม่ แถวเก่าเป็น v4 กระจายอยู่ แถวใหม่เป็น v7 ต่อท้าย ถ้าแอปสร้าง id เองฝั่งโค้ด ต้องเปลี่ยนไลบรารีฝั่งนั้นด้วย ถ้ามีการเปลี่ยน schema ใหญ่กว่านี้ระหว่างระบบรันอยู่ ใช้ขั้นตอนในย้าย schema PostgreSQL ไม่ต้องปิดระบบ
v7 ช่วย index ไม่ได้ช่วยความลับ id ที่โชว์ให้คนนอกเห็นและเวลาสร้างเป็นเรื่องอ่อนไหว ยังควรเป็น v4
Async I/O: io_method
ก่อน 18 PostgreSQL อ่านหน้าจากดิสก์แบบส่งคำขอแล้วรอทีละคำขอ (พึ่ง readahead ของ OS ช่วย) 18 เพิ่มระบบ AIO ให้ backend ส่งคำขออ่านหลายตัวต่อคิวพร้อมกัน ใน 18 ใช้กับ sequential scan, bitmap heap scan และ vacuum ประกาศของโปรเจกต์บอกว่า benchmark เห็นเร็วขึ้น สูงสุด 3 เท่าในบางสถานการณ์ ตอนอ่านจาก storage
ค่า io_method | ทำงานยังไง | ใช้เมื่อ |
|---|---|---|
worker (ค่าเริ่มต้น) | ส่งงาน I/O ให้ process io_workers (ค่าเริ่มต้น 3) | ทุกระบบ เริ่มที่นี่ |
io_uring | ใช้ io_uring ของ Linux kernel ตรง ๆ | Linux ที่ PostgreSQL build ด้วย --with-liburing / -Dliburing |
sync | แบบเดิม อ่านแบบ synchronous | เทียบผล หรือเจอปัญหา |
io_method ตั้งได้ตอน start เท่านั้น เปลี่ยนแล้วต้อง restart
SHOW io_method;
SHOW io_workers;
SHOW effective_io_concurrency; -- 18 เปลี่ยนค่าเริ่มต้นเป็น 16
# ตรวจว่าแพ็กเกจที่ติดตั้ง build ด้วย liburing หรือไม่ (แพ็กเกจที่ build ด้วย configure)
pg_config --configure | grep -o with-liburing
ระหว่าง query ทำงาน ดู I/O ที่ค้างอยู่ได้จาก view ใหม่ pg_aios
ได้ประโยชน์เมื่อไร AIO ช่วยตอนต้องอ่านจาก storage จริง และ storage มี latency ต่อคำขอ เช่น network disk ของ cloud ถ้าข้อมูลทั้งหมดอยู่ใน shared_buffers แล้ว ไม่มีอะไรให้เร่ง และใน 18 ยังไม่ครอบทุกชนิดการอ่าน เช่น index scan ปกติยังไม่อยู่ในรายการที่ประกาศ
ข้อผิดพลาดที่เจอบ่อย
- ใช้ uuidv7 เป็น token ลับ เช่น ลิงก์รีเซ็ตรหัสผ่าน ส่วนสุ่มเหลือ 62 บิตและเวลาเดาได้ งานแบบนี้ใช้ค่าสุ่มล้วนที่ยาวพอ
- เก็บ UUID เป็น text 36 ตัวอักษร ใหญ่กว่า
uuid(16 ไบต์) เกินสองเท่า และเทียบช้ากว่า - ตั้ง io_method=io_uring บนแพ็กเกจที่ไม่ได้ build ด้วย liburing เช็คก่อน และเปลี่ยนตอนมีเวลาดูแลเพราะต้อง restart
- คาดว่า AIO จะเร่งทุก query วัดด้วย
EXPLAIN (ANALYZE, BUFFERS)ก่อนและหลัง ถ้า query อ่านจากแรมอยู่แล้วจะไม่เห็นความต่าง - ลืมว่าแอปก็สร้าง id ตั้ง default ใน DB เป็น v7 แต่ ORM ยังสร้าง v4 ฝั่งแอป index ก็ยังสุ่มเหมือนเดิม
uuidv7() คือการเปลี่ยนหนึ่งบรรทัดที่คุ้มที่สุดใน 18 สำหรับตารางที่ใช้ UUID เป็น primary key
แหล่งอ้างอิง
- PostgreSQL 18 Released! — postgresql.org
- UUID Functions (PostgreSQL 18) — postgresql.org
- Resource Consumption: io_method, io_workers — postgresql.org
- Release 18 notes — postgresql.org
- CREATE INDEX: fillfactor — postgresql.org
- Write Ahead Log: full_page_writes — postgresql.org
- RFC 9562: Universally Unique IDentifiers (UUIDs) — rfc-editor.org
คำถามที่พบบ่อย
uuidv7 เปิดเผยเวลาที่สร้างแถวไหม
เปิดเผย 48 บิตแรกคือ Unix timestamp เป็นมิลลิวินาที ใครเห็น id ก็รู้ว่าแถวสร้างเมื่อไร ใช้ uuid_extract_timestamp() อ่านได้เลย RFC 9562 บอกว่าถ้าใช้ UUID เพื่องานด้านความปลอดภัยให้ใช้ v4 ถ้าเวลาที่สร้างเป็นข้อมูลอ่อนไหว ใช้ v4 กับ id ที่โชว์ภายนอก
ต้องตั้ง io_method=io_uring ไหม
ไม่จำเป็น ค่าเริ่มต้น worker ใช้ได้ทุกระบบและได้ประโยชน์ส่วนใหญ่แล้ว io_uring ใช้ได้เฉพาะ Linux และ PostgreSQL ต้อง build ด้วย liburing วัดกับงานจริงก่อนเปลี่ยน และต้อง restart
ใช้ bigint identity ไม่ดีกว่าเหรอ
ถ้ามีฐานข้อมูลเดียวเป็นคนออกเลข bigint เล็กกว่า (8 ไบต์ เทียบ 16) และเรียงอยู่แล้ว UUID เหมาะเมื่อต้องสร้าง id ก่อนถึงฐานข้อมูล เช่น ฝั่ง client, หลาย service หรือ merge ข้อมูลข้ามฐาน
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ