บทที่ 5 · Part 1 — Foundation
Data Model to Architecture
Step 4–7 — entity/data model, การวาด high-level architecture, การเลือก deep dive และ failure analysis
โค้ดแอปพลิเคชันถูกเขียนใหม่ทุกไม่กี่ปี แต่ข้อมูลอยู่นานกว่านั้น และการย้ายข้อมูลขนาดใหญ่ขณะที่ยังต้อง serve traffic อยู่คืองานประจำที่ยากที่สุดในสายนี้ เพราะฉะนั้นขั้นที่ 4 คือขั้นที่ควรใช้สมองส่วนที่ดีที่สุดของคุณ
จบบทนี้คุณจะ
- เขียน access pattern table ได้ และรู้ว่าทำไมมันต้องมาก่อน data model
- วาด architecture diagram ที่กล่องแต่ละใบมีความหมาย (สิ่งที่ล้มได้เอง)
- trace happy path และ failure path ออกมาเป็นคำพูดได้
- เลือกได้ว่า deep dive ตรงไหน และเติม failure table ได้ครบ 6 คอลัมน์
ขั้นที่ 4 — Entity และ data model
วิธีทำ ตามลำดับนี้:
- ลิสต์ entity และความสัมพันธ์ — เอาคำนามจาก requirement:
Account,Transfer,LedgerEntry,Device,Limit - ลิสต์ access pattern เป็นประโยค ไม่ใช่เป็นตาราง แต่เป็น query: "ดึงยอดคงเหลือด้วย account_id", "ดึง transfer 20 รายการล่าสุดของ account เรียงใหม่ไปเก่า", "หา transfer ทั้งหมดที่ไปยัง counterparty รายนี้ในช่วงวันที่ เพื่อ fraud review", "รวมยอด settled ต่อ merchant ต่อวัน"
- ใส่ความถี่และเป้า latency ให้ทุก pattern — 500/s ที่ p99 50 ms เป็น design ที่ต่างจาก 5 ครั้ง/วัน ที่ p99 30 s โดยสิ้นเชิง
- เลือก key จาก pattern — primary key = pattern ที่ serve บ่อยที่สุดและด่วนที่สุด
- แล้วค่อยเลือก store — ลิสต์ pattern เป็นตัวตัดสิน (ตารางตัดสินใจอยู่ในบทที่ 14)
Access pattern table — กรอกก่อนวาดอะไรทั้งสิ้น
| # | Pattern | Freq | p99 | Consistency |
|---|---|---|---|---|
| 1 | get balance by account_id | 300/s | 50 ms | read-your-writes |
| 2 | list last 20 transfers by account | 100/s | 150 ms | eventual (ช้า 5 s ได้) |
| 3 | create transfer (write path) | 60/s พีค | 500 ms | strong, atomic |
| 4 | get transfer by idempotency_key | 60/s | 20 ms | strong |
| 5 | daily settlement sum per merchant | 1/วัน | 10 นาที | strong ณ เวลา cutoff |
| 6 | fraud: transfers by device_id ใน 24 ชม. | 60/s | 100 ms | eventual |
| 7 | ops: ประวัติทั้งหมดของ account ย้อน 7 ปี | 50/วัน | 30 s | eventual |
ข้อสรุปที่อ่านออกมาจากตารางนี้ได้ทันที:
- Pattern 1–4 เป็น OLTP ขนาดเล็ก และมี key ชัดเจน → relational database แบ่ง shard ตามช่วงของ account ก็เหลือเฟือ
- Pattern 5 เป็น analytics ที่มี cutoff → ห้ามรันบน OLTP primary ตอน 02:00 ที่ retry กำลังถล่มอยู่ ต้องแยกออกไป
- Pattern 6 ใช้ key คนละตัว (
device_idไม่ใช่account_id) → ต้องมี secondary index หรือ read model ที่สร้างมาเพื่อเรื่องนี้ - Pattern 7 เป็น cold data ที่ SLA หลวม → archive tier + object storage
นิสัยที่ต้องสร้าง
ห้ามนำเสนอ data model โดยไม่มี access pattern table วางข้างๆ ในทุก design review คำถาม "index ตัวนี้ serve query ไหน" คือสิ่งที่แยก model ที่คิดมาแล้วออกจาก diagram ที่แค่ตกแต่งไว้
และในทางกลับกัน: index ที่ไม่ serve pattern ไหนในลิสต์เลย คือภาษีที่เก็บจากทุก write — ลบมันทิ้ง
จาก pattern มาเป็น schema
-- Pattern 1: ยอดคงเหลือ
-- balance_minor เป็น "snapshot ที่ derive มา" ไม่ใช่ source of truth —
-- source of truth คือ ledger_entries ด้านล่าง (เหตุผลเต็มอยู่ในบทที่ 16)
CREATE TABLE accounts (
account_id TEXT PRIMARY KEY,
currency CHAR(3) NOT NULL,
status TEXT NOT NULL,
balance_minor BIGINT NOT NULL DEFAULT 0, -- snapshot ณ last_entry_id
last_entry_id TEXT, -- watermark ของ snapshot
version BIGINT NOT NULL DEFAULT 0, -- optimistic locking
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT accounts_balance_non_negative CHECK (balance_minor >= 0)
);
-- Pattern 3, 4: transfer + idempotency
CREATE TABLE transfers (
transfer_id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL,
request_hash BYTEA NOT NULL, -- hash ของ canonical request body
idem_expires_at TIMESTAMPTZ NOT NULL, -- อายุของ key (เช่น +72 ชม.)
source_account_id TEXT NOT NULL REFERENCES accounts(account_id),
amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
currency CHAR(3) NOT NULL,
status TEXT NOT NULL, -- PENDING/SETTLED/REJECTED/UNKNOWN/REVERSED
device_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT transfers_idem UNIQUE (source_account_id, idempotency_key)
);
-- Pattern 2: list ล่าสุดของ account — index ตรงกับ ORDER BY
CREATE INDEX transfers_by_account_recent
ON transfers (source_account_id, created_at DESC, transfer_id DESC);
-- Pattern 6: fraud ใช้ key คนละตัว
CREATE INDEX transfers_by_device_recent
ON transfers (device_id, created_at DESC) WHERE device_id IS NOT NULL;
-- source of truth ของเงิน: append-only, double-entry, รวมกันได้ 0
CREATE TABLE ledger_entries (
entry_id TEXT PRIMARY KEY,
transaction_id TEXT NOT NULL, -- = transfer_id สำหรับ transfer
account_id TEXT NOT NULL REFERENCES accounts(account_id),
direction TEXT NOT NULL CHECK (direction IN ('DEBIT','CREDIT')),
amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
currency CHAR(3) NOT NULL,
entry_type TEXT NOT NULL, -- TRANSFER|FEE|REVERSAL|ADJUSTMENT|SETTLEMENT
effective_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ledger_by_account ON ledger_entries (account_id, entry_id);
-- ทางออกเดียวของ event: insert ใน transaction เดียวกับการเปลี่ยนแปลงธุรกิจ
CREATE TABLE outbox (
event_id TEXT PRIMARY KEY,
aggregate_id TEXT NOT NULL, -- key ของ message = ลำดับต่อ entity
topic TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
-- partial index: hot set เล็กจิ๋วแม้ตารางจะใหญ่มาก
CREATE INDEX outbox_unpublished ON outbox (created_at)
WHERE published_at IS NULL;
`UNIQUE (source_account_id, idempotency_key)` ไม่ใช่รายละเอียดเล็ก
constraint บรรทัดนี้คือสิ่งที่ทำให้การจ่ายซ้ำ เป็นไปไม่ได้ในระดับฐานข้อมูล ไม่ใช่แค่ "ไม่น่าเกิดในระดับ application" ทุกครั้งที่กฎความถูกต้องเรื่องเงิน สามารถบังคับด้วย constraint ได้ ให้บังคับด้วย constraint — โค้ดที่ check-then-insert จะแพ้ race condition เสมอ
[!TIP] ทำไมต้องมี request_hash ไม่ใช่เก็บแค่ key
Contract ในบทที่ 4 กำหนดว่า key เดิม + body ต่าง → 409
ซึ่งเทียบไม่ได้เลยถ้าเก็บแค่ key
ให้ hash canonical form ของ body (เรียง field, normalize ตัวเลข/เวลา)
แล้วเทียบตอนเจอ key ซ้ำ: hash ตรง → คืนผลลัพธ์เดิม · hash ต่าง → 409
และต้องระบุ scope ของ key (ที่นี่คือต่อ source_account_id) กับ TTL
ให้ชัดใน contract — key ที่ไม่มี scope ทำให้ client 2 รายชนกันได้
และ key ที่ไม่มี TTL ทำให้ตารางโตตลอดกาล
ขั้นที่ 5 — วาด architecture
Diagram 1 ใบ กฎมี 2 ข้อ: กล่อง = สิ่งที่ล้มได้อย่างเป็นอิสระ, ลูกศร = การเรียก พร้อมกำกับ protocol และบอกว่า synchronous หรือไม่
Trace happy path ออกมาเป็นคำพูด
"โอน 250 บาท" — happy path
1. POST /v1/transfers พร้อม Idempotency-Key (gateway: authn, rate limit)
2. Transfer Service: SELECT by idempotency_key (dedupe; ถ้าเจอคืนผลเดิม)
3. BEGIN
ตรวจ limit
INSERT transfer (PENDING)
INSERT ledger debit + credit
INSERT outbox row
COMMIT (หน่วย atomic 1 หน่วย)
4. ตอบ 201 PENDING ให้ client (เป้า p99 500 ms)
5. Outbox publisher อ่าน row ที่ commit แล้ว -> Kafka (async ~100 ms)
6. Rail adapter เรียกธนาคาร retry แบบ backoff แล้ว update status
7. TransferSettled event -> Notify Service -> push notification
8. 02:00 reconciliation เทียบ ledger ของเรากับรายงานของ rail
Trace failure path ด้วย
"ธนาคาร timeout ที่ขั้นที่ 6"
- status ยังเป็น PENDING (ไม่เคยเป็น SUCCESS ไม่เคยเป็น FAILED)
- adapter retry ด้วย reference ฝั่ง rail ตัวเดิม (idempotent)
- ครบ N ครั้ง -> status UNKNOWN, alert ops, ตัดออกจาก auto-retry
- reconciliation ที่ขั้นที่ 8 ปิดสถานะเป็น SETTLED หรือ REVERSED
- ลูกค้าเห็นคำว่า "กำลังดำเนินการ" ไม่ใช่ยอดเงินที่ผิด
พูด request trace ออกมาดังๆ
Diagram นิ่ง แต่ระบบเคลื่อนไหว การ trace happy path 1 เส้นและ failure path 1 เส้น ออกเสียงทีละขั้น คือวิธีหารูที่เร็วที่สุด
9 ใน 10 ครั้ง รูจะโผล่ที่ขอบเขตที่คุณวาดเป็นลูกศรเส้นเดียว — "แล้วเราก็เรียกธนาคาร" — ซึ่งความจริงคือ state machine 1 ตัว, timeout 1 ชุด, และ reconciliation job 1 ตัว
[!WARNING] กล่องที่ไม่มี requirement ข้อไหนต้องการ ทุกกล่องในไดอะแกรมต้องชี้กลับไปที่ requirement ได้ ถ้าชี้ไม่ได้ ให้ลบ service mesh, Kafka, Elasticsearch ที่ใส่มาเพราะ "เดี๋ยวก็ได้ใช้" คือของที่คุณต้อง on-call ดูแลตั้งแต่วันแรกโดยไม่ได้ประโยชน์อะไร
ขั้นที่ 6 — เลือก deep dive
คุณ deep dive ทุกอย่างไม่ได้ เลือก 2–3 sub-problem ที่ผิดแล้วแพง เกณฑ์การเลือก เรียงตามลำดับความสำคัญ:
| ลำดับ | เกณฑ์ | ตัวอย่าง |
|---|---|---|
| 1 | ความถูกต้องเป็นเดิมพัน | เงิน, identity, authorization, อะไรที่ regulator หรือ auditor จะอ่าน |
| 2 | โหลดกระจุกตัว | hot key, ตารางที่ทุกคนใช้ร่วมกัน, ของที่เป็น single-thread, rate limit ของ third party |
| 3 | ตัดสินใจแล้วกลับยาก | partition key, event schema, public API, สัญญา vendor |
| 4 | จุดที่คุณมั่นใจน้อยที่สุด | พูดออกมาตรงๆ แล้วเสนอ spike หรือ load test |
สำหรับตัวอย่าง transfer ข้างบน deep dive จะเป็น: (ก) exactly-once semantics ด้วย idempotency key, (ข) schema ของ ledger และการคำนวณยอดคงเหลือโดยไม่มี hot row, (ค) flow ของสถานะ unknown และ reconciliation — ทั้ง 3 เรื่องถูกเดินให้ครบในบทที่ 18
วิธีพูดตอนที่ยังไม่มั่นใจ
"จุดที่ผมไม่มั่นใจที่สุดคือ throughput ของ ledger insert ที่พีค ผมจะทำ load test บน schema จริงภายในสัปดาห์นี้ และจะกลับมาบอกตัวเลข" — คำตอบแบบนี้ทำให้คุณดูน่าเชื่อถือขึ้น ไม่ใช่ลดลง
ขั้นที่ 7 — Failure และ evolution
เติมตารางนี้ให้ทุก design ใช้เวลา 20 นาที และเป็น 20 นาทีที่คุ้มที่สุดในกระบวนการทั้งหมด เพราะคำถามแรกของ reviewer มักจะเป็นแถวใดแถวหนึ่งในตารางนี้
| Component | Failure | Blast radius | Detection | Mitigation | Recovery |
|---|---|---|---|---|---|
| API gateway | AZ หนึ่งล่ม | เสีย capacity ~⅓ | health check, อัตรา 5xx | multi-AZ, routing ตาม health | อัตโนมัติ |
| Postgres primary | node ตาย | write หยุดทั้งหมด | replication lag + heartbeat | automated failover ไป sync standby | write ใช้ไม่ได้ 30–90 s, RPO 0 |
| Redis | cache ล่ม / เย็น | โหลดเต็มลง DB | hit rate ตก, CPU ของ DB | serve ค่าเก่า, request coalescing, load shed | warm ขึ้นทีละน้อย ห้าม stampede |
| Kafka | consumer lag โต | notification ช้า เงินไม่กระทบ | alert lag ต่อ consumer group | เพิ่ม consumer; outbox ยังเก็บความจริงไว้ใน DB | ไล่ตามได้ ลำดับต่อ key ยังคงอยู่ |
| Bank rail | timeout / maintenance | transfer ใหม่ค้าง PENDING | error rate, latency, สถานะ circuit | circuit breaker, คิวไว้แล้วค่อยระบาย, บอกผู้ใช้ตามจริง | reconcile; resume อัตโนมัติเมื่อ healthy |
| Deploy | release เสีย | อาจกระทบทุกอย่าง | canary metric เทียบ baseline | canary + auto-rollback, feature flag | rollback ในไม่กี่นาที ไม่ใช่ hotfix |
| คน | ops ทำผิด | เสี่ยงข้อมูลหาย | audit log, gate 4 ตา | least privilege, ห้าม write ตรงเข้า prod DB, ต้องอนุมัติก่อนทำ destructive op | point-in-time restore ที่ทดสอบแล้ว |
แล้วเพิ่มอีก 3 เรื่องที่ตารางไม่ครอบคลุม:
1. SLO และ error budget — สัญญาว่าอะไร วัดอย่างไร และจะเกิดอะไรเมื่อ budget หมด (ดูบทที่ 11)
2. Rollout plan — flag ปิด → ผู้ใช้ภายใน → 1% → 10% → 100% พร้อม metric ที่เป็น gate ของแต่ละขั้น และเงื่อนไข rollback
ใครกดปุ่ม release
ในองค์กรที่ถูกกำกับดูแล ให้เขียนไว้ใน design doc ว่า ใครคือผู้มีอำนาจอนุมัติการ release ขึ้น production architect เป็นคนเสนอ ผู้มีอำนาจเป็นคนตัดสิน — และการอนุมัติต้องมีร่องรอย
3. Evolution — ถ้าโหลดเป็น 10 เท่าจะเปลี่ยนอะไร ถ้า 100 เท่าจะเปลี่ยนอะไร ข้อนี้พิสูจน์ว่า design มีอนาคตโดยที่คุณไม่ต้อง build อนาคตนั้นวันนี้
EVOLUTION (เขียน 2 ย่อหน้าพอ)
ที่ 10x (3,000 tps):
- แยก read path ของ balance ไปที่ read replica + cache แบบ write-through
- partition ตาราง ledger_entries by month
- เพิ่ม consumer group ของ rail adapter และแยก task queue ต่อ rail
- ยังใช้ Postgres ตัวเดียว (primary) — ยังไม่ต้อง shard
ที่ 100x (30,000 tps):
- shard by account_id (hash) — ต้องเตรียมตั้งแต่วันนี้โดยห้ามมี query
ที่ join ข้าม account
- แยก ledger ออกเป็น service ของตัวเอง
- ย้าย settlement/analytics ไป columnar store ผ่าน CDC
--> ข้อนี้ยังไม่ build วันนี้ แต่ข้อจำกัด "ห้าม join ข้าม account"
บังคับใช้ตั้งแต่วันนี้ เพราะเป็นสิ่งที่แก้ทีหลังแพงที่สุด
Checklist ปิดขั้นที่ 4–7
- Access pattern table มีทุก pattern พร้อม freq / p99 / consistency
- ทุก index ชี้กลับไปที่ pattern ได้
- กฎความถูกต้องเรื่องเงินถูกบังคับด้วย DB constraint ไม่ใช่แค่โค้ด
- Diagram: ทุกกล่องล้มได้อย่างเป็นอิสระ ทุกลูกศรกำกับ protocol + sync/async
- ทุกกล่องชี้กลับไปที่ requirement ได้
- Trace happy path 1 เส้นและ failure path 2 เส้นเป็นลายลักษณ์อักษร
- Deep dive 2–3 เรื่องตามเกณฑ์ ไม่ใช่ตามความชอบ
- Failure table ครบ 6 คอลัมน์ และมีแถว "คน"
- SLO + error budget + rollout plan + ผู้อนุมัติ release
- ย่อหน้า evolution ที่ 10x และ 100x
สรุปบทนี้
Access pattern มาก่อน schema · schema มาก่อนกล่อง · กล่องต้องล้มได้เองและต้องมีเหตุผล ·
trace ทั้ง happy และ failure ออกมาเป็นคำพูด · deep dive ที่ผิดแล้วแพง ·
และ failure table ต้องมีแถว "คน" เพราะ ops ที่พิมพ์ DELETE ผิด
คือ failure mode ที่เกิดจริงบ่อยกว่า AZ ล่ม