Cách thiết kế hệ thống backend tốt
Mình gặp rất nhiều lời khuyên system design kiểu… “nghe ngầu là chính”. Bài này là phiên bản giải thích theo lối thực dụng: ít màu mè, nhiều thứ bạn dùng được ngay khi xây sản phẩm.
Mục lục
- Sơ đồ tổng quát
- System design là gì (theo cách dễ hình dung)
- Nhận biết thiết kế tốt
- State: thứ làm bạn mất ngủ
- Database: làm đúng từ đầu là nhẹ người
- Nhanh vs chậm: tách việc ra cho “đỡ nghẹt”
- Cache: cứu tốc độ, nhưng thêm rủi ro
- Events: dùng khi cần “phát sóng”
- Push vs Pull: dữ liệu nên tự đến hay phải đi xin?
- Hot paths: chỗ phải nghiêm túc nhất
- Logging & metrics: nhìn được thì mới sửa được
- Killswitch, retry, và thất bại “có văn hoá”
- Checklist áp dụng nhanh
Sơ đồ tổng quát: “đường đi” của một thiết kế hệ thống tốt
Đây là bản đồ tư duy để bạn không bị ngợp. Cứ đi từ trên xuống: chọn đúng chỗ quan trọng, dùng đúng mảnh ghép, rồi bọc hệ thống bằng quan sát + cơ chế tự cứu.
Ghi chú: Sơ đồ này cố tình “đơn giản hoá”. Thiết kế tốt thường trông rất bình thường — và đó là dấu hiệu tốt.
System design là gì (theo cách dễ hình dung)
Nếu software design là cách bạn ghép các dòng code lại (hàm, class, module…), thì system design là cách bạn ghép các khối dịch vụ lại: app server, database, cache, queue, event hub, proxy…
Nhận biết thiết kế tốt: thường… không gây ấn tượng
Thiết kế tốt hay có cảm giác: “Ủa sao cái này dễ hơn mình tưởng?” hoặc “Phần này mình hiếm khi phải nghĩ tới”. Ngược lại, hệ thống trông quá “ngầu” (quá nhiều cơ chế phức tạp) đôi khi là cách che một quyết định gốc sai.
Dấu hiệu tốt
- Chạy ổn trong thời gian dài
- Dễ debug vì luồng rõ
- Ít thành phần nhưng đúng chỗ
Dấu hiệu đáng nghi
- “Cần” quá nhiều mẹo phức tạp để sống
- Không ai dám đụng vì sợ gãy
- Thêm tính năng nhỏ mà phải chỉnh cả 5 nơi
Nguyên tắc sống còn
- Hệ thống phức tạp bền vững thường tiến hoá từ hệ thống đơn giản bền vững
- Xây phức tạp ngay từ đầu thường là tự làm khó
State: thứ làm bạn mất ngủ
Thứ khó nhất trong mọi thiết kế là state (trạng thái): dữ liệu được lưu lại qua thời gian, sống “ngoài” vòng đời request/response. Cái gì “đụng DB” thường là stateful. Còn một dịch vụ chỉ nhận input rồi trả output (không lưu gì lâu dài) là gần với stateless.
Có ngoại lệ (đọc nhanh có thể đọc trực tiếp DB thay vì gọi service nội bộ), nhưng mặc định “ít điểm ghi” sẽ dễ sống hơn.
Database: làm đúng từ đầu là nhẹ người
1) Schema vừa đủ “mềm”, nhưng vẫn đọc được
Schema nên đủ linh hoạt để không vỡ khi sản phẩm phát triển, nhưng nếu bạn “mềm quá” (ví dụ nhét mọi thứ vào một cột JSON hoặc thiết kế kiểu key/value chung chung), bạn đẩy độ phức tạp sang code và tự mua thêm rủi ro hiệu năng.
2) Index: đừng tiếc, nhưng cũng đừng spam
Nếu bảng không còn “vài dòng cho vui”, hãy nghĩ sớm về index theo đúng query phổ biến. Nhưng mỗi index đều có giá (write nặng hơn), nên chọn cái nào thực sự phục vụ đường đi chính.
3) DB thường là nút thắt cổ chai (và hay bị “oan”)
Nhiều hệ thống chậm không phải vì app server yếu, mà vì app đang bắn quá nhiều query—thậm chí theo chuỗi, tuần tự. Khi đã query thì hãy để DB làm đúng việc DB giỏi: JOIN, lọc, gom nhóm… Đừng vô tình tạo “N+1 queries” từ ORM (lặp trong vòng lặp mà không hay).
4) Replicas & spike
Một setup rất phổ biến: 1 node ghi + nhiều node đọc. Đẩy càng nhiều đọc sang replica càng tốt, trừ khi bạn cực kỳ nhạy với độ trễ đồng bộ (replication lag). Với spike truy vấn (đặc biệt write/transaction), hãy nghĩ tới throttling để DB không bị “lụt”.
Nhanh vs chậm: tách việc ra cho “đỡ nghẹt”
Có việc người dùng cần thấy ngay (vài trăm ms). Có việc bản chất chậm (xử lý file lớn, tạo report…). Pattern kinh điển: làm phần tối thiểu để người dùng “thấy có tiến triển”, phần còn lại quăng vào nền.
Background jobs gồm gì?
- Queue (hay gặp: Redis)
- Worker/job runner kéo job về chạy
Job chạy theo lịch
- Dọn dẹp định kỳ
- Tổng hợp số liệu theo ngày/tuần
Job “để lâu” (1 tháng…)
- Đôi khi hợp hơn khi lưu vào DB (có scheduled_at)
- Một cron/job mỗi ngày quét và thực thi
Cache: cứu tốc độ, nhưng thêm rủi ro
Cache thường xuất hiện khi một thao tác chậm vì nó phải làm lại cùng một việc cho nhiều người (ví dụ gọi API giá hiện tại, tính toán nặng…). Cache giúp nhanh hơn, nhưng đổi lại nó là một “nguồn state” nữa: có thể bị stale, lệch với sự thật, hoặc gây bug khó đoán.
Ngoài cache kiểu Redis/Memcached, còn một trick “rất đời”: nếu kết quả quá to không nhét vừa cache nhanh, hãy tạo file kết quả và lưu vào object storage (S3/Blob…), rồi phục vụ file đó như một cache bền vững.
Events: dùng khi cần “phát sóng”
Event hub (một ví dụ quen: Kafka) giống queue, nhưng thay vì “hãy chạy job X”, bạn gửi “sự kiện Y vừa xảy ra”. Nhiều service có thể nghe cùng một sự kiện và làm phần việc của họ (gửi email chào mừng, kiểm tra gian lận, tạo tài nguyên…).
Push vs Pull: dữ liệu nên tự đến hay phải đi xin?
Pull là kiểu truyền thống: client hỏi thì server trả. Dễ làm, dễ hiểu. Nhưng nếu client hỏi liên tục để “refresh” (như inbox), bạn sẽ trả đi trả lại một đống dữ liệu giống nhau.
Push là kiểu “có gì mới thì báo”: client đăng ký, server chủ động đẩy cập nhật khi dữ liệu đổi. Trải nghiệm mượt hơn, nhưng để push cho rất nhiều client thì hệ thống phía sau phải được tổ chức tốt.
Khi push rất hợp
- Nhiều service nội bộ cùng cần 1 dữ liệu
- Dữ liệu đổi không quá thường xuyên
Khi pull hợp lý
- Client ít, nhu cầu đơn giản
- Ưu tiên dễ vận hành
Scale cho “rất nhiều client”
- Push: thường đi qua queue + nhiều processor
- Pull: thường có lớp cache server/read replica phía trước
Hot paths: chỗ phải nghiêm túc nhất
Thiết kế hệ thống dễ bị “ngợp” vì có quá nhiều tương tác. Cách gỡ: tập trung vào hot paths — phần quan trọng nhất và phần ăn nhiều lưu lượng nhất. Những chỗ này ít lựa chọn hơn, và nếu sai thì “nổ” cũng to hơn.
Logging & metrics: nhìn được thì mới sửa được
Mình cực đồng ý với nguyên tắc: log mạnh ở unhappy paths. Tức là các nhánh trả lỗi/không thoả điều kiện (422, từ chối billing, chặn abuse…), hãy log rõ “vì sao” (điều kiện nào kích hoạt).
Và tối thiểu bạn nên thấy được: CPU/memory, độ dài queue, thời gian xử lý request/job. Với request time, hãy nhìn cả p95/p99 (những request chậm nhất), vì đôi khi “vài request chậm” lại rơi đúng vào nhóm khách hàng lớn nhất.
Killswitch, retry, và thất bại “có văn hoá”
1) Killswitch: tắt nhanh hơn deploy
Một “công tắc khẩn cấp” giúp bạn tắt một luồng tự động đang chạy sai mà không cần chờ deploy. Trong sự cố, vài phút khác biệt là cả thế giới.
2) Retry không phải thần dược
Retry mù có thể làm hệ kia chết nhanh hơn (vì bị spam thêm tải). Với call lớn, nên có circuit breaker: lỗi nhiều quá thì tạm ngừng gọi để hệ kia hồi phục.
3) Ghi (write) mà bị lỗi 5xx: “đã ghi hay chưa?”
Đây là nơi idempotency key phát huy: mỗi request ghi mang một mã duy nhất, phía nhận lưu mã đó để tránh xử lý lại cùng một thao tác khi client retry.
4) Fail open vs fail closed
Khi một phần phụ trợ chết (ví dụ rate limit dùng Redis), bạn cần quyết định: fail open (cho qua) hay fail closed (chặn). Thực dụng: rate limit thường fail open để tránh biến lỗi phụ thành sự cố lớn; auth thì nên fail closed vì sai một cái là rò dữ liệu.
Checklist áp dụng nhanh (copy về dùng trong review)
| Mục | Câu hỏi phải trả lời | Dấu hiệu “ổn” |
|---|---|---|
| Hot paths | Chỗ nào chạy nhiều nhất và quan trọng nhất? | Thiết kế ưu tiên rõ ràng, không bị loãng |
| State | State nằm ở đâu? Có bao nhiêu nơi ghi? | Ít điểm ghi, trách nhiệm rõ |
| DB | Schema có đọc được không? Index theo query chính chưa? | Query hợp lý, tránh N+1, có kế hoạch replica |
| Fast vs slow | Việc nào phải nhanh? Việc nào có thể làm nền? | Background job cho việc nặng, có throttle |
| Cache | Cache vì lý do gì? Có nguy cơ stale không? | Cache tối thiểu, có TTL/invalidate rõ |
| Events | Cần fan-out thật không? Hay gọi API là đủ? | Event dùng đúng chỗ, không biến hệ thành “mù log” |
| Observability | Khi lỗi, mình có biết “lỗi vì sao” không? | Log unhappy path, theo dõi p95/p99, queue size |
| Graceful failure | Khi component chết, hệ làm gì? | Killswitch, circuit breaker, idempotency, fail open/closed hợp lý |
Tham khảo: Bài gốc của Sean Goedecke (link ở dưới). Đây là bản diễn giải lại bằng tiếng Việt để học và áp dụng nhanh.
Nguồn:
https://www.seangoedecke.com/good-system-design/
