บทที่ 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 ที่ต้องมี
| เรื่อง | สิ่งที่ต้องทำ |
|---|---|
| Index | CREATE 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 เป็นเครื่องมือมาตรฐาน)
| Outbox | CDC | |
|---|---|---|
| จุดแข็ง | คุณควบคุม contract — event เป็นรูปแบบธุรกิจ (TransferSettled) | ไม่ต้องแก้ application; จับ write จากทุกแหล่งรวมถึงการแก้ด้วยมือ; ได้ change stream ครบสำหรับ read model และ analytics |
| จุดอ่อน | ต้องเขียนโค้ดฝั่ง application; ต้องดูแล relay | event เป็นรูปแบบแถว ไม่ใช่รูปแบบธุรกิจ ("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 | อายุ | Store | Query latency | ต้นทุนเทียบ |
|---|---|---|---|---|
| Hot | 0–90 วัน | OLTP primary มี index | ms | สูงสุด |
| Warm | 90 วัน–2 ปี | partitioned table หรือ archive DB / columnar แยก | วินาที | ~10× ถูกกว่า |
| Cold | 2–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 และภาษาอะไรก็ได้ ทำตามลำดับ ห้ามข้าม
| # | ขั้น | เวลา | สิ่งที่ต้องได้ |
|---|---|---|---|
| 1 | Naive version | 20 นาที | 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 | สร้างใหม่เป็น ledger | 50 นาที | ledger_entries ที่ immutable, transactions ที่มี unique idempotency key, fee account, และ balance ที่ derive มา · เพิ่ม zero-sum invariant เป็น query แล้วรันหลังทุก test |
| 5 | Idempotency | 20 นาที | ส่ง request เดิม 50 ครั้งพร้อมกันด้วย key เดิม — ต้องมี transaction เดียวเท่านั้น และ response ทั้ง 50 ต้องเหมือนกันรวมถึง transfer_id ที่คืนมา · แล้วส่ง key เดิมด้วย body ต่าง และพิสูจน์ว่าคืน 409 |
| 6 | Snapshot | 20 นาที | เพิ่ม account_balances พร้อม watermark · เขียน job กลางคืนที่คำนวณใหม่จากศูนย์แล้วเทียบ · ทำลายแถว snapshot 1 แถวโดยเจตนา แล้วพิสูจน์ว่า job จับได้ |
| 7 | Outbox | 30 นาที | ปล่อย 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 ทุกครั้ง