GĐ21 — System Design & Scaling (sản phẩm thật + phỏng vấn)
Study note cho FE engineer (JS/TS mạnh) đã học nền tảng backend. Đọc được ngay sau GĐ19 và GĐ20; không cần làm xong các giai đoạn AI (GĐ22–25) trước. Các ví dụ AI SaaS ở đây là case study tự đứng được. 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ước | Việc | Thời gian |
|---|---|---|
| 1. Làm rõ yêu cầu | Functional (làm gì) + non-functional (bao nhiêu, nhanh cỡ nào, chịu lỗi ra sao) | 5–8' |
| 2. Ước lượng | QPS, dung lượng, băng thông — để định cỡ, không phải để chính xác | 3–5' |
| 3. API + mô hình dữ liệu | Endpoint chính, schema, mẫu truy vấn | 8–10' |
| 4. Kiến trúc mức cao | Vẽ box: client → LB → app → cache → DB → queue → worker | 8–10' |
| 5. Đào sâu 1–2 điểm | Người phỏng vấn chọn, hoặc bạn đề xuất chỗ khó nhất | 10–12' |
| 6. Nút thắt & mở rộng | Cá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"):
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. Bảng độ trễ chuẩn chỉ nằm ở một nơi: GĐ19 mục 2 (RAM, SSD, round-trip datacenter, Redis, Postgres, xuyên lục địa, LLM). Đừng học thuộc từng chữ số: chúng là bậc độ lớn (order of magnitude), đổi theo phần cứng và đường truyền. Điều cần nhớ là các khoảng cách giữa chúng: RAM nhanh hơn mạng trong datacenter khoảng 5.000 lần, mạng trong datacenter nhanh hơn xuyên lục địa khoảng 400 lần, và lời gọi LLM chậm hơn mọi thứ còn lại.
Quy đổi hữu ích:
Ví dụ — ước lượng đầy đủ (URL shortener):
Kiểm lại từng con số của ví dụ URL shortener
Kiểm chứng kết luận cache: nếu cache bắt 99% lượt đọc thì DB chỉ còn 1% × 580.000 = 5.800 RPS đỉnh, mức một Postgres có index xử lý được; nếu cache chỉ bắt 90% thì còn 58.000 RPS và đó vẫn là vấn đề. Tỉ lệ cache hit là con số cần hỏi tiếp.
Đã chạy: Node 24.21.0, các phép nhân/chia trên cho đúng các số đã làm tròn trong bài.
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:
Những chỗ hay giấu trạng thái mà không nhận ra: file upload tạm trên đĩa (GĐ12), cache in-memory, cron chạy trong process app (3 instance = job chạy 3 lần!), WebSocket connection (GĐ09 mục 9; thiết kế chat ở mục 15 của bài này), 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. Cơ chế, điểm ảo và bẫy khoá nóng, có số đo: mục 13.
Health check — phân biệt hai loại (chi tiết: GĐ09 mục 17, probe trên K8s: GĐ18 mục 5):
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. Mẫu code chuẩn của cả giáo trình nằm ở GĐ09 mục 18; ở tầng hệ thống chỉ cần nhớ thứ tự 4 bước:
- Đánh dấu đang tắt và để readiness fail → LB ngừng gửi request mới.
- Chờ việc gỡ instance khỏi LB lan xong (thường vài giây tới 15 giây, tuỳ chu kỳ health check).
- Ngừng nhận connection mới và await cho request đang chạy hoàn tất (kèm hẹn giờ thoát cưỡng bức nhỏ hơn grace period của orchestrator).
- Đóng worker queue và DB pool sau khi HTTP đã drain, rồi mới
exit.
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.
Bước 2 làm ở đâu: chọn một nơi, không cộng dồn. Trên Kubernetes, bước 2 do preStop đảm nhiệm (GĐ18 mục 6): app không sleep thêm, vì sleep trong app cộng với preStop làm mỗi lần deploy chờ lâu gấp đôi mà không an toàn hơn. sleep trong app chỉ dành cho nền tảng không có hook tương đương (VM + LB tự quản).
Thứ tự thực tế trên Kubernetes khác danh sách 4 bước ở trên: preStop chạy trước (pod vẫn nhận traffic trong lúc endpoint được gỡ), rồi mới đến SIGTERM, và từ đó app làm bước 3 và 4. Vì endpoint đã được gỡ trong lúc preStop, app không cần tự làm readiness fail rồi chờ như bước 1 và 2; nhánh đó chỉ dành cho nền tảng không có preStop.
Sơ đồ: thứ tự thật trên Kubernetes và bất đẳng thức thời gian
Ví dụ số (chỉ để minh hoạ cách cộng, không phải khuyến nghị): preStop 10 s + request dài nhất 20 s + đóng dependency 2 s = 32 s, nên terminationGracePeriodSeconds 45 s là hợp lý, còn 30 s thì bị SIGKILL cắt request dài nhất. preStop kiểu sleep là tính năng stable từ Kubernetes 1.34 (kiểm chứng ngày 2026-10-05); chi tiết ở GĐ18 mục 6.
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Đ05), 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.
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).
PgBouncer ở transaction mode không giữ trạng thái phiên giữa các transaction. Theo trang tính năng của PgBouncer (đọc 2026-10-05), SET/RESET, LISTEN, WITH HOLD CURSOR, advisory lock mức session và PREPARE/DEALLOCATE không dùng được; prepared statement mức giao thức dùng được khi đặt max_prepared_statements khác 0. Trang đó không nói về SET LOCAL (chưa xác minh). Cách Prisma phối hợp với PgBouncer (tham số pgbouncer=true trong chuỗi kết nối, chạy migrate qua kết nối trực tiếp) chưa xác minh với Prisma 7: đọc tài liệu Prisma đúng phiên bản bạn dùng trước khi bật.
(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.
(4) Caching. Xem GĐ09 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 BYtoà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í):
uuidv7() có sẵn từ PostgreSQL 18 (hàm UUID, đọc 2026-10-05); bản cũ hơn thì để ứng dụng sinh UUIDv7 hoặc dùng ULID. Cùng lập luận ở GĐ19 mục 5: thứ tự theo thời gian của id chỉ giúp cục bộ trong index, không phải thứ tự commit.
Điều này cũng chính là thứ đảm bảo tenant isolation trong SaaS nhiều khách hàng (capstone ở GĐ25 dùng đúng cách này) — một mũi tên trúng hai đích.
Sơ đồ và tính toán: pool, PgBouncer và sharding
Connection: 20 instance × pool 20 = 400 kết nối, mỗi kết nối là một process ~5–10 MB, nên ~2–4 GB RAM chỉ để giữ kết nối rảnh và vượt max_connections = 100 (400 > 100: từ chối kết nối). Với PgBouncer ở transaction mode, 400 kết nối phía app dồn xuống ví dụ 20 kết nối thật tới Postgres; mỗi kết nối thật phục vụ một transaction rồi nhả cho client khác.
Rebalance: với hash % N, đổi N từ 4 sang 5 thì khoá giữ nguyên shard khi k mod 4 = k mod 5, tức 4/20 = 20% (trên chu kỳ 20), nên 80% dữ liệu phải di chuyển. Vì vậy dùng consistent hashing (chạy được ở mục 13: đo thật còn khoảng 22% khoá di chuyển thay vì khoảng 80%) hoặc bảng ánh xạ tenant -> shard, và đây là lý do sharding là dự án nhiều tháng. Đã chạy: Node 24.21.0 cho phép tính 20%/80% (chỉ số học, chưa thử Postgres hay PgBouncer thật).
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ệu | Yêu cầu | Vì sao |
|---|---|---|
| Số dư ví, tồn kho | Strong | Bán quá số lượng = mất tiền thật |
| Quyền hạn / role | Strong | Dữ liệu cũ = lỗ hổng bảo mật |
| Đếm lượt xem, số like | Eventual | Không ai chết vì lệch 3 lượt trong 5 giây |
| Kết quả tìm kiếm | Eventual | Trễ vài giây là bình thường |
| Feed, thông báo | Eventual |
Ví dụ — nơi bắt buộc phải strong:
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âu và ngườ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.
(2) Retry + backoff + jitter.
Đã đo ở GĐ19 mục 7: 1.000 client cùng lỗi, không jitter thì đỉnh 1.000 request mỗi 100 ms, full jitter còn 143.
Chỉ retry cái an toàn khi lặp lại: GET, hoặc POST có idempotency key (GĐ09 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. Endpoint LLM (chậm, 30 s) và endpoint CRUD (nhanh, 50 ms) dùng chung một pool thì một đợt tải LLM làm cạn pool, CRUD chết theo dù chẳng liên quan. Cách sửa: mỗi nhóm một ngăn riêng (semaphore giới hạn đồng thời cộng hàng đợi có trần, đầy thì từ chối ngay), hoặc tách hẳn service. Code chạy được nằm ở phần chi tiết bên dưới.
(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.
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.
Sơ đồ, code và kết quả đã chạy: circuit breaker, bulkhead, retry, load shedding
Đã chạy (Node 24.21.0, tsc --strict sạch; resetMs đặt 100 ms để demo, dependency giả có độ trễ 5 ms; lớp dùng thuộc tính khai báo thường vì type stripping của Node không chạy cú pháp parameter property):
| Tình huống | Kết quả |
|---|---|
| 20 lời gọi tuần tự, dependency luôn lỗi, ngưỡng 5 | 5 lời gọi thật (5 lỗi), 15 lời gọi CircuitOpenError ngay |
Sau resetMs, 10 lời gọi đồng thời, dependency vẫn lỗi | 1 lời gọi thật (thăm dò), 9 CircuitOpenError; breaker mở lại |
Sau resetMs, 10 lời gọi đồng thời, dependency đã hồi phục | 1 lời gọi thật thành công, 9 CircuitOpenError; breaker đóng |
| Lời gọi kế tiếp | 1 lời gọi thật, thành công (đã đóng) |
Hạn chế còn lại: lời gọi đã bay trước lúc breaker mở vẫn có thể đóng nó khi hoàn tất thành công; chưa phân biệt lỗi 4xx (không nên tính vào ngưỡng); chưa có cửa sổ thời gian cho việc đếm lỗi. Bản sản xuất nên dùng thư viện đã được kiểm chứng.
Ghép với retry: timeout nằm trong lời gọi, retry ở một tầng bọc breaker, breaker mở thì dừng retry. Đoạn này đã chạy trong test MSW ở Thực hành B, mục 8.
Bulkhead chạy được. Hai ngăn riêng so với một ngăn dùng chung, cùng một đợt 10 request LLM (200 ms) rồi 10 request CRUD (5 ms):
Đã chạy (Node 24.21.0, tsc --strict sạch; độ trễ làm tròn 10 ms):
| Cấu hình | LLM | CRUD |
|---|---|---|
| Một ngăn chung (2 chạy + 2 chờ) | 4 xong, 6 bị từ chối | 0 xong, 10 bị từ chối (dù CRUD chỉ mất 5 ms) |
| Hai ngăn (LLM 2+2, CRUD 10+10) | 4 xong, 6 bị từ chối | 10 xong, độ trễ lớn nhất khoảng 10 ms |
Điều cần nhớ: bulkhead không làm LLM nhanh hơn, nó chỉ giữ cho thiệt hại nằm trong ngăn của LLM. Từ chối khi hàng đợi đầy cũng chính là load shedding ở mức một nhóm lời gọi.
Load shedding, tính số. Năng lực phục vụ 100 req/s, đến 120 req/s, client bỏ cuộc sau 2 s.
Đây là suy ra bằng công thức (hàng đợi tăng đều, client không hồi đáp sau timeout), không phải số đo.
Pitfall. Retry ở mọi tầng, lồng nhau. Client thử tối đa 3 lần (gồm lần đầu) × API gateway 3 lần × service 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 nhiều khách hàng, 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Đ09 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_timeouttheo tenant; chặnLIMITquá 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ụ:
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ách | Có 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).
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":
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Đ25 gánh 100× tải#
Bối cảnh. Đây là case study suy luận, đọc được trước khi làm capstone ở GĐ25. Giả sử có một AI SaaS RAG đa tenant (đúng loại sản phẩm capstone GĐ25 xây) 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ưu ý lọc theo tenant_id trong pgvector là post-filter: điều kiện WHERE áp dụng sau khi quét index nên có thể trả ít hơn LIMIT (kiểm chứng ngày 2026-10-05, chi tiết ở GĐ23). Bật iterative scan (pgvector 0.8.0 trở lên), partition theo tenant lớn; partial index theo tenant chỉ hợp cho vài tenant rất lớn, còn hàng chục nghìn tenant thì không tạo index riêng cho từng cái (suy ra: mỗi index tốn bộ nhớ và thời gian xây). 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Đ14 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à với giả định 1M link/ngày thì dữ liệu chỉ ~0,2 TB/năm, nằm gọn trong một Postgres. Nếu ghi vượt 50k WPS, hoặc lên 100M link/ngày (~18 TB/năm), thì phải shard theo khoá." — 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.
13. Consistent hashing: chia khoá mà không xáo trộn cả hệ#
Định nghĩa. Cách ánh xạ khoá sang node sao cho thêm hoặc bớt một node chỉ di chuyển một phần nhỏ khoá.
Tại sao quan trọng. hash(key) % N đơn giản nhưng đổi N là xáo gần hết khoá: cache phân tán thì mất gần hết dữ liệu nóng cùng lúc (cơn bão cache miss đổ vào DB), shard thì phải di chuyển gần hết dữ liệu.
Cơ chế. Đặt các node lên một vòng số từ 0 đến 2^32. Mỗi khoá băm ra một điểm trên vòng và thuộc node đầu tiên gặp được khi đi theo chiều kim đồng hồ. Thêm node mới chỉ cướp phần vòng nằm ngay trước nó, nên chỉ khoảng 1/(N+1) khoá đổi chỗ, và đều đổ về node mới. Một node trên vòng chỉ có một điểm thì phân bố rất lệch, nên mỗi node được đặt nhiều điểm ảo (virtual node) rải khắp vòng.
Code và kết quả đã chạy: vòng băm, điểm ảo, khoá nóng
Đã chạy (Node 24.21.0, tsc --strict sạch; 100.000 khoá, hàm băm là 4 byte đầu của SHA-1):
| Thí nghiệm | Kết quả |
|---|---|
| Thêm node thứ 5 (từ 4 node) | hash % N di chuyển 79,9% khoá; vòng băm 150 điểm ảo di chuyển 21,7% (lý thuyết 1/(N+1) = 20%); mọi khoá di chuyển đều đổ về node mới |
| Cân bằng tải, 1 điểm ảo/node | node nặng nhất 54,2%, nhẹ nhất 6,2% |
| 10 điểm ảo/node | nặng nhất 48,2%, nhẹ nhất 8,0% |
| 150 điểm ảo/node | nặng nhất 26,1%, nhẹ nhất 23,9% (lý tưởng 25%) |
| Một khoá nhận 30% lưu lượng | node chứa nó gánh 47,9%, dù đã có 150 điểm ảo |
Tách khoá nóng thành 32 bản viral#0 đến viral#31 | node nặng nhất còn 27,7% |
Bẫy: khoá nóng (hot key). Vòng băm chia khoá đều, không chia lưu lượng đều. Một link viral, một tenant khổng lồ, một bộ đếm toàn cục thì mọi truy cập vẫn rơi vào một node dù có bao nhiêu điểm ảo (hàng "47,9%" ở bảng trên). Cách xử lý: tách khoá nóng thành nhiều bản (key#0 đến key#K, đọc chọn ngẫu nhiên một bản, ghi cộng dồn rồi gộp), cache cục bộ trong process cho khoá nóng, và phát hiện khoá nóng bằng đo đạc thay vì đoán. Chưa đo ở đây: chi phí gộp khi ghi vào các bản.
Bẫy khác. Khi một node chết, khoá của nó đổ sang node kề trên vòng; node kề đó tăng tải đột ngột (cơn bão miss) và có thể chết theo. Dùng điểm ảo để chia phần dồn đó cho nhiều node thay vì một. Nhân bản (lưu mỗi khoá ở K node liên tiếp trên vòng) là cách Dynamo-style làm; chưa chạy ở đây. Quan hệ với shard: GĐ19 mục 4 giải thích vì sao chia theo khoá và nhân bản là hai việc khác nhau.
14. Một thiết kế hoàn chỉnh: URL shortener theo 6 bước#
Mục này đi hết khung 6 bước của mục 1 trên một đề cụ thể, để bạn thấy mỗi bước sinh ra đầu vào cho bước sau. Giả định như mục 2: 100M link mới mỗi ngày, đọc/ghi 100:1, đỉnh 5× trung bình.
Bước 1. Làm rõ yêu cầu (câu hỏi đã liệt kê ở mục 1). Chốt phạm vi để thiết kế:
| Loại | Chốt |
|---|---|
| Chức năng | Tạo link ngắn từ URL dài; truy cập link ngắn thì chuyển hướng; tuỳ chọn alias riêng và ngày hết hạn; đếm lượt click |
| Phi chức năng | p99 chuyển hướng phía server dưới 50 ms; uptime 99,95%; không được mất link đã tạo (bền vững); số click chỉ cần gần đúng |
| Ngoài phạm vi | Đăng nhập, thanh toán, chống lạm dụng nâng cao (nhưng vẫn chặn scheme lạ, xem bước 3) |
Bước 2. Ước lượng (số học ở mục 2): đỉnh đọc khoảng 580.000 RPS nên cache là bắt buộc; 36,5 tỷ hàng và khoảng 18 TB mỗi năm nên phải chia theo khoá; băng thông khoảng 58 MB/s không phải nút thắt. Không gian mã: 7 ký tự base62 cho 62^7 = 3.521.614.606.208 mã (đã tính), 5 năm cần 182,5 tỷ, dư gấp khoảng 19 lần.
Bước 3. API và mô hình dữ liệu.
Lượt click không ghi vào bảng links (ghi mỗi lượt đọc làm đọc thành ghi): mỗi lần chuyển hướng bắn một event vào queue, worker cộng dồn vào bảng đếm theo ngày. Chặn javascript: và các scheme lạ khi tạo link, nếu không dịch vụ thành công cụ lừa đảo hoặc XSS.
Bước 4. Kiến trúc mức cao.
Bước 5. Đào sâu hai điểm.
Điểm 1: sinh mã. Có ba cách. Băm URL rồi cắt 7 ký tự: va chạm phải kiểm tra lại và cùng URL luôn ra cùng mã (không cho hai người có hai link riêng). Sinh ngẫu nhiên rồi thử lại khi trùng: xác suất trùng mỗi lần thử bằng số mã đã dùng chia 62^7, khoảng 1,0% sau năm đầu (36,5 tỷ) và khoảng 5,2% sau năm năm (182,5 tỷ), tức ghi chậm dần theo thời gian. Cách thứ ba, cấp phát bộ đếm theo dải: mỗi instance xin DB một dải id (ví dụ 1.000), tự phát trong dải rồi đổi sang base62. Không va chạm, DB chỉ bị hỏi một lần mỗi dải. Đánh đổi: mã tuần tự nên đoán được, và instance chết làm bỏ trống phần dải chưa dùng (không sao vì không gian dư lớn). Che thứ tự bằng một phép hoán vị song ánh (id × K) mod 62^7 với K nguyên tố cùng nhau với 62^7; đây là che mắt chứ không phải bảo mật.
Code và kết quả đã chạy: dải id, base62, hoán vị
Đã chạy (Node 24.21.0, tsc --strict sạch; dải id nằm trong bộ nhớ, chưa chạy với Postgres thật):
| Kiểm tra | Kết quả |
|---|---|
| 30.000 id từ 3 instance phát xen kẽ, dải 1.000 | 30.000 id khác nhau, DB bị hỏi 30 lần |
| Độ dài mã | id 1 tỷ ra 015ftgG; id 36,5 tỷ ra 0dqAHNQ (7 ký tự); 62^7 gấp khoảng 19,3 lần nhu cầu 5 năm |
Hoán vị (id × 1.000.003) mod 62^7, 1.000.000 id | 1.000.000 mã khác nhau; id 1, 2, 3 ra 0004C95, 0008OIA, 000CaRF |
Điểm 2: chuyển hướng và bộ nhớ đệm. 301 được trình duyệt ghi nhớ nên giảm tải nhưng mất số click; 302 mỗi lần về server, đổi lại đếm được. Chọn 302 vì yêu cầu có đếm click. Đường đọc là cache-aside (GĐ09 mục 10): Redis trước, miss mới xuống DB rồi điền lại; lưu cả kết quả "không có mã" với TTL ngắn để người quét mã bậy không đập thẳng vào DB. Phân phối khoá giữa các node Redis dùng consistent hashing (mục 13).
Bước 6. Nút thắt và mở rộng ở 10×. Số học đã chạy: đỉnh đọc 5.800.000 RPS; nếu cache bắt 99% thì 58.000 RPS xuống DB, nếu 99,9% thì 5.800 RPS. Đỉnh ghi 58.000 WPS với dải 1.000 id chỉ là 58 lần hỏi bảng id_blocks mỗi giây, nên bộ cấp id không phải nút thắt. Thứ vỡ trước, theo thứ tự: (1) tỉ lệ cache hit và khoá nóng: một link viral dồn vào một node cache (cách xử lý ở mục 13); (2) DB đọc ở 58.000 RPS nếu hit chỉ 99%: cần replica đọc hoặc chia shard theo code; (3) đường click: nếu đếm từng lượt thì 5,8 triệu event mỗi giây ở đỉnh qua queue là thứ lớn nhất, nên gộp theo lô, lấy mẫu hoặc cộng dồn tại biên trước khi đẩy đi. Cái nào vỡ trước trong thực tế phải do đo, không do đoán (mục 10).
15. Ba case ngắn có đáp án: chat, feed, rate limiter#
Mỗi case: đọc đề, tự làm 15 phút theo 6 bước (giả định ghi ra trước), rồi mở đáp án. Số liệu trong đáp án do tính bằng Node (số học, không phải số đo); mọi giả định ghi rõ và đổi giả định thì kết luận đổi.
Case A: chat 1-1 và nhóm#
Đề. Thiết kế dịch vụ nhắn tin: 50 triệu người dùng hoạt động mỗi ngày, mỗi người gửi 40 tin, mỗi tin khoảng 200 B. Tin phải đến đúng thứ tự trong từng cuộc trò chuyện và không được mất.
Đáp án
Ước lượng. 50M × 40 = 2 tỷ tin mỗi ngày, khoảng 23.100 tin/s trung bình, đỉnh 5× khoảng 115.700 tin/s. Lưu trữ 2 tỷ × 200 B = 400 GB mỗi ngày, khoảng 146 TB mỗi năm. Kết nối: giả định 20% người dùng online cùng lúc là 10 triệu kết nối WebSocket; nếu mỗi kết nối tốn khoảng 20 KB (giả định, chưa đo) thì 200 GB RAM, tức khoảng 100 máy nếu mỗi máy giữ 100.000 kết nối.
Kiến trúc. Tầng kết nối giữ WebSocket (có trạng thái, GĐ09 mục 9) tách khỏi tầng dịch vụ tin nhắn (không trạng thái). Một bảng định tuyến (Redis) ghi người dùng đang nối ở máy kết nối nào. Gửi tin: client gửi lên máy kết nối, chuyển cho dịch vụ tin nhắn, ghi DB, rồi đẩy tới máy kết nối của người nhận nếu họ online; nếu offline thì chỉ lưu và gửi thông báo đẩy.
Đào sâu 1: thứ tự và không mất tin. Thứ tự chỉ cần đúng trong một cuộc trò chuyện, nên chia DB theo conversation_id và để một nơi cấp số thứ tự tăng dần cho từng cuộc trò chuyện (một nguồn thứ tự, như GĐ19 mục 5; đừng dựa vào đồng hồ máy khách). Giao tin là at-least-once: client gửi kèm message_id do client sinh, server khử trùng lặp theo id; client nhận khử trùng lặp theo số thứ tự và xin lại phần thiếu khi thấy khoảng trống.
Đào sâu 2: kết nối và hiện diện. Máy kết nối chết làm hàng triệu client kết nối lại cùng lúc: client phải backoff có jitter (GĐ19 mục 7) để không tự tạo cơn bão. Hiện diện (online/offline) lưu bằng khoá có TTL, làm mới theo nhịp tim; chấp nhận trạng thái trễ vài chục giây. Nhóm lớn: gửi một tin thành N lần đẩy, nên nhóm nghìn người nên chuyển sang kéo theo yêu cầu thay vì đẩy.
Vỡ trước ở 10×. RAM và số kết nối ở tầng kết nối, rồi lưu trữ (ở 10× là khoảng 1,46 PB mỗi năm) cần tầng lạnh cho tin cũ. Tin cũ ít đọc nên chuyển sang lưu trữ rẻ hơn theo tuổi.
Case B: bảng tin (news feed)#
Đề. Thiết kế bảng tin: 100 triệu người dùng, 10 triệu bài đăng mỗi ngày, trung bình mỗi người có 200 người theo dõi. Mở bảng tin phải nhanh; bài mới của người mình theo dõi phải hiện trong vài giây.
Đáp án
Ước lượng. 10M bài mỗi ngày khoảng 116 bài/s. Nếu ghi trước (fan-out on write: mỗi bài đăng được chèn vào bảng tin của từng người theo dõi) thì 10M × 200 = 2 tỷ lần chèn mỗi ngày, khoảng 23.100 lần/s trung bình, đỉnh khoảng 115.700 lần/s. Lưu: mỗi bảng tin giữ 800 id bài (8 B mỗi id) cho 100M người là khoảng 640 GB (chỉ id, chưa tính chi phí của kho chứa).
Hai cách. Ghi trước: đọc rất nhanh (bảng tin đã dựng sẵn) nhưng ghi nhân lên theo số người theo dõi. Đọc sau (fan-out on read): ghi rẻ, nhưng mỗi lần mở bảng tin phải gom bài từ mọi người mình theo dõi, đọc chậm và tốn.
Đào sâu 1: người nổi tiếng. Một người có 50 triệu người theo dõi đăng một bài là 50 triệu lần chèn; ở tốc độ 100.000 lần chèn mỗi giây là 500 giây, khoảng 8,3 phút trước khi bài đến hết mọi người. Lời giải lai: ghi trước cho người có ít người theo dõi (dưới một ngưỡng, ví dụ vài chục nghìn), còn người nổi tiếng thì không đẩy; khi mở bảng tin, đọc bảng tin dựng sẵn rồi gộp thêm bài mới của những người nổi tiếng mình theo dõi (đọc sau cho nhóm này).
Đào sâu 2: phân trang và xoá. Phân trang theo con trỏ (cursor) chứ không theo offset, vì bảng tin đổi liên tục (GĐ09). Bài bị xoá hoặc người dùng bị chặn phải lọc lại lúc đọc, vì bảng tin dựng sẵn đã chứa id. Xếp hạng (ranking) là bài toán riêng, nằm ngoài phạm vi.
Vỡ trước ở 10×. Thông lượng ghi của bộ phát bảng tin (23.100 lên 231.000 lần chèn mỗi giây) và độ trễ của người nổi tiếng; cách xử lý là hạ ngưỡng của chế độ lai và dựng bảng tin lười cho người ít hoạt động (chỉ dựng khi họ mở ứng dụng).
Case C: bộ giới hạn tốc độ (rate limiter) phân tán#
Đề. Thiết kế dịch vụ giới hạn tốc độ cho một API với 1 triệu request mỗi giây qua nhiều instance. Mỗi tenant có hạn mức riêng. Vượt hạn mức thì trả 429 kèm Retry-After.
Đáp án
Thuật toán (chi tiết ở GĐ09 mục 6): cửa sổ cố định (rẻ, nhưng cho gấp đôi hạn ở ranh giới cửa sổ), cửa sổ trượt (chính xác, tốn bộ nhớ), token bucket (cho burst có kiểm soát). Bài này tập trung vào phần phân tán.
Ước lượng. Nếu mỗi request hỏi bộ đếm trung tâm một lần thì 1.000.000 thao tác mỗi giây lên Redis; thuê token theo lô 10 thì 100.000, theo lô 100 thì 10.000.
Kiến trúc. Đặt ở biên (gateway, GĐ20 mục 8) để request vượt hạn bị chặn trước khi tốn tài nguyên. Bộ đếm nằm ở Redis, mỗi khoá rl:<tenant>:<cửa sổ>, cập nhật bằng một script Lua nguyên tử. Chia khoá giữa nhiều node Redis bằng consistent hashing (mục 13): một tenant luôn rơi vào cùng một node nên đếm đúng.
Đào sâu 1: giảm số lần hỏi Redis bằng thuê theo lô. Mỗi instance xin bộ đếm trung tâm một lô token rồi tự tiêu cục bộ; khi trung tâm báo hết thì nhớ "hết hạn mức" tới hết cửa sổ để không hỏi lại. Đã chạy (5 instance, hạn mức 100, 300 request rải ngẫu nhiên, một cửa sổ):
Tổng nhận không bao giờ vượt hạn mức vì token được thuê từ bộ đếm chung; cái giá là token thuê rồi mà chưa dùng có thể nằm chết ở cuối cửa sổ (dòng 3: nhận 97 thay vì 100). Mức hụt tối đa suy ra bằng số instance × (cỡ lô − 1).
Code đã chạy:
Đã chạy (Node 24.21.0, tsc --strict sạch; bộ đếm trung tâm giả trong bộ nhớ, chưa chạy với Redis và Lua thật).
Đào sâu 2: Redis chết thì sao. Phải chọn trước: fail-open (cho qua hết, rủi ro là quá tải và lạm dụng) hay fail-closed (chặn hết, rủi ro là sập sản phẩm). API công khai thường chọn fail-open kèm giới hạn cục bộ thô trên mỗi instance làm lưới an toàn; endpoint nhạy cảm như đăng nhập chọn fail-closed. Đây là quyết định sản phẩm, ghi thành ADR (GĐ19 bài tập mục 10).
Vỡ trước ở 10×. Một tenant khổng lồ dồn mọi lượt vào một khoá Redis, tức khoá nóng (mục 13): tách bộ đếm của tenant đó thành nhiều bản mỗi bản giữ một phần hạn mức, chấp nhận sai số nhỏ.
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):
-
URL shortener (kinh điển; luyện ước lượng + cache).
Lời giải và cách kiểm tra
Bài 1. URL shortener. Lời giải đầy đủ là thiết kế ở mục 14: đủ 6 bước, API, mô hình dữ liệu, sinh mã có số đo và nút thắt ở 10×. Tự làm trước 45 phút (giả định: 100M link/ngày, đọc/ghi 100:1, đỉnh 5×), rồi so với mục 14. Bài tương tự để luyện thêm: chat, feed, rate limiter ở mục 15.
-
Nền tảng RAG đa tenant — loại sản phẩm capstone ở GĐ25 xây, nhìn ở quy mô 100× (làm trên giấy, không cần có capstone).
Lời giải và cách kiểm tra
Bài 2. RAG đa tenant ở 100× (giả định: 100.000 người dùng, mỗi người 20 truy vấn/ngày, 200 chunk/người, vector 1536 chiều).
- Ước lượng: 100.000 × 20 = 2M truy vấn/ngày ≈ 23 QPS trung bình, đỉnh 5× ≈ 116 QPS. LLM ~10 s mỗi lượt, theo định luật Little (đồng thời = tốc độ đến × thời gian) 116 × 10 ≈ 1.160 stream đồng thời. Vector: 100.000 × 200 = 20M; 1536 × 4 B = 6.144 B nên ≈ 123 GB (float32) hoặc ≈ 61 GB nếu lưu
halfvec. - Sơ đồ: LB ─► API (CRUD) | service streaming (SSE) riêng ─► pgvector (lọc
tenant_id) ─► LLM; ingest qua queue riêng theo gói (mục 11). - Đào sâu 1: lọc theo
tenant_idáp dụng sau khi quét index (post-filter, kiểm chứng ngày 2026-10-05), nên bật iterative scan, và chỉ dùng partition hoặc partial index cho vài tenant lớn (hàng chục nghìn tenant thì không tạo index riêng cho từng cái). Đào sâu 2: giới hạn rate của nhà cung cấp LLM, dùng hàng đợi theo gói, hạn mức cứng, cache ngữ nghĩa. - 10× vỡ trước: 11.600 stream đồng thời giữ kết nối lâu và 1,2 TB vector, nên tách service streaming và cân nhắc kho vector chuyên dụng sau khi đo p99.
- Ước lượng: 100.000 × 20 = 2M truy vấn/ngày ≈ 23 QPS trung bình, đỉnh 5× ≈ 116 QPS. LLM ~10 s mỗi lượt, theo định luật Little (đồng thời = tốc độ đến × thời gian) 116 × 10 ≈ 1.160 stream đồng thời. Vector: 100.000 × 200 = 20M; 1536 × 4 B = 6.144 B nên ≈ 123 GB (float32) hoặc ≈ 61 GB nếu lưu
-
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).
Lời giải và cách kiểm tra
Bài 3. Đo sử dụng và hạn mức (giả định: 10M sự kiện/ngày, mỗi bản ghi ~100 B).
- Ước lượng: 10M / 86.400 ≈ 116 sự kiện/s, đỉnh ≈ 580/s; 10M × 100 B = 1 GB/ngày ≈ 365 GB/năm.
- Thiết kế: nhật ký sự kiện append-only (nguồn sự thật, partition theo tháng) cộng bộ đếm nhanh để chặn hạn mức. Mỗi sự kiện có
event_idUNIQUE để idempotent. Kiểm hạn mức: giữ chỗ nguyên tử trong Redis (một script LuaINCRBYrồi so với hạn mức, hoàn lại nếu vượt), ghi sự kiện vào nhật ký, rồi định kỳ đối soát bộ đếm với tổng nhật ký. - Đào sâu 1: chính xác so với trễ. Bộ đếm có thể lệch tạm thời nên hạn mức mềm cho phép vượt nhẹ, tiền (billing) tính từ nhật ký chứ không từ bộ đếm. Đào sâu 2: mất Redis thì dựng lại bộ đếm từ nhật ký (rollup).
- 10× vỡ trước: ghi 5.800/s đỉnh, nên gom ghi theo lô và dùng bảng rollup, không
COUNT(*)trên nhật ký thô.
-
Hàng đợi job có ưu tiên và đảm bảo công bằng giữa các tenant.
Lời giải và cách kiểm tra
Bài 4. Hàng đợi ưu tiên và công bằng giữa tenant (giả định: 1.000 tenant, 1M job/ngày).
- Ước lượng: 1M / 86.400 ≈ 11,6 job/s trung bình, đỉnh ≈ 58/s; Redis xử lý dư sức, nút thắt là worker và tenant ồn ào.
- Thiết kế: queue theo mức ưu tiên (free/pro) với worker và
concurrencyriêng; trần đồng thời theo tenant (bộ đếm trong Redis, vượt trần thì trì hoãn job); tuổi job tăng dần ưu tiên (aging) để tenant nhỏ không bị bỏ đói. - Đào sâu 1: vì sao FIFO chung bất công (một tenant đẩy 10.000 job thì tenant khác chờ sau 10.000 job). Đào sâu 2: công bằng bằng vòng luân phiên có trọng số giữa các tenant, chọn thứ có sẵn trong thư viện bạn dùng (kiểm tra tài liệu BullMQ về nhóm/ưu tiên trước khi cam kết; một số tính năng nhóm là bản trả phí, chưa xác minh).
- 10× vỡ trước: số tenant hoạt động và độ sâu hàng đợi, nên tự scale worker theo độ sâu hàng đợi và load shedding gói free (mục 7).
Khung và mã dùng chung (Thực hành A)
Mỗi bài dưới đây là một bài mẫu có giả định ghi rõ, không phải đáp án duy nhất: đổi giả định thì kiến trúc đổi (đúng bài học mục 2). Tự làm trước 45 phút, rồi so. Các phép tính đã kiểm lại bằng Node 24.21.0; không có thành phần nào được dựng thật.
Lỗi hay gặp. Vẽ sơ đồ trước khi ghi giả định; bỏ qua tỉ lệ đọc/ghi; kết luận không rút ra từ con số; im lặng khi chưa chắc (mục 12).
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; làm thêm trên capstone nếu bạn đã có): 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).
Lời giải và cách kiểm tra
Với mỗi kết quả quyết định: sang Redis (session, đếm rate limit), sang S3 (file tạm), sang BullMQ scheduler (cron; GĐ10 mục 8: upsertJobScheduler, không dùng repeat đã bị xoá ở v6). Đếm rate limit atomic:
Nghiệm thu: chạy 3 instance sau một LB (compose hoặc nginx), gửi 100 request, đếm: tổng đếm bằng 100 (không phải 33 mỗi nơi) và log cron mỗi tick xuất hiện một lần, không phải ba.
-
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.
Lời giải và cách kiểm tra
Mẫu code ở GĐ09 mục 18, sơ đồ thứ tự ở mục 4 của bài này. Nghiệm thu bằng k6 với ngưỡng cứng:
typescriptReadyChạy
k6 run -e BASE=https://host script.js; trong 60 s đó thực hiện deploy (kubectl rollout restart deployment/api). Mong đợi:http_req_failed0,00% và ngưỡng đạt; nếu thấy lỗi 502 thì kiểm tra bước 2 (thiếupreStop) trước, rồi bất đẳng thức thời gian ở sơ đồ mục 4. -
Thêm PgBouncer. Đo số connection trước/sau.
Lời giải và cách kiểm tra
textReadyChưa chạy: cấu hình tham chiếu, chưa dựng PgBouncer. Đo trước/sau bằng
SELECT count(*) FROM pg_stat_activity WHERE datname = 'app';khi chạy tải. Mong đợi (số học, không phải số đo): trước = số instance × cỡ pool (ví dụ 20 × 20 = 400, vượtmax_connections), sau ≤default_pool_size(20, cộng vài kết nối dự phòng). Trước khi bật transaction mode:SET,LISTEN, advisory lock mức session vàPREPAREkhông dùng được, prepared statement mức giao thức cầnmax_prepared_statementskhác 0 (trang tính năng của PgBouncer, đọc 2026-10-05;SET LOCALvà phần Prisma chưa xác minh, xem mục 5). -
Thêm timeout + retry có jitter + circuit breaker cho lời gọi LLM (hoặc một API ngoài bất kỳ nếu bạn chưa học GĐ22). Dùng MSW (GĐ13) 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.
Lời giải và cách kiểm tra
Dùng lớp
CircuitBreakerở mục 7 (đặt trongbreaker-lib.ts: phần trước dòng// ---- thử) vàcallLlmở đó. Một điểm hay sai:fetchkhông ném lỗi khi server trả 500, nên hàmrunphải tự ném lỗi nếu!res.ok, nếu không breaker không đếm được lỗi nào. Test (MSW 2, Vitest 5):typescriptReadyĐã chạy (Node 24.21.0, vitest 5.0.3, msw 2.15.0,
tsc --strictsạch):1 passed, khẳng định số lần gọi thật tới provider không quá 5 và cả 20 câu trả lời đều là fallback. Breaker nằm trong vòng retry: mỗi lần thử là một lỗi, nó mở ở lỗi thứ 5, các lời gọi sau fail nhanh và endpoint vẫn trả lời được. -
Cài load shedding: khi độ sâu hàng đợi > N, trả 429 kèm
Retry-Aftercho gói free.Lời giải và cách kiểm tra
typescriptReadyNghiệm thu: bơm job đến khi vượt
MAX_WAITING, gói free nhận 429 kèmRetry-After, gói trả phí vẫn được nhận. Tính số ở mục 7: chặn ở độ sâu tương ứng thời gian chờ ≤ timeout của client. -
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.
Lời giải và cách kiểm tra
Dùng prom-client: histogram http_request_duration_seconds (nhãn route, method, status) và gauge độ sâu hàng đợi. Truy vấn mẫu:
Cảnh báo đặt trên p99 và tỉ lệ lỗi (ví dụ p99 vượt SLO trong 5 phút), không đặt trên CPU. Chưa chạy: không dựng Prometheus.
- 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.
Lời giải và cách kiểm tra
Kịch bản k6:
Ước lượng điểm gãy trước khi chạy để so với kết quả: pool 10 kết nối, mỗi truy vấn 20 ms thì năng lực tối đa = 10 / 0,02 s = 500 truy vấn/s; nếu mỗi request thực hiện 5 truy vấn thì chỉ ~100 request/s. Ghi theo bảng: mức tải, p95, tỉ lệ lỗi, số kết nối đang chờ pool, CPU app. Cái hỏng trước thường không phải thứ bạn đoán (đúng như đề bài); sửa đúng một nút thắt rồi chạy lại.
Khung và mã dùng chung (Thực hành B)
Mục B làm trên Dự án 3 của người học (hoặc capstone nếu đã có) nên không có trong repo này. Chưa chạy: mọi code dưới đây là tham chiếu, kèm "kết quả mong đợi" suy ra, không phải số đo. Phiên bản theo kiểm chứng ngày 2026-10-05 (Node 24, BullMQ 6, Vitest 5).
Done khi#
-
Áp dụng được khung 6 bước cho một đề bài lạ trong 45 phút, có bấm giờ.
Đáp án
Làm rõ yêu cầu (5–8'), ước lượng (3–5'), API và dữ liệu (8–10'), kiến trúc mức cao (8–10'), đào sâu 1–2 điểm (10–12'), nút thắt và mở rộng (5'). Cách tự kiểm: bài mẫu ở lời giải Thực hành A có đủ giả định, số, sơ đồ, 2 điểm đào sâu, "vỡ gì ở 10×". Sai thường gặp: 20 phút đầu mới vẽ được hộp. Xem GĐ21 mục 1.
-
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ố.
Đáp án
Nhớ các khoảng cách, không nhớ từng số: RAM nhanh hơn round-trip DC ~5.000 lần, DC nhanh hơn xuyên lục địa ~400 lần. Ví dụ rút kết luận: đỉnh đọc 580.000 RPS nên cache là bắt buộc; 18,25 TB/năm nên bàn shard; với 1M link/ngày (0,18 TB/năm) thì không. Xem GĐ19 mục 2 và GĐ21 mục 2.
-
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.
Đáp án
Máy lớn không đổi code và không có chi phí phân tán; chỉ chạm trần và là SPOF. Chỗ giấu trạng thái: session in-memory, bộ đếm rate limit, file upload tạm trên đĩa, cache in-memory, cron trong process (3 instance chạy 3 lần), WebSocket connection. Xem GĐ21 mục 3.
-
Phân biệt liveness vs readiness; giải thích vì sao không check DB trong liveness.
Đáp án
Liveness fail thì restart nên phải nhẹ, không chạm DB; nếu DB chập chờn mà liveness check DB, toàn bộ app bị restart và sự cố nhỏ thành sự cố lớn. Readiness fail thì LB ngừng gửi request (không restart) và được phép check DB/Redis. Tự kiểm:
/health/livetrả 200 khi tắt DB,/health/readytrả 503. Xem GĐ21 mục 4. -
Cài graceful shutdown đủ 4 bước và chứng minh deploy không mất request.
Đáp án
Readiness fail, chờ gỡ khỏi LB (trên K8s do
preStop, không sleep trong app),await server.close()cùng hẹn giờ thoát cưỡng bức, đóng queue/DB sau khi HTTP drain. Chứng minh: k6 với ngưỡnghttp_req_failed: rate==0trong lúcrollout restart, mong đợi 0,00% lỗi (script ở lời giải Thực hành B, mục 6). Sơ đồ thời gian ở GĐ21 mục 4. -
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.
Đáp án
Query (index, EXPLAIN, N+1; thường 10–100×, miễn phí), pool/PgBouncer, replica, cache, shard. Shard cuối vì mất JOIN, transaction và unique toàn cục xuyên shard và rebalance tốn nhiều tháng (đổi N từ 4 sang 5 với
hash % Nphải di chuyển 80% dữ liệu). Chỉ shard khi máy lớn nhất không gánh nổi lượng ghi. Xem GĐ21 mục 5. -
Giải thích bẫy read-after-write với replica và cách xử lý.
Đáp án
Replica trễ vài chục đến vài trăm ms nên người dùng lưu xong F5 thấy dữ liệu cũ. Xử lý: ép đọc primary ngay sau khi ghi (hoặc N giây trong session), hoặc token LSN. Ví dụ replica trễ 150 ms: đọc ngay sau 30 ms thấy tên cũ, đọc primary thấy mới. Xem GĐ21 mục 5 và GĐ19 mục 4.
-
Quyết định strong vs eventual theo từng loại dữ liệu và nêu được hậu quả nghiệp vụ.
Đáp án
Số dư, tồn kho, quyền: strong (sai là mất tiền hoặc lỗ hổng); lượt xem, like, feed, tìm kiếm: eventual, nói rõ cửa sổ trễ và người dùng thấy gì trong đó. Strong thật sự nằm ở điều kiện trong câu
UPDATE ... WHERE balance >= $x, không ở kiểm tra ở tầng app. Xem GĐ21 mục 6. -
Cài đủ timeout + retry có jitter + circuit breaker; giải thích vì sao chỉ retry ở một tầng.
Đáp án
Timeout ở mọi lời gọi ra ngoài và ngắn hơn timeout tầng gọi bạn; retry backoff + jitter chỉ cho thao tác idempotent; breaker mở thì fail nhanh. Ba tầng mỗi tầng 3 lần thử là 27 lần gọi nên chỉ retry ở một tầng. Code và sơ đồ ở GĐ21 mục 7 và GĐ19 mục 7.
-
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.
Đáp án
Công suất 100 req/s, đến 120 req/s, client timeout 2 s: nhận hết thì hàng đợi tăng 20 req/s, sau 200/20 = 10 s mọi request đều muộn hơn 2 s, thành công mong đợi ~0%; chặn hàng đợi ở 200 thì 83,3% thành công và 16,7% bị 429. Tính ở GĐ21 mục 7.
-
Giải thích consistent hashing: vì sao đổi N với
hash % Nxáo gần hết khoá, vòng băm và điểm ảo sửa thế nào, và vì sao nó không cứu được khoá nóng.Đáp án
Đã đo (100.000 khoá, 4 lên 5 node):
hash % Ndi chuyển 79,9% khoá, vòng băm 150 điểm ảo di chuyển 21,7% (lý thuyết 1/(N+1) = 20%), và chỉ đổ về node mới. Ít điểm ảo thì lệch (1 điểm: nặng nhất 54,2%, nhẹ nhất 6,2%; 150 điểm: 26,1% và 23,9%). Khoá nóng nhận 30% lưu lượng vẫn làm node chứa nó gánh 47,9%; tách thành 32 bản còn 27,7%. Sai thường gặp: tưởng chia khoá đều là chia tải đều. Xem GĐ21 mục 13. -
Viết ra thiết kế URL shortener đủ 6 bước, có API, mô hình dữ liệu, cách sinh mã có con số và nút thắt ở 10×.
Đáp án
Yêu cầu chốt phạm vi; ước lượng cho 580.000 RPS đọc đỉnh nên cache bắt buộc; API
POST /v1/linksvàGET /{code}trả302; bảnglinks(code PK, url, expires_at)chia shard theocode; mã sinh từ dải id cấp cho từng instance rồi base62 7 ký tự (62^7 dư gấp khoảng 19 lần nhu cầu 5 năm); ở 10× vỡ trước là tỉ lệ cache hit và khoá nóng, rồi DB đọc, rồi đường đếm click. Tự kiểm: có đủ giả định, số, sơ đồ, hai điểm đào sâu. Xem GĐ21 mục 14. -
Thiết kế được chat, feed và rate limiter ở mức 6 bước, nêu được điểm đào sâu của từng bài.
Đáp án
Chat: thứ tự theo từng cuộc trò chuyện (một nơi cấp số thứ tự), at-least-once và khử trùng lặp theo id, kết nối lại có jitter. Feed: ghi trước cho người ít người theo dõi, đọc sau cho người nổi tiếng (50 triệu lần chèn ở 100.000 lần mỗi giây là khoảng 8,3 phút). Rate limiter: bộ đếm Redis nguyên tử, thuê token theo lô (hỏi trung tâm 15 lần thay vì 105 trong thử nghiệm), chọn fail-open hay fail-closed trước khi Redis chết. Xem GĐ21 mục 15.
-
Dùng được bulkhead và breaker chỉ cho một lời gọi thăm dò; chỉ ra chúng bảo vệ cái gì.
Đáp án
Đã chạy: một ngăn chung thì 10 request CRUD đều bị từ chối vì LLM chiếm hết chỗ; hai ngăn riêng thì CRUD xong cả 10 trong khoảng 10 ms. Breaker half-open cho đúng 1 lời gọi thăm dò trong 10 lời gọi đồng thời, 9 cái fail nhanh. Bulkhead không làm LLM nhanh hơn, nó giữ thiệt hại trong ngăn. Xem GĐ21 mục 7.
-
Nêu biện pháp chống noisy neighbor; giải thích vì sao rate limit theo IP là không đủ.
Đáp án
Rate limit theo tenant/API key, queue riêng theo gói,
statement_timeoutvà trầnLIMIT, cách ly cứng cho enterprise. IP không đủ: nhiều nhân viên một tenant sau cùng NAT bị chặn oan, kẻ lạm dụng đổi IP dễ. Tự kiểm: một tenant bơm 10.000 job không làm tenant khác chờ sau hàng đợi của nó. Xem GĐ21 mục 8. -
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.
Đáp án
Nhiều đội giẫm chân khi deploy, một phần cần scale rất khác (GPU cho LLM), cần công nghệ khác (Python cho ML), cách ly lỗi cho vùng nghiêm ngặt (thanh toán). Cái giá: lời gọi mạng có thể lỗi, mất ACID, cần tracing, N pipeline. Bảo vệ modular monolith: giữ transaction, một stack trace, đường cắt sẵn. Xem GĐ21 mục 9 và GĐ20 mục 3.
-
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.
Đáp án
Trình tự: p99 chậm mà p50 ổn (một số request: thiếu index, N+1), cả hai chậm (bão hoà: pool, replica lag), chậm dần (rò rỉ), chỉ giờ cao điểm (xếp hàng, xem saturation), trace chỉ ra DB/gọi ngoài/CPU. CPU 90% có thể ổn, CPU 30% có thể đang chờ I/O: cảnh báo theo p99 và tỉ lệ lỗi. Xem GĐ21 mục 10.
-
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.
Đáp án
Kịch bản k6 theo bậc (50, 200, 500 VU), ghi p95, lỗi, số kết nối chờ pool, CPU ở mỗi bậc; so với ước lượng trước (pool 10 kết nối × 20 ms/truy vấn cho 500 truy vấn/s). Đạt khi bạn nêu được cái hỏng trước và sửa nó, rồi chạy lại. Kịch bản ở lời giải Thực hành B, mục 11 của GĐ21.
-
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.
Đáp án
Nói to lý luận ngay cả khi chưa chốt; luôn nêu đánh đổi bằng con số ("với 1M link/ngày, 0,2 TB/năm nằm gọn một Postgres; lên 100M/ngày, ~18 TB/năm thì shard"); chủ động nêu điểm lỗi đơn; "tôi chưa vận hành X ở quy mô đó, tôi sẽ đo trước khi cam kết". Sai thường gặp: học thuộc kiến trúc mẫu, sụp khi người phỏng vấn đổi ràng buộc. Xem GĐ21 mục 12.
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.
Hướng trả lời hiện tại
Chưa phải kết luận. Đặt điều kiện đo được: tách khi p99 của endpoint CRUD xấu đi vào lúc tải stream cao, hoặc khi số stream đồng thời (tốc độ đến × thời lượng, ví dụ 116 × 10 s ≈ 1.160) làm cạn pool hoặc kết nối của API.
-
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.
Hướng trả lời hiện tại
Chưa phải kết luận. Vẽ p99 theo số vector và chỉ chuyển khi vượt SLO sau khi đã thử HNSW, iterative scan, partition cho tenant lớn và
halfvec. -
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.
Hướng trả lời hiện tại
Chưa phải kết luận. Chỉ đáng khi có khách hàng thật ở vùng khác; khởi đầu thường là một primary, đọc gần người dùng bằng replica/CDN, và chấp nhận ghi xuyên vùng chậm cho tới khi đo được là không chịu nổi.
-
Kubernetes: GĐ18 đã dạy K8s, nhưng biết dùng chưa có nghĩa là nên dùng. Ngưỡng hợp lý để chọn K8s thay cho PaaS hoặc ECS là khi đã có nhiều service và nhiều môi trường cần điều phối.
Hướng trả lời hiện tại
Chưa phải kết luận. Chọn K8s khi đã có nhiều service và nhiều môi trường cần điều phối, và đội đủ người vận hành nó; trước đó PaaS hoặc ECS đủ dùng và rẻ hơn về công vận hành.