บทที่ 16 · Part 4 — Data & Storage

Integrity: Outbox, CDC & Ledger

Dual-write problem, transactional outbox, CDC, saga, double-entry ledger สำหรับเงิน และ retention/privacy by design

ถ้าคุณทำงานสาย fintech บทนี้เป็นหน้าที่มีค่าที่สุดในคอร์ส ยอดเงินใน wallet ที่เก็บเป็นตัวเลขที่แก้ได้ในคอลัมน์เดียว คือความผิดพลาดเชิงออกแบบที่ร้ายแรงและพบบ่อยที่สุดในอุตสาหกรรมนี้

จบบทนี้คุณจะ

  • เข้าใจ dual-write problem และแก้ด้วย transactional outbox ได้
  • รู้ว่าเมื่อไหร่ใช้ outbox เมื่อไหร่ใช้ CDC
  • ออกแบบ saga ด้วย reserve → confirm/cancel ไม่ใช่ "ทำแล้วค่อยแก้"
  • ออกแบบ double-entry ledger ที่ตรวจสอบได้ และยอดเงินที่ derive มา
  • วางเรื่อง retention, การลบข้อมูล และ PDPA เป็นสถาปัตยกรรม ไม่ใช่เอกสาร
  • ทำ schema migration แบบไม่ต้องปิดระบบด้วย expand–migrate–contract

Dual-write problem — bug ข้อมูลกระจายที่พบบ่อยที่สุด

// ดูสมเหตุสมผลทุกประการ และมันพัง
db.save(transfer);              // สำเร็จ
kafka.publish(transferCreated); // process ตายที่นี่ หรือ broker ล่ม

ผลลัพธ์: ฐานข้อมูลบอกว่ามี transfer นี้ แต่ไม่มีระบบปลายน้ำไหนรู้
notification ไม่เคยส่ง, read model ไม่เคย update, partner ไม่เคยรู้
และ *ไม่มี error เกิดขึ้นเลย* คุณรู้เรื่องจากลูกค้า

สลับลำดับก็ไม่ช่วย — กลายเป็น publish event ของ transfer
ที่ไม่เคยถูกบันทึก (phantom event)

ไม่มีลำดับใดของการเขียน 2 ที่แบบอิสระที่ปลอดภัย
คุณต้องมี *การเขียนที่ atomic 1 ครั้ง* บวก *relay ที่เชื่อถือได้*

Transactional outbox — ทางแก้ และมันเรียบง่าย

BEGIN;
  INSERT INTO transfers (...) VALUES (...);
  INSERT INTO ledger_entries (...) VALUES (...), (...);
  INSERT INTO outbox (event_id, aggregate_id, topic, payload, created_at)
       VALUES ($1, $2, 'payments.transfer.v1', $3, now());
COMMIT;   -- หน่วย atomic 1 หน่วย ฐานข้อมูลเดียว
-- Relay process แยก ทำงานต่อเนื่อง
SELECT event_id, topic, payload, aggregate_id
  FROM outbox
 WHERE published_at IS NULL
 ORDER BY created_at
 LIMIT 500
   FOR UPDATE SKIP LOCKED;      -- ให้ relay worker หลายตัวรันได้อย่างปลอดภัย

-- --> publish ไป broker
UPDATE outbox SET published_at = now() WHERE event_id = ANY($1);

การรับประกันที่ได้

event มีอยู่ก็เมื่อและเมื่อการเปลี่ยนแปลงทางธุรกิจ commit สำเร็จเท่านั้น

การ publish เป็น at-least-once (crash หลัง publish แต่ก่อน UPDATE จะทำให้ publish ซ้ำ) → consumer ต้อง idempotent ซึ่งมันต้องเป็นอยู่แล้วตั้งแต่ต้น (บทที่ 9)

หมายเหตุเชิง operational ที่ต้องมี

เรื่องสิ่งที่ต้องทำ
IndexCREATE INDEX ... ON outbox (created_at) WHERE published_at IS NULL; — hot set เล็กจิ๋วแม้ตารางจะใหญ่มาก
การล้างลบหรือ archive แถวที่ publish แล้วตามตาราง ไม่อย่างนั้นตารางโตไม่หยุดและ vacuum จะทรมาน
Alertอายุของแถวที่เก่าสุดที่ยังไม่ publish > 30 s — alert ตัวเดียวนี้จับ integration failure แบบเงียบได้ทั้งคลาส
Orderingถ้า consumer ต้องการลำดับต่อ entity ให้ relay ตามลำดับ (aggregate_id, created_at) และ key message ด้วย aggregate_id

Alert ที่ขาดบ่อยที่สุดในระบบที่มี outbox

ถ้า relay ตายเงียบๆ ทุกอย่างในฐานข้อมูลยังถูกต้องสมบูรณ์ แต่โลกภายนอกหยุดรู้เรื่อง — notification ไม่ไป, partner ไม่ได้รับแจ้ง, read model ค้าง และ dashboard ทุกตัวยังเขียว

max(now() - created_at) WHERE published_at IS NULL คือ metric ที่ต้องมี

อ้างอิง: Transactional Outbox pattern · Transactionally-staged job drains (Brandur Leach — แนวคิดเดียวกันใช้กับ background job อธิบายได้ดีมาก)

Change data capture (CDC)

แทนที่จะมีตาราง outbox ให้อ่าน replication log ของฐานข้อมูล แล้วเปลี่ยนการเปลี่ยนแปลงทุกแถวเป็น event (Debezium เป็นเครื่องมือมาตรฐาน)

OutboxCDC
จุดแข็งคุณควบคุม contract — event เป็นรูปแบบธุรกิจ (TransferSettled)ไม่ต้องแก้ application; จับ write จากทุกแหล่งรวมถึงการแก้ด้วยมือ; ได้ change stream ครบสำหรับ read model และ analytics
จุดอ่อนต้องเขียนโค้ดฝั่ง application; ต้องดูแล relayevent เป็นรูปแบบแถว ไม่ใช่รูปแบบธุรกิจ ("row updated" ไม่ใช่ "TransferSettled") จึงทำให้ consumer ผูกกับ schema ของคุณ; schema migration แผ่ผลออกไปข้างนอก; เป็น infrastructure จริงที่ต้องดูแล

กฎเชิงปฏิบัติ

ใช้ outbox สำหรับ business event ที่ service อื่นตอบสนอง (คุณคุม contract) · ใช้ CDC สำหรับ replicate ข้อมูลไป analytics, search และ cache

ระบบที่โตแล้วหลายระบบใช้ทั้งคู่ โดยเจตนา — Debezium documentation

Saga — transaction ข้าม service

เมื่อ business operation หนึ่งกินหลาย service คุณใช้ database transaction ไม่ได้ Saga คือลำดับของ local transaction ที่แต่ละตัวมี compensating action ถ้าขั้นถัดไปล้ม

ORCHESTRATED SAGA — "ซื้อประกันด้วยยอดเงินใน wallet"

  1. reserve funds        (wallet)     comp: release reservation
  2. create policy        (insurance)  comp: void policy
  3. capture funds        (wallet)     comp: refund
  4. issue documents      (docs)       comp: revoke + notify

Orchestrator ถือ state machine เรียกแต่ละขั้น
และเมื่อล้มก็รัน compensation ย้อนกลับ

Choreography (แต่ละ service ตอบสนองต่อ event) เลี่ยง component กลางได้
แต่ทำให้ flow ทั้งหมด *มองไม่เห็น* — ไม่มีใครตอบได้ว่า
"ใบสมัครนี้ค้างอยู่ที่ขั้นไหน"

กฎที่ได้มาด้วยความเจ็บปวด

7 กฎของ saga

1. ใช้ RESERVE → CONFIRM/CANCEL ไม่ใช่ "ทำเลยแล้วค่อย undo" reservation ยกเลิกได้ · การหักเงินที่เสร็จแล้วต้อง refund ซึ่งเป็น transaction อีกใบที่มีการบันทึกบัญชีต่างกันและอาจมีค่าธรรมเนียมต่างกัน

2. Compensation ไม่ใช่ rollback — มันคือ ข้อเท็จจริงทางธุรกิจใหม่ที่มองเห็นได้ การคืนเงินจะปรากฏบน statement ของลูกค้า ออกแบบการสื่อสารกับลูกค้าด้วย ไม่ใช่แค่โค้ด

3. ทุกขั้นต้อง idempotent และทุกขั้นต้องถูก retry

4. Reservation ต้องมีวันหมดอายุ (TTL) ไม่อย่างนั้น saga ที่ crash จะถือเงินของลูกค้าไว้ตลอดกาล — และต้องมี sweeper เก็บ reservation ที่หมดอายุ พร้อม alert ถ้า sweeper เจอเยอะเกินไป

5. Saga ไม่ให้ isolation ระหว่างขั้น 1 ถึง 3 คนอ่านคนอื่นอาจเห็นสถานะกลางทาง ตัดสินใจว่าเขาเห็นอะไร: สถานะ pending, แยก "available balance" กับ "total balance", หรือซ่อนทั้งหมด

6. สำหรับอะไรที่กินเวลานาน (KYC, ข้อพิพาท, การตัดสินสินเชื่อ) ใช้ durable workflow engine ดีกว่าเขียนมือใน cron job กับคอลัมน์ status — คอร์สTemporal for Fintech ลงรายละเอียดเรื่องนี้ทั้งคอร์ส

7. การตัดสินใจว่าจะ compensate หรือรอต่อ สำหรับรายการที่ค้างเกินเกณฑ์ เป็นเรื่องที่ต้องมีคนที่มีอำนาจอนุมัติ ไม่ใช่ timeout ในโค้ด

อ้างอิง: Saga pattern · Patterns of Distributed Systems

ออกแบบข้อมูลเงิน: ledger

ผิด — balance เป็นคอลัมน์ที่แก้ได้

accounts(account_id, balance_minor)
UPDATE accounts SET balance_minor = balance_minor - 30000;
ทำไมมันพัง — ในทางปฏิบัติ ทุกครั้ง:
 - ไม่มีประวัติ "ทำไมยอดผมเหลือ 4,231" -> ตอบไม่ได้
 - ไม่มี audit trail auditor ขอที่มาของตัวเลข คุณไม่มีให้
 - 1 แถวต่อ 1 account = lock hot spot บน account ที่ยุ่งที่สุด
 - bug ที่เขียนค่าผิดกู้คืนไม่ได้ ความจริงก่อนหน้านั้นหายไปแล้ว
   ไม่มีอะไรให้ reconcile เทียบด้วย
 - เงินถูกสร้างหรือทำลายได้ด้วย transaction ที่ขาดไป 1 ใบ
   โดยไม่มีความไม่สมดุลที่ไหนให้ตรวจจับได้

ถูก — immutable double-entry ledger + balance ที่ derive มา

-- ทุกการเคลื่อนย้ายคือ entry 2 ใบหรือมากกว่า ที่ *รวมกันได้ 0*
-- ไม่มีการ update และไม่มีการ delete การแก้ไขคือ entry ใบใหม่

CREATE TABLE ledger_entries (
  entry_id        TEXT        PRIMARY KEY,          -- ULID, immutable
  transaction_id  TEXT        NOT NULL,             -- จัดกลุ่ม entry ของการเคลื่อนย้ายเดียว
  account_id      TEXT        NOT NULL,
  direction       TEXT        NOT NULL CHECK (direction IN ('DEBIT','CREDIT')),
  amount_minor    BIGINT      NOT NULL CHECK (amount_minor > 0),
  currency        CHAR(3)     NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  effective_at    TIMESTAMPTZ NOT NULL,             -- วันที่ทางธุรกิจต่างจากวันที่ระบบได้
  entry_type      TEXT        NOT NULL              -- TRANSFER|FEE|REVERSAL|ADJUSTMENT|SETTLEMENT
);

CREATE TABLE transactions (
  transaction_id  TEXT        PRIMARY KEY,
  idempotency_key TEXT        NOT NULL,
  status          TEXT        NOT NULL,             -- PENDING|SETTLED|REJECTED|UNKNOWN|REVERSED
  external_ref    TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT transactions_idem UNIQUE (idempotency_key)
);

Invariant แกนกลาง ที่ตรวจได้ทุกเมื่อ

-- ต้องคืน "0 แถว" เสมอ — รันเป็น monitor ต่อเนื่อง
SELECT transaction_id,
       SUM(CASE WHEN direction = 'DEBIT' THEN amount_minor
                ELSE -amount_minor END) AS imbalance
  FROM ledger_entries
 GROUP BY transaction_id
HAVING SUM(CASE WHEN direction = 'DEBIT' THEN amount_minor
                ELSE -amount_minor END) <> 0;

ทำไม invariant นี้มีค่ามหาศาล

ถ้ามันคืนแถวขึ้นมา คุณมี bug — และคุณหาตัวมันได้ นี่คือความต่างระหว่างระบบที่ "อาจมีเงินหายที่ไหนไม่รู้" กับระบบที่ "รู้ว่าผิดที่ transaction ไหน ภายในไม่กี่นาที"

ตัวอย่าง: โอน 250.00 บาท จาก A ไป B พร้อมค่าธรรมเนียม 2.00

DEBIT   A            25200
CREDIT  B            25000
CREDIT  fee_income     200
--> 25200 - 25000 - 200 = 0  รวมกันได้ 0

สังเกต fee account

เงินไม่เคยหายไปในค่าธรรมเนียม — มันย้ายไปยัง account ที่คุณเป็นเจ้าของ นั่นคือสิ่งที่ทำให้บัญชีสมดุล

ทุกครั้งที่มีเงินออกจากที่หนึ่ง ต้องมีที่ปลายทางที่ระบุได้ว่ามันไปไหน รวมถึงค่าธรรมเนียม, ภาษี, ส่วนลด และ VAT

Balance — derive มา ไม่เคยเก็บเป็นความจริง

-- นิยาม
balance(account) = SUM(credits) - SUM(debits)

-- ที่ scale จริง ห้าม sum entry ย้อนหลัง 10 ปีทุกครั้งที่อ่าน — ใช้ snapshot
CREATE TABLE account_balances (
  account_id     TEXT        NOT NULL,
  currency       CHAR(3)     NOT NULL,
  balance_minor  BIGINT      NOT NULL,   -- ณ ...
  last_entry_id  TEXT        NOT NULL,   -- ... entry นี้ (watermark)
  updated_at     TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (account_id, currency)
);

-- Read path: snapshot + SUM(entry ที่ใหม่กว่า last_entry_id)

Snapshot คือ cache ของการคำนวณ และมันตรวจสอบได้

รัน job กลางคืนที่คำนวณใหม่จากศูนย์แล้วเทียบ ถ้าต่างกันให้ alert เสียงดัง — คุณจะเจอ bug จริง เจอเร็ว และเจอพร้อมหลักฐาน

กฎของข้อมูลเงิน — บทเรียนที่อุตสาหกรรมจ่ายค่าเรียนไปแล้ว

กฎเหตุผล
Integer เท่านั้น — หน่วยย่อย (สตางค์) และ currency เก็บคู่กับจำนวนเสมอห้าม float ไม่ว่าที่ไหน แม้แต่ในรายงาน
Append-only — ไม่มี UPDATE ไม่มี DELETE บน ledger entryความผิดพลาดแก้ด้วย reversal entry ที่อ้างถึงใบเดิม · entry ที่ผิดยังมองเห็นได้ — นั่นคือฟีเจอร์ และ auditor ต้องการมัน
ทุก transaction มี idempotency key ที่มี UNIQUE constraintฐานข้อมูล ไม่ใช่โค้ดของคุณ เป็นตัวกันการ post ซ้ำ
แยก authorized / pending / settledการ authorize บัตรไม่ใช่การ settle · แสดงทั้ง "available balance" (settled − holds) และ "total balance" และระบุชัดว่าทุกหน้าจอและทุก API field หมายถึงอันไหน
Reconcile กับโลกภายนอกทุกวันledger ของคุณเทียบรายงานของธนาคาร/rail · ทำเป็นอัตโนมัติ, alert ทุก break, และมีคิวที่มีเจ้าของสำหรับ break
ห้ามให้แถว account เดียวเป็นคอขวด throughputเพราะ entry ถูก append (ไม่ใช่ update แถวเดียว) transaction พร้อมกันบน account เดียวกันจึงไม่แย่ง lock แถวเดียว — ประโยชน์ข้อที่ 2 ของ design นี้ที่คนมองข้าม
Model สถานะ unknownเมื่อ external rail timeout สถานะคือ UNKNOWN ไม่ใช่ failed · ออกจากสถานะนั้นได้ทาง reconciliation หรือการ query สถานะจาก rail อย่างชัดแจ้งเท่านั้น — ไม่ใช่ด้วยการเดาหรือ default ของ timeout

Reconciliation ไม่ใช่งานบัญชีที่แปะเพิ่มทีหลัง

มันเป็น component ทางสถาปัตยกรรม ที่มี storage ของตัวเอง, SLA ของตัวเอง และ alert ของตัวเอง — ออกแบบเข้าไปตั้งแต่วันแรก (เดินให้ครบในบทที่ 18)

[!TIP] อ้างอิงเรื่อง ledger design

  • Accounting for Developers — Modern Treasury · ซีรีส์หลายตอนที่สอน double-entry bookkeeping ให้ engineer เริ่มที่นี่ถ้าศัพท์บัญชียังใหม่: debit/credit, chart of accounts, T-account
  • TigerBeetle documentation — ฐานข้อมูลที่สร้างมาเพื่อ double-entry accounting เท่านั้น แม้ไม่ได้ใช้ เอกสารของพวกเขาอธิบายได้ดีว่าทำไม workload การเงินต่างจากอย่างอื่น (two-phase transfer, pending transfer, strict serializability, contention ของ hot account)
  • Designing robust and predictable APIs with idempotency — Stripe · และ Implementing Stripe-like idempotency keys in Postgres ของ Brandur ที่เดิน table design และเทคนิค recovery point สำหรับ operation หลายขั้น
  • Building a modern bank backend — Monzo · มุมมองตรงไปตรงมาเรื่องการรันบริการธนาคารบนสถาปัตยกรรมกระจาย

Data lifecycle, retention และ privacy by design

Tiering — คำตอบของ "ฐานข้อมูลเรา 8 TB แล้ว"

TierอายุStoreQuery latencyต้นทุนเทียบ
Hot0–90 วันOLTP primary มี indexmsสูงสุด
Warm90 วัน–2 ปีpartitioned table หรือ archive DB / columnar แยกวินาที~10× ถูกกว่า
Cold2–10 ปีobject storage เป็น Parquet, query ตามต้องการวินาที–นาที~100× ถูกกว่า
Frozenเท่าที่กฎกำหนดขั้นต่ำarchive storage class, immutable, encryptedชั่วโมงถูกสุด

Partition ตามเวลาคือกลไกที่ทำให้ tiering เป็นไปได้จริง

การ drop partition เกิดขึ้นทันที ในขณะที่ DELETE FROM ... WHERE created_at < ... บน 500 ล้านแถว เป็น operation ที่กินทั้งคืน ทำให้ตารางบวม และล้มฐานข้อมูลได้

ออกแบบ partition scheme ก่อนตารางจะใหญ่ — ทำทีหลังต้องย้ายข้อมูลทั้งตาราง

Privacy และ retention เป็นสถาปัตยกรรม

Data minimisation — ไม่เก็บสิ่งที่ไม่ต้องใช้ ไม่ copy มันไปอีก 3 ระบบ ทุกสำเนาคือพื้นที่ข้อมูลรั่วและภาระการลบ ภายใต้ PDPA ของไทย ภาระผูกพันเดินตามข้อมูลไปทุกที่ที่มันไป — รวมถึง data lake, log, backup และ vendor ด้าน analytics ของคุณ

Retention period ต้องมาจากเจ้าของที่รับผิดชอบ

เอกสารทางการเงิน, เอกสาร KYC, บันทึกความยินยอม และ access log มักมีระยะเวลาที่กฎกำหนดต่างกัน และบางอย่างนานมาก

ขอแต่ละข้อเป็นลายลักษณ์อักษรจาก Legal/Compliance, บันทึกใน design doc พร้อมวันที่และชื่อ, และ implement เป็น automated lifecycle rule ไม่ใช่งานที่ต้องทำมือ

ห้าม derive ระยะเวลา retention จากบทความในบล็อก และห้ามให้ engineer คิดขึ้นเอง

การลบยากกว่าที่คิด — คำขอลบข้อมูลต้องไปถึง primary store, read model, search index, cache, message backlog, analytics warehouse, log และ backup

แนวทางที่ใช้กันจริง:
  ระบบที่ยังใช้งาน     -> hard delete
  ชั้นที่ immutable/backup -> crypto-shredding
                          (เข้ารหัสข้อมูลของแต่ละ subject ด้วย key ของ subject นั้น
                           แล้วทำลาย key)

ความขัดแย้งที่ architect ไม่ใช่คนตัดสิน

ระยะเวลาเก็บข้อมูลทางการเงินที่กฎบังคับอาจขัดกับคำขอลบข้อมูล ความขัดแย้งนั้นแก้โดย Legal ไม่ใช่ architect

หน้าที่ของคุณคือสร้างความสามารถให้ทำได้ทั้ง 2ทาง และบันทึกว่าใช้ทางไหน

Card data — วิธีที่ถูกที่สุดในการปฏิบัติตามกฎของ card scheme คือ ไม่เก็บ PAN เลย · tokenize ที่ขอบเขต, เก็บข้อมูลบัตรไว้ในขอบเขตที่แคบและแยกออกมา, และทำให้มันไปถึง log, trace, error report และ analytics ของคุณไม่ได้

Scope reduction เป็นการตัดสินใจทางสถาปัตยกรรมที่มีผลต่อต้นทุน audit โดยตรง

ยิ่งขอบเขตที่แตะข้อมูลบัตรแคบ ยิ่งมีระบบน้อยที่ต้องผ่านการตรวจ — นี่คือเหตุผลทางธุรกิจที่ชัดเจนในการแยก service (บทที่ 6)

Encryption — TLS ทุกที่รวม hop ภายใน · encryption at rest ด้วย managed key · field-level encryption สำหรับ attribute ที่อ่อนไหวที่สุด (เลขบัตรประชาชน, เลขบัญชี) เพื่อให้ database dump เพียงอย่างเดียวไม่พอที่จะเปิดเผยมัน

Alert ที่จับการขโมยข้อมูลได้

Log และ alert เมื่อมีการอ่านตารางที่อ่อนไหวเป็นจำนวนมาก — การขโมยข้อมูลออกจำนวนมากมีหน้าตาเหมือน query ที่ถูกต้องตามกฎหมาย ที่ถูกรันซ้ำหลายครั้ง

Test data — ข้อมูลส่วนบุคคลจาก production ห้าม copy ลง development หรือ test ใช้ข้อมูลสังเคราะห์หรือข้อมูลที่ mask แบบย้อนกลับไม่ได้ — เป็นทั้งข้อกำหนด compliance ในระบอบกฎหมายส่วนใหญ่ และเป็นมาตรการป้องกันการรั่วที่แท้จริง

Schema migration แบบไม่ต้องปิดระบบ

Pattern expand–migrate–contract ที่คุณจะใช้ไปตลอดอาชีพ

1. EXPAND    เพิ่มคอลัมน์/ตารางใหม่ ให้เป็น nullable ยังไม่ใส่ constraint
             deploy โค้ดที่ *เขียนทั้งเก่าและใหม่* แต่ *อ่านของเก่า*

2. BACKFILL  copy ข้อมูลย้อนหลังเป็น batch เล็กๆ แบบมี throttle
             และมีตาราง progress เพื่อให้ resume ได้
             *ห้าม UPDATE ก้อนเดียวใหญ่ๆ* — มันล็อก, ทำให้บวม, และหยุดกลางทางไม่ได้

3. VERIFY    เทียบเก่ากับใหม่ ตัวอย่างก่อน แล้วทั้งหมด
             shadow-read: serve จากของเก่า, คำนวณจากของใหม่, log ความต่าง
             *ห้ามไปต่อขณะที่ยังมีความต่าง*

4. SWITCH    deploy โค้ดที่ *อ่านของใหม่* แต่ยังเขียนทั้งคู่
             นี่คือจุดที่ย้อนกลับได้ — flag พลิกกลับได้

5. CONTRACT  หยุดเขียนของเก่า แล้ว (อีกนานหลังจากนั้น) ค่อยลบ

กฎของ migration

กฎเหตุผล
ทุกขั้น deploy แยกกันได้และย้อนกลับได้ถ้าขั้นไหนพัง คุณย้อนได้ทีละขั้น
ห้าม deploy การเปลี่ยนโค้ดพร้อมกับ breaking schema changeคุณจะไม่รู้ว่าอะไรพัง และย้อนอะไรไม่ได้เลย
เช็คพฤติกรรมของ engine และเวอร์ชันที่ใช้ก่อนรันบน prodการเพิ่มคอลัมน์ที่มี DEFAULT แบบ volatile, การเพิ่ม NOT NULL, หรือการเพิ่ม foreign key อาจ rewrite หรือล็อกตารางใหญ่
สร้าง index ด้วย CONCURRENTLY (Postgres)และเตรียมรับว่ามันล้มแล้วทิ้ง invalid index ไว้ ต้องมีขั้นตอนเก็บกวาด
ตั้ง lock_timeout ในทุก migrationทำให้ DDL ที่ถูกบล็อกล้มเร็ว แทนที่จะให้ทุก query เข้าคิวอยู่ข้างหลัง — setting ตัวเดียวนี้ป้องกัน outage จาก migration ได้ส่วนใหญ่
-- Header ของทุก migration
SET lock_timeout = '3s';
SET statement_timeout = '30s';

-- และสำหรับ index บนตารางใหญ่
CREATE INDEX CONCURRENTLY IF NOT EXISTS ...;   -- รันนอก transaction

Lab 16 — สร้าง ledger ที่ถูกต้อง (สำคัญที่สุดในคอร์ส)

ใช้ Postgres และภาษาอะไรก็ได้ ทำตามลำดับ ห้ามข้าม

#ขั้นเวลาสิ่งที่ต้องได้
1Naive version20 นาทีaccounts(id, balance) และ endpoint โอนที่อ่าน-คำนวณ-เขียน ให้ทำงานได้
2ทำให้มันพัง20 นาทีรัน 200 การโอนพร้อมกันจาก account เดียวที่มีเงินพอแค่ครึ่ง วัดยอดสุดท้าย มันจะผิด และผิดในทางที่สร้างเงินขึ้นมา — จดตัวเลขที่แน่นอนไว้
3แก้ 4 วิธี40 นาทีatomic conditional update · SELECT … FOR UPDATE · optimistic version column · SERIALIZABLE + retry loop · รัน concurrency test ใหม่ทุกวิธี จดทั้งความถูกต้องและ throughput ทั้ง 4 — ตอนนี้คุณมีข้อมูล trade-off ของตัวเอง
4สร้างใหม่เป็น ledger50 นาทีledger_entries ที่ immutable, transactions ที่มี unique idempotency key, fee account, และ balance ที่ derive มา · เพิ่ม zero-sum invariant เป็น query แล้วรันหลังทุก test
5Idempotency20 นาทีส่ง request เดิม 50 ครั้งพร้อมกันด้วย key เดิม — ต้องมี transaction เดียวเท่านั้น และ response ทั้ง 50 ต้องเหมือนกันรวมถึง transfer_id ที่คืนมา · แล้วส่ง key เดิมด้วย body ต่าง และพิสูจน์ว่าคืน 409
6Snapshot20 นาทีเพิ่ม account_balances พร้อม watermark · เขียน job กลางคืนที่คำนวณใหม่จากศูนย์แล้วเทียบ · ทำลายแถว snapshot 1 แถวโดยเจตนา แล้วพิสูจน์ว่า job จับได้
7Outbox30 นาทีปล่อย event TransferSettled ผ่านตาราง outbox ด้วย relay ที่ใช้ FOR UPDATE SKIP LOCKED · kill relay กลางการ publish แล้วพิสูจน์ว่าไม่มี event หายและไม่มีการเปลี่ยนแปลงทางธุรกิจที่ไม่มี event · แล้วพิสูจน์ว่า consumer idempotent ด้วยการ replay event เดิม 10 ครั้ง

Deliverable: repository พร้อมเอกสาร 1 หน้าที่มี ยอดที่ผิดจากขั้นที่ 2, ตัวเลข throughput ทั้ง 4จากขั้นที่ 3, และ invariant query

ถ้าทำ lab เดียวจากคอร์สนี้ ให้ทำอันนี้

[!NOTE] สรุปบทนี้ ไม่มีลำดับของ dual write ที่ปลอดภัย — ต้องใช้ outbox · alert ที่อายุของแถวที่ยังไม่ publish คือ alert ที่จับ integration failure เงียบได้ · saga ใช้ reserve → confirm/cancel และ compensation คือข้อเท็จจริงใหม่ที่ลูกค้าเห็น · ledger เป็น append-only, double-entry, รวมกันได้ 0 และ balance derive มา · ค่าธรรมเนียมต้องมี fee account ไม่ใช่เงินที่หายไป · retention มาจากเจ้าของที่รับผิดชอบ ไม่ใช่จาก engineer · และ migration ใช้ expand–migrate–contract พร้อม lock_timeout ทุกครั้ง