PostgreSQL Partitioning SaaS Architecture

โดย อ. บอมกิตติทัศน์เจริญพนาสิทธิ์ | อัปเดต 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
Q: PostgreSQL Partitioning คืออะไร
แบ่ง Table ใหญ่เป็นหลาย Sub-table ตามเงื่อนไข (Date, Tenant) ทำให้ Query เร็วขึ้น VACUUM ง่ายลบข้อมูลเก่าเร็ว