Mười bảy bài trước, mỗi bài giải một vấn đề riêng lẻ. Bài này ghép chúng lại: nhận một đề bài, ước lượng, chọn kiến trúc tối giản, chạy thật, đo, tìm nút cổ chai, vá, rồi đo lại. Phần giá trị nhất không phải kiến trúc cuối cùng mà là bảng số liệu trước/sau của từng bước — vì nó cho thấy bước nào đáng công và bước nào chỉ thêm phức tạp.
Kết quả tổng: từ 6.115 lên 25.786 request/giây (4,2 lần) và p99 từ 14,71 xuống 1,28 ms (11,5 lần). Nhưng hai con số đáng nhớ hơn nằm ở giữa. Cache — tối ưu ai cũng nghĩ tới đầu tiên — xoá được 99% truy vấn đọc nhưng chỉ tăng throughput 16%, vì nút cổ chai thật nằm ở đường ghi. Và read replica, một tối ưu hoàn toàn hợp lý trên giấy, cho đúng 0% cải thiện — thậm chí kém đi một chút.
app/shortener.js, Node http thuần, 0 dependency) giới hạn 1 CPU và 256 MB;
PostgreSQL 18 primary + read replica, Redis 7; bộ đo tải loadgen.js, 20 kết nối,
closed-loop, warm-up 3 giây, đo 12 giây, không gian khoá 1.000 link.Mỗi phiên bản chỉ đổi đúng một tham số so với phiên bản trước. Đây không phải chi tiết nhỏ mà là điều kiện để kết luận có nghĩa: đổi hai thứ cùng lúc thì bạn chỉ biết "có gì đó tốt hơn", không biết là thứ nào — và lần sau sẽ lặp lại cả hai dù chỉ một cái có tác dụng.
18.1 Nhận đề và làm rõ yêu cầu
Đề bài: một hệ thống rút gọn URL kèm thống kê lượt click. Chọn bài này có chủ đích, vì nó gói ba dạng tải rất khác nhau vào một hệ thống nhỏ đủ để đọc hết trong một buổi:
| Đường đi | Tính chất tải | Yêu cầu nhất quán |
|---|---|---|
| Chuyển hướng — người dùng bấm link | Đọc cực nặng, tỉ lệ đọc:ghi có thể 1000:1 | Mạnh vừa phải — link sai là dẫn nhầm chỗ |
| Tạo link | Ghi, lưu lượng thấp | Mạnh — mã không được trùng |
| Thống kê click | Ghi nặng, sinh ra từ mỗi lượt chuyển hướng | Yếu — trễ vài giây không ai chết |
Cột cuối là cột quyết định toàn bộ thiết kế, và nó chỉ lộ ra khi ta chịu hỏi. Ba đường đi có ba yêu cầu nhất quán khác nhau, nên chúng xứng đáng ba cách xử lý khác nhau — và như phần đo sẽ cho thấy, chính chỗ này là nơi phần lớn hiệu năng nằm.
Trước khi vẽ bất cứ thứ gì, đây là những câu phải hỏi. Không có câu trả lời thì mọi lựa chọn sau đó là phỏng đoán:
- DAU và RPS đỉnh? Quyết định có cần scale ngang hay không.
- Tỉ lệ đọc:ghi? Quyết định cache và replica có đáng không.
- Link sống bao lâu? Quyết định dung lượng và chiến lược dọn dẹp.
- Thống kê cần chính xác tới đâu? Quyết định được phép bất đồng bộ hay không.
- SLO là gì? Quyết định khi nào thì gọi là "đủ tốt" và dừng tối ưu.
Thứ tự đúng là: hỏi số → ước lượng → thiết kế bản tối giản → đo → chỉ thêm thành phần khi số liệu chỉ ra nhu cầu. Phần 18.4 sẽ cho thấy một tối ưu nghe rất hợp lý mà đo ra không cải thiện gì.
18.2 Ước lượng và thiết kế v1
Giả sử con số nhận được là 10 triệu lượt chuyển hướng mỗi ngày và 100 nghìn link mới mỗi ngày. Back-of-envelope theo đúng cách của Bài 1:
| Đại lượng | Phép tính | Kết quả |
|---|---|---|
| RPS trung bình (đọc) | 10.000.000 ÷ 86.400 | ~116 req/s |
| RPS đỉnh (hệ số 3) | 116 × 3 | ~350 req/s |
| RPS ghi (tạo link) | 100.000 ÷ 86.400 | ~1,2 req/s |
| Tỉ lệ đọc:ghi | 116 ÷ 1,2 | ~100:1 |
| Dung lượng link sau 5 năm | 100k/ngày × 365 × 5 × ~200 byte | ~36 GB |
| Dung lượng bản ghi click sau 5 năm | 10 triệu/ngày × 365 × 5 × ~50 byte | ~912 GB |
Hai dòng cuối đã nói cho ta một điều quan trọng trước khi viết dòng code nào: bảng click lớn gấp 25 lần bảng link. Bài toán thật ở đây không phải lưu link mà là xử lý click — và đó là gợi ý đầu tiên rằng đường ghi analytics xứng đáng được đối xử khác.
Còn 350 req/s đỉnh thì sao? Nó rất nhỏ. Một server duy nhất thừa sức. Vì vậy v1 được cố ý thiết
kế tối giản: một service, một PostgreSQL, không cache, không hàng đợi. Ghi click thẳng vào bảng
clicks ngay trong lượt chuyển hướng.
Phần cuối mới là phần đáng giá nhất về sau. Sáu tháng nữa sẽ có người hỏi "sao không dùng X?", và nếu không có ADR thì cả đội sẽ tranh luận lại từ đầu — với ít thông tin hơn lúc quyết định, vì bối cảnh đã phai. ADR đầu tiên của dự án này chỉ có một câu đáng nhớ: "350 req/s đỉnh không cần kiến trúc phân tán; bắt đầu bằng một server và đo."
18.3 Dựng thật và đo baseline
v1 chạy được. Bây giờ điều quan trọng nhất và cũng hay bị bỏ qua nhất: đo trước khi tối ưu. Không có baseline thì mọi cải thiện sau đó là cảm giác, và bạn không có cách nào biết một thay đổi có đáng giữ hay không.
$ ./tools/capstone-test.sh v1
v1 rps= 6115.1 p50= 2.91 p95= 4.93 p99= 14.71
dbReads= 90431 dbWrites= 90431 redisOps= 0 hitRatio=None
Hai dòng này đã chứa toàn bộ manh mối. Số truy vấn đọc và số lệnh ghi bằng nhau chằn chặn: 90.431 và 90.431. Nghĩa là mỗi lượt chuyển hướng sinh ra một lệnh ghi vào database — và ghi thì đắt hơn đọc rất nhiều, lại còn nằm trên đường đi nóng nhất của hệ thống.
Trực giác thông thường sẽ nói: "đọc nặng thì thêm cache". Trực giác đó không sai, nhưng nó trả lời câu hỏi sai. Ta hãy làm theo nó trước — vì đó chính là điều mọi người sẽ làm — rồi xem số liệu nói gì.
Cách chữa rẻ đến bất ngờ: đếm số thao tác, không chỉ đo thời gian. Bộ đếm
dbReads và dbWrites ở trên chỉ là hai biến số nguyên, nhưng chúng chỉ thẳng
vào chỗ cần nhìn theo cách mà biểu đồ latency không làm được. Đây chính là bài học
Bài 16 áp dụng vào một tình huống cụ thể: metrics cho biết
có chuyện, còn bộ đếm đúng chỗ cho biết ở đâu.
18.4 Vòng lặp đo → vá → đo lại
Bốn vòng, mỗi vòng đổi đúng một tham số. Toàn bộ số liệu là số đo thật của cùng một máy, cùng một kịch bản tải:
| Phiên bản | Thay đổi | Throughput | p99 | Truy vấn đọc DB | Lệnh ghi DB |
|---|---|---|---|---|---|
| v1 | Bản gốc tối giản | 6.115 rps | 14,71 ms | 90.431 | 90.431 |
| v2 | + cache-aside (Bài 5) | 7.122 rps (+16%) | 5,94 ms | 1.006 | 103.775 |
| v3 | + đếm click bất đồng bộ (Bài 12) | 25.853 rps (×3,6) | 1,75 ms | 0 | 0 |
| v4 | + read replica (Bài 7) | 24.803 rps (−4%) | 1,85 ms | 0 | 0 |
| v5 | + rate limit (Bài 13) | 25.786 rps | 1,28 ms | 0 | 0 |
v1 → v2: cache xoá 99% truy vấn đọc nhưng chỉ tăng 16% throughput. Nếu chỉ nhìn cột "truy vấn đọc" thì đây là một thành công vang dội — 90.431 xuống 1.006. Nhưng cột throughput nói khác, và lý do nằm ở cột cuối: số lệnh ghi không giảm chút nào, thậm chí tăng theo lưu lượng. Cache đã sửa một thứ không phải nút cổ chai. Điểm sáng duy nhất là p99 giảm 2,5 lần (14,71 → 5,94), vì đường đọc không còn phải xếp hàng chờ connection pool cùng đường ghi.
Throughput bị chặn bởi tài nguyên khan hiếm nhất trên đường đi — ở v2 là đường ghi vào database, và cache không chạm tới nó chút nào. Bỏ lượt đọc đi thì nút cổ chai vẫn nguyên vẹn, nên trần thông lượng gần như không đổi.
p99 thì phản ánh thời gian xếp hàng. Ở v1, lượt đọc và lượt ghi tranh nhau cùng một connection pool, nên một lượt đọc xui xẻo có thể phải đợi sau vài lệnh ghi (Bài 1). Cache rút 99% lượt đọc ra khỏi hàng đợi đó, nên phần đuôi gọn hẳn lại — dù tổng năng lực không tăng.
Bài học tổng quát: một tối ưu có thể cải thiện độ trễ mà không cải thiện thông lượng, và ngược lại. Nếu chỉ theo dõi một trong hai, bạn sẽ hoặc bỏ lỡ giá trị thật của một thay đổi, hoặc gán cho nó công lao nó không có.
v2 → v3: đổi cách đếm click, throughput nhân 3,6 lần. Đây là bước đáng công nhất, và nó không phải một tối ưu kỹ thuật mà là một quyết định về yêu cầu: chấp nhận rằng số liệu thống kê được phép trễ vài giây. Nhận ra điều đó ở mục 18.1 là lý do bước này khả thi. Sau v3, đường nóng của hệ thống không chạm database một lần nào — 0 đọc, 0 ghi.
Lý do hoàn toàn hiển nhiên sau khi nhìn số liệu: cột "truy vấn đọc DB" ở v3 đã là 0. Cache hấp thụ 100% lượt đọc, nên không còn truy vấn nào để chuyển sang replica. Chúng ta vừa thêm một container, một luồng replication, một nguồn replication lag và một lớp phức tạp vận hành — để đổi lấy con số không.
Giới hạn của kết luận này: tập dữ liệu nóng ở đây chỉ có 1.000 link nên nằm gọn trong cache. Với không gian khoá lớn hơn nhiều lần bộ nhớ cache, hit ratio sẽ tụt và replica sẽ có việc để làm. Bài học không phải "read replica vô dụng" mà là thứ tự áp dụng tối ưu quyết định giá trị của từng cái — và chỉ phép đo trên chính hệ thống của bạn mới nói được thứ tự đó.
v4 → v5: rate limit gần như miễn phí. p50 tăng 0,06 ms — đúng một vòng mạng tới Redis, khớp với con số của Bài 13. Và cũng như ở Bài 13, p95/p99 lại đo được thấp hơn (1,01 và 1,28 so với 1,49 và 1,85). Hiện tượng này lặp lại ổn định qua hai bài khác nhau nhưng mình vẫn chưa chứng minh được cơ chế, nên vẫn ghi lại như một quan sát chứ không phải kết luận.
Còn cái giá của v3 thì sao? Nó có thật, chỉ là không nằm trong cột throughput. Điểm cuối
/stats phơi bày nó ra:
$ curl -s localhost:3010/stats/k7
{
"code": "k7",
"clickTrongDb": 191, // already flushed to the database
"clickConTrongBoDem": 1028, // still sitting in Redis, not flushed yet
"tong": 1219 // the CORRECT number only exists when you add both
}
Khoảng cách giữa 191 và 1.219 chính là nhất quán cuối ở dạng cụ thể nhất (Bài 9). Ai đọc thẳng bảng clicks trong database sẽ thấy một con số sai. Đó là cái giá phải trả
cho 3,6 lần throughput — và với thống kê click thì đó là cái giá đúng, còn với số dư tài khoản thì không.
18.5 Tổng kết đánh đổi, và những gì lab này không nói được
Mười bảy bài, và mỗi bài rút gọn lại được thành một câu đánh đổi:
| Chủ đề | Được | Mất |
|---|---|---|
| Cache (Bài 5) | Giảm tải database, giảm p99 | Dữ liệu cũ, thundering herd, một tầng nữa phải vận hành |
| CDN / edge (Bài 6) | Vượt qua giới hạn khoảng cách vật lý | Vô hiệu hoá cache trở nên khó, phân mảnh theo cache key |
| Replication (Bài 7) | Scale tầng đọc, có bản dự phòng | Replication lag, bug read-your-writes |
| Sharding (Bài 8) | Vượt giới hạn một máy | Truy vấn xuyên shard, hotspot, rebalance |
| Bất đồng bộ (Bài 12) | Tách độ trễ người dùng khỏi việc nặng | Nhất quán cuối, tồn đọng, bắt buộc idempotent |
| Rate limit (Bài 13) | Hệ thống nói "không" có kiểm soát | Một vòng mạng thêm vào mọi request |
| Event sourcing (Bài 14) | Lịch sử đầy đủ, dựng lại read model bằng replay | Chi phí nhận thức, upcasting, mất truy vấn tuỳ ý |
| Microservices (Bài 15) | Deploy độc lập, scale riêng phần nóng | p99 xấu 4 lần, độ khả dụng nhân dồn, saga |
| Observability (Bài 16) | Trả lời được câu hỏi chưa lường trước | Chi phí lưu trữ, cardinality, phải bảo trì |
| Circuit breaker (Bài 17) | Chặn cascade, thất bại nhanh | Thêm trạng thái phải chỉnh, và chính nó cũng có bug |
Nhìn cả bảng cùng lúc mới thấy điều đáng nói nhất: không dòng nào có cột "mất" trống. Không có kỹ thuật nào trong series này là miễn phí. Câu hỏi đúng chưa bao giờ là "nên dùng X không" mà luôn là "vấn đề mình đang có xứng với cái giá của X không" — và câu đó chỉ trả lời được bằng số liệu của chính hệ thống mình.
Mạng thật. Mọi container ở đây nói chuyện qua loopback, một vòng mạng tốn ~0,07 ms. Thật là 0,5–2 ms trong cùng trung tâm dữ liệu, hàng chục tới hàng trăm ms liên vùng — và quan trọng hơn, mạng thật mất gói, phân mảnh, và chậm bất thường chứ không sạch như ở đây.
Sự cố hạ tầng. Đĩa hỏng, node bị thu hồi, cả một vùng sẵn sàng mất điện, DNS trục trặc. Lab chỉ tiêm được những lỗi ta nghĩ ra trước.
Chi phí tiền bạc. Ở đây thêm một container là miễn phí. Ngoài kia mỗi thành phần là một hoá đơn hàng tháng, và ràng buộc ngân sách thường định hình kiến trúc nhiều hơn ràng buộc kỹ thuật.
Yếu tố con người. Không lab nào mô phỏng được việc phải chẩn đoán một hệ thống lạ lúc 3 giờ sáng, với tài liệu đã cũ và người viết nó đã nghỉ việc. Đây là lý do thật sự khiến sự đơn giản có giá trị — không phải vì đơn giản thì đẹp, mà vì hệ thống đơn giản là hệ thống bạn còn hiểu nổi khi đang mệt và đang sợ.
Nói cách khác: nếu đây là hệ thống thật với con số thật đó, thì v2, v3, v4, v5 đều không cần thiết. Chúng tồn tại trong bài này để dạy quy trình đo, không phải vì bài toán đòi hỏi. Đây có lẽ là điều khó chấp nhận nhất với người kỹ thuật: kiến trúc đúng thường là kiến trúc nhàm chán, và phần lớn tối ưu chúng ta thích làm là tối ưu cho một quy mô chưa bao giờ tới.
Checklist tự đánh giá một bản thiết kế
- Đã có số chưa — RPS đỉnh, tỉ lệ đọc:ghi, dung lượng, SLO?
- Bản tối giản đã đo baseline chưa, hay đang tối ưu trong bóng tối?
- Mỗi thành phần thêm vào có số liệu trước/sau chứng minh nó đáng không?
- Mỗi lời gọi mạng có timeout, và timeout có giảm dần theo độ sâu không?
- Thao tác nào có thể được thử lại — chúng đã idempotent chưa?
- Retry ở mấy tầng? (Đáp án đúng gần như luôn là: một.)
- Khi quá tải, bạn bỏ cái gì trước, và điều đó đã được quyết định chưa?
- Có correlation ID xuyên suốt, kể cả qua hàng đợi không?
- Đường fallback có phụ thuộc ít thứ hơn đường chính không?
- Cơ chế failover gần nhất được diễn tập khi nào?
Tự làm lại các phép đo
cd blog/sysdesign/sysdesign-lab
./tools/capstone-test.sh seed # seed 1,000 links (run once)
./tools/capstone-test.sh all # run v1 -> v5 in sequence
# v1 baseline rps 6,115 p99 14.71ms dbReads 90,431 dbWrites 90,431
# v2 + cache rps 7,122 p99 5.94ms dbReads 1,006 dbWrites 103,775
# v3 + async click counts rps 25,853 p99 1.75ms dbReads 0 dbWrites 0
# v4 + read replica rps 24,803 p99 1.85ms (!) NO improvement at all
# v5 + rate limit rps 25,786 p99 1.28ms
# The visible cost of v3:
curl -s localhost:3010/stats/k7 # inDb 191 · stillBuffered 1028 · total 1219
Tóm lại
Quy trình quan trọng hơn kiến trúc. Hỏi số trước khi vẽ; thiết kế bản tối giản; đo baseline; rồi mỗi vòng chỉ đổi một thứ và ghi lại số trước/sau. Nghe rất chậm, nhưng nó là cách duy nhất để biết mình đang cải thiện hay chỉ đang thêm phức tạp.
Ba con số của capstone này đáng mang theo. Cache — tối ưu ai cũng nghĩ tới đầu tiên — xoá được 99% truy vấn đọc mà chỉ tăng throughput 16%, vì nút cổ chai nằm ở chỗ khác. Thứ thật sự đáng công là một quyết định về yêu cầu, không phải một thủ thuật kỹ thuật: chấp nhận cho thống kê click trễ vài giây, và throughput nhân 3,6 lần. Còn read replica, hợp lý trên mọi sơ đồ, cho đúng 0% — vì cache đã hấp thụ hết phần đọc mà nó định gánh.
Và điều cuối cùng, cũng là điều đáng nhớ nhất của cả 18 bài: bảng đánh đổi ở mục 18.5 không có dòng nào cột "mất" để trống. Mọi kỹ thuật trong series này đều có giá. Việc của người thiết kế không phải là biết nhiều kỹ thuật, mà là biết khi nào cái giá đó xứng đáng — và câu trả lời luôn nằm trong số liệu của chính hệ thống mình, không nằm trong bài viết nào, kể cả bài này.
Đây là bài cuối của lộ trình. Nếu bạn đã chạy hết các lab và tự đo lại bằng máy của mình, bạn không chỉ biết những khái niệm này — bạn đã thấy chúng xảy ra. Đó là khác biệt mà cả series được viết ra để tạo nên.
📖 Tài liệu tham khảo
- Wikipedia — Architecture Decision Record: định dạng bối cảnh/quyết định/hệ quả/phương án đã loại bỏ, nguồn cho ADR ở mục 18.2
- Google SRE Book — Embracing Risk: khái niệm error budget dùng để quyết định khi nào dừng tối ưu, nguồn cho mục 18.5
- Wikipedia — Back-of-the-envelope calculation: kỹ thuật ước lượng nhanh dùng ở mục 18.2
Tải mã nguồn thực hành
Toàn bộ hệ rút gọn URL dùng trong capstone: cả năm phiên bản v1–v5 (baseline, cache, đếm click bất đồng bộ, read replica, rate limit) nằm trong cùng một file, chọn qua biến môi trường — mọi con số trong bài đến từ file này:
Tải về shortener.js (toàn bộ hệ rút gọn URL — 0 dependency)
Bình luận