บทความ › เซิร์ฟเวอร์และ 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

กลาง อ่าน 9 นาที 23 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • 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 ผลคือ

  1. ทุก insert แตะหน้า leaf สุ่ม ถ้า index ใหญ่กว่า shared_buffers หน้านั้นมักไม่อยู่ในแรม ต้องอ่านจากดิสก์ก่อนเขียน
  2. หน้าเต็มก็แยกครึ่ง (page split) หน้าส่วนใหญ่จึงไม่เคยเต็ม index ใหญ่กว่าที่ควร
  3. WAL โตขึ้น ค่าเริ่มต้น full_page_writes = on การแก้หน้าใดครั้งแรกหลัง checkpoint ต้องเขียนทั้งหน้า 8 KB ลง WAL ยิ่งแตะหน้าต่างกันมาก ยิ่งเขียนทั้งหน้าบ่อย

RFC 9562 เขียนตรง ๆ ว่า UUID ที่ไม่เรียงตามเวลาอย่าง v4 มี database-index locality ไม่ดี

uuidv7() ต่างตรงไหน

โครงของ UUIDv7 ตาม RFC 9562

บิตข้อมูล
0-47Unix timestamp เป็นมิลลิวินาที (48 บิต)
48-51version = 7
52-6312 บิต (PostgreSQL ใช้เป็นเวลาระดับต่ำกว่ามิลลิวินาที)
64-65variant
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)

UUID v7 (เรียงตามเวลา)ต่อท้ายหน้าขวาสุดของ indexเติมได้ถึง fillfactor 90%~298 MB ที่ 10 ล้านแถวUUID v4 (สุ่ม)แทรกลงหน้าไหนก็ได้หน้าเต็มเฉลี่ย ~69%~389 MB ที่ 10 ล้านแถว
เวลาอยู่หน้าสุด ค่าใหม่จึงไปต่อท้าย index หน้าเดียว (เลขคณิต ไม่ใช่ benchmark)

ตัวอย่างคำนวณ: 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

แหล่งอ้างอิง

คำถามที่พบบ่อย

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 ข้อมูลข้ามฐาน

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#Serverขั้นสูง

ย้าย schema PostgreSQL ไม่ต้องปิดระบบ: expand, migrate, contract

เปลี่ยนชื่อคอลัมน์ใน 3 deploy โดยไม่มีใครเห็น error ล็อกตัวไหนต้องระวัง CREATE INDEX CONCURRENTLY, NOT VALID แล้ว VALIDATE, lock_timeout และ backfill ทีละก้อน

อ่าน 11 นาที
#Serverกลาง

เก็บข้อมูลตามเวลาด้วย TimescaleDB บีบอัด สรุปรายชั่วโมง และเมื่อไหร่ห้ามลบ

ข้อมูลที่เข้าทุกไม่กี่วินาทีโตจนฐานข้อมูลช้า TimescaleDB แก้ด้วย hypertable บีบอัดของเก่า สรุปรายชั่วโมงอัตโนมัติ และเมื่อไหร่ไม่ควรตั้งให้ลบ พร้อม SQL จริง

อ่าน 8 นาที
#Serverกลาง

รันฐานข้อมูล cache แอป และ HTTPS บนเครื่อง 2GB ให้รอด ตั้งเพดานทุกอย่างที่โตได้

เครื่องเล็กไม่ได้พังเพราะเล็ก แต่พังเพราะมีอะไรสักอย่างโตไม่มีเพดาน ค่าตั้ง PostgreSQL Redis log ของ Docker และ swap ที่ใช้จริงบนเครื่อง 2GB พร้อมเหตุผลของแต่ละตัว

อ่าน 8 นาที