DEV Community

End-to-end Design Walkthrough — Full System Design

Full system design walkthrough: vì sao quy trình 5 bước quan trọng hơn thuật ngữ, và cái bẫy nhảy thẳng vào deep-dive

Một buổi phỏng vấn system design 45–60 phút không đánh giá xem thí sinh biết bao nhiêu tên công nghệ, mà đánh giá xem thí sinh có xử lý được một bài toán mơ hồ theo một quy trình có kỷ luật hay không. Cùng đề "thiết kế URL shortener", người thất bại thường bắt đầu bằng "dùng Cassandra vì scale tốt" ngay ở phút thứ hai — chưa hỏi scale bao nhiêu, chưa biết read/write ratio, chưa đồng ý interface. Người pass đi theo một trình tự cứng: requirements → estimation → high-level → deep-dive → tradeoff. Cùng một đề, cùng thời lượng, nhưng người thứ hai dẫn dắt được interviewer thay vì bị dí. Bài này mô tả quy trình đó ở mức thực dụng, các thất bại thường gặp khi bỏ bước, và một hands-on end-to-end trên URL shortener để tự luyện.

Cơ chế hoạt động

Quy trình chuẩn được nhiều tài liệu tổng hợp (Alex Xu — System Design Interview vol.1; Donne Martin — system-design-primer; Kleppmann — Designing Data-Intensive Applications) đều có 4–5 bước gần như trùng nhau. Đây là khung 5 bước dùng thực tế trong phỏng vấn:

  1. Requirements clarification (~5 phút). Chốt functional requirement (hệ thống làm gì — API nào, use case nào) và non-functional requirement (availability target, latency target, consistency yêu cầu, security, cost). Không đoán — hỏi lại interviewer. Output: 3–5 gạch đầu dòng functional + 3–5 gạch NFR + danh sách "out of scope" (analytics, billing, ...).
  2. Back-of-the-envelope estimation (~5 phút). Tính DAU, QPS peak/avg, read:write ratio, storage growth theo năm, bandwidth. Không cần chính xác — cần đúng order of magnitude để quyết định "cần shard hay không", "cache có ý nghĩa không", "một node đủ hay phải scale ngang".
  3. High-level design (~10–15 phút). Vẽ box diagram: client → LB → app tier → cache → primary datastore → async worker → object storage/CDN. Định nghĩa API (endpoint, request/response, status code) và data model (schema chính, index chính). Ở bước này chưa đi sâu vào từng box.
  4. Deep-dive (~15–20 phút). Interviewer sẽ chỉ vào một box và hỏi "cái này scale thế nào?". Đây là nơi giải thích: sharding scheme (partition key nào, tại sao), replication (sync/async, quorum), cache strategy (cache-aside, write-through, TTL, invalidation), consistency model (read-your-write, eventual), bottleneck và cách xử lý.
  5. Tradeoff & wrap-up (~5 phút). Nêu lựa chọn nào đánh đổi cái gì, cái gì có thể làm khác nếu NFR đổi (vd: nếu strong consistency là bắt buộc thì phải bỏ multi-region active-active), và các failure mode chính đã được xử lý (single AZ down, DB primary chết, cache miss storm).

Một mental checklist ngắn để không lạc:

[R] Functional API:      ...
[R] NFR (SLA, RPS, p99): ...
[R] Out of scope:        ...
[E] DAU / QPS:           ...
[E] Read:Write ratio:    ...
[E] Storage / year:      ...
[H] API contract:        POST /... GET /...
[H] Data model:          table(...), index(...)
[H] Diagram:             client -> LB -> app -> cache -> DB / queue -> worker
[D] Partition key:       ...
[D] Cache strategy:      ...
[D] Consistency:         ...
[D] Bottleneck & fix:    ...
[T] What breaks:         ...
[T] What we would change:...
Enter fullscreen mode Exit fullscreen mode

Điểm quan trọng: các bước này không phải tuyến tính cứng. Deep-dive có thể phát hiện estimation sai → quay lại estimation. Nhưng bỏ hẳn một bước — điển hình là bỏ estimation và requirements — thì phần sau mất neo, mọi lựa chọn thành opinion không có căn cứ.

Vấn đề gặp trong production

Đây là "production" của bài phỏng vấn — thất bại thật sự lặp lại ở loạt vòng System Design mà nhiều tài liệu (Alex Xu, interviewing.io blog, Grokking SDI) đã tổng kết:

Failure mode 1: nhảy vào chi tiết không có cấu trúc. Đề "design Twitter", ngay phút thứ 3 đã vẽ Kafka + Cassandra + Redis + Elasticsearch. Interviewer hỏi "read:write ratio là bao nhiêu?" → không biết. Hỏi "vì sao Cassandra chứ không Postgres?" → không trả lời được vì chưa biết access pattern. Đây là dạng thất bại phổ biến nhất: chọn công nghệ trước khi có yêu cầu.

Sai:

"Sẽ dùng Cassandra vì scale tốt, Kafka làm message bus, Redis cache,
 Elasticsearch cho search, Nginx load balancer..."
(đã 5 phút trôi qua, chưa có 1 dòng requirement, chưa có API, chưa có schema)
Enter fullscreen mode Exit fullscreen mode

Đúng — bắt đầu ngược lại:

"Trước khi chọn stack, cần rõ vài thứ:
 - System hỗ trợ post text/media hay chỉ text?
 - Timeline home feed là chronological hay ranked?
 - Read heavy hay write heavy? Order of magnitude DAU?
 - Latency target cho feed? p99 dưới 200ms?
 - Cần strong consistency cho counter không, hay eventual ok?"
Enter fullscreen mode Exit fullscreen mode

Sau khi có câu trả lời mới sang estimation, rồi high-level, rồi mới nói tới Kafka/Cassandra — và lúc đó mỗi lựa chọn có lý do gắn với requirement cụ thể.

Failure mode 2: bỏ back-of-envelope estimation. Không có ước lượng thì mọi câu "cần shard không", "một DB đủ không", "cache có đáng không" đều thành đoán mò. Tệ hơn: chọn kiến trúc quá phức tạp cho tải nhỏ (over-engineer) hoặc quá đơn giản cho tải lớn (miss bottleneck). Ví dụ với URL shortener: nếu không tính ra "read gấp nhiều lần write" (thường được cite ở tỉ lệ order-of-magnitude 100:1 trong các tài liệu SDI), sẽ không thấy vì sao cache là quyết định trung tâm chứ không phải phụ. Cách sửa: sau khi có DAU và QPS, luôn viết ra một dòng "read RPS ≈ ..., write RPS ≈ ..., ratio ≈ ..." và một dòng "storage tăng ≈ ... / năm" trước khi vẽ box.

Failure mode 3: thiết kế thiếu trọng tâm — bỏ sót thành phần bắt buộc. Vẽ xong high-level mà thiếu load balancer, thiếu cache tier, thiếu async worker cho việc nặng, thiếu CDN cho static, thiếu health check / retry / timeout — đến deep-dive bị hỏi "làm sao xử lý spike?" thì đứng hình. Rule để không sót: mỗi box phải trả lời được ba câu — "nó scale thế nào (horizontal/vertical)", "nó fail thì sao (replica, failover)", "nó bị chậm thì có gì đỡ (cache, queue, backpressure)". Nếu một box không trả lời được cả ba, đó là chỗ interviewer sẽ đâm vào.

Failure mode 4: deep-dive không có ưu tiên. Interviewer nói "đi sâu vào phần thấy thú vị nhất" — thí sinh chọn phần dễ (thường là tầng app stateless) thay vì phần khó (sharding datastore, hot key, cache invalidation). Cách xử lý: chủ động chỉ ra 2–3 chỗ thật sự khó của bài rồi chọn một để đi sâu — điều này cho thấy đã nhận diện được risk area, khác hẳn với đi sâu chỗ an toàn cho có.

Failure mode 5: quản lý thời gian sai — hết giờ ở high-level. Dành 20 phút cho requirements + high-level, còn 5 phút cho deep-dive và không có wrap-up. Interviewer chấm điểm deep-dive nặng nhất nên phần yếu nhất lại là phần nặng điểm nhất. Rule cứng: đặt time-box mềm cho mỗi bước và tự nhắc mình sang bước tiếp — nếu chưa xong requirement sau 7 phút thì bỏ những thứ còn mơ hồ vào "assumption" và đi tiếp.

Failure mode 6: không nêu tradeoff, chỉ nêu lựa chọn. Nói "dùng Postgres" không đủ; phải nói "chọn Postgres vì write pattern có transaction, chấp nhận scale ngang khó hơn Cassandra — nếu QPS write vượt X thì sẽ shard theo user_id hoặc chuyển sang một wide-column store". Mỗi lựa chọn kèm điều kiện đảo. Đây là cách interviewer phân biệt senior với người học thuộc bài.

Cách debug và monitor

"Debug" ở đây là tự kiểm tra buổi phỏng vấn khi đang chạy — không có monitor tự động cho câu trả lời miệng, nhưng có heuristic thay thế.

Triệu chứng đang lạc bước:

  • Đã 10 phút mà chưa vẽ box đầu tiên → mắc kẹt ở requirement, cần bỏ chi tiết chưa rõ vào "assumption: ..." và đi tiếp.
  • Interviewer hỏi cùng một câu hai lần theo cách khác → dấu hiệu câu trả lời chưa chạm; dừng lại xác nhận lại câu hỏi thay vì nói tiếp.
  • Đã vẽ 6 box mà chưa có API/schema nào → high-level thiếu "mặt phẳng dữ liệu", quay lại định nghĩa API và data model trước khi thêm box.
  • Không có con số nào trong phần deep-dive (không QPS, không kích thước row, không cache hit ratio giả định) → deep-dive đang là opinion; thêm một estimation cụ thể.

Checklist "monitor" trước khi kết thúc — dùng như post-flight check:

  • [ ] Đã hỏi ít nhất 3 câu clarify requirement chưa?
  • [ ] Có 1 dòng QPS + 1 dòng storage growth + 1 dòng ratio chưa?
  • [ ] Có API contract (method, path, request, response) chưa?
  • [ ] Có schema chính (bảng, primary key, index) chưa?
  • [ ] Có LB + cache + primary store + async layer trên diagram chưa?
  • [ ] Đã nêu partition key và tại sao chưa?
  • [ ] Đã nêu ít nhất 2 failure mode và cách xử lý chưa?
  • [ ] Đã nêu ít nhất 1 tradeoff kèm điều kiện đảo chưa?

Rule phòng ngừa: luyện quy trình bằng cách viết ra khung 5 bước ở đầu tờ giấy nháp (hoặc góc whiteboard) trước khi nghe đề — buộc bản thân đi qua tuần tự, không cho phép nhảy. Sau vài lần lặp, quy trình trở thành reflex, không phải checklist phải nhớ.

Tradeoff

Quy trình có cấu trúc bao quát được toàn bộ bề mặt bài toán, giảm rủi ro bỏ sót và giúp interviewer chấm dễ (vì họ đã có rubric theo bước) — đổi lại nó cần luyện tập nhiều buổi để không trở thành đọc kịch bản cứng nhắc. Đi quá cứng theo bước dễ mất tự nhiên: chuyển từ "estimation" sang "high-level" mà bỏ qua manh mối interviewer vừa gợi ý là mất điểm giao tiếp; ngược lại đi quá tự do thì thiếu một bước nào đó, mất điểm coverage. Time-box quá chặt thì không kịp deep-dive; quá lỏng thì hết giờ ở requirement. Chọn công nghệ sớm (trước estimation) giúp câu chuyện có "hook" nhưng dễ bị hỏi ngược "tại sao đúng cái đó?" và không trả lời được; chọn muộn (sau deep-dive) an toàn hơn nhưng câu chuyện lan man.

Rule of thumb: đi đúng 5 bước theo thứ tự, nhưng time-box mềm — nếu một bước tràn quá 30% thời lượng dự kiến, chốt assumption và đi tiếp; luôn kết thúc bằng ít nhất một tradeoff kèm điều kiện đảo. Trong khoảng 10 buổi mock đầu tiên tuân thủ khung này chặt để tạo reflex; sau đó nới lỏng theo tình huống, giữ 5 mốc bắt buộc (requirement, estimation, API/schema, một deep-dive có số, một tradeoff).

Câu hỏi phỏng vấn

Quy trình thiết kế hệ thống trong một buổi phỏng vấn System Design gồm những bước nào, và tại sao thứ tự đó lại quan trọng?

Năm bước theo thứ tự: requirements (functional + non-functional + out-of-scope, hỏi lại interviewer để chốt), back-of-the-envelope estimation (DAU, QPS đọc/ghi, storage growth, ratio đọc/ghi — đủ order of magnitude để quyết định shard/cache), high-level design (API contract, data model chính, box diagram với LB + app + cache + DB + async worker), deep-dive (partition key, replication, consistency model, cache strategy và invalidation, bottleneck cụ thể), và tradeoff + wrap-up (mỗi lựa chọn kèm điều kiện đảo, failure mode chính đã cover). Thứ tự quan trọng vì mỗi bước là input cho bước sau: không có requirement thì không biết estimate cái gì; không có estimation thì mọi lựa chọn công nghệ ở high-level đều là opinion; không có high-level ổn thì deep-dive lạc chỗ; không có tradeoff thì interviewer không phân biệt được senior với người học thuộc bài. Điểm ăn điểm là gọi tên hai failure mode production của chính quy trình phỏng vấn: (1) nhảy thẳng vào chi tiết công nghệ (Kafka/Cassandra/Redis) trước khi có requirement — mọi câu "tại sao chọn cái này?" đều đứng hình; (2) thiết kế thiếu trọng tâm — bỏ sót load balancer/cache/async worker hoặc không nêu partition key ở deep-dive; cách xử lý là bám khung 5 bước với time-box mềm, luôn có ít nhất một con số ở deep-dive, và mỗi lựa chọn kèm điều kiện đảo.

Hands-on

Bài tập: thiết kế end-to-end một URL shortener theo đúng 5 bước, rồi dựng phiên bản tối thiểu bằng Docker để tự bắn tải và quan sát bottleneck xuất hiện ở đúng chỗ mà phần deep-dive đã dự đoán.

Bước 1 — Requirements (viết ra trước khi code):

Functional:
  - POST /shorten { url } -> { short }
  - GET  /:short          -> 301 -> original URL
  - (optional) custom alias
Non-functional:
  - Read heavy (click gấp nhiều lần tạo mới) — ratio order-of-magnitude 100:1
  - p99 redirect < 100ms
  - Availability cao (redirect là hot path)
  - Short code không đoán được
Out of scope:
  - Analytics chi tiết, billing, user auth
Enter fullscreen mode Exit fullscreen mode

Bước 2 — Estimation (qualitative, đủ để quyết định):

Giả sử vài trăm triệu URL mới / năm  -> vài GB row data / tháng (thân URL + short)
Read RPS peak lớn hơn write RPS ~100 lần  -> cache là quyết định trung tâm
Short code 7 ký tự base62 -> ~62^7 ≈ hơn 3 nghìn tỉ tổ hợp — dư
Enter fullscreen mode Exit fullscreen mode

Bước 3 — High-level design + API + schema:

client -> nginx (LB, TLS) -> app (stateless) -> Redis (cache) -> Postgres (primary)
                                              -> Postgres write path (POST /shorten)
Enter fullscreen mode Exit fullscreen mode

Schema:

CREATE TABLE urls (
  short       TEXT PRIMARY KEY,
  long_url    TEXT NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

Bước 4 — Deep-dive (viết ra trước khi dựng):

Partition key: short (hash-shard nếu vượt 1 node)
Cache: cache-aside, key = short, TTL vài giờ, invalidate khi update
Hot key: một short viral -> Redis single key nóng, cần local in-process cache 1s TTL
ID generation: base62 của counter tăng dần (Snowflake-like) hoặc random 7-char + retry on conflict
Consistency: eventual — cache stale vài giây chấp nhận được
Enter fullscreen mode Exit fullscreen mode

Bước 5 — Dựng phiên bản tối thiểu để verify deep-dive đúng.

Tạo thư mục lab và service Node tối giản (server.js):

const http = require("http");
const { Client } = require("pg");
const Redis = require("ioredis");
const crypto = require("crypto");

const pg = new Client({ connectionString: process.env.PG_URL });
const redis = new Redis(process.env.REDIS_URL);
const USE_CACHE = process.env.USE_CACHE === "1";

function shortCode() {
  return crypto.randomBytes(5).toString("base64url").slice(0, 7);
}

async function main() {
  await pg.connect();
  await pg.query(
    "CREATE TABLE IF NOT EXISTS urls(short TEXT PRIMARY KEY," +
    " long_url TEXT NOT NULL," +
    " created_at TIMESTAMPTZ NOT NULL DEFAULT now())"
  );

  http.createServer(async (req, res) => {
    try {
      if (req.method === "POST" && req.url === "/shorten") {
        let body = ""; for await (const c of req) body += c;
        const { url } = JSON.parse(body || "{}");
        const short = shortCode();
        await pg.query(
          "INSERT INTO urls(short,long_url) VALUES($1,$2)",
          [short, url]
        );
        res.writeHead(200, { "content-type": "application/json" });
        res.end(JSON.stringify({ short }));
      } else if (req.method === "GET" && req.url.length > 1) {
        const short = req.url.slice(1);
        let long;
        if (USE_CACHE) long = await redis.get("u:" + short);
        if (!long) {
          const r = await pg.query(
            "SELECT long_url FROM urls WHERE short=$1",
            [short]
          );
          if (!r.rows[0]) { res.writeHead(404).end(); return; }
          long = r.rows[0].long_url;
          if (USE_CACHE) await redis.set("u:" + short, long, "EX", 3600);
        }
        res.writeHead(301, { location: long }).end();
      } else {
        res.writeHead(404).end();
      }
    } catch (e) { res.writeHead(500).end(e.message); }
  }).listen(3000);
}
main();
Enter fullscreen mode Exit fullscreen mode

package.json:

{ "name": "url-lab", "dependencies": { "pg": "^8", "ioredis": "^5" } }
Enter fullscreen mode Exit fullscreen mode

Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY package.json server.js ./
RUN npm install --omit=dev
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml:

services:
  pg:
    image: postgres:16-alpine
    environment: { POSTGRES_PASSWORD: pw, POSTGRES_DB: urls }
    ports: ["5432:5432"]
  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
  app:
    build: .
    environment:
      PG_URL: postgres://postgres:pw@pg:5432/urls
      REDIS_URL: redis://redis:6379
      USE_CACHE: "0"
    depends_on: [pg, redis]
    ports: ["3000:3000"]
Enter fullscreen mode Exit fullscreen mode

Dựng cụm và tạo một URL để lấy short code:

mkdir -p url-lab && cd url-lab
# lưu 4 file trên vào thư mục này
docker compose up -d --build

SHORT=$(curl -s -X POST localhost:3000/shorten \
  -H "content-type: application/json" \
  -d '{"url":"https://example.com/very/long/path"}' \
  | sed 's/.*"short":"\([^"]*\)".*/\1/')
echo "short=$SHORT"
curl -sI "localhost:3000/$SHORT" | head -2
Enter fullscreen mode Exit fullscreen mode

Bắn tải vào redirect (read hot path), CHƯA bật cache:

# macOS: brew install wrk ; Debian/Ubuntu: sudo apt-get install wrk
wrk -t4 -c100 -d20s "http://localhost:3000/$SHORT"
# ghi lại Requests/sec và Latency p99
Enter fullscreen mode Exit fullscreen mode

Bật cache và đo lại:

# sửa USE_CACHE trong docker-compose.yml thành "1"
docker compose up -d app
wrk -t4 -c100 -d20s "http://localhost:3000/$SHORT"
# kỳ vọng: RPS tăng nhiều lần, p99 giảm rõ; Postgres CPU giảm sâu
Enter fullscreen mode Exit fullscreen mode

Verify Postgres đã hết là bottleneck khi có cache:

docker compose exec pg psql -U postgres -d urls -c \
  "SELECT query, calls, mean_exec_time FROM pg_stat_statements ORDER BY calls DESC LIMIT 5;"
# nếu chưa có extension:
#   docker compose exec pg psql -U postgres -c "CREATE EXTENSION pg_stat_statements"
# và thêm shared_preload_libraries='pg_stat_statements' vào postgresql.conf rồi restart
Enter fullscreen mode Exit fullscreen mode

Tái hiện hot-key: chọn 1 short code duy nhất và bắn wrk với nhiều kết nối vào chính key đó — Redis single-key sẽ trở thành bottleneck (một shard, một key). Thêm một tầng in-process LRU cache 1s TTL trong server.js và đo lại — RPS lại nhảy vì phần lớn request không còn ra tới Redis. Đây chính là hot-key mitigation mà phần deep-dive đã dự đoán.

Cuối cùng, viết một trang one-pager tóm gọn: 5 bước, mỗi bước 3–5 dòng, khớp với những gì lab vừa verify. Đây là artifact mang vào phỏng vấn thật — chỉ khác đề, khung không đổi.

Top comments (0)