บทที่ 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

วิธีทำ ตามลำดับนี้:

  1. ลิสต์ entity และความสัมพันธ์ — เอาคำนามจาก requirement: Account, Transfer, LedgerEntry, Device, Limit
  2. ลิสต์ access pattern เป็นประโยค ไม่ใช่เป็นตาราง แต่เป็น query: "ดึงยอดคงเหลือด้วย account_id", "ดึง transfer 20 รายการล่าสุดของ account เรียงใหม่ไปเก่า", "หา transfer ทั้งหมดที่ไปยัง counterparty รายนี้ในช่วงวันที่ เพื่อ fraud review", "รวมยอด settled ต่อ merchant ต่อวัน"
  3. ใส่ความถี่และเป้า latency ให้ทุก pattern — 500/s ที่ p99 50 ms เป็น design ที่ต่างจาก 5 ครั้ง/วัน ที่ p99 30 s โดยสิ้นเชิง
  4. เลือก key จาก pattern — primary key = pattern ที่ serve บ่อยที่สุดและด่วนที่สุด
  5. แล้วค่อยเลือก store — ลิสต์ pattern เป็นตัวตัดสิน (ตารางตัดสินใจอยู่ในบทที่ 14)

Access pattern table — กรอกก่อนวาดอะไรทั้งสิ้น

#PatternFreqp99Consistency
1get balance by account_id300/s50 msread-your-writes
2list last 20 transfers by account100/s150 mseventual (ช้า 5 s ได้)
3create transfer (write path)60/s พีค500 msstrong, atomic
4get transfer by idempotency_key60/s20 msstrong
5daily settlement sum per merchant1/วัน10 นาทีstrong ณ เวลา cutoff
6fraud: transfers by device_id ใน 24 ชม.60/s100 mseventual
7ops: ประวัติทั้งหมดของ account ย้อน 7 ปี50/วัน30 seventual

ข้อสรุปที่อ่านออกมาจากตารางนี้ได้ทันที:

  • 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 มักจะเป็นแถวใดแถวหนึ่งในตารางนี้

ComponentFailureBlast radiusDetectionMitigationRecovery
API gatewayAZ หนึ่งล่มเสีย capacity ~⅓health check, อัตรา 5xxmulti-AZ, routing ตาม healthอัตโนมัติ
Postgres primarynode ตายwrite หยุดทั้งหมดreplication lag + heartbeatautomated failover ไป sync standbywrite ใช้ไม่ได้ 30–90 s, RPO 0
Rediscache ล่ม / เย็นโหลดเต็มลง DBhit rate ตก, CPU ของ DBserve ค่าเก่า, request coalescing, load shedwarm ขึ้นทีละน้อย ห้าม stampede
Kafkaconsumer lag โตnotification ช้า เงินไม่กระทบalert lag ต่อ consumer groupเพิ่ม consumer; outbox ยังเก็บความจริงไว้ใน DBไล่ตามได้ ลำดับต่อ key ยังคงอยู่
Bank railtimeout / maintenancetransfer ใหม่ค้าง PENDINGerror rate, latency, สถานะ circuitcircuit breaker, คิวไว้แล้วค่อยระบาย, บอกผู้ใช้ตามจริงreconcile; resume อัตโนมัติเมื่อ healthy
Deployrelease เสียอาจกระทบทุกอย่างcanary metric เทียบ baselinecanary + auto-rollback, feature flagrollback ในไม่กี่นาที ไม่ใช่ hotfix
คนops ทำผิดเสี่ยงข้อมูลหายaudit log, gate 4 ตาleast privilege, ห้าม write ตรงเข้า prod DB, ต้องอนุมัติก่อนทำ destructive oppoint-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 ล่ม