Database

PostgreSQL Partitioning SaaS Architecture — คู่มือฉบับสมบูรณ์ 2026

PostgreSQL Partitioning SaaS Architecture — คู่มือฉบับสมบูรณ์ 2026 | SiamCafe Blog

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

PostgreSQL Partitioning SaaS Architecture 2026

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

คำถามที่พบบ่อย (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

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