Skip to content

HARRY-17 — GĐ18 — System Design & Scaling (sản phẩm thật + phỏng vấn)

GĐ18 — System Design & Scaling (sản phẩm thật + phỏng vấn)

Study note cho FE engineer (JS/TS mạnh) đã đi hết GĐ2–GĐ22. Mỗi concept: định nghĩa → tại sao quan trọng → cơ chế → ví dụ → pitfall. Đây là giai đoạn hợp nhất: mọi thứ bạn đã học (index, cache, queue, transaction, observability) giờ được nhìn ở tầng hệ thống. Nó phục vụ hai mục tiêu cùng lúc: quyết định kiến trúc cho sản phẩm của bạn, và trả lời được vòng phỏng vấn system design — vốn là vòng quyết định level (và lương) ở vị trí mid/senior.

Nguyên tắc bao trùm: system design không phải chọn công nghệ xịn nhất. Nó là nêu ra đánh đổi và bảo vệ lựa chọn của mình bằng con số.


1. Khung tư duy — 6 bước, dùng cho cả hai mục tiêu

Định nghĩa. Một quy trình cố định để đi từ đề bài mơ hồ tới kiến trúc có cơ sở.

Tại sao quan trọng. Cả trong phỏng vấn lẫn ngoài đời, thất bại phổ biến nhất không phải thiếu kiến thức — mà là nhảy thẳng vào vẽ box trước khi biết hệ thống phải làm gì. Người phỏng vấn chấm quy trình nhiều hơn chấm đáp án.

Cơ chế — 6 bước (phỏng vấn 45 phút, phân bổ thời gian kèm theo):

BướcViệcThời gian
1. Làm rõ yêu cầuFunctional (làm gì) + non-functional (bao nhiêu, nhanh cỡ nào, chịu lỗi ra sao)5–8'
2. Ước lượngQPS, dung lượng, băng thông — để định cỡ, không phải để chính xác3–5'
3. API + mô hình dữ liệuEndpoint chính, schema, mẫu truy vấn8–10'
4. Kiến trúc mức caoVẽ box: client → LB → app → cache → DB → queue → worker8–10'
5. Đào sâu 1–2 điểmNgười phỏng vấn chọn, hoặc bạn đề xuất chỗ khó nhất10–12'
6. Nút thắt & mở rộngCái gì vỡ trước ở 10×? Chống lỗi thế nào?5'

Ví dụ — bước 1 phải hỏi những gì (đề "thiết kế URL shortener"):

Functional:  Rút gọn URL? Custom alias? Hết hạn? Thống kê lượt click?
Non-func:    Bao nhiêu link tạo/ngày? Tỉ lệ đọc/ghi? p99 redirect mục tiêu?
             Uptime yêu cầu? Được phép mất dữ liệu không?
Ràng buộc:   Số người dùng? Toàn cầu hay một vùng? Ngân sách?
Ngoài phạm vi: Auth? Chống lạm dụng? Thanh toán?

Câu quan trọng nhất luôn là tỉ lệ đọc/ghi. URL shortener đọc/ghi ≈ 100:1 → kiến trúc xoay quanh cache. Hệ thống phân tích log thì ngược lại → xoay quanh throughput ghi.

Pitfall. Trong phỏng vấn, im lặng suy nghĩ. Người phỏng vấn không đọc được suy nghĩ của bạn — họ chấm lý luận nói ra thành lời. Nói to đánh đổi ngay cả khi chưa chốt: "Có thể cache ở CDN, đổi lại thống kê click sẽ trễ — chấp nhận được nếu số liệu gần đúng là đủ."


2. Ước lượng nhanh — và các con số phải thuộc

Định nghĩa. Tính nhẩm bậc độ lớn (order of magnitude) để định cỡ hệ thống.

Tại sao quan trọng. Nó quyết định kiến trúc. 100 GB dữ liệu → một Postgres là quá đủ. 100 TB → phải bàn sharding. Không ước lượng thì mọi lựa chọn đều là cảm tính — và bạn sẽ over-engineer, đây là lỗi thường gặp nhất của người mới.

Cơ chế — bảng số phải thuộc lòng:

Thao tácĐộ trễGhi nhớ
Đọc cache trong RAM~100 ns
Redis (cùng vùng)~0.5–1 ms
Postgres, query có index~1–5 ms
Postgres, quét toàn bảng 1M hàng~100–1000 ms100–1000× chậm hơn
SSD đọc ngẫu nhiên~100 µs
Trong cùng datacenter~0.5 ms
Xuyên lục địa (VN ↔ US)~150–200 msVật lý, không tối ưu được
Gọi LLM (không stream)1–30 giâyChậm hơn mọi thứ khác 1000×

Quy đổi hữu ích:

1 triệu request/ngày   ≈ 12 RPS trung bình  → đỉnh thường 3–5× ≈ 50 RPS
100 triệu request/ngày ≈ 1.160 RPS          → đỉnh ≈ 5.000 RPS
1 năm ≈ 31,5 triệu giây
1 hàng Postgres điển hình ≈ 100 B – 1 KB

Ví dụ — ước lượng đầy đủ (URL shortener):

Giả định: 100M link mới/ngày, đọc/ghi = 100:1

Ghi:  100M / 86.400 ≈ 1.160 WPS   → đỉnh ≈ 5.000 WPS
Đọc:  116.000 RPS                 → đỉnh ≈ 500.000 RPS  ← con số quyết định
Lưu:  100M × 500 B ≈ 50 GB/ngày → 18 TB/năm
Băng thông đọc: 116k × 500 B ≈ 58 MB/s

Kết luận rút ra:
- 500k RPS đọc: KHÔNG cách nào để DB gánh → cache là bắt buộc, không phải tối ưu.
- 18 TB/năm: một Postgres kham được vài năm nếu có TTL. Chưa cần sharding NGAY,
  nhưng phải thiết kế khoá sao cho sharding về sau không phải viết lại.
- 58 MB/s: bình thường. Băng thông không phải nút thắt.

Pitfall. Sa vào tính toán chính xác ("86.400 giây × 0,73..."). Làm tròn mạnh tay: 1 ngày ≈ 100.000 giây. Cái người phỏng vấn muốn thấy là kết luận bạn rút ra từ con số, không phải phép chia.


3. Scale dọc vs ngang — và điều kiện tiên quyết

Định nghĩa. Dọc (vertical) = máy to hơn. Ngang (horizontal) = nhiều máy hơn.

Tại sao quan trọng. Ngành công nghệ có định kiến chống lại scale dọc, và điều đó thường sai. Một máy 64 vCPU / 256 GB RAM ngày nay phục vụ được lượng tải mà năm 2010 cần cả một cụm server — không kèm theo chi phí phức tạp phân tán.

Cơ chế.

  • Dọc: đơn giản nhất, không đổi code. Chạm trần phần cứng, và là điểm lỗi đơn (SPOF).
  • Ngang: gần như vô hạn, chịu lỗi tốt. Nhưng đòi hỏi stateless và kéo theo mọi vấn đề của hệ phân tán.

Điều kiện tiên quyết của scale ngang — stateless:

ts
// ❌ Trạng thái trong bộ nhớ process → instance B không thấy được của A.
// Người dùng bị LB đưa sang instance khác → mất session, mất dữ liệu.
const sessions = new Map<string, Session>();
const rateLimits = new Map<string, number>();
let uploadProgress: Record<string, number> = {};

// ✅ Trạng thái chia sẻ nằm ở kho bên ngoài
await redis.setex(`session:${id}`, 3600, JSON.stringify(session));
await redis.incr(`rl:${userId}:${minute}`);

Những chỗ hay giấu trạng thái mà không nhận ra: file upload tạm trên đĩa (GĐ11), cache in-memory, cron chạy trong process app (3 instance = job chạy 3 lần!), WebSocket connection (mục 5), bộ đếm rate limit.

Pitfall. Nhảy sang Kubernetes + 12 microservice khi mới có 100 người dùng. Bạn trả toàn bộ chi phí phân tán (debug xuyên service, nhất quán cuối, độ trễ mạng, deploy phức tạp) để đổi lấy khả năng scale mà mình chưa cần. Thứ tự đúng: một máy to → thêm read replica → thêm cache → scale ngang tầng app → và chỉ khi ấy mới bàn tách service.


4. Load balancer & tầng biên

Định nghĩa. LB phân phối request tới nhiều instance, loại bỏ instance hỏng khỏi vòng xoay.

Cơ chế — thuật toán:

  • Round-robin — lần lượt. Đơn giản, giả định request có chi phí như nhau.
  • Least connections — chọn instance đang rảnh nhất. Tốt hơn cho API có thời gian xử lý chênh lệch lớn (ví dụ endpoint LLM: có request 200 ms, có request 30 s).
  • Consistent hashing — cùng khoá tới cùng instance. Dùng cho cache phân tán.

Health check — phân biệt hai loại (đã gặp ở GĐ14):

ts
// Liveness: process còn sống không? Fail → RESTART container.
// Phải SIÊU nhẹ. Đừng check DB ở đây: DB chập chờn sẽ làm restart toàn bộ app,
// khiến sự cố nhỏ thành sự cố toàn hệ thống.
app.get('/healthz', (_, res) => res.sendStatus(200));

// Readiness: sẵn sàng nhận traffic chưa? Fail → LB NGỪNG gửi request (không restart).
app.get('/readyz', async (_, res) => {
  const [db, redis] = await Promise.allSettled([pingDb(), pingRedis()]);
  const ok = db.status === 'fulfilled' && redis.status === 'fulfilled';
  res.status(ok ? 200 : 503).json({ db: db.status, redis: redis.status });
});

Pitfall (a) — sticky session. "Ghim người dùng vào một instance" nghe như cách sửa nhanh cho trạng thái in-memory. Thực tế nó phá vỡ scale ngang: tải lệch, deploy làm mất session, instance chết là mất dữ liệu người dùng. Hãy sửa gốc — làm app stateless.

Pitfall (b) — graceful shutdown. Khi deploy, instance nhận SIGTERM. Nếu thoát ngay, các request đang xử lý bị cắt → người dùng thấy lỗi 502 mỗi lần deploy.

ts
process.on('SIGTERM', async () => {
  // 1. Báo readiness FAIL trước → LB ngừng gửi request mới
  isShuttingDown = true;
  // 2. Chờ LB cập nhật (thường 5–15s, tuỳ chu kỳ health check)
  await sleep(10_000);
  // 3. Ngừng nhận connection mới, để request đang chạy hoàn tất
  await new Promise(r => server.close(r));
  // 4. Đóng worker queue, DB pool
  await Promise.all([worker.close(), prisma.$disconnect()]);
  process.exit(0);
});

Thiếu bước 2 là lỗi phổ biến nhất: đóng server ngay lập tức trong khi LB vẫn đang gửi request tới.


5. Mở rộng tầng dữ liệu — thường là nút thắt thật

Định nghĩa. Tầng app scale ngang dễ (stateless). Database mới là chỗ khó, vì nó có trạng thái.

Cơ chế — thứ tự áp dụng, rẻ trước đắt sau:

(1) Tối ưu truy vấn. Trước khi mua thêm phần cứng: EXPLAIN ANALYZE (GĐ4), thêm index, diệt N+1, bỏ SELECT *. Thường đem lại 10–100× — nhiều hơn mọi bước sau cộng lại, và miễn phí.

(2) Connection pooling. Mỗi connection Postgres là một process riêng tốn ~5–10 MB RAM. Mặc định max_connections = 100. 20 instance app × pool 20 = 400 connection → DB từ chối kết nối.

App (pool nhỏ) → PgBouncer (transaction mode) → Postgres
                 1000 client ←→ 20 connection thật

Serverless đặc biệt cần: mỗi lần gọi hàm mở connection mới → cạn ngay. Dùng PgBouncer, Prisma Accelerate, hoặc driver HTTP (Neon).

(3) Read replica. Ghi vào primary, đọc từ replica. Phù hợp vì hầu hết app đọc/ghi ≈ 10:1 trở lên.

ts
// Bẫy "read-after-write": replica trễ vài chục–vài trăm ms.
// Người dùng lưu hồ sơ → chuyển trang → đọc từ replica → thấy dữ liệu CŨ → tưởng mất.
async function getProfile(userId: string, justWrote: boolean) {
  // Đọc ngay sau khi ghi (hoặc trong cùng session người dùng) → ép về primary
  return (justWrote ? primary : replica).user.findUnique({ where: { id: userId } });
}

(4) Caching. Xem GĐ8 mục 10–11. Ở tầng hệ thống, nhớ thứ bậc cache: trình duyệt → CDN → cache ứng dụng (Redis) → cache của DB. Bắt được ở tầng càng ngoài càng rẻ.

(5) Sharding — biện pháp cuối. Chia dữ liệu theo khoá (thường tenant_id) sang nhiều DB.

  • Được: ghi scale gần như tuyến tính.
  • Mất: JOIN xuyên shard, transaction xuyên shard, ORDER BY toàn cục, unique toàn cục — tất cả đều mất hoặc trở nên rất đắt. Rebalance shard là dự án nhiều tháng.
  • Khi nào: khi một máy lớn nhất không còn gánh nổi lượng ghi. Không phải khi dữ liệu "nhiều".

Ví dụ — chuẩn bị cho sharding mà chưa shard (làm ngay từ đầu, gần như miễn phí):

sql
-- Dùng UUID/ULID thay vì SERIAL: id không đụng nhau khi gộp/tách shard sau này
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- MỌI bảng đều có shard key, MỌI query đều lọc theo nó
tenant_id UUID NOT NULL,

Điều này cũng chính là thứ đảm bảo tenant isolation (GĐ22) — một mũi tên trúng hai đích.

Pitfall. Nhảy sang NoSQL để "scale" khi chưa từng chạy EXPLAIN. Phần lớn trường hợp "Postgres chậm" thực ra là thiếu một index. Postgres một máy phục vụ tốt hàng chục nghìn RPS đọc nếu được đánh index đúng — xa hơn nhiều so với nơi hầu hết sản phẩm từng đến.


6. Nhất quán — chọn có ý thức

Định nghĩa. Strong consistency — đọc luôn thấy lần ghi mới nhất. Eventual consistency — cuối cùng sẽ thấy, tạm thời có thể cũ.

Tại sao quan trọng. Đây là quyết định sản phẩm, không phải kỹ thuật. Đúng câu hỏi phải hỏi là: "Dữ liệu cũ vài giây ở đây gây hại gì?"

Cơ chế — chọn theo trường hợp:

Dữ liệuYêu cầuVì sao
Số dư ví, tồn khoStrongBán quá số lượng = mất tiền thật
Quyền hạn / roleStrongDữ liệu cũ = lỗ hổng bảo mật
Đếm lượt xem, số likeEventualKhông ai chết vì lệch 3 lượt trong 5 giây
Kết quả tìm kiếmEventualTrễ vài giây là bình thường
Feed, thông báoEventual

Ví dụ — nơi bắt buộc phải strong:

ts
// Ép DB làm trọng tài, không tin điều kiện kiểm ở tầng app.
// Đọc rồi kiểm rồi ghi (read-check-write) ở app luôn có race condition
// giữa lúc đọc và lúc ghi khi có 2 request đồng thời.
const updated = await prisma.$executeRaw`
  UPDATE wallets SET balance_cents = balance_cents - ${amount}
  WHERE user_id = ${userId} AND balance_cents >= ${amount}`;   -- điều kiện NẰM TRONG câu UPDATE
if (updated === 0) throw new InsufficientFundsError();

CAP nói ngắn gọn. Khi mạng giữa các node bị chia cắt (partition — sẽ xảy ra), bạn phải chọn: từ chối phục vụ để giữ nhất quán (CP), hay vẫn phục vụ và chấp nhận dữ liệu lệch (AP). Không có lựa chọn thứ ba. Postgres một primary là CP. DNS là AP.

Pitfall. Nói "hệ thống của tôi eventually consistent" như một đặc điểm kỹ thuật, mà không nói rõ cửa sổ trễ là bao lâungười dùng thấy gì trong cửa sổ đó. Người phỏng vấn sẽ hỏi đúng câu đó.


7. Chống chịu lỗi — cách hệ thống hỏng

Định nghĩa. Tập kỹ thuật giữ cho lỗi cục bộ không lan thành sự cố toàn hệ thống.

Tại sao quan trọng. Ở quy mô nhỏ, dependency chết = trang lỗi. Ở quy mô lớn, dependency chậm (chưa chết) là nguy hiểm hơn chết hẳn: request dồn lại, thread/connection cạn, và toàn bộ hệ thống sập dây chuyền vì một service phụ trợ.

Cơ chế — 5 kỹ thuật, theo thứ tự quan trọng:

(1) Timeout — ở MỌI lời gọi ra ngoài. Không có timeout = chờ vô hạn = cạn tài nguyên.

ts
// Timeout phải NGẮN HƠN timeout của tầng gọi bạn — nếu không, client bỏ cuộc
// trước, và bạn vẫn giữ tài nguyên cho một request không còn ai chờ.
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });

(2) Retry + backoff + jitter.

ts
// Không có jitter: mọi client retry ĐỒNG THỜI sau đúng 1s → tự tạo cơn bão
// đánh sập service vừa mới hồi phục ("thundering herd").
const delay = Math.min(baseMs * 2 ** attempt, maxMs) * (0.5 + Math.random() * 0.5);

Chỉ retry cái an toàn khi lặp lại: GET, hoặc POST có idempotency key (GĐ8 mục 14). Retry một POST /payments không idempotent = trừ tiền hai lần.

(3) Circuit breaker. Sau N lỗi liên tiếp, ngừng gọi trong T giây và fail nhanh. Bảo vệ cả bạn (không cạn tài nguyên) lẫn dependency (không bị dội request khi đang ngộp).

(4) Bulkhead. Chia tài nguyên theo ngăn, để một phần hỏng không kéo cả tàu chìm.

ts
// Endpoint LLM (chậm, 30s) và endpoint CRUD (nhanh, 50ms) dùng chung pool
// → một đợt tải LLM làm cạn pool → CRUD cũng chết theo dù chẳng liên quan.
// ✅ Tách pool/hàng đợi riêng, hoặc tách hẳn service.

(5) Suy giảm có kiểm soát (graceful degradation). Tính năng phụ chết thì tính năng chính vẫn sống.

ts
// Gợi ý cá nhân hoá chết → trả danh sách phổ biến, KHÔNG trả lỗi 500 cả trang.
const recs = await withTimeout(recSvc.get(userId), 200).catch(() => popularItems);

Load shedding — điều phản trực giác. Khi quá tải, chủ động từ chối một phần request (429) là đúng. Nhận hết rồi phục vụ tất cả chậm hơn timeout của client = 0% thành công. Từ chối 30% để 70% thành công là kết quả tốt hơn hẳn.

Pitfall. Retry ở mọi tầng, lồng nhau. Client retry 3 lần × API gateway retry 3 lần × service retry 3 lần = 27 request cho một hành động. Bạn vừa tự DDoS chính mình đúng lúc hệ thống yếu nhất. Quy tắc: retry ở đúng một tầng, thường là tầng ngoài cùng gần người dùng nhất.


8. Đa người thuê & hàng xóm ồn ào

Định nghĩa. Một tenant tiêu thụ quá nhiều tài nguyên chung làm ảnh hưởng mọi tenant khác.

Tại sao quan trọng. Trong SaaS (GĐ22), một khách hàng import 10 triệu bản ghi làm chậm hệ thống của 500 khách hàng khác. Họ không quan tâm nguyên nhân — họ chỉ thấy sản phẩm của bạn chậm.

Cơ chế.

  • Rate limit theo tenant, không chỉ theo IP (GĐ8 mục 6). Hạn mức theo gói dịch vụ.
  • Hàng đợi riêng theo mức ưu tiên: job của gói free vào queue riêng, worker riêng, số lượng hạn chế.
  • Giới hạn tài nguyên trên mỗi truy vấn: statement_timeout theo tenant; chặn LIMIT quá lớn.
  • Cách ly cứng cho khách hàng lớn: khi cần, cho enterprise DB/cụm riêng.

Ví dụ:

ts
// Việc nặng theo lô KHÔNG BAO GIỜ đi chung hàng đợi với việc tương tác
await bulkQueue.add('import', payload, { priority: tenant.plan === 'free' ? 10 : 1 });
// worker riêng, concurrency giới hạn → không bao giờ chiếm hết chỗ của việc realtime
new Worker('bulk', handler, { concurrency: 2 });

Pitfall. Chỉ rate limit theo IP. Một tenant doanh nghiệp đứng sau NAT dùng chung một IP → tất cả nhân viên của họ bị chặn oan. Ngược lại, kẻ lạm dụng đổi IP dễ dàng. Rate limit phải theo danh tính (user/tenant/API key), IP chỉ là lớp bổ sung cho request chưa xác thực.


9. Từ monolith tới nhiều service — khi nào, và cái giá thật

Định nghĩa. Tách một ứng dụng thành nhiều service triển khai độc lập.

Tại sao quan trọng. Đây là quyết định kiến trúc đắt nhất và khó đảo ngược nhất. Và nó thường được đưa ra vì lý do sai.

Cơ chế — lý do đúng và sai:

Lý do táchCó hợp lệ?
Nhiều team giẫm chân nhau khi deploy✅ Lý do thật sự duy nhất phổ biến
Một phần cần scale rất khác phần còn lại (GPU cho LLM)
Một phần cần công nghệ khác (Python cho ML)
Cần cách ly lỗi cho vùng nghiêm ngặt (thanh toán)
"Microservice là kiến trúc hiện đại"
"Codebase to quá"❌ Dùng module trong monolith
"Để scale được"❌ Monolith scale ngang tốt nếu stateless

Cái giá phải trả — nói rõ ra: lời gọi hàm (nano-giây, không bao giờ lỗi) trở thành lời gọi mạng (mili-giây, lỗi thường xuyên). Transaction ACID biến mất → phải dùng saga/outbox. Debug cần distributed tracing. Test local cần dựng 8 service. Mỗi service cần CI/CD, monitoring, on-call riêng.

Đường đi được khuyến nghị: monolith có mô-đun (modular monolith).

src/modules/
  billing/     ← ranh giới rõ: chỉ giao tiếp qua interface công khai của module
  documents/
  auth/

Ranh giới rõ ràng trong một tiến trình. Khi cần tách thật, đường cắt đã sẵn ở đó. Đây gần như luôn là lựa chọn đúng cho một sản phẩm dưới 10 kỹ sư.

Pitfall. Tách theo tầng kỹ thuật (user-service, order-service, notification-service) mà không theo ranh giới nghiệp vụ. Kết quả: mỗi tính năng phải sửa 5 service và deploy đồng bộ — bạn nhận đủ chi phí phân tán mà không có chút độc lập nào. Đó là "distributed monolith", tệ hơn cả monolith lẫn microservice.


10. Quan sát để tìm nút thắt

Định nghĩa. Hai bộ chỉ số chuẩn: RED cho service (Rate, Errors, Duration) và USE cho tài nguyên (Utilization, Saturation, Errors).

Tại sao quan trọng. Không đo thì mọi tối ưu là đoán. Và ở tầng hệ thống, nút thắt hầu như luôn nằm ở chỗ bạn không ngờ tới.

Cơ chế — cách đọc:

  • Đo p50 / p95 / p99, không đo trung bình. Trung bình giấu đi đuôi chậm — mà đuôi chậm chính là trải nghiệm của khách hàng lớn nhất (họ có nhiều dữ liệu nhất).
  • Saturation thường cảnh báo sớm hơn utilization: độ dài hàng đợi, số connection pool đang chờ, độ trễ replica.
  • Tracing trả lời "3 giây đó tiêu ở đâu" — thứ mà log rời rạc không bao giờ nói được.

Ví dụ — trình tự chẩn đoán "API chậm":

1. p99 chậm nhưng p50 bình thường?  → chỉ một số request; nghi vấn: thiếu index cho
                                       tenant dữ liệu lớn, hoặc N+1 theo số phần tử
2. Cả p50 lẫn p99 đều chậm?         → tài nguyên bão hoà; xem CPU, pool DB, replica lag
3. Chậm dần theo thời gian chạy?    → rò rỉ (memory/connection); so sánh trước/sau restart
4. Chậm chỉ vào giờ cao điểm?       → xếp hàng; xem saturation, không xem utilization
5. Trace nói thời gian nằm ở đâu?   → DB / gọi ngoài / CPU trong app — ba hướng khác nhau

Pitfall. Cảnh báo dựa trên CPU. CPU 90% có thể hoàn toàn ổn (đang tận dụng tốt), CPU 30% có thể đang chết (chờ I/O). Hãy cảnh báo dựa trên triệu chứng người dùng cảm nhận: p99 latency, tỉ lệ lỗi, độ sâu hàng đợi. CPU chỉ dùng để chẩn đoán sau khi đã có cảnh báo.


11. Case study — AI SaaS ở GĐ22 gánh 100× tải

Bối cảnh. Capstone GĐ22 chạy tốt với 1.000 người dùng. Giờ là 100.000. Cái gì vỡ, theo thứ tự?

(1) Vỡ đầu tiên — connection pool DB. Scale app lên 20 instance → 20 × 20 = 400 connection > max_connections. → PgBouncer, pool app nhỏ lại.

(2) Kế tiếp — chi phí và độ trễ LLM. 100× lời gọi LLM = 100× hoá đơn, và rate limit của nhà cung cấp bắt đầu chặn. → Cache ngữ nghĩa (câu hỏi giống nhau → dùng lại kết quả), prompt caching, định tuyến model theo độ khó (model nhỏ cho việc dễ), hàng đợi riêng theo gói, hạn mức cứng theo tenant.

(3) Kế tiếp — vector search. Bảng pgvector lên hàng chục triệu dòng; index IVFFlat bắt đầu chậm. → Chuyển HNSW, lọc theo tenant_id trước khi tìm vector (partial index theo tenant), cân nhắc vector DB chuyên dụng khi đã đo được là Postgres không kham nổi.

(4) Kế tiếp — worker queue tồn đọng. Job xử lý tài liệu chất đống, người dùng chờ hàng giờ. → Tách queue theo mức ưu tiên, tự động scale worker theo độ sâu hàng đợi, load shedding cho gói free.

(5) Kế tiếp — bảng usage/audit phình. usage_records 500 triệu hàng, báo cáo timeout. → Partition theo tháng (GĐ13 mục 11), bảng tổng hợp sẵn (rollup) cho dashboard, drop partition cũ.

(6) Xuyên suốt — streaming SSE giữ connection lâu. Mỗi request LLM giữ connection 30 giây. → Đây chính là lúc bulkhead cần thiết: tách hẳn service streaming ra khỏi API CRUD; chỉnh timeout ở LB (mặc định 30–60s sẽ cắt giữa chừng câu trả lời).

Điểm mấu chốt cần rút ra. Không có bước nào ở trên bắt đầu bằng "viết lại bằng Go" hay "chuyển sang microservice". Mở rộng quy mô thật là một chuỗi các nút thắt cụ thể, được đo đạc và xử lý từng cái một — theo đúng thứ tự chúng xuất hiện.


12. Chiến thuật phỏng vấn

Nói to suy nghĩ. Người phỏng vấn chấm quá trình lập luận, không chấm hình vẽ.

Luôn nêu đánh đổi. "Tôi chọn Postgres thay vì Cassandra: ta cần transaction cho thanh toán, và 18 TB nằm trong tầm của Postgres. Nếu ghi vượt 50k WPS thì phải xem lại." — Một câu này có giá trị hơn cả một sơ đồ đẹp.

Bắt đầu đơn giản, tiến hoá dần. Vẽ kiến trúc đơn giản chạy được trước, rồi để người phỏng vấn ép bạn scale. Vẽ ngay 15 box là dấu hiệu của over-engineering.

Chủ động nêu điểm yếu. "Ở đây có điểm lỗi đơn. Nếu cần 99,99% uptime thì phải thêm replica standby — có đáng không tuỳ vào yêu cầu."

Biết nói "tôi không biết". "Tôi chưa vận hành Kafka ở quy mô này. Theo hiểu biết của tôi thì nó phù hợp vì X — nhưng tôi sẽ cần đo trước khi cam kết." Thành thật ghi điểm cao hơn tự tin sai.

Quản lý thời gian. Nếu đã 20 phút mà chưa vẽ được gì, bạn đã làm rõ yêu cầu quá lâu. Chốt giả định thành tiếng và đi tiếp.

Pitfall. Đọc thuộc lòng một kiến trúc mẫu học từ video. Người phỏng vấn sẽ thay đổi ràng buộc ("nếu 90% traffic dồn vào 1% tenant thì sao?") và bài học thuộc lòng sụp đổ ngay. Học khung tư duy, đừng học đáp án.


Thực hành

A — Thiết kế trên giấy (mỗi bài 45 phút, bấm giờ, viết ra):

  1. URL shortener (kinh điển; luyện ước lượng + cache).
  2. Nền tảng RAG đa tenant — chính là sản phẩm GĐ22 ở quy mô 100×.
  3. Hệ thống đo lường sử dụng + tính hạn mức cho SaaS (ghi nhiều, cần chính xác).
  4. Hàng đợi job có ưu tiên và đảm bảo công bằng giữa các tenant.

Mỗi bài phải có: giả định, ước lượng bằng số, sơ đồ, 2 điểm đào sâu, và mục "cái gì vỡ trước ở 10×".

B — Trên sản phẩm thật (Dự án 3 / Capstone): 5. Rà toàn bộ code tìm trạng thái in-memory; chuyển hết sang Redis. Chạy 3 instance sau một LB → xác nhận mọi thứ vẫn đúng (đặc biệt: rate limit và cron không chạy 3 lần). 6. Cài graceful shutdown đủ 4 bước. Deploy trong lúc k6 đang bắn tải → xác nhận 0 request lỗi. 7. Thêm PgBouncer. Đo số connection trước/sau. 8. Thêm timeout + retry có jitter + circuit breaker cho lời gọi LLM. Dùng MSW (GĐ12) mô phỏng nhà cung cấp trả 500 → xác nhận hệ thống suy giảm có kiểm soát, không sập. 9. Cài load shedding: khi độ sâu hàng đợi > N, trả 429 kèm Retry-After cho gói free. 10. Dựng dashboard RED: p50/p95/p99, tỉ lệ lỗi, độ sâu hàng đợi, độ trễ replica. Cảnh báo theo p99 và tỉ lệ lỗi, không theo CPU. 11. Chạy k6 tăng tải tới khi hỏng. Ghi lại cái gì hỏng trước — hầu như luôn khác với dự đoán của bạn. Sửa. Lặp lại.


Done khi

  • [ ] Áp dụng được khung 6 bước cho một đề bài lạ trong 45 phút, có bấm giờ.
  • [ ] Thuộc bảng độ trễ; ước lượng QPS/dung lượng bằng nhẩm và rút ra kết luận kiến trúc từ con số.
  • [ ] Giải thích được vì sao scale dọc thường là bước đúng đầu tiên; nêu được ít nhất 5 chỗ hay giấu trạng thái phá vỡ scale ngang.
  • [ ] Phân biệt liveness vs readiness; giải thích vì sao không check DB trong liveness.
  • [ ] Cài graceful shutdown đủ 4 bước và chứng minh deploy không mất request.
  • [ ] Nêu đúng thứ tự mở rộng tầng dữ liệu (query → pool → replica → cache → shard) và giải thích vì sao sharding là cuối cùng.
  • [ ] Giải thích bẫy read-after-write với replica và cách xử lý.
  • [ ] Quyết định strong vs eventual theo từng loại dữ liệu và nêu được hậu quả nghiệp vụ.
  • [ ] Cài đủ timeout + retry có jitter + circuit breaker; giải thích vì sao chỉ retry ở một tầng.
  • [ ] Giải thích load shedding và vì sao từ chối bớt request lại tốt hơn nhận hết.
  • [ ] Nêu biện pháp chống noisy neighbor; giải thích vì sao rate limit theo IP là không đủ.
  • [ ] Nêu được 4 lý do hợp lệ để tách service và cái giá thật; bảo vệ được lựa chọn modular monolith.
  • [ ] Chẩn đoán "API chậm" theo trình tự có hệ thống; biết vì sao không cảnh báo theo CPU.
  • [ ] Chạy được bài tăng tải tới điểm gãy và tìm ra nút thắt thật.
  • [ ] Trong phỏng vấn: nói to suy nghĩ, nêu đánh đổi, chủ động chỉ ra điểm yếu, nói được "tôi không biết" đúng cách.

Câu hỏi mở / chưa giải quyết

  • Ngưỡng nào thì đáng tách service streaming LLM khỏi API chính? Phụ thuộc timeout của LB và tỉ lệ request dài — cần đo trên hệ thống thật, không có con số chung.
  • pgvector tới quy mô nào thì phải chuyển vector DB chuyên dụng? Đo p99 của truy vấn theo số lượng vector, đừng chuyển theo lời đồn.
  • Multi-region: độ trễ xuyên lục địa 150–200 ms là giới hạn vật lý. Đáng làm khi nào, và ghi dữ liệu xử lý ra sao (một primary hay multi-primary)? Đây là bài toán lớn — chỉ mở ra khi có khách hàng thật ở vùng khác.
  • Kubernetes: GĐ14 dừng ở Docker + PaaS + ECS một cách có chủ đích. Ngưỡng hợp lý để học K8s là khi đã có nhiều service và nhiều môi trường — không phải trước đó.

Học bằng cách build. Chứng minh, đừng tin.