Ba bài trước làm việc trong phạm vi một chương trình. Từ bài này, đơn vị thiết kế là cả một máy chủ, và câu hỏi đổi khác: một máy có đủ không, khi nào cần hai, dữ liệu để ở đâu, chuyện gì xảy ra khi một máy chết.
System design hay được trình bày bằng sơ đồ: ô vuông "load balancer", ô vuông "cache", mũi tên tới "database". Sơ đồ dễ vẽ, nhưng nó không trả lời được câu hỏi quan trọng nhất: có cần những ô vuông đó không? Câu trả lời nằm ở con số. Bài này xây công cụ ước lượng trước, rồi mới đi qua từng khối.
Lộ trình: bảng độ trễ (phần lớn đo ở seri Máy tính từ trong ra ngoài), ước lượng nhanh, định luật Little và vì sao không nên chạy máy gần đầy tải, mở rộng theo chiều dọc và chiều ngang, cache, database (chỉ mục, nhân bản, chia mảnh), tính nhất quán, hàng đợi, và xử lý lỗi.
Mọi con số trong bài đều đo trên máy thật, với Python 3.12 và SQLite, chạy đơn luồng, trừ những con số được ghi rõ là ước lượng.
Bảng độ trễ
Lập trình viên nào cũng nên thuộc lòng thứ tự độ lớn của những con số này. Phần lớn được đo trong các bài trước của trang:
| Thao tác | Thời gian |
|---|---|
| Đọc từ cache L1 | 0,87 ns |
| Đọc từ RAM (truy cập ngẫu nhiên) | 73 ns |
| Tra một khóa trong dict Python | ~30 ns |
Một lời gọi hệ thống (write) |
345 ns |
| Đọc một dòng SQLite theo khóa chính | 1,4 µs |
Tạo một tiến trình (fork) |
267 µs |
| Quét 200.000 dòng SQLite không có chỉ mục | 2,8 ms |
| Một vòng đi về qua mạng tới máy chủ gần nhất của Cloudflare | 55–99 ms |
| Mở kết nối HTTPS mới (TCP + TLS) | ~145 ms |
Từ dòng đầu tới dòng cuối cách nhau 8 bậc độ lớn, một trăm triệu lần. Hệ quả cho thiết kế:
- Một vòng qua mạng đắt bằng hàng chục nghìn lần đọc database có chỉ mục. Một trang mà gọi 20 dịch vụ khác nối tiếp nhau sẽ chậm vì mạng, không vì code.
- Bộ nhớ trong tiến trình nhanh hơn database cùng máy hàng chục lần, và database cùng máy nhanh hơn bất cứ thứ gì qua mạng.
- Truy vấn không có chỉ mục có thể biến một thao tác micro giây thành mili giây.
Ước lượng nhanh
Trước khi chọn kiến trúc, ước lượng tải. Ví dụ một ứng dụng có 1 triệu người dùng hoạt động mỗi ngày, mỗi người tạo 20 request:
20 triệu request / ngày ÷ 86.400 giây ≈ 230 request/giây trung bình
giờ cao điểm thường gấp 3–5 lần ≈ 700–1.200 request/giây
(Đây là ước lượng, không phải số đo; mục đích là biết mình đang ở bậc 100, 10.000 hay 1.000.000 request mỗi giây.) Ở bậc vài trăm tới một nghìn request mỗi giây, một máy chủ tốt chạy một ứng dụng web viết cẩn thận thường là đủ, miễn là mỗi request không làm gì quá nặng. Phần lớn hệ thống trên đời không bao giờ vượt quá bậc này. Biết điều đó giúp tránh xây một cụm 20 máy cho một tải mà một máy gánh được.
Một quy tắc khác hay dùng: ghi ra tỉ lệ đọc và ghi. Một trang tin, một blog, một dịch vụ rút gọn link đọc nhiều hơn ghi hàng trăm lần, và kiến trúc của chúng xoay quanh việc làm cho phần đọc rẻ.
Định luật Little và vì sao không chạy máy gần đầy
Định luật Little: số request đang nằm trong hệ thống bằng tốc độ request tới nhân với thời gian mỗi request ở lại:
L = λ × W
230 request/giây × 0,05 giây = 11,5 request đang xử lý cùng lúc
Định luật này đúng với mọi hệ thống ổn định, bất kể bên trong ra sao, và nó cho biết cần bao nhiêu kết nối database, bao nhiêu worker, bao nhiêu bộ nhớ cho các request đang chờ.
Câu hỏi tiếp theo: một máy chủ xử lý mỗi request mất trung bình 10 ms, vậy nó gánh được 100 request mỗi giây. Có nên cho nó chạy ở 95 request mỗi giây không? Mô phỏng một hàng đợi đơn giản (request tới ngẫu nhiên, thời gian xử lý ngẫu nhiên quanh 10 ms, một máy xử lý lần lượt), 200.000 request ở mỗi mức tải:
| Mức tải | Thời gian trung bình (đợi + xử lý) | Lý thuyết | p99 |
|---|---|---|---|
| 50% | 20,0 ms | 20,0 ms | 91 ms |
| 70% | 33,2 ms | 33,3 ms | 153 ms |
| 80% | 50,5 ms | 50,0 ms | 225 ms |
| 90% | 96,3 ms | 100,0 ms | 416 ms |
| 95% | 203,4 ms | 200,0 ms | 939 ms |
Cột "lý thuyết" là công thức của hàng đợi M/M/1: thời gian = thời gian xử lý ÷ (1 − mức tải). Mô phỏng khớp gần như tuyệt đối. Điều đáng sợ nằm ở hình dạng: từ 50% lên 90% tải, thời gian gấp gần 5 lần; từ 90% lên 95%, nó gấp đôi thêm lần nữa, và 1% request chậm nhất mất gần một giây. Lúc gần đầy, chỉ một đợt tải tăng nhẹ cũng làm hàng đợi phình ra.
Đây là lý do các hệ thống thật chạy ở khoảng 50–70% công suất lúc cao điểm, và lý do "CPU mới 80% thôi mà" là một câu đáng lo.
Mở rộng theo chiều dọc và chiều ngang
Khi một máy không đủ, có hai hướng:
- Chiều dọc (scale up): máy to hơn, nhiều nhân hơn, nhiều RAM hơn. Đơn giản nhất, không phải sửa code, nhưng có giới hạn trên và một máy vẫn là một điểm chết duy nhất.
- Chiều ngang (scale out): nhiều máy giống nhau, phía trước là một bộ cân bằng tải (load balancer) chia request. Gần như không có giới hạn, và một máy chết thì các máy khác gánh thay.
Chiều ngang chỉ dễ khi máy chủ ứng dụng không giữ trạng thái (stateless): mọi thứ cần nhớ giữa các request (phiên đăng nhập, giỏ hàng) nằm trong database hay cache dùng chung, không nằm trong bộ nhớ của một máy. Khi đó request nào tới máy nào cũng được, và thêm máy là thêm công suất. Phần có trạng thái, database, mới là phần khó mở rộng, và phần lớn system design là về nó.
Cache
Cache là giữ một bản sao của dữ liệu ở chỗ gần hơn, rẻ hơn để đọc. Nó hiệu quả vì truy cập trong đời thực không đều: một ít khóa được hỏi rất nhiều, phần lớn khóa hiếm khi được hỏi. Phân bố hay gặp là Zipf: khóa phổ biến thứ k được hỏi với tần suất tỉ lệ 1/k.
Mô phỏng 300.000 request trên 100.000 khóa theo phân bố Zipf, với một cache LRU (bỏ đi khóa lâu nhất chưa được dùng khi đầy) ở bốn kích thước:
| Kích thước cache | Phần trăm số khóa | Tỉ lệ trúng (hit rate) |
|---|---|---|
| 100 khóa | 0,1% | 29,0% |
| 1.000 khóa | 1% | 50,7% |
| 10.000 khóa | 10% | 72,9% |
| 50.000 khóa | 50% | 84,0% |
Giữ 1% số khóa đã chặn được một nửa số request. Đó là lý do cache đáng giá đến vậy: một lớp cache nhỏ trước database có thể giảm một nửa tải của nó. Nhưng cũng thấy lợi ích giảm dần: tăng cache từ 10% lên 50% số khóa, gấp 5 lần bộ nhớ, chỉ thêm 11 điểm phần trăm.
Cache nằm ở nhiều tầng: trình duyệt, CDN (máy chủ ở rìa mạng, gần người dùng), cache trong ứng dụng, cache trước database (Redis, Memcached), và chính bộ nhớ đệm của database. Trang này dùng hai tầng đầu bằng header Cache-Control:
# /static/...?v=<content hash>: the URL changes whenever the file does, so it can be cached forever.
STATIC_VERSIONED = "public, max-age=31536000, immutable"
# HTML page shells (no per-user or live data in them): short browser cache, longer edge cache.
PAGE = "public, max-age=60, s-maxage=300, stale-while-revalidate=600"
Phần khó của cache là làm mất hiệu lực (invalidation): khi dữ liệu gốc đổi, bản sao phải đổi theo. Có ba cách hay dùng: đặt thời hạn (TTL, như max-age=60: chấp nhận cũ tối đa 60 giây), xóa chủ động khi ghi, và đưa phiên bản vào khóa (?v=<content hash> ở trên: file đổi thì URL đổi, bản cũ không bao giờ phải xóa, chỉ không còn ai hỏi tới).
Database: chỉ mục
Một bảng 200.000 người dùng trong SQLite, tìm theo email, trước và sau khi tạo chỉ mục:
db.execute("select id from users where email = ?", (email,))
# trước: SCAN users
db.execute("create index ix_email on users(email)")
# sau: SEARCH users USING COVERING INDEX ix_email (email=?)
| Mỗi truy vấn | |
|---|---|
| Không có chỉ mục (quét cả bảng) | 2,80 ms |
| Có chỉ mục (B-tree) | 0,6 µs |
Nhanh hơn khoảng 4.700 lần, và tạo chỉ mục cho 200.000 dòng chỉ mất 21 ms. Quét bảng tăng tuyến tính theo số dòng; chỉ mục B-tree chỉ tăng theo log của số dòng, nên ở 20 triệu dòng khoảng cách còn lớn hơn nhiều. EXPLAIN QUERY PLAN (hay EXPLAIN trong Postgres, MySQL) là công cụ đầu tiên khi một truy vấn chậm: chữ SCAN trên một bảng lớn gần như luôn là vấn đề.
Chỉ mục không miễn phí: mỗi lần ghi phải cập nhật cả bảng lẫn mọi chỉ mục của nó, và chỉ mục chiếm chỗ. Đánh chỉ mục cho những cột thật sự được tìm, không phải mọi cột.
Database: nhân bản và chia mảnh
Khi một database không đủ:
- Nhân bản (replication): một máy chính nhận mọi lệnh ghi, gửi các thay đổi sang vài máy phụ, và các lệnh đọc chia ra các máy phụ. Hợp với hệ thống đọc nhiều. Cái giá là độ trễ nhân bản: máy phụ có thể chậm hơn máy chính vài mili giây tới vài giây, nên người dùng vừa sửa hồ sơ xong, đọc từ máy phụ, có thể thấy bản cũ. Cách xử lý hay gặp: đọc những gì chính người dùng vừa ghi từ máy chính.
- Chia mảnh (sharding): chia dữ liệu theo một khóa (ví dụ mã người dùng) ra nhiều database, mỗi database giữ một phần. Mở rộng được cả phần ghi, nhưng truy vấn trải nhiều mảnh, giao dịch trải nhiều mảnh, và chia lại khi thêm mảnh đều trở nên khó. Chia mảnh là bước nên đi sau cùng, khi mọi cách khác đã hết.
Tính nhất quán
Khi dữ liệu có nhiều bản sao trên nhiều máy, và mạng giữa chúng có thể đứt, hệ thống phải chọn. Định lý CAP nói: khi mạng bị chia cắt (P), hệ thống chỉ giữ được một trong hai, nhất quán (C, mọi người đọc thấy cùng một giá trị mới nhất) hoặc sẵn sàng (A, mọi request đều được trả lời). Ngân hàng chọn nhất quán: thà từ chối giao dịch còn hơn cho rút tiền hai lần. Một mạng xã hội chọn sẵn sàng: số lượt thích hiện chậm vài giây không sao.
Ngay cả khi mạng bình thường, vẫn có một sự đánh đổi giữa độ trễ và tính nhất quán: muốn mọi bản sao xác nhận trước khi trả lời thì phải chờ máy chậm nhất. Phần lớn hệ thống trộn cả hai: nhất quán chặt cho tiền và tồn kho, "nhất quán dần dần" (eventual consistency) cho bộ đếm, nguồn tin, thống kê.
Hàng đợi
Không phải việc gì cũng cần làm xong trước khi trả lời người dùng. Gửi email, tạo ảnh thu nhỏ, ghi thống kê có thể đưa vào một hàng đợi (message queue) cho worker làm sau. Lợi ích:
- Request nhanh hơn: người dùng không phải chờ máy chủ SMTP. Trang này làm đúng như vậy ở quy mô nhỏ, với
BackgroundTasks(gặp ở bài trước): "send in the background, so a slow SMTP server never holds up the page". - Hấp thụ đợt tải: 10.000 việc tới cùng lúc nằm chờ trong hàng thay vì đánh sập worker.
- Tách rời: bên gửi không cần biết bên nhận là ai, có bao nhiêu (Observer ở quy mô hệ thống, gọi là pub/sub).
Cái giá: kết quả tới chậm hơn, và phải tính tới việc một tin nhắn được xử lý hai lần (worker chết giữa chừng, tin nhắn được gửi lại). Vì vậy việc xử lý nên idempotent: làm hai lần cho cùng kết quả như làm một lần.
Xử lý lỗi
Ở quy mô nhiều máy, lỗi không còn là ngoại lệ mà là điều chắc chắn xảy ra. Vài quy tắc tối thiểu:
- Luôn có timeout. Một lời gọi không có timeout có thể treo mãi và giữ một worker theo. Code của trang này đặt timeout cho mọi lời gọi ra ngoài: 10 giây cho nhà cung cấp OAuth, 20 giây cho SMTP.
- Thử lại có giới hạn, có giãn cách, có ngẫu nhiên. Thử lại ngay lập tức, không giới hạn, khiến một dịch vụ đang quá tải nhận thêm request đúng lúc nó yếu nhất. Giãn cách tăng dần (exponential backoff) cộng một chút ngẫu nhiên (jitter) để hàng nghìn client không cùng thử lại một lúc.
- Idempotency key cho những thao tác không được làm hai lần (trừ tiền, tạo đơn): client gửi kèm một mã duy nhất, máy chủ nhớ mã đã xử lý và trả lại kết quả cũ nếu gặp lại.
- Giảm cấp nhẹ nhàng (graceful degradation): khi phần gợi ý sản phẩm chết, trang sản phẩm vẫn hiện, chỉ thiếu phần gợi ý.
Tóm tắt
| Khối | Con số / ý chính |
|---|---|
| Bảng độ trễ | 8 bậc độ lớn từ L1 (0,87 ns) tới một kết nối HTTPS mới (~145 ms) |
| Ước lượng | 1 triệu người dùng × 20 request ≈ 230 request/giây, cao điểm ~1.000 |
| Định luật Little | Số request đang xử lý = tốc độ tới × thời gian ở lại |
| Mức tải | 50% → 20 ms, 90% → 96 ms, 95% → 203 ms (p99 939 ms) |
| Mở rộng | Máy ứng dụng không trạng thái; phần khó là database |
| Cache | 1% số khóa chặn ~51% request; khó nhất là làm mất hiệu lực |
| Chỉ mục | 2,80 ms → 0,6 µs (~4.700 lần) trên 200.000 dòng |
| Nhân bản / chia mảnh | Đọc nhiều thì nhân bản; chia mảnh là bước cuối |
| Nhất quán | CAP: khi mạng đứt, chọn nhất quán hoặc sẵn sàng |
| Hàng đợi | Request nhanh, hấp thụ đợt tải; xử lý phải idempotent |
| Lỗi | Timeout, thử lại có backoff + jitter, idempotency key |
Các khối đã đủ. Bài cuối của seri ghép chúng lại thành một hệ thống nhiều service, với một điều kiện của thời nay: phần lớn code do agent AI viết. Ví dụ là một hệ thống bán vé concert: chọn ghế, giữ chỗ, thanh toán, phát vé, thông báo, soát vé và phòng chờ ngày mở bán. Con người phải đưa ra những gì, chia service ra sao, đo xem cách viết nào bán trùng ghế, và viết tài liệu thế nào để AI đọc hiểu rồi mở rộng tính năng.