Partitioning
table บาง table โตขึ้นไม่มีขีดจำกัด table ของ event, log line, หรือค่าจาก sensor สามารถถึงพันล้าน row ได้ และเมื่อขนาดเท่านั้นทุกอย่างก็ยากขึ้น: index พองตัว, งาน maintenance รันเป็นชั่วโมง, และการลบข้อมูลเก่าก็ lock table ไว้นานแสนนาน Partitioning จัดการเรื่องนี้ด้วยการแบ่ง table เชิงตรรกะขนาดมหึมาหนึ่ง table ออกเป็น table ทางกายภาพที่เล็กกว่าหลาย table โดยที่ query ของคุณยังเห็นเป็น table เดียวเหมือนเดิม
Declarative partitioning ของ PostgreSQL ให้คุณกำหนดกฎการแบ่งเพียงครั้งเดียว คุณเขียนและอ่านจาก table parent ตามปกติ และ server จะ route แต่ละ row ไปยัง partition ที่ถูกต้องให้เองเบื้องหลัง
table เชิงตรรกะหนึ่ง partition จำนวนมาก
หัวข้อที่มีชื่อว่า “table เชิงตรรกะหนึ่ง partition จำนวนมาก”คุณประกาศ table parent ให้เป็นแบบ partitioned แล้วกำหนด partition key ให้ — column ที่ค่าในนั้นตัดสินว่า row หนึ่งไปอยู่ partition ไหน ตัว parent เองไม่เก็บข้อมูลเลย แต่ทำหน้าที่เป็นชั้น routing อยู่เหนือ child partition
flowchart TD Parent[events - logical parent table] --> P1[events_2024_q1] Parent --> P2[events_2024_q2] Parent --> P3[events_2024_q3] Parent --> P4[events_2024_q4]
มีกลยุทธ์ partitioning สามแบบ เลือกตามวิธีที่ key แมปไปยัง partition
| กลยุทธ์ | row ถูกกำหนดโดย… | เหมาะสำหรับ |
|---|---|---|
| RANGE | ค่าที่ตกอยู่ในช่วงหนึ่ง เช่นช่วงวันที่ | ข้อมูล time-series ที่ partition ตามเดือน |
| LIST | key ที่ตรงกับค่าหนึ่งในชุดค่าที่กำหนดตายตัว | การแบ่งตาม region, status, หรือ tenant |
| HASH | hash ของ key ที่กระจายอย่างสม่ำเสมอข้าม bucket | การกระจายอย่างสม่ำเสมอเมื่อไม่มีช่วงตามธรรมชาติ |
range partitioning table time-series
หัวข้อที่มีชื่อว่า “range partitioning table time-series”กรณีที่พบบ่อยที่สุดคือ range partitioning ตามเวลา คุณประกาศ parent ด้วย PARTITION BY RANGE แล้วสร้าง child partition หนึ่งตัวต่อหนึ่งช่วงเวลา
-- Parent table, partitioned by the created_at timestampCREATE TABLE events ( id bigint GENERATED ALWAYS AS IDENTITY, created_at timestamptz NOT NULL, kind text NOT NULL, payload jsonb) PARTITION BY RANGE (created_at);
-- One partition per quarter; the bounds are inclusive lower, exclusive upperCREATE TABLE events_2024_q1 PARTITION OF events FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE events_2024_q2 PARTITION OF events FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');การ insert เข้า events จะลง row ใน partition ที่ตรงกันโดยอัตโนมัติ
-- This row routes itself into events_2024_q1INSERT INTO events (created_at, kind, payload)VALUES ('2024-02-14', 'signup', '{"plan": "pro"}');ข้อสังเกตเกี่ยวกับ partition key: ต้องเป็นส่วนหนึ่งของ primary key บน table ที่ partition ไว้ จึงเป็นเหตุผลที่ตัวอย่างใช้ GENERATED ALWAYS AS IDENTITY โดยไม่มี PRIMARY KEY แบบเดี่ยวบน id เพียงตัวเดียว
list และ hash partitioning
หัวข้อที่มีชื่อว่า “list และ hash partitioning”PARTITION BY LIST กำหนด row ด้วยค่า key ที่ตรงกันพอดี — สะดวกเมื่อมีหมวดหมู่ตามธรรมชาติอยู่
CREATE TABLE customers ( id bigint GENERATED ALWAYS AS IDENTITY, region text NOT NULL, name text NOT NULL) PARTITION BY LIST (region);
CREATE TABLE customers_emea PARTITION OF customers FOR VALUES IN ('eu', 'me', 'africa');
CREATE TABLE customers_amer PARTITION OF customers FOR VALUES IN ('us', 'ca', 'latam');PARTITION BY HASH กระจาย row อย่างสม่ำเสมอข้าม bucket จำนวนที่กำหนดตายตัวเมื่อไม่มี range หรือ list ใดที่สมเหตุสมผล
CREATE TABLE sessions ( id bigint NOT NULL, token text NOT NULL) PARTITION BY HASH (id);
CREATE TABLE sessions_0 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_1 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 1);partition pruning
หัวข้อที่มีชื่อว่า “partition pruning”ผลตอบแทนที่แท้จริงคือ partition pruning: เมื่อ clause WHERE ของ query กรองบน partition key planner จะข้าม partition ทุกตัวที่ไม่อาจมี row ที่ตรงกัน และ scan เฉพาะตัวที่เกี่ยวข้องเท่านั้น
-- The planner reads only events_2024_q1, not the whole tableEXPLAINSELECT count(*) FROM eventsWHERE created_at >= '2024-02-01' AND created_at < '2024-03-01';แผนแสดงเพียง partition ของไตรมาสเดียวที่ถูก scan ถ้าคุณกลับไป query โดยไม่กล่าวถึง created_at planner ก็ไม่มีทางจำกัดให้แคบลงและต้อง scan ทุก partition — ซึ่งช้ากว่า table เดียวที่ทำ index ดี Pruning คือจุดประสงค์ทั้งหมด และ pruning ต้องการ partition key ใน clause WHERE
partitioning ช่วยเมื่อใด
หัวข้อที่มีชื่อว่า “partitioning ช่วยเมื่อใด”Partitioning ไม่ได้มาฟรี: มี table ให้จัดการมากขึ้น มีชั้น routing ในทุกการเขียน และ query ที่มองข้าม key อาจช้าลงได้ จะคุ้มค่าก็ต่อเมื่อ:
- table ใหญ่จริง ๆ — คิดถึงหลายร้อยล้าน row ขึ้นไป
- query ส่วนใหญ่กรองบนมิติเดียวที่คุณ partition ตามได้ ซึ่งโดยปกติคือเวลา
- คุณทิ้งข้อมูลเก่าเป็นกลุ่มก้อน การ detach หรือ drop ทั้ง partition แทบจะทันที เทียบกับ
DELETEที่ scan และ lock หลายล้าน row
ไม่กี่ล้าน row พร้อม index ที่ดีไม่ต้องการ partitioning การรีบใส่ partitioning เร็วเกินไปคือการซื้อความซับซ้อนมาเปล่า ๆ
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- เลือก partition key ให้ตรงกับ query pattern หลักของคุณ ถ้าคุณ query ตามวันที่ ก็ partition ตามวันที่เพื่อให้ pruning ทำงานได้
- Pruning เกิดขึ้นเฉพาะเมื่อ partition key ปรากฏใน clause
WHEREเท่านั้น query ที่กรองบนสิ่งอื่นจะ scan ทุก partition - สร้าง partition ในอนาคตไว้ล่วงหน้า หรือทำให้เป็นอัตโนมัติ การ insert ที่ไม่มี partition ตรงกันจะล้มเหลว เว้นแต่คุณจะมี default partition
- การทิ้งข้อมูลเก่าด้วยการ detach หรือ drop ทั้ง partition เร็วและเบากว่า
DELETEแบบทีละ row อย่างมหาศาล - ทำ index ที่ระดับ partition ไม่ใช่แค่ที่ parent ถ้าสร้าง index ไว้บน parent ที่ partition แล้ว PostgreSQL จะสร้างต่อลงไปในทุก partition ให้อัตโนมัติ เป็นวิธีง่าย ๆ ในการรักษาให้สอดคล้องกัน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| Partitioning table ขนาดใหญ่ | maintenance เร็วขึ้น (vacuum/rebuild index ต่อ partition), drop ข้อมูลเก่าเป็นกลุ่มก้อนได้เกือบทันที | query ต้องออกแบบให้ตรงกับ partition key, มี table ให้จัดการมากขึ้น |
| ไม่ partition (table เดียว) | query และ schema เรียบง่าย ไม่ต้องคิดเรื่อง partition key | maintenance ช้าลงเรื่อย ๆ เมื่อ table โต, การลบข้อมูลเก่าต้อง DELETE ทีละ row |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- Partition table ตั้งแต่ยังเล็ก ทั้งที่ยังไม่ถึงขนาดที่ต้องการ — ซื้อความซับซ้อนมาโดยไม่ได้ประโยชน์ รอจนกว่า table จะใหญ่จริง ๆ (หลายร้อยล้าน row) ก่อนค่อย partition
- เลือก partition key ที่ query ส่วนใหญ่ไม่ได้กรองด้วย — planner ทำ prune ไม่ได้และต้อง scan ทุก partition อยู่ดี เลือก key ให้ตรงกับ query pattern จริง
- ลืมสร้าง default partition — การ insert row ที่ไม่ตรงกับ partition ใดเลยจะล้มเหลวทันที ถ้าไม่มี default partition รองรับไว้
💡 ตัวอย่างจากของจริง
Instagram — partition/shard table ขนาดมหึมาอย่าง post หรือ event ตามเวลา หรือ user ID เพื่อให้ drop หรือ archive partition เก่าเป็น operation ระดับ metadata ที่เร็วมาก แทนที่จะต้อง
DELETEทีละ rowTime-series/event logging platform — ระบบเก็บ log หรือ metric จำนวนมากมักใช้ range partitioning ตามวันหรือเดือน เพื่อให้ retention policy ทำได้ด้วยการ drop partition แทนการ delete แบบสแกนทั้ง table