บทที่ 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 คุณไม่ UPDATE ledger 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)
Writeupdate ในที่เดิม; random I/O; มี write-ahead log เพื่อ durabilityappend ลง memory แล้ว flush เป็นไฟล์ที่เรียงแล้ว; sequential I/O; write throughput สูงกว่ามาก
Readคาดเดาได้: อ่าน page ไม่กี่หน้าลงไปตามต้นไม้อาจต้องเช็คหลายไฟล์; bloom filter ช่วย; latency แปรผันมากกว่า
Spacepage fragmentationcompaction คืนพื้นที่ แต่ 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 readnon-repeatable read, phantom, lost update, write skewCRUD ทั่วไป — ปลอดภัยเฉพาะเมื่อคุณใช้ explicit lock หรือ atomic statement สำหรับ read-modify-write
Repeatable Read / Snapshot (default ของ MySQL InnoDB)+ non-repeatable readwrite skew · phantom ขึ้นกับ engineรายงานที่ต้องการ snapshot ที่นิ่ง; การอ่านหลาย statement
Serializableanomaly ทั้งหมดที่นิยามไว้ในระดับนี้—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

ทางแก้:

  1. SERIALIZABLE
  2. 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