SU02 · Phát triển Phần mềm Số 02 · ISC · FPT Telecom

Worked Examples — Hai bài giải mẫu đầy đủ

Hai bài toán khác 12 đề chính thức, giải đầy đủ từ napkin math tới ADR, dùng để minh hoạ cách lập luận trước buổi Dojo đầu tiên — không phải khuôn để chép.

Tài liệu 10 / 10Dùng ở buổi kick-offSA1 và SA2–3Bản v1.0
← Bộ tài liệu SA · SU02

Mục đíchCách dùng hai bài giải mẫu này

Đây không phải hai trong số 12 đề chính thức của Problem Bank — cố ý chọn hai bài toán khác, cùng độ khó tương đương, để cohort thấy cách lập luận mà không thuộc lòng đáp án cho bài sẽ bị chấm.

Dùng khi nào

  • Phát cho cả cohort trước buổi Dojo đầu tiên, đọc ở nhà hoặc đọc chung 20 phút đầu buổi kick-off
  • Người điều phối vẽ lại Ví dụ 2 trực tiếp trên bảng trong buổi kick-off — xem chữ biến thành hình vẽ tay quan trọng hơn đọc chữ
  • Dùng lại bất cứ khi nào một người trong cohort bị kẹt không biết bắt đầu từ đâu

Không dùng để làm gì

  • Không phải khuôn để chép cấu trúc cho 12 đề chính thức — mỗi bài toán có ràng buộc riêng, áp khuôn có sẵn là dấu hiệu SA0–SA1
  • Không phải "đáp án đúng duy nhất" — phần cuối mỗi ví dụ nêu rõ phương án khác vẫn có thể chấp nhận được nếu lập luận chặt
Khác biệt cố ý giữa hai ví dụ: Ví dụ 1 dạy rằng không phải bài toán nào cũng cần kiến trúc phức tạp — bài học dễ bị bỏ qua nhất. Ví dụ 2 dạy napkin math dẫn tới quyết định kiến trúc như thế nào khi con số thật sự vượt trần.

Cách đọc nâng caoĐọc một bài giải như thế nào?

Mỗi ví dụ cần được đọc theo năm lớp: giả định → tính toán → phương án → phản biện → bằng chứng. Không chỉ đọc phần phương án được chọn.

Lớp đọcCâu hỏi người học cần tự hỏiLiên kết rubric
Giả địnhThông tin nào là dữ kiện, thông tin nào đang được giả định?Trụ 1 · Trụ 2
Tính toánCon số nào quyết định bottleneck hoặc loại phương án?Trụ 2
Phương ánCó ít nhất hai lựa chọn thực tế không? Vì sao chọn một?Trụ 1 · Trụ 4 · Trụ 5
Phản biệnThiết kế thay đổi thế nào khi ràng buộc hoặc failure mode thay đổi?Trụ 3 · Trụ 5
Bằng chứngĐiều gì cần load test, spike, ADR hoặc review stakeholder để xác nhận?Trụ 5 · Trụ 6
Quy ước đánh dấu: các dòng bắt đầu bằng “Giả định”, “Cảnh báo”, “Lỗi thường gặp” hoặc “Rubric” là điểm cần dừng lại để thảo luận, không phải nội dung cần học thuộc.

VÍ DỤ 1 · Mức SA1 · Trụ 1, 2, 5Cảnh báo hợp đồng B2B sắp hết hạn

Bối cảnh nghiệp vụ

Đội kinh doanh B2B hiện phải tự tra bảng tính để biết hợp đồng nào sắp hết hạn, dẫn tới nhiều trường hợp gia hạn trễ. Yêu cầu: hệ thống tự động rà soát hằng ngày và cảnh báo cho sales phụ trách khi hợp đồng còn 30, 15, hoặc 7 ngày là hết hạn — qua email và thông báo trong ứng dụng CRM.

Ràng buộc định lượng

Bước 1 — Làm rõ ràng buộc trước khi thiết kế

Đây là bước mà người mới hay bỏ qua. Trước khi vẽ bất cứ gì, SA nên hỏi và ghi lại câu trả lời:

Câu hỏi đặt raCâu trả lời giả định (ghi rõ đây là giả định)
Cảnh báo có cần đúng-thời-gian-thực không, hay chạy theo lô hằng ngày là đủ?Theo lô — nghiệp vụ chỉ cần thấy cảnh báo trong ngày, không cần tức thì
Một hợp đồng có thể có nhiều sales phụ trách không?Không — mỗi hợp đồng có đúng một sales sở hữu
Nếu job chạy lỗi giữa chừng, có được phép gửi trùng cảnh báo không?Không được trùng — sales sẽ mất niềm tin vào hệ thống nếu bị nhắc lặp lại
Ràng buộc availability của Mail Gateway hiện tại?SLA nội bộ 99%, không do team này kiểm soát

Bước 2 — Napkin math

40.000 hợp đồng, rà soát 1 lần/ngày lúc 6:00, hạn chót xong trước 8:00
→ Ngân sách thời gian: 2 giờ = 7.200 giây

Giả sử mỗi hợp đồng cần 1 phép so sánh ngày + ghi tối đa 1 dòng thông báo:
  Ngay cả với 50 ms/hợp đồng (chậm, không tối ưu) → 40.000 × 0,05s = 2.000s
  Vẫn nằm trong ngân sách 7.200s, kể cả khi không tối ưu gì cả.

So với trần Postgres write đơn giản (vài nghìn/s):
  40.000 dòng ghi trong 2 giờ ≈ 5,6 write/s trung bình
  → Cách trần hàng nghìn lần. Đây KHÔNG phải bài toán scale.
Điểm dạy quan trọng nhất của ví dụ này: phép tính cho thấy ngay cả cách làm "ngây thơ" nhất (quét tuần tự, không tối ưu) vẫn thừa thời gian gấp 3 lần. Một người mới thường nhảy thẳng vào bàn "dùng queue", "dùng Kafka" — trong khi câu trả lời đúng là không cần. Nhận ra khi nào không cần kiến trúc phức tạp là một phần của trụ Trade-off, không kém phần quan trọng so với biết dùng kiến trúc phức tạp khi cần.

Bước 3 — Hai phương án được cân nhắc

Phương ánMô tảĐánh đổi
A — Batch job hằng ngày
Được chọn
Cron job 6:00 sáng, quét bảng hợp đồng, tính hợp đồng nào rơi vào mốc 30/15/7 ngày, ghi vào bảng Notifications và gọi Mail Gateway gửi email. Ưu: đơn giản, dùng hạ tầng có sẵn, dễ debug, dễ vận hành.
Nhược: nếu job chạy quá 6:00–8:00 (ví dụ do DB chậm bất thường) thì trễ SLA; cần cơ chế idempotent để job chạy lại không gửi trùng.
B — Event-driven liên tục Mỗi lần hợp đồng được tạo/sửa, emit event; một service riêng liên tục tính ngày còn lại và lên lịch cảnh báo. Ưu: lý thuyết "hiện đại" hơn, phản ứng ngay khi dữ liệu đổi.
Nhược: phức tạp hơn hẳn cho một bài toán không cần thời gian thực — cần vận hành thêm một service, thêm điểm hỏng, thêm chi phí bảo trì, mà không giải quyết bài toán nào tốt hơn phương án A ở scale này.
Vì sao B bị loại: không phải vì B sai kỹ thuật, mà vì B là kiến trúc cho một bài toán không tồn tại ở đây. Ràng buộc đã xác nhận ở Bước 1 rằng nghiệp vụ chỉ cần cảnh báo trong ngày — không có yêu cầu tức thì nào biện minh cho độ phức tạp của B. Đây chính là anti-pattern SA vẽ theo công nghệ nếu chọn B.
Phương án sai nhưng dễ bị chọn: dùng event-driven liên tục vì “gần realtime luôn tốt hơn”. Sai ở đây không nằm ở công nghệ, mà ở việc tối ưu một NFR không tồn tại và bỏ qua chi phí vận hành. Lỗi người mới: chọn kiến trúc trước khi hỏi SLA nghiệp vụ và trước khi tính 40.000 bản ghi trong 2 giờ.

Bước 4 — Idempotency, phần dễ bị quên nhất

Ràng buộc "không gửi trùng" ở Bước 1 quyết định một chi tiết thiết kế quan trọng: mỗi lần job chạy phải kiểm tra hợp đồng này đã được cảnh báo ở đúng mốc này chưa trước khi gửi — ví dụ bằng một cột last_alert_milestone trên bảng hợp đồng, không dựa vào "job chỉ chạy một lần" (giả định đó sẽ sai vào đúng ngày cần đúng nhất).

Bước 5 — Mô tả kiến trúc để vẽ tay lại

Bước 6 — ADR mẫu

# ADR-001 — Dùng batch job hằng ngày cho cảnh báo hết hạn hợp đồng

Trạng thái:  Đã chấp nhận
Người quyết: {SA}          Người tham vấn: {TL CRM, PM Sales Ops}

## Bối cảnh
Cần cảnh báo sales khi hợp đồng còn 30/15/7 ngày hết hạn, trước 8:00 sáng.
40.000 hợp đồng, không có yêu cầu thời gian thực từ nghiệp vụ.

## Các phương án đã cân nhắc
A. Batch job hằng ngày, quét toàn bộ bảng hợp đồng lúc 6:00
B. Event-driven, tính lại liên tục mỗi khi hợp đồng thay đổi

## Quyết định
Chọn phương án A.

## Vì sao loại phương án B
Ở quy mô 40.000 hợp đồng và ngân sách 2 giờ, batch job chạy dưới 2.000
giây kể cả không tối ưu — cách trần thời gian yêu cầu rất xa. B thêm một
service phải vận hành, thêm điểm hỏng, mà không giải quyết vấn đề nghiệp
vụ nào tốt hơn A ở scale hiện tại. B chỉ đáng cân nhắc nếu nghiệp vụ đổi
yêu cầu sang "cảnh báo tức thì khi hợp đồng gần hết hạn", điều chưa xảy ra.

## Hệ quả
Tích cực:  triển khai nhanh trong 1 sprint, dùng hạ tầng có sẵn
Tiêu cực:  nếu job chạy trễ do DB chậm bất thường, có rủi ro trễ SLA 8:00
Nợ kỹ thuật chấp nhận: không

## Khả năng đảo ngược
Type 2 — đảo ngược được. Nếu sau này nghiệp vụ cần cảnh báo tức thì,
có thể chuyển sang phương án B mà không ảnh hưởng dữ liệu đã có.

## Cần xem lại khi
Số hợp đồng vượt 500.000 (ngân sách 2 giờ không còn dư dả) HOẶC
nghiệp vụ yêu cầu cảnh báo trong vòng vài phút thay vì trong ngày.

Tự kiểm tra trước khi chuyển sang Ví dụ 2

  1. Bạn có tự hỏi "bài toán này có thật sự cần kiến trúc phức tạp không" trước khi vẽ chưa?
  2. Bạn có làm napkin math trước khi chọn phương án chưa, hay chọn theo cảm tính?
  3. ADR của bạn có phần "vì sao loại phương án khác" cụ thể, hay chỉ nói chung chung "phương án kia phức tạp hơn"?
Câu hỏi tự kiểm tra thêm: Nếu số hợp đồng tăng lên 500.000, điều kiện nào khiến bạn xem lại quyết định? Nếu Mail Gateway chậm, trạng thái trong CRM có bị mất không? Bằng chứng nào cần có trước khi chuyển từ batch sang event-driven?

VÍ DỤ 2 · Mức SA2–SA3 · Trụ 2, 3, 5Giám sát trạng thái thiết bị đầu cuối gần thời gian thực

Bối cảnh nghiệp vụ

Bộ phận vận hành muốn một dashboard hiển thị trạng thái online/offline của modem/router khách hàng theo thời gian gần thực, phân theo khu vực địa lý, để chủ động phát hiện sự cố hạ tầng vùng và điều phối kỹ thuật hiện trường trước khi khách hàng gọi lên tổng đài phàn nàn.

Ràng buộc định lượng

Bước 1 — Làm rõ ràng buộc trước khi thiết kế

Câu hỏi đặt raCâu trả lời giả định
"Vài giây" cho dashboard nghĩa là p95 hay tuyệt đối?p95 dưới 5 giây — chấp nhận một số ít trường hợp chậm hơn khi tải cực đỉnh
Lịch sử 90 ngày cần lưu mọi heartbeat hay chỉ lúc đổi trạng thái?Chỉ lúc đổi trạng thái (online↔offline) — đây là câu hỏi quyết định toàn bộ kiến trúc, xem Bước 2
Hệ thống có phải là nguồn duy nhất để tính SLA hợp đồng không, hay chỉ tham khảo vận hành?Tham khảo vận hành trong giai đoạn 1; tính SLA hợp đồng chính thức là bài toán riêng, không trong phạm vi này
Có ràng buộc phải xử lý đúng 100% mọi heartbeat không, hay được phép mất một phần khi quá tải?Được phép mất một phần heartbeat khi quá tải cực đỉnh — dashboard là công cụ vận hành, không phải hệ thống tính cước
Câu hỏi thứ hai là bản lề của toàn bộ thiết kế. Nếu câu trả lời là "lưu mọi heartbeat", bài toán trở thành lưu trữ time-series khổng lồ. Nếu câu trả lời là "chỉ lưu lúc đổi trạng thái", bài toán giảm bậc độ lớn đáng kể ở phần lưu trữ bền vững — dù phần xử lý heartbeat thời gian thực vẫn nặng như nhau. Không hỏi câu này trước khi thiết kế là dấu hiệu SA1, không phải SA2.

Bước 2 — Napkin math

2.000.000 thiết bị, heartbeat mỗi 60 giây
→ TPS trung bình = 2.000.000 / 60 ≈ 33.333 heartbeat/giây

So với trần thành phần (xem Performance & Scale Handbook §02):
  Postgres write đơn giản        vài nghìn/s     → VƯỢT TRẦN ~5–10 lần
  Redis (100.000 ops/s)          → còn cách trần ~3 lần, CHƯA chạm trần
  Kafka 1 broker (trăm nghìn/s)  → còn cách trần nhiều lần

Kết luận sớm: ghi trực tiếp mọi heartbeat vào Postgres là bất khả thi ở
scale này ngay từ điều kiện bình thường — chưa cần đợi tới sự cố khu vực.

Kịch bản đỉnh — sự cố khu vực rồi phục hồi (retry storm):
  Giả sử 5% thiết bị (100.000) cùng mất kết nối rồi cùng reconnect
  trong cửa sổ 10 giây khi trạm mạng phục hồi:
  100.000 / 10s = 10.000 reconnect/giây dồn thêm vào đúng thời điểm đó
  → Đỉnh tức thời ≈ 33.333 (nền) + 10.000 (storm) ≈ 43.000/giây
  → Vẫn dưới trần Redis, nhưng gấp hơn 20 lần trần Postgres.

Dung lượng lịch sử (chỉ lưu lúc đổi trạng thái, không lưu mọi heartbeat):
  Giả định trung bình mỗi thiết bị đổi trạng thái ~3 lần/ngày
  (bao gồm cả nhiễu mạng ngắn) × 2 triệu thiết bị × 90 ngày
  ≈ 540 triệu dòng sự kiện — nằm trong khả năng một Postgres bình
  thường nếu partition theo thời gian, KHÔNG cần time-series DB riêng.
Điểm dạy quan trọng nhất của ví dụ này: hai con số — 43.000/giây cho luồng heartbeat và 540 triệu dòng cho luồng lịch sử — dẫn tới hai quyết định kiến trúc khác nhau cho hai luồng dữ liệu khác nhau. Một thiết kế gộp chung hai luồng vào cùng một kho dữ liệu là dấu hiệu chưa tách được "trạng thái hiện tại" (cần nhanh, không cần bền vững tuyệt đối) khỏi "lịch sử" (cần bền vững, không cần cực nhanh).

Bước 3 — Hai phương án được cân nhắc

Phương ánMô tảĐánh đổi
A — Redis cho trạng thái hiện tại + batch diff cho lịch sử
Được chọn cho giai đoạn 1
Mỗi heartbeat ghi thẳng vào Redis: key = device_id, value = timestamp, TTL = 90 giây (1,5× chu kỳ heartbeat). Dashboard đọc trực tiếp Redis, pre-aggregate theo vùng mỗi vài giây bằng một job nền. Một batch job riêng, chạy mỗi 30 giây, so sánh snapshot Redis hiện tại với snapshot trước để phát hiện thiết bị đổi trạng thái, chỉ những thay đổi này mới được ghi vào bảng lịch sử (Postgres, partition theo tháng). Ưu: Redis xử lý 43.000/giây dễ dàng (còn cách trần ~2 lần ngay cả ở đỉnh). Không cần vận hành thêm hệ thống stream processing. Luồng lịch sử tách biệt, tải nhẹ, dùng hạ tầng quen thuộc.
Nhược: Redis là một điểm phụ thuộc mới — cần cấu hình cluster/replica để không phải SPOF. Batch diff mỗi 30 giây nghĩa là phát hiện đổi trạng thái có độ trễ tối đa 30 giây, không phải tức thời tuyệt đối. TTL phải tính đúng — sai một chút sẽ báo nhầm thiết bị offline dù vẫn online.
B — Kafka + stream processing (state store) Heartbeat đẩy vào Kafka topic, partition theo device_id. Một stream processor (ví dụ Kafka Streams hoặc Flink) giữ trạng thái mới nhất từng thiết bị trong state store nội bộ, phát hiện đổi trạng thái ngay trong luồng xử lý, ghi thẳng "state change event" vào kho lịch sử, đồng thời flush snapshot tổng hợp theo vùng cho dashboard mỗi vài giây. Ưu: độ trễ phát hiện đổi trạng thái thấp hơn (gần tức thời thay vì tối đa 30 giây). Kiến trúc chịu tải tốt hơn nữa nếu quy mô tăng thêm một bậc.
Nhược: vận hành phức tạp hơn hẳn — cần đội ngũ quen thuộc với stream processing, xử lý state store phục hồi sau khi service restart, chi phí hạ tầng và vận hành cao hơn cho một mức cải thiện độ trễ (30 giây → gần tức thời) mà nghiệp vụ chưa yêu cầu.
Vì sao B bị loại — cho giai đoạn 1, không phải mãi mãi: Bước 1 đã xác nhận yêu cầu là "vài giây, p95", không phải "tức thời tuyệt đối". Phương án A đáp ứng đúng yêu cầu đó với độ phức tạp vận hành thấp hơn nhiều. B là kiến trúc đúng cho một bài toán khắt khe hơn bài toán đang có — chọn B bây giờ là trả chi phí vận hành cho một yêu cầu chưa tồn tại. Đây là ví dụ về nguyên tắc Bậc thang mở rộng trong Performance & Scale Handbook: không nhảy lên bậc cao hơn mức bài toán đòi hỏi.
Phương án sai nhưng dễ bị chọn: ghi mọi heartbeat trực tiếp vào Postgres vì “dữ liệu phải bền vững”. Sai ở đây là không phân biệt dữ liệu cần realtime với dữ liệu cần lưu lâu dài. Lỗi người mới: chỉ tính 33.000 heartbeat/s bình thường, quên retry storm và quên rằng mỗi loại dữ liệu có yêu cầu khác nhau.

Bước 4 — Ba vấn đề không được bỏ sót

Vấn đềCách xử lý trong phương án A
Retry storm khi phục hồiBắt buộc: client (modem) phải reconnect với backoff có jitter, không đồng loạt gửi lại cùng một giây. Nếu không kiểm soát được phía thiết bị, cần thêm một lớp rate limiter trước Redis dành riêng cho luồng heartbeat (bulkhead) để không ảnh hưởng các luồng khác nếu dùng chung Redis cluster.
Redis là single point of failureRedis Cluster hoặc Sentinel với replica, không chạy Redis đơn lẻ cho một luồng dữ liệu quan trọng vận hành ở quy mô này.
Hot region khi sự cố diện rộngPre-aggregate theo vùng được tính sẵn bởi batch job nền, không để dashboard tự quét toàn bộ 2 triệu key mỗi lần tải trang — đúng lúc cần xem dashboard nhất (đang có sự cố) cũng là lúc hệ thống tải nặng nhất.

Bước 5 — Mô tả kiến trúc để vẽ tay lại

Bước 6 — ADR mẫu

# ADR-002 — Dùng Redis + batch diff cho giám sát trạng thái thiết bị

Trạng thái:  Đã chấp nhận
Người quyết: {SA}          Người tham vấn: {TL Vận hành, Hạ tầng}

## Bối cảnh
2 triệu thiết bị, heartbeat 60s (~33.000/s nền, ~43.000/s đỉnh khi có
retry storm sau sự cố khu vực). Dashboard cần phản ánh trạng thái theo
vùng trong vài giây (p95 < 5s). Lịch sử 90 ngày chỉ cần lưu lúc đổi
trạng thái, không cần lưu mọi heartbeat.

## Các phương án đã cân nhắc
A. Redis cho trạng thái hiện tại + batch job diff mỗi 30s cho lịch sử
B. Kafka + stream processing (state store) cho cả hai luồng

## Quyết định
Chọn phương án A cho giai đoạn 1.

## Vì sao loại phương án B (ở giai đoạn này)
Yêu cầu nghiệp vụ là "vài giây", không phải tức thời tuyệt đối. Redis
đáp ứng đủ tải kể cả ở đỉnh retry storm (43.000/s, còn cách trần Redis
~100.000/s khoảng 2 lần) với độ phức tạp vận hành thấp hơn nhiều so với
việc vận hành thêm một hệ thống stream processing. B là kiến trúc đúng
cho một yêu cầu khắt khe hơn yêu cầu hiện tại — chọn B bây giờ là trả
chi phí vận hành cho độ trễ mà nghiệp vụ chưa cần.

## Hệ quả
Tích cực:  triển khai nhanh hơn B đáng kể, dùng năng lực vận hành sẵn có
Tiêu cực:  độ trễ phát hiện đổi trạng thái tối đa 30s (chu kỳ batch diff)
Nợ kỹ thuật chấp nhận: có — chấp nhận độ trễ 30s đổi lấy tốc độ triển khai
  và chi phí vận hành thấp hơn. Điều kiện trả nợ: xem mục dưới.

## Khả năng đảo ngược
Type 2 — đảo ngược được, có đường nâng cấp rõ ràng sang B mà không phải
thiết kế lại luồng heartbeat vào Redis.

## Cần xem lại khi
Độ trễ 30s không còn chấp nhận được về mặt nghiệp vụ, HOẶC số thiết bị
tăng vượt 6–8 triệu (đỉnh retry storm khi đó có thể tiệm cận trần Redis),
HOẶC xảy ra sự cố Redis cho thấy cấu hình cluster hiện tại không đủ.

Tự kiểm tra trước khi tự làm bài đầu tiên

  1. Bạn có tách được luồng "cần nhanh" và luồng "cần bền vững" thành hai thiết kế khác nhau chưa, hay đang gộp chung vào một kho dữ liệu?
  2. Bạn có tính napkin math cho cả điều kiện bình thường lẫn điều kiện đỉnh sau sự cố chưa? Phần lớn lỗi thiết kế nằm ở việc chỉ tính điều kiện bình thường.
  3. Nếu panel hỏi "vì sao không chọn phương án phức tạp hơn", bạn có câu trả lời gắn với con số cụ thể, hay chỉ nói "đơn giản hơn thì tốt hơn"?
  4. ADR của bạn có ghi rõ điều kiện để xem lại quyết định không, hay chỉ dừng ở việc chọn phương án?
Câu hỏi tự kiểm tra thêm: Nếu yêu cầu đổi từ p95 dưới 5 giây thành dưới 1 giây, quyết định nào phải thay đổi? Nếu Redis mất 10 phút, dữ liệu nào được phép mất? Nếu số thiết bị tăng gấp 5 lần, bottleneck nào chạm trần trước?