Postgres Partition Range by timestamp based UUIDs
ช่วงนึงที่ผมทำงานในทีม มักจะได้ยินคนพูดบ่อยๆ ว่าเวลาเราออกแบบ Database ใหญ่ๆ อย่าง Oracle หรือ Postgres สิ่งสำคัญที่ห้ามมองข้ามเลยคือเรื่อง…
Postgres Partition Range by timestamp based UUIDs

ช่วงนึงที่ผมทำงานในทีม มักจะได้ยินคนพูดบ่อยๆ ว่าเวลาเราออกแบบ Database ใหญ่ๆ อย่าง Oracle หรือ Postgres สิ่งสำคัญที่ห้ามมองข้ามเลยคือเรื่อง Query Performance ซึ่งความเร็วของมัน จะขึ้นอยู่กับกลยุทธ์การแบ่งเก็บข้อมูลเป็นหลัก โดยปกติเขาก็จะไล่กันประมาณ:
Index -> Partition -> Cold storage
ส่วนตัวผมเองเคยเจ็บมาครบทุกสเต็ปแล้ว (ถึงแม้ส่วนใหญ่จะฟัดกับ Oracle มา) แต่พอต้องมาเจองานท้าทายชิ้นใหม่ที่ต้องใช้ Postgres ซึ่งถือว่าใหม่สำหรับผม เพราะกระบวนการเบื้องหลังและคำสั่งบางอย่างจะไม่เหมือนเดิม แต่โชคดีที่ ‘หลักการ’ คิดยังคล้ายๆ กันอยู่
คนเก่งๆ หลายคนมักบอกกับผมเสมอว่า “ถ้าเราสามารถคาดเดาได้ว่า มีแนวโน้มจะเกิดขึ้น เราก็ควรจะคิดเผื่อ หรือทำเผื่อไปเลย ดีกว่ามาแก้ทีหลัง มันก็เหมือนหนี้ทางเทคนิค ที่เราแอบซุกมันไว้นั้นแหละ”
งั้นเรามาดู ‘ท่าปกติ’ ที่คนส่วนใหญ่ชอบใช้กันก่อนครับ สมมติเรามีตารางหนึ่งตาราง แล้วเราตั้ง PK (Primary Key) เป็น ID ประเภท UUIDv4 แบบให้มันสุ่มสร้างอัตโนมัติ (Auto-generate) มองแวบแรกมันก็ดูโอเคดีใช่ไหมล่ะ? เพราะจุดเด่นของ UUIDv4 คือมันถูกออกแบบมาให้ Gen จากที่ไหนบนโลกก็ได้ โดยที่โอกาสที่ค่ามันจะมาซ้ำกันเนี่ย ต่ำซะจนแทบจะเป็นไปไม่ได้เลย
ฟังมาถึงตรงนี้ คงเริ่มนึกภาพออกใช่ไหมครับ ว่ามัน Random!! หมายความว่า ถ้ามันถูกสร้างขึ้นมาใหม่ มันจะถูกแทรกตรงไหนไม่รู้ ถ้าเราทำ Index ตามภาพ

และเมื่อมันมีข้อมูลมากขึ้นเรื่อยๆ ในระดับ 50m+ rows ตัว index ก็อาจจะไม่เพียงพอ ให้ query เราทำงานได้เร็ว เพราะสุดท้ายมันต้องสแกนผ่าน B-Tree จำนวนมาก และเกิด สิ่งที่เรียกว่า Page Fragmentation กับ Index Bloat ตามมา
Page Fragmentation / Index Bloat คือ?
มันคือการแบ่ง Page ของการทำ logic B-Tree ประมาณว่า ฐานข้อมูลเรามีกล่องใบหนึ่งที่ตัวกล่องระบุว่า กล่องใบนี้ใส่หนังสือชื่อขึ้นต้นด้วย A-D ซึ่งในกล่องนั้น สามารถมีหนังสือได้ 4 เล่ม โดยมี Ant ,Bird ,Cat ,Dog
ต่อมา มีหนังสือเล่มใหม่ ชื่อ Anna เข้ามา ทำให้กล่องต้องถูกแบ่งใหม่ เป็น
- ใบที่ 1 ชื่อ A-B ที่มีหนังสือ Ant ,Anna ,Bird (เหลือที่ว่าง 1 ช่อง)
- ใบที่ 2 ชื่อ C-D ที่มี Cat ,Dog (เหลือที่ว่าง 2 ช่อง)
จะเห็นได้ว่า ทั้งกล่อง 1 กับกล่องสองมันมีช่องว่าง อย่างละ 1 กับ 2 ตามลำดับ สิ่งนี้เรียกว่า Page Fragmentation
อธิบายง่ายๆ ถ้าหนังสือมี 100 เล่ม มันควรจะมีแค่ 25 กล่อง แต่นี่มันโอกาสถึง 50 กล่อง!! ซึ่งเจ้ากล่องที่เกินๆมาจะถูกเรียกว่า Index Bloat
เมื่อเกิด Page Fragmentation ทำให้เกิด Buffer Pool แบบไม่ยั้งคิดขึ้นอีก
Buffer Pool Thrashing มันคืออะไรฟังดูน่ากลัวจัง?
โดยปกติแล้ว ที่ฐานข้อมูลทำงานได้เร็ว นั้นเพราะมันเก็บข้อมูลที่ ‘hot’ ไว้ใน RAM (Buffer Pool) คือถ้าเราเพิ่มข้อมูลที่เรียงลำดับ (sequential) อย่างพวก Timestamp หรือ Auto-Increment มันจะไปต่อหลัง B-Tree ซึ่งใช้พื้นที่เล็กๆ เท่านั้น
แต่… กับ UUIDv4 ที่เมื่อเพิ่มจะไปแทรกตรงไหนก็ไม่รู้ นั้นบังคับให้ Database ต้องดึงข้อมูล Index ก่อนๆ รวมกับของใหม่ ขึ้นมาจาก disk วางไว้ที่ RAM อย่างต่อเนื่อง นี่ไม่รวมว่าคุณจะต้องเสีย index Page อื่นๆไปจาก RAM ด้วยนะ
Buffer Pool Thrashing นี่แหละ เป็นเหตุผลหลักๆที่ทำให้เกิดค่าใช้จ่ายแฝงเพิ่มเข้ามา ฮา~
งั้นเรากลับไปใช้ Auto-Increment ดีไหม?
คำตอบคือ… ไม่ เพราะมันมีข้อจำกัดหลายๆอย่าง
- ความปลอดภัย เพราะมันเดาง่ายเกินไป สมมติถ้าเราทำ http method
GETแล้วรับเป็น (/users/100) ถ้างั้น (/users/101) ก็น่าจะถูกเดาได้ไม่ยาก - อย่างที่บอก
UUIDv4มันแก้เรื่อง ทำให้ id สามารถ Gen ที่ไหนก็ได้ ส่วน Auto-Increment ต้องรอ Database Gen เราจะกลับไปหาปัญหาเดิมทำไม?
เพราะงั้น … จึงเกิด
UUID Version 7 (The New Standard)
บอกก่อนว่า ในที่สุด มันก็ถูกกำหนดออกมาเป็นมาตราฐานผ่าน IETF (Internet Engineering Task Force) ผ่าน RFC 9562 Universally Unique IDentifiers (UUIDs), May 2024 หลังจากที่ ใช้ UUIDv4 มาอย่างยาวนานนน
แล้วเรื่องตลกคือ มันไม่ใช่เรื่องใหม่ แต่ Community ของ GO เขาทำกันมาก่อนแล้ว ชื่อ ULID (Universally Unique Lexicographically Sortable Identifier) โดยทั้งสองอย่างอยู่บนหลักการคล้ายๆ กัน คือเพิ่ม timestamp เข้าไปใน 48 bit แรกของ uuid นั้นเอง

Structure: [ 48 bit unix_ts_ms ] - [ 4 bit var ] [ 12 bit random] - [ 2 bit var ] [ 62 bit random ]
ต้องเป็น Postgres version 18 ขึ้นไปถึงจะรองรับ
UUIDv7ไม่งั้นต้องใช้ plug-in ทำแทน
เราสามารถใช้ type UUID (128bit) เดิมของ postgres แทนได้เลย ไม่ต้องแก้ไขอะไร ทีนี่ลำดับของ uuid ที่เป็นปัญหาด้านบนก็ได้ถูกแก้ไข
ยังไม่จบ เพราะบทความเราพูดถึงการสร้าง Partition รูปแบบ Range จากปัญหาด้านบน การใช้ UUIDv4 จะมีปัญหา เมื่อแบ่ง Partition ของ Table ออกมาเป็นส่วนย่อยๆ ทำให้ต้องค้นหาว่า uuid ที่ระบุนั้น ซ้ำกับ row อื่นๆที่อยู่ในแต่ละ Partition ไหม?

นั้นหมายความว่า เราจะเสียทรัพยากรในการ Write ข้อมูลลง Database มากขึ้น แถมตอนดึงข้อมูล เรายังต้องส่ง scope ของเวลาที่เราทำ Partition ลงไปอีก (Partition Scan) และที่สำคัญ การออกแบบ PK จะต้องรวมวันที่เข้าไปด้วย!

ตัวอย่างการสร้าง Table Partition range ด้วย uuid แบบเดิม
CREATE TABLE stock_trn(
id uuid DEFAULT gen_random_uuid() NOT NULL,
product_id varchar(255) NULL,
quantity numeric(18, 2) NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NULL,
deleted_at timestamptz NULL,
CONSTRAINT stock_trn_pkey
PRIMARY KEY (id, created_at) -- รวม created_at เข้าใน PK
) PARTITION BY RANGE (created_at);
แต่ถ้าเราเปลี่ยนใช้ UUIDv7 ปัญหาข้างต้นจะหายไป เพราะเมื่อมีข้อมูลใหม่มา ลำดับมันจะไปอยู่บนสุดเสมอ เพราะ unix_time 48 bit แรก ได้เรียงลำดับให้เราแล้ว
นั้นหมายความว่า เวลาเราเขียน query อย่าง
select * from stock_trn where id = 'xxx';
มันจะเจออยู่ลำดับบนๆอย่างรวดเร็ว แล้วถ้าเป็นส่วนข้อมูลเก่าที่เราต้อง query โดยบอกวันที่ เราก็สามารถ แกะมันออกมาได้ผ่าน Postgres version 18 function uuid_extract_timestamp()

ทีนี้เรามาพูดถึงวิธีสร้างกัน เนื่องจาก postgres ได้ออกฟังก์ชั่นแบบ Build-in ให้เราแค่ uuidv7() กับ uuid_extract_timestamp() https://www.postgresql.org/docs/18/functions-uuid.html
จะเห็นได้ว่า การสร้าง Partition ยังขาดกำหนดช่วงขอบเขตของ Range อยู่ครับ ซึ่งแบบเก่าเราใช้เป็น timestamp แบบนี้
created_at timestamptz DEFAULT now() NOT NULL,
) PARTITION BY RANGE (created_at);
--สร้างด้วย timestamptz กำหนดวัน
CREATE TABLE IF NOT EXISTS stock_trn_y2025_m01
PARTITION OF stock_trn FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
แต่ถ้าเป็น Partition ที่กำหนดขอบเขตด้วย uuidv7 หลักการแยก จะอยู่ที่ id แบบนี้ จะทำยังไง
CREATE TABLE stock_trn(
id uuid DEFAULT gen_random_uuid() NOT NULL,
product_id varchar(255) NULL,
quantity numeric(18, 2) NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NULL,
deleted_at timestamptz NULL,
CONSTRAINT stock_trn_pkey
PRIMARY KEY (id)
) PARTITION BY RANGE (id); --กำหนด range ด้วย id แทน!!
จากจดหมายของ Jonathan Reis คุยกับ David Rowley ทำให้เราได้รู้เรื่องสำคัญ 2 เรื่องคือ
- ยังคงมีการพูดถึงท่าที่ดีที่สุด ซึ่งในจดหมาย เขาแนะนำ 2 ท่า แต่เราจะแสดงแค่ท่าที่ 2
- ยังไม่มีฟังก์ชั่นกลาง (Build-in) สำหรับแปลง timestamp ไปเป็น uuid 12 หลัก เพื่อกำหนดเวลา Range ของ Partition แต่โชคดีที่ในจดหมายบอกถึง ฟังก์ชั่น uuidv7_floor() เอาไว้!!
ฟังก์ชั่น uuidv7_floor() เป็น custom function ที่ต้องเพิ่มเอง ณ วันที่บทความถูกเขียน วันที่ 20–05–2026
มาเริ่มกันเลย เราจะเริ่มด้วยการสร้าง Custom function สำหรับแกะ timestamp ออกเป็นภาษาคนก่อน
CREATE OR REPLACE FUNCTION uuidv7_floor(ts timestamptz)
RETURNS uuid
LANGUAGE sql
IMMUTABLE
AS $$
WITH ms AS (
SELECT floor(extract(epoch FROM ts) * 1000)::bigint AS ms
),
h AS (
SELECT lpad(to_hex(ms), 12, '0') AS h FROM ms
)
SELECT (
substr(h.h,1,8) || '-' ||
substr(h.h,9,4) || '-' ||
'7000' || '-' || -- version 7 + rand_a all zero
'8000' || '-' || -- variant '10' + rest zero
'000000000000' -- zero node
)::uuid
FROM h;
$$;
จากนั้นสร้าง Table ขึ้นมา พร้อมกับ Partition ด้วย id จะสังเกตุได้ว่า uuidv7_floor ช่วยแปลงภาษาคนไปเป็น uuidv7
CREATE TABLE stock_trn(
id uuid DEFAULT uuidv7() NOT NULL,
product_id varchar(255) NULL,
quantity numeric(18, 2) NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NULL,
deleted_at timestamptz NULL,
CONSTRAINT stock_trn_pkey
PRIMARY KEY (id)
) PARTITION BY RANGE (id);
create table stock_trn_y2026_m01 partition of stock_trn for values from
(uuidv7_floor('2026-01-01')) to (uuidv7_floor('2026-02-01'));
--('019b755a-2280-7000-8000-000000000000') TO ('019c14ff-4680-7000-8000-000000000000')
create table stock_trn_y2026_m02 partition of stock_trn for values from
(uuidv7_floor('2026-02-01')) to (uuidv7_floor('2026-03-01'));
--('019c14ff-4680-7000-8000-000000000000') TO ('019ca531-5680-7000-8000-000000000000')
create table stock_trn_y2026_m03 partition of stock_trn for values from
(uuidv7_floor('2026-03-01')) to (uuidv7_floor('2026-04-01'));
--('019ca531-5680-7000-8000-000000000000') TO ('019d44d6-7a80-7000-8000-000000000000')
create table stock_trn_y2026_m04 partition of stock_trn for values from
(uuidv7_floor('2026-04-01')) to (uuidv7_floor('2026-05-01'));
--('019d44d6-7a80-7000-8000-000000000000') TO ('019ddf55-4280-7000-8000-000000000000')
create table stock_trn_y2026_m05 partition of stock_trn for values from
(uuidv7_floor('2026-05-01')) to (uuidv7_floor('2026-06-01'));
--('019ddf55-4280-7000-8000-000000000000') TO ('019e7efa-6680-7000-8000-000000000000')
จากนั้นลอง Insert เราจะพบว่าแต่ละ row ได้แยกไปในแต่ละ Partition โดยไม่จำเป็นต้องกำหนดวันที่แล้ว!
--ไป stock_trn_y2026_m01
INSERT INTO stock_trn (id, product_id, quantity)
VALUES (uuidv7(INTERVAL '-4 month'),'PROD-001', 10.00);
--ไป stock_trn_y2026_m05
INSERT INTO stock_trn (product_id, quantity)
VALUES ('PROD-002', 10.00);
หากต้องการดึงข้อมูลด้วยวันที่ เราก็ดึงผ่านไอดีได้เลย
SELECT * FROM stock_trn
WHERE
id BETWEEN uuidv7_floor('2026-01-01')
AND uuidv7_floor('2026-02-15');

แถม เราสามารถทำ index เพิ่มผ่านตัว Table หลักโดยมันจะกระจายไปยัง Partition ย่อยๆได้เลย นั้นก็นับว่าเป็นความสะดวกสบายอย่างนึงเพราะโดยปกติ Postgres ต้องทำแยกในแต่ละ Patition เอง
CREATE INDEX stock_trn_product_id_id_idx ON stock_trn (product_id, id);
ขอบคุณครับ Happy Coding.
อ้างอิง
메타데이터
- post_id
- 38f7fb3f87b4
- slug
- postgres-partition-range-by-timestamp-based-uuids-38f7fb3f87b4
- url
- https://medium.com/myorder/postgres-partition-range-by-timestamp-based-uuids-38f7fb3f87b4
- canonical_url
- https://medium.com/myorder/postgres-partition-range-by-timestamp-based-uuids-38f7fb3f87b4
- author_url
- https://medium.com/@mang_9162
- status
- ok
- fetched_at
- 2026-06-21 21:05:38