Database

PostgreSQL Partitioning SaaS Architecture

postgresql partitioning saas architecture
PostgreSQL Partitioning SaaS Architecture | SiamCafe Blog

โดย อ. บอมกิตติทัศน์เจริญพนาสิทธิ์ | อัปเดต 24 ก. พ. 2026 | อ่าน 16 นาที

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
HashHash ของ 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 แบบ

StrategyIsolationComplexityCostเหมาะกับ
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

Best Practices และสรุป

PostgreSQL Partitioning + RLS เป็นรากฐานของ SaaS Multi-tenant Database ที่ Scalable, Secure และ Maintainable ทำให้ Table ที่มีข้อมูลหลายร้อยล้าน Row Query ได้เร็วเหมือน Table เล็กติดตามบทความใหม่ๆได้ที่ SiamCafe.net

อ. บอมกิตติทัศน์เจริญพนาสิทธิ์
IT Infrastructure Expert | Thaiware Award | ประสบการณ์กว่า 25 ปี — ผู้ก่อตั้ง SiamCafe.net Since 2000-2026

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

บทความแนะนำ:

อ่านเพิ่มเติม: บทความทั้งหมด | หน้าแรก Blog