PostgreSQL Partitioning SaaS Architecture — คู่มือฉบับสมบูรณ์ 2026
โดย อ.บอม กิตติทัศน์ เจริญพนาสิทธิ์ | อัปเดต 24 ก.พ. 2026 | อ่าน 16 นาที
- Table Partitioning คืออะไร — ทำไมต้องแบ่ง
- Partition Types — Range, List, Hash
- สร้าง Range Partition — แบ่งตามวันที่
- สร้าง List Partition — แบ่งตาม Tenant
- สร้าง Hash Partition — กระจายเท่าๆ กัน
- Sub-partitioning — ซ้อน Partition
- Partition Pruning — Query เร็วขึ้นอย่างไร
- SaaS Multi-tenant Architecture — 3 แบบ
- Shared Table + Partitioning + RLS
- Row Level Security (RLS) — Tenant Isolation
- Auto-create Partition สำหรับ Tenant ใหม่
- Partition Maintenance — VACUUM, Archive, Drop
- Performance Tuning
- Best Practices และสรุป
Table Partitioning คืออะไร — ทำไมต้องแบ่ง
Table Partitioning คือการแบ่ง Table ใหญ่ออกเป็นหลาย Partition (Sub-table) ที่แต่ละ Partition เก็บข้อมูลตามเงื่อนไขที่กำหนด เช่น แบ่งตามเดือน แบ่งตาม Region แบ่งตาม Tenant ID ข้อมูลถูก INSERT เข้า Partition ที่ถูกต้องอัตโนมัติ เมื่อ Query ก็สแกนเฉพาะ Partition ที่เกี่ยวข้อง
ทำไมต้อง Partition? เมื่อ Table มีข้อมูลหลายร้อยล้าน Row ปัญหาที่เกิดคือ Query ช้าเพราะ Full Table Scan, Index ใหญ่จน B-tree ลึกหลายระดับ, VACUUM ใช้เวลานานมาก Block การทำงาน, DELETE ข้อมูลเก่าช้ามากและสร้าง Dead Tuple มหาศาล Partitioning แก้ปัญหาทั้งหมดนี้
Partition Types — Range, List, Hash
| Type | แบ่งตาม | เหมาะกับ | ตัวอย่าง |
|---|---|---|---|
| Range | ช่วงค่า (ตั้งแต่...ถึง...) | Time-series, Log, Order | แบ่งตามเดือน: 2026-01, 2026-02 |
| List | ค่าที่กำหนดชัดเจน | Category, Region, Tenant | แบ่งตาม Country: TH, US, JP |
| Hash | Hash ของ Column | กระจายเท่าๆ กัน | Hash(user_id) แบ่ง 16 Partition |
สร้าง Range Partition — แบ่งตามวันที่
-- สร้าง Parent Table
CREATE TABLE orders (
id BIGSERIAL,
tenant_id UUID NOT NULL,
customer_id UUID NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- สร้าง Partition รายเดือน
CREATE TABLE orders_2026_01 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE orders_2026_02 PARTITION OF orders
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE orders_2026_03 PARTITION OF orders
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- Default Partition สำหรับข้อมูลที่ไม่ตรงกับ Partition ไหนเลย
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
-- Index (สร้างที่ Parent จะ Propagate ไปทุก Partition)
CREATE INDEX idx_orders_tenant ON orders (tenant_id);
CREATE INDEX idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_status ON orders (status, created_at);
สร้าง List Partition — แบ่งตาม Tenant
-- List Partition ตาม Tenant ID
CREATE TABLE documents (
id BIGSERIAL,
tenant_id VARCHAR(50) NOT NULL,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (id, tenant_id)
) PARTITION BY LIST (tenant_id);
-- แต่ละ Tenant มี Partition ของตัวเอง
CREATE TABLE documents_acme PARTITION OF documents
FOR VALUES IN ('acme-corp');
CREATE TABLE documents_globex PARTITION OF documents
FOR VALUES IN ('globex-inc');
CREATE TABLE documents_wayne PARTITION OF documents
FOR VALUES IN ('wayne-enterprises');
-- Default สำหรับ Tenant ใหม่ที่ยังไม่มี Partition
CREATE TABLE documents_default PARTITION OF documents DEFAULT;
-- Query จะ Scan เฉพาะ Partition ของ Tenant นั้น
EXPLAIN ANALYZE
SELECT * FROM documents WHERE tenant_id = 'acme-corp';
-- Seq Scan on documents_acme (ไม่ Scan Partition อื่น)
สร้าง Hash Partition — กระจายเท่าๆ กัน
-- Hash Partition กระจายตาม user_id
CREATE TABLE events (
id BIGSERIAL,
user_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);
-- สร้าง 8 Partition
CREATE TABLE events_p0 PARTITION OF events FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE events_p1 PARTITION OF events FOR VALUES WITH (MODULUS 8, REMAINDER 1);
CREATE TABLE events_p2 PARTITION OF events FOR VALUES WITH (MODULUS 8, REMAINDER 2);
CREATE TABLE events_p3 PARTITION OF events FOR VALUES WITH (MODULUS 8, REMAINDER 3);
CREATE TABLE events_p4 PARTITION OF events FOR VALUES WITH (MODULUS 8, REMAINDER 4);
CREATE TABLE events_p5 PARTITION OF events FOR VALUES WITH (MODULUS 8, REMAINDER 5);
CREATE TABLE events_p6 PARTITION OF events FOR VALUES WITH (MODULUS 8, REMAINDER 6);
CREATE TABLE events_p7 PARTITION OF events FOR VALUES WITH (MODULUS 8, REMAINDER 7);
Sub-partitioning — ซ้อน Partition
-- Sub-partition: List (Tenant) → Range (Date)
CREATE TABLE audit_logs (
id BIGSERIAL,
tenant_id VARCHAR(50) NOT NULL,
action VARCHAR(100) NOT NULL,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, tenant_id, created_at)
) PARTITION BY LIST (tenant_id);
-- Tenant Partition → แบ่งย่อยตามเดือน
CREATE TABLE audit_logs_acme PARTITION OF audit_logs
FOR VALUES IN ('acme-corp')
PARTITION BY RANGE (created_at);
CREATE TABLE audit_logs_acme_2026_01 PARTITION OF audit_logs_acme
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE audit_logs_acme_2026_02 PARTITION OF audit_logs_acme
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
Partition Pruning — Query เร็วขึ้นอย่างไร
Partition Pruning คือกระบวนการที่ PostgreSQL ข้าม Partition ที่ไม่เกี่ยวข้องกับ Query เช่น ถ้า orders มี 12 Partition (รายเดือน) และ Query ถามเฉพาะเดือนมกราคม PostgreSQL จะ Scan เฉพาะ orders_2026_01 ข้าม 11 Partition ที่เหลือ Query เร็วขึ้น ~12 เท่า
-- ตรวจว่า Partition Pruning ทำงาน
SET enable_partition_pruning = on; -- Default = on
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE created_at BETWEEN '2026-02-01' AND '2026-02-28'
AND tenant_id = 'acme-corp';
-- ผลลัพธ์ควรเห็น:
-- → Append
-- → Index Scan on orders_2026_02 (เฉพาะ Partition กุมภาพันธ์)
-- ไม่มี Partition อื่นใน Plan = Pruning ทำงานถูกต้อง
SaaS Multi-tenant Architecture — 3 แบบ
| Strategy | Isolation | Complexity | Cost | เหมาะกับ |
|---|---|---|---|---|
| Database per Tenant | สูงสุด | สูงมาก | สูง | Enterprise, Compliance สูง |
| Schema per Tenant | สูง | สูง | ปานกลาง | Tenant จำนวนน้อย (<100) |
| Shared Table + tenant_id | ปานกลาง (+ RLS) | ต่ำ | ต่ำ | SaaS ทั่วไป, Tenant จำนวนมาก |
แบบที่นิยมที่สุดในปี 2026 คือ Shared Table + tenant_id + Partitioning + RLS เพราะ ง่ายต่อ Migration, ง่ายต่อการ Query ข้าม Tenant (สำหรับ Analytics), Cost ต่ำ (ใช้ Connection Pool ร่วมกัน) และ RLS ป้องกัน Data Leakage ที่ระดับ Database
Shared Table + Partitioning + RLS
-- 1. สร้าง Table พร้อม List Partition ตาม Tenant
CREATE TABLE invoices (
id BIGSERIAL,
tenant_id UUID NOT NULL,
invoice_no VARCHAR(50) NOT NULL,
customer_id UUID NOT NULL,
total DECIMAL(12,2) NOT NULL,
status VARCHAR(20) DEFAULT 'draft',
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (id, tenant_id)
) PARTITION BY LIST (tenant_id);
-- 2. สร้าง Application User
CREATE ROLE app_user LOGIN PASSWORD 'secret';
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app_user;
-- 3. เปิด RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- 4. สร้าง Policy — User เห็นเฉพาะ Tenant ของตัวเอง
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::UUID);
-- 5. Application ตั้ง Tenant Context ก่อน Query
SET app.current_tenant = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
SELECT * FROM invoices; -- เห็นเฉพาะ Tenant นี้
Row Level Security (RLS) — Tenant Isolation
RLS ป้องกัน Data Leakage ระหว่าง Tenant ที่ระดับ Database แม้ Application มี Bug ที่ลืมใส่ WHERE tenant_id = ... ก็ไม่เห็นข้อมูล Tenant อื่น
-- Policy แยกสำหรับแต่ละ Operation
CREATE POLICY tenant_select ON invoices
FOR SELECT USING (tenant_id = current_setting('app.current_tenant')::UUID);
CREATE POLICY tenant_insert ON invoices
FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant')::UUID);
CREATE POLICY tenant_update ON invoices
FOR UPDATE USING (tenant_id = current_setting('app.current_tenant')::UUID);
CREATE POLICY tenant_delete ON invoices
FOR DELETE USING (tenant_id = current_setting('app.current_tenant')::UUID);
-- Admin Role ที่เห็นทุก Tenant (สำหรับ Analytics)
CREATE ROLE admin_user LOGIN PASSWORD 'admin-secret';
GRANT ALL ON invoices TO admin_user;
CREATE POLICY admin_all ON invoices
TO admin_user
USING (true); -- เห็นทุก Row
Auto-create Partition สำหรับ Tenant ใหม่
-- Function สร้าง Partition อัตโนมัติเมื่อมี Tenant ใหม่
CREATE OR REPLACE FUNCTION create_tenant_partition(p_tenant_id UUID)
RETURNS VOID AS $$
DECLARE
partition_name TEXT;
BEGIN
partition_name := 'invoices_' || replace(p_tenant_id::TEXT, '-', '_');
-- ตรวจว่ามี Partition แล้วหรือยัง
IF NOT EXISTS (
SELECT 1 FROM pg_class WHERE relname = partition_name
) THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF invoices FOR VALUES IN (%L)',
partition_name, p_tenant_id
);
RAISE NOTICE 'Created partition: %', partition_name;
END IF;
END;
$$ LANGUAGE plpgsql;
-- เรียกเมื่อ Onboard Tenant ใหม่
SELECT create_tenant_partition('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
-- Auto-create Monthly Partition
CREATE OR REPLACE FUNCTION create_monthly_partitions(months_ahead INT DEFAULT 3)
RETURNS VOID AS $$
DECLARE
start_date DATE;
end_date DATE;
partition_name TEXT;
BEGIN
FOR i IN 0..months_ahead LOOP
start_date := date_trunc('month', CURRENT_DATE + (i || ' months')::INTERVAL);
end_date := start_date + INTERVAL '1 month';
partition_name := 'orders_' || to_char(start_date, 'YYYY_MM');
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = partition_name) THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF orders FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date
);
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- รันทุกเดือนด้วย pg_cron
SELECT cron.schedule('create-partitions', '0 0 1 * *', 'SELECT create_monthly_partitions(3)');
Partition Maintenance — VACUUM, Archive, Drop
-- VACUUM ทีละ Partition (ไม่ Block Table ทั้งหมด)
VACUUM ANALYZE orders_2026_01;
-- Archive Partition เก่า (ย้ายไป Tablespace ถูก)
ALTER TABLE orders_2025_01 SET TABLESPACE archive_ssd;
-- Drop Partition เก่า (เร็วมาก ไม่เหมือน DELETE)
-- DROP ข้อมูลทั้ง Partition ทันที ไม่สร้าง Dead Tuple
ALTER TABLE orders DETACH PARTITION orders_2024_01;
DROP TABLE orders_2024_01;
-- หรือ Detach แบบ CONCURRENTLY (ไม่ Lock)
ALTER TABLE orders DETACH PARTITION orders_2024_02 CONCURRENTLY;
Performance Tuning
- Partition Key ต้องอยู่ใน WHERE — ทุก Query ควรมี Partition Key ใน WHERE เพื่อให้ Pruning ทำงาน
- Partition Key ต้องอยู่ใน Primary Key — PostgreSQL บังคับให้ Partition Key เป็นส่วนหนึ่งของ PK หรือ Unique Constraint
- จำนวน Partition ไม่ควรเกิน 1,000 — มากเกินไปจะ Slow ตอน Planning ใช้ Sub-partition แทน
- Index Strategy — สร้าง Index ที่ Parent จะ Propagate ไปทุก Partition อัตโนมัติ
- enable_partition_pruning = on — ค่า Default แต่ตรวจให้แน่ใจ
- parallel_append = on — ให้ Query ข้าม Partition ใช้ Parallel Workers
- Connection Pool — ใช้ PgBouncer หรือ Supavisor สำหรับ Multi-tenant ลด Connection per Tenant
Best Practices และสรุป
- เลือก Partition Key ตาม Query Pattern — ถ้า Query ส่วนใหญ่ Filter ตาม Date ใช้ Range by Date ถ้า Filter ตาม Tenant ใช้ List by Tenant ID
- ใช้ Declarative Partitioning — ไม่ใช่ Inheritance-based (เก่า)
- มี Default Partition เสมอ — ป้องกัน INSERT ล้มเหลวเมื่อ Partition ไม่มี
- Auto-create Partition ล่วงหน้า — ใช้ pg_cron สร้าง Partition เดือนหน้าอัตโนมัติ
- ใช้ RLS สำหรับ Tenant Isolation — ป้องกัน Data Leakage ที่ระดับ Database
- DROP Partition แทน DELETE — ลบข้อมูลเก่าเร็วมาก ไม่สร้าง Dead Tuple
- VACUUM ทีละ Partition — ไม่ Block Table ทั้งหมด
- Monitor Partition Count — อย่าให้เกิน 1,000 Partition
- Test Partition Pruning — EXPLAIN ANALYZE ตรวจว่า Pruning ทำงาน
PostgreSQL Partitioning + RLS เป็นรากฐานของ SaaS Multi-tenant Database ที่ Scalable, Secure และ Maintainable ทำให้ Table ที่มีข้อมูลหลายร้อยล้าน Row Query ได้เร็วเหมือน Table เล็ก ติดตามบทความใหม่ๆ ได้ที่ SiamCafe.net
คำถามที่พบบ่อย (FAQ)
Q: PostgreSQL Partitioning คืออะไร
แบ่ง Table ใหญ่เป็นหลาย Sub-table ตามเงื่อนไข (Date, Tenant) ทำให้ Query เร็วขึ้น VACUUM ง่าย ลบข้อมูลเก่าเร็ว
Q: Multi-tenant Database ออกแบบอย่างไร
นิยมสุดคือ Shared Table + tenant_id + Partitioning + RLS ง่าย Cost ต่ำ RLS ป้องกัน Data Leakage ที่ระดับ DB
Q: Partitioning มีกี่แบบ
3 แบบ: Range (ช่วงค่า เช่น วันที่), List (ค่าเฉพาะ เช่น Tenant ID), Hash (กระจายเท่าๆ กัน) + Sub-partitioning ซ้อนได้
Q: RLS คืออะไร
Row Level Security — กำหนดว่า User เห็น/แก้ Row ไหนได้ เช่น Tenant A เห็นเฉพาะ Row ที่ tenant_id = A ป้องกัน Data Leakage