Skip to content

บทที่ 7 — PostgreSQL Features เด่น (intermediate → advanced)

← บทที่ 6 | สารบัญ | บทที่ 8 →

บทนี้รวม feature เด่นของ PostgreSQL ที่ทำให้เลือกใช้ Postgres แทน MySQL หรือ NoSQL

🟡 หมายเหตุระดับ: ชื่อบทเดิมระบุ "ลึก" — แต่เนื้อหาหลักคือ tour of features + syntax พร้อมตัวอย่างใช้งาน (JSONB, partition, full-text, extension) ไม่ใช่ deep dive ระดับ MVCC internal / TOAST / WAL / planner mechanism ถ้าคุณเป็น senior ที่กำลังหา mechanism ลึก แนะนำอ่านควบกับ PostgreSQL internals docs — บทนี้เหมาะกับ "รู้ว่า feature อะไรมี + ใช้ยังไงเบื้องต้น + เห็น pitfall หลัก"

🚩 ข้ามได้ถ้าเพิ่งเริ่ม ถ้ายังไม่คล่อง SQL พื้นฐาน (บท 1-6) แนะนำกลับไปแม่นตรงนั้นก่อน แล้วค่อยกลับมาอ่านบทนี้เป็นรายหัวข้อตอนต้องใช้จริง

📑 หัวข้อย่อยข้ามได้รายอัน — บทนี้เหมือนเมนูบุฟเฟต์ ไม่ต้องอ่านเรียง อ่านเฉพาะหัวข้อที่ต้องใช้ก็ได้ (เช่น ต้องใช้ JSONB ก็ข้ามไปอ่าน §1, ต้องใช้ partition ก็ข้ามไปอ่าน §6) ที่เหลือไว้กลับมาอ่านเมื่อเจองาน

หลังจบบท คุณจะ:

  • ใช้ JSONB เก็บ flexible data + query/index ได้
  • ทำ full-text search ภายใน Postgres
  • ใช้ array, enum, range types
  • เข้าใจ materialized view + partition
  • ใช้ extension (pg_trgm, pgvector, PostGIS)

1. JSONB — JSON ที่ Index ได้

PostgreSQL (อ่าน โพสต์-เกรส-คิว-แอล หรือสั้น ๆ ว่า Postgres / โพสต์-เกรส) รองรับ JSON + binary JSON (JSONB — อ่าน เจ-สัน-บี) — เก็บ document แบบ MongoDB ได้

sql
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(200),
    metadata JSONB
);

INSERT INTO products (name, metadata) VALUES
    ('iPhone 15', '{"brand": "Apple", "color": "black", "storage": "256GB", "specs": {"weight": 174, "screen": 6.1}}'),
    ('Galaxy S24', '{"brand": "Samsung", "color": "white", "storage": "512GB", "specs": {"weight": 167, "screen": 6.2}}');

Operators

JSONB มี operator เยอะแต่จัดกลุ่มได้ 3 ก้อนใหญ่ — navigate (เดินเข้าไปเอาค่า), existence (เช็คว่ามี key ไหม), containment (เช็คว่ามี subset ไหม) ตารางสรุปก่อนแล้วค่อยดูตัวอย่าง:

Operatorชื่อเรียกกลุ่มใช้เมื่อ
->get-jsonnavigateเอา field คืนเป็น JSON (ใช้ chain ต่อได้)
->>get-textnavigateเอา field คืนเป็น text (ใช้เทียบ string ใน WHERE)
#>path-jsonnavigateเอา nested field ตาม path คืนเป็น JSON
#>>path-textnavigateเอา nested field ตาม path คืนเป็น text
?key-existsexistenceมี key นี้ไหม
?|any-key-existsexistenceมี key ใด ๆ จาก list ไหม
?&all-keys-existexistenceมีครบทุก key จาก list ไหม
@>containscontainmentobject ฝั่งซ้ายมี subset ฝั่งขวาไหม
<@contained-bycontainmentobject ฝั่งซ้ายเป็น subset ของฝั่งขวาไหม

ตัวอย่างแยกตามกลุ่ม:

sql
-- ===== กลุ่ม 1: navigate (เดินเข้าไปเอาค่า) =====
-- -> = get-json (return JSON, ใช้ chain ต่อได้)
SELECT metadata -> 'specs' FROM products;
-- {"weight": 174, "screen": 6.1}

-- ->> = get-text (return TEXT, ใช้เทียบ string)
SELECT metadata ->> 'brand' FROM products;
-- Apple

-- nested — chain -> หลายชั้นแล้วลงท้าย ->>
SELECT metadata -> 'specs' ->> 'weight' FROM products;
-- 174

-- ใช้ใน WHERE
SELECT * FROM products WHERE metadata ->> 'brand' = 'Apple';

-- #> #>> = path-json / path-text (ลัดทาง nested ได้ในขั้นเดียว)
SELECT metadata #> '{specs,screen}' FROM products;     -- as JSON
SELECT metadata #>> '{specs,screen}' FROM products;    -- as text

-- ===== กลุ่ม 2: existence (เช็คว่ามี key ไหม) =====
-- ? = key-exists
SELECT * FROM products WHERE metadata ? 'storage';

-- ?| = any-key-exists (มี key ใด ๆ ใน list)
SELECT * FROM products WHERE metadata ?| ARRAY['storage', 'ram'];

-- ?& = all-keys-exist (มีครบทุก key)
SELECT * FROM products WHERE metadata ?& ARRAY['brand', 'color'];

-- ===== กลุ่ม 3: containment (เช็คว่ามี subset) =====
-- @> = contains (left contains right)
SELECT * FROM products WHERE metadata @> '{"brand": "Apple"}';
-- หมายถึง row ที่มี brand=Apple (ไม่สนว่ามี field อื่นเพิ่ม)

Tip: 3 ตัวที่ใช้บ่อยที่สุดคือ ->>, @>, ? — จำ 3 ตัวนี้พอเริ่มต้น ที่เหลือเปิด table ดูเมื่อต้องใช้

JSONB Functions

sql
-- Build / convert
SELECT jsonb_build_object('a', 1, 'b', 2);    -- {"a": 1, "b": 2}
SELECT to_jsonb('hello'::text);                -- "hello"
SELECT jsonb_array_elements('[1,2,3]'::jsonb); -- 1, 2, 3 (each row)

-- Update
UPDATE products SET metadata = metadata || '{"discount": 0.1}' WHERE id = 1;
UPDATE products SET metadata = jsonb_set(metadata, '{specs,weight}', '180') WHERE id = 1;

-- Delete key
UPDATE products SET metadata = metadata - 'discount' WHERE id = 1;

-- Pretty print
SELECT jsonb_pretty(metadata) FROM products;

JSONB Index

sql
-- GIN index — fast contain queries
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);

-- ใช้กับ @>, ?, ?|, ?&

-- Index เฉพาะ key (expression index บน text)
CREATE INDEX idx_products_brand ON products ((metadata ->> 'brand'));

-- ใช้กับ
SELECT * FROM products WHERE metadata ->> 'brand' = 'Apple';

เลือกแบบไหน?

แบบข้อดีข้อเสียใช้เมื่อ
GIN บน metadata ทั้งก้อนยืดหยุ่น — รองรับ @>, ?, ?|, ?& ทุก fieldindex ใหญ่, write ช้าลงquery หลาย key ไม่แน่นอน
Expression index บน key เฉพาะเล็ก + เร็วเฉพาะ key นั้นใช้ได้กับ key เดียวที่ index ไว้query ซ้ำที่ key เดียวบ่อยมาก (เช่น brand)

JSONB Use Cases

✅ ดี:

  • Optional fields ที่ schema ไม่แน่นอน (product specs, settings)
  • Event payload
  • Config / preferences
  • Webhook data
  • Nested data ที่ไม่ join ออก

❌ ไม่ดี:

  • Data ที่ relation ชัด → ใช้ table
  • Data ที่ query ทุกครั้ง → table column เร็วกว่า
  • Frequently updated → row update overhead

2. Array Type

sql
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200),
    tags TEXT[],
    scores INTEGER[]
);

INSERT INTO posts (title, tags, scores) VALUES
    ('Hello', ARRAY['intro', 'tutorial'], ARRAY[5, 4, 5]),
    -- syntax ทางเลือก '{...}' (array literal string) ก็ใช้ได้ ผลเท่ากัน
    -- แต่แนะนำใช้ ARRAY[...] เพราะอ่านง่ายและไม่มีปัญหา escape เมื่อค่ามี space/comma
    ('Bye', ARRAY['farewell', 'ending'], ARRAY[3, 4]);

-- Query
SELECT * FROM posts WHERE 'tutorial' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['intro'];      -- contain
SELECT * FROM posts WHERE tags && ARRAY['intro', 'unknown'];  -- overlap

-- Function
SELECT array_length(tags, 1) FROM posts;
SELECT unnest(tags) FROM posts;     -- 1 tag per row
SELECT array_agg(tag) FROM (SELECT unnest(tags) AS tag FROM posts) AS t;

-- Index
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);

⚠️ Array OK สำหรับ "list of values" ที่ไม่ต้องการ table แยก
แต่ถ้า tags ใหญ่ + query/filter เยอะ → table แยก + junction table ดีกว่า


ศัพท์ที่ต้องรู้:

  • tsvector (อ่าน ที-เอส-เวก-เตอร์ = text-search vector) = "document ที่แตกเป็นคำพร้อมตำแหน่งและตัด stem แล้ว"
  • tsquery (อ่าน ที-เอส-เคียว-รี่ = text-search query) = "ประโยคค้นหา ที่แตกเป็นคำพร้อม operator (& | !)"
  • @@ = operator "match" ระหว่าง tsvector กับ tsquery
sql
-- tsvector = document + tsquery = search
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
-- tsvector แตกข้อความเป็นคำพร้อมตำแหน่ง และตัด stem (เช่น lazy → lazi, jumps → jump)
-- เพื่อให้ค้นได้แม้รูปคำต่างกัน (search "jump" เจอ "jumps"/"jumping" ด้วย)
-- คำ stop word (the, are, over) ถูกตัดทิ้ง

SELECT to_tsvector('english', 'cats are sleeping') @@ to_tsquery('cat & sleep');
-- true (stem: cats→cat, sleeping→sleep)

Setup สำหรับ Production

📌 GENERATED ALWAYS AS (...) STORED คืออะไร? = column ที่ Postgres คำนวณค่าให้เองอัตโนมัติทุกครั้งที่ insert/update โดยอิงสูตรในวงเล็บ STORED = เก็บลง disk จริง (ใช้ index ได้) ตรงข้ามคือ VIRTUAL (คำนวณ on-the-fly, Postgres รองรับเฉพาะ STORED ในตอนนี้)

sql
ALTER TABLE articles ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
        to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
    ) STORED;

CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);

-- Query
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, plainto_tsquery('english', 'database performance') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;

Operators

sql
to_tsquery('english', 'cat & dog')             -- both
to_tsquery('english', 'cat | dog')             -- either
to_tsquery('english', 'cat & !dog')            -- cat but not dog
plainto_tsquery('english', 'cat dog')          -- safe (auto AND)
phraseto_tsquery('english', 'quick brown fox') -- phrase
websearch_to_tsquery('english', '"quick brown" -dog')  -- google-style

Highlight

sql
-- ใช้ตัวคั่นแบบเรียบ ๆ (ไม่มี HTML angle bracket — ปลอดภัยกับ parser ของ ts_headline)
SELECT ts_headline('english', body, query, 'StartSel=[, StopSel=]')
FROM articles, plainto_tsquery('english', 'database') AS query
WHERE search_vector @@ query;

-- ถ้าจะ output เป็น HTML <mark> ก็ทำได้ แต่ระวัง option string ต้องไม่มี comma
-- ที่ไม่ใช่ separator ตามนี้
SELECT ts_headline(
    'english', body, query,
    'StartSel=<mark>, StopSel=</mark>, MaxFragments=2'
)
FROM articles, plainto_tsquery('english', 'database') AS query
WHERE search_vector @@ query;

vs Elasticsearch

Postgres FTSElasticsearch
Setupใน Postgres ที่มีอยู่แล้วservice แยก
Performanceดีถึง 10M documentscale ได้ unlimited
Featurebasic + decentadvanced (aggregation, facets, geo)
ใช้เมื่อsmall/medium + อยาก simplesearch-heavy + scale

เริ่มด้วย Postgres FTS — ถ้าไม่พอค่อยย้าย Elasticsearch


4. Range Type

PostgreSQL มี range type ที่เก็บ "ช่วง" (ช่วงเวลา, ช่วงตัวเลข) เป็น type เดียว — ทรงพลังมากเมื่อใช้กับ exclusion constraint ที่ DB การันตีว่าช่วงห้ามทับกัน (เช่นห้องประชุมจองชนเวลาไม่ได้) โดยไม่ต้องเขียน logic เช็คเอง:

📌 TSTZRANGE (อ่าน ที-เอส-ที-เซด-เรนจ์) = TimeStamp + Time Zone + Range = "ช่วงเวลาที่มี timezone" (ถ้าเป็น TSRANGE คือไม่มี timezone, INT4RANGE คือช่วง integer)

📌 Bracket notation [) คืออะไร? เป็นสัญลักษณ์คณิตศาสตร์: [ = inclusive (รวมขอบเขต), ) = exclusive (ไม่รวมขอบเขต) — เช่น [10:00, 12:00) = "เริ่ม 10:00 รวมถึง ก่อน 12:00 (ไม่รวม)" จึงไม่ชนกับช่วง [12:00, 14:00) ที่ขึ้นต่อพอดี

sql
-- ต้องเปิด extension btree_gist ก่อน เพื่อให้ใช้ = operator (integer) ร่วมกับ GIST ได้
-- ไม่งั้นจะ error: "data type integer has no default operator class for access method gist"
CREATE EXTENSION IF NOT EXISTS btree_gist;

-- ช่วงเวลา
CREATE TABLE reservations (
    id SERIAL PRIMARY KEY,
    room_id INTEGER,
    period TSTZRANGE,
    
    -- Exclusion constraint — ห้ามชน
    -- อ่านว่า "ห้ามมี 2 row ที่ room_id เท่ากัน AND period ทับกัน (&&)"
    EXCLUDE USING GIST (room_id WITH =, period WITH &&)
);

INSERT INTO reservations (room_id, period) VALUES
    (1, '[2026-05-18 10:00, 2026-05-18 12:00)'),
    (1, '[2026-05-18 14:00, 2026-05-18 16:00)');

-- ทับซ้อน — error!
INSERT INTO reservations (room_id, period) VALUES
    (1, '[2026-05-18 11:00, 2026-05-18 13:00)');
-- ERROR: conflicting key value

Operators:

  • && overlap
  • @> contains
  • <@ contained
  • << strictly before
  • -|- adjacent
sql
-- Reservation ที่ทับช่วงนี้
SELECT * FROM reservations 
WHERE period && '[2026-05-18 11:30, 2026-05-18 14:30)';

5. Materialized View

sql
-- View — virtual (re-execute query ทุกครั้ง)
CREATE VIEW user_stats AS
    SELECT user_id, COUNT(*) AS order_count, SUM(total) AS revenue
    FROM orders WHERE status = 'PAID'
    GROUP BY user_id;

SELECT * FROM user_stats;     -- run query เต็มทุกครั้ง

-- Materialized view — cache result บน disk
CREATE MATERIALIZED VIEW user_stats_mat AS
    SELECT user_id, COUNT(*) AS order_count, SUM(total) AS revenue
    FROM orders WHERE status = 'PAID'
    GROUP BY user_id
WITH DATA;  -- WITH DATA = สร้างพร้อม populate ค่าทันที (default)
            -- WITH NO DATA = สร้าง schema เปล่า ต้อง REFRESH ก่อนใช้

SELECT * FROM user_stats_mat;  -- เร็ว (อ่านจาก cache)

-- Refresh
REFRESH MATERIALIZED VIEW user_stats_mat;
-- CONCURRENTLY = refresh โดยไม่ lock read (user อื่น query ได้ระหว่าง refresh)
-- ข้อจำกัด: ต้องมี unique index บน view ก่อน + ใช้เวลานานกว่า refresh ปกติ
REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats_mat;

ใช้สำหรับ:

  • Dashboard analytics (ไม่ต้อง realtime)
  • Pre-aggregate ของ data ใหญ่
  • Report ที่ run ช้า

ตั้ง cron / scheduler refresh เป็นรอบ


6. Partitioning — Table ใหญ่มาก

แบ่ง table ใหญ่ตาม range / list / hash:

Range Partition (by date)

sql
-- หมายเหตุ: บน partitioned table, partition key (created_at) ต้องเป็นส่วนหนึ่งของ PK
-- เพราะ PG ต้องใช้ค่านี้ในการเลือก partition (ไม่งั้นรับ unique ข้าม partition ไม่ได้)
-- BIGSERIAL ยังทำงานปกติบน partitioned table — ไม่ต้องกำหนด default เอง
CREATE TABLE events (
    id BIGSERIAL,
    user_id INTEGER,
    event_type VARCHAR(50),
    created_at TIMESTAMPTZ NOT NULL,
    
    PRIMARY KEY (id, created_at)  -- ต้องมี created_at ใน PK
) PARTITION BY RANGE (created_at);

-- Sub-tables (1 ต่อเดือน)
CREATE TABLE events_2026_01 PARTITION OF events
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE events_2026_02 PARTITION OF events
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

-- ใช้เหมือน table ปกติ
INSERT INTO events (user_id, event_type, created_at) VALUES (1, 'CLICK', NOW());
SELECT * FROM events WHERE created_at > '2026-01-15';
-- Query planner เลือก partition ที่เกี่ยวข้องเท่านั้น

List Partition (by category)

sql
CREATE TABLE products (id SERIAL, region TEXT) PARTITION BY LIST (region);
CREATE TABLE products_asia PARTITION OF products FOR VALUES IN ('TH', 'JP', 'SG');
CREATE TABLE products_eu PARTITION OF products FOR VALUES IN ('UK', 'DE', 'FR');

Hash Partition (distribute load)

sql
CREATE TABLE users (id BIGSERIAL) PARTITION BY HASH (id);
CREATE TABLE users_p0 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE users_p1 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 1);
-- ...

ข้อดี

  • Query เร็วขึ้น (partition pruning)
  • Drop partition เก่า = เร็ว (vs DELETE)
  • Index เล็กลงต่อ partition

ใช้เมื่อ

  • Table > 100M row
  • Time-series data
  • Data ที่ "ลบเก่า" บ่อย

⚠️ Partition Pruning จะ fail เมื่อ...

💡 Pruning (อ่าน พรู-นิ่ง) แปลตรง ๆ คือ "การเล็มกิ่งต้นไม้" — เกษตรกรเล็มกิ่งที่ไม่ออกผลทิ้งให้ต้นไม้โตเฉพาะกิ่งที่ดี ในที่นี้คือ planner "เล็ม partition ที่ไม่เกี่ยวกับ query ทิ้ง" ก่อน scan เพื่อไม่ต้องเสียเวลาเปิดดูทุก partition

Partition pruning = planner ตัด partition ที่ไม่เกี่ยวออกก่อน scan แต่จะ fail (= scan ทุก partition) เมื่อ:

  • ใช้ function/expression ที่ planner คำนวณค่า partition key ไม่ได้ ณ plan time เช่น WHERE created_at::date = CURRENT_DATE (cast บน column)
  • Prepared statement ที่ใช้ generic plan — value ของ parameter ไม่รู้ตอน plan → PG 11+ มี run-time pruning ช่วยได้บางกรณีตอน execute แต่ไม่ทุก plan node
  • JOIN ที่ partition key มาจาก outer table — pruning อาจไม่เกิดถ้า planner คำนวณค่าตอน execute ไม่ได้
  • WHERE ใช้ OR ที่ครอบทุก partition — เช่น WHERE region = 'TH' OR user_id = 5
  • ลืม created_at ใน WHERE — query ที่ filter ด้วย column อื่นล้วน ๆ จะ scan ทุก partition (เรียก partition scan all)

ตรวจด้วย EXPLAIN — ถ้าเห็น Append ที่มี child node ครบทุก partition แสดงว่า pruning ไม่ทำงาน

Pitfall อื่นที่ควรรู้

  • Default partition trap: ถ้าสร้าง DEFAULT PARTITION แล้วต้องการเพิ่ม partition range ใหม่ → PG ต้อง scan default partition เพื่อ verify ว่า row ไม่ทับ range ใหม่ (lock + ช้าบน table ใหญ่)
  • Index = local per partition — ไม่มี global index ใน partitioned table → unique constraint ข้าม partition ไม่ได้ ยกเว้นจะ include partition key ใน unique key
  • FK ไปยัง partitioned table รองรับตั้งแต่ PG 12 — แต่ FK จาก partitioned table ไปยัง table อื่นรองรับเช่นกัน ส่วน FK ระหว่าง partitioned tables มีข้อจำกัด ตรวจ release notes ของ version ที่ใช้
  • ทุก index ที่สร้างบน partitioned parent จะ propagate ลง child — แต่ถ้าสร้างก่อนจะมี child ใหม่ ต้องระวัง maintenance window

(last reviewed: 2026-06 — ตรวจ release notes ทางการก่อนใช้ บางพฤติกรรมเปลี่ยนไปตาม minor version)


7. Triggers + Stored Procedures

sql
-- Function
CREATE OR REPLACE FUNCTION audit_user_changes()
RETURNS TRIGGER AS $$
BEGIN
    -- ทั่วไป audit ควรเก็บว่า "ใครเปลี่ยน" ด้วย — current_user หรือ session_user
    INSERT INTO user_audit (user_id, changed_at, old_email, new_email, changed_by)
    VALUES (OLD.id, NOW(), OLD.email, NEW.email, current_user);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Trigger
CREATE TRIGGER trg_user_audit
AFTER UPDATE OF email ON users
FOR EACH ROW
WHEN (OLD.email IS DISTINCT FROM NEW.email)
EXECUTE FUNCTION audit_user_changes();

Types:

  • BEFORE / AFTER / INSTEAD OF
  • INSERT / UPDATE / DELETE / TRUNCATE
  • FOR EACH ROW / FOR EACH STATEMENT

⚠️ Trigger Pitfalls

Business logic ใน trigger เยอะใช้ใน app layer — debug ง่ายกว่า
Trigger cascading หลายชั้นจำกัด — เข้าใจ flow ยาก
Trigger ที่ external call (HTTP)ใช้ event/queue แทน
Forget version controlcommit trigger ใน migration

ใช้ trigger สำหรับ: audit log, computed column, derived data, constraint ที่ CHECK ทำไม่ได้


8. CTE — Common Table Expression (recap)

ทบทวน CTE ที่เรียนในบท 3 ในมุมเฉพาะของ PostgreSQL — นอกจาก WITH ปกติและ recursive แล้ว Postgres ยังมีของพิเศษ: MATERIALIZED hint คุมว่าจะ cache ผล CTE ไหม และ DML ใน CTE (DELETE...RETURNING แล้ว INSERT ต่อ) ที่ทำ data migration ใน query เดียวได้:

ดูบทที่ 3 — ใน Postgres รองรับเต็มที่:

  • WITH x AS (...) — readability
  • WITH RECURSIVE — tree
  • MATERIALIZED / NOT MATERIALIZED hint — materialize (อ่าน มา-ที-เรียล-ไลซ์) แปลตรง ๆ คือ "ทำให้กลายเป็นวัตถุจริง" ในที่นี้คือ "cache ผลลัพธ์ CTE ไว้แทนการคำนวณซ้ำ"; MATERIALIZED = บังคับ cache, NOT MATERIALIZED = บอก planner ว่าฝัง CTE เข้าไปกับ main query ได้ (inline)
  • DML ใน CTE — WITH deleted AS (DELETE ... RETURNING *) INSERT INTO archive SELECT * FROM deleted

9. Extension ที่ควรรู้

sql
-- ดู extension ที่มี
SELECT * FROM pg_available_extensions ORDER BY name;
sql
CREATE EXTENSION pg_trgm;

CREATE INDEX idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);

-- Fast ILIKE '%text%'
SELECT * FROM products WHERE name ILIKE '%phone%';

-- Similarity
SELECT name, similarity(name, 'iphone') AS sim
FROM products
WHERE name % 'iphone'           -- threshold 0.3 default
ORDER BY sim DESC;

B. pgcrypto — Encryption + Hash

🔤 pgcrypto (อ่าน พีจี-คริฟ-โต)

sql
CREATE EXTENSION pgcrypto;

-- Hash
SELECT digest('hello', 'sha256');             -- bytea
SELECT encode(digest('hello', 'sha256'), 'hex');

-- BCrypt password
SELECT crypt('mypassword', gen_salt('bf', 10));
SELECT crypt('mypassword', stored_hash) = stored_hash;     -- verify

-- Symmetric encryption
SELECT pgp_sym_encrypt('secret', 'key');
SELECT pgp_sym_decrypt(encrypted_col, 'key');

-- UUID
SELECT gen_random_uuid();

⚠️ Password verify ใน production: การใช้ crypt(input, stored) = stored ใน DB ไม่ใช่ constant-time compare — เปิดช่อง timing attack ในเชิงทฤษฎี ทั่วไป production ควรใช้ Argon2id/BCrypt ผ่าน library ฝั่ง application (เช่น Spring Security BCryptPasswordEncoder, Argon2PasswordEncoder) ที่ทำ constant-time compare ให้ — ใช้ pgcrypto ใน DB เป็น utility ตอน prototype หรือ batch script ก็ได้ แต่อย่าให้เป็น hot path ของ login

C. UUIDgen_random_uuid() (core) และ uuid-ossp (legacy)

ตั้งแต่ PostgreSQL 13 ฟังก์ชัน gen_random_uuid() (สร้าง UUID v4) อยู่ใน core แล้ว — ไม่ต้องเปิด extension ใด ๆ:

sql
-- ใช้ได้ทันทีตั้งแต่ PG 13+ (มากับ pgcrypto/core)
SELECT gen_random_uuid();          -- UUID v4 (random)

uuid-ossp (อ่าน ยู-ยู-ไอ-ดี-ออส-เอส-พี) เป็น extension เก่า — ใช้เฉพาะเมื่อ ต้องการ UUID v1 (timestamp-based), v3, หรือ v5 (namespace-based) ที่ core ไม่มี:

sql
-- เปิดเฉพาะถ้าต้องการ v1/v3/v5
CREATE EXTENSION "uuid-ossp";

SELECT uuid_generate_v1();      -- timestamp-based
SELECT uuid_generate_v4();      -- random (เหมือน gen_random_uuid)
SELECT uuid_generate_v5(uuid_ns_url(), 'https://example.com');  -- namespace

D. PostGIS — Geospatial

🔤 PostGIS (อ่าน โพสต์-จิส)

⚠️ กับดักลำดับ coordinate (อ่านก่อนเขียน query!): PostGIS POINT(x y) คือ POINT(longitude latitude) = ลองจิจูดก่อน ละติจูดทีหลัง ซึ่งสลับกับ Google Maps / Apple Maps ที่ใช้ (lat, lng) — เป็น bug หาแล้วหาอีกของมือใหม่ทุกคน Bangkok = POINT(100.5018 13.7563) ไม่ใช่ POINT(13.7563 100.5018)

sql
CREATE EXTENSION postgis;

CREATE TABLE places (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    location GEOGRAPHY(POINT)
);

INSERT INTO places (name, location) VALUES
    -- POINT(lng lat) — longitude (100.5018) ก่อน, latitude (13.7563) ทีหลัง
    ('Bangkok', ST_GeogFromText('POINT(100.5018 13.7563)'));

-- Find places within 10km
-- พารามิเตอร์ตัวสุดท้าย 10000 = ระยะทาง 'หน่วยเมตร' (เพราะ GEOGRAPHY ใช้เมตร)
SELECT name, ST_Distance(location, ST_GeogFromText('POINT(100.55 13.75)')) AS dist
FROM places
WHERE ST_DWithin(location, ST_GeogFromText('POINT(100.55 13.75)'), 10000)
ORDER BY dist;

E. pgvector — AI / RAG

🔤 pgvector (อ่าน พีจี-เวก-เตอร์)

sql
CREATE EXTENSION vector;

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    content TEXT,
    -- vector(1536) = array ของ float 1536 ตัว ที่ AI model แปลงจากข้อความ
    -- 1536 = output dim ของ OpenAI text-embedding-3-small (ขนาดมาตรฐาน 2026)
    -- ทางเลือก: text-embedding-3-large → vector(3072), Cohere embed-multilingual-v3 → vector(1024)
    embedding vector(1536)
);

-- HNSW = มาตรฐานที่ใช้กันจริง (de facto, อ่าน "เด-แฟค-โต" = "โดยพฤตินัย") ใน pgvector 0.5+
-- เร็ว + recall (ความแม่นในการดึง neighbor) ดีกว่า IVFFlat
-- m, ef_construction = parameter ที่ส่งผลต่อความเร็ว/recall — ค่าเริ่มต้นที่แนะนำ:
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);
-- m สูง = recall ดีขึ้น, index ใหญ่ขึ้น, build ช้าลง (default 16 OK สำหรับ start)
-- ef_construction สูง = recall ดีขึ้นตอน build, build ช้าลง (default 64 OK)
-- production ที่ต้อง recall สูงมากลอง m=32, ef_construction=128 + benchmark recall

-- ทางเลือก: IVFFlat — build เร็ว/ใช้พื้นที่น้อยกว่า แต่ต้องตั้ง lists + ANALYZE หลัง load data
-- CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- <=> = cosine distance operator (ค่าน้อย = ใกล้กัน, range 0-2)
-- ทางเลือก: <-> = Euclidean distance (L2), <#> = inner product
-- Find similar — ดึง 5 document ที่ embedding ใกล้กับ query มากที่สุด
SELECT content, embedding <=> '[0.1, 0.2, ...]' AS distance
FROM documents
ORDER BY distance
LIMIT 5;

ใช้สำหรับ RAG (Retrieval-Augmented Generation) — ดูใน AI book

📌 ตัวอย่างนี้ใช้ pgvector ≥ 0.5 (มี HNSW index) — ตรวจ version ของ extension ด้วย \dx vector ใน psql ก่อน เพราะ HNSW ไม่มีใน pgvector รุ่นก่อนหน้า last reviewed: 2026-06

F. TimescaleDB — Time series (extension เพิ่ม)

ใช้กับ metrics, IoT, financial data — convert table เป็น hypertable ที่ partition ตามเวลาอัตโนมัติ


10. Listen / Notify — Pub/Sub ใน DB

sql
-- Listener (terminal 1)
LISTEN order_paid;

-- Publisher (terminal 2)
NOTIFY order_paid, '{"order_id": 123}';

-- Terminal 1 ได้รับ

ใน application:

java
// Spring Boot — listen
pgConnection.addNotificationListener(event -> {
    System.out.println("Got: " + event.getParameter());
});

ใช้สำหรับ:

  • Real-time update (เปลี่ยน DB → frontend อัพเดท)
  • Lightweight pub/sub โดยไม่ต้องมี Kafka/Redis

⚠️ ข้อจำกัดสำคัญที่ต้องรู้ก่อนเอาไปใช้แทน message queue:

  • ไม่ persistent — ถ้า listener ไม่อยู่ตอน NOTIFY → message หาย (ไม่มี retry, ไม่มี dead letter)
  • Payload limit ~8000 bytes (กำหนดใน source: NOTIFY_PAYLOAD_MAX_LENGTH) — ใส่ JSON ใหญ่ ๆ ไม่ได้ แนวทางคือ NOTIFY แค่ id แล้วให้ listener ไป SELECT ตามทีหลัง
  • Async queue per cluster มีขนาดจำกัด (8 GB ตาม source code default) — ถ้าเต็มเพราะ listener ค้างไม่ดูด transaction ที่พยายาม NOTIFY จะ fail
  • ใช้กับ PgBouncer transaction-mode ไม่ได้ (PgBouncer อ่าน พีจี-บาวน์-เซอร์ = ตัวกลาง pool connection) — connection ถูก reuse ระหว่าง transaction ทำให้ session-level state (LISTEN) หายไป ต้องใช้ session-mode pooling หรือต่อตรง
  • Replica/failover: notifications ไม่ replicate ข้าม streaming replica — failover แล้ว listener ที่อยู่บน standby เก่าหาย

สำหรับงาน production จริง (delivery guarantee, retry, partitioning) ใช้ Kafka, Redis Streams, หรือ outbox pattern + transactional polling แทน (last reviewed: 2026-06)


11. Window Function — Recap จากบทที่ 3

ทบทวน window function ที่เรียนในบท 3 สั้น ๆ — PostgreSQL รองรับครบทุก feature (ranking, LAG/LEAD, aggregate OVER, frame) เป็นหนึ่งในจุดแข็งของ Postgres สำหรับงาน analytics ที่ทำใน DB โดยตรง:

PostgreSQL รองรับทุก feature window function — recap key features:

  • ROW_NUMBER() / RANK() / DENSE_RANK()
  • LAG() / LEAD()
  • SUM() OVER (...), AVG() OVER (...)
  • PARTITION BY, ORDER BY, ROWS BETWEEN

12. EXPLAIN ลึกขึ้น

sql
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT) SELECT ...;
  • BUFFERS — แสดง disk read / cache hit
  • VERBOSE — แสดง column ที่ใช้
  • FORMAT JSON — สำหรับ tool visualize (pev2, explain.dalibo.com)

อ่าน buffers

Buffers: shared hit=100 read=50
  • hit = อ่านจาก memory cache (เร็ว)
  • read = อ่านจาก disk (ช้า)

hit / (hit + read) = cache hit ratio (target > 99%)


13. Replication + High Availability

Streaming Replication

Primary → standby1 (read replica)
       → standby2
  • Write → primary
  • Read → standby (เก็บ stale ตามเวลา replication lag)
sql
-- pg_stat_replication เป็น VIEW (ไม่ใช่ GUC) — ต้องใช้ SELECT ไม่ใช่ SHOW
-- (SHOW ใช้กับ parameter เช่น SHOW max_connections เท่านั้น)
-- รัน query นี้บน 'primary' จะเห็นข้อมูล standby ที่ต่ออยู่
SELECT * FROM pg_stat_replication;

🏗️ HA orchestration (2026 standard): ใน production ไม่ควรทำ failover ด้วยมือ — ใช้ tool ที่จัดการให้:

  • Patroni (อ่าน พา-โทร-นี) — ใช้ etcd/Consul/ZooKeeper เก็บ state, มาตรฐานทั่วไปบน VM/bare-metal
  • CloudNativePG — Kubernetes operator ที่เป็น CNCF Sandbox, แนะนำสำหรับ K8s 1.29+
  • Stolon, repmgr — ทางเลือกที่เก่ากว่าแต่ยังใช้ได้

tool พวกนี้ดูแล leader election, fencing, promote standby, สร้าง replication slot ให้อัตโนมัติ

Logical Replication

sql
-- Publisher
CREATE PUBLICATION my_pub FOR TABLE users, orders;

-- Subscriber
-- หมายเหตุ: connection string นี้ไม่มี password — ต้องตั้ง .pgpass ของ user postgres
-- บน subscriber server หรือใส่ password=xxx ใน connection string (ไม่แนะนำ — ติด log)
-- production: ใช้ replication slot + dedicated replication user (REPLICATION privilege)
CREATE SUBSCRIPTION my_sub 
    CONNECTION 'host=primary.com dbname=mydb user=replicator'
    PUBLICATION my_pub;

⚠️ Replication slot gotcha: ถ้าสร้าง replication slot ไว้แล้ว subscriber ไม่ทำงาน slot จะค้างกินที่ — กระทบ autovacuum ของ primary และในเคสร้ายสุดทำให้เกิด TXID wraparound ตรวจด้วย SELECT * FROM pg_replication_slots WHERE active = false; เป็นประจำ

ใช้สำหรับ:

  • Replicate ระหว่าง version ต่าง
  • Replicate เฉพาะบาง table
  • Zero-downtime upgrade

Connection Pooler

PostgreSQL — connection แพง (process per connection, ใช้ RAM ~10MB+ ต่อ connection)

  • ใช้ PgBouncer (lightweight, แนะนำ) หรือ Pgpool-II (มี load-balancing/failover) หน้า DB
  • App → pooler → DB
  • 1000 client → pooler → ~50 actual DB connection

Pool sizing — จุดเริ่มต้น:

จุดเริ่มที่ community แนะนำคือ pool_size ≈ (2 × CPU cores) + effective_spindle_count ของ DB server (สำหรับ SSD นับ effective_spindle_count = 1) — เช่น server 8 core SSD → pool ~17 connection ต่อ pooler instance สาเหตุคือ Postgres เป็น process-per-connection → connection ที่เยอะเกินกว่า core มักทำให้ context switch + lock contention สูงขึ้นแทนที่จะเร็วขึ้น แนวทางนี้มาจาก wiki ของ PgBouncer/community และต้องปรับตาม workload จริง (mixed OLTP vs analytics ใช้คนละแบบ) (last reviewed: 2026-06 — ตรวจ guideline ทางการของ PgBouncer/Postgres ก่อนใช้บน production)

PgBouncer pool mode — เลือกอย่างไร:

ก่อนอ่านตาราง — มาเข้าใจคำศัพท์ที่จะเจอกันก่อน:

ศัพท์คำอ่านความหมาย
PgBouncerพีจี-บาวน์-เซอร์ตัวกลาง (proxy) ที่ pool connection ระหว่าง app กับ Postgres
LISTEN/NOTIFYลิส-เซ่น/โน-ทิฟายpub/sub ที่ผูกกับ session (ดูหัวข้อ §10)
prepared statementพรี-แพร์ด-สเตท-เม้นท์คำสั่ง SQL ที่ compile ค้างไว้ใน server เพื่อ run ซ้ำเร็ว — server-side prepared = ผูก session
advisory lockแอด-ไว-เซอ-รี ล็อคlock เชิงตรรกะที่ app กำหนดเอง (ไม่ใช่ lock ของ row/table) ผูก session
autocommitออ-โต้-คอม-มิทmode ที่ทุก statement เป็น transaction เดี่ยว (ไม่มี BEGIN/COMMIT)
Modereuse connection เมื่อfeature ที่ใช้ได้
sessionclient disconnectใช้ได้ทุกอย่าง (LISTEN/NOTIFY, prepared statement, SET, advisory lock) — แต่ pool น้อยที่สุด
transactionCOMMIT/ROLLBACK ของแต่ละ transactionpool ดีที่สุด — แต่ server-side prepared statement พัง, SET/LISTEN/advisory lock ที่ผูกกับ session ใช้ไม่ได้
statementจบทุก statementจำกัดมาก — ใช้กับ short-lived query เท่านั้น (autocommit)

ส่วนใหญ่เริ่มที่ transaction mode เพื่อ pool ใช้ได้ดี — แต่ต้องปิด server-side prepared statement ของ driver (เช่น JDBC: prepareThreshold=0 หรือใช้ prefer_simple_query ของ PgBouncer 1.21+) และห้ามใช้ feature ที่ผูกกับ session แล้วคาดหวังว่ายังอยู่ในคำสั่งถัดไป

ความสัมพันธ์กับ max_connections: total ของ pool_size × จำนวน pooler ต้อง ≤ max_connections - superuser_reserved_connections ของ DB เสมอ (ไม่งั้น pooler จะ error เมื่อ pool เต็มและ DB ปฏิเสธ connection ใหม่)


14. Monitoring

PostgreSQL มี system view ที่ให้มองเห็นสุขภาพและ performance ของ DB ได้ละเอียด — pg_stat_activity (connection ที่ทำงานอยู่), pg_stat_statements (query ช้า), pg_locks (lock ที่ค้าง) และ cache hit ratio query กลุ่มนี้คือเครื่องมือแรกเวลา DB มีปัญหา:

sql
-- Active connections
SELECT pid, usename, application_name, state, query
FROM pg_stat_activity
WHERE state = 'active';

-- Slow query
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 10;

-- Lock
SELECT * FROM pg_locks WHERE granted = FALSE;

-- Table size
SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC;

-- Cache hit ratio
SELECT 
    sum(heap_blks_hit) / nullif(sum(heap_blks_hit + heap_blks_read), 0) AS hit_ratio
FROM pg_statio_user_tables;

15. ตัวอย่างจริง — ใช้ JSONB + FTS รวมกัน

ปิดท้ายด้วยการรวมฟีเจอร์เด่นของ PostgreSQL เป็นเคสจริง — ระบบ article ที่ใช้ JSONB เก็บ metadata ยืดหยุ่น + full-text search ค้นเนื้อหา + GIN index ให้เร็ว ตัวอย่างนี้แสดงว่า Postgres เดียวทำงานที่ปกติต้องใช้หลายระบบ (DB + search engine) ได้:

sql
CREATE TABLE articles (
    id BIGSERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    metadata JSONB DEFAULT '{}',
    
    search_vector tsvector
        GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED,
    
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);
CREATE INDEX idx_articles_metadata ON articles USING GIN (metadata);

-- Insert
INSERT INTO articles (title, body, metadata) VALUES
    ('Postgres rocks', 'Database is essential...', 
     '{"author": "Anna", "tags": ["db", "postgres"], "views": 100}');

-- Search: keyword + filter
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, plainto_tsquery('english', 'database postgres') AS query
WHERE search_vector @@ query
  AND metadata @> '{"tags": ["postgres"]}'
  AND (metadata ->> 'views')::int > 50
ORDER BY rank DESC
LIMIT 10;

16. ⚠️ PostgreSQL Pitfalls

Pitfallแก้
ใช้ JSONB เก็บทุกอย่างใช้ table column เมื่อ structure ชัด
Trigger เยอะ → debug ยากlogic ใน app layer
ไม่ vacuum → table bloatautovacuum tune หรือ manual
Index ไม่ rebuild → bloatREINDEX CONCURRENTLY
Large transaction → MVCC bloatสั้น
Refresh materialized view ตอน peakconcurrent + off-peak
ใช้ LISTEN/NOTIFY แทน proper queueใช้ Kafka/RabbitMQ สำหรับ critical

17. Checkpoint

🛠️ Checkpoint 7.1 — JSONB Product Catalog
สร้าง schema product ที่:

  • มี structured fields (id, name, price, stock)
  • มี attributes JSONB สำหรับ spec ที่แต่ละหมวดต่าง (mobile: weight, screen / laptop: ram, cpu)
  • query: products with screen > 6" + weight < 200g

🛠️ Checkpoint 7.2 — Full-text Search Blog

  • Schema: posts(id, title, body, tags TEXT[], search_vector)
  • Insert 100 blog posts
  • Search by keyword + filter tag
  • Highlight matching terms

🛠️ Checkpoint 7.3 — Partition Logs

  • Table app_logs partitioned by month
  • Insert 10M log row (across 12 months)
  • Query: this month — ดู partition pruning ใน EXPLAIN
  • Drop partition เก่า — เร็วกว่า DELETE

🛠️ Checkpoint 7.4 — Real-time Notification

  • Trigger บน orders ที่ NOTIFY เมื่อ status = 'PAID'
  • App fake (psql LISTEN) — รับ event

18. สรุปบท

JSONB = JSON ที่ index/query ได้ — flexible schema
Array + GIN index สำหรับ tags
Full-text search ภายใน Postgres (tsvector + tsquery) — ดีถึง 10M docs
Range type + EXCLUDE constraint ป้องกัน overlap
Materialized View สำหรับ pre-aggregate (refresh manual/cron)
Partition สำหรับ table > 100M row (time-series)
Trigger — audit, computed column (ใช้ระมัดระวัง)
Extensions เด็ด: pg_trgm, pgcrypto, PostGIS, pgvector
LISTEN/NOTIFY = lightweight pub/sub
✅ Replication: streaming + logical, PgBouncer สำหรับ connection pool
✅ Monitoring: pg_stat_statements, pg_stat_activity


← บทที่ 6 | บทที่ 8 → NoSQL Intro


🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-03