บทที่ 14 · Part 4 — Data & Storage
Data Modelling & Indexes
Access-pattern-first modelling, การเลือก datastore, B-tree กับ LSM-tree, index rule และ isolation level
Service เปลี่ยนได้ใน 1 ไตรมาส แต่ data model ที่ผิดอยู่กับคุณเป็นปีๆ และการแก้มันคือการ migrate ข้อมูลสดขณะที่ลูกค้ากำลังใช้งาน Part นี้จึงเป็นส่วนที่ตัดสินว่าระบบของคุณจะอยู่รอดหรือไม่
จบบทนี้คุณจะ
- รู้กฎข้อเดียวที่แก้ปัญหา duplication ได้: normalize source of truth, denormalize read model
- เลือก datastore จากคำถาม 6 ข้อ ไม่ใช่จากกระแส
- เข้าใจว่าทำไม B-tree กับ LSM-tree ทำให้ store ของคุณมีพฤติกรรมแบบนั้น
- แก้ query ช้าได้ด้วยกฎ index 5 ข้อ
- เข้าใจ lost update และ write skew — bug ที่สร้างเงินขึ้นมาจากอากาศแบบไม่มี error
Access-pattern-first modelling
การทำ data model มี 2 แบบ และเหมาะกับเวลาที่ต่างกัน
| Entity-first (normalize) | Query-first (denormalize) | |
|---|---|---|
| วิธี | model โดเมนตามความจริง, 3NF, ให้ query planner จัดการ join | เริ่มจาก query แล้วเก็บข้อมูลในรูปที่พร้อมอ่าน ทำซ้ำได้ตามต้องการ |
| เหมาะกับ | ความถูกต้อง, ความยืดหยุ่น, query ในอนาคตที่ยังไม่รู้ | read latency และ horizontal scale บน access pattern ที่รู้แน่ |
| ต้นทุน | join แพงขึ้นเมื่อ scale; shard ข้ามความสัมพันธ์ยาก | ทุกสำเนาคือภาระเรื่อง consistency; query รูปใหม่อาจต้องมีตารางใหม่ + backfill |
| ใช้เมื่อ | default — relational, OLTP, invariant สำคัญ, query จะวิวัฒน์ | wide-column/document, read volume สุดขั้ว, pattern นิ่งจริง |
กฎข้อเดียวที่ผมจะให้ทีมของตัวเอง
Normalize source of truth · Denormalize read model
เก็บสำเนาที่เป็น authoritative ไว้ชุดเดียว — normalize, มี constraint ป้องกัน — ที่นั่นคือที่ที่ความถูกต้องอยู่ แล้วสร้าง read model ที่ derive มา (cache, search index, summary table, analytics table) ได้เท่าที่ต้องการเพื่อความเร็ว โดยทุกตัว rebuild จาก source ได้
ตอนนี้ duplication ปลอดภัย เพราะไม่มีความคลุมเครือว่าสำเนาไหนถูก และสำเนาที่ derive มาทิ้งแล้วสร้างใหม่ได้ทุกตัว
[!CAUTION] Failure mode ที่ต้องเลี่ยง — สำเนา authoritative 2 ชุด เมื่อ 2 store รับ write สำหรับข้อเท็จจริงเดียวกัน คุณเซ็นสัญญาทำ reconciliation ไปตลอดกาล
Modelling checklist
- เขียน access pattern table ก่อน (บทที่ 5) — ไม่มีข้อยกเว้น
- ต่อ entity: natural key คืออะไร, surrogate key คืออะไร, อะไรต้อง unique, อะไรต้องไม่เป็น null เลย
- ผลัก invariant ลงไปในฐานข้อมูล (ดูกล่องล่าง)
- ตัดสินมิติเวลา: แถวนี้ mutable หรือ append-only พร้อมประวัติ version
— เงินและข้อมูล audit ต้อง append-only คุณไม่
UPDATEledger entry - ตัดสิน partition/shard key แม้จะยังไม่ shard แล้วเขียนไว้ใน design doc — model ที่มี shard key ที่เป็นไปได้จะ shard ทีหลังได้ถูก ที่ไม่มีคือการเขียนใหม่
- วางแผนขนาด: ตารางไหนจะมี 1 พันล้านแถว, โตวันละเท่าไหร่, query จะเป็นอย่างไรเมื่อมันใหญ่ขึ้น 100 เท่า
Constraint ในฐานข้อมูลปกป้องได้มากกว่าโค้ด
UNIQUE, CHECK (amount > 0), foreign key, NOT NULL — สิ่งเหล่านี้ยังคงทำงาน
แม้ตอนที่ service ใหม่, script แก้ข้อมูล, หรือ bug ของเพื่อนร่วมงานในอนาคต
พยายามจะละเมิดมัน
Validation ระดับ application ป้องกันความผิดพลาด · constraint ในฐานข้อมูลป้องกันทุกอย่าง
สำหรับข้อมูลการเงิน อันนี้ไม่ใช่ตัวเลือก
เลือก datastore ด้วยคำถาม 6 ข้อ
| คำถาม | ถ้าใช่… |
|---|---|
| ต้องเปลี่ยนหลายแถวแบบ atomic พร้อม invariant ระหว่างแถวไหม | Relational จบ (เงิน, สินค้าคงคลัง, สิทธิ์, limit) |
| Query จะเปลี่ยนในทางที่คุณคาดเดาไม่ได้ไหม | Relational — ความยืดหยุ่นของ ad-hoc query คือพลังพิเศษของมัน |
| การเข้าถึงถูกครอบด้วย key เดียว และปริมาณเกิน 1 node ไหม | key-value, document หรือ wide-column |
| Workload คือ "scan แล้ว aggregate หลายล้านแถว" ไหม | columnar/analytical store ที่ป้อนมาจาก OLTP store ไม่ใช่ตัว OLTP เอง |
| เป็นข้อความอิสระที่ต้องจัดอันดับความเกี่ยวข้องไหม | search engine ในฐานะ index ที่ derive มา |
| ความสัมพันธ์คือตัว query เอง (3 hop ขึ้นไป) ไหม | graph store หรือ recursive CTE ที่มี index ดีถ้า depth น้อย |
คำแนะนำที่น่าเบื่อและมักถูกต้อง
เริ่มด้วย PostgreSQL 1 ตัวกับ Redis 1 ตัว
Postgres เป็น relational database, JSON document store, queue (SKIP LOCKED),
full-text search engine, time-series store (ด้วย partitioning),
geospatial engine (PostGIS) และ vector store (pgvector) — ในตัวเดียว
ระบบเดียวที่ต้องดูแล, backup, secure และรับคนมาทำ เพิ่ม store เฉพาะทางเมื่อคุณมีหลักฐานที่วัดแล้วว่า Postgres ทำงานนั้นไม่ได้ — ไม่ใช่ก่อนหน้านั้น
ทีมที่เริ่มด้วย datastore 5 ตัวใช้ปีแรกไปกับงานท่อ ไม่ใช่กับ product
B-tree กับ LSM-tree
| B-tree (Postgres, MySQL/InnoDB) | LSM-tree (Cassandra, RocksDB, ScyllaDB) | |
|---|---|---|
| Write | update ในที่เดิม; random I/O; มี write-ahead log เพื่อ durability | append ลง memory แล้ว flush เป็นไฟล์ที่เรียงแล้ว; sequential I/O; write throughput สูงกว่ามาก |
| Read | คาดเดาได้: อ่าน page ไม่กี่หน้าลงไปตามต้นไม้ | อาจต้องเช็คหลายไฟล์; bloom filter ช่วย; latency แปรผันมากกว่า |
| Space | page fragmentation | compaction คืนพื้นที่ แต่ compaction แย่ง I/O กับ traffic ของคุณ — นี่คือความจริงของการรัน Cassandra |
| Delete | ทันที | tombstone — ข้อมูลที่ลบยังค้างจนกว่าจะ compact; tombstone จำนวนมากทำให้ read ช้า |
ข้อสรุปที่ใช้ได้
ถ้า workload เป็น write-heavy append-only (message, event, metric) LSM store คือความเหมาะสมทางสถาปัตยกรรมจริง ไม่ใช่ความชอบ
ถ้าเป็น read-modify-write ที่มี invariant B-tree relational store คือคำตอบ
กฎ index ที่แก้ query ช้าได้ส่วนใหญ่
1. ลำดับคอลัมน์ใน composite index: equality → range → sort
-- Query: "transfer 20 รายการล่าสุดของ account นี้"
SELECT * FROM transfers
WHERE source_account_id = $1
ORDER BY created_at DESC, transfer_id DESC
LIMIT 20;
-- ถูก: equality ก่อน แล้ว sort
CREATE INDEX ok ON transfers (source_account_id, created_at DESC, transfer_id DESC);
-- ผิด: index นี้ serve query ข้างบนไม่ได้เลย
CREATE INDEX bad ON transfers (created_at, source_account_id);
2. Covering index
ถ้า index มีทุกคอลัมน์ที่ query ต้องใช้ ฐานข้อมูลไม่ต้องแตะตารางเลย
บน hot read path นี่คือชัยชนะระดับ 10 เท่า ใน Postgres ใช้ INCLUDE
CREATE INDEX transfers_list_covering
ON transfers (source_account_id, created_at DESC)
INCLUDE (transfer_id, amount_minor, currency, status);
3. Partial index — เทคนิคที่มีค่าที่สุดใน Postgres
Index บน boolean หรือบน status ที่มี 3 ค่ามักไม่ช่วยอะไรเลย
แต่ partial index ช่วยมาก เพราะ index แค่ส่วนย่อยที่ร้อน
-- Work queue / outbox: hot set เล็กมากแม้ตารางจะมี 1 พันล้านแถว
CREATE INDEX transfers_pending ON transfers (created_at)
WHERE status = 'PENDING';
CREATE INDEX outbox_unpublished ON outbox (created_at)
WHERE published_at IS NULL;
4. ทุก index เก็บภาษีจากทุก write
และกิน memory ที่ควรใช้ cache ข้อมูล
Audit หา index ที่ไม่มีใครใช้ (pg_stat_user_indexes) แล้วลบทิ้ง
5. อ่าน plan อย่าเดา
EXPLAIN (ANALYZE, BUFFERS)
SELECT ... ; -- ต้องรันบนปริมาณข้อมูลจริง
Plan บน 1,000 แถวไม่บอกอะไรเลยเกี่ยวกับ 100 ล้านแถว
สิ่งที่ต้องจับตา:
- sequential scan บนตารางใหญ่
- nested loop ที่มีจำนวนแถวสูง
- sort ที่ spill ลง disk
- estimated vs actual row count ต่างกันเป็น order of magnitude (statistics เก่า)
6. Keyset (seek) pagination ไม่ใช่ OFFSET
-- ผิด: OFFSET 100000 ทำให้ DB เดินผ่าน 100,000 แถวแล้วทิ้ง
-- ต้นทุนโตตามค่า offset -> หน้าท้ายๆ ช้าลงเรื่อยๆ
SELECT * FROM transfers WHERE source_account_id = $1
ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
-- ดีกว่า: seek จากตำแหน่งล่าสุด ต้นทุนไม่ขึ้นกับว่าอยู่หน้าที่เท่าไหร่
SELECT * FROM transfers
WHERE source_account_id = $1
AND (created_at, transfer_id) < ($2, $3) -- tuple comparison
ORDER BY created_at DESC, transfer_id DESC
LIMIT 20;
Seek pagination ไม่ใช่ยาครอบจักรวาล
- ต้นทุนไม่ใช่
O(1)— โดยทั่วไปเป็นประมาณO(log n + page size)จากการ descend ต้นไม้แล้ว scan ต่อเนื่อง แต่ข้อดีคือไม่โตตามหมายเลขหน้า - ยังเกิดรายการซ้ำหรือตกหล่นได้ ถ้า sort key ของแถวที่มีอยู่เปลี่ยน
ระหว่างที่ผู้ใช้กำลังไล่หน้า (เช่นแก้
updated_atแล้วใช้มันเป็น sort key) - ต้องใช้ composite order ที่ stable และ unique ทั้งชุด
(
created_at, transfer_id) ไม่อย่างนั้นแถวที่created_atเท่ากันจะข้ามกัน - ถ้า use case ต้องการผลลัพธ์ นิ่งทั้งชุด (รายงาน, การ export, reconciliation)
ให้ใช้ cutoff/snapshot: fix
created_at <= :as_ofตอนเปิดหน้าแรก แล้วส่งas_ofไปกับทุกหน้า
[!TIP] อ้างอิงที่ดีที่สุดเรื่อง index Use The Index, Luke! โดย Markus Winand — ฟรี และเป็นแหล่งเรียน SQL indexing เชิงปฏิบัติที่ดีที่สุดที่มี อ่านบท composite index กับ pagination
Transaction และ isolation
ACID บรรทัดละข้อ: Atomic (ทั้งหมดหรือไม่มีเลย), Consistent (invariant ยังคงอยู่), Isolated (transaction ที่ทำพร้อมกันไม่ทำลายกัน), Durable (commit แล้วรอด crash)
| Level | ป้องกัน | ยังปล่อยผ่าน | ใช้กับ |
|---|---|---|---|
| Read Uncommitted | ไม่มีอะไรที่มีประโยชน์ | dirty read | ไม่ใช้เลย |
| Read Committed (default ของ Postgres) | dirty read | non-repeatable read, phantom, lost update, write skew | CRUD ทั่วไป — ปลอดภัยเฉพาะเมื่อคุณใช้ explicit lock หรือ atomic statement สำหรับ read-modify-write |
| Repeatable Read / Snapshot (default ของ MySQL InnoDB) | + non-repeatable read | write skew · phantom ขึ้นกับ engine | รายงานที่ต้องการ snapshot ที่นิ่ง; การอ่านหลาย statement |
| Serializable | anomaly ทั้งหมดที่นิยามไว้ในระดับนี้ | — | invariant ทางการเงินที่คุณไม่สามารถแจกแจง lock ได้ทั้งหมด — จ่ายด้วย throughput และจะเกิด serialization error ที่โค้ดของคุณต้อง retry |
ชื่อ isolation level เหมือนกัน แต่พฤติกรรมไม่เหมือนกันข้าม engine
ระดับพวกนี้ถูกนิยามด้วย anomaly ที่ห้ามเกิด ไม่ใช่ด้วยกลไก แต่ละ engine จึงให้อะไรที่แรงกว่าหรือกันคนละอย่าง:
- PostgreSQL —
Repeatable Readเป็น snapshot isolation (กัน phantom ในการอ่าน แต่ยังปล่อย write skew);Serializableใช้ SSI แล้ว abort transaction พร้อม error ที่ต้อง retry - MySQL / InnoDB —
Repeatable Readเป็น default และใช้ gap lock / next-key lock ในการ locking read จึงมีพฤติกรรมเรื่อง phantom กับ locking ต่างจาก Postgres
ต้องยืนยันกับเอกสารของ engine และเวอร์ชันที่คุณใช้จริงก่อนพึ่งพา — PostgreSQL transaction isolation · MySQL InnoDB isolation levels
Lost update — bug ที่สร้างเงินขึ้นมาแบบเงียบ
-- ทั้ง 2 transaction รันที่ READ COMMITTED ห่างกัน 3 ms:
T1: SELECT balance FROM accounts WHERE id=1; -- อ่านได้ 1000
T2: SELECT balance FROM accounts WHERE id=1; -- อ่านได้ 1000
T1: UPDATE accounts SET balance = 1000 - 300 ... -- เขียน 700
T2: UPDATE accounts SET balance = 1000 - 500 ... -- เขียน 500
-- COMMIT ทั้งคู่ ถอนเงิน 2 ครั้งรวม 800 ยอดสุดท้ายเหลือ 500
-- 300 บาทถูกสร้างขึ้นจากอากาศ และไม่มี error เกิดขึ้นที่ไหนเลย
4 ทางแก้ที่ถูกต้อง:
-- 1. Atomic arithmetic ในฐานข้อมูล (ง่ายที่สุด — ใช้อันนี้เป็น default)
UPDATE accounts SET balance_minor = balance_minor - 300
WHERE account_id = 1 AND balance_minor >= 300;
-- แล้ว *เช็ค affected row count* — 0 แถว = เงินไม่พอ
-- invariant ถูกบังคับโดยฐานข้อมูล ไม่ใช่โดย if ของคุณ
-- 2. Pessimistic lock — เมื่อต้องอ่าน คำนวณ แล้วเขียน
SELECT balance_minor FROM accounts WHERE account_id = 1 FOR UPDATE;
-- serialize บนแถวนั้น; transaction อื่นรอ
-- ให้ *สั้น* และ *ล็อกแถวในลำดับที่คงที่เสมอ* เพื่อเลี่ยง deadlock
-- 3. Optimistic concurrency — ดีที่สุดสำหรับการแก้ที่ผู้ใช้เป็นคนกด
UPDATE accounts SET balance_minor = $1, version = version + 1
WHERE account_id = 1 AND version = $2;
-- 0 แถว = คนอื่นชนะ -> โหลดใหม่แล้ว retry
-- ไม่ถือ lock ไว้ระหว่างที่ผู้ใช้กำลังคิด
-- 4. SERIALIZABLE + retry loop เมื่อเกิด serialization failure
เพิ่ม `CHECK` ด้วยทุกครั้ง
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative
CHECK (balance_minor >= 0);
constraint นี้เปลี่ยน bug ที่เหลือรอดให้เป็น error ที่ดังลั่น แทนที่จะเป็นการสูญเงินอย่างเงียบ — ใส่มัน
Write skew — anomaly ที่รอดจาก Repeatable Read
Transaction 2 ตัวอ่านชุดของแถว, แต่ละตัวตรวจว่ากฎยังเป็นจริง, แล้วแต่ละตัวเขียน — และผลรวมทำลายกฎนั้น
ตัวอย่างคลาสสิก: "ต้องมีหมออยู่เวรอย่างน้อย 1 คน"
T1 เห็นหมอ 2 คนอยู่เวร -> อนุญาตให้หมอ A ออก
T2 เห็นหมอ 2 คนอยู่เวร -> อนุญาตให้หมอ B ออก
--> ตอนนี้ไม่มีใครอยู่เวร
ใน fintech: transfer 2 รายการพร้อมกัน แต่ละรายการเช็ค
"ยังไม่เกิน daily limit" กับผลรวม ผ่านทั้งคู่ แต่รวมกันเกิน limit
Row lock ไม่ช่วย เพราะ conflict อยู่ที่แถวที่ยังไม่มีอยู่ หรืออยู่ที่ค่า aggregate
ทางแก้:
SERIALIZABLE- Materialize the conflict — ล็อกแถวเดียวที่แทน aggregate นั้น ซึ่งให้ transaction ที่ทำพร้อมกันมี "ของจริง" ให้แย่งกัน
ข้อ 2 คือคำตอบเชิงปฏิบัติที่ทีมส่วนใหญ่ใช้
-- Materialize the conflict สำหรับ daily limit
-- มีแถวเดียวต่อ (account, วันที่) ที่แทนยอดใช้ไปของวันนั้น
CREATE TABLE daily_limits (
account_id TEXT NOT NULL,
limit_date DATE NOT NULL,
used_minor BIGINT NOT NULL DEFAULT 0,
cap_minor BIGINT NOT NULL,
PRIMARY KEY (account_id, limit_date)
);
BEGIN;
-- ล็อกแถวที่แทน aggregate -> ตอนนี้มีของให้แย่งกันจริงๆ
SELECT used_minor, cap_minor FROM daily_limits
WHERE account_id = $1 AND limit_date = current_date
FOR UPDATE;
UPDATE daily_limits
SET used_minor = used_minor + $2
WHERE account_id = $1 AND limit_date = current_date
AND used_minor + $2 <= cap_minor; -- กฎถูกบังคับที่นี่
-- 0 แถว -> เกิน limit ยกเลิก transaction
INSERT INTO transfers (...) VALUES (...);
COMMIT;
`cap_minor` มาจากไหน
เพดานการโอนต่อวันเป็น นโยบาย ไม่ใช่ค่าคงที่ทางวิศวกรรม ต้องมาจาก configuration ที่มีเจ้าของและมีร่องรอยการอนุมัติ ไม่ใช่ตัวเลขที่ engineer เขียนลงไปใน migration — ดูบทที่ 10
Deadlock และ transaction ที่ยาว
กฎข้อเดียวที่กำจัด deadlock ที่พบบ่อยที่สุดในระบบ payment
ล็อกในลำดับที่คงที่เสมอ
ในการโอนระหว่าง A และ B ให้ล็อก min(A, B) ก่อน
Transaction 1: โอน A -> B ล็อก min(A,B)=A แล้ว B
Transaction 2: โอน B -> A ล็อก min(A,B)=A แล้ว B <-- ลำดับเดียวกัน
--> ไม่มี deadlock
ถ้าไม่ทำ T1 จะถือ A รอ B ขณะที่ T2 ถือ B รอ A — deadlock ทันที
Deadlock ยังจะเกิดอยู่ดี
ฐานข้อมูลตรวจจับแล้ว abort 1 transaction โค้ดของคุณต้องจับแล้ว retry ด้วย delay สั้นๆ ที่มี jitter — retry-on-deadlock เป็นสิ่งที่ต้องมีบน production ไม่ใช่ตัวเลือก
ห้ามเปิด transaction ค้างข้ามการเรียก network
`BEGIN; ...; เรียก partner API; ...; COMMIT;`
รูปแบบนี้ถือ row lock ไว้เท่ากับ latency ของ partner และถ้า partner ค้าง 30 วินาที ฐานข้อมูลของคุณถือ row lock 30 วินาที
ใน Postgres transaction ที่ยาวยังบล็อก vacuum และทำให้ตารางบวม
Pattern ที่ถูก: commit local state พร้อม outbox row แล้วเรียก partner นอก transaction (บทที่ 16)
ตั้ง timeout ให้ transaction
-- ตั้งที่ระดับ role หรือ session — กัน client ที่ค้างถือ lock ไม่มีที่สิ้นสุด
SET statement_timeout = '5s';
SET idle_in_transaction_session_timeout = '10s';
SET lock_timeout = '2s'; -- สำคัญมากตอนรัน migration
Checklist data modelling
- Access pattern table เขียนก่อน schema
- Source of truth normalize · read model ทั้งหมด derive และ rebuild ได้
- ไม่มีข้อเท็จจริงใดที่มีสำเนา authoritative 2 ชุด
- Invariant สำคัญทุกข้ออยู่ใน constraint ของฐานข้อมูล
- ตาราง ledger / audit เป็น append-only
- Shard key ตัดสินแล้วและเขียนไว้ แม้ยังไม่ shard
- Partition scheme ตามเวลาออกแบบแล้ว ก่อนตารางจะใหญ่
- Index ทุกตัว serve pattern ที่ระบุไว้ · index ที่ไม่มีใครใช้ถูกลบแล้ว
- Pagination ใช้ keyset ไม่ใช่
OFFSET - Read-modify-write ทุกจุดใช้ atomic statement,
FOR UPDATE, version หรือSERIALIZABLE - มี
CHECKกันยอดติดลบ - Aggregate rule (limit ต่อวัน) ถูก materialize เป็นแถวให้ล็อก
- ล็อกในลำดับที่คงที่ (
min(id)ก่อน) และมี retry-on-deadlock - ไม่มี transaction ไหนครอบการเรียก network
- ตั้ง
statement_timeout,idle_in_transaction_session_timeout,lock_timeout
สรุปบทนี้
Normalize source of truth, denormalize read model — และห้ามมี authoritative 2 ชุด ·
เริ่มด้วย Postgres 1 ตัวจนกว่าจะมีหลักฐานว่ามันไม่พอ ·
composite index เรียง equality → range → sort · partial index คือของดีที่คนไม่ใช้ ·
OFFSET เป็นกับดัก · lost update สร้างเงินขึ้นมาโดยไม่มี error ·
และ write skew ต้องแก้ด้วย SERIALIZABLE หรือ materialize the conflict