Persistence, snapshot & disaster recovery trong exchange-core
May 26, 2026
Khi server crash giữa lúc 1M user đang đặt lệnh, làm sao để không mất tiền? Bài viết giải thích event sourcing, journal, snapshot, replay và disaster recovery
Hãy tưởng tượng bạn đang vận hành một sàn giao dịch. 12 giờ đêm, server đột ngột tắt nguồn. Khi bật lại lúc 12 giờ 5 phút:
- Số dư của user còn không?
- Lệnh đang treo trên order book còn không?
- Có user nào bị mất tiền vì lệnh đã match nhưng chưa kịp lưu không?
Đây là bài toán persistence + recovery — và nó khó hơn rất nhiều so với "lưu state xuống database mỗi lần thay đổi". Vì với exchange, mỗi giây có thể có hàng chục nghìn lệnh, không thể ghi DB sau mỗi lệnh được.
Bài viết này mổ xẻ cách exchange-core-nodejs giải quyết bài toán đó: journal + snapshot + replay, hay còn gọi là event sourcing.
Bài viết được viết cho dev mới — mọi thuật ngữ sẽ được giải thích trước khi dùng. Nếu bạn đã quen LMAX / Kafka / WAL, có thể skim nhanh.
Mục lục
- Vấn đề: vì sao không dùng database như bình thường
- Event sourcing — ý tưởng cốt lõi
- Walkthrough: user A 1000 USDT, place lệnh, crash, recovery
- 1. Journal — cuốn nhật ký không thể xóa
- 2. fsync — lằn ranh giữa "đã ghi" và "thật sự an toàn"
- 3. Snapshot — ảnh chụp toàn bộ state
- 4. Replay — sống lại từ đống tro
- 5. Disaster scenarios — đối phó từng tình huống
- 6. Những góc tối ít ai nói tới
- 7. So sánh với các hệ thống khác
- Tổng kết
Vấn đề: vì sao không dùng database như bình thường
Cách CRUD truyền thống: mỗi khi state thay đổi → UPDATE users SET balance = ... WHERE id = ?.
Với 100 request/giây thì OK. Với 30,000–100,000 lệnh/giây (con số thật của một exchange tier-2), cách này chết ngay vì:
- DB round-trip chậm: PostgreSQL commit ~1ms/transaction → tối đa 1000 tx/s/connection. Cần pool 100 connection mới bắt kịp, và lúc đó DB sẽ là bottleneck.
- Mất ordering: 2 lệnh cùng symbol nếu chạy song song qua 2 connection sẽ có thể commit lệch thứ tự → orderbook sai.
- Không reproducible: nếu tháng sau phát hiện bug matching engine, ta không có cách nào chạy lại từng lệnh để debug.
Exchange-core đi theo hướng khác hẳn: không lưu state — lưu lịch sử.
Event sourcing — ý tưởng cốt lõi
Event sourcing: thay vì lưu "state hiện tại", lưu toàn bộ sự kiện dẫn tới state đó. State =
replay(events).
Ví dụ với tài khoản ngân hàng:
- Cách CRUD: lưu
balance = 1000. Hết. - Cách event sourcing: lưu chuỗi sự kiện
+500 (deposit)→+700 (deposit)→-200 (withdraw). Balance = sum = 1000.
Cả hai đều cho balance = 1000. Nhưng cách thứ hai cho phép:
- Audit: biết chính xác từng đồng đến từ đâu.
- Recovery: mất state → replay lại từ event 1.
- Time-travel: muốn biết balance lúc 3pm hôm qua? Replay tới event xảy ra trước 3pm.
Trong exchange-core, mỗi OrderCommand (place/cancel/modify) chính là một event. Lưu lại toàn bộ chuỗi command theo thứ tự = lưu lại toàn bộ lịch sử sàn.
Kiến trúc tổng:
┌──────────────────────────────┐
│ JOURNAL (disk) │ ← append-only file
│ cmd#1, cmd#2, cmd#3, ... │
└──────────────────────────────┘
▲ ▲
│ append │ replay khi recover
│ │
Sequencer ──▶ JournalWriter ──▶ R1/Match/R2 ──▶ State
│
│ định kỳ
▼
┌──────────────────┐
│ SNAPSHOT (disk) │
│ state @ seq=X │
└──────────────────┘
Walkthrough: user A 1000 USDT, place lệnh, crash, recovery
Trước khi đi vào lý thuyết, hãy đi qua một kịch bản cụ thể từ đầu tới cuối. Không trừu tượng, không "giả sử", chỉ số và bytes thật.
Setup:
- User A có
uid = 99231, balance = 1000 USDT (lưu dạng bigint scale 1e6 =1_000_000_000). - Cặp giao dịch: BTC/USDT,
symbolId = 1, giá thị trường ~50,000 USDT. - Exchange config:
fsyncEveryN = 1024.
Bước 1: User A gửi lệnh PLACE BUY 0.5 BTC @ 50,000 USDT lúc 12:00:00.000.
Gateway nhận, đẩy vào queue. Sequencer gán seq = 7, gán timestamp = 1748275200000. Pipeline gọi journal.append(cmd).
Bước 2: journal.append() encode command thành 99 bytes binary (4 LEN + 4 CRC + 91 payload), write vào internal buffer. Buffer chưa đầy 16KB và chưa đến mốc 1024 records → chưa fsync.
Nội dung bytes thực tế của record này:
Đọc diagram: từng ô là 1 byte hex.
LEN = 0x63 = 99cho biết payload dài 99 bytes.CRC = 0x7CE14F9Alà checksum tính từ payload.price = 50_000_000_000(50K USDT × scale 1e6) → little-endian =00 74 3B A4 0B 00 00 00. Mọi field đều fixed-offset → decode chỉ là đọc bytes, không parse.
Bước 3: R1 (RiskEngine) check: user A có 1000 USDT, lệnh cần hold 25,000 USDT (0.5 × 50,000). Không đủ tiền → reject với RISK_NSF.
Câu hỏi quan trọng: lệnh đã reject, vậy journal vẫn giữ record này? Có. Vì replay phải tái tạo cả "user thử đặt lệnh và bị từ chối" —
lastTxnIdcủa user A đã tăng từ N lên N+1, cần preserve.
Bước 4: Cmd #8, #9, ..., #1023 chạy bình thường. Buffer tích tụ ~100KB → đã flush vài lần xuống kernel page cache, nhưng chưa fsync.
Bước 5 — CRASH: Cmd #1500 vừa được append vào buffer. Lúc này:
- App buffer chứa cmd 1025..1500 (chưa flush).
- Kernel page cache chứa cmd 1..1024 (đã write nhưng chưa fsync — kernel có thể đã ghi xuống disk hoặc chưa).
- Disk chắc chắn có cmd 1..1024 (vì fsync chạy sau cmd 1024 tại bước 4).
Đột ngột mất điện. RAM = 0. Disk còn nguyên cmd 1..1024.
Bước 6 — Restart. Bootstrap chạy recover():
1. Tìm snapshot mới nhất: không có (sàn mới chạy)
2. startSeq = 0
3. Mở journal-1.bin, đọc record đầu tiên:
- read 4 bytes LEN → 99
- read 4 bytes CRC → 0x7CE14F9A
- read 99 bytes payload
- compute CRC32(payload) → 0x7CE14F9A → MATCH
- decode, applyDirect(cmd #1) → state update
4. Loop tới cmd #1024 → tất cả CRC match
5. Tới cmd #1025:
- read LEN → đọc được rác (vì bytes này chưa từng xuống disk)
- Trường hợp 1: rác mà LEN > 16KB → throw, dừng tại 1024
- Trường hợp 2: rác mà LEN hợp lệ → đọc payload → CRC mismatch → dừng tại 1024
- Trường hợp 3: EOF (file kết thúc tại offset của cmd 1024) → dừng tại 1024
6. sequencer.nextSeq = 1025
7. Sẵn sàng nhận lệnh mới
Kết quả: state đúng tới cmd #1024. Cmd #1025..#1500 mất (≤1023 commands, đúng như cam kết của fsyncEveryN = 1024). User A vẫn còn 1000 USDT — không bị trừ sai.
Quan trọng: không có inconsistency. Mọi user trên sàn đều ở cùng một "thời điểm logic" = sau khi apply cmd 1024. Không có chuyện user A đã trừ tiền mà user B đối ứng chưa cộng tiền.
Đây là atomicity ở mức exchange-wide, đạt được nhờ single-writer (chỉ có 1 pipeline thread apply commands theo thứ tự seq).
1. Journal — cuốn nhật ký không thể xóa
Journal = một file binary mở ở chế độ append-only. Mỗi command sau khi được sequencer gán số thứ tự (sequence number, viết tắt seq) sẽ được nối thêm vào cuối file. Không bao giờ sửa, không bao giờ xóa.
Tên file: journal-{startSeq}.bin. Khi file đạt 10 triệu record sẽ rotate sang file mới (journal-10000001.bin, journal-20000001.bin, ...).
Vì sao ghi journal TRƯỚC khi xử lý lệnh
Đây là chi tiết dễ làm sai nhất với người mới. Pipeline xử lý 1 lệnh trong exchange-core:
Sequencer.assign(cmd) ← gán seq
│
▼
JournalWriter.append(cmd) ← ★ ghi journal NGAY tại đây
│
▼
RiskEngine (R1) check ← có thể REJECT lệnh
│
▼
MatchingEngine ← match với orderbook
│
▼
RiskEngine (R2) settlement
│
▼
Result trả về client
Câu hỏi rất tự nhiên: "Lệnh chưa biết có được chấp nhận không, sao đã ghi vào journal?"
Lý do nằm ở yêu cầu deterministic replay (sẽ giải thích kỹ ở phần 4). Tóm tắt:
- Nếu chỉ ghi lệnh đã match thành công → khi replay, sàn sẽ không biết là user đã từng bị reject (do thiếu balance chẳng hạn). Trong khi đó những lệnh reject vẫn có thể ảnh hưởng tới state khác (ví dụ: cập nhật
lastTxnIdđể chống replay attack). - Nếu ghi sau matching → có một khoảng cửa sổ giữa lúc match xong và lúc fsync. Crash trong cửa sổ này = state RAM khác state disk = mất tiền user.
Quy tắc: ghi journal là sự kiện xảy ra đầu tiên, kể cả lệnh sau đó bị reject. Khi replay, R1 sẽ reject lại y hệt vì input giống nhau → state ra giống nhau.
Đây gọi là Write-Ahead Logging (WAL) — ghi "ý định" trước khi thực thi. PostgreSQL, MySQL InnoDB, SQLite, Kafka, etcd... tất cả đều dùng pattern này.
Binary format — vì sao không dùng JSON
Một OrderCommand JSON nhìn cho người đọc:
{
"seqNumber": 12345678,
"timestamp": 1748275200000,
"command": "PLACE_ORDER",
"symbolId": 1,
"uid": 99231,
"orderId": 8821,
"price": "50000000000",
"size": "100",
"action": "BID",
"orderType": "GTC"
}
Khoảng 200 bytes sau khi parse và stringify. Trong khi binary format chỉ tốn 71 bytes cố định:
| LEN (4) | CRC (4) | seqNumber (8) | timestamp (8) | command (1)
| symbolId (4) | uid (8) | orderId (8) | requestId (8)
| price (8) | size (8) | reservePrice (8) | action (1) | orderType (1) | flags (1) |
Tiết kiệm 3x size, và tránh hoàn toàn cost của JSON.parse (rất chậm với high throughput vì alloc nhiều object → GC pressure).
Ví dụ encode price = 50000000000 (50,000 USDT, scale 1e6):
buf.writeBigInt64LE(50_000_000_000n, offset); // ghi 8 bytes
// Disk bytes (little-endian):
// 00 5C 7C 64 0B 00 00 00
Đọc lại: buf.readBigInt64LE(offset) → đúng 50000000000n. Zero parsing cost — chỉ là 1 lệnh CPU đọc 8 bytes liên tiếp.
Length-prefix + CRC32 — phát hiện file hỏng
Mỗi record được bọc trong layout:
+---------+---------+----------------+
| LEN (4) | CRC (4) | PAYLOAD (LEN) |
+---------+---------+----------------+
LEN: payload dài bao nhiêu bytes. Đọc 4 bytes này biết phải đọc tiếp bao nhiêu.CRC: checksum CRC32 của payload. Sau khi đọc xong payload, tính lại CRC, so với CRC ghi trong file. Lệch nhau = file bị corrupt.
Vì sao cần thiết kế này? Giả sử kịch bản:
- Process đang
write(record_500)xuống disk. - Power outage. Disk chỉ kịp ghi 30/71 bytes của record 500.
- Bật lại, file có 499 record nguyên + 30 bytes rác.
Lúc replay tới record 500:
- Đọc
LEN: đọc đúng vì 4 bytes đầu đã ghi. - Đọc payload theo
LEN: nhưng chỉ còn 26 bytes thật → đọc lẹm sang đoạn rác (zero bytes). - Tính CRC payload → không match CRC ghi trên file.
- Replayer kết luận: "đến đây là logical EOF" → dừng replay, không crash.
Nếu không có CRC, ta sẽ apply một command rác → user bỗng dưng đặt lệnh ma → state hỏng vĩnh viễn.
Ngoài ra còn check LEN > 16KB → throw ngay. Vì sao? Nếu LEN bị corrupt thành 0xFFFFFFFF (4GB), naive code sẽ alloc(4GB) → OOM. Đây là một dạng defensive programming chống lại corrupt data.
2. fsync — lằn ranh giữa "đã ghi" và "thật sự an toàn"
fsync = lệnh hệ điều hành ép kernel flush hết buffer trong RAM xuống đĩa vật lý.
Đây là điểm khó nhằn nhất. Khi bạn gọi fs.writeSync(fd, buf), dữ liệu chưa nằm trên ổ cứng. Nó nằm ở:
App buffer (RAM)
│ write()
▼
Kernel page cache (RAM) ← writeSync trả về tại đây
│ fsync()
▼
Disk controller cache (SSD/HDD RAM)
│
▼
NAND flash / từ tính ← thực sự an toàn
Mất điện ở bất kỳ tầng RAM nào = mất data. Chỉ fsync() mới ép kernel đẩy xuống tận disk controller (và disk controller hiện đại có battery-backed cache nên thường an toàn).
Đọc diagram: 3 tầng (app buffer → kernel page cache → disk). Mỗi chấm là 1 command. Disk chỉ "nuốt" data tại các mốc fsync (mũi tên xanh đi xuống). Vùng đỏ là cửa sổ data vẫn ở RAM, chưa xuống disk — power outage trong vùng này = mất tất cả command trong cửa sổ.
fsync mỗi lệnh vs fsync theo batch
Có hai cực:
fsyncmỗi command → throughput ~10K cmd/s, mất tối đa 0 command khi crash (an toàn 100%).fsyncmỗi 1024 command → throughput ~30–50K cmd/s, mất tối đa 1023 command.- Không bao giờ
fsync→ cực nhanh, mất toàn bộ buffer trong RAM nếu crash.
Tại sao chênh lệch lớn vậy? Vì fsync đợi disk controller xác nhận thực sự ghi xong — với SSD thường mất ~0.1ms, với NVMe ~10–50μs. 10K cmd/s với fsync mỗi cmd = đợi disk 10K lần mỗi giây.
exchange-core-nodejs chọn middle ground:
new JournalWriter({
dir: "./data",
fsyncEveryN: 1024, // fsync sau mỗi 1024 records
maxFlushIntervalMs: 200, // hoặc sau 200ms — cái nào đến trước
});
Đây là quyết định business, không phải kỹ thuật:
- Dev/test: 1024 records OK — mất vài command test thì không sao.
- Production sàn nhỏ: có thể vẫn 1024, accept rủi ro mất 1024 lệnh khi power outage.
- Production sàn lớn: cần
fsyncEveryN: 1hoặc dùng RAID + UPS + battery-backed disk cache để fsync không đắt.
Đây gọi là durability vs throughput trade-off. PostgreSQL có
synchronous_commit, MySQL cóinnodb_flush_log_at_trx_commit— cùng một bài toán.
Trường hợp đặc biệt: traffic thấp
Imagine sàn của bạn rất rảnh — 1 lệnh mỗi giờ. Với rule "fsync mỗi 1024 cmd", buffer sẽ giữ data trong RAM 1024 giờ ≈ 43 ngày. Crash trong 43 ngày đó = mất hết.
Giải pháp: maxFlushIntervalMs: 200. Pipeline tick() chạy mỗi 1ms gọi flushIfStale(now):
flushIfStale(nowMs: number): void {
if (this.bufPos === 0) return; // buffer trống, skip
if (nowMs - this.lastFlushMs < 200) return; // chưa đủ 200ms
this._flushToFile();
fdatasyncSync(this.fd); // ép xuống disk
this.lastFlushMs = nowMs;
}
Logic kép:
- Traffic cao → flush theo
fsyncEveryN, timer là no-op. - Traffic thấp → flush sau 200ms kể từ lệnh cuối.
- Buffer trống → return ngay, không fsync vô ích (fsync trống tốn ~50μs nhưng vẫn là I/O).
3. Snapshot — ảnh chụp toàn bộ state
Vấn đề: nếu chỉ có journal, recovery sau 6 tháng phải replay hàng tỷ command → mất hàng giờ. Không chấp nhận được.
Snapshot = chụp toàn bộ state hiện tại vào 1 file tại một seq cụ thể.
journal: cmd#1 ... cmd#500K ... cmd#1M ... cmd#1.5M
│ │
▼ ▼
snapshot@500K snapshot@1M
Khi recover: load snapshot mới nhất (vd @1M) + replay journal từ cmd#1000001 trở đi. Thay vì replay 1.5M command, chỉ phải replay 500K.
Snapshot lưu cái gì
interface ExchangeSnapshot {
seqNumber: bigint; // chụp tại seq nào
timestamp: bigint;
symbols: CoreSymbolSpec[]; // danh sách symbol đang trade
users: UserProfile[]; // toàn bộ user + balance + held + open orders
books: Array<{
// orderbook của từng symbol
symbolId: SymbolId;
bookState: OrderBookSnapshot; // gồm asks/bids buckets + từng order
}>;
feesCollected: Map<CurrencyId, bigint>; // phí sàn đã thu
nextSeq: bigint; // seq tiếp theo
}
Đây là toàn bộ state cần thiết để tái dựng exchange. Sau khi load snapshot, sàn nhìn y hệt như tại thời điểm chụp.
Một snapshot 1K user + 100 symbol + 10K orders thường ~5–10MB binary. Snapshot 1M user thì cỡ vài GB — vẫn nhanh hơn rất nhiều so với replay 1 tỷ command.
Atomic write — viết tới nửa chừng thì sao
Bài toán: snapshot 1GB, đang ghi tới 500MB thì crash. Recovery khởi động, đọc file 500MB → tưởng đó là snapshot hoàn chỉnh → load → state hỏng tận gốc.
Giải pháp atomic rename:
async write(path: string, snapshot: ExchangeSnapshot) {
const tmp = path + '.tmp';
// 1. Ghi toàn bộ vào file .tmp
await fs.writeFile(tmp, encode(snapshot));
// 2. fsync để chắc xuống disk
await fdatasync(tmp);
// 3. Atomic rename — POSIX guarantee đây là operation duy nhất, hoặc xong hoặc không
await fs.rename(tmp, path);
}
Tính chất quan trọng: trên POSIX filesystem, rename(a, b) là atomic — tại bất kỳ thời điểm nào, file đích hoặc là phiên bản cũ, hoặc là phiên bản mới, không bao giờ có trạng thái nửa vời.
Đọc diagram: 3 panel = 3 thời điểm. Panel ② là trường hợp nguy hiểm — crash trước khi rename xong. Snapshot mới (
.tmp) bị corrupt, nhưng snapshot cũ vẫn nguyên vẹn → recovery dùng cái cũ, không mất dữ liệu, chỉ là phải replay journal nhiều hơn một chút.
Crash giữa lúc ghi .tmp? File .tmp tồn tại nhưng hỏng → recovery code chỉ tìm file snapshot-{seq}.bin (không có .tmp), bỏ qua. Lần snapshot sau sẽ overwrite .tmp đó.
Pattern này xuất hiện khắp nơi: Git object store, SQLite, các editor hiện đại (VSCode) khi save file đều dùng tmp + rename để chống mất data.
Pause-the-world khi chụp
Snapshot phải capture state nhất quán — không thể có chuyện đang ghi user balance thì matching engine modify nó.
exchange-core-nodejs chọn cách đơn giản nhất: pause-the-world.
Pipeline.tick():
if (snapshotRequested):
drain queue cho cạn ← đợi hết command pending
snapshot.write(currentState) ← ghi snapshot (state immutable trong window này)
snapshotRequested = false
else:
xử lý batch như bình thường
Trong lúc chụp, sàn dừng nhận lệnh mới (queue đầy thì gateway sẽ throttle hoặc reject). Với snapshot 1GB ghi mất 5–10 giây = sàn down 5–10 giây.
Đây là trade-off accept được cho Phase 5 (giai đoạn build), nhưng không dùng cho production lớn. Các kỹ thuật nâng cao:
- Copy-on-write snapshot: fork process, child ghi snapshot, parent tiếp tục xử lý (Redis BGSAVE dùng cách này).
- Incremental snapshot: chỉ ghi phần thay đổi từ snapshot trước.
4. Replay — sống lại từ đống tro
Recovery là lúc tất cả các piece ghép lại:
async function recover(dir, pipeline) {
const r = new Replayer(dir);
// 1. Tìm snapshot mới nhất
const latest = r.findLatestSnapshot();
let startSeq = 0n;
if (latest) {
const snap = await r.loadSnapshot(latest.path);
pipeline.applySnapshot(snap); // restore users + books + registry
startSeq = snap.seqNumber;
}
// 2. Replay journal từ sau snapshot
for await (const cmd of r.replayJournal(startSeq)) {
pipeline.applyDirect(cmd); // chạy y hệt tick(), nhưng KHÔNG ghi journal lại
}
return pipeline.sequencer.nextSeq;
}
Chú ý applyDirect: nó là tick() body cho 1 command, trừ bước ghi journal. Vì command này đã có trong journal rồi — ghi lại = duplicate.
Vì sao replay phải deterministic
Deterministic: cùng input → cùng output, mỗi lần chạy. Không có random, không có wall-clock, không có thread scheduling khác nhau.
Đây là điều kiện bắt buộc để event sourcing hoạt động. Nếu replay 1 triệu command lần thứ 2 ra state khác lần đầu → ta không thể trust state nào cả.
Những thứ phá deterministic mà junior dev hay vướng:
Math.random() trong matching logic
- Hậu quả: lệnh match khác thứ tự khi replay.
- Fix: cấm dùng, hoặc seed từ command.
Date.now() lấy thời gian trong handler
- Hậu quả: order timestamp khác → ordering trong bucket khác.
- Fix: timestamp đã capture trong command, không lấy lại.
Map iteration order phụ thuộc Node version
- Hậu quả: order kết quả khác giữa các Node version.
- Fix: dùng array có thứ tự rõ ràng.
setTimeout, setImmediate xen vào
- Hậu quả: race condition không reproducible.
- Fix: hot path không async, mọi thứ chạy trong tick.
Floating point cho tiền
- Hậu quả:
0.1 + 0.2 !== 0.3→ balance lệch dần. - Fix: dùng
bigint, scale 1e6 hoặc 1e8.
Tóm tắt nguyên tắc: mọi input cần thiết phải nằm trong command. Khi replay, ta không có gì ngoài file journal.
Ví dụ cụ thể — đặt lệnh PLACE_ORDER lúc 12:00:00.123:
// SAI — non-deterministic
function place(cmd) {
const order = {
...cmd,
timestamp: Date.now(), // ← replay lúc 3pm sẽ khác
id: crypto.randomUUID(), // ← random
};
book.add(order);
}
// ĐÚNG — deterministic
function place(cmd) {
const order = {
...cmd,
timestamp: cmd.timestamp, // ← timestamp đã capture lúc nhận lệnh, lưu trong journal
id: cmd.orderId, // ← id do client/gateway gán, đã trong journal
};
book.add(order);
}
Recovery flow chi tiết
Crash → restart, đây là những gì xảy ra:
1. bootstrap() được gọi
2. Liệt kê dir, tìm snapshot mới nhất:
- snapshot-500000.bin ← chọn cái này
- snapshot-400000.bin
- snapshot-300000.bin
(snapshot-600000.bin.tmp bị bỏ qua — atomic rename chưa xong)
3. Load snapshot-500000.bin:
- Verify CRC trailer → OK
- applySnapshot(): restore users, books, registry
- sequencer.nextSeq = 500001
4. Tìm journal file phủ seq >= 500001:
- journal-1.bin (seq 1..10M)
- Mở, seek tới offset của seq 500001
- Stream từng record, decode, applyDirect()
- Tới record 750000 → CRC fail → đây là logical EOF
- Dừng replay tại seq 749999
5. sequencer.nextSeq = 750000
6. Start tick loop, sẵn sàng nhận lệnh mới
Time recovery cho 1M command: < 30 giây trên SSD thường.
Đọc diagram: hàng "RAM" cho thấy state bị wipe sau crash, được rebuild dần qua snapshot load → journal replay. Hàng "Disk" cho thấy disk không hề thay đổi — chính vì vậy recovery mới khả thi.
5. Disaster scenarios — đối phó từng tình huống
Tổng hợp các tình huống tệ nhất và cách hệ thống xử lý:
Process crash (uncaught exception, SIGSEGV)
- Mất: ≤ 1023 command chưa fsync.
- Cứu: recovery từ journal + snapshot.
Power outage giữa lúc fsync
- Mất: 0 command nếu disk có battery cache, ≤ 1023 nếu không.
- Cứu: length-prefix + CRC bỏ qua record dở dang.
Disk lỗi bit ngẫu nhiên (bit-rot)
- Mất: 1 record bị corrupt.
- Cứu: CRC fail → dừng replay tại đó, alert ops xử lý.
Crash giữa lúc ghi snapshot
- Mất: 0 (snapshot cũ vẫn còn nguyên).
- Cứu: atomic rename + filter tên
.tmplúc load.
Bug logic phát hiện sau 1 tháng
- Mất: 0 (có toàn bộ journal).
- Cứu: fix bug, replay journal qua code mới.
Disk đầy
- Mất: command mới bị reject.
- Cứu: monitor + auto-rotate + cleanup snapshot cũ định kỳ.
Toàn bộ datacenter cháy
- Mất: tất cả.
- Cứu: cluster replication — nằm ngoài scope phase này.
Câu hỏi thường gặp: "Vậy chỉ persistence local có đủ không?"
Trả lời: không đủ cho production thật. Một sàn giao dịch nghiêm túc luôn cần:
- Replication sang node thứ 2/3 — write journal đồng thời ra 2 server qua mạng. Mất 1 server vẫn còn server kia.
- Backup snapshot ra cold storage (S3, GCS) — mất cả datacenter vẫn restore được, chỉ chậm hơn.
- Cross-region failover — DNS switch sang region khác trong vài giây.
exchange-core-nodejs Phase 5 cố ý không làm replication — vì đó là một bài toán lớn riêng (consensus, leader election, split-brain). Persistence local là nền tảng, replication build trên nền đó.
6. Những góc tối ít ai nói tới
Phần này là kiến thức bạn chỉ học khi đã đụng phải lần đầu — và thường mất tiền. Đọc trước để khỏi mất tiền lần đầu.
fsyncgate — fsync fail mà bạn không hề biết
Năm 2018, cộng đồng PostgreSQL phát hiện ra một bug cực kỳ tế nhị trong Linux kernel, được gọi là "fsyncgate":
Khi
fsync()trả về lỗi (vd disk hỏng tạm thời), Linux kernel đánh dấu page là "đã thử fsync" và xóa lỗi đó khỏi inode. Lầnfsync()tiếp theo trên cùng file sẽ trả về thành công, dù page kia chưa thật sự xuống disk. Ứng dụng tưởng data đã an toàn → tiếp tục bình thường → mất data lúc nào không hay.
PostgreSQL từng tin rằng nếu fsync thành công lần 2 = data đã ghi xong lần 1. Sai. Bug này tồn tại trong kernel hàng chục năm.
Fix: Postgres giờ panic và crash process nếu fsync ever fail, buộc admin phải khởi động lại — lúc đó WAL recovery sẽ rebuild từ trạng thái biết chắc.
Bài học cho exchange-core-nodejs: phải treat fsync error như fatal. Code cần:
try {
fs.fdatasyncSync(this.fd);
} catch (err) {
// KHÔNG retry. KHÔNG swallow. Panic.
process.emit("fatal", err);
process.exit(1);
}
Restart sẽ recovery từ journal/snapshot đã biết chắc là intact, an toàn hơn là "thử lại và hi vọng".
Partial fsync và barrier semantics
fsync(fd) chỉ guarantee data của fd đó xuống disk. Nó không đảm bảo:
- Metadata (kích thước file, mtime) đã update — phải dùng
fsync(full) thay vìfdatasyncnếu cần. - File directory entry đã persist — nếu vừa tạo file mới, cần fsync cả parent directory:
// Sai — file tồn tại trong directory chỉ sau khi parent dir cũng fsync
fs.writeSync(fd, data);
fs.fdatasyncSync(fd);
// Crash ngay đây → file có thể "tồn tại" trong RAM nhưng directory entry chưa persist
// → bật lại, file biến mất hoàn toàn
// Đúng
fs.writeSync(fd, data);
fs.fdatasyncSync(fd);
const dirFd = fs.openSync(dirname(path), "r");
fs.fsyncSync(dirFd); // ← persist directory entry
fs.closeSync(dirFd);
Đây là chi tiết khiến nhiều atomic-write implementation vẫn không atomic thật sự. SQLite có cả một whitepaper "How To Corrupt An SQLite Database File" nói về điều này.
Disk full giữa lúc ghi
Disk còn 1MB, snapshot cần 10MB. Hai kịch bản:
Kịch bản A — write fail giữa chừng: fs.write() throws ENOSPC. Code catch, log lỗi, để snapshot tmp file lại (size không đủ). Recovery sau đó bỏ qua .tmp, ổn.
Kịch bản B — journal append fail: cmd đang vào, write ENOSPC. Lúc này nguy hiểm — pipeline đã mark cmd là processed trong RAM. Nếu cứ continue:
- Cmd N: journal fail nhưng state đã update.
- Crash sau đó → recovery replay journal đến cmd N-1, RAM state thiếu cmd N → inconsistency.
Fix: journal fail = panic. Tương tự fsyncgate, không có "recover gracefully" — phải dừng và để recovery rebuild từ state biết chắc.
Phòng ngừa ở tầng ops:
- Monitor disk usage, alert ở mức 80%.
- Pre-allocate journal file ngay khi mở (
fs.ftruncateSynclên 1GB) → nếu thiếu disk thì fail ngay lúc start, không fail giữa lúc chạy. - Rotate + cleanup snapshot cũ tự động.
Snapshot version mismatch
Hôm nay bạn deploy code v2.0 thêm field marginType vào UserProfile. Snapshot ghi từ v1.0 không có field này. Bật v2.0 lên, load snapshot → crash hoặc tệ hơn: load thành công với marginType = undefined → matching engine xử lý sai.
Fix: snapshot file luôn có version byte ở header:
| MAGIC (4) | "EXCH"
| VERSION (2) | uint16 — bump mỗi lần thay đổi schema
| seq (8) | ...
Khi load:
if (snapshot.version > CURRENT_VERSION) {
throw new Error("Snapshot from newer version, cannot load");
}
if (snapshot.version < CURRENT_VERSION) {
// Có hai option:
// 1. Migration function: upgrade(snapshot) → returns snapshot mới
// 2. Refuse: yêu cầu admin downgrade code hoặc replay journal từ đầu
}
Trong production, option 1 (migration) là chuẩn. Mỗi version có một upgrade function v1ToV2(snap) → snap. Chain nhiều version: v1 → v2 → v3 → ....
Pattern này y hệt database migration (Rails, Django, Prisma). Snapshot file = một dạng database, schema có thể tiến hóa.
Journal compaction — vì sao file không phình vô tận
Sau 1 năm chạy, journal sẽ vài TB. Không thể giữ mãi. Hai chiến lược:
1. Snapshot + truncate: snapshot là "checkpoint" — bất kỳ lúc nào sau khi snapshot ghi xong, journal trước snapshot có thể xóa. State đã được capture đầy đủ trong snapshot rồi.
journal-1.bin (seq 1..10M) ← có thể xóa nếu snapshot @ 12M tồn tại
journal-2.bin (seq 10M..20M) ← cần giữ một phần (10M..12M không nằm trong snapshot)
snapshot-12M.bin ← current
Thực tế: thường giữ 2 snapshot gần nhất + toàn bộ journal sau snapshot cũ hơn để có thể rollback nếu snapshot mới hỏng.
2. Cold storage: journal cũ không xóa, mà compress (LZ4/zstd) rồi upload lên S3. Khi cần audit hoặc replay phục hồi từ rất xa, download về.
Trade-off:
- Giữ ít → khôi phục nhanh, nhưng nếu phát hiện bug 1 tháng trước, không replay được.
- Giữ nhiều → audit tốt, nhưng storage cost.
Sàn thường giữ journal vĩnh viễn (vì lý do pháp lý — audit trail của giao dịch tài chính), chỉ chuyển sang cold storage. Snapshot thì rotate (giữ 7 snapshot gần nhất chẳng hạn).
Clock skew — khi Date.now() nhảy ngược
Date.now() không monotonic. NTP có thể giật giờ về phía sau khi sync. Hậu quả:
12:00:00.500 cmd #1 timestamp=1748275200500
12:00:00.300 cmd #2 timestamp=1748275200300 ← THỜI GIAN ĐI LÙI
Nếu code tin "cmd sau luôn có timestamp ≥ cmd trước", logic sẽ vỡ.
Fix trong exchange-core: dùng process.hrtime.bigint() + offset cố định khi sequencer assign timestamp. hrtime là monotonic — không bao giờ giảm.
class Sequencer {
private readonly startWall = BigInt(Date.now()) * 1_000_000n;
private readonly startMono = process.hrtime.bigint();
now(): number {
const elapsed = process.hrtime.bigint() - this.startMono;
return Number((this.startWall + elapsed) / 1_000_000n);
}
}
startWall chỉ đọc 1 lần lúc start, elapsed luôn tăng → timestamp luôn tăng. NTP skew không ảnh hưởng tới ordering nội bộ.
Trade-off: nếu server chạy nhiều ngày, timestamp có thể lệch wall clock vài ms — accept được. Đổi lại, deterministic và monotonic — quan trọng hơn.
7. So sánh với các hệ thống khác
Những pattern trong bài này không phải sáng tạo riêng của exchange-core-nodejs. Hầu hết mọi hệ thống "stateful, durable" đều dùng biến thể của journal + snapshot. So sánh ngắn:
PostgreSQL
- Journal: WAL (write-ahead log) — segments 16MB.
- Snapshot: full backup qua
pg_basebackup. - fsync: config
synchronous_commit—on/off/local/remote_write. - Replay: crash recovery replay WAL từ last checkpoint.
MySQL InnoDB
- Journal: redo log (
ib_logfile) + binlog cho replication. - Snapshot: InnoDB tablespace files.
- fsync: config
innodb_flush_log_at_trx_commit(0 / 1 / 2). - Replay: redo log replay + undo log rollback cho transaction dở.
Kafka
- Journal: log segments (
.logfiles), append-only — đây là "first-class citizen". - Snapshot: không có — chỉ log + compaction theo key.
- fsync: producer config
acks=all+ broker configflush.messages. - Replay: consumer offset = position trong log, "rewind" là first-class.
Redis
- Journal: AOF (Append-Only File).
- Snapshot: RDB (binary dump theo định kỳ).
- fsync:
appendfsync—always/everysec/no. - Replay: load RDB + replay AOF tail (giống hệt exchange-core).
etcd / Raft
- Journal: Raft log entries.
- Snapshot: periodic snapshot khi log quá dài.
- fsync: fsync mỗi entry — consensus protocol yêu cầu.
- Replay: apply log từ last snapshot.
Git
- Journal: loose objects (commits, trees, blobs).
- Snapshot: pack files (gom + nén loose objects).
- fsync: không gọi fsync mặc định (config
core.fsyncObjectFiles). - Replay: không cần — Merkle tree tự verify integrity qua SHA hash.
SQLite
- Journal: journal mode (rollback) hoặc WAL mode.
- Snapshot: toàn bộ DB file (1 file duy nhất).
- fsync:
PRAGMA synchronous—OFF/NORMAL/FULL/EXTRA. - Replay: WAL checkpoint merge vào DB chính.
exchange-core-nodejs
- Journal:
journal-{seq}.bin(binary append, length-prefix + CRC). - Snapshot:
snapshot-{seq}.bin(full state dump, atomic rename). - fsync:
fsyncEveryN+ time-based flush sau 200ms. - Replay: snapshot + replay journal tail.
Vài observation thú vị:
- Redis
appendfsync everysecy hệt ý tưởngmaxFlushIntervalMs = 1000trong exchange-core — chấp nhận mất tối đa 1 giây data để đổi throughput. - Kafka không có snapshot truyền thống — vì Kafka là log, không có "state" cần dump. Compaction là cách giảm size: với cùng key, giữ giá trị mới nhất.
- Git là một database event-sourced — mỗi commit là một event. Nhưng Git không gọi fsync mặc định (config
core.fsyncObjectFiles) — vì Git design cho dev workstation, không phải production server. Đây là lý dogit pushserver-side (như GitHub) thường cấu hình fsync khắt khe hơn. - etcd/Raft fsync mỗi entry — bắt buộc, vì consensus protocol yêu cầu: nếu majority node đã commit, không node nào được "quên". Đánh đổi: throughput thấp hơn nhiều so với Kafka.
Câu hỏi thường gặp: "Sao không dùng PostgreSQL/Kafka luôn cho chắc?"
Trả lời: latency. PostgreSQL INSERT + COMMIT mất ~1ms ngay cả với best config. Kafka producer.send p99 ~5ms. Trong khi exchange-core target latency p99.99 < 100μs cho matching engine. Đi qua một hệ thống generic = thua ngay tại budget latency. Custom journal binary + single-writer = đạt ngưỡng đó.
Quy tắc chung: khi latency yêu cầu < 1ms, mọi abstraction "an toàn" đều phải bị bóc đi. Đó là lý do high-frequency trading, low-latency game server, real-time bidding đều có persistence layer riêng.
Tổng kết
Cái khó của persistence trong exchange không phải "ghi xuống đâu", mà là ghi như thế nào để vừa nhanh vừa không mất dữ liệu khi crash.
Những ý tưởng cốt lõi đã đi qua:
- Event sourcing: lưu lịch sử, không lưu state. State =
replay(journal). - Write-ahead logging: ghi journal trước, xử lý sau. Crash thì recovery rebuild được.
- Binary format + CRC + length-prefix: nhỏ, nhanh, tự phát hiện corruption.
- fsync trade-off: chọn giữa "an toàn 100%" và "throughput cao" — đây là quyết định business.
- Snapshot: rút ngắn thời gian recovery từ "tính bằng giờ" xuống "tính bằng phút".
- Atomic rename: cứu file khỏi tình trạng "viết nửa chừng".
- Deterministic replay: bí mật giúp toàn bộ event sourcing hoạt động — cùng input phải ra cùng output.
Cuối cùng, đây không phải kiến thức riêng của exchange. Mọi hệ thống cần durability cao — database, message broker (Kafka), distributed log (etcd, ZooKeeper), thậm chí git — đều áp dụng những pattern y hệt. Hiểu được nó, bạn sẽ đọc PostgreSQL internals hay Kafka design dễ hơn rất nhiều.