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.

Dễ hiểu cho người mới Tập trung vào “mảnh ghép” phổ biến Có checklist + sơ đồ tổng quát

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.

1) Hiểu bài toán & luồng chính Ai dùng? tải lớn ở đâu? Mốc nhanh/chậm? dữ liệu nào “chuẩn”? 2) Chọn “hot paths” Phần quan trọng nhất và chạy nhiều nhất 3) Giữ thiết kế… boring Ít thành phần nhất có thể Tránh “ngầu” để bù lỗi gốc State core • 1 nơi “giữ sự thật” (DB) • Hạn chế nhiều service cùng viết • Schema + index + replica Tách nhanh/chậm • Request trả nhanh • Việc nặng → background job • Throttle để tránh spike Tăng tốc đúng cách • Cache (cẩn thận “stale”) • Events khi cần fan-out • Push/Pull tuỳ số client Quan sát (Observability) Log “unhappy paths”, theo dõi p95/p99, CPU/mem, queue size Tự cứu khi có sự cố Killswitch • circuit breaker • idempotency • fail open/closed

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…

Một câu định nghĩa “nghe là nhớ” System design là nghệ thuật lắp ráp những khối “bình dân” thành một hệ thống chạy lâu mà ít drama.

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.

Mẹo rất thực dụng Giảm số lượng thành phần stateful. Và nếu được, gom logic ghi dữ liệu về một nơi chịu trách nhiệm chính (thay vì 5 service cùng viết vào một bảng rồi… cầu trời).

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).

Cảnh báo thân thiện Đừng cache để che một query “dở”. Trước khi cache, hãy tự hỏi: “Mình đã thêm index đúng chưa?”

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.

Một quan sát thú vị Người mới thường muốn cache mọi thứ. Người có kinh nghiệm thường cache… ít lại. Lý do không phải vì họ ghét tốc độ — mà vì họ ghét “state bí ẩ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…).

Đừng lạm dụng Nhiều lúc gọi API trực tiếp giữa hai service lại dễ hiểu hơn (log tập trung, luồng rõ, nhìn thấy response ngay). Event hợp khi: người phát không cần biết ai tiêu thụ, hoặc lưu lượng lớn và không quá “gấp từng giây”.

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.

Gợi ý thực tế Khi bắt đầu một thiết kế, hãy tự hỏi: “Nếu chỉ được làm đúng 2 phần, mình phải làm đúng phần nào để hệ không sập?” Câu trả lời thường nằm ở hot paths.

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.

Một câu nói khó chịu nhưng đúng Trung bình (average) đôi khi là cái bẫy. Bạn cần nhìn phần “đuôi” để biết ai đang đau.

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.

Điểm mấu chốt “Thất bại” là chắc chắn sẽ có. Thiết kế tốt là khiến thất bại nhỏ lại, chậm lại, và dễ kiểm soát.

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ý
Kết bài (ngắn gọn nhưng đau) Thiết kế hệ thống tốt hiếm khi “khoe kỹ thuật”. Nó giống hệ thống ống nước: chạy êm lâu ngày thì chẳng ai nhớ tới, nhưng lúc làm sai thì… cả nhà biết ngay.

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/