Harbor

Booking & tracking container

Trung bìnhIdempotencyOutboxEvent ingestCache-asideGiống Cổng khách hàng của hãng tàu (ONE, Maersk, CMA CGM)

Hiểu đề

Booking & tracking container là gì? Cổng web cho khách của hãng tàu: đặt chỗ chở container và theo dõi container qua các cảng. Booking ít nhưng phải đúng, sự kiện tracking đến đều và nhiều, tra cứu thì đọc nhiều hơn ghi.

Yêu cầu chức năng

  1. Khách tạo, sửa, huỷ booking: cảng đi, cảng đến, ngày, loại và số container (ví dụ 2 × 40'HC từ VNDAD đến SGSIN)
  2. Ai cũng tra cứu được hành trình bằng số booking, số vận đơn (B/L) hoặc số container ISO 6346 (ví dụ ONEU1234567)
  3. Hệ thống nhận sự kiện từ cảng, tàu, bãi: Gate-in, Loaded, Departed, Arrived, Discharged, Gate-out
  4. Khách nhận thông báo (email, trên web) khi container đổi trạng thái hoặc bị trễ

Yêu cầu phi chức năng

  • Tra cứu p95 < 300 ms
  • Sẵn sàng 99,9% (≈ 43,8 phút downtime/tháng)
  • Sự kiện mới hiển thị trong vòng 1 phút
  • 0 booking trùng khi client gửi lại, 0 sự kiện bị mất
  • Chịu đỉnh 350 sự kiện/s và 116 lượt tra cứu/s ở mức 2

Ngoài phạm vi: Tính giá cước và thanh toán; Lập kế hoạch xếp container lên tàu (stowage); Bản đồ vị trí tàu theo AIS thời gian thực; Thủ tục hải quan.

Thực thể và API

Customer
Công ty khách hàng và tài khoản người dùng, vai trò khách / nhân viên / admin.
Booking
booking_no, customer_id, pol, pod (mã UN/LOCODE), etd, status (DRAFT, PENDING, CONFIRMED, REJECTED, CANCELLED), idempotency_key UNIQUE, version cho optimistic locking.
BookingContainer
Container thuộc booking: container_no ISO 6346, size_type (20GP, 40HC, 40RF).
TrackingEvent
Một document Mongo mỗi sự kiện: _id là id từ nguồn, containerNo, type, location, vessel, occurredAt, receivedAt, source. Index {containerNo: 1, occurredAt: -1}.
OutboxMessage
Sự kiện nghiệp vụ (BookingCreated...) ghi cùng transaction với booking, relay đẩy sang hàng đợi sau.
Subscription
Khách đăng ký nhận thông báo cho booking hoặc container: kênh (email, web, webhook), bộ lọc loại sự kiện.
MethodEndpointGhi chú
POST/v1/bookingsHeader Idempotency-Key. Gửi lại cùng key thì trả booking cũ, không tạo mới. Trả 201.
PATCH/v1/bookings/{bookingNo}Header If-Match: version. Sai version trả 409 BOOKING_VERSION_CONFLICT.
POST/v1/bookings/{bookingNo}/cancelHành động nghiệp vụ nên có endpoint riêng thay vì PATCH status.
GET/v1/tracking/{containerNo}Công khai, có rate limit theo IP, đọc qua cache Redis. Không trả tên khách hay giá cước.
GET/v1/tracking/{containerNo}/streamSSE đẩy sự kiện mới; hỗ trợ Last-Event-ID để lấy bù khi kết nối lại.
POST/internal/eventsCho đối tác (cảng, bãi). Xác thực API key + chữ ký HMAC, đẩy vào hàng đợi rồi trả 202 ngay.

Thiết kế tổng thể

Đi lần lượt từng yêu cầu chức năng. Mục tiêu ở đây là chạy đúng, việc tối ưu để dành cho phần đào sâu.

1) Khách tạo booking không bị trùng

Mạng chập chờn khiến người dùng bấm hai lần hoặc client tự retry. Idempotency key cộng ràng buộc UNIQUE đảm bảo chỉ ra một booking. Outbox đảm bảo booking đã lưu thì sự kiện BookingCreated chắc chắn được gửi.

  1. 1Next.js gửi POST /v1/bookings kèm Idempotency-Key k1
  2. 2API tìm booking theo k1; đã có thì trả lại booking cũ với 201
  3. 3Chưa có: BEGIN, INSERT booking, INSERT outbox, COMMIT; nếu UNIQUE báo trùng do request song song thì đọc lại và trả bản đã có
  4. 4Outbox relay đẩy BookingCreated sang hàng đợi; booking ở PENDING tới khi xác nhận còn chỗ trên tàu

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97), mục "Writing idempotent message handlers" (tạo order với ID do client cấp) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100), mục "Using a database table as a message queue" (outbox) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3.2 Countermeasures for handling the lack of isolation (trạng thái *_PENDING, tr. 128–132)

2) Nhận sự kiện tracking từ cảng, tàu, bãi

Ingest chỉ kiểm tra và xếp hàng, việc ghi để worker làm. Hàng đợi chỉ đảm bảo at-least-once, nên worker phải chịu được trùng và lệch thứ tự.

  1. 1Đối tác gọi /internal/events; API kiểm tra chữ ký, validate DTO, publish vào topic container-events với ordering key containerNo, trả 202
  2. 2Worker upsert vào Mongo theo _id = eventId bằng $setOnInsert, nhận trùng thì không làm gì
  3. 3Chỉ cập nhật trạng thái mới nhất nếu occurredAt mới hơn trạng thái đang lưu
  4. 4Ghi đè cache tracking:{containerNo}, publish thông báo; lỗi quá 5 lần thì vào dead-letter topic

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97) (at-least-once, xử lý trùng) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.5 Competing receivers and message ordering (tr. 94–95) (ordering key / shard key) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "Evaluation of a Distributed Messaging Queue's Design", mục "Functional requirements" (dead-letter queue) ; MongoDB: The Definitive Guide 2e ↗ (.zip) · ch.3 Creating, Updating, and Deleting Documents · mục "Upserts" (tr. 45)

3) Tra cứu tracking và nhận cập nhật realtime

Trang tra cứu render bằng Server Component của Next.js để có SEO và tải nhanh, sau đó mở SSE. Mongo và Redis là phía đọc (kiểu CQRS), tách khỏi Postgres là phía ghi booking.

  1. 1Next.js gọi GET /v1/tracking/{containerNo}, validate check digit ISO 6346 trước
  2. 2API đọc Redis; miss thì đọc 50 sự kiện mới nhất từ Mongo, ghi cache TTL 60 s cộng thời gian ngẫu nhiên
  3. 3Trình duyệt mở SSE /stream; mọi instance API subscribe kênh Redis và đẩy cho client của mình
  4. 4Mất kết nối thì trình duyệt tự kết nối lại với Last-Event-ID, API lấy bù từ Mongo

Nguồn: Microservices Patterns ↗ (.zip) · ch.7 · §7.2 Using the CQRS pattern (phía đọc tách khỏi phía ghi, tr. 228–236) ; Redis in Action ↗ · ch.3 · §3.6 Publish/subscribe (tr. 54–57) ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · bài "Background of Distributed Cache", mục "Writing policies" (cache-aside) ; Chưa có nguồn trong thư viện: SSE/EventSource, GCP Pub/Sub, Cloud Run và chuẩn mã container ISO 6346 không có trong sách của thư viện; chỉ nêu khái niệm cơ bản

Hạ tầng theo quy mô

Cùng một sản phẩm, ba mức người dùng. Chọn từng mức để xem sơ đồ, phép tính và cấu hình server thay đổi thế nào.

Người dùng
≈ 5.000 khách · 20.000 container đang chạy (forwarder khu vực)
Tải đỉnh
≈ 5 req/s đỉnh
Chi phí ước lượng
≈ 80–200 USD/tháng (ước lượng)

Cổng của một công ty forwarder ở Việt Nam, kéo dữ liệu tracking từ API của các hãng tàu. Một API NestJS trên Cloud Run và một Postgres giữ cả booking lẫn sự kiện (cột JSONB) là đủ. Chưa cần Mongo, Redis hay hàng đợi.

Người dùngKhách hàngDịch vụNext.js (Run)Dịch vụNestJS APIWorkerJob đồng bộDatabasePostgresBên ngoàiEmailBên ngoàiAPI hãng tàuHTTPSthông báokéo 15 phútupsert sự kiện

Ước lượng

  • Sự kiện: 20.000 container × 3 = 60.000/ngày / 86.400 ≈ 0,7/s, đỉnh ×5 ≈ 3,5/s
  • Tra cứu: 5.000 khách × 4 lần = 20.000/ngày / 86.400 ≈ 0,23/s, đỉnh ×5 ≈ 1,2/s
  • Booking: 300/ngày, giờ cao điểm khoảng 30 booking/giờ, chưa tới 1 booking/phút
  • Lưu trữ: 60.000 × 500 B × 365 ≈ 11 GB/năm
  • API: (3,5 + 1,2) / (1.000 × 0,6) ≈ 0,008 → 1 instance, chạy 2 để dự phòng

Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · bài "Examples of Resource Estimation", mục "Number of servers required", "Storage requirements" ; Grokking Modern System Design · ch.4 Non-functional System Characteristics ↗ · bài "Availability", mục "Measuring availability" (bảng The Nines of Availability) ; Quy tắc ước lượng của khoá học · /hoc/chon-server (500 req/s mỗi vCPU, chạy 60% CPU, Redis ×1,3; hệ số đỉnh ×5, số container và số sự kiện mỗi container là giả định của bài)

Bảng hạ tầng

Thành phầnChọnSố lượngCấu hìnhVì sao
WebNext.js (App Router) trên Cloud Run1–21 vCPU · 1 GB RAMTrang tra cứu render phía server cho SEO; tải rất thấp.
APINestJS modular monolith (booking, tracking, notification) trên Cloud Run22 vCPU · 4 GB RAMDưới 5 req/s; 2 instance tối thiểu để không mất dịch vụ khi một instance khởi động lại.
DatabasePostgreSQL (Cloud SQL), sự kiện ở bảng riêng với cột JSONB1 + standby2 vCPU · 8 GB RAM · 50 GB SSD11 GB/năm và vài ghi/s vừa một node; một DB thì dễ vận hành và có transaction cho booking.
Đồng bộ trackingCloud Scheduler gọi job kéo API hãng tàu mỗi 15 phút1 jobCloud Run jobForwarder không có EDI riêng; kéo định kỳ đủ cho NFR cập nhật trong 1 giờ ở mức này.
EmailSendGrid hoặc dịch vụ email có sẵn--Vài trăm email/ngày, không cần hàng đợi riêng.
Thay đổi so với mức trước
  • Điểm xuất phát: một monolith NestJS chia module theo nghiệp vụ, một Postgres
  • Booking đã có idempotency key và UNIQUE ngay từ đầu vì chi phí gần như bằng 0
  • Sự kiện kéo định kỳ, upsert theo id nguồn nên chạy lại job không sinh trùng
Nút thắt tiếp theo

Khi trở thành cổng của chính hãng tàu, sự kiện được đẩy tới liên tục thay vì kéo 15 phút một lần, NFR đòi hiển thị trong 1 phút, và một bảng sự kiện JSONB lên tới hàng TB làm chậm cả truy vấn booking.

Cấu hình và chi phí là ước lượng để học và trình bày trong phỏng vấn. Xem cách tính ở bài Chọn server.

So sánh nhanh ba mức

Thành phầnMức 1Mức 2Mức 3
Người dùng≈ 5.000 khách · 20.000 container đang chạy (forwarder khu vực)≈ 500.000 khách · 2 triệu container đang chạy (cổng toàn cầu của một hãng tàu)Hub tracking toàn ngành · ≈ 20 triệu container · 10× sự kiện
Chi phí≈ 80–200 USD/tháng (ước lượng)≈ 1.500–3.000 USD/tháng (ước lượng)≈ 30.000–60.000 USD/tháng (ước lượng)
Web
Next.js (App Router) trên Cloud Run
1–2 × 1 vCPU · 1 GB RAM
Next.js trên Cloud Run, trang tracking SSR cache 60 s
2 (autoscale tới 10) × 1 vCPU · 2 GB RAM
Chưa cần
API
NestJS modular monolith (booking, tracking, notification) trên Cloud Run
2 × 2 vCPU · 4 GB RAM
NestJS modular monolith trên Cloud Run, rate limit token bucket bằng Lua trên Redis
2 (autoscale tới 10) × 2 vCPU · 4 GB RAM
NestJS trên GKE, tách module Tracking thành service riêng
5 pod × 2 vCPU · 4 GB RAM
Database
PostgreSQL (Cloud SQL), sự kiện ở bảng riêng với cột JSONB
1 + standby × 2 vCPU · 8 GB RAM · 50 GB SSD
Chưa cầnChưa cần
Đồng bộ tracking
Cloud Scheduler gọi job kéo API hãng tàu mỗi 15 phút
1 job × Cloud Run job
Chưa cầnChưa cần
Email
SendGrid hoặc dịch vụ email có sẵn
- × -
Chưa cầnChưa cần
CDN + LBChưa cần
Cloud CDN + HTTPS Load Balancer
1 × managed
Chưa cần
Event workerChưa cần
NestJS worker, pull subscription Pub/Sub
2 × 2 vCPU · 4 GB RAM
Chưa cần
Booking DBChưa cần
PostgreSQL (Cloud SQL HA), bảng outbox + relay polling
1 primary + standby × 2 vCPU · 8 GB RAM
Chưa cần
Event storeChưa cần
MongoDB Atlas replica set, index {containerNo: 1, occurredAt: -1}, giữ online 13 tháng
3 node × M30: 2 vCPU · 8 GB RAM · 1,5 TB
MongoDB sharded, shard key {containerNo: 1, occurredAt: 1}
11 shard × 3 node × 8 vCPU · 32 GB · 2,5 TB
Cache + pub/subChưa cần
Redis (Memorystore Standard)
1 primary + 1 replica × 1 GB
Chưa cần
Hàng đợiChưa cần
GCP Pub/Sub: container-events (ordering key containerNo), notifications, dead-letter
3 topic × managed
Chưa cần
Notification workerChưa cần
NestJS worker gửi email và web push
1–2 × 1 vCPU · 2 GB RAM
Chưa cần
EdgeChưa cầnChưa cần
Global LB + Cloud CDN, API key và rate limit theo khách B2B
1 × managed
Ingest + adapterChưa cầnChưa cần
Một adapter cho mỗi hãng/cảng (EDI, API, file), payload gốc lưu GCS
9 pod × 2 vCPU · 4 GB RAM
Event busChưa cầnChưa cần
Kafka (managed), 24 partition key containerNo, giữ 7 ngày
3 broker × 8 vCPU · 32 GB RAM
NormalizerChưa cầnChưa cần
Chuẩn hoá mã sự kiện, dedupe giữa các nguồn, ghép container với chuyến hiện tại
12 pod × 2 vCPU · 4 GB RAM
Trạng thái mới nhấtChưa cầnChưa cần
Redis Cluster
3 primary + 3 replica × 8 GB mỗi node
Booking + subscriptionChưa cầnChưa cần
PostgreSQL HA + read replica
1 primary + 1 replica × 8 vCPU · 32 GB RAM
Webhook senderChưa cầnChưa cần
Worker có retry backoff + jitter, circuit breaker và pool riêng cho từng khách
12 pod × 2 vCPU · 4 GB RAM

Đào sâu

Mỗi câu hỏi đi từ cách đơn giản đến cách người phỏng vấn mong đợi ở cấp senior.

1) Làm sao để booking không bị tạo trùng và hai nhân viên sửa cùng lúc không ghi đè nhau?

Cách chưa tốt
Kiểm tra trùng trong code bằng SELECT rồi mới INSERT

Hai request đến gần như cùng lúc đều SELECT không thấy gì và cùng INSERT. Sửa booking thì người lưu sau ghi đè âm thầm thay đổi của người trước.

Đánh đổi: Dễ viết, nhưng sai đúng lúc tải cao hoặc mạng chập chờn.

Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.3.1 Overview of anomalies (lost updates, tr. 127–128)

Cách tốt
Idempotency-Key từ client + UNIQUE trong DB

Client sinh key một lần cho mỗi lần bấm tạo. DB là tuyến phòng thủ cuối: request song song thứ hai vi phạm UNIQUE và được trả booking đã tạo. Key gắn ý nghĩa vào request, giống cách Newman làm thao tác cộng điểm trở nên idempotent.

Đánh đổi: Chặn được tạo trùng nhưng chưa giải quyết sửa đồng thời và gửi sự kiện.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97), mục "Writing idempotent message handlers" ; Building Microservices ↗ (.zip) · ch.11 Microservices at Scale · mục "Idempotency" (tr. 215)

Cách tốt nhất
Idempotency key + optimistic locking + transactional outbox

Sửa bằng UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?; 0 dòng thì trả 409 để người dùng tải lại. Booking và sự kiện ghi chung transaction, relay đẩy sau bằng polling bảng outbox (đủ ở quy mô này) hoặc đọc WAL bằng Debezium khi lớn. Relay có thể gửi trùng nên phía nhận vẫn phải idempotent.

Đánh đổi: Thêm bảng outbox và một tiến trình relay; pessimistic lock (SELECT FOR UPDATE) chỉ đáng khi tranh chấp cao.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100), mục "Using a database table as a message queue" (polling publisher) và "Publishing events by applying the Transaction log tailing pattern" ; Microservices Patterns ↗ (.zip) · ch.6 · §6.1.3 Handling concurrent updates using optimistic locking (tr. 193–194) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97) (phía nhận vẫn phải idempotent) ; Chưa có nguồn trong thư viện: Debezium là sản phẩm cụ thể, sách chỉ nêu mẫu transaction log tailing

2) Sự kiện tracking có thể đến trùng và lệch thứ tự. Xử lý thế nào để timeline và trạng thái luôn đúng?

Cách chưa tốt
Insert mỗi message và đặt trạng thái theo message vừa nhận

Pub/Sub gửi lại 'Discharged' thì timeline có hai dòng. 'Discharged' đến sau 'Gate-out' thì trạng thái hiện tại bị kéo lùi.

Đánh đổi: Không tốn gì thêm, nhưng khách thấy dữ liệu sai.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97) (message có thể bị giao lại, redeliver sai thứ tự gây lỗi, tr. 95–96)

Cách tốt
Consumer idempotent: dùng eventId làm _id và upsert

updateOne({_id: eventId}, {$setOnInsert: evt}, {upsert: true}) nên nhận lại bao nhiêu lần cũng chỉ có một document. Với NoSQL, lưu message id ngay trong bản ghi là cách chống trùng gọn nhất.

Đánh đổi: Hết trùng nhưng thứ tự vẫn chưa được bảo đảm.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97), mục "Tracking messages and discarding duplicates" (lưu message id làm khóa chống trùng) ; MongoDB: The Definitive Guide 2e ↗ (.zip) · ch.3 · mục "Upserts" (tr. 45)

Cách tốt nhất
Upsert theo eventId + ordering key containerNo + chỉ tiến trạng thái khi occurredAt mới hơn + DLQ

Mọi sự kiện cùng containerNo đi vào cùng một ordering key nên được xử lý tuần tự. Message gửi lại vẫn có thể lệch, nên trạng thái hiện tại chỉ cập nhật khi occurredAt mới hơn; sự kiện muộn vẫn vào timeline. Lỗi quá 5 lần thì vào dead-letter topic để người xem lại và replay, không chặn cả hàng.

Đánh đổi: Ordering key làm một container kẹt thì các sự kiện sau của nó cũng chờ; cần cảnh báo khi DLQ tăng.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.5 Competing receivers and message ordering (tr. 94–95) (shard key giữ thứ tự cho cùng một thực thể) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "Evaluation of a Distributed Messaging Queue's Design", mục "Functional requirements" (dead-letter queue) ; Building Microservices ↗ (.zip) · ch.4 Integration · mục "Complexities of Asynchronous Architectures" (tr. 57–58: giới hạn retry, message hospital)

3) Cache tra cứu thế nào để khi key hết hạn, hàng nghìn request cùng lúc không đè sập Mongo?

Cách chưa tốt
Không cache, hoặc cache với TTL cố định giống nhau

Không cache thì mỗi lượt tra cứu là một truy vấn Mongo. TTL cố định khiến nhiều key hết hạn cùng lúc, và một container được nhiều người xem sẽ bị 1.000 request cùng truy vấn khi key vừa hết.

Đánh đổi: Ổn khi ít người dùng, sập đúng lúc tàu cập cảng và mọi người cùng mở.

Nguồn: Grokking Modern System Design · ch.16 Distributed Cache ↗ · bài "Background of Distributed Cache", mục "Cache invalidation"

Cách tốt
Cache-aside TTL 60 s có jitter, worker chủ động ghi đè key khi có sự kiện

Đọc Redis trước, miss mới đọc Mongo rồi ghi cache. TTL cộng thời gian ngẫu nhiên để các key không hết hạn cùng lúc. Worker ghi đè key ngay khi có sự kiện mới nên dữ liệu không cũ quá vài giây.

Đánh đổi: Vẫn còn stampede trên một key rất nóng tại thời điểm hết hạn.

Nguồn: Grokking Modern System Design · ch.16 Distributed Cache ↗ · bài "Background of Distributed Cache", mục "Writing policies" và "Eviction policies" ; Redis in Action ↗ · ch.2 · §2.3 Web page caching và §2.4 Database row caching (tr. 30–34) ; Chưa có nguồn trong thư viện: TTL jitter chống hết hạn đồng loạt không được sách nào nêu

Cách tốt nhất
Single-flight trong instance + khoá Redis SET NX PX giữa các instance

Trong một instance, giữ Map các promise đang chạy theo key để mọi request cùng key dùng chung một lần đọc Mongo. Giữa các instance, chỉ instance lấy được SET lock:tracking:{no} <uuid> NX PX 3000 mới tải lại; các instance khác chờ chút rồi đọc cache hoặc trả bản cũ. Validate check digit ISO 6346 trước để số rác không làm tốn cache.

Đánh đổi: Code phức tạp hơn và có lúc trả dữ liệu cũ vài giây, chấp nhận được với NFR 1 phút.

Nguồn: Node.js Design Patterns 2e ↗ (.zip) · ch.9 Advanced Asynchronous Recipes · mục "Batching and caching with promises" (tr. 369) ; Redis in Action ↗ · ch.6 · §6.2.3 Building a lock in Redis (SETNX) và §6.2.5 Locks with timeouts (tr. 120–127) ; Chưa có nguồn trong thư viện: SSE/EventSource, GCP Pub/Sub, Cloud Run và chuẩn mã container ISO 6346 không có trong sách của thư viện; chỉ nêu khái niệm cơ bản

4) Đẩy cập nhật realtime cho trang tracking khi API chạy nhiều instance?

Cách chưa tốt
Trình duyệt polling mỗi 5 giây

Container đổi trạng thái vài lần một ngày, nên gần như mọi lần poll đều trả về không có gì mới nhưng vẫn tốn request và cache.

Đánh đổi: Dễ làm nhất, lãng phí nhất.

Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Introduction to Pub-sub", mục "Use cases of pub-sub" (push thay vì kiểm tra định kỳ)

Cách tốt
SSE trên từng instance

Dữ liệu chỉ đi một chiều nên SSE đủ, chạy trên HTTP thường, trình duyệt tự kết nối lại. WebSocket chỉ cần khi có tương tác hai chiều như chat với nhân viên.

Đánh đổi: Worker phát sự kiện không biết client đang nối vào instance nào.

Nguồn: Chưa có nguồn trong thư viện: SSE/EventSource, GCP Pub/Sub, Cloud Run và chuẩn mã container ISO 6346 không có trong sách của thư viện; chỉ nêu khái niệm cơ bản ; Grokking Modern System Design · ch.36 WhatsApp ↗ · bài "Detailed Design of WhatsApp", mục "Connection with a WebSocket server" (WebSocket cho hai chiều)

Cách tốt nhất
SSE + Redis pub/sub phát cho mọi instance + Last-Event-ID lấy bù từ Mongo

Mọi instance subscribe một kênh Redis và đẩy cho client của mình, không cần sticky session. Redis pub/sub không lưu tin, nên khi kết nối lại client gửi Last-Event-ID và API lấy bù từ Mongo. Ghi cho client chậm phải tôn trọng backpressure: write() trả false thì chờ 'drain', và dọn kết nối khi req đóng.

Đánh đổi: Thêm một phụ thuộc vào Redis cho realtime; Redis lỗi thì realtime tạm dừng nhưng tra cứu vẫn chạy.

Nguồn: Redis in Action ↗ · ch.3 · §3.6 Publish/subscribe (tr. 54–57; pub/sub không lưu tin, mất kết nối thì mất message) ; Node.js Design Patterns 2e ↗ (.zip) · ch.5 Coding with Streams · mục "Back-pressure" (tr. 155) ; Chưa có nguồn trong thư viện: SSE/EventSource, GCP Pub/Sub, Cloud Run và chuẩn mã container ISO 6346 không có trong sách của thư viện; chỉ nêu khái niệm cơ bản

Trong phỏng vấn

Nên nói
  • “Ở khoảng 350 lượt ghi/giây lúc đỉnh, một Postgres primary cộng Redis là đủ. Tôi scale ra nhiều instance để đạt 99,9% và chuẩn bị cho tải gấp 10, không phải vì tải hiện tại.”
  • “Tôi bắt đầu bằng modular monolith NestJS chia theo nghiệp vụ. Module tách ra đầu tiên sẽ là Tracking vì tải khác hẳn Booking.”
  • “Hàng đợi chỉ đảm bảo at-least-once, nên consumer upsert theo eventId và chỉ tiến trạng thái khi occurredAt mới hơn.”
  • “Ràng buộc UNIQUE trong DB là tuyến phòng thủ cuối cho idempotency, vì hai request có thể chạy song song.”
Bẫy hay gặp
  • Tách microservice ngay từ đầu khi ranh giới chưa rõ (bài học SnapCI trong Building Microservices)
  • Dùng Redis pub/sub như hàng đợi bền: subscriber mất kết nối là mất tin
  • Ghi DB rồi publish message trong hai bước riêng, API crash ở giữa là mất sự kiện
  • Retry không có backoff và jitter, tự biến retry của mình thành một cuộc DDoS như sự cố AWS us-east-1 tháng 12/2021

Đọc thêm

Kafka
Kafka là log phân tán bền vững: topic chia partition, consumer đọc theo offset và có thể đọc lại. Bài này đi qua thứ tự theo partition, consumer group, replication với ISR và acks, giới hạn của exactly-once, cách chọn số partition và so sánh với SQS, Pub/Sub, RabbitMQ.
Change data capture (CDC)
Change data capture đọc mọi thay đổi trong database và phát thành luồng sự kiện. Bài này so sánh CDC dựa trên log, trigger và polling, giải thích Debezium với Kafka, quan hệ với transactional outbox, các use case và những bẫy về thứ tự và schema.
Scale ghi
Khi lượng ghi vượt sức một database: gom batch, ghi bất đồng bộ qua hàng đợi, chọn storage tối ưu cho ghi, chia dữ liệu theo key, xử lý partition nóng bằng salting, sharded counter và tổng hợp trước. Kèm con số cho từng quy mô.
Time-series database
Time-series database lưu dữ liệu đo theo thời gian như metric server, giá cổ phiếu, vị trí GPS. Bài này giải thích đặc điểm của loại dữ liệu này, cách nén, downsampling và retention, so sánh TimescaleDB, InfluxDB, Prometheus, ClickHouse, và bẫy cardinality.
PostgreSQL
PostgreSQL là lựa chọn mặc định an toàn cho phần lớn hệ thống: ACID, index phong phú, replication, partitioning và hệ extension mạnh. Bài này giải thích MVCC, các loại index, mức isolation, replication, connection pooling, bảng sizing theo cấu hình và dấu hiệu nên rời một node Postgres.

Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.