โหมดมืด
บทที่ 7 — PostgreSQL Features เด่น (intermediate → advanced)
บทนี้รวม 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-json | navigate | เอา field คืนเป็น JSON (ใช้ chain ต่อได้) |
->> | get-text | navigate | เอา field คืนเป็น text (ใช้เทียบ string ใน WHERE) |
#> | path-json | navigate | เอา nested field ตาม path คืนเป็น JSON |
#>> | path-text | navigate | เอา nested field ตาม path คืนเป็น text |
? | key-exists | existence | มี key นี้ไหม |
?| | any-key-exists | existence | มี key ใด ๆ จาก list ไหม |
?& | all-keys-exist | existence | มีครบทุก key จาก list ไหม |
@> | contains | containment | object ฝั่งซ้ายมี subset ฝั่งขวาไหม |
<@ | contained-by | containment | object ฝั่งซ้ายเป็น 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 ทั้งก้อน | ยืดหยุ่น — รองรับ @>, ?, ?|, ?& ทุก field | index ใหญ่, 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 ดีกว่า
3. Full-Text Search
ศัพท์ที่ต้องรู้:
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-styleHighlight
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 FTS | Elasticsearch | |
|---|---|---|
| Setup | ใน Postgres ที่มีอยู่แล้ว | service แยก |
| Performance | ดีถึง 10M document | scale ได้ unlimited |
| Feature | basic + decent | advanced (aggregation, facets, geo) |
| ใช้เมื่อ | small/medium + อยาก simple | search-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 valueOperators:
&&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 OFINSERT/UPDATE/DELETE/TRUNCATEFOR EACH ROW/FOR EACH STATEMENT
⚠️ Trigger Pitfalls
| ❌ | ✅ |
|---|---|
| Business logic ใน trigger เยอะ | ใช้ใน app layer — debug ง่ายกว่า |
| Trigger cascading หลายชั้น | จำกัด — เข้าใจ flow ยาก |
| Trigger ที่ external call (HTTP) | ใช้ event/queue แทน |
| Forget version control | commit 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 (...)— readabilityWITH RECURSIVE— treeMATERIALIZED/NOT MATERIALIZEDhint — 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;A. pg_trgm — Trigram (fuzzy search)
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 SecurityBCryptPasswordEncoder,Argon2PasswordEncoder) ที่ทำ constant-time compare ให้ — ใช้ pgcrypto ใน DB เป็น utility ตอน prototype หรือ batch script ก็ได้ แต่อย่าให้เป็น hot path ของ login
C. UUID — gen_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'); -- namespaceD. 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 hitVERBOSE— แสดง column ที่ใช้FORMAT JSON— สำหรับ tool visualize (pev2, explain.dalibo.com)
อ่าน buffers
Buffers: shared hit=100 read=50hit= อ่านจาก 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) |
| Mode | reuse connection เมื่อ | feature ที่ใช้ได้ |
|---|---|---|
| session | client disconnect | ใช้ได้ทุกอย่าง (LISTEN/NOTIFY, prepared statement, SET, advisory lock) — แต่ pool น้อยที่สุด |
| transaction | COMMIT/ROLLBACK ของแต่ละ transaction | pool ดีที่สุด — แต่ 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 bloat | autovacuum tune หรือ manual |
| Index ไม่ rebuild → bloat | REINDEX CONCURRENTLY |
| Large transaction → MVCC bloat | สั้น |
| Refresh materialized view ตอน peak | concurrent + 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_logspartitioned 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