Tech Backend · học từ notify-service · 09/2026
Sổ tay Backend
Sách giảng bài về một backend thật đang chạy — bưu điện trung tâm gửi push notification cho hàng nghìn shop Shopify. Đọc liền từ trên xuống, hoặc nhảy chương bằng mục lục bên trái. Mỗi chương: tình huống → cách ngây thơ chết thế nào → cách đúng → vì sao → chỗ hay hiểu sai → tự kiểm tra → bài tập.
Giới thiệu
Tech Backend — học từ `notify-service`
Tài liệu này giải thích một backend thật đang chạy (notify-service — hệ thống gửi thông báo đẩy cho app mobile của các shop Shopify) theo cách mà người không viết code cũng đọc được.
Mỗi bài viết theo kiểu giảng bài, cùng một mạch:
- Bài này trả lời câu hỏi gì — mở đầu, đặt vấn đề.
- Với mỗi chủ đề: Tình huống → Cách ngây thơ chết thế nào → Cách đúng → Vì sao đúng → Chỗ hay hiểu sai → Xem code.
- Checklist — rút thành câu hỏi để dùng khi review/thiết kế.
- Tự kiểm tra — 4 câu, trả lời được là hiểu.
- Bài tập — 2 việc làm trên project thật.
Bản gốc (dạng tóm tắt) lưu trong backup/.
Hình dung tổng thể
Tưởng tượng notify-service là bưu điện trung tâm của hàng nghìn cửa hàng:
- Cửa hàng (merchant) đến gửi thư (thông báo) cho khách của họ.
- Khách (app mobile) đến hộp thư của mình lấy thư.
- Shopify liên tục gửi "tin nhắn nội bộ" về: có đơn hàng mới, khách mới, sản phẩm hết hàng…
- Bưu điện có kho (database), có bảng ghi nhớ tạm dán trên tường (Redis), có băng chuyền cho việc nặng (queue), và có nhân viên đi kiểm tra định kỳ (cron).
Mọi thứ trong các file dưới đây đều là cách bưu điện này không sập khi đông, không mất thư, và không để thư của shop A lọt sang shop B.
Mục lục
| # |
File |
Trả lời câu hỏi |
| 00a |
Khái niệm cơ bản |
Đọc đầu tiên, không cần biết code. Gọi trực tiếp vs để lại phiếu vs hẹn giờ. SQS, BullMQ, worker, cron, Redis, Postgres, webhook, cache, retry, timeout, idempotent — mỗi từ một ví von quán phở, và một đơn hàng đi qua tất cả. |
| 00c |
Công nghệ & chống gì |
16 công nghệ trong dự án: mỗi thứ là gì, không có nó thì chết kiểu gì (ví dụ cụ thể), có nó thì sao, dùng ở đâu. SQS chặn bão webhook, BullMQ chặn người gọi phải chờ, Redis chặn giẫm đạp… |
| 00d |
Dịch vụ Amazon |
~25 dịch vụ AWS hay dùng cho backend, chia 5 nhóm: hàng đợi/sự kiện (SQS, SNS, EventBridge…), chạy code, lưu trữ, cửa vào, vận hành. Mỗi cái: ví von, làm gì, dự án có dùng không, không thuê thì thay bằng gì. |
| 00b |
Thành phần & thời gian |
Đọc thứ hai. Mỗi thành phần (cửa SQS, từng worker, từng cron 5 phút / 1 giờ / hàng ngày, đồng hồ hẹn giờ, từng ô Redis) làm gì, chạy khi nào, chậm bao lâu chấp nhận. |
| 01 |
Kiến trúc tổng quan |
Một yêu cầu đi qua những cửa nào? Việc gì làm ngay, việc gì để sau? |
| 02 |
Tổ chức database |
Kho chứa hàng nghìn shop mà không lẫn? Tìm nhanh nhờ đâu? "Xoá" nghĩa là gì? |
| 03 |
Bảo vệ endpoint |
Làm sao biết người gọi là app thật? Chặn spam ở đâu? Lỗi thì báo thế nào mà không lộ bí mật? |
| 04 |
Cache Redis |
Bảng ghi nhớ dán tường dùng vào việc gì? Vì sao hộp thư khách nằm ở đó chứ không trong kho? |
| 05 |
Queue & xử lý bất đồng bộ |
Băng chuyền hoạt động ra sao? Việc hỏng thì làm lại thế nào mà không làm trùng? |
| 06 |
Gọi hệ thống bên ngoài mà không sập |
Shopify/Google/Gorgias chậm hoặc từ chối thì sao? |
| 07 |
Hẹn giờ & tự phục hồi |
Restart máy chủ thì lịch hẹn gửi có mất không? Thư kẹt giữa đường ai gỡ? |
| 08 |
Checklist senior / teamlead |
Bộ câu hỏi để review hoặc thiết kế tính năng mới. |
| 09 |
Tối ưu database triệu dòng |
Ngoài project. 5 bậc rẻ → đắt: index đúng cách + EXPLAIN, viết query đúng (N+1, keyset, batch), pool & cấu hình, replica/partition/archive, sharding khi nào. |
| 10 |
Server chịu triệu request |
Ngoài project. Quy đổi khách → rps, request phải rẻ, cache theo tầng, stateless + LB + autoscale, circuit breaker / bulkhead / backpressure / idempotency key / throttle theo tenant, đo p99. |
| 11 |
DevOps & vận hành |
Ngoài project, kiểu thầy dạy 10 bài. Docker, CI/CD, rolling/blue-green/canary, log-metrics-trace, alert & SLO, backup diễn tập, bí mật, IaC, xử lý sự cố, chi phí. Mỗi bài có câu tự kiểm tra. |
| 12 |
Lộ trình học + con số |
Bảng độ trễ / sức chịu / dung lượng phải thuộc; lộ trình 6 tháng từ mobile sang backend; sách đáng đọc; cách tự học. |
| 13 |
Bài giảng tổng hợp |
Đọc sau khi đã lướt 00-12, hoặc đọc đầu tiên nếu muốn bức tranh lớn. Gom toàn bộ thành một mạch: 3 câu hỏi (đông / hỏng / lẫn), 3 cách giao việc, giải phẫu hệ thống, 7 bài học lớn, vượt ra ngoài project, bộ xương một trang, 10 câu tự kiểm tra. |
| — |
AI Engineering |
Bộ riêng: từ "dùng AI" lên "xây trên AI" — agent, context, tool, eval, MCP. 8 bài. |
Ba nguyên tắc chạy xuyên suốt
- Nhận việc và làm việc là hai người khác nhau. Quầy tiếp nhận chỉ ghi phiếu rồi trả biên lai ngay. Việc nặng (gửi 50.000 thư, hỏi Shopify) do băng chuyền phía sau làm. Không ai phải đứng chờ ở quầy.
- Cái phụ hỏng thì bỏ qua, cái chính hỏng thì dừng. Bảng ghi nhớ mất → tra kho, chậm hơn nhưng vẫn chạy. Chữ ký giả → từ chối thẳng. Thiếu chìa khoá bí mật lúc mở cửa → không mở cửa luôn, thay vì mở rồi mới phát hiện.
- Việc nào có thể kẹt thì phải có người đi gỡ. Thư đánh dấu "đang gửi" quá 15 phút → nhân viên kiểm tra 5 phút/lần gỡ. Đơn hàng lỗi mãi → sau 5 lần thì bỏ, không để nghẽn cả hàng. Lịch hẹn → ghi ra giấy, mất điện bật lại vẫn còn.
00a · Khái niệm cơ bản
Bài 00a — Khái niệm cơ bản: ba cách giao việc và từ điển backend
Bài này trả lời câu hỏi gì
Bạn mở source một backend và thấy: controller, queue, worker, cron, Redis, Postgres, webhook, retry, timeout. Mười thứ, mỗi thứ một tên lạ. Câu hỏi của người mới là: "Tại sao cần nhiều thứ thế? Chúng liên quan gì nhau?"
Bài này trả lời bằng cách rút tất cả về một ý duy nhất: backend là một tổ chức nhận việc từ nhiều phía, và có đúng ba cách để giao một việc — làm ngay, để lại phiếu, hoặc hẹn giờ. Mọi công nghệ trong danh sách trên đều là công cụ phục vụ một trong ba cách đó. Hiểu ba cách này là hiểu 80% kiến trúc — phần còn lại chỉ là chi tiết.
Không cần biết lập trình để đọc bài này. Chỉ cần hình dung một quán phở đông khách.
Phần 1 — Ba cách giao việc
1.1 Gọi trực tiếp (đồng bộ / synchronous)
Tình huống. Khách gọi "cho tôi một bát phở". Chủ quán đứng đó chờ đầu bếp nấu xong, bưng ra, rồi mới tiếp khách tiếp theo.
Vì sao người ta làm thế. Đơn giản nhất có thể. Khách nhận kết quả thật ngay lập tức, không cần cơ chế gì thêm. Với việc nhanh — múc một cốc nước — đây là cách đúng.
Nó gãy ở đâu. Khách sau phải chờ khách trước. Đầu bếp chậm thì cả quán đứng im. 50 khách vào cùng lúc, khách thứ 50 chờ 50 bát. Không phải chủ quán lười — mà là cách tổ chức khiến một việc chậm kéo mọi việc khác chậm theo.
Trong hệ thống. App hỏi "hộp thư của tôi có gì?" → server tra bảng Redis, vài mili giây, trả ngay. Đây là đồng bộ, và nó đúng vì việc nhanh. Quy tắc: chỉ dùng cho việc dưới 1 giây.
1.2 Để lại phiếu (hàng đợi / queue)
Tình huống. Khách gọi món. Chủ quán ghi phiếu, kẹp lên dây, đưa khách số bàn, quay sang tiếp khách tiếp theo ngay. Đầu bếp đứng ở bếp, thấy phiếu là nấu. Nấu xong bưng ra bàn.
Vì sao cách này thắng. Chủ quán không bao giờ bị kẹt — 50 khách thành 50 phiếu trong 1 phút, đầu bếp nấu dần. Đầu bếp ốm, phiếu vẫn treo đó, đầu bếp khác nấu. Nấu hỏng, nấu lại theo phiếu. Bạn tách người nhận việc khỏi người làm việc, và nhờ đó mỗi bên có nhịp riêng.
Cái giá. Khách không nhận ngay, mà nhận "sau vài phút". Phải có cách báo khách khi xong (số bàn, tiếng gọi tên). Trong hệ thống: phải có trạng thái để hỏi "xong chưa", hoặc push kết quả về.
Trong hệ thống. Chủ shop bấm "gửi thông báo cho 50.000 khách" → server ghi phiếu, trả "đã nhận" trong dưới 1 giây → worker (đầu bếp) gửi dần tới Google Firebase. Đây là BullMQ.
Chỗ hay hiểu sai — quan trọng nhất bài này. Người mới hay hỏi: "Thông báo đi qua queue thì đâu còn realtime?" Câu trả lời: queue không làm chậm. Phiếu kẹp lên dây, đầu bếp đứng ngay đó, nhặt trong dưới 0,1 giây. Cái chậm là bản thân việc nấu — Firebase mất 1-2 giây mỗi lô 500 máy, dù có queue hay không. Không có queue: chủ quán đứng chờ 2 phút, quán kẹt. Có queue: chủ quán rảnh, bát phở vẫn ra sau đúng chừng đó thời gian. Queue giúp quán không kẹt, không phải giúp nấu nhanh hơn. Nếu bạn nhớ một câu từ bài này, hãy nhớ câu đó.
1.3 Hẹn giờ (cron)
Tình huống. Không ai gọi. Cứ 5 phút nhân viên đi một vòng: gom bát bẩn, lau bàn. Cuối ngày kế toán cộng sổ.
Vì sao cần cách thứ ba. Có những việc không ai đang chờ, và làm từng cái một thì lãng phí. Gom bát bẩn ngay khi khách đứng dậy? Nhân viên chạy suốt. Gom 5 phút một lần? Một vòng gom 20 bàn. Rẻ hơn hẳn. Và chạy được lúc vắng khách.
Cái giá. Chậm. Việc xảy ra lúc 12:01 thì 12:05 mới xử lý. Không dùng cho việc khách đang chờ. Chu kỳ cron chính là độ trễ tối đa bạn chấp nhận.
Trong hệ thống. Đơn hàng mới ghi nháp lên Redis. 5 phút một lần gom vào Postgres và tính lại báo cáo ngày. Chủ shop xem báo cáo trễ 5 phút — chấp nhận được. Trong flash sale 5.000 đơn/phút, cách này tính lại báo cáo một lần thay vì 5.000 lần.
1.4 Bảng so sánh
|
Gọi trực tiếp |
Để lại phiếu (queue) |
Hẹn giờ (cron) |
| Ai kích hoạt |
Người gọi |
Người gọi |
Đồng hồ |
| Người gọi có chờ không |
Có |
Không, nhận "đã ghi phiếu" rồi đi |
Không có người gọi |
| Việc bắt đầu sau bao lâu |
Ngay |
Ngay khi có người rảnh (< 0,1s) |
Đến giờ mới bắt đầu |
| Hỏng thì sao |
Người gọi nhận lỗi, tự gọi lại |
Phiếu quay lại dây, thử lại |
Lần chạy sau làm nốt |
| Dùng cho |
Việc < 1 giây |
Việc nặng, cần xong sớm |
Gom lô, chốt sổ, dọn dẹp |
| Trong notify-service |
API mobile |
BullMQ + worker |
9 cron |
1.5 Ba cách ghép lại — một sự kiện, ba đường
Đây là điều bảng trên không cho thấy: một sự kiện thật thường đi qua cả ba cách cùng lúc, mỗi đường một tốc độ, không đường nào chặn đường nào. Phần 3 sẽ kể một đơn hàng đi qua cả ba.
Phần 2 — Từ điển: mỗi từ một ví von, và vì sao nó tồn tại
Tôi không chỉ định nghĩa. Với mỗi từ, tôi nói nó giải quyết vấn đề gì — vì thuật ngữ nào cũng sinh ra từ một nỗi đau.
API và endpoint
Cửa sổ gọi món. Ai muốn gì thì đến cửa sổ này nói. Mỗi cửa sổ nhận một loại yêu cầu — "cho tôi hộp thư", "đăng ký máy này". Một cửa sổ cụ thể gọi là endpoint.
Webhook
Ngược với API. Không phải mình đến hỏi, mà người ta tự gọi điện báo khi có chuyện: "Shopify đây, shop X vừa có đơn mới."
Vì sao cần: nếu không có webhook, mình phải hỏi Shopify mỗi giây "có đơn mới không?" — 5.000 shop × 1 lần/giây = 5.000 câu hỏi/giây, 99,9% trả lời "không". Webhook đảo chiều: bên có tin thì báo. Cái giá: phải có người trực máy, và phải trả lời "nhận rồi" nhanh, không thì họ gọi lại hoặc cúp máy luôn.
SQS (Amazon Simple Queue Service)
Hòm thư ngoài cổng, do Amazon giữ. Shopify không gọi điện thẳng vào quán mà bỏ thư vào hòm. Quán cử người ra lấy: đứng chờ tối đa 20 giây, có thư là cầm vào ngay, tối đa 10 lá một lần, đọc xong xé. Lá thư lấy ra mà 30 giây chưa xé → Amazon coi như chưa ai đọc, cho người khác lấy.
Vì sao cần hòm thay vì gọi thẳng: quán đóng cửa 10 giây để sửa bếp (deploy) → thư vẫn nằm trong hòm, mở cửa ra đọc tiếp. Gọi điện thẳng thì 10 giây đó ai gọi là mất. Và khi 1.000 người gọi cùng lúc, hòm chịu được, còn điện thoại thì nghẽn.
SQS là hàng đợi giữa bên ngoài và mình. Chỗ hay hiểu sai: "lá thư có thể được đọc 2 lần" — đây không phải lỗi, mà là thiết kế. Hệ quả ở mục "idempotent".
BullMQ
Dây kẹp phiếu trong bếp. Do quán tự quản, kẹp trên tấm bảng Redis. Nhanh hơn hòm thư ngoài cổng nhiều (< 0,1 giây), có sẵn tính năng: phiếu hỏng tự quay lại, chờ bao lâu thì thử lại, hai phiếu giống nhau thì gộp, giữ phiếu hỏng để xem lại.
BullMQ là hàng đợi trong nhà. Khác SQS: SQS là hòm giữa Shopify và mình (bên ngoài không có quyền vào bếp). BullMQ là dây trong bếp, chỉ mình dùng. Cửa SQS đọc thư xong, nếu việc nặng thì viết thành phiếu kẹp lên BullMQ.
Worker
Đầu bếp đứng sẵn ở dây phiếu. Không làm gì khác, chỉ nhìn dây; có phiếu là nhặt. Mỗi loại phiếu có đầu bếp riêng: người chuyên gửi thông báo, người chuyên hỏi Shopify hồ sơ khách, người chuyên dọn khi shop gỡ app.
Chỗ hay hiểu sai: worker không chạy theo giờ. Nó chạy liên tục. Đây là điểm khác cron — người mới hay gộp hai thứ này.
Cron
Nhân viên xem đồng hồ. "Cứ 5 phút", "cứ 1 giờ", "0 giờ đêm". Đến giờ thì làm, xong thì ngồi chờ giờ sau. Không có ai gọi họ.
Tên "cron" từ chronos (thời gian). Lịch viết kiểu */5 * * * * = "mỗi 5 phút".
Database / Postgres
Sổ cái có khóa. Ghi gì vào là chắc, có luật ("mã đơn không được trùng"), có thể ghi nhiều dòng "cả gói hoặc không gì cả" (transaction). Chậm hơn bảng trắng nhưng tin cậy. Mọi thứ không được mất nằm đây.
Redis
Bảng trắng dán tường. Ghi/xóa tức thì, ai cũng nhìn thấy, có thể ghi "tự bay sau 30 ngày". Mất điện là trắng bảng (thực tế có sao lưu, nhưng đừng tin tuyệt đối).
Dùng cho: bản sao để tra nhanh (cache), hộp thư của khách, phiếu nháp chờ gom, khóa "đang có người làm", bộ đếm, và cả dây phiếu BullMQ. Bài 04 sẽ cho thấy Redis là năm công cụ, không phải một.
Cache
Chép lại câu trả lời để lần sau khỏi hỏi. Khách hỏi "hôm nay có món gì" → hỏi bếp một lần, viết lên bảng, 100 khách sau đọc bảng. Bảng có hạn: 5 phút sau xóa, hỏi lại bếp.
Nguyên lý quan trọng: cache hỏng thì không sao, chỉ chậm hơn vì phải hỏi bếp lại. Đó là lý do mọi chỗ dùng cache đều viết "lỗi thì bỏ qua".
Timeout
Gọi điện, đổ chuông 15 giây không ai bắt máy thì cúp. Không cúp thì cầm máy mãi, không tiếp được ai khác.
Vì sao sống còn: bên kia treo mà mình không cúp → 200 cuộc gọi treo → 2.000 → hết máy. Mình chết vì họ chết.
Retry và backoff
Gọi lại sau. Retry = gọi lại. Backoff = mỗi lần chờ lâu hơn: 2 giây, rồi 4, rồi 8. Vì nếu người ta bận, gọi dồn dập chỉ làm họ bận hơn.
Điểm khó mà người mới bỏ qua: chờ bao lâu phải khớp lý do bên kia bận. Bên kia nói "20 giây nữa" mà mình gọi lại sau 2 giây thì 3 lần gọi lại đều thất bại. Bài 05 có chuyện thật về điều này.
Rate limit
Bên kia chỉ tiếp 40 cuộc gọi mỗi 20 giây. Gọi hơn thì họ từ chối cả nhóm. Cách hay: mình tự đếm trước, tới 36 thì tự dừng, đừng để họ phải từ chối.
Idempotent (làm lại không đổi kết quả)
"Đánh dấu bàn 5 đã thanh toán." Làm 1 lần hay 3 lần, bàn 5 vẫn là "đã thanh toán", không thành "thanh toán 3 lần".
Vì sao đây là khái niệm quan trọng nhất từ điển: mọi cơ chế thử lại — retry, thư SQS hiện lại, cron chạy lại — đều có thể làm một việc 2 lần. Việc nào không idempotent ("cộng thêm 1 đơn") sẽ bị đếm gấp đôi. Nên trong sổ cái luôn ghi kiểu "có rồi thì cập nhật, chưa có thì tạo" (upsert) thay vì "thêm mới". Không có idempotent, không dám retry; không dám retry, không có hệ thống tự phục hồi.
Fail-open / fail-closed
Cửa tự động bị hỏng: fail-open = mở toang (ai cũng vào được); fail-closed = đóng chặt (không ai vào được).
Quy tắc: thứ chỉ là tiện ích (cache, đếm, cảnh báo) → hỏng thì mở, bỏ qua chạy tiếp. Thứ là an toàn (kiểm tra thẻ, chữ ký) → hỏng thì đóng, từ chối. Bạn sẽ gặp lại cặp từ này ở mọi bài sau.
Multi-tenant
Một quán phục vụ 5.000 chủ shop, mỗi shop chỉ được thấy phiếu của mình. Cách làm: mọi phiếu, mọi dòng sổ đều đóng dấu tên shop, và mọi lần tra đều kèm dấu. Bài 02 nói kỹ.
Realtime
Không phải "0 giây". Với thông báo đẩy, realtime = vài giây: điện thoại rung 2-5 giây sau khi bấm. Google/Apple tự nó đã mất 1-5 giây, và điện thoại còn gom lại để tiết kiệm pin. Ai hứa "realtime = mili giây" cho push notification là chưa từng đo.
Phần 3 — Một đơn hàng đi qua tất cả
Khách Lan mua áo trên app của shop "Áo Xinh", thanh toán lúc 12:00:00. Theo dõi từng giây:
| Giờ |
Chuyện gì |
Khái niệm |
| 12:00:00 |
Shopify ghi đơn, bỏ thư vào hòm Amazon: "Áo Xinh có đơn #1001, đã thanh toán" |
Webhook → SQS |
| 12:00:01 |
Người trực hòm thư cầm lá thư vào, đọc |
Cửa SQS |
| 12:00:01 |
Dán note "đơn #1001" lên bảng trắng ô orders:aoxinh — note cùng tên thì đè, nên 3 lá thư về cùng đơn = 1 note |
Redis, idempotent |
| 12:00:01 |
Cùng lúc: kiểm tra "Lan mua lần đầu?" → viết phiếu "gửi thông báo Cảm ơn cho Lan" kẹp lên dây |
BullMQ |
| 12:00:01 |
Xé lá thư trong hòm Amazon |
SQS ack |
| 12:00:01 |
Đầu bếp nhặt phiếu, tra bảng trắng lấy chìa khóa Firebase của Áo Xinh |
Worker, cache |
| 12:00:02 |
Gọi Google Firebase, đợi tối đa 15 giây |
Gọi ra ngoài, timeout |
| 12:00:03 |
Google trả "đã nhận". Điện thoại Lan rung. Ghi sổ cái: "thông báo X gửi Lan: ĐÃ GIAO" |
Realtime ≈ 3 giây |
| 12:00:03 |
Nếu Google lỗi → phiếu quay lại dây, chờ 2s thử lại, tối đa 3 lần |
Retry + backoff |
| 12:05:00 |
Nhân viên 5 phút đi tuần: thấy note #1001 → ghi vào sổ cái (có rồi thì cập nhật) → xóa note → tính lại báo cáo ngày |
Cron, Postgres, idempotent |
| 12:05:02 |
Chủ shop mở dashboard: doanh thu hôm nay đã cộng đơn của Lan |
Trễ 5 phút, chấp nhận |
| 12:20:00 |
Nếu lúc 12:00:03 máy chủ chết sau khi ghi "ĐANG GỬI" mà chưa kịp ghi "XONG" → nhân viên 5 phút khác thấy thông báo kẹt > 15 phút → gỡ |
Cron gỡ kẹt |
| 18:00 |
Nhân viên 6 giờ kéo số liệu Google Analytics về |
Cron |
| 0:00 |
Nhân viên hàng ngày đồng bộ hành vi khách sang Shopify Marketing |
Cron |
Nhìn lại: từ một lá thư có ba đường song song.
- Đường thông báo: SQS → BullMQ → worker → Google. 3 giây.
- Đường sổ cái: SQS → Redis → cron 5 phút → Postgres. ≤ 5 phút.
- Đường báo cáo: sau cron → tính lại ngày. ≤ 5 phút.
Không đường nào chặn đường nào. Đường push không phải chờ đường sổ cái. Đường sổ cái không phải chờ Google. Đây là toàn bộ tinh thần của kiến trúc: mỗi việc đi đường riêng, với tốc độ riêng, đúng mức chấp nhận của nó.
Phần 4 — Khi nào chọn gì: quy tắc một dòng
- Khách đang chờ màn hình và việc < 1 giây → gọi trực tiếp.
- Khách đang chờ nhưng việc > 1 giây hoặc phải gọi bên ngoài → trả "đã nhận" + phiếu BullMQ.
- Bên ngoài báo cho mình → webhook, nhận qua SQS nếu bên đó hỗ trợ (Shopify), nhận trực tiếp nếu không (Gorgias, Smile) — và luôn chỉ xác minh + viết phiếu.
- Việc không ai chờ, gom được → cron, chu kỳ = độ trễ chấp nhận được.
- Dữ liệu không được mất → Postgres. Tra nhanh, có hạn → Redis.
- Mọi việc có thể chạy 2 lần → viết sao cho chạy 2 lần vẫn đúng.
Tự kiểm tra
- Vì sao push đi qua queue mà vẫn "realtime"? Cái gì chậm, cái gì không?
- Worker và cron khác nhau ở điểm nào? Việc "gỡ thông báo kẹt" nên là worker hay cron, vì sao?
- Idempotent liên quan gì đến retry? Nếu không idempotent thì chuyện gì xảy ra khi SQS đọc thư 2 lần?
- Nêu một thứ fail-open và một thứ fail-closed trong bảng đơn hàng của Lan.
Bài tập
- Chọn 5 việc trong app bạn đang làm (mobile hoặc web). Với mỗi việc: gọi trực tiếp, phiếu, hay cron? Ghi lý do một dòng.
- Vẽ lại bảng "một đơn hàng đi qua tất cả" cho sự kiện "khách thêm sản phẩm vào giỏ rồi không mua" — từ lúc thêm giỏ tới lúc nhận push nhắc.
00b · Thành phần & thời gian
Bài 00b — Thành phần và thời gian: ai làm gì, khi nào, chậm bao lâu
Bài này trả lời câu hỏi gì
Bài 00a cho bạn ba cách giao việc. Bài này là sơ đồ nhân sự của bưu điện: mỗi thành phần là một người, có việc cụ thể, có giờ làm cụ thể, và có mức chậm chấp nhận được. Mục tiêu: sau bài này, khi ai hỏi "push có realtime không?", "đơn vào báo cáo sau bao lâu?", "deploy 2h sáng có mất lịch hẹn 9h không?" — bạn trả lời được bằng con số, kèm lý do, mà không cần mở code.
Mọi số liệu ở đây lấy từ code (@Cron(...), @Processor(...), hằng số) — không ước lượng. Khi code đổi, bài này phải đổi theo.
Tôi chia thành 5 nhóm theo cách chúng được kích hoạt: luôn mở (cửa), có phiếu là nhặt (worker), xem đồng hồ (cron), hẹn giờ (scheduler), và nơi chứa (kho). Nhớ cách phân nhóm này quan trọng hơn nhớ từng dòng.
Nhóm 1 — Cửa nhận việc: luôn mở, phản hồi ngay
Cửa là chỗ bên ngoài chạm vào hệ thống. Nguyên tắc chung của mọi cửa, học từ bài 00a: chỉ xác minh, ghi, và trả lời — không bao giờ gọi Google hay Shopify trong lúc khách chờ.
| Thành phần |
Nhận gì từ ai |
Làm gì rồi trả về |
Mất bao lâu |
| HTTP API (Fastify) |
App mobile, chủ shop, hệ thống nội bộ |
Kiểm tra thẻ → kiểm tra phiếu → ghi kho/bảng trắng → đặt phiếu lên băng chuyền nếu việc nặng → trả kết quả |
Mục tiêu < 1 giây |
Cửa SQS "webhook Shopify" (SQS_WEBHOOK_URL) |
Shopify, qua Amazon: đơn hàng, khách, sản phẩm, bộ sưu tập, giảm giá, tồn kho, shop update, bulk xong |
Đọc tin → tùy loại: dán note Redis (đơn), gọi gửi thông báo tự động ngay (đơn paid/giao, hết hàng, giảm giá), đặt phiếu (bulk), báo sang hệ thống quản trị (shop/discount) → xé thư |
Shopify bắn → bưu điện đọc: ~1-2 giây. Xử lý mỗi tin < 1 giây |
Cửa SQS "tracking app" (SQS_APP_TRACKING_URL) |
SDK trong app: mở app, xem sản phẩm, thêm giỏ, tìm kiếm, đăng nhập, bắt đầu thanh toán |
Dán note lên Redis (hạn 24h). Riêng thêm giỏ → gọi gửi thông báo "giỏ bỏ quên" + đặt lịch kiểm tra giỏ 6h/12h/24h. Đăng nhập → gắn tag khách trên Shopify (ném rồi quên) |
~1-2 giây từ app tới bưu điện |
Webhook Gorgias (POST /gorgias/webhook/:token) |
Gorgias khi nhân viên hỗ trợ trả lời ticket |
So token → lấy số ticket → đặt phiếu → luôn trả 200 |
< 100ms |
| Webhook Smile |
Smile khi điểm khách thay đổi |
Kiểm chữ ký (lệch > 5 phút từ chối) → đặt phiếu |
< 100ms |
Vì sao có hai cửa SQS mà không gộp một? Vì hai nguồn có nhịp khác nhau: webhook Shopify dồn theo đơn hàng (flash sale), tracking app dồn theo lượt mở app (giờ cao điểm). Tách hòm để bão bên này không làm bên kia chờ.
Vì sao webhook Gorgias "luôn trả 200" kể cả token sai? Vì Gorgias có luật: bưu điện trả lỗi vài lần liên tiếp → Gorgias tắt kết nối cả tài khoản, nhiều shop dùng chung một tài khoản. Trả 200 rồi âm thầm bỏ qua rẻ hơn nhiều so với để một token sai làm nhiều shop mất tính năng chat. Đây là ví dụ đầu tiên của việc hiểu luật bên kia rồi mới thiết kế cửa.
Cả hai cửa SQS: lấy tối đa 10 thư/lần, chờ tối đa 20 giây nếu hàng đợi rỗng (có thư là về ngay — gọi là long-poll), thư lấy ra không xé trong 30 giây thì Amazon cho người khác lấy. Lỗi hạ tầng 10 lần liên tiếp → cửa tự đóng, cần restart. Con số 30 giây có hệ quả: xử lý một lô phải xong dưới 30 giây — nên cửa chỉ dán note, không làm việc nặng.
Nhóm 2 — Worker băng chuyền: nhặt phiếu ngay
BullMQ là băng chuyền trên Redis. Worker không chạy theo giờ — có phiếu là nhặt trong dưới 100ms. Đây là chỗ mọi việc "cần xong sớm nhưng nặng" chạy.
| Băng chuyền |
Ai đặt phiếu |
Worker làm gì |
Thử lại |
Song song |
notifications |
Merchant bấm gửi; thông báo tự động; lịch hẹn tới giờ |
Gọi Google Firebase theo lô 500 → ghi kết quả từng máy → đổi trạng thái XONG → tắt máy hỏng |
3 lần, chờ 2s / 4s / 8s |
1 phiếu = 1 đợt gửi; các lô 500 gọi tuần tự |
customer-sync |
Cửa đăng ký thiết bị (khi khách đăng nhập) |
Hỏi Shopify hồ sơ khách → ghi kho |
3 lần, chờ 5s / 10s / 20s; bỏ qua nếu shop đang bị Shopify từ chối |
Số phiếu = sync:{shop}:{khách} → trùng thì gộp |
orders-backfill |
Cửa SQS khi Shopify báo "bulk export xong" |
Tải file JSONL theo dòng → ghi Mongo theo lô 50 → báo hệ thống quản trị "xong/hỏng" |
3 lần, chờ 5s / 10s / 20s |
5 shop cùng lúc |
gorgias-webhook |
Webhook Gorgias |
Gọi Gorgias đọc lại ticket → ghi hộp thư khách → xóa cache hội thoại |
3 lần, chờ đúng 20s (khớp cửa sổ rate-limit Gorgias) |
5 phiếu cùng lúc |
smile-webhook |
Webhook Smile |
So điểm cũ/mới → quyết định push "vừa nhận điểm" / "sắp lên hạng" |
mặc định 3 lần |
— |
shop-cleanup |
Hệ thống quản trị khi shop gỡ app (48h sau) |
Xóa mềm 8 bảng, xóa cứng 15 bảng, dọn Redis của shop |
mặc định |
— |
cross-* (6 băng) |
notify-service đặt, hệ thống quản trị nhặt |
Báo: shop đổi thông tin, giảm giá tạo/sửa/xóa, bulk xong, xin cấp lại chìa khóa Shopify |
5 lần, chờ 5s → 80s; giữ 500 phiếu hỏng để soi |
— |
Đọc cột "Thử lại" và để ý: mỗi băng một chính sách khác nhau, không phải copy một số. notifications chờ 2/4/8 giây vì Firebase lỗi thoáng qua. gorgias-webhook chờ đúng 20 giây vì Gorgias giới hạn theo cửa sổ 20 giây. cross-* chờ tới 80 giây, 5 lần, vì bên kia có thể đang deploy. Bài 05 kể chuyện vì sao con số này quan trọng đến mức có comment dài nhất repo.
Thời gian thực tế cho thông báo: phiếu vào băng → worker nhặt < 100ms → Firebase nhận lô đầu ~1-2s → máy đầu tiên rung ~2-3s sau khi bấm. 50.000 máy = 100 lô × ~1-2s ≈ 1-3 phút để gửi hết. Trần này là do gọi tuần tự; muốn nhanh hơn thì song song hóa lô hoặc tách phiếu.
Nhóm 3 — Cron: nhân viên đi tuần theo giờ
Cron không đứng trên đường gửi thông báo. Việc của cron là gom lô, chốt sổ, dọn dẹp, kéo dữ liệu từ bên ngoài. Nếu bạn thấy ai định gom push lại chạy cron "cho đỡ tải", đó là hiểu sai bài 00a.
| Lịch |
Tên |
Làm gì |
Vì sao chọn nhịp này |
Giới hạn |
| 5 phút |
OrderJobService |
Gom note orders:{shop} trên Redis → ghi Postgres lô 50 → tính lại báo cáo ngày bị đụng |
Báo cáo chậm 5 phút chấp nhận được; gom lô rẻ hơn ghi từng tin |
Tối đa 1 giờ/lượt; 10 shop song song; đơn lỗi vĩnh viễn bỏ sau 5 lần |
| 5 phút |
AllOrderJobService |
Gom note all-orders:{shop} (đơn cả app + web) → ghi Mongo |
Như trên, cho kho analytics |
Có cờ "đang chạy" — lượt trước chưa xong thì lượt sau bỏ qua |
| 5 phút |
AppTrackingEventJobService |
Gom note sự kiện app → ghi Postgres → kích hoạt tracking sản phẩm/bộ sưu tập |
Hành vi khách không cần tức thì |
Tối đa 1 giờ/lượt; 10 shop song song |
| 5 phút |
NotificationReaperService |
Tìm thông báo "ĐANG GỬI" > 15 phút → chưa có lịch sử thì gửi lại, có rồi thì đóng XONG |
Gửi thật chỉ vài giây; 15 phút = chắc chắn kẹt |
— |
| 1 giờ |
TrackingJobService |
Tính số liệu phiên cho từng shop |
Dashboard phiên xem theo giờ là đủ |
Tối đa 1 giờ/lượt |
| 6 giờ |
GaCronService |
Gọi Google Analytics lấy số liệu → ghi kho |
Google tổng hợp chậm, gọi dày hơn vô ích |
10 tài khoản Firebase mỗi lô |
| 0:00 |
MarketingCronService |
Đồng bộ hành vi sang Shopify Marketing |
Chạy đêm, tải thấp |
Tối đa 1 giờ; cảnh báo nếu > 10% shop lỗi |
| 0:00 |
SyncCacheJob |
Chép cache sản phẩm/bộ sưu tập (hạn 24h) vào bảng ShopProductCollection |
Trước khi cache bay |
— |
| 9:00 |
SmileExpiringRewardService |
Quét voucher Smile sắp hết hạn → push nhắc, mỗi voucher đúng 1 lần |
9 giờ sáng khách dễ đọc |
Tối đa 40 trang/shop |
Cột "Vì sao chọn nhịp này" là cột quan trọng nhất. Chu kỳ cron không phải số tùy ý — nó bằng độ trễ tối đa nghiệp vụ chấp nhận. Báo cáo: 5 phút. Google Analytics: 6 giờ vì Google tự nó tổng hợp chậm. Nếu ai đề xuất "đổi 5 phút thành 1 phút cho nhanh", câu hỏi phải là: ai cần nhanh hơn, và Postgres chịu thêm 5 lần ghi được không?
Giới hạn hiện tại đã ghi trong code: tất cả cron chạy trên mọi bản nếu scale ra nhiều bản. Hiện chỉ chạy 1 bản nên chưa sao. Bài 07 nói cách nâng cấp.
Nhóm 4 — Đồng hồ hẹn giờ trong RAM (có ghi giấy lên Redis)
Đây là nhóm dễ bị bỏ qua và nguy hiểm nhất, vì nó sống trong RAM.
| Thành phần |
Hẹn gì |
Khi nào kêu |
Sống qua restart? |
NotificationSchedulerService — hẹn một lần |
Merchant lên lịch gửi; thông báo tự động có "chờ X giờ" (giỏ bỏ quên 24h, chào mừng 1 ngày) |
Đúng giờ → đặt phiếu lên băng notifications |
Có: ghi Redis hạn 30 ngày + quét kho khi bật. Quá giờ ≤ 24h → gửi bù; > 24h → vô hiệu |
NotificationSchedulerService — lặp lại |
"Khách không mở app N ngày" |
Mỗi N ngày/giờ kiểm tra rồi hẹn lượt sau |
Có, cùng cơ chế |
CartSchedulerService |
Kiểm tra giỏ bỏ quên |
6h, 12h, 24h sau khi thêm giỏ → hỏi Shopify giỏ còn không → bắn trigger marketing |
Có: ghi Redis hạn 24h. Khách thanh toán → hủy cả 3 |
Vì sao cần cột "Sống qua restart?": setTimeout trong Node chết cùng process. Deploy 2h sáng = mọi hẹn 9h sáng biến mất, nếu không có gì ghi ra ngoài. Bài 07 giảng kỹ cơ chế "ghi giấy" này. Ở đây chỉ cần nhớ: mọi hẹn giờ đều có bản sao trên Redis, và khi bật máy hệ thống đọc lại.
Nhóm 5 — Ba loại kho và hạn giữ
| Kho |
Giữ gì |
Giữ bao lâu |
| Postgres |
Thông báo, lịch sử gửi, thiết bị, khách, đơn, phân khúc, báo cáo ngày, credential |
Vĩnh viễn (xóa mềm khi shop gỡ app) |
| MongoDB |
Toàn bộ đơn (app + web) cho analytics |
Vĩnh viễn |
| Redis |
Xem bảng dưới |
Theo từng ô |
Các ô Redis quan trọng — và cột "Hạn" là điều bạn phải để ý:
| Ô |
Giữ gì |
Hạn |
shop:shopInfo:{shop} |
Thông tin + chìa khóa Shopify (hệ thống quản trị ghi) |
Không hạn, quản trị xóa |
firebase:{shop} |
Shop thuộc dự án Firebase nào |
Không hạn, xóa tay khi đổi dự án |
credential:{project} |
Chìa khóa Firebase |
24 giờ |
mab:{shop}:u:{máy}:inbox / :unread |
Hộp thư + chưa đọc từng máy |
Tự dọn mục > 30 ngày |
mab:{shop}:msg:{id} |
Nội dung 1 thông báo (dùng chung mọi máy) |
30 ngày |
orders:{shop}, all-orders:{shop} |
Đơn nháp chờ cron |
Không hạn — cron xóa khi nhập xong |
appTracking:{shop} |
Sự kiện app nháp chờ cron |
24 giờ |
products:{shop}, collections:{shop} |
Cache sản phẩm/bộ sưu tập |
24 giờ |
authFail:{shop} |
Cờ "Shopify đang từ chối" |
5 phút |
token-refresh-req:{shop} |
Cờ "vừa xin chìa khóa" |
60 giây |
gorgias:rl:{account}:{bucket} |
Đếm lần gọi Gorgias |
21 giây |
notification:scheduled:{id} / cart:scheduled:{token} |
Giấy hẹn giờ |
30 ngày / 24 giờ |
Nguyên tắc rút từ bảng: mỗi hạn là một quyết định nghiệp vụ. orders:{shop} không có hạn vì mất là mất đơn — phải sống tới khi cron nhập xong. appTracking 24 giờ vì mất một ngày hành vi thì chấp nhận. authFail 5 phút vì đó là thời gian đủ để chìa khóa mới được cấp. Và ô không có hạn thì phải có người xóa — firebase:{shop} quên xóa khi đổi dự án là Google từ chối mọi thông báo của shop đó vĩnh viễn (code có cảnh báo đậm).
Cách dùng bài này để "chốt" câu hỏi
| Câu hỏi |
Tra ở đâu |
Trả lời |
| "Push có realtime không?" |
Nhóm 2, băng notifications |
Có: worker nhặt < 100ms, máy đầu rung ~2-3s. Cron không liên quan. |
| "Đơn vào báo cáo sau bao lâu?" |
Nhóm 3, OrderJobService |
Tối đa 5 phút + vài giây nhập. |
| "Khách thêm giỏ rồi bỏ, bao lâu được nhắc?" |
Nhóm 1 (SQS tracking) + Nhóm 4 |
Theo waitTime cấu hình (thường 24h); kiểm tra giỏ ở 6h/12h/24h. |
| "Số liệu Google cập nhật bao lâu một lần?" |
Nhóm 3 |
6 giờ. |
| "Deploy lúc 2h sáng có mất lịch hẹn 9h không?" |
Nhóm 4 |
Không, ghi Redis + quét kho khi bật. |
| "Shopify đổi chìa khóa thì bao lâu tự lành?" |
Nhóm 5 authFail, token-refresh-req |
~1 phút. |
Tự kiểm tra
- Nêu 3 thành phần chạy liên tục và 3 thành phần chạy theo giờ. Tiêu chí phân biệt là gì?
- Vì sao cửa SQS phải xử lý xong một lô dưới 30 giây? Chuyện gì xảy ra nếu quá?
- Ba băng chuyền có thời gian retry khác nhau — chọn 2 và giải thích vì sao con số khác nhau.
- Ô Redis nào không có hạn và ai chịu trách nhiệm xóa? Quên xóa thì sao?
Bài tập
- Mở
src/cron/, đếm lại số @Cron. Khớp bảng Nhóm 3 không? Nếu lệch, sửa bảng.
- Chọn một ô Redis trong Nhóm 5, tìm chỗ ghi và chỗ đọc nó trong code. Hạn có đúng như bảng không?
00c · Công nghệ & chống gì
Bài 00c — Công nghệ trong dự án: mỗi thứ chống một cách chết cụ thể
Bài này trả lời câu hỏi gì
Người mới nhìn package.json thấy 40 dependency và nghĩ "chắc người ta cài cho đủ bộ". Không. Mỗi công nghệ trong notify-service được chọn để chặn một cách chết cụ thể — có ví dụ, có con số, và nhiều cái có sự cố thật đằng sau.
Cách học công nghệ đúng không phải "nó là gì" mà là "không có nó thì chết kiểu gì". Khi bạn thấy được cái chết, bạn sẽ không bao giờ quên vì sao cần nó, và — quan trọng hơn — bạn sẽ biết khi nào không cần nó.
Bài này giảng 16 công nghệ theo đúng mạch đó: là gì → không có thì chết thế nào → có thì sao → dùng ở đâu → cái giá.
Tổng quan một trang
| # |
Công nghệ |
Một câu |
Chống cái gì |
| 1 |
Amazon SQS |
Hòm thư ngoài cổng |
Bão webhook đánh sập cửa vào; mất tin khi restart |
| 2 |
BullMQ |
Dây phiếu trong bếp |
Người gọi phải chờ việc nặng; việc hỏng giữa chừng mất; làm trùng |
| 3 |
Redis |
Bảng trắng dán tường |
Database bị hỏi quá nhiều; giẫm đạp; đếm/khóa nhanh |
| 4 |
PostgreSQL + Prisma |
Sổ cái có luật |
Dữ liệu trùng, mất nửa chừng, shop này thấy shop kia |
| 5 |
MongoDB |
Thùng hồ sơ dày |
Sổ cái phình vì đơn hàng 80 trường × triệu dòng |
| 6 |
Cron |
Nhân viên xem đồng hồ |
Ghi từng tin làm DB quá tải; việc kẹt không ai gỡ |
| 7 |
Fastify |
Cửa tiếp nhận nhanh |
Cửa chậm hơn cần thiết; gói tin quá to vào RAM |
| 8 |
Throttler |
Người đếm ở cổng |
Một máy gọi dồn dập chiếm hết chỗ |
| 9 |
class-validator |
Người soát phiếu |
Phiếu điền bậy lọt vào; gửi thêm trường lạ để ghi đè |
| 10 |
Hash theo giờ + timingSafeEqual |
Thẻ tự đổi mỗi giờ |
Giả mạo app; đoán thẻ bằng đo thời gian |
| 11 |
HMAC + rawBody |
Chữ ký số |
Webhook giả; phát lại tin cũ |
| 12 |
Firebase Admin |
Đường dây tới Google |
Gửi thông báo tới điện thoại |
| 13 |
axios + timeout |
Điện thoại có giờ cúp |
Bên ngoài treo kéo mình treo theo |
| 14 |
Lua script trên Redis |
Một lệnh gộp nhiều lệnh |
Ghi nửa chừng rồi chết |
| 15 |
Exception filter + redact |
Bộ phận sự cố có che |
Lộ khóa bí mật vào log |
| 16 |
Swagger |
Tờ hướng dẫn cửa sổ |
Đội mobile không biết gọi thế nào |
1. Amazon SQS — hòm thư ngoài cổng
Là gì. Dịch vụ hàng đợi của Amazon. Shopify không gọi thẳng vào máy chủ mình; Shopify bỏ thư vào hòm, mình ra lấy theo nhịp của mình.
Không có nó thì chết thế nào. Hãy tưởng tượng cụ thể: shop chạy flash sale, 1.000 đơn trong 10 giây. Shopify bắn 1.000 webhook thẳng vào API cùng lúc — thực ra là 3.000, vì mỗi đơn 2-3 webhook (tạo, thanh toán, giao). Máy chủ có, ví dụ, 50 chỗ tiếp nhận. 2.950 yêu cầu xếp hàng, mỗi yêu cầu chờ database → timeout → Shopify thấy lỗi → gửi lại → càng dồn → API chết → và khách mở app cũng chết theo, vì cùng một cửa. Đây là cảnh "1.000 request một lúc qua API gateway → die" mà bạn từng nghe.
Có nó thì sao. Shopify bỏ 3.000 thư vào hòm trong 10 giây — hòm của Amazon chịu được hàng triệu. Mình lấy 10 lá một lần, đọc xong xé, lấy tiếp. Cửa API cho khách không dính gì tới hòm thư. Đang restart 30 giây để deploy? Thư vẫn nằm trong hòm, mở lên đọc tiếp, không mất lá nào. Lấy ra mà chưa xé (máy chết giữa chừng) → 30 giây sau Amazon cho lấy lại.
Dùng ở đâu. 2 hòm — webhook Shopify (SQS_WEBHOOK_URL) và tracking từ app (SQS_APP_TRACKING_URL). Đọc bằng SqsService.pollMessages.
Cái giá. Trễ thêm ~1-2 giây (đường Amazon). Một lá thư có thể được đọc 2 lần → mọi việc phía sau phải chịu được làm 2 lần (idempotent — bài 00a).
Không thuê Amazon thì tự dựng thế nào
Câu hỏi này đáng trả lời, vì nó lộ ra nguyên lý đằng sau SQS chứ không phải thương hiệu. Ý cốt lõi: cái nhận thư phải nhỏ, ngu, và đứng riêng — chỉ bỏ thư vào hòm rồi nói "nhận rồi".
Shopify ──▶ [1] nginx ──▶ [2] Cửa nhận thư (process riêng, ~30 dòng) ──▶ [3] Redis (hòm) ──▶ worker đọc theo nhịp mình
Khách ──▶ [1] nginx ──▶ API chính (process riêng) ← không chung hàng chờ với cột trên
| Lớp |
Làm gì |
Vì sao |
| [1] nginx |
Giới hạn tốc độ đường webhook (ví dụ 500 cú/giây, xếp hàng thêm 1.000, dư nữa trả 503); giới hạn thân thư 1MB; chia webhook và API khách vào hai process |
Shopify thấy 503 sẽ tự gửi lại sau vài phút → không mất. Bão webhook không làm khách mở app chậm |
| [2] Cửa nhận thư |
Đúng 3 việc: kiểm chữ ký HMAC → ghi vào Redis (XADD Stream hoặc queue.add BullMQ) → 200. Không đọc sổ cái, không gọi ai |
Chịu 5.000-10.000 cú/giây một máy nhỏ. Không giữ trạng thái → cần thêm thì chạy 2-3 bản |
| [3] Redis |
Redis Stream (XREADGROUP / XACK / XAUTOCLAIM) — y hệt "lấy thư, xé thư, chưa xé thì người khác lấy lại" |
Bật ghi đĩa (AOF) để restart không mất; cảnh báo khi hòm dài bất thường (XLEN) |
Đây đúng là cách dự án đang nhận webhook Gorgias và Smile (cửa @SkipThrottle() → kiểm token → BullMQ → 200).
Phải tự lo những gì SQS cho không: cửa nhận thư restart → Shopify nhận lỗi vài giây → Shopify tự gửi lại trong 48h, nhưng Gorgias không gửi lại → cửa đó phải chạy ≥ 2 bản. Redis chết → cửa không bỏ thư được → trả 503, đừng trả 200 giả. Phải tách process webhook khỏi API khách.
Điều giữ URL sống là ba nguyên tắc (cửa chỉ bỏ thư + đứng riêng + gác cổng), không phải Amazon. Amazon chỉ bán cho bạn ba nguyên tắc đó đóng gói sẵn.
2. BullMQ — dây phiếu trong bếp
Là gì. Hàng đợi nội bộ, phiếu kẹp trên Redis. Có sẵn: thử lại, chờ tăng dần, gộp phiếu trùng, giới hạn số đầu bếp làm cùng lúc.
Không có nó thì chết thế nào. Chủ shop bấm "Gửi thông báo" cho 50.000 khách. Không có dây phiếu → máy chủ gửi ngay trong lúc chủ shop chờ: 100 lô × 2 giây = hơn 3 phút. Trình duyệt cúp sau 30 giây → chủ shop tưởng lỗi, bấm lại → 25.000 khách nhận 2 lần. Máy chủ restart giữa chừng → không biết gửi tới đâu, không làm lại được.
Có nó thì sao. Bấm → viết phiếu (0,5 giây) → "đã tiếp nhận". Đầu bếp nhặt phiếu ngay, gửi dần. Google lỗi → phiếu tự quay lại dây, thử lại sau 2s/4s/8s. Máy chủ chết → phiếu còn trên dây, máy khác nhặt. Khách đăng nhập 5 lần → 5 phiếu "đồng bộ khách #123" gộp thành 1 nhờ số phiếu.
Dùng ở đâu. 7 dây trong nhà (gửi thông báo, đồng bộ khách, nhập đơn cũ, webhook Gorgias, webhook Smile, dọn shop gỡ app) và 6 dây cross-* báo sang hệ thống quản trị.
Khác SQS chỗ nào — câu hỏi hay gặp. SQS là hòm giữa Shopify và mình — bên ngoài không có quyền vào bếp, nên phải có hòm ở cổng. BullMQ là dây trong nhà: nhanh hơn (< 0,1 giây), nhiều tính năng hơn (retry, dedup, priority), nhưng chỉ mình dùng. Hai thứ không thay thế nhau; chúng ở hai vị trí khác nhau trong luồng.
3. Redis — bảng trắng dán tường
Là gì. Bộ nhớ cực nhanh, ghi/đọc mili giây, đặt được hạn tự xóa, có cấu trúc dữ liệu (danh sách có thứ tự, tập hợp, bảng con).
Không có nó thì chết thế nào — ba ví dụ, vì Redis chống ba thứ khác nhau:
- Hộp thư: 200.000 lần mở app mỗi ngày, mỗi lần hỏi sổ cái "20 thông báo mới nhất của máy này + đếm chưa đọc" trên bảng hàng trăm triệu dòng → database dành cả ngày trả lời câu này, chủ shop mở dashboard thì chờ.
- Giẫm đạp: thông tin điểm thưởng hết hạn cache, 500 khách cùng mở → 500 lần hỏi Smile cùng lúc → Smile chặn → 500 khách lỗi.
- Bão xin chìa khóa: Shopify từ chối chìa khóa, 50 yêu cầu đang chạy cùng bị từ chối → 50 lần "xin cấp lại" bắn sang hệ thống quản trị.
Có nó thì sao. Hộp thư mỗi máy nằm trên bảng: mở app = 3 lần hỏi Redis, không đụng sổ cái. Khóa "đang có người đi hỏi Smile" → 1 người hỏi, 499 người dùng bản cũ hoặc chờ 1 giây. Cờ "vừa xin chìa khóa, 60 giây nữa hãy xin" → 50 từ chối = 1 lần xin. Đơn nháp dán lên bảng, cùng mã đơn thì đè → 3 webhook = 1 note.
Dùng ở đâu. Hộp thư (mab:*), cache shop/Firebase, đơn nháp (orders:*), sự kiện nháp, khóa Smile, cờ lỗi Shopify, bộ đếm Gorgias, giấy hẹn giờ, và cả dây phiếu BullMQ. Bài 04 mổ từng thứ.
Cái giá — và nguyên tắc sống còn. Redis hỏng thì mất bảng → mọi chỗ đều "lỗi thì bỏ qua, tra sổ cái", không được để tính năng chết theo. Nếu bạn viết code Redis mà không có catch bỏ qua, bạn vừa biến một tối ưu thành một điểm chết.
4. PostgreSQL + Prisma — sổ cái có luật
Là gì. Database quan hệ. Prisma là lớp giúp code nói chuyện với nó và giữ luật.
Không có luật thì chết thế nào. Webhook đơn #1001 đến 3 lần → 3 dòng đơn → doanh thu gấp 3. Ghi "thông báo XONG" xong thì chết trước khi ghi "số liệu" → trạng thái nửa vời. Tra thông báo chỉ theo số → shop A đoán số là xem được thông báo shop B. Shop gỡ app, cài lại → thấy 200 thông báo cũ.
Có nó thì sao. Luật "shop + mã đơn chỉ được một dòng" (@@unique) → 3 webhook = 1 dòng, database tự chặn, không cần code kiểm tra. Transaction: "đổi trạng thái + ghi số liệu" cả gói hoặc không gì. Mọi bảng đóng dấu shop, mọi index bắt đầu bằng shop. Bộ lọc "bỏ qua dòng đã xóa" cài ở cửa tủ, một chỗ.
Từ khóa ở đây là "database tự chặn". Kiểm tra bằng code ("có chưa rồi mới thêm") không an toàn — hai request kiểm tra cùng lúc đều thấy "chưa có" rồi cùng thêm. Ràng buộc trong database là thứ duy nhất chặn được race condition này. Bài 02 giảng sâu.
Dùng ở đâu. Thông báo, lịch sử gửi, thiết bị, khách, đơn, phân khúc, báo cáo ngày, credential.
5. MongoDB — thùng hồ sơ dày
Là gì. Database dạng tài liệu, mỗi bản ghi là một cục JSON tùy ý.
Không có nó thì sao. Toàn bộ đơn hàng app + web để tính báo cáo — mỗi đơn 80 trường, nhiều tầng. Nhét vào Postgres cùng bảng với thông báo/thiết bị → bảng phình, index nặng, chậm cả những cái không liên quan.
Có nó thì sao. Đơn nguyên cục vào thùng riêng. Chỉ đọc nguyên cục, chỉ cộng số. Sổ cái Postgres gọn.
Dùng ở đâu. AllOrder (nhập từ webhook và từ bulk export Shopify).
Chỗ hay hiểu sai. Không phải "Mongo nhanh hơn Postgres". Mà là: dữ liệu này không cần ràng buộc, không cần JOIN, chỉ đọc nguyên khối — nên đặt nó vào chỗ không phải trả giá cho những thứ không dùng. Nếu một ngày cần JOIN đơn với khách, Mongo là chọn sai.
6. Cron — nhân viên xem đồng hồ
Là gì. Việc chạy theo lịch: mỗi 5 phút, mỗi giờ, 0:00, 9:00.
Không có nó thì chết thế nào. Mỗi webhook đơn ghi thẳng sổ cái + tính lại báo cáo ngày → flash sale 5.000 đơn/phút = 5.000 lần tính lại cùng một con số. Database bận cộng, khách chờ. Và: thông báo kẹt ở "ĐANG GỬI" vì máy chết → kẹt mãi mãi, chủ shop không thao tác được.
Có nó thì sao. 5 phút gom note đơn vào sổ, tính lại báo cáo một lần cho những ngày bị đụng. 5 phút tìm thông báo kẹt > 15 phút → gỡ. 6 giờ kéo Google Analytics, hàng ngày đồng bộ marketing — chạy lúc vắng.
Dùng ở đâu. 9 cron (bài 00b).
Không dùng cho. Thông báo đẩy. Cron không đứng trên đường gửi. Đây là điều tôi nhắc đi nhắc lại vì nó là hiểu lầm số một.
7. Fastify — cửa tiếp nhận nhanh
Là gì. Khung web thay Express, xử lý nhiều yêu cầu/giây hơn ~2 lần, phân tích JSON nhanh hơn.
Chống gì. Cửa chậm hơn cần thiết khi 3.000 khách mở app cùng phút. Gói tin 500MB đọc hết vào bộ nhớ → hết RAM: Fastify chặn ở 10MB, trước khi mở.
Bug thật đáng nhớ. Fastify mặc định đường dẫn tối đa 100 ký tự. Token ảnh hỗ trợ dài ~380 → router không khớp → trả 404 "Cannot GET" im lặng, không log gì, y hệt route không tồn tại. Tính năng xem ảnh trong chat hỗ trợ chưa từng chạy được kể từ ngày ra đời. Sửa: maxParamLength: 2000. Bài học: giới hạn mặc định của framework là thứ bạn phải biết, và lỗi im lặng là lỗi đắt nhất.
8. Throttler — người đếm ở cổng
Là gì. Đếm số lần một địa chỉ gọi trong 60 giây, quá thì trả 429 "chờ chút".
Không có nó thì sao. Một app dính lỗi vòng lặp gọi 1.000 lần/giây → chiếm hết 50 chỗ tiếp nhận → mọi shop khác chậm. Không cần hacker — chỉ cần một bug ở client.
Có nó thì sao. Máy đó bị chặn ở cổng, chưa tốn gì bên trong.
Ngoại lệ có chủ đích. Cửa webhook Gorgias tắt đếm — vì Gorgias tắt kết nối của cả tài khoản nếu thấy lỗi. Cửa đó bảo vệ bằng token, không bằng đếm. Bài học: mọi ngoại lệ phải có comment "vì sao" ngay cạnh.
Giới hạn. Đếm theo IP chỉ chặn máy lẻ. Chặn "một shop lớn ăn hết tài nguyên" phải đếm theo shop — bài 10 nói.
9. class-validator — người soát phiếu
Là gì. Khai mẫu phiếu cho mỗi cửa; phiếu sai kiểu, sai khoảng, thừa mục → trả 400 hoặc cắt mục thừa.
Không có nó thì chết thế nào. limit=1000000 → database kéo cả triệu dòng. sortBy=password → sắp xếp theo cột tùy ý, lộ cấu trúc hoặc lỗi. Cập nhật thông báo mà gửi kèm shopDomain: "shop-khac" → thông báo chuyển sang shop khác.
Có nó thì sao. limit tối đa 100, sortBy chỉ 3 giá trị, mục lạ bị cắt trước khi vào trong (chống mass assignment).
Ví dụ thứ ba là nghiêm trọng nhất và ít người nghĩ tới: không phải input sai kiểu, mà là input thừa. Không có whitelist, mọi trường trong body đều có thể ghi đè vào model.
10. Hash theo giờ + so sánh đều tay — thẻ tự đổi
Là gì. App tính mã = băm(bí mật + giờ hiện tại + tên shop), gửi kèm. Máy chủ tính lại, so.
Không có nó thì sao. Mật khẩu cố định trong app → ai tải app, mở ra (decompile) là có mật khẩu dùng mãi. So sánh thường (===) dừng ở ký tự đầu khác → đo thời gian phản hồi là đoán dần từng ký tự (timing attack).
Có nó thì sao. Mã lộ chỉ sống ≤ 1 giờ, chỉ cho đúng shop. So sánh luôn chạy hết chiều dài. Không cần bảng vé, không cần Redis — chỉ tính toán thuần.
11. HMAC + rawBody — chữ ký số cho webhook
Là gì. Smile ký thời gian + nội dung thô bằng bí mật chung. Mình tính lại, so.
Không có nó thì sao. Ai biết địa chỉ webhook là gửi được "khách X vừa nhận 1.000 điểm" giả → mình push thông báo giả. Hoặc bắt được một tin thật, gửi lại 100 lần.
Có nó thì sao. Sai chữ ký → 401. Tin cũ hơn 5 phút → từ chối (chống phát lại).
Chi tiết dễ sai. Phải giữ nội dung thô đúng từng byte (rawBody: true). Nếu cổng tự đọc JSON rồi ghi lại (khoảng trắng, thứ tự khóa đổi) → chữ ký lệch → từ chối nhầm mọi webhook.
12. Firebase Admin — đường dây tới Google
Là gì. Thư viện gửi thông báo đẩy qua Google (FCM), tới cả iOS lẫn Android.
Chống gì. Không thay thế được — muốn điện thoại rung phải qua đây. Điều dự án làm thêm mới đáng học:
- Gửi theo lô 500 (giới hạn Google).
- Kiểm tra chìa khóa đúng dự án trước khi gửi → sai thì nổ 1 lỗi rõ, không rải 50.000 lỗi nhỏ rồi tắt nhầm máy tốt.
- Chỉ tắt máy khi lỗi thuộc về máy (token hết hạn), không tắt khi lỗi cấu hình.
- Không nhầm "dự án đầy 30 app" với "tạo app nhanh quá" (cùng mã 429) — sự cố thật từng làm cạn cả kho dự án. Bài 06 kể đầy đủ.
13. axios + timeout — gọi ra ngoài có giờ cúp
Là gì. Thư viện gọi HTTP, mọi cuộc gọi đều đặt giờ cúp: Shopify 15s, Gorgias 15s, Discord 5s, tải file 120s.
Không có nó thì sao. Shopify treo, không trả lời không ngắt → 200 yêu cầu của mình đứng chờ → 2.000 → hết kết nối, hết RAM → mình chết vì Shopify chết, dù khách mở hộp thư không cần Shopify.
Có nó thì sao. Kẹt tối đa = số yêu cầu trong 15 giây, không tăng vô hạn.
(bottleneck trong dependencies là thư viện giới hạn tốc độ gọi — cùng mục đích với bộ đếm Gorgias tự viết.)
14. Lua script trên Redis — một lệnh gộp nhiều lệnh
Là gì. Viết 4 lệnh Redis thành một đoạn, Redis chạy trọn gói không bị chen.
Không có nó thì sao. Ghi thông báo vào hộp thư = 4 lệnh (ghi nội dung, thêm vào inbox, thêm vào chưa-đọc, dọn cũ). Máy chết sau lệnh 2 → inbox có mã mà không có nội dung → app hiện ô trống.
Có nó thì sao. 4 lệnh = 1, hoặc cả 4 hoặc không gì. Tương tự khóa Smile: "xóa nếu vẫn là khóa của tôi" — hai bước so + xóa không thể bị chen.
Đây là "transaction" của Redis. Bất kỳ khi nào bạn viết 2 lệnh Redis liên tiếp mà lệnh sau phụ thuộc lệnh trước, hỏi: nếu chết giữa hai lệnh thì sao?
15. Exception filter + redact — bộ phận sự cố có che
Là gì. Mọi lỗi đều qua một chỗ: trả lời khách gọn, ghi log đủ để debug nhưng che bí mật.
Không có nó thì sao. Chủ shop nhập cấu hình Firebase (chứa khóa riêng tư) sai một mục → lỗi validation → log ghi nguyên phiếu → khóa riêng tư nằm trong file log. Khách tải ảnh hỗ trợ gặp 503 → log ghi URL có token → ai đọc log là tải được ảnh riêng của khách.
Có nó thì sao. authorization, x-customer-token, serviceAccountKey, token trên URL… đều thành [REDACTED]. Lỗi 500 chỉ nói "Internal Server Error". JSON sai cú pháp → 400 kèm gợi ý thay vì 500 khó hiểu.
16. Swagger — tờ hướng dẫn cửa sổ
Là gì. Trang /api/docs tự sinh từ code, liệt kê mọi cửa, mẫu phiếu, ví dụ.
Chống gì. Đội mobile hỏi "gọi thế nào" qua chat; tài liệu viết tay lệch code. Chỉ bật ở dev.
Cách dùng bài này khi thiết kế
Gặp một yêu cầu mới, hỏi theo thứ tự — mỗi câu dẫn tới một công nghệ:
1. Ai gọi vào, có thể dồn không? → SQS (bên ngoài) / Throttler (khách).
2. Việc > 1 giây hay gọi bên ngoài? → BullMQ.
3. Hỏi lặp đi lặp lại cùng câu? → Redis cache. Nhiều người cùng làm một việc? → Redis lock.
4. Dữ liệu không được mất/trùng? → Postgres + unique + transaction.
5. Có thể gom lô, không ai chờ? → Cron.
6. Gọi ra ngoài? → timeout + retry khớp lý do + tự đếm trước rate-limit.
7. Có bí mật đi qua? → redact + fail-closed.
Và câu hỏi ngược, quan trọng không kém: công nghệ nào trong dự án bạn có thể bỏ mà không ai chết? Nếu trả lời được, bạn hiểu dự án hơn người viết nó.
Tự kiểm tra
- SQS và BullMQ đều là hàng đợi. Vì sao cần cả hai? Bỏ một cái thì gãy ở đâu?
- Lua script trên Redis giải quyết vấn đề gì mà 4 lệnh riêng lẻ không giải quyết được?
- Bug
maxParamLength dạy bạn điều gì về framework?
- Ba công nghệ nào trong 16 cái là "fail-closed" (hỏng thì chặn)? Vì sao chúng phải closed?
Bài tập
- Mở
package.json của notify-service. Với mỗi dependency không có trong bảng 16, tìm xem nó dùng ở đâu. Có cái nào không dùng không?
- Chọn một công nghệ, tưởng tượng bỏ nó đi. Viết một kịch bản 5 dòng "hệ thống chết thế nào" giống mục "Không có nó thì chết" ở trên.
00d · Dịch vụ Amazon
Bài 00d — Dịch vụ Amazon (AWS): cái nào làm gì, và không thuê thì thay bằng gì
Bài này trả lời câu hỏi gì
AWS có ~200 dịch vụ. Người mới nhìn vào console và hoảng. Sự thật: backend cỡ notify-service chỉ đụng ~15, và mỗi cái đều tương ứng với một khái niệm bạn đã học ở bài 00a-00c — hòm thư, loa, sổ cái, bảng trắng, kho đồ, két sắt, chuông báo. AWS không phát minh khái niệm; AWS bán khái niệm đóng gói sẵn.
Cách học đúng: với mỗi dịch vụ, hỏi ba câu — nó là ví von gì? dự án có dùng không? không thuê thì tự dựng bằng gì? Câu thứ ba là quan trọng nhất, vì nó buộc bạn hiểu nguyên lý thay vì thuộc tên thương hiệu. Người hiểu nguyên lý chuyển từ AWS sang GCP hay VPS tự dựng trong một buổi. Người thuộc tên thì kẹt.
Ký hiệu: ✅ đang dùng · 🟡 gián tiếp (Shopify/hạ tầng dùng, mình hưởng) · ⬜ chưa dùng, nên biết.
Nhóm A — Nhắn tin, hàng đợi, sự kiện (họ hàng của SQS)
Đây là nhóm quan trọng nhất với bạn, vì nó là trái tim của kiến trúc bất đồng bộ. Bốn dịch vụ, bốn hình dạng khác nhau của cùng một ý "để lại phiếu":
|
Dịch vụ |
Ví von |
Làm gì |
Khi nào dùng |
Thay thế tự dựng |
| ✅ |
SQS |
Hòm thư ngoài cổng |
Một người bỏ thư, một nhóm người lấy ra xử lý. Thư nằm đó tới khi xé. Lấy mà chưa xé → 30s sau người khác lấy |
Bên ngoài bắn nhiều, mình xử lý theo nhịp mình. Chặn bão webhook |
Redis Stream / BullMQ + cửa nhận riêng (bài 00c mục 1) |
| 🟡 |
EventBridge |
Tổng đài phân loại thư |
Nhận sự kiện từ Shopify (có tích hợp sẵn), lọc theo loại, đẩy vào SQS/Lambda tương ứng |
Một nguồn sự kiện chia cho nhiều nơi theo luật |
Tự viết cửa webhook + if/else theo topic |
| ⬜ |
SNS |
Loa phát thanh |
Một tin phát cho nhiều người nghe cùng lúc (fan-out): mỗi SQS, mỗi email, mỗi SMS |
Một sự kiện cần 3 hệ thống cùng biết |
Redis pub/sub (nhưng mất tin khi không ai nghe — dự án đã bỏ vì lý do này) |
| ⬜ |
Kinesis |
Băng chuyền ghi âm liên tục |
Luồng dữ liệu rất lớn (triệu sự kiện/giây), giữ 24h-7 ngày, nhiều người đọc lại từ đầu |
Log/tracking khổng lồ, phân tích realtime |
Kafka tự nuôi; Redis Stream ở quy mô nhỏ |
| ⬜ |
Step Functions |
Sơ đồ quy trình |
Nối nhiều bước thành quy trình có nhánh, retry, chờ |
Quy trình dài nhiều bước, cần nhìn thấy đang ở bước nào |
BullMQ Flow (job cha-con) |
Ba cái hay bị lẫn — phân biệt bằng một câu: SQS = hòm thư, một người lấy. SNS = loa, nhiều người nghe cùng lúc. EventBridge = tổng đài, lọc theo luật rồi mới chuyển.
Chi tiết đáng chú ý: lá thư trong SQS của dự án có dạng detail.metadata — đó là định dạng EventBridge. Nghĩa là Shopify → EventBridge → SQS, chứ không phải Shopify bỏ thẳng vào SQS. Bạn đang hưởng EventBridge mà không cấu hình gì.
Vì sao dự án bỏ Redis pub/sub để sang BullMQ queue (bài 01 sẽ nói kỹ): pub/sub là loa — ai đang nghe thì nghe, không ai nghe thì tin bay mất. Hệ thống quản trị đang restart đúng lúc notify-service hô "shop X đổi thông tin" → mất vĩnh viễn. SNS cũng là loa, nhưng SNS → SQS thì thành "loa phát vào hòm thư" — hòm giữ tin. Dự án chọn cách rẻ hơn: BullMQ queue chung Redis.
Nhóm B — Chạy code
|
Dịch vụ |
Ví von |
Làm gì |
Khi nào dùng |
Thay thế |
| 🟡 |
EC2 |
Thuê máy tính |
Một máy ảo, mình cài gì cũng được |
Cần kiểm soát toàn bộ; cách truyền thống |
VPS bất kỳ |
| ⬜ |
ECS / Fargate |
Thuê chỗ chạy container |
Đưa Docker image (dự án có Dockerfile), AWS chạy, tự nhân bản khi đông |
App NestJS như dự án này — chạy 2-3 bản, có load balancer |
Docker Compose trên VPS; Kubernetes |
| ⬜ |
Lambda |
Thuê người làm một việc rồi về |
Một hàm chạy khi có sự kiện, tính tiền theo mili giây, không có máy chủ chạy nền |
Việc nhỏ, lẻ tẻ, không cần kết nối lâu |
Cửa nhận thư tự viết chạy nền |
| ⬜ |
App Runner / Elastic Beanstalk |
Thuê trọn gói |
Đưa code, AWS lo hết |
Team nhỏ không muốn học ECS |
Render, Railway, Fly.io |
Lambda hợp với cái gì trong dự án? Cửa nhận webhook — đúng 3 việc: kiểm chữ ký → bỏ vào queue → 200. Không giữ trạng thái, chạy vài mili giây, có sự kiện mới chạy. Đó là hình mẫu Lambda. Không hợp với worker gửi push (chạy lâu, giữ kết nối Firebase) hay cron gom đơn (cần kết nối DB ổn định).
Nhóm C — Lưu trữ
|
Dịch vụ |
Ví von |
Làm gì |
Khi nào dùng |
Thay thế |
| ⬜ |
RDS |
Sổ cái có người trông |
Postgres/MySQL do AWS nuôi: sao lưu, vá lỗi, nhân bản đọc |
Postgres của dự án nên nằm đây thay vì tự cài trên EC2 |
Supabase, Neon, tự cài |
| ⬜ |
ElastiCache |
Bảng trắng có người trông |
Redis do AWS nuôi, tự chuyển máy khi hỏng |
Redis của dự án ở production |
Upstash, Redis Cloud, tự cài |
| ⬜ |
DocumentDB |
Thùng hồ sơ có người trông |
Mongo-tương-thích do AWS nuôi |
Thùng AllOrder |
MongoDB Atlas |
| ⬜ |
S3 |
Kho đồ vô hạn |
Lưu file: ảnh, export, backup. Rẻ, không giới hạn, có link tải |
Ảnh đính kèm hỗ trợ Gorgias hiện tải qua bưu điện → có thể lưu S3 + link tạm |
MinIO tự dựng, Cloudflare R2 |
| ⬜ |
DynamoDB |
Tủ ngăn kéo tra theo khóa |
Key-value, không schema, chịu triệu yêu cầu/giây, tính tiền theo lượt |
Bảng tra theo một khóa cực nhanh |
Redis + Postgres như hiện tại |
"Có người trông" nghĩa là gì, và đáng tiền không? Tự cài Postgres trên EC2: bạn lo backup, lo vá bảo mật, lo replica, lo đĩa đầy, lo 3h sáng máy chết. RDS lo hết, đổi lại đắt hơn ~30-50%. Với team nhỏ, thời gian kỹ sư đắt hơn chênh lệch đó. Đây thường là dịch vụ đáng thuê nhất khi lên production.
Nhóm D — Cửa vào và mạng
|
Dịch vụ |
Ví von |
Làm gì |
Khi nào dùng |
Thay thế |
| 🟡 |
ALB |
Người chia khách vào bàn |
Nhận HTTP, chia đều cho 2-3 bản app, tự bỏ bản đang chết |
Chạy > 1 bản app |
nginx (dự án có nginx phía trước) |
| ⬜ |
API Gateway |
Cổng có soát vé |
Đứng trước API: đếm lượt theo khách (rate limit theo key), xác thực, ghi log |
Muốn giới hạn theo shop thay vì theo IP — việc Throttler không làm được |
nginx + Redis đếm; Kong; Cloudflare |
| ⬜ |
CloudFront |
Chi nhánh gần khách |
CDN: chép nội dung tĩnh ra hàng trăm điểm gần khách |
Ảnh thông báo, ảnh sản phẩm cho app |
Cloudflare CDN |
| ⬜ |
Route 53 |
Danh bạ |
DNS, kiểm tra máy còn sống để chuyển hướng |
Tên miền + failover |
Cloudflare DNS |
| ⬜ |
WAF |
Bảo vệ ở cổng |
Chặn tấn công web (SQL injection, bot, DDoS) trước khi vào ALB |
Public API bị quét |
Cloudflare WAF |
API Gateway giải quyết đúng lỗ hổng bài 00c nêu ở Throttler: đếm theo IP chỉ chặn máy lẻ. Một shop 500.000 khách gửi push đồng loạt → 500.000 IP khác nhau cùng mở app → throttle IP vô nghĩa. Cần đếm theo shop — API Gateway làm được, hoặc nginx + Redis INCR tự viết.
Nhóm E — Vận hành, bảo mật
|
Dịch vụ |
Ví von |
Làm gì |
Khi nào dùng |
Thay thế |
| ⬜ |
CloudWatch |
Camera + chuông báo |
Gom log, vẽ biểu đồ, đặt chuông ("hòm SQS > 10.000 thư → gọi tôi") |
Mọi cảnh báo trong bài 08 mục D nên đặt ở đây |
Grafana + Loki, Datadog, Axiom (dự án có createAxiomStream) |
| ⬜ |
Secrets Manager / Parameter Store |
Két sắt |
Giữ HASH_KEY, chìa khóa Firebase, mật khẩu DB; app lấy lúc khởi động; tự xoay định kỳ |
Thay vì file .env.prod nằm trên máy |
Vault, Doppler |
| 🟡 |
IAM |
Thẻ ra vào từng phòng |
Quyền: process này chỉ được đọc hòm SQS này, không được xóa S3 |
Dự án ở prod không dùng access key mà dùng vai trò IAM của máy |
— |
| ⬜ |
SES |
Bưu điện email |
Gửi email giao dịch, rẻ |
Nếu thông báo cần thêm kênh email |
SendGrid, Postmark |
| ⬜ |
X-Ray |
Theo dấu một yêu cầu |
Vẽ đường một yêu cầu đi qua ALB → app → SQS → worker → DB, mất bao lâu mỗi chặng |
Tìm chỗ chậm |
OpenTelemetry + Jaeger |
Vì sao IAM role tốt hơn access key: access key là mật khẩu — lộ là mất. IAM role gắn vào máy: máy đó tự có quyền, không có chuỗi nào để lộ, không phải xoay. Dự án dùng đúng ở prod (NODE_ENV=prod nhánh trong SqsService).
Ghép lại: notify-service trên AWS "chuẩn" trông thế nào
Shopify ──▶ EventBridge ──▶ SQS (webhook) ──┐
App SDK ──▶ SQS (tracking) ─────────────────┤
▼
Khách/Shop ──▶ Route53 ──▶ WAF ──▶ ALB ──▶ ECS/Fargate: 2-3 bản notify-service
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
RDS Postgres ElastiCache Redis DocumentDB/Atlas
│
S3 (ảnh, export) Secrets Manager (khóa) CloudWatch (log + chuông)
Cái đang có: SQS ✅, EventBridge 🟡, IAM 🟡, nginx thay ALB.
Cái nên thêm, theo thứ tự lợi ích trên chi phí:
1. CloudWatch chuông báo — rẻ nhất, cứu sớm nhất. Không có chuông thì mọi thứ khác chỉ giúp bạn chết chậm hơn.
2. Secrets Manager — bỏ .env.prod khỏi máy.
3. ElastiCache / RDS — bớt nuôi Redis/Postgres tay.
4. API Gateway — chỉ khi cần giới hạn theo shop.
Thứ tự này là bài học: quan sát trước, bảo mật thứ hai, giảm việc vận hành thứ ba, tính năng mới cuối.
Bảng tra nhanh "tôi cần X → dùng gì"
| Cần |
AWS |
Không AWS |
| Nhận bão webhook không chết |
SQS (+ EventBridge nếu nguồn hỗ trợ) |
nginx + cửa nhận riêng + Redis Stream |
| Một sự kiện báo nhiều nơi |
SNS → nhiều SQS |
BullMQ nhiều queue (cách dự án đang làm) |
| Chạy việc lẻ khi có sự kiện |
Lambda |
Worker BullMQ |
| Chạy app, tự nhân bản |
ECS/Fargate + ALB |
Docker + nginx trên VPS |
| Postgres/Redis không phải nuôi |
RDS / ElastiCache |
Neon / Upstash |
| Lưu file |
S3 |
MinIO / R2 |
| Giới hạn theo shop |
API Gateway |
nginx + Redis đếm |
| Chuông báo |
CloudWatch |
Grafana / Axiom |
| Giữ bí mật |
Secrets Manager |
Vault / Doppler |
Tự kiểm tra
- SQS, SNS, EventBridge — mỗi cái một câu phân biệt. Dự án dùng cái nào, hưởng cái nào?
- Vì sao Redis pub/sub bị bỏ? SNS có cùng vấn đề không? Vì sao SNS → SQS thì ổn?
- Lambda hợp với thành phần nào của notify-service, không hợp với thành phần nào? Vì sao?
- Nếu chỉ được thuê thêm một dịch vụ AWS, bạn chọn gì và vì sao?
Bài tập
- Vẽ lại sơ đồ "notify-service trên AWS chuẩn" nhưng không dùng AWS — mỗi ô thay bằng công cụ tự dựng hoặc nhà cung cấp khác.
- Với 3 dịch vụ ⬜ bạn thấy hữu ích nhất, viết một đoạn 3 dòng: dùng cho tính năng nào, chi phí ước lượng, cái gì phải đổi trong code.
01 · Kiến trúc tổng quan
Bài 01 — Kiến trúc tổng quan: một yêu cầu đi qua những cửa nào
Bài này trả lời câu hỏi gì
Bài 00a-00d cho bạn từng viên gạch. Bài này xếp chúng thành ngôi nhà. Câu hỏi: khi nhiều thứ đổ vào cùng lúc — shop bấm gửi 50.000 push, 3.000 khách mở app, Shopify bắn 500 webhook, một shop mới cài app — hệ thống sắp xếp ai làm gì để không ai chặn ai?
Câu trả lời là một nguyên tắc duy nhất, và cả bài này là các hệ quả của nó: tách quầy tiếp nhận khỏi xưởng làm việc.
Phần 1 — Câu chuyện và cái chết ngây thơ
Shop "Áo Xinh" có 50.000 khách cài app. Chủ shop bấm "Gửi thông báo: Sale 50% hôm nay". Cùng phút đó: 3.000 khách đang mở app xem hộp thư; Shopify bắn về 500 tin "đơn hàng mới" vì sale đang chạy; một shop khác vừa cài app, cần tạo cấu hình Firebase.
Cách ngây thơ: chủ shop bấm gửi → máy chủ ngồi gửi từng thông báo cho 50.000 điện thoại → mất 2 phút → trình duyệt của chủ shop báo timeout → chủ shop bấm lại → gửi trùng 100.000 lần. Trong lúc đó 3.000 khách mở app phải xếp hàng sau vụ gửi này. Shopify gửi tin không ai nhận kịp → Shopify gửi lại → càng nghẽn.
Tôi muốn bạn thấy điều này: hệ thống sập vì chính khách hàng của mình, không cần hacker. Và nguyên nhân không phải thiếu máy — mà là một việc chậm đứng chung hàng với mọi việc khác.
Phần 2 — Tách quầy tiếp nhận khỏi xưởng
KHÁCH / SHOP / SHOPIFY
│
┌───────────▼────────────┐
│ QUẦY TIẾP NHẬN │ ← nhanh, chỉ kiểm tra giấy tờ + ghi phiếu
│ (HTTP API, SQS) │ trả biên lai trong < 1 giây
└───────────┬────────────┘
│ ghi phiếu vào
┌───────────▼────────────┐
│ KHO + BẢNG GHI NHỚ │ ← Postgres (kho chính), Redis (bảng dán tường),
│ + BĂNG CHUYỀN │ BullMQ (băng chuyền việc cần làm)
└───────────┬────────────┘
│ lấy phiếu ra làm
┌───────────▼─────────────────────────────────────────────────┐
│ XƯỞNG LÀM VIỆC — hai loại nhân viên KHÁC NHAU │
│ │
│ WORKER (băng chuyền BullMQ) CRON (nhân viên đi tuần) │
│ Có phiếu là nhặt NGAY (< 100ms) Chạy theo lịch: 5 phút, │
│ gửi Firebase, đồng bộ khách 6 tiếng, nửa đêm │
│ từ Shopify, nhập đơn cũ, nhập đơn nháp vào kho, │
│ dọn shop gỡ app tính báo cáo, gỡ kẹt, │
│ kéo Google Analytics │
│ → việc cần XONG SỚM → việc gom lô, không ai chờ│
└──────────────────────────────────────────────────────────────┘
Ba tầng, và mỗi tầng có một việc duy nhất:
- Quầy xác minh và ghi. Không làm việc nặng. Trả lời dưới 1 giây.
- Kho + băng chuyền giữ trạng thái và giữ phiếu. Đây là chỗ hai tầng kia gặp nhau — quầy ghi vào, xưởng đọc ra. Chúng không nói chuyện trực tiếp.
- Xưởng làm việc nặng. Không ai chờ xưởng.
Vì sao tách qua kho thay vì quầy gọi thẳng xưởng? Vì kho giữ được phiếu khi xưởng chết. Quầy gọi thẳng xưởng = gọi hàm; xưởng crash là mất. Quầy ghi vào kho, xưởng tự đọc = phiếu nằm đó cho tới khi có ai làm.
Điều hay hiểu sai nhất
Thông báo đẩy KHÔNG đi qua cron. Merchant bấm gửi → phiếu lên băng chuyền → worker nhặt ngay → Firebase. Cron chỉ đụng tới đơn hàng, báo cáo, và dọn dẹp. Người mới thấy "xử lý sau" là nghĩ ngay tới cron; nhưng "sau" có hai loại — ngay khi có người rảnh (worker) và đến giờ (cron) — và push là loại đầu.
Phần 3 — Ba đường đi thực tế và thời gian
| Việc |
Đường đi |
Từ lúc bấm/xảy ra tới lúc điện thoại rung |
| Merchant bấm "Gửi" cho 50.000 khách |
HTTP → ghi kho + hộp thư (~0.5s) → BullMQ → worker nhặt ngay → Firebase từng lô 500 |
Máy đầu ~2s; toàn bộ 50.000 máy ~1-3 phút |
| Khách thanh toán → push "cảm ơn" tự động |
Shopify → SQS (~1-2s) → cửa SQS đọc → gọi gửi thông báo ngay trong handler → BullMQ → Firebase |
~3-5s |
| Đơn hàng vào kho để tính báo cáo |
Shopify → SQS → dán note Redis → cron 5 phút gom vào Postgres → tính lại báo cáo |
Tối đa 5 phút |
Nhìn hàng 2 và 3: cùng một webhook "đơn đã thanh toán" đi hai nhánh song song trong cùng một handler. Nhánh thông báo đi ngay, nhánh lưu đơn đi cron. Mở shopify.sqs.service.ts → handleOrderWebhook: cacheOrder(...) và sendAutoNotifications(...) nằm cạnh nhau. Đây là hình ảnh cụ thể nhất của "mỗi việc đi đường riêng với tốc độ riêng".
"Realtime" với push nghĩa là gì. Vài giây, không phải mili giây. Firebase/Apple/Google tự nó mất 1-5s và còn gom lô phía điện thoại. Mục tiêu hệ thống: trả lời merchant < 1s, máy đầu rung < 5s, 50.000 máy xong trong vài phút. Ai hứa hơn thế là chưa đo.
Trần hiện tại và cách vượt. 100 lô Firebase gọi tuần tự (for + await trong firebase-admin.factory.ts → sendManyNotifications). Muốn 50.000 máy xong dưới 30s: gọi 5-10 lô song song, hoặc tách một phiếu lớn thành 100 phiếu nhỏ để nhiều worker cùng nhặt. Đây là loại quyết định bạn sẽ phải đưa ra: biết trần ở đâu, và biết cách nâng trần khi cần — không nâng trước khi cần.
Phần 4 — Quầy có ba cửa, một nguyên tắc
| Cửa |
Ai đến |
Quầy làm gì rồi trả về ngay |
| API HTTP |
App mobile, chủ shop |
Kiểm tra thẻ → kiểm tra phiếu → ghi kho/Redis hoặc đặt phiếu → trả kết quả |
| SQS |
Shopify webhook, SDK tracking |
Đọc tin → ghi nháp lên Redis → xác nhận với Amazon |
| Webhook trực tiếp |
Gorgias, Smile |
Kiểm tra chữ ký → đặt phiếu → luôn trả "OK" |
Điểm chung: quầy không bao giờ tự đi gọi Google/Shopify. Việc đó là của xưởng. Khi review code, đây là câu hỏi đầu tiên: handler này có await gọi ra ngoài không? Có → quầy đang làm việc xưởng.
Vì sao ba loại cửa chứ không một? Vì ba nguồn có luật khác nhau: app mobile cần xác thực theo shop; Shopify hỗ trợ EventBridge/SQS nên đi qua hòm; Gorgias/Smile không hỗ trợ hòm nên phải nhận trực tiếp, và Gorgias có luật phạt lỗi nên phải luôn trả 200. Bài 03 mổ từng cửa.
Phần 5 — Xưởng có hai kiểu nhân viên
- Worker băng chuyền (BullMQ): thấy phiếu là làm. Gửi 50.000 thông báo, đồng bộ hồ sơ khách từ Shopify, nhập hàng loạt đơn cũ, dọn dữ liệu khi shop gỡ app. Hỏng → phiếu quay lại băng chuyền, thử lại sau.
- Nhân viên đi tuần (cron): 5 phút gom đơn nháp vào kho; 5 phút tìm thông báo kẹt; nửa đêm đồng bộ marketing; 6 tiếng kéo Google Analytics.
Tiêu chí chọn: có ai đang chờ kết quả không? Có → worker. Không, và gom được → cron.
Phần 6 — Ba loại kho, mỗi loại một việc
| Kho |
Ví von |
Chứa gì |
| PostgreSQL |
Tủ hồ sơ có khóa, sổ cái |
Thông báo, khách, thiết bị, đơn, báo cáo ngày. Thứ không được mất, không được trùng |
| Redis |
Bảng trắng dán tường, tự bay sau X ngày |
Hộp thư từng khách, đơn nháp chờ nhập kho, khóa "đang có người làm", bộ đếm |
| MongoDB |
Thùng carton chứa hồ sơ dày |
Toàn bộ đơn hàng để tính báo cáo — mỗi đơn một tập giấy dày, không cần tra chéo |
Câu hỏi để chọn kho: dữ liệu này là nguồn sự thật hay bản sao? Nguồn → Postgres. Bản sao có hạn → Redis. Hồ sơ dày chỉ đọc nguyên khối → Mongo hoặc cột JSON trong Postgres (bài 02).
Phần 7 — Nhiều shop dùng chung một bưu điện
- Mọi hồ sơ đóng dấu tên shop (
shopDomain). Mọi tra cứu kèm dấu. Không có chuyện shop A xem được thư shop B (bài 02).
- Google Firebase giới hạn ~30 app mỗi "dự án" → nhiều shop dùng chung một dự án. Có bảng nối "shop nào → dự án nào" (
firebase:{shop} trên Redis).
- Chìa khóa Shopify của shop không nằm trong bưu điện này — nằm ở Redis do hệ thống quản trị (
mobile-admin-shopify) ghi, đã mã hóa. Bưu điện chỉ đọc. Đây là quyết định phân quyền: hệ thống nào tạo ra bí mật thì hệ thống đó sở hữu.
Phần 8 — Hai hệ thống nói chuyện qua băng chuyền, không qua loa
Đây là một quyết định có lịch sử, và lịch sử đó là bài học.
Trước đây notify-service báo cho mobile-admin-shopify bằng cách hô qua loa (Redis pub/sub): "shop X vừa đổi thông tin!". Loa có tính chất: ai đang nghe thì nghe, không ai nghe thì tin bay mất. Bên kia đang restart đúng lúc đó → không ai nghe → mất tin vĩnh viễn, không log, không lỗi, không ai biết.
Giờ đổi sang đặt phiếu lên băng chuyền chung (BullMQ queue dùng chung Redis): phiếu nằm đó cho tới khi bên kia bật lên và nhặt. Deploy bên nào cũng không mất tin.
Cái giá: hai hệ thống phải khớp tên queue byte-for-byte (CROSS_SERVICE_QUEUES trong common/constants.ts và bản sao bên admin), và không bên nào được cấu hình BullMQ prefix khác. Lệch một ký tự → producer ghi vào một bộ Redis key, worker đọc bộ khác, mọi job biến mất im lặng. Cả hai repo có test pin những chuỗi này. Đây là ví dụ của: khi hai hệ thống chia sẻ một hợp đồng ngầm, phải có test ghim hợp đồng đó.
Kết quả
- Chủ shop bấm gửi → "đã tiếp nhận" trong < 1 giây, bất kể 500 hay 500.000 khách.
- Khách mở hộp thư không phải chờ vụ gửi hàng loạt của shop khác.
- Shopify gửi 500 tin cùng lúc → được xác nhận ngay → không gửi lại dồn.
- Một worker chết giữa chừng → phiếu còn trên băng chuyền → worker khác nhặt.
Đã tối ưu / còn giới hạn
Thông minh: Fastify làm quầy (nhanh hơn Express ~2 lần). Mỗi cửa SQS có bảng tra "tin loại gì → ai xử lý" — thêm loại tin mới chỉ thêm một dòng.
Giới hạn phải biết:
- Chưa có "bầu trưởng ca": chạy 2 bản bưu điện → cron và scheduler chạy ở cả hai → có thể gửi trùng. Code ghi rõ. Nâng cấp: khóa Redis "ai giành được thì làm", hoặc chuyển scheduler sang BullMQ delayed job.
- Cửa SQS lỗi 10 lần liên tiếp thì tự đóng và không tự mở lại — cần người restart. Hợp lý (10 lần liên tiếp = hạ tầng hỏng) nhưng phải có cảnh báo, không thì cửa đóng mà không ai biết.
Xem code
src/main.ts — dựng quầy (Fastify, giới hạn body, bộ lọc lỗi).
src/app.module.ts — lắp module, bật throttle toàn cục.
src/sqs/services/shopify.sqs.service.ts, app-tracking.sqs.service.ts — cửa SQS.
src/bull-queue/*.consumer.ts — worker.
src/cron/services/*.ts — cron.
src/common/constants.ts → CROSS_SERVICE_QUEUES — băng chuyền chung, kèm lý do bỏ pub/sub.
Tự kiểm tra
- Vì sao quầy ghi vào kho rồi xưởng đọc, thay vì quầy gọi thẳng xưởng? Cái gì mất nếu gọi thẳng?
- Một webhook "đơn thanh toán" đi hai nhánh — nhánh nào nhanh, nhánh nào chậm, vì sao chấp nhận được?
- Redis pub/sub bị bỏ vì tính chất gì? BullMQ queue khác ở đâu? Cái giá phải trả là gì?
- Chạy 2 bản notify-service thì cái gì gãy? Bạn sẽ sửa thế nào?
Bài tập
- Vẽ lại sơ đồ 3 tầng cho một hệ thống bạn đang làm hoặc từng làm. Ô nào trống? Có việc nào đang ở sai tầng không?
- Đọc
handleOrderWebhook trong shopify.sqs.service.ts. Liệt kê mọi việc nó làm, gắn nhãn: quầy / kho / xưởng-worker / xưởng-cron.
02 · Tổ chức database
Bài 02 — Tổ chức database: kho chứa hàng nghìn shop mà không lẫn
Bài này trả lời câu hỏi gì
Ba câu, và cả ba đều là chuyện sống còn với hệ thống nhiều shop:
1. 5.000 shop chung một kho — làm sao shop A không đọc được của shop B?
2. Shopify báo một đơn ba lần, app đăng ký một máy năm mươi lần — làm sao kho chỉ có một bản?
3. Shop gỡ app rồi cài lại — "xóa" nghĩa là gì?
Và một ý xuyên suốt tôi muốn bạn mang đi: ràng buộc đặt trong database, không đặt trong code. Code có thể quên. Code có thể bị race. Database thì không.
2.1 Mỗi hồ sơ đóng dấu tên shop
Tình huống
Bưu điện phục vụ 5.000 shop. Shop "Áo Xinh" có 200 thông báo, shop "Giày Đẹp" có 300. Tất cả nằm chung một bảng Notification.
Cách ngây thơ chết thế nào
Hai cách sai, ở hai cực:
- Tra cứu chỉ theo id. Ai đoán được số là đọc được — thông báo của Giày Đẹp hiện lên màn hình Áo Xinh. Lỗ hổng này có tên: IDOR (Insecure Direct Object Reference), và nó nằm trong top 10 lỗ hổng web nhiều năm liền. Nó không cần hacker giỏi — chỉ cần đổi 123 thành 124 trên URL.
- Tách mỗi shop một database. 5.000 database, mỗi lần đổi schema phải sửa 5.000 lần, pool kết nối 5.000 bộ. Không vận hành nổi.
Cách đúng
Một bảng chung, nhưng mọi dòng đều có cột shopDomain, và mọi truy vấn bắt buộc kèm nó:
"Tìm thông báo số 123 của shop Áo Xinh" — chứ không bao giờ "tìm thông báo số 123".
shopDomain lấy từ thẻ mà app gửi lên (header X-SHOP-DOMAIN, xác minh ở bài 03), rồi truyền xuống mọi tầng. Không có đường nào để lấy shopDomain từ body request — vì body là thứ client kiểm soát.
Index sắp theo shop trước. Index (mục lục tra nhanh của database) luôn là (shopDomain, ...) — shop đứng đầu, rồi mới đến trạng thái hay ngày. Giống thư viện xếp sách theo khoa trước rồi mới theo tên: tìm sách khoa Toán chỉ cần vào dãy Toán, không lướt cả thư viện.
Vì sao đúng
Một kho, một pool, một lần sửa schema. Không thể "đoán số" để xem hồ sơ shop khác. Và về hiệu năng: tra cứu của shop 200 hồ sơ nhanh như nhau dù kho có 5 triệu hồ sơ của shop khác — vì index đưa thẳng vào "dãy" của shop đó.
Chỗ hay hiểu sai
"Tôi đã check quyền ở controller rồi, service không cần where shopDomain nữa." Sai. Service được gọi từ nhiều chỗ — controller khác, cron, worker. Một chỗ quên check là lộ. Ràng buộc phải ở tầng thấp nhất mọi đường đều đi qua.
Xem code
prisma/schema.prisma — mọi @@index([shopDomain, ...]); send-notification.service.ts → getValidatedNotification luôn where: { id, shopDomain }.
2.2 Khóa "thật" của hồ sơ là gì
Tình huống
Shopify báo "có đơn hàng #1001" — nhưng báo 3 lần: lúc tạo, lúc thanh toán, lúc giao. Điện thoại của khách đăng ký nhận thông báo — nhưng app gửi đăng ký mỗi lần mở.
Cách ngây thơ chết thế nào
Mỗi lần nhận là một dòng mới → 3 bản đơn #1001, 50 bản cùng một điện thoại. Báo cáo doanh thu gấp 3. Gửi thông báo 50 lần cho một máy.
Và cách "sửa" ngây thơ cũng chết: if (!exists) create(). Hai webhook đến cùng lúc, cả hai check "chưa có", cả hai tạo. Race condition. Code không chặn được cái này — chỉ database chặn được.
Cách đúng
Mỗi loại hồ sơ có một khóa nghiệp vụ duy nhất — cặp thông tin mà ngoài đời không thể trùng:
| Hồ sơ |
Khóa thật |
| Thiết bị |
shop + mã thiết bị |
| Khách |
shop + mã khách Shopify |
| Đơn hàng |
shop + mã đơn Shopify |
| Mã giảm giá |
shop + mã code |
| Số liệu Google |
ngày + luồng dữ liệu + tên sự kiện |
Khai với database: "khóa này chỉ được có một dòng" (@@unique). Khi nhận lần 2, lần 3 → upsert ("có rồi thì cập nhật, chưa có thì tạo"). Làm 1 lần hay 10 lần, kết quả như nhau — idempotent.
Vì sao đây là nền của mọi thứ
Bài 05 sẽ nói về retry, bài 07 về tự phục hồi. Tất cả đều dựa trên một giả định: làm lại không sợ trùng. Giả định đó đúng vì khóa thật + upsert. Không có nó, mọi cơ chế retry đều biến thành cơ chế nhân đôi dữ liệu.
Tài liệu 08 có câu tôi muốn bạn thuộc: "Khóa thật là gì? Nếu không trả lời được câu này, chưa thiết kế xong."
Đã tối ưu / đánh đổi
- Hồ sơ Khách và Đơn có thêm
isDeleted vào khóa: cho phép một bản đã xóa + một bản đang sống cùng tồn tại (shop gỡ app rồi cài lại). Đổi lại, khi gỡ app lần 2 phải dọn bản đã xóa của lần 1 trước, không thì đụng khóa. Code có ghi. Đây là ví dụ của: mỗi quyết định thiết kế mở ra một ràng buộc mới, và ràng buộc đó phải được ghi lại.
- Đăng ký thiết bị không dùng upsert đơn giản mà làm trong một transaction: vì một khách có nhiều máy, cần gộp chúng về một mã nội bộ chung.
Xem code
prisma/schema.prisma — các @@unique; device-token/device-token.service.ts → upsert.
2.3 "Xóa" nghĩa là giấu đi, và giấu ở một chỗ duy nhất
Tình huống
Shop "Áo Xinh" gỡ app. Luật bảo vệ dữ liệu (GDPR) yêu cầu xóa dữ liệu của họ trong 48 giờ. Sáu tháng sau họ cài lại.
Cách ngây thơ chết thế nào
- Xóa cứng (rút dòng ra hủy): mất lịch sử đối soát, mất số liệu báo cáo cũ. Sáu tháng sau shop hỏi "tháng 3 tôi gửi bao nhiêu push?" — không trả lời được.
- Xóa mềm ngây thơ (đóng dấu
isDeleted = true nhưng để nguyên): mọi chỗ tra cứu — ~100 chỗ trong code — phải nhớ thêm where isDeleted = false. Quên một chỗ → shop cài lại thấy 200 thông báo cũ của mình hiện lên. Và bạn sẽ quên, vì người viết chỗ thứ 101 không biết luật này.
Cách đúng
Đóng dấu isDeleted = true, nhưng bộ lọc "bỏ qua dòng đã xóa" cài ở cửa tủ (Prisma extension), không cài ở từng người tra:
Bất kỳ ai mở tủ để đọc (find, count, aggregate) → tự động bị lọc. Ai mở tủ để ghi/đóng dấu → không lọc, vì chính thao tác đóng dấu "ĐÃ XÓA" cần đi qua.
Người thực sự cần xem dòng đã xóa (đối soát nội bộ) nói rõ "cho tôi xem cả dòng đã xóa" — bộ lọc nhường.
Áp cho 8 loại hồ sơ có giá trị lịch sử (Thông báo, Lịch sử gửi, Phân khúc, Chiến dịch, Đơn, Khách, Sự kiện app, Số liệu chiến dịch). Còn thiết bị, mã giảm giá, báo cáo ngày → xóa cứng, vì giữ lại sẽ đụng khóa thật khi shop cài lại (cùng mã thiết bị, cùng mã code).
Vì sao đúng — nguyên lý tổng quát
30 dòng code ở một file bảo vệ toàn bộ đường đọc. Đây là cùng nguyên lý với 2.1: ràng buộc an toàn đặt ở một chỗ mọi đường đều đi qua. Người viết chỗ thứ 101 không cần biết luật — luật tự áp dụng.
Chỗ hay hiểu sai
"Xóa mềm cho tất cả cho an toàn." Không. Bảng nào có khóa thật trùng khi cài lại (thiết bị, mã code) mà xóa mềm thì cài lại là @@unique conflict. Quyết định mềm/cứng phải xét từng bảng, và bảng mới thêm vào phải được xếp vào một trong hai danh sách — quên cả hai là vi phạm GDPR.
Xem code
prisma/soft-delete.extension.ts (bộ lọc ở cửa tủ); bull-queue/shop-cleanup.consumer.ts (ai xóa mềm, ai xóa cứng, và thứ tự).
2.4 Khi nào nhét cả cục JSON vào một ô
Tình huống
Một đơn hàng Shopify có ~80 thông tin: khách, địa chỉ giao, địa chỉ thanh toán, 5 món hàng mỗi món 10 thuộc tính, lịch sử hoàn tiền, phí ship…
Cách ngây thơ chết thế nào
Tách thành 10 bảng con (đơn, món hàng, địa chỉ, hoàn tiền…), mỗi lần lưu là 10 lần ghi, mỗi lần xem là ghép 10 bảng. Trong khi bưu điện này chưa bao giờ cần hỏi "món hàng nào trong đơn" — chỉ cần tổng tiền theo ngày. Bạn trả giá cho một khả năng không dùng.
Cách đúng
Quy tắc đơn giản:
- Thông tin nào cần tìm, lọc, sắp xếp → tách thành cột riêng (shop, mã đơn, ngày tạo, trạng thái thanh toán, tổng tiền).
- Còn lại → nhét nguyên cục vào một ô JSON. Cần thì đọc nguyên cục ra.
Tương tự, một điều kiện phân khúc khách ("đã xem sản phẩm X ít nhất 2 lần trong 7 ngày") có ~12 tham số tùy loại → gom vào một ô JSON thay vì 12 cột trống phần lớn thời gian.
Trường hợp cần tìm bên trong danh sách (ví dụ "sản phẩm nào có tag sale"), Postgres có kiểu mảng + index đặc biệt (GIN) làm việc đó nhanh.
Vì sao đúng
Bảng đơn hàng gọn, ghi một lần, không JOIN. Khi sau này cần tra sâu hơn thì thêm cột hoặc thêm index — không phải đập đi. Nguyên lý: thiết kế cho câu hỏi hiện tại, để đường mở cho câu hỏi tương lai. Chuẩn hóa (normalization) là công cụ, không phải đức tin.
Xem code
prisma/schema.prisma → model Order (cột phẳng vs Json?), SegmentCondition.valueJson, ShopProductCollection.productTags với type: Gin.
2.5 Báo cáo đọc từ bảng tổng hợp, không đếm lại từ đầu
Tình huống
Chủ shop mở dashboard: "doanh thu 30 ngày qua, theo nguồn, theo nền tảng". Shop có 2 triệu đơn.
Cách ngây thơ chết thế nào
Mỗi lần mở dashboard, database cộng lại 2 triệu đơn. 100 chủ shop mở cùng lúc → database bận cộng số, khách mở app thì chờ. Và dashboard là thứ người ta mở rất nhiều — mở lúc sáng, mở sau khi chạy quảng cáo, mở để khoe.
Cách đúng
Giống kế toán chốt sổ cuối ngày: bảng SaleAnalytics mỗi shop mỗi ngày một dòng — tổng đơn, tổng tiền, chia theo nguồn, top sản phẩm. Dashboard 30 ngày = đọc 30 dòng.
Ai chốt sổ? Cron 5 phút (bài 05): khi nhập đơn mới vào kho, ghi nhớ "hôm nay và hôm qua có đơn mới" → tính lại chỉ những ngày đó, không cả tháng.
Sổ chi tiết (Order) vẫn giữ → nếu công thức đổi, có nút "tính lại toàn bộ".
Vì sao đúng
Mở dashboard: đọc 30 dòng, không phụ thuộc shop có 100 hay 2 triệu đơn. Việc cộng số dồn vào lúc rảnh, theo nhịp đều. Đây là tách đường đọc khỏi đường ghi: ghi từng đơn vào bảng chi tiết, đọc từ bảng tổng hợp. Bài 09 gọi nó là bậc 4 và mở rộng thành materialized view, read replica.
Đã tối ưu
Tính lại analytics lỗi không làm hỏng việc nhập đơn — đơn đã vào kho an toàn, số liệu lệch thì lần sau tự sửa hoặc bấm tính lại. Việc chính (giữ đơn) và việc phụ (số liệu) tách bạch — lại là fail-open cho việc phụ.
Xem code
cron/services/order-job.service.ts → recomputeDays; order/services/order.service.ts → recomputeSaleAnalytics; POST /orders/rebuild-sale-analytics.
2.6 Vận hành: kết nối và thay đổi cấu trúc
- Pool kết nối: database chỉ tiếp được N kết nối cùng lúc. Service giữ sẵn một "bể" (
DB_POOL_MIN/MAX) và dùng lại, thay vì mỗi yêu cầu mở kết nối mới (mở kết nối tốn ~50ms và tài nguyên). Bài 09 nói cách tính cỡ pool.
- Tắt máy êm: khi service dừng, đóng kết nối tử tế thay vì rút phích.
- Không log từng câu truy vấn ở production — tốn ổ cứng và lộ dữ liệu khách.
- Đổi cấu trúc bằng
db push (áp thẳng) thay vì migration có lịch sử — nhanh, hợp team nhỏ một môi trường. Giới hạn: khi cần quay lui hoặc có nhiều môi trường thì phải chuyển sang migration. Đây là shortcut có chủ đích, và điều kiện nâng cấp đã ghi.
- Ba kho ba việc (bài 01): Postgres cho thứ cần ràng buộc; Mongo cho đơn hàng dày; Redis cho thứ có hạn dùng.
Checklist khi thêm một bảng mới
- Có cột
shopDomain chưa? Index đầu tiên có bắt đầu bằng nó chưa?
- Khóa thật của hồ sơ này là gì? Đã khai
@@unique chưa?
- Shop gỡ app thì bảng này xóa mềm hay cứng? Đã thêm vào một trong hai danh sách chưa?
- Cột nào cần tìm/lọc/sắp xếp → cột riêng. Còn lại → JSON.
- Dashboard có đọc bảng này không? Có → cần bảng chốt sổ theo ngày chưa?
Tự kiểm tra
- Vì sao
if (!exists) create() không chặn được trùng? Cái gì chặn được?
- Xóa mềm ở "cửa tủ" khác xóa mềm ở "từng người tra" thế nào? Nêu nguyên lý chung với mục 2.1.
- Bảng nào nên xóa cứng dù có giá trị lịch sử? Vì sao?
- Khi nào nhét JSON vào một ô là đúng, khi nào là sai?
Bài tập
- Mở
schema.prisma, chọn 5 model. Với mỗi model: khóa thật là gì? Đã @@unique chưa? Index đầu có shopDomain không?
- Tìm trong code một query trên bảng có
shopDomain mà where không kèm nó. Có không? Nếu có, nó được gọi từ đâu và có an toàn không?
03 · Bảo vệ endpoint
Bài 03 — Bảo vệ endpoint: chặn rác, chặn giả, không lộ bí mật
Bài này trả lời câu hỏi gì
Endpoint là cửa. Cửa nào cũng có người xấu và người vụng đến. Ba câu hỏi: làm sao biết người gọi là app thật? chặn spam ở đâu cho rẻ? lỗi xảy ra thì báo thế nào mà không lộ bí mật?
Và một nguyên lý tổ chức cả bài: mỗi yêu cầu đi qua 5 lớp cửa, lớp ngoài rẻ nhất, lớp trong đắt nhất. Yêu cầu xấu bị chặn càng sớm, càng tốn ít sức. Đây không chỉ là bảo mật — đó là hiệu năng: mỗi request bị chặn ở lớp 1 là một request không tốn kết nối database.
[1] Cân hành lý ở cổng — gói quá to, tên quá dài → trả lại ngay
[2] Đếm người qua cổng — một người vào 100 lần/phút → "chờ chút"
[3] Kiểm tra thẻ — thẻ có thật không, của shop nào
[4] Kiểm tra phiếu điền — điền đúng mẫu chưa, có ghi thêm mục lạ không
[5] Làm việc — tra kho, ghi kho
└─ Bộ phận xử lý sự cố bọc ngoài: lỗi gì cũng trả lời tử tế, ghi sổ nhưng che bí mật
3.1 Lớp 1 — Cân hành lý ở cổng
Tình huống
Một app lỗi gửi ảnh 500MB lên cửa "gửi ảnh cho hỗ trợ". Và một chuyện có thật: link tải ảnh hỗ trợ có token dài ~380 ký tự, mà cổng mặc định chỉ nhận đường dẫn ≤ 100 ký tự → cổng trả "không tìm thấy trang" im lặng, không ghi lỗi gì → tính năng xem ảnh trong chat hỗ trợ chưa từng chạy được một lần nào kể từ khi ra đời.
Vì sao lớp này quan trọng
Vì nó chạy trước khi đọc nội dung vào bộ nhớ. 500MB bị từ chối ở lớp 1 = 0 byte vào RAM. Bị từ chối ở lớp 4 (validation) = 500MB đã vào RAM rồi mới bị từ chối. Cùng kết quả "từ chối", chi phí khác nhau 500MB.
Cách đúng
- Body tối đa 10MB → to hơn trả 413 luôn.
- Đường dẫn tối đa 2.000 ký tự (đủ cho token dài 700).
- Upload: tối đa 1 file, kích thước theo cấu hình, vượt thì báo lỗi rõ (
throwFileSizeLimit: true) thay vì cắt cụt file rồi đưa vào — cắt cụt là lỗi im lặng, tìm mãi không ra.
Bài học từ bug 380 ký tự
Không phải bài học về Fastify. Bài học là: lỗi im lặng là lỗi đắt nhất. 404 trông y hệt "route không tồn tại", không log, không phân biệt được. Tính năng chết mà không ai biết. Khi bạn cấu hình bất cứ giới hạn nào, hỏi: vượt giới hạn thì hệ thống nói gì? Nếu câu trả lời là "không nói gì", bạn có một quả bom.
Xem code
main.ts → bodyLimit, maxParamLength: 2000, fastifyMultipart({ limits, throwFileSizeLimit: true }).
3.2 Lớp 2 — Đếm người qua cổng (throttle)
Tình huống
Một app bị lỗi vòng lặp, gọi API 1.000 lần/giây từ một máy. Hoặc một script quét.
Cách ngây thơ chết thế nào
1.000 yêu cầu/giây chiếm hết pool kết nối database → khách thật của mọi shop khác bị chậm. Một client lỗi làm cả hệ thống chậm.
Cách đúng
Bộ đếm theo IP: quá X lần trong 60 giây → trả 429 trước khi tới bất kỳ xử lý nào.
Ngoại lệ có chủ đích — và vì sao nó dạy nhiều hơn quy tắc
Cửa nhận webhook Gorgias tắt bộ đếm. Lý do: Gorgias có luật — nếu bưu điện trả lỗi vài lần liên tiếp, Gorgias tắt kết nối của toàn bộ tài khoản. Nhiều shop dùng chung một tài khoản Gorgias → một lần chặn nhầm là nhiều shop mất tính năng chat. Và một đợt nhân viên hỗ trợ trả lời dồn dập là lưu lượng hợp lệ, không phải tấn công. Cửa đó bảo vệ bằng token bí mật thay vì đếm IP.
Điều tôi muốn bạn thấy: quy tắc "throttle mọi cửa" là đúng 95%. Nhưng 5% còn lại đòi hỏi bạn hiểu luật của bên kia. Người chỉ thuộc quy tắc sẽ throttle cả Gorgias và làm nhiều shop mất chat. Người hiểu nguyên lý ("throttle để chặn kẻ dồn dập không hợp lệ") sẽ thấy Gorgias không phải đối tượng đó.
Và mọi ngoại lệ phải có @SkipThrottle() kèm comment vì sao ngay bên cạnh. Ngoại lệ không có lý do ghi lại sẽ bị người sau "sửa" lại thành quy tắc.
Giới hạn
Đếm theo IP chỉ chặn máy lẻ. Chặn "một shop lớn ăn hết tài nguyên" phải đếm theo shop — bài 10 nói, và việc đó ở gateway phía trước.
Xem code
app.module.ts → ThrottlerModule, APP_GUARD; gorgias/gorgias.webhook.controller.ts → @SkipThrottle() kèm comment.
3.3 Lớp 3 — Kiểm tra thẻ: làm sao biết đó là app thật của shop thật
Tình huống
App của shop "Áo Xinh" gọi lên: "cho tôi hộp thư của thiết bị X". Làm sao bưu điện biết đó đúng là app của Áo Xinh, không phải ai đó tự viết chương trình giả?
Hai cách ngây thơ và vì sao chúng chết
- Nhúng một mật khẩu cố định trong app. Ai tải app về, mở ra xem (decompile — việc này mất 5 phút với công cụ miễn phí) là có mật khẩu dùng mãi mãi. Đổi mật khẩu = phát hành app mới = mọi user cũ chết.
- Bắt app "đăng nhập" lấy vé (JWT). Đúng về bảo mật, nhưng: thêm một vòng đi-về mỗi lần mở app, thêm bảng lưu vé, thêm việc thu hồi vé, thêm Redis để kiểm tra vé bị thu hồi. Chi phí vận hành cho một app chỉ cần chứng minh "tôi thuộc shop X".
Cách đúng: mật khẩu tự đổi mỗi giờ
Cả app và bưu điện cùng biết một bí mật gốc (HASH_KEY). Mỗi giờ, app tự tính:
mã = băm( bí_mật_gốc + "giờ hiện tại (UTC, làm tròn giờ)" + tên_shop )
Gửi mã kèm tên shop. Bưu điện tính lại y hệt, so sánh. Khớp → cho vào.
Ví dụ: lúc 14:37 ngày 15/9, app của Áo Xinh tính băm(bí_mật + "2026-09-15-14:00:00" + "aoxinh.myshopify.com"). Sang 15:00 mã đổi. Ai bắt được mã lúc 14:37 chỉ dùng được đến 14:59, và chỉ cho đúng shop Áo Xinh.
Vì sao đúng
Không cần bảng vé, không cần Redis, không cần vòng đi-về — chỉ tính toán thuần, vài micro giây. Mã lộ sống tối đa 1 giờ. Đây là ví dụ của chọn mức bảo mật đúng với rủi ro: app này không giữ tiền, không giữ dữ liệu nhạy cảm của user, chỉ cần chứng minh thuộc shop nào. JWT là quá tay; mật khẩu cố định là quá lỏng.
So sánh phải "đều tay" — chi tiết nhỏ, hậu quả lớn
So sánh chuỗi thông thường (===) dừng ngay ở ký tự đầu tiên khác nhau. Kẻ tấn công đo thời gian phản hồi: khác ở ký tự 1 → trả lời nhanh hơn khác ở ký tự 30. Đo đủ nhiều lần là đoán được dần từng ký tự. Gọi là timing attack. Bưu điện dùng timingSafeEqual — luôn chạy hết chiều dài bất kể khác ở đâu. Một hàm dùng chung cho cả 3 loại thẻ — không ai tự viết lại.
Ba loại thẻ
| Thẻ |
Ai dùng |
Thẻ shop (X-SHOP-DOMAIN + mã theo giờ) |
App mobile của khách |
Thẻ shop kiểu cũ (X-SHOP-ID) |
Route cũ, giữ tương thích |
Thẻ nội bộ (X-MOBILE-INTERNAL-KEY) |
Hệ thống khác trong công ty; nhận cả mã theo giờ lẫn khóa thô |
Thiếu bí mật là không mở cửa — fail-closed
Dòng đầu tiên của bootstrap() kiểm tra secret bắt buộc. Thiếu → dừng ngay với thông báo rõ, thay vì khởi động "xanh" rồi khách đầu tiên gọi vào mới 500. Thẻ nội bộ thiếu khóa cấu hình → từ chối tất cả, không mở toang.
Đây là quyết định fail-closed điển hình. Một deploy thiếu env var nên chết ngay lúc boot — ồn ào, rõ ràng, dễ sửa. Không nên chạy được và âm thầm hỏng.
Giới hạn chấp nhận
Đồng hồ app lệch qua mốc giờ → vài giây đầu giờ bị từ chối. Chấp nhận; nếu thành vấn đề, bưu điện thử thêm mã của giờ trước.
Xem code
common/utils/header-hash.ts, common/utils/safe-equal.ts, common/guards/*.ts, main.ts → assertAttachmentTokenSecretConfigured (dòng đầu bootstrap).
3.4 Lớp 4 — Kiểm tra phiếu điền (validation)
Tình huống
App gọi "cho tôi danh sách thông báo, mỗi trang 1.000.000 dòng, sắp xếp theo cột password". Hoặc khi cập nhật thông báo, app "tiện tay" gửi thêm shopDomain: "shop-khac".
Cách ngây thơ chết thế nào
Database kéo cả triệu dòng. Cột sắp xếp tùy ý → lỗi hoặc lộ cấu trúc. Trường thừa shopDomain được ghi đè → thông báo chuyển sang shop khác.
Ví dụ thứ ba nghiêm trọng nhất và ít người nghĩ tới. Input không sai kiểu, không sai khoảng — nó chỉ thừa. Và nếu code làm update({ ...body }), mọi trường trong body đều ghi đè vào model. Gọi là mass assignment.
Cách đúng
Mỗi cửa có mẫu phiếu (DTO) khai rõ từng mục: kiểu gì, tối đa bao nhiêu, chỉ được chọn trong danh sách nào. Bộ kiểm tra chung chạy trước mọi xử lý:
- limit tối đa 100 (@Max(100)).
- sortBy chỉ được là createdAt, updatedAt, title (@IsEnum).
- Mục không có trong mẫu → âm thầm bỏ (whitelist: true). App cũ gửi thừa vẫn chạy, nhưng không ghi đè được thứ không được phép.
- Chuỗi "20" tự đổi thành số 20 (transform: true) để bên trong không phải đổi tay.
Vì sao đúng
Bên trong nhận đúng kiểu, đúng khoảng. Không có if (!body.title) rải rác. Và quan trọng: whitelist biến "quên validate một trường" từ lỗ hổng thành vô hại — trường không khai thì không vào, thay vì trường không khai thì vào tự do.
Xem code
main.ts → ValidationPipe({ whitelist: true, transform: true }); ví dụ notification/dtos/notification-filters.dto.ts → @Max(100), @IsEnum([...]).
3.5 Lớp bọc ngoài — Khi có lỗi: trả lời tử tế, ghi sổ nhưng che bí mật
Tình huống
Chủ shop nhập cấu hình Firebase (chứa khóa riêng tư của Google) nhưng điền sai một mục → lỗi validation. Hệ thống ghi log để debug. Nếu ghi nguyên phiếu → khóa riêng tư nằm trong file log, ai đọc log là có.
Tương tự: link tải ảnh hỗ trợ có token trong đường dẫn; header có token đăng nhập. Một lỗi 503 thoáng qua → log ghi cả token → ai có quyền xem log là tải được ảnh riêng của khách.
Vì sao đây là vấn đề thật
Log là thứ nhiều người đọc nhất và bảo vệ kém nhất. Dev đọc, ops đọc, đôi khi gửi cho bên thứ ba để debug. Log thường lưu lâu, backup nhiều nơi. Một bí mật vào log là một bí mật đã lộ — không phải "có thể lộ".
Cách đúng
Một bộ phận xử lý sự cố bọc ngoài cùng, mọi lỗi đều qua đây:
- Trả lời khách: lỗi do khách (400/404) → nói rõ sai gì. Lỗi hệ thống (500) → chỉ nói "Internal Server Error", không lộ stack trace, không lộ tên bảng.
- Ghi sổ: đường dẫn, phương thức, body, tham số, header — nhưng che (
[REDACTED]) mọi mục trong danh sách bí mật: authorization, cookie, x-customer-token, x-mobile-api-key, serviceAccountKey, token trong URL, internalId…
- Dịch lỗi khó hiểu: JSON sai cú pháp → thay vì 500 khó hiểu, trả 400 kèm gợi ý "kiểm tra dấu phẩy thừa, tên thuộc tính phải trong ngoặc kép…".
Riêng webhook Gorgias: luôn trả OK
Cửa Gorgias đã luôn trả 200. Nhưng nếu Gorgias gửi JSON hỏng thì lỗi xảy ra ở cổng, trước khi tới cửa → cửa không kịp trả 200. Nên bộ phận sự cố có ngoại lệ: thấy đường dẫn là webhook Gorgias → trả 200 luôn, chỉ ghi tên loại lỗi.
Để ý: đây là ngoại lệ lần thứ hai cho cùng một lý do (Gorgias phạt lỗi). Khi một bên ngoài có luật đặc biệt, luật đó thấm vào nhiều lớp — throttle, exception filter, retry. Bạn phải theo nó đến cùng.
Giới hạn cần nhớ
Danh sách "che" là liệt kê tay theo tên trường/header. Thêm tính năng mới có bí mật mới → phải thêm vào danh sách. Đây là chỗ dễ quên nhất trong cả hệ thống, và không có test nào bắt được. Đưa vào checklist review.
Xem code
common/filters/http-exception.filter.ts → REDACTED_HEADERS, respondOkForGorgiasWebhook; common/utils/redact-body.ts, redact-url.ts, redact-headers.ts.
3.6 Webhook: khi bên ngoài gọi vào
Hai webhook, hai cách xác minh khác nhau — vì hai bên cung cấp công cụ khác nhau:
|
Gorgias (chat hỗ trợ) |
Smile (điểm thưởng) |
| Chứng minh "tôi là thật" |
Token bí mật trong URL, so sánh đều tay |
Chữ ký HMAC tính từ thời gian + nội dung thô; lệch quá 5 phút → từ chối |
| Tin tưởng nội dung? |
Không. Chỉ đọc số ticket, worker tự gọi Gorgias hỏi lại |
Có, sau khi xác minh chữ ký |
| Trả lời |
Luôn 200, kể cả token sai |
Sai chữ ký → 401; còn lại 200 |
| Việc nặng |
Đặt phiếu rồi về |
Đặt phiếu rồi về |
Vì sao Gorgias "không tin nội dung"? Token trong URL chứng minh người gọi biết URL — nhưng URL có thể lộ. Nên bưu điện chỉ lấy số ticket, rồi worker tự gọi Gorgias bằng credential của mình để lấy nội dung thật. Kẻ giả mạo cùng lắm làm bưu điện gọi Gorgias một lần vô ích. Smile có HMAC — chữ ký chứng minh nội dung không bị sửa — nên tin được.
Chống phát lại (replay). Bắt được một tin thật, gửi lại 100 lần. Smile: chữ ký gồm timestamp, lệch quá 5 phút thì từ chối. Kẻ tấn công có 5 phút để phát lại — chấp nhận được vì hệ quả chỉ là push trùng, và tin trùng sẽ bị dedup phía sau.
Chi tiết dễ sai: rawBody. Chữ ký Smile tính trên nội dung thô đúng từng byte. Nếu cổng tự parse JSON rồi stringify lại (khoảng trắng, thứ tự khóa đổi) → chữ ký lệch → từ chối nhầm mọi webhook. Nên cổng bật rawBody: true để giữ bản thô.
Nguyên tắc chung: cửa webhook = xác minh + đặt phiếu + trả lời. Không gọi ra ngoài, không xử lý nghiệp vụ tại cửa.
Checklist khi mở một cửa mới
- Cửa cho app → thẻ shop. Cửa nội bộ → thẻ nội bộ. Webhook → xác minh token/chữ ký, và tắt đếm IP nếu bên gửi phạt lỗi.
- DTO có
@Max cho limit, có @IsEnum cho sortBy/type?
- Cửa có gọi Shopify/Google/Gorgias không? Có → chuyển sang băng chuyền.
- Phiếu/URL/header có bí mật không? Có → thêm vào danh sách che.
- Lỗi nào khách tự sửa được (400/404), lỗi nào của mình (500)? Ném đúng loại.
Tự kiểm tra
- Vì sao 5 lớp xếp theo thứ tự đó? Đảo lớp 1 và lớp 4 thì mất gì?
- Mật khẩu theo giờ so với JWT: mỗi cái hợp với loại app nào? Vì sao dự án chọn cái đầu?
whitelist: true chặn lỗ hổng gì? Cho ví dụ cụ thể.
- Gorgias và Smile xác minh khác nhau — vì sao Gorgias "không tin nội dung" còn Smile tin?
Bài tập
- Mở một controller bất kỳ. Với mỗi route: guard nào? DTO có
@Max/@IsEnum không? Có await gọi ngoài không?
- Tìm trong
redact-*.ts danh sách trường bị che. Nghĩ ra một trường nhạy cảm trong dự án chưa có trong danh sách. Thêm vào.
04 · Cache Redis
Bài 04 — Redis: bảng trắng dán tường, và vì sao nó không chỉ là "cache"
Bài này trả lời câu hỏi gì
Nhiều người học backend nghĩ Redis = "cache", tức là "bản sao tạm của database cho nhanh". Đó chỉ là 1 trong 5 việc. Trong bưu điện này, Redis là bảng trắng dán tường: ghi xoá tức thì (nhanh gấp ~100 lần database), từng ô có thể đặt hạn dùng tự bay. Và bảng trắng dùng cho 5 việc khác hẳn nhau:
- Bản sao tạm (cache đúng nghĩa) — khoá Firebase, thông tin shop.
- Kho chính cho dữ liệu nóng — hộp thư khách nằm ở đây, không phải trong database.
- Vùng đệm gom lô — đơn hàng nháp chờ nhập kho.
- Cờ và khoá — "Shopify đang từ chối", "ai đang đi hỏi Smile".
- Bộ đếm — số lần gọi Gorgias trong cửa sổ 20 giây.
Mỗi việc một cấu trúc dữ liệu, một chính sách hạn dùng, một cách xử lý khi Redis chết. Bài này đi qua từng việc, nhưng mở đầu bằng thứ nền tảng nhất: đặt tên ô.
4.1 Đặt tên ô trên bảng: một quy ước, một chỗ
Tình huống
50 file code, mỗi file tự ghép tên ô kiểu 'shop:' + tên_shop. Một ngày đổi định dạng ở 49 file, quên 1. File đó ghi vào ô cũ, không ai đọc → tính năng chết im lặng. Chưa kể bảng Redis dùng chung với hệ thống khác → hai bên đặt trùng tên ô, ghi đè nhau — lỗi này xuất hiện ngẫu nhiên, không tái hiện được, mất ngày để tìm.
Cách đúng
- Một nơi duy nhất sinh tên ô (
CacheKey). Ví dụ hộp thư của thiết bị X shop Áo Xinh: mab:aoxinh:u:X:inbox. Đổi định dạng → sửa một chỗ.
- Mọi ô của bưu điện tự động có tiền tố
cache: → không đụng ô của hệ thống khác.
- Ô dùng chung với hệ thống quản trị (
shop:shopInfo:*) thì không gắn tiền tố này, và code phải nói rõ "tôi đang đọc ô chung".
Vì sao đúng
Tìm chữ CacheKey. là thấy toàn bộ bản đồ bảng trắng. Người mới vào đọc một file là biết hệ thống ghi gì lên Redis. So sánh với việc phải grep 'shop:' qua 50 file và hy vọng không sót.
Chỗ hay hiểu sai — bẫy đã ghi trong code
Hàm quét bảng (scan) nhận mẫu tìm thô, không tự gắn tiền tố như hàm đọc/ghi. Ai quên gắn → quét không thấy gì trên production, nhưng test local (không có tiền tố) vẫn chạy. Code ghi cảnh báo ở 3 chỗ.
Bài học thiết kế rộng hơn: khi viết bộ công cụ bọc Redis (hay bất kỳ wrapper nào), quyết định "tiền tố gắn ở đâu" phải nhất quán cho mọi hàm. Wrapper mà 9 hàm tự gắn, 1 hàm không, thì hàm thứ 10 là bẫy vĩnh viễn cho mọi người sau.
Xem code
common/utils/cache-key.ts, redis/redis.service.ts → buildKey, scan.
4.2 Hạn dùng: mỗi loại một chính sách, và cái không hết hạn phải có người xoá
Tình huống
Bạn thêm một ô mới lên bảng. Câu hỏi đầu tiên không phải "ghi gì" mà là "bao giờ nó biến mất, và ai chịu trách nhiệm". Trả lời sai câu này có hai kiểu chết: hạn quá ngắn → bảng bay liên tục, database bị hỏi lại liên tục; không hạn mà không ai xoá → bảng phình mãi, hoặc tệ hơn, dữ liệu cũ sai được dùng mãi.
Bảng chính sách của dự án
| Ghi gì |
Hạn |
Vì sao |
| Khoá Firebase của dự án |
24 giờ |
Ít đổi; khi thay khoá thì gọi xoá tay |
| Shop → dự án Firebase nào |
không hết hạn |
Cố định; bắt buộc xoá tay khi chuyển shop sang dự án khác — quên là Google từ chối mọi thông báo của shop đó vĩnh viễn (code cảnh báo đậm) |
| Nội dung một thông báo |
30 ngày |
Hộp thư chỉ giữ 30 ngày |
| Đơn hàng nháp chờ nhập kho |
không hạn |
Phải sống tới khi nhân viên đi tuần nhập xong; mất là mất đơn |
| Sự kiện app nháp |
24 giờ |
Mất 1 ngày dữ liệu hành vi thì chấp nhận |
| Cờ "Shopify đang từ chối shop này" |
5 phút |
Tự hết, thử lại |
| Cờ "vừa xin cấp lại chìa khoá" |
60 giây |
Chống xin dồn dập |
| Bộ đếm gọi Gorgias |
21 giây |
Cửa sổ 20 giây + 1 giây dư |
Đọc bảng này thế nào
Cột "Vì sao" là cột quan trọng. Để ý ba loại lý do:
- Hạn = tuổi thọ dữ liệu (nội dung 30 ngày, hộp thư 30 ngày). Hạn khớp nghiệp vụ.
- Hạn = cửa sổ chống dồn dập (60 giây, 21 giây, 5 phút). Hạn là tham số kỹ thuật, không phải nghiệp vụ.
- Không hạn = dữ liệu không được phép mất (đơn nháp) hoặc hiếm đổi (shop → dự án Firebase). Hai lý do khác nhau, hậu quả khác nhau: mất đơn nháp = mất tiền; quên xoá mapping Firebase = shop chết vĩnh viễn.
Hàng "shop → dự án Firebase" là hàng nguy hiểm nhất. Sai mapping → Google nhận token của dự án A, khoá của dự án B → từ chối. Cache không hết hạn → sai mãi. Đây là kiểu bug "vận hành làm đúng quy trình, nhưng hệ thống không dạy quy trình đó" — nên code phải cảnh báo đậm và hàm xoá phải có tên rõ ràng.
Quy tắc
Ô không hết hạn → code phải có hàm xoá tường minh, kèm ghi chú ai gọi khi nào. Không có hàm xoá = không được phép bỏ hạn.
4.3 Hộp thư của khách nằm trên bảng trắng, không trong tủ hồ sơ
Tình huống
Shop gửi thông báo cho 50.000 khách. Mỗi khách mở app là xem hộp thư: "20 thông báo mới nhất, cái nào chưa đọc, tổng chưa đọc bao nhiêu". Mỗi ngày hàng trăm nghìn lần mở.
Cách ngây thơ chết thế nào
Mỗi lần gửi tạo 50.000 dòng "lịch sử gửi" trong tủ. Khách mở app → tra tủ hàng trăm triệu dòng, lọc theo thiết bị, sắp theo ngày, ghép với nội dung thông báo, đếm số chưa đọc. Hàng trăm nghìn lần/ngày. Database quỳ.
Để ý ba việc riêng biệt, mỗi việc đắt theo cách khác: lọc + sắp (cần index đúng), ghép (JOIN với bảng nội dung), đếm (COUNT không dùng index trọn vẹn — bài 09 nói kỹ). Cộng lại trên bảng hàng trăm triệu dòng, hàng trăm nghìn lần/ngày.
Cách đúng: mỗi thiết bị có 3 ô trên bảng trắng
mab:{shop}:u:{thiết bị}:inbox danh sách có thứ tự (ZSET): mã thông báo, sắp theo thời gian
mab:{shop}:u:{thiết bị}:unread tập hợp (SET): mã thông báo chưa đọc
mab:{shop}:msg:{mã thông báo} nội dung — MỘT bản dùng chung cho cả 50.000 thiết bị, hạn 30 ngày
Hãy nhìn cách mỗi cấu trúc dữ liệu chọn đúng việc:
- ZSET (sorted set) cho inbox — vì cần "20 mới nhất" theo thời gian: lệnh ZREVRANGE 0 19 trả về đúng thứ, O(log n + 20).
- SET cho unread — vì cần "có trong tập không" và "đếm": SISMEMBER, SCARD, đều O(1).
- STRING cho nội dung — một bản, 50.000 inbox chỉ trỏ tới mã.
Chọn sai cấu trúc là mất hết lợi thế. Ví dụ, lưu inbox thành một STRING JSON → mỗi lần thêm 1 thông báo phải đọc cả chuỗi, parse, thêm, stringify, ghi lại — và hai worker cùng thêm sẽ ghi đè nhau.
Khi gửi, với mỗi thiết bị làm 4 việc trong một lệnh không thể bị ngắt giữa chừng (Lua script): ghi nội dung (nếu chưa có), thêm mã vào inbox, thêm mã vào chưa-đọc, và tiện tay xoá mã cũ hơn 30 ngày khỏi inbox. Nếu không gom thành một lệnh, máy chết giữa chừng → inbox có mã mà không có nội dung → khách mở app thấy dòng trống.
Khi khách mở app, 3 lần hỏi Redis:
1. Lấy 20 mã mới nhất từ inbox.
2. Đếm + lấy tập chưa-đọc.
3. Lấy 20 nội dung trong một lần hỏi (MGET), không hỏi 20 lần.
Điểm 3 là bài học độc lập: round-trip là thứ đắt, không phải lệnh. 20 lệnh GET tuần tự = 20 chuyến đi-về ≈ 20 × 0,5ms = 10ms. Một MGET = 1 chuyến ≈ 0,5ms. Cùng số lệnh, nhanh gấp 20.
Cá nhân hoá ("Chào {tên}"): nội dung lưu một bản kèm bảng "thiết bị nào → tên gì"; thay tên lúc đọc. 50.000 thiết bị = 1 bản nội dung, không phải 50.000. Nguyên tắc: lưu mẫu, render lúc đọc — dữ liệu càng ít bản sao, càng dễ sửa và càng nhẹ.
Số trên icon app (badge) khi gửi push: cần số chưa-đọc của 50.000 thiết bị → gửi 50.000 lệnh đếm trong một chuyến (pipeline) thay vì 50.000 chuyến. Cùng bài học round-trip, quy mô lớn hơn: 50.000 chuyến ≈ 25 giây; 1 pipeline ≈ dưới 1 giây.
Tủ hồ sơ (NotificationHistory) vẫn ghi — để đối soát, tính tỉ lệ mở, và là nguồn khôi phục nếu bảng trắng mất. Thứ tự: ghi tủ trước, bảng sau; bảng lỗi chỉ ghi log, không làm hỏng việc gửi. Thứ tự này không ngẫu nhiên: tủ là sự thật, bảng là bản chiếu. Mất bản chiếu thì dựng lại từ sự thật được; mất sự thật thì hết.
Vì sao đúng
Mở hộp thư = 3 lần hỏi Redis, luôn ~20 mục, không đụng tủ hồ sơ. Đếm chưa-đọc tức thì. Chi phí không tăng theo số thiết bị hay số thông báo lịch sử — chỉ theo kích thước một inbox (bị cắt ở 30 ngày).
Chỗ hay hiểu sai
"Redis là cache nên mất cũng không sao." Ở đây Redis là kho chính cho hộp thư. Mất Redis = mọi khách thấy hộp thư trống cho tới khi dựng lại từ NotificationHistory. Nên Redis phải có persistence (AOF — bài 11) và phải có đường dựng lại. Đừng gọi thứ gì là "cache" nếu mất nó thì user thấy khác.
Còn có thể tốt hơn (senior nhìn ra)
- Kiểm tra "còn trang sau không" đang kéo cả inbox ra đếm — nên hỏi Redis "có bao nhiêu mục" (
ZCARD) là đủ.
- Đánh dấu đã đọc nhiều thông báo đang ghi tủ từng cái một (code có ghi chú thừa nhận, vì số lượng nhỏ). Gộp thành một lệnh khi lớn.
- Tập chưa-đọc không tự dọn mục quá 30 ngày (ghi chú trong script). Chấp nhận vì lúc hiển thị chỉ lấy mục còn nội dung.
Ba mục này minh hoạ một thái độ: biết chỗ chưa tối ưu, ghi lại lý do chấp nhận, và có tiêu chí khi nào phải sửa. Tốt hơn tối ưu mù.
Xem code
common/services/noti-common.service.ts → addNotificationToInbox (Lua), getNotificationInbox, getUnreadCounts; redis/redis.service.ts → scardMany, mget.
4.4 Đơn hàng nháp trên bảng, nhập kho theo đợt
Tình huống
Flash sale: 5.000 đơn/phút. Mỗi đơn Shopify báo 3 lần (tạo, thanh toán, giao). Mỗi lần báo, nếu ghi thẳng vào tủ + tính lại báo cáo ngày → 15.000 lần ghi tủ + 15.000 lần tính lại cùng một ngày trong 1 phút.
Cách đúng
Cửa SQS nhận tin → dán lên bảng ô orders:{shop}, mỗi đơn một dòng, tên dòng = mã đơn → 3 tin cùng mã đơn ghi đè lên nhau = 1 dòng, bản mới nhất. Dedup miễn phí nhờ cấu trúc dữ liệu (HASH). Xác nhận với Amazon ngay.
Dừng ở đây một chút. "Dedup nhờ cấu trúc dữ liệu" là kỹ thuật đáng nhớ: thay vì viết code "kiểm tra đã có chưa, có thì cập nhật, chưa thì thêm" (2 lệnh, có race), chọn cấu trúc mà thao tác thêm tự nhiên là ghi đè (HSET — 1 lệnh, không race). Chọn đúng cấu trúc thì bớt được cả một tầng logic.
Nhân viên đi tuần 5 phút/lần:
1. Quét bảng tìm mọi ô orders:*.
2. Với mỗi shop: bóc hết dòng, nhập tủ theo lô 50, tối đa 10 shop song song.
3. Dòng nhập xong → xoá khỏi bảng.
4. Dòng lỗi → phân loại:
- Lỗi hạ tầng (mất kết nối database, timeout) → giữ nguyên, lần sau thử, không tính vào số lần thử.
- Lỗi dữ liệu (đơn thiếu trường, vi phạm ràng buộc) → ghi số lần thử +1; quá 5 lần → bỏ, ghi log "đơn độc".
- Không phân loại được → coi là hạ tầng (giữ lại). Thà thử thừa hơn mất đơn.
5. Ghi nhớ "ngày nào có đơn mới" → tính lại báo cáo chỉ những ngày đó.
Vì sao đúng
Tủ nhận ghi đều đặn 5 phút/lần, mỗi đơn 1 lần dù báo 3 lần. Báo cáo tính 1 lần/ngày-có-thay-đổi thay vì mỗi tin. Đỉnh 5.000 đơn/phút được san phẳng thành luồng ghi đều — database không bao giờ thấy đỉnh.
Chuyện có thật đã sửa — và bài học về retry
Bản cũ xoá dòng mọi khi lỗi → database sập 5 phút = mất sạch đơn trong bảng. Bản mới phân loại lỗi như trên.
Đây là ví dụ điển hình: "retry" không đủ, phải biết retry cái gì, bỏ cái gì. Hai loại lỗi, hai chiến lược ngược nhau:
- Lỗi hạ tầng: thử lại chắc chắn có ích (database sẽ sống lại). Đếm số lần thử là sai — nếu database chết 30 phút, đơn bị bỏ oan sau 5 lần.
- Lỗi dữ liệu: thử lại chắc chắn vô ích (đơn thiếu trường thì mãi thiếu). Không đếm là sai — đơn hỏng chiếm chỗ mãi.
Và khi không biết thuộc loại nào → chọn hướng ít mất mát hơn. Ở đây thử thừa rẻ hơn mất đơn.
Giới hạn
Ô orders:* không có hạn dùng → nhân viên đi tuần chết lâu thì bảng phình. Cần cảnh báo khi số dòng lớn bất thường. (Đây chính là quy tắc 4.2: không hạn thì phải có người canh.)
Xem code
sqs/services/shopify.sqs.service.ts → cacheOrder; cron/services/order-job.service.ts → syncShopOrders, isTransientPrismaError.
4.5 Chống "giẫm đạp" khi bảng vừa bay (cache stampede)
Tình huống
Thông tin điểm thưởng Smile của shop lưu trên bảng, hạn 5 phút. Đúng giây nó bay, 500 khách cùng mở màn hình điểm thưởng. Cả 500 thấy ô trống → cả 500 cùng gọi Smile hỏi → Smile chặn vì gọi quá nhiều → cả 500 lỗi.
Bảng trắng sinh ra để chặn đúng chuyện này, mà lại gây ra nó. Nghịch lý này có tên: cache stampede (hoặc thundering herd). Nó chỉ xảy ra dưới tải đông — test local với 1 user không bao giờ thấy. Đó là lý do nhiều hệ thống chỉ phát hiện ra nó ở production, đúng lúc đông nhất.
Cách đúng: chỉ một người đi hỏi, những người còn lại chờ hoặc dùng bản cũ
Xem ô → có → trả về.
Trống:
Thử giành "khoá đang làm" (chỉ 1 người giành được, khoá tự bay sau 5 giây)
├─ Giành được → đi hỏi Smile → ghi ô chính (hạn ngắn) + ô "bản cũ" (hạn dài) → trả khoá
├─ Không giành được, Redis OK:
│ có "bản cũ"? → trả NGAY bản cũ (cũ vài phút tốt hơn chờ vài trăm ms)
│ không → ngó ô chính 10 lần, mỗi 100ms → có thì trả
│ vẫn không (người giữ khoá chết) → tự đi hỏi luôn — thà hỏi trùng hơn từ chối khách
└─ Redis hỏng → đi hỏi luôn, không chờ ai
Hỏi Smile lỗi → có "bản cũ" → trả bản cũ + cảnh báo; không → báo lỗi
Đọc cây này với câu hỏi "mỗi nhánh, ai chịu thiệt?":
- Nhánh giành được khoá: 1 người chờ Smile (~300ms). Chấp nhận.
- Nhánh có bản cũ: 499 người nhận dữ liệu cũ vài phút. Với điểm thưởng — chấp nhận.
- Nhánh không có bản cũ: 499 người chờ ≤ 1 giây. Chấp nhận.
- Nhánh người giữ khoá chết: hỏi Smile trùng vài lần. Chấp nhận — Smile chịu được vài lần, không chịu được 500.
- Nhánh Redis hỏng: mất chống giẫm đạp, nhưng tính năng vẫn chạy. Chấp nhận.
Không nhánh nào trả về "lỗi" cho khách trừ khi cả Smile lẫn bản cũ đều không có. Đó là tiêu chí thiết kế.
Bản cũ (stale copy) là mảnh hay bị bỏ qua. Ô chính hạn 5 phút; ô bản cũ hạn dài hơn nhiều. Khi ô chính bay, bản cũ vẫn còn → phần lớn khách được trả lời ngay, không chờ. Đây là kỹ thuật "stale-while-revalidate" — trả cũ, làm mới ngầm.
Trả khoá phải cẩn thận — chi tiết nhỏ, bug thật
Khoá tự bay sau 5 giây; nếu mình làm lâu hơn, người khác đã lấy khoá mới; mình "trả khoá" bằng cách xoá ô khoá → xoá nhầm khoá của người ta → người thứ ba lại giành được → hai người cùng đi hỏi. Khoá mất tác dụng đúng lúc cần.
Nên trả khoá = "xoá nếu vẫn là khoá của tôi" (một lệnh Lua: so sánh giá trị rồi mới xoá — hai lệnh riêng GET + DEL vẫn có race giữa hai lệnh). Logic này viết một lần dùng chung 3 chỗ — trước đây copy 3 nơi, cùng một lỗi sửa 3 lần.
Nguyên tắc
Mọi thao tác Redis ở đây đều "lỗi thì bỏ qua" → Redis chết = mất cache, không mất tính năng. Đây là fail-open đúng chỗ: thứ mất đi (chống giẫm đạp) rẻ hơn thứ giữ được (khách xem được điểm).
Xem code
smile/services/smile-cache.service.ts → wrap; smile/utils/smile-lock.ts → acquireSmileLock, RELEASE_IF_OWNED.
4.6 Quét bảng: đi từng trang, không lật cả bảng
Tình huống
Cần xoá mọi ô cache:shop:aoxinh:* khi shop gỡ app. Bảng có triệu ô.
Cách ngây thơ chết thế nào
Redis làm việc một tay (single-thread). Lệnh "liệt kê tất cả ô khớp mẫu" (KEYS) bắt Redis dừng mọi việc khác cho tới khi liệt kê xong — triệu ô = vài giây. Trong vài giây đó: mọi khách mở hộp thư treo, mọi worker treo, mọi khoá chống giẫm đạp không giành được. Một lệnh dọn dẹp làm cả bưu điện đứng hình.
Cách đúng
SCAN: xem 200 ô, nghỉ, xem 200 ô tiếp. Redis xen kẽ việc khác giữa các trang. Và xoá sau mỗi trang — gom 100.000 ô rồi xoá một lệnh DEL cũng treo lâu y như KEYS. Lỗi thứ hai này hay gặp ở người đã biết SCAN: sửa được nửa đầu, quên nửa sau.
Xem code
redis/redis.service.ts → deleteByPattern (comment giải thích dài).
Checklist khi thêm một ô lên bảng
- Tên sinh từ
CacheKey? Ô riêng hay ô chung với hệ thống khác?
- Hạn bao lâu, vì sao? Không hạn → ai xoá, khi nào?
- Ô bay dưới tải đông có giẫm đạp không? Có → dùng mẫu
wrap.
- Redis hỏng thì bỏ qua chạy tiếp (đúng cho cache) hay dừng (chỉ đúng cho khoá chống trùng tiền)?
- Đọc nhiều mục → một chuyến (
MGET/pipeline), không loop.
- Xoá theo mẫu →
deleteByPattern, không KEYS.
Tự kiểm tra
- Kể 5 việc Redis làm trong dự án. Với mỗi việc: Redis chết thì user thấy gì?
- Vì sao inbox dùng ZSET, unread dùng SET, đơn nháp dùng HASH? Đổi inbox sang STRING JSON thì hỏng gì?
- Cache stampede là gì, vì sao test local không thấy? Kể 3 nhánh trong
wrap và ai chịu thiệt ở mỗi nhánh.
- Vì sao trả khoá phải "xoá nếu vẫn là của tôi" thay vì
DEL thẳng? Hai lệnh GET rồi DEL có đủ không?
Bài tập
- Mở
cache-key.ts, liệt kê mọi loại ô. Với từng loại, điền vào bảng 4.2: hạn bao lâu, ai xoá. Tìm ô nào chưa có trong bảng.
- Tìm trong dự án một chỗ gọi Redis trong vòng lặp (
for … await redis.get). Viết lại bằng MGET hoặc pipeline.
05 · Queue & bất đồng bộ
Bài 05 — Băng chuyền: làm việc nặng phía sau mà không mất, không trùng
Bài này trả lời câu hỏi gì
"Bất đồng bộ" = nhận việc bây giờ, làm sau. Nghe đơn giản, nhưng ngay khi tách "nhận" và "làm" thành hai thời điểm, bạn phải trả lời ba câu mà lúc làm đồng bộ không cần hỏi:
- Việc có bị mất không? Nhận xong, chưa làm, máy chết — việc đi đâu?
- Việc có bị làm trùng không? Làm dở, máy chết, máy khác nhặt lại — làm lại từ đầu có gửi 2 lần không?
- Hỏng thì ai làm lại, sau bao lâu? Và làm lại mãi có nghẽn cả hàng không?
Bưu điện có 4 kiểu giao việc, mỗi kiểu trả lời ba câu này khác nhau. Chọn sai kiểu là mất việc hoặc làm trùng.
| Kiểu |
Ví von |
Dùng khi |
Việc có bị mất? |
Tự làm lại? |
Chống trùng? |
| BullMQ |
Băng chuyền có phiếu, phiếu hỏng quay lại đầu băng |
Việc có kết quả cần theo dõi: gửi thông báo, đồng bộ khách, nhập đơn cũ |
Không — phiếu nằm Redis tới khi xong |
Có, cấu hình từng loại |
Có, qua số phiếu |
| SQS (Amazon) |
Hòm thư đến, lấy ra đọc, đọc xong mới xé |
Nhận tin từ ngoài: Shopify, SDK trong app |
Không — Amazon giữ tới khi xé |
Chưa xé thì tin hiện lại |
Không (có thể nhận 2 lần) |
| Bảng nháp + đi tuần |
Dán giấy note lên bảng, 5 phút gom một lần |
Ghi rất nhiều, cần gom lô trước khi vào tủ |
Redis giữ (không hạn) |
Lần đi tuần sau |
Có, cùng tên note thì đè |
| Ném rồi quên |
Nói với đồng nghiệp "tiện thì làm nhé" |
Việc phụ, mất cũng không sao |
Có thể mất |
Không |
Không |
Đọc bảng theo cột "Chống trùng?": SQS không chống trùng — Amazon có thể giao một thư 2 lần. Nghĩa là bất cứ thứ gì nhận từ SQS phải tự chịu được làm 2 lần (idempotent). Đây là ràng buộc thiết kế lan sang mọi handler SQS, không phải chi tiết của SQS.
5.1 Băng chuyền gửi thông báo
Tình huống
Chủ shop bấm "Gửi" cho 50.000 khách. Gửi tới Google Firebase mỗi lần tối đa 500 máy → 100 lần gọi, mất vài chục giây. Giữa chừng máy chủ restart để deploy.
Cách ngây thơ chết thế nào
Gửi ngay trong lúc chủ shop chờ → trình duyệt timeout sau 30 giây → chủ shop tưởng lỗi, bấm lại → 25.000 máy nhận 2 lần. Restart giữa chừng → không biết đã gửi tới đâu, không dám gửi lại, cũng không dám bỏ.
Hai lỗi, hai nguồn khác nhau: lỗi đầu do người dùng chờ việc nặng; lỗi sau do không có bản ghi tiến độ. Băng chuyền giải cả hai.
Cách đúng
Ở quầy (trong < 1 giây):
1. Lọc danh sách máy cần gửi (theo phân khúc, theo khách).
2. Lấy chìa khoá Firebase của shop từ bảng trắng.
3. Ghi vào tủ 50.000 dòng "lịch sử gửi", trạng thái ĐANG GỬI. Đồng thời dán vào hộp thư từng máy trên bảng trắng (bài 04). Bảng lỗi → chỉ ghi log.
4. Đếm số chưa-đọc của từng máy để in lên icon app.
5. Đặt một phiếu lên băng chuyền: nội dung, danh sách máy, chìa khoá Firebase, mã thông báo. Trả "đã tiếp nhận".
Để ý bước 3 xảy ra trước bước 5. Trạng thái ĐANG GỬI được ghi vào tủ trước khi phiếu lên băng. Nếu máy chết giữa 3 và 5: tủ có 50.000 dòng ĐANG GỬI nhưng không có phiếu → kẹt. Bài 07 nói ai gỡ (reaper). Điểm cần thấy ngay: mọi khoảng trống giữa hai bước ghi đều là chỗ có thể kẹt, và mỗi chỗ kẹt phải có người gỡ.
Ở xưởng (worker nhặt phiếu):
1. Gọi Firebase theo lô 500.
2. Đọc kết quả từng máy: thành công → ĐÃ GIAO; lỗi → THẤT BẠI kèm lý do.
- Tra 50.000 dòng lịch sử một lần (không tra 50.000 lần).
- Cập nhật "đã giao" một lệnh cho cả lô; "thất bại" từng dòng vì mỗi dòng lý do khác.
- Máy báo "token không còn tồn tại" → tắt máy đó khỏi danh sách gửi sau.
- Máy báo "sai chứng chỉ" → không tắt: đây là lỗi cấu hình phía bưu điện, máy vẫn tốt. (Bản cũ tắt nhầm → sửa cấu hình xong vẫn không gửi được ai.)
3. Đổi thông báo sang XONG + ghi số liệu (bao nhiêu máy, bao nhiêu giao).
4. Nếu lỗi: gửi cảnh báo Discord (chỉ production, không bao giờ làm hỏng việc chính, tối đa 5 giây) → nếu đây là lần thử cuối → đổi thông báo sang XONG, dòng còn ĐANG GỬI → THẤT BẠI (không để kẹt) → ném lỗi để băng chuyền thử lại.
Phiếu mặc định: thử 3 lần, chờ 2s → 4s → 8s giữa các lần, giữ 10 phiếu xong gần nhất để soi.
Vì sao đúng
Chủ shop nhận phản hồi < 1 giây. Firebase chậm/lỗi → tự thử lại 3 lần. Worker chết → phiếu còn trên băng, worker khác nhặt. Trạng thái không bao giờ kẹt vĩnh viễn (nhờ bước 4 và reaper bài 07).
Chỗ hay hiểu sai — bug "sai chứng chỉ" tắt nhầm máy
Bug này đáng học vì nó là lỗi phân loại, không phải lỗi code. Firebase trả về nhiều mã lỗi; bản cũ gom tất cả thành "máy này hỏng → tắt". Nhưng "token không tồn tại" nói về máy, còn "sai chứng chỉ" nói về bưu điện. Gom chung → sửa chứng chỉ xong, toàn bộ máy đã bị tắt, không gửi được ai. Muốn sửa phải bật lại tay hàng chục nghìn máy.
Quy tắc: khi nhận lỗi từ bên ngoài, hỏi "lỗi này nói về ai?" — về dữ liệu của mình, về cấu hình của mình, hay về bên kia. Mỗi câu trả lời dẫn tới hành động khác nhau.
Đã tối ưu
- Phiếu mang theo cả chìa khoá Firebase → worker không cần tra tủ/bảng, chạy được kể cả bảng trắng mất. Đổi lại phiếu to hơn và Redis chứa khoá riêng tư → Redis phải nằm trong mạng nội bộ. Đây là đánh đổi có ý thức: tự đủ (self-contained) đổi lấy kích thước và một ràng buộc bảo mật.
- Kết quả Firebase trả về cùng thứ tự danh sách gửi → phiếu mang sẵn "máy thứ i là máy nào", không phải tra lại.
Xem code
notification/services/send-notification.service.ts → handleNotificationProcess, queueNotification; bull-queue/notification.consumer.ts; notification/services/notification-history.service.ts → processFCMBatchResponse.
5.2 Chống phiếu trùng bằng số phiếu
Tình huống
Khách mở app 5 lần trong 1 phút. Mỗi lần mở, app đăng ký thiết bị → bưu điện muốn cập nhật hồ sơ khách từ Shopify → 5 phiếu "đồng bộ khách #123" → 5 lần gọi Shopify cho cùng một người. Nếu Shopify đang từ chối chìa khoá → 5 lần xin cấp lại chìa khoá.
Cách ngây thơ chết thế nào
Không phải chết ngay. Nó âm thầm nhân chi phí: mỗi hành động user tạo N phiếu thay vì 1. Với 1.000 shop, mỗi shop 1.000 khách, mỗi khách mở app 5 lần/ngày → 5 triệu phiếu/ngày thay vì 1 triệu, và 5 triệu lần gọi Shopify. Shopify giới hạn tần suất → các phiếu hợp lệ khác bị chặn theo.
Cách đúng
Đặt phiếu với số phiếu cố định = sync:{shop}:{mã khách}. Băng chuyền thấy số phiếu này đang nằm trên băng hoặc đang làm → bỏ qua phiếu mới. 5 lần đặt = 1 phiếu.
Xin cấp lại chìa khoá: số phiếu token-refresh:{shop} cộng thêm một cờ trên bảng trắng "vừa xin rồi, 60 giây nữa hãy xin". Hai lớp, hai mục đích: cờ chặn đợt dồn dập rẻ hơn (một lệnh Redis SET NX, không đụng băng chuyền); số phiếu chặn trùng khi đang làm (cờ đã hết hạn nhưng phiếu cũ chưa xong).
Vì sao đúng
N lần đăng ký → 1 phiếu. Xin chìa khoá tối đa 1 lần/phút/shop. Và quan trọng: chống trùng nằm ở hạ tầng (băng chuyền), không phải ở code nghiệp vụ — không ai phải viết "kiểm tra có phiếu chưa" bằng tay, không có race giữa kiểm tra và đặt.
Chỗ hay sai
Số phiếu chứa dấu : phải chia đúng 3 phần theo luật BullMQ, nên mã khách dạng gid://shopify/Customer/123 phải rút gọn thành 123 trước. Quên rút gọn → BullMQ từ chối hoặc hiểu sai số phiếu → chống trùng mất tác dụng mà không báo lỗi rõ.
Còn một điểm tinh tế: số phiếu chỉ chống trùng khi phiếu cũ còn trên băng hoặc đang làm. Phiếu xong rồi thì số phiếu được dùng lại. Nên "5 lần mở app trong 1 phút" → 1 phiếu, nhưng "mở app lúc 9:00, rồi 9:10" → 2 phiếu (phiếu đầu đã xong). Đó là hành vi đúng — muốn đồng bộ lại sau 10 phút.
Xem code
device-token/device-token.service.ts → enqueueCustomerSync; shopify/shopify.client.ts → requestTokenRefresh; cross-service/cross-service-queue.service.ts → publishTokenRefreshRequest.
5.3 Thời gian chờ thử lại phải khớp với lý do lỗi
Tình huống (comment dài nhất trong repo)
Gorgias cho phép 40 lần gọi mỗi 20 giây cho cả tài khoản. Bưu điện tự đặt trần 36 để chừa chỗ. Khi vượt trần, bưu điện từ chối ngay, không gọi. Phiếu xử lý webhook Gorgias dùng thời gian chờ mặc định: 2 giây, rồi 6 giây.
Vì sao mặc định chết
Cả 3 lần thử (0s, 2s, 8s) đều rơi trong cùng cửa sổ 20 giây vừa hết trần → cả 3 lần đều bị từ chối → phiếu hỏng hẳn. Gorgias không gửi lại webhook đã nhận "OK" → tin nhắn của khách mất vĩnh viễn.
Vẽ ra trục thời gian để thấy: cửa sổ Gorgias là [0, 20). Trần đầy lúc giây 5. Phiếu thử lúc 5, 7, 13 — tất cả trong [0, 20). Trần chỉ reset lúc 20. Ba lần thử vô ích, rồi bỏ cuộc trước khi trần reset. Retry ở đây không sai về ý — nó sai về nhịp.
Cách đúng
Phiếu Gorgias có thời gian chờ riêng: đúng 20 giây, cố định. Cửa sổ đếm được tính theo đồng hồ (giây thứ 0–19, 20–39, …), nên chờ đúng 20 giây chắc chắn rơi sang cửa sổ kế tiếp, trần đã reset.
Bài học
Thời gian thử lại không phải con số mặc định. Nó suy ra từ thứ gây lỗi:
- Bên kia giới hạn theo cửa sổ → chờ ≥ cửa sổ.
- Database bận thoáng qua → chờ ngắn, ngẫu nhiên (jitter — để N worker không cùng thử lại một giây).
- Hệ thống bên kia đang deploy → chờ dài, thử nhiều lần (phiếu liên hệ thống: 5 lần, 5s → 10s → 20s → 40s → 80s, giữ 500 phiếu hỏng để soi).
Cách đọc ba dòng này: mỗi loại lỗi có thời gian tự hết riêng. Cửa sổ rate-limit: 20 giây. Database bận: vài trăm ms. Deploy: 1–3 phút. Retry phải sống lâu hơn thời gian tự hết của lỗi, không thì mọi lần thử đều vô ích.
Xem code
gorgias/gorgias.webhook.controller.ts → GORGIAS_WEBHOOK_JOB_OPTIONS; common/constants.ts → CROSS_SERVICE_JOB_OPTIONS.
5.4 Hòm thư Amazon SQS: lấy, xử lý, rồi mới xé
Luồng
lặp mãi:
hỏi Amazon "có thư không?" — chờ tối đa 20 giây nếu chưa có (không hỏi dồn dập)
lấy tối đa 10 thư một lần
├─ có thư → xử lý cả 10 song song → xé từng thư (xé sau khi xử lý xong)
└─ hỏi lỗi → chờ 1s, 2s, 4s… tối đa 60s rồi hỏi lại
lỗi 10 lần liên tiếp → đóng cửa (hạ tầng hỏng, cần người)
Ba con số đáng nhớ và lý do:
- Chờ 20 giây (long-poll): không có thư thì Amazon giữ kết nối tới 20 giây rồi mới trả "trống". Không long-poll → hỏi 10 lần/giây, mỗi lần tính tiền, kết quả toàn "trống".
- 10 thư/lần: tối đa Amazon cho. Xử lý song song 10 → mỗi vòng lặp là 10 đơn.
- 30 giây visibility: thư lấy ra mà chưa xé → sau 30 giây Amazon cho hiện lại cho người khác lấy. Nên xử lý một lô phải xong dưới 30 giây — ở đây chỉ dán note lên bảng, < 1 giây. Nếu handler mất 40 giây, thư hiện lại lúc giây 30 → hai worker cùng xử lý → làm trùng.
Vì sao xé thư kể cả khi xử lý lỗi?
Việc bền (dán note đơn hàng lên bảng) làm đầu tiên. Việc sau (gửi thông báo tự động "cảm ơn đã mua") lỗi thì để thư hiện lại cũng không giúp — lỗi nghiệp vụ không tự hết, mà còn gửi thông báo 2 lần. Nguyên tắc: ghi bền trước → xé → phần còn lại cố gắng hết sức.
Đây là quy tắc "ack sau khi ghi bền" (ack-after-durable-write). Thứ tự này quyết định loại lỗi bạn gặp:
- Xé trước ghi bền → máy chết giữa chừng = mất thư.
- Xé sau ghi bền → máy chết giữa chừng = thư hiện lại = ghi trùng (nhưng HASH ghi đè, nên vô hại).
Mất hay trùng — luôn chọn trùng, rồi làm cho trùng vô hại.
Đánh đổi: nếu Redis chết đúng lúc dán note → mất đơn đó. Chấp nhận vì còn đường nhập lại hàng loạt từ Shopify (bulk).
Đã tối ưu
- Webhook "sản phẩm sắp hết hàng" cần báo cho có thể 1.000 khách đang để trong giỏ → gửi theo lô 10, không bắn 1.000 cùng lúc.
Xem code
sqs/services/sqs.service.ts → pollMessages; sqs/services/shopify.sqs.service.ts → handleSqsMessage, handleOrderWebhook, handleVariantWebhook.
5.5 Nhập file lớn: đọc tới đâu làm tới đó
Tình huống
Shop mới cài app, cần nhập 2 năm đơn hàng cũ. Shopify xuất ra một file mỗi dòng một đơn, có thể vài trăm MB.
Cách ngây thơ chết thế nào
Tải cả file vào bộ nhớ rồi mới đọc → RAM = kích thước file → 3 shop nhập cùng lúc là hết RAM → process bị kill → cả 3 shop mất tiến độ, và mọi việc khác trên process đó cũng chết theo.
Cách đúng
Đọc file như vòi nước (stream): mỗi dòng đọc được thì bỏ vào rổ; rổ đủ 50 → đổ vào kho (50 đơn song song) → rồi mới đọc tiếp. Kho chậm thì vòi tự chậm theo (backpressure).
Chữ "rồi mới đọc tiếp" là chỗ nhiều người làm sai. Đọc stream nhưng không chờ ghi xong đã đọc tiếp → dữ liệu đọc nhanh hơn ghi → tích trong RAM → cuối cùng vẫn hết RAM, chỉ chậm hơn. Backpressure nghĩa là tốc độ đọc bị kéo theo tốc độ ghi.
Vì sao đúng
RAM ≈ 50 đơn, bất kể file 10MB hay 1GB. Kích thước input không còn là biến số của bộ nhớ.
Xem code
order/services/bulk-order-ingest.service.ts → ingest; order/utils/bulk-jsonl.ts.
5.6 "Ném rồi quên": khi nào được phép
Kiểu void việcPhụ().catch(ghi log) xuất hiện ở:
- Shopify từ chối chìa khoá → xin cấp lại + cắm cờ "shop này đang lỗi". Không được để Redis chậm làm chậm thêm một yêu cầu đã lỗi.
- Khách đăng nhập app → gắn tag "APPUSER" cho khách trên Shopify. Cửa SQS không chờ Shopify.
- Ghi lịch hẹn gửi ra bảng trắng để sống qua restart — là tối ưu phục hồi, không được làm hỏng việc hẹn.
- Gửi cảnh báo Discord.
Điều kiện được phép — cả ba phải đúng:
1. Mất cũng không sai nghiệp vụ (chỉ mất cảnh báo, mất tối ưu, hoặc có cơ chế khác bù).
2. Hàm được gọi tự nuốt lỗi + ghi log — không để lỗi bay lung tung làm sập process. (Promise bị reject mà không ai .catch → Node in cảnh báo, và ở phiên bản mới có thể kill process.)
3. Không có việc nào sau nó phụ thuộc kết quả của nó.
Ngược lại, worker gửi thông báo chờ cảnh báo Discord xong mới đánh dấu phiếu hỏng — vì muốn cảnh báo đi trước; có trần 5 giây nên chấp nhận.
Chỗ hay hiểu sai
"Ném rồi quên" không phải "không quan tâm". Nó là quyết định rõ ràng rằng kết quả của việc này không nằm trên đường chính. Mỗi chỗ void phải trả lời được: mất thì bù bằng gì? Không trả lời được → không được void.
Checklist khi thêm một việc làm sau
- Việc này mất có sao không? Có → băng chuyền hoặc bảng nháp. Không → ném rồi quên.
- Làm 2 lần có sao không? Có → số phiếu cố định + ghi kho kiểu "có rồi thì cập nhật".
- Lỗi gì gây thử lại? Thời gian chờ phải dài hơn nguyên nhân (cửa sổ giới hạn, thời gian deploy).
- Lần thử cuối vẫn hỏng thì trạng thái nào bị kẹt? Gỡ nó ngay trong lần cuối.
- Phiếu có đủ thông tin để worker làm mà không cần bảng trắng không?
- Lô bao nhiêu, song song bao nhiêu — có chỗ nào bắn "tất cả cùng lúc" không?
Tự kiểm tra
- Bốn kiểu giao việc — với mỗi kiểu, kể một việc trong dự án dùng nó, và giải thích vì sao không dùng kiểu khác.
- SQS có thể giao một thư 2 lần. Handler đơn hàng chịu được 2 lần nhờ đâu? Handler "gửi thông báo cảm ơn" thì sao?
- Vì sao phiếu Gorgias chờ đúng 20 giây cố định, không phải 2s → 4s → 8s? Nếu Gorgias đổi cửa sổ thành 60 giây thì sửa gì?
- Ba điều kiện để được "ném rồi quên". Kể một chỗ trong dự án không được
void và vì sao.
Bài tập
- Vẽ trục thời gian cho phiếu gửi thông báo: quầy ghi tủ lúc nào, phiếu lên băng lúc nào, worker nhặt lúc nào, mỗi lần thử lại cách nhau bao lâu. Đánh dấu mọi điểm mà "máy chết ở đây" sẽ để lại trạng thái kẹt.
- Tìm một handler SQS trong dự án. Chỉ ra dòng nào là "ghi bền", dòng nào là "xé thư", dòng nào là "cố gắng hết sức". Thứ tự có đúng không?
06 · Gọi bên ngoài không sập
Bài 06 — Gọi hệ thống bên ngoài mà không sập theo họ
Bài này trả lời câu hỏi gì
Bưu điện phụ thuộc 4 bên ngoài: Shopify (dữ liệu shop), Google Firebase (đẩy thông báo), Gorgias (chat hỗ trợ), Smile (điểm thưởng). Mỗi bên đều có lúc chậm, lúc từ chối, lúc đổi luật. Bạn không điều khiển được họ. Bạn chỉ điều khiển được mình phản ứng thế nào.
Nguyên tắc cả bài: họ hỏng, mình chậm; không để họ hỏng, mình chết. Nghe hiển nhiên, nhưng hệ thống nào không thiết kế có chủ đích thì mặc định là "họ hỏng, mình chết" — vì mọi lời gọi ra ngoài mặc định chờ vô hạn, thử lại vô tội vạ, và coi mọi lỗi như nhau.
Bài này đi qua 4 kiểu hỏng của bên ngoài (chậm, từ chối chìa khoá, giới hạn tần suất, lỗi cấu hình) và kết bằng bảng tra + quy tắc fail-open/fail-closed.
6.1 Luôn có giới hạn thời gian chờ
Tình huống
Shopify bị sự cố, không trả lời nhưng cũng không ngắt kết nối. 200 yêu cầu của bưu điện đang chờ Shopify. Mỗi yêu cầu giữ một kết nối, một chỗ trong bộ nhớ.
Cách ngây thơ chết thế nào
Chờ mãi. 200 → 2.000 → hết kết nối, hết bộ nhớ. Bưu điện sập vì Shopify sập, dù khách mở hộp thư không cần Shopify.
Điểm cần thấy: "không trả lời nhưng không ngắt" tệ hơn "ngắt ngay". Ngắt ngay → lỗi → xử lý → xong. Treo → tài nguyên bị giữ vô hạn. Và kiểu hỏng này rất phổ biến: load balancer bên kia nhận kết nối nhưng backend phía sau chết.
Cách đúng
Mọi lần gọi ra ngoài đều có đồng hồ: Shopify 15 giây, Gorgias 15 giây, Discord 5 giây, tải file bulk 120 giây. Hết giờ → coi là lỗi, thả tài nguyên, xử lý như lỗi bình thường.
Con số không tuỳ tiện: Discord 5 giây vì là cảnh báo phụ — chờ lâu hơn chỉ làm chậm việc chính. Bulk 120 giây vì file vài trăm MB. Shopify/Gorgias 15 giây vì p99 bình thường của họ dưới 2 giây — 15 giây đã là "chắc chắn có chuyện".
Vì sao đúng
Tài nguyên bị "kẹt" tối đa = số yêu cầu trong 15 giây, không tăng vô hạn. Bạn biến "vô hạn" thành "có trần" — và có trần thì tính được, cấu hình được, cảnh báo được.
Chỗ hay hiểu sai
Timeout không phải "lỗi". Timeout là quyết định của mình rằng chờ thêm không đáng. Sau timeout vẫn phải hỏi: việc này thử lại được không (bài 05)? Bên kia có thể đã làm xong mà mình không biết → gọi lại có trùng không (idempotent)?
Xem code
shopify/shopify.client.ts → timeout: 15000; gorgias/gorgias-api.client.ts → TIMEOUT_MS; common/services/error-webhook.service.ts → REQUEST_TIMEOUT_MS.
6.2 Shopify từ chối chìa khoá (401/403): xin cấp lại, nhưng không xin dồn
Tình huống
Chủ shop đổi mật khẩu / gỡ cài lại app → chìa khoá (access token) cũ hết hiệu lực. Đúng lúc đó bưu điện đang gọi Shopify 50 lần (đồng bộ 50 khách) → 50 lần bị từ chối.
Cách ngây thơ chết thế nào — ba mức
- Không làm gì: shop đó chết vĩnh viễn cho tới khi ai đó để ý.
- Mỗi lần bị từ chối là xin cấp lại: 50 yêu cầu xin chìa khoá cùng lúc tới hệ thống quản trị — hệ thống quản trị gọi Shopify OAuth 50 lần → Shopify chặn luôn cả việc xin.
- Tiếp tục đặt phiếu đồng bộ khách: mỗi phiếu thử 3 lần → 150 lần gọi Shopify bằng chìa khoá chết.
Cả ba đều là bài học: một lỗi ở bên ngoài, nếu không có van, sẽ khuếch đại qua hệ thống của mình. 1 lần đổi mật khẩu → 50 lần từ chối → 50 lần xin → 150 lần gọi vô ích.
Cách đúng (3 lớp, tất cả không chờ nhau)
Khi Shopify trả 401/403:
1. Xin cấp lại chìa khoá — nhưng trước đó cắm cờ trên bảng trắng "shop này vừa xin, 60 giây nữa hãy xin lại". Cờ chỉ cắm được một lần trong 60 giây (lệnh SET NX) → 50 lần từ chối = 1 lần xin. Yêu cầu đi qua băng chuyền chung sang hệ thống quản trị (không phải hô loa — bài 01), có số phiếu cố định để không trùng.
2. Cắm cờ "shop này đang lỗi chìa khoá" sống 5 phút. Cửa đăng ký thiết bị thấy cờ → không đặt phiếu đồng bộ khách nữa, đỡ 150 lần gọi vô ích. Mọi lần gọi Shopify thành công → nhổ cờ.
3. Trả lỗi 401 cho người gọi ngay — hai việc trên chạy "ném rồi quên", không làm chậm phản hồi.
Phiếu đồng bộ khách đang trên băng chuyền vẫn thử lại 3 lần với khoảng chờ tăng dần → tới lần thử sau, chìa khoá mới có thể đã được cấp → thành công.
Vì sao đúng
Shop đổi chìa khoá → tự lành trong ~1 phút, không ai phải để ý. Không có bão yêu cầu xin chìa khoá. Ba lớp chặn khuếch đại ở ba điểm: lớp 1 chặn "xin dồn", lớp 2 chặn "phiếu mới vô ích", lớp 3 chặn "phản hồi chậm theo".
Chi tiết đáng học: fail-open cho cờ
Cờ "đang lỗi" nếu không đọc được (Redis hỏng) → coi như không có cờ, cứ đặt phiếu. Vì cờ này là tối ưu, không phải an toàn; thà gọi thừa hơn dừng đồng bộ vì Redis hắt hơi.
Hãy thử đảo lại: nếu Redis hỏng → coi như có cờ → không đặt phiếu nào → mọi shop ngừng đồng bộ khách vì Redis chết 30 giây. Một sự cố nhỏ ở hạ tầng phụ làm tê liệt tính năng chính. Đó là fail-closed đặt sai chỗ.
Xem code
shopify/shopify.client.ts → graphql (nhánh 401/403), requestTokenRefresh; common/services/auth-failure.service.ts; device-token/device-token.service.ts → enqueueCustomerSync.
6.3 Bên kia giới hạn số lần gọi (429): tự đếm trước khi họ đếm
Tình huống
Gorgias cho 40 lần gọi / 20 giây / tài khoản. Hai shop có thể dùng chung một tài khoản Gorgias. Một shop có 100 khách mở màn hình hỗ trợ cùng lúc, mỗi màn hình cần 6 lần gọi → 600 lần trong vài giây.
Cách ngây thơ chết thế nào
Gọi thẳng, để Gorgias từ chối. Gorgias từ chối cả tài khoản → shop kia (không làm gì sai) cũng bị ảnh hưởng. Thử lại ngay → càng bị chặn.
Đây là vấn đề "hàng xóm ồn ào" (noisy neighbour) nhưng ở phía bên ngoài: đơn vị giới hạn của Gorgias (tài khoản) rộng hơn đơn vị của mình (shop). Nên mình phải tự đếm theo đơn vị của họ.
Cách đúng: tự đặt trần thấp hơn, đếm trên bảng trắng
- Mỗi tài khoản Gorgias, mỗi cửa sổ 20 giây (tính theo đồng hồ: giây 0–19, 20–39…), có một ô đếm trên Redis, tự bay sau 21 giây.
- Trước mỗi lần gọi mạng: tăng ô đếm. Vượt 36 (chừa 4 cho thử lại và cho worker webhook) → từ chối ngay tại bưu điện, không gọi Gorgias. Giảm ô đếm lại 1 (yêu cầu bị từ chối không được "ăn" ngân sách — nếu không, một đợt đọc bị dội liên tục sẽ đẩy bộ đếm lên mãi và chặn luôn cả đường gửi tin).
- Việc gửi tin của khách được ưu tiên: trần 39 thay vì 36 → lúc đông, đọc có thể bị chặn nhưng khách gửi tin vẫn đi.
- Đếm mỗi lần gọi mạng, không phải mỗi lần "yêu cầu": một yêu cầu có thể thử 3 lần → phải tốn 3 đơn vị. Nếu đếm 1, trần 36 hoá thành 108 lần gọi thật.
- Gorgias vẫn trả 429 → đọc header
Retry-After (họ bảo chờ bao lâu), chặn trên 60 giây, chờ rồi thử lại tối đa 3 lần. Lỗi 5xx → chờ 300ms × số lần thử.
- Redis hỏng → bỏ qua bộ đếm, gọi thẳng. Redis chết không được kéo tính năng chat chết.
Đọc kỹ ba chi tiết
Trần 36, không phải 40. Chừa 4 vì bộ đếm của mình và của Gorgias không đồng bộ tuyệt đối (lệch đồng hồ, gọi đang bay). Trần bằng đúng giới hạn = thỉnh thoảng vượt.
Từ chối thì trả lại ngân sách. Không có dòng này: 100 khách bị từ chối → 100 lần tăng → bộ đếm lên 136 → gửi tin (trần 39) cũng bị chặn → khách không gửi được tin vì có nhiều người đang xem. Một dòng giảm đếm tách được "xem" khỏi "gửi".
Đếm theo lần gọi mạng. Bộ đếm phải đo đúng thứ Gorgias đo. Gorgias không biết "yêu cầu" của mình — chỉ thấy HTTP call. Đơn vị sai → trần sai gấp 3.
Vì sao đúng
Bưu điện không bao giờ để Gorgias phải từ chối. Shop dùng chung tài khoản không bị vạ lây. Lúc đông, khách gửi tin vẫn được — vì mình chọn cái gì bị chặn trước, thay vì để Gorgias chọn ngẫu nhiên.
Con số phải sống cùng nhau
Thời gian tệ nhất một lần gọi Gorgias = 3 lần × 15 giây + 2 lần chờ × 60 giây = 165 giây. Con số này được xuất ra để nơi khác (khoá chống gửi tin trùng) tính hạn khoá từ nó thay vì bịa một số cố định — sửa hằng số này là hạn khoá tự đúng theo.
Bài học: hằng số liên quan nhau phải suy ra từ nhau, không ghi hai nơi. Khoá hết hạn lúc 120 giây trong khi lời gọi có thể kéo 165 giây → khoá bay khi việc chưa xong → người khác vào → gửi tin trùng. Bug này sẽ xuất hiện khi ai đó tăng timeout từ 15 lên 30 giây mà không nhớ có khoá phụ thuộc.
Xem code
gorgias/gorgias-rate-limit.core.ts; gorgias/gorgias-api.client.ts → consumeBudget, executeWithRetry, GORGIAS_MAX_CALL_DURATION_MS.
6.4 Google Firebase: giới hạn lô, và đừng nhầm "hết chỗ" với "gọi nhanh quá"
Câu chuyện 1: giới hạn lô
Firebase nhận tối đa 500 máy mỗi lần gọi. Gửi 50.000 → phải chia 100 lần, và kết quả trả về phải khớp thứ tự với danh sách gửi để biết máy nào lỗi.
Giải quyết: cắt lô 500, gọi tuần tự, ghép kết quả lại theo đúng thứ tự. Đơn giản nhưng phải làm đúng — lệch một chỉ số là tắt nhầm máy tốt, giữ máy hỏng.
Câu chuyện 2: sự cố thật — cả kho chìa khoá bị "khoá nhầm"
Mỗi dự án Firebase chứa tối đa ~30 app. Khi shop mới cài, bưu điện tạo app trong một dự án còn chỗ. Firebase trả lỗi 429 "RESOURCE_EXHAUSTED" cho cả hai trường hợp: (a) dự án đã đủ 30 app, và (b) bạn tạo app nhanh quá (giới hạn 9 lần/phút).
Bản cũ thấy 429 là đánh dấu dự án "đầy" vĩnh viễn. Hai shop cài cùng lúc → 429 loại (b) → dự án còn 22 chỗ trống bị đánh dấu đầy → dần dần mọi dự án bị đánh dấu đầy → shop mới không cài được, lỗi "No Firebase credential available".
Hãy nhìn cách lỗi lan: mỗi lần hai shop cài cùng lúc là mất một dự án. Không ai để ý vì shop vẫn cài được (dự án khác còn). Tới khi dự án cuối bị đánh dấu → mọi shop mới đều lỗi cùng lúc, và lúc đó nguyên nhân đã xảy ra từ nhiều tuần trước. Lỗi lan chậm là lỗi khó tìm nhất.
Giải quyết: chỉ coi là "đầy" khi nội dung lỗi nói rõ về giới hạn số app (app limit, maximum number of apps), không dựa vào mã 429 hay chữ RESOURCE_EXHAUSTED. Lỗi 403 (thiếu quyền) thì không bao giờ là "đầy", vì đổi sang dự án khác cũng không giúp.
Bài học: mã lỗi HTTP không đủ để quyết định. Phải đọc lý do. Và quyết định "vĩnh viễn" (tắt một dự án) phải dựa trên bằng chứng chắc, vì sai là lan ra toàn hệ thống. Quy tắc chung: hành động càng khó đảo ngược, bằng chứng càng phải chắc. Thử lại — bằng chứng lỏng cũng được. Đánh dấu vĩnh viễn — phải chắc.
Câu chuyện 3: chìa khoá không khớp dự án
Chìa khoá Firebase là của dự án A nhưng shop được ghi là thuộc dự án B → Firebase từ chối từng máy với lỗi mismatched-credential. Bản cũ coi đó là "máy hỏng" → tắt máy khỏi danh sách gửi → sửa cấu hình xong vẫn không gửi được ai.
Giải quyết: kiểm tra trước khi gửi: chìa khoá ghi project_id nào, so với dự án của shop → khác thì ném một lỗi rõ ràng nêu cả hai tên, không gửi. Lỗi cấu hình phải nổ to một lần, không rải thành 50.000 lỗi nhỏ.
Hai lý do "nổ to một lần" tốt hơn: (1) 50.000 lỗi nhỏ trông giống lỗi máy, dẫn tới hành động sai (tắt máy); (2) một lỗi nêu "chìa khoá của A, shop thuộc B" là chẩn đoán xong — người vận hành biết sửa gì ngay.
Xem code
firebase/factories/firebase-admin.factory.ts → sendManyNotifications (chunk 500), isProjectAtAppCapacity, getOrCreateApp (kiểm tra khớp dự án).
6.5 Bảng tra nhanh: bên nào hỏng thì mình làm gì
| Bên |
Lỗi |
Bưu điện làm gì |
Fail-open hay closed? |
| Shopify |
401/403 |
Xin chìa khoá mới (debounce 60s), cắm cờ lỗi 5 phút, trả lỗi ngay |
Cờ lỗi: open (Redis hỏng → coi như không lỗi) |
| Shopify |
429 |
Trả "tạm không phục vụ", phiếu băng chuyền tự thử lại sau |
— |
| Shopify |
Không kết nối được |
Trả "tạm không phục vụ" |
— |
| Gorgias |
Sắp vượt trần |
Từ chối tại chỗ, không gọi |
Bộ đếm: open (Redis hỏng → gọi thẳng) |
| Gorgias |
429 |
Chờ theo Retry-After (≤ 60s), thử ≤ 3 lần |
— |
| Gorgias |
5xx |
Chờ 300ms × n, thử ≤ 3 lần |
— |
| Firebase |
Token máy hỏng |
Tắt máy đó |
— |
| Firebase |
Sai chứng chỉ |
Không gửi, nổ 1 lỗi rõ |
closed (đúng: đây là lỗi cấu hình) |
| Smile |
Bất kỳ |
Trả bản cũ trên bảng trắng nếu có |
open |
| Discord (cảnh báo) |
Bất kỳ |
Ghi log, bỏ qua |
open |
| Redis |
Bất kỳ |
Bỏ cache/đếm/cờ, chạy tiếp |
open |
| Postgres |
Mất kết nối |
Giữ note trên bảng, thử lần đi tuần sau |
không mất dữ liệu |
Quy tắc fail-open / fail-closed
Fail-open = hỏng thì cho qua. Fail-closed = hỏng thì chặn. Quy tắc: thứ gì là tối ưu (cache, đếm, cờ, cảnh báo) → open. Thứ gì là an toàn/đúng đắn (xác thực, khớp chứng chỉ, không gửi trùng tiền) → closed.
Cách kiểm tra nhanh: hỏi "nếu bộ phận này biến mất, hệ thống sai hay chỉ chậm/kém?" Chỉ chậm/kém → open. Sai → closed. Bảng trên có 8 dòng open và 1 dòng closed — tỉ lệ đó bình thường: phần lớn thứ mình bọc quanh lời gọi ngoài là tối ưu, chỉ vài thứ là an toàn.
Chỗ hay hiểu sai
"Fail-open là lỏng lẻo." Không — fail-open đúng chỗ là kỷ luật: bạn đã xác định thứ đó là tối ưu và chấp nhận mất nó. Fail-closed sai chỗ (cờ 401 khi Redis chết) mới là lỗi thiết kế, vì nó biến hạ tầng phụ thành điểm chết chung.
Checklist khi gọi một hệ thống ngoài
- Có timeout chưa? Bao nhiêu, vì sao?
- Họ giới hạn bao nhiêu lần/giây? Mình tự đếm trước chưa, đếm theo đơn vị nào (tài khoản? shop?)?
- Bị từ chối chìa khoá thì ai xin lại, có debounce chưa, phiếu đang chờ có bị bão không?
- Lỗi nào là "thử lại được", lỗi nào là "vĩnh viễn"? Quyết định vĩnh viễn dựa trên bằng chứng gì?
- Redis/cache hỏng thì đường này chạy tiếp được không?
- Thời gian tệ nhất của một lần gọi là bao nhiêu — khoá/hạn nào phụ thuộc con số đó?
Tự kiểm tra
- Vì sao "treo không ngắt" tệ hơn "ngắt ngay"? Timeout giải quyết gì và không giải quyết gì?
- Ba lớp chống khuếch đại khi Shopify trả 401 — mỗi lớp chặn ở điểm nào? Bỏ lớp 2 thì chuyện gì xảy ra?
- Vì sao bộ đếm Gorgias phải trả lại ngân sách khi từ chối? Vì sao đếm theo lần gọi mạng chứ không theo yêu cầu?
- Với mỗi dòng trong bảng 6.5: nếu đảo open ↔ closed thì hậu quả là gì? Chọn 3 dòng và giải thích.
Bài tập
- Tìm mọi chỗ trong dự án gọi HTTP ra ngoài (
axios, fetch, client). Với mỗi chỗ: có timeout không? Bao nhiêu? Có chỗ nào thiếu không?
- Tính lại
GORGIAS_MAX_CALL_DURATION_MS nếu đổi timeout thành 30 giây và Retry-After trần 90 giây. Tìm mọi nơi dùng hằng số này và kiểm tra chúng có tự đúng theo không.
07 · Hẹn giờ & tự phục hồi
Bài 07 — Hẹn giờ & tự phục hồi: restart không mất việc, kẹt có người gỡ
Bài này trả lời câu hỏi gì
Hai câu:
- Restart máy chủ thì lịch hẹn có mất không? Deploy vài lần mỗi tuần. Nếu mỗi lần deploy là mất lịch hẹn, tính năng hẹn giờ vô dụng.
- Việc kẹt giữa đường ai gỡ? Mọi trạng thái "đang làm" đều có thể kẹt: máy chết, phiếu mất, worker treo. Không ai gỡ → kẹt mãi.
Và một ý xuyên suốt: tự phục hồi không phải một cơ chế, mà là nhiều lưới xếp chồng, mỗi lưới bắt một loại hỏng. Bài này đi từng lưới, rồi tổng kết thành ba tầng.
7.1 Lịch hẹn gửi sống qua restart
Tình huống
Chủ shop lên lịch "gửi thông báo Sale lúc 9:00 sáng mai". Khách bỏ giỏ hàng → hệ thống tự hẹn "24 giờ nữa nhắc". 2 giờ sáng, team deploy phiên bản mới → máy chủ restart.
Cách ngây thơ chết thế nào
Lịch hẹn nằm trong bộ nhớ (một đồng hồ đếm ngược trong RAM — setTimeout). Restart → RAM trắng → 9:00 sáng không có gì xảy ra. Thông báo trong tủ vẫn ghi "ĐÃ LÊN LỊCH" mãi mãi. Chủ shop không biết, khách không nhận.
Lỗi này im lặng tuyệt đối: không exception, không log, không alert. Chỉ có "không có gì xảy ra". Bạn phát hiện khi chủ shop hỏi "sao Sale hôm qua không gửi?".
Cách đúng: ghi ra giấy, và khi bật máy thì đọc lại giấy
Khi đặt lịch:
1. Tạo đồng hồ đếm ngược trong RAM (như cũ).
2. Ghi một tờ giấy lên bảng trắng Redis: mã thông báo, shop, giờ gửi, (khách nào, giỏ hàng nào nếu là nhắc tự động). Hạn 30 ngày. Thêm mã vào một danh mục để lúc bật máy tìm được mà không phải quét cả bảng.
3. Ghi giấy là "ném rồi quên" — ghi lỗi chỉ log, không làm hỏng việc đặt lịch (giấy là để phục hồi, không phải điều kiện để chạy).
Để ý bước 3 áp dụng đúng ba điều kiện của "ném rồi quên" (bài 05): mất giấy thì có lưới thứ hai (quét tủ) bù; hàm tự nuốt lỗi; không việc nào sau phụ thuộc nó.
Khi bật máy (onModuleInit), hai nguồn theo thứ tự:
1. Đọc danh mục trên bảng trắng → từng tờ giấy → kiểm tra tủ: thông báo còn tồn tại và còn trạng thái hợp lệ không?
- Không còn / đã gửi → xé giấy.
- Giờ gửi còn ở tương lai → đặt lại đồng hồ.
- Đã quá giờ trong lúc máy tắt → gửi ngay (chi tiết chống trùng bên dưới).
- Quá giờ hơn 24 tiếng → không gửi, chuyển sang "VÔ HIỆU". Vì tờ hẹn 3 tháng trước quên xoá, gửi bây giờ vừa sai vừa làm phiền; và không có luật này thì mỗi lần deploy lại bắn cả đống hẹn cũ.
2. Quét tủ hồ sơ tìm thông báo "ĐÃ LÊN LỊCH" mà không có giấy (dữ liệu từ trước khi có cơ chế ghi giấy) → xử lý như trên. Đây là lưới an toàn thứ hai.
Một tờ giấy hỏng chỉ ghi log và bỏ qua — không làm cả quá trình phục hồi dừng. Quy tắc chung cho mọi vòng lặp phục hồi: một mục hỏng không được làm dừng cả lượt. Nếu không, một bản ghi rác từ 6 tháng trước chặn phục hồi của mọi lịch hẹn hôm nay.
Ngưỡng 24 giờ — vì sao cần và chọn thế nào
Không có ngưỡng: giấy hẹn cũ (thông báo bị xoá nhưng giấy chưa xé, hoặc bug nào đó) sẽ được "gửi bù" mỗi lần deploy. Bạn deploy 3 lần/tuần → khách nhận thông báo Sale của tháng trước 3 lần/tuần.
Chọn 24 giờ vì: máy tắt hiếm khi quá vài giờ; thông báo hẹn trễ 1 ngày vẫn còn ý nghĩa (Sale thường kéo dài vài ngày); trễ hơn thì gần như chắc chắn là rác. Con số này là nghiệp vụ, không phải kỹ thuật — nếu shop bán vé sự kiện, ngưỡng có thể phải là 1 giờ.
Chống gửi trùng khi phục hồi
Tình huống: đồng hồ kêu lúc 9:00, bưu điện đặt phiếu lên băng chuyền, rồi restart trước khi worker kịp đổi trạng thái sang XONG. Bật lại → thấy giấy "9:00, đã quá giờ" → gửi lần nữa → khách nhận 2.
Giải pháp: giành quyền trước khi bắn (claimBeforeFire). Ngay lúc đồng hồ kêu, đổi trạng thái trong tủ ĐÃ LÊN LỊCH → ĐANG GỬI bằng một lệnh có điều kiện ("đổi nếu đang là ĐÃ LÊN LỊCH" — UPDATE … WHERE status = 'SCHEDULED', đọc số dòng bị ảnh hưởng). Chỉ một người đổi được. Bật máy lại thấy trạng thái đã là ĐANG GỬI → biết đã có người bắn → không bắn nữa. Nếu phiếu trên băng chuyền thật sự mất → nhân viên gỡ kẹt (7.2) sẽ lo.
Đây là mẫu compare-and-set — cùng ý với "xoá nếu vẫn là khoá của tôi" ở bài 04. Kiểm tra và đổi trong một lệnh, không phải đọc rồi ghi. Hai bản cùng đọc "SCHEDULED" rồi cùng ghi "SENDING" là race; một lệnh có điều kiện thì database đảm bảo chỉ một bên thắng.
Với nhắc tự động theo khách (giỏ hàng bỏ quên): thông báo mẫu luôn ở trạng thái HOẠT ĐỘNG (dùng chung cho nhiều khách), không đổi trạng thái được → dùng khoá theo cặp (thông báo, khách) trên bảng trắng, sống 24 giờ, chỉ cắm được một lần (SET NX). Lúc bật máy, cắm không được = đã gửi rồi.
Hai kỹ thuật, cùng mục đích: khi có "một dòng" đại diện cho việc → compare-and-set trên dòng đó. Khi không có → tạo khoá riêng cho việc.
Vì sao đúng
Deploy lúc nào cũng được. Lịch hẹn không mất. Hẹn quá giờ trong lúc tắt máy → gửi bù khi bật (trong vòng 24 giờ). Không gửi trùng.
Giới hạn đã ghi
Chưa có "bầu trưởng ca" (leader election): chạy 2 bản bưu điện → cả hai cùng bật máy, cùng đọc giấy. Hẹn thủ công an toàn nhờ lệnh đổi trạng thái có điều kiện, nhưng nhắc tự động lặp lại (ví dụ "nhắc khách không mở app 7 ngày") đăng ký ở cả 2 bản → gửi 2 lần. Cách nâng cấp: khoá Redis "ai giành được thì phục hồi", hoặc chuyển sang phiếu hẹn giờ của băng chuyền BullMQ (có chống trùng sẵn).
Đây là giới hạn có ghi lại, có điều kiện kích hoạt rõ (chạy >1 bản), có hướng sửa. Bài 01 đã nói: dự án hiện chạy 1 bản. Khi scale ra 2 bản, dòng này là việc đầu tiên phải làm.
Xem code
notification/services/notification-scheduler.service.ts → scheduleNotification, persistJob, restoreScheduledJobs, restoreOneShot, claimBeforeFire.
7.2 Nhân viên gỡ kẹt: thông báo "ĐANG GỬI" quá lâu
Tình huống
Thông báo đổi sang ĐANG GỬI, phiếu đặt lên băng chuyền. Rồi: Redis restart mất phiếu / worker chết trước khi đổi sang XONG / phiếu thử 3 lần đều hỏng. Thông báo ở ĐANG GỬI mãi mãi. Chủ shop thấy xoay xoay không xong, không sửa được, không gửi lại được.
Vì sao trạng thái trung gian luôn kẹt được
ĐANG GỬI là trạng thái trung gian: được đặt bởi một bên (quầy), được kết thúc bởi bên khác (worker), cách nhau thời gian và có thể cách nhau process. Giữa hai thời điểm đó, bất kỳ thứ gì chết → trạng thái đứng yên. Điều này đúng với mọi trạng thái trung gian trong mọi hệ thống: PROCESSING, PENDING, IN_PROGRESS. Nếu bạn có trạng thái như vậy mà không có ai gỡ, bạn có bug đang chờ.
Cách đúng
Cứ 5 phút, nhân viên gỡ kẹt (reaper) tìm thông báo ĐANG GỬI mà lần cập nhật cuối cách đây > 15 phút (gửi thật chỉ mất vài giây; 15 phút là chắc chắn kẹt). Hai trường hợp, xử lý khác nhau:
| Thấy gì |
Nghĩa là |
Làm gì |
| Không có dòng lịch sử gửi nào |
Chết trước khi kịp đặt phiếu — chưa ai nhận được gì |
Gửi lại từ đầu (đi đúng đường gửi bình thường) |
| Có dòng lịch sử |
Phiếu đã chạy, có thể đã gửi một phần |
Không gửi lại (sẽ trùng) → đổi thông báo sang XONG, dòng còn ĐANG GỬI → THẤT BẠI |
Cột "Nghĩa là" là suy luận từ bằng chứng. Dòng lịch sử được ghi ở quầy trước khi đặt phiếu (bài 05, bước 3 rồi 5). Nên: không có lịch sử = chết trước bước 3 = chắc chắn chưa gửi ai = an toàn gửi lại. Có lịch sử = đã qua bước 3, không biết worker đã gửi tới đâu = không dám gửi lại. Reaper không đoán — nó đọc dấu vết mà luồng gửi để lại.
Gửi lại mà vẫn lỗi (shop không còn máy nào, thiếu cấu hình Firebase) → chuyển sang XONG luôn, không để nó thử lại mỗi 5 phút mãi mãi.
Vì sao 15 phút, vì sao 5 phút
15 phút = ngưỡng "chắc chắn kẹt". Gửi 50.000 máy mất vài chục giây; 3 lần thử với backoff 2/4/8 giây thêm nửa phút. 15 phút gấp ~10 lần thời gian tệ nhất → không bao giờ gỡ nhầm việc đang sống. Ngưỡng quá ngắn → reaper "gỡ" thông báo đang gửi thật → gửi trùng. Ngưỡng quá dài → chủ shop chờ lâu.
5 phút = chu kỳ quét. Kẹt tối đa 15 + 5 = 20 phút.
Vì sao đúng
Không thông báo nào kẹt quá 20 phút. Chủ shop luôn có thể thao tác tiếp.
Kết hợp với lớp trước
Worker băng chuyền ở lần thử cuối đã tự gỡ (bài 05). Nhân viên gỡ kẹt là lưới thứ hai cho trường hợp worker không kịp chạy tới đó (mất phiếu, crash cứng). Hai lưới, hai nguyên nhân khác nhau — không thừa. Worker gỡ được lỗi có kiểm soát (Firebase từ chối 3 lần). Reaper gỡ lỗi không kiểm soát (process bị kill, Redis mất phiếu — worker không có cơ hội chạy dòng gỡ nào).
Xem code
cron/services/notification-reaper.service.ts; bull-queue/notification.consumer.ts → releaseNotificationAfterTerminalFailure.
7.3 Nhân viên đi tuần dài: có đồng hồ, có giới hạn, có báo cáo
Tình huống
Nửa đêm, việc đồng bộ dữ liệu marketing cho 5.000 shop. Mỗi shop mất vài giây. Một shop có dữ liệu hỏng làm treo.
Cách ngây thơ chết thế nào
const shops = await findAll(); await Promise.all(shops.map(sync)). Ba lỗi trong một dòng: tải 5.000 shop vào RAM; bắn 5.000 việc cùng lúc (pool database 20 kết nối → 4.980 chờ, Shopify rate-limit); một shop throw → Promise.all reject → 4.999 shop còn lại không biết đã xong hay chưa.
Cách đúng (mẫu dùng chung cho mọi việc đi tuần)
- Lấy shop theo trang (50 shop một lần, con trỏ SCAN), không tải 5.000 shop vào bộ nhớ.
- Song song có giới hạn: 10 shop một lúc, xong lô này mới lô sau. Không
Promise.all(5000).
- Mỗi shop bọc riêng (
allSettled): shop hỏng không kéo lô hỏng.
- Đồng hồ tổng: quá 1 giờ → dừng, ghi log "đã xử lý X shop". Việc đêm nay không được chạy đè sang việc đêm mai.
- Báo cáo cuối: tổng shop, thành công, thất bại, thời gian. Tỉ lệ lỗi > 10% → log mức lỗi (cảnh báo tự động bắt được).
Đồng hồ tổng đáng nói thêm. Cron chạy 0:00 mỗi ngày. Nếu đêm nay mất 25 giờ (Shopify chậm), 0:00 ngày mai lượt mới bắt đầu trong khi lượt cũ chưa xong → hai lượt cùng chạy → gấp đôi tải → càng chậm → ba lượt. Đồng hồ 1 giờ cắt vòng xoáy đó: lượt nào cũng kết thúc trước lượt sau.
Xem code
cron/services/integrate-marketing-cron.service.ts, cron/services/order-job.service.ts → processBatchWithLimit, EXECUTION_TIMEOUT.
7.4 Tắt máy tử tế
Tình huống
Deploy: hệ thống gửi SIGTERM cho bản cũ, chờ vài giây, rồi kill.
Cách ngây thơ chết thế nào
Không xử lý SIGTERM → process chết ngay giữa chừng: worker đang gửi lô 500 → dở dang; SQS đang xử lý 10 thư → chưa xé; kết nối database đang trong transaction → rollback.
Cách đúng
Khi nhận lệnh dừng, bưu điện:
- Dừng mọi đồng hồ hẹn giờ (onModuleDestroy của scheduler) — giấy trên bảng vẫn còn để bật lại đọc.
- Ngừng hỏi Amazon SQS (AbortController) — thư chưa xé sẽ hiện lại cho bản mới.
- Đóng kết nối Redis, Postgres tử tế.
Nhờ vậy deploy = "tạm nghỉ 10 giây" chứ không phải "mất việc đang làm". Và nhờ mọi lưới ở trên (phiếu còn trên băng, thư chưa xé hiện lại, giấy hẹn còn trên bảng, reaper gỡ kẹt), kể cả khi tắt không tử tế (OOM kill, máy mất điện), hệ thống vẫn tự lành. Tắt tử tế làm nhanh hơn; các lưới làm đúng dù không tử tế.
Tóm tắt: ba tầng tự phục hồi
Tầng 1 Băng chuyền thử lại — lỗi thoáng qua (mạng, bên kia bận)
Tầng 2 Worker lần thử cuối — gỡ trạng thái kẹt trước khi bỏ cuộc
Tầng 3 Nhân viên gỡ kẹt 5 phút — mọi thứ lọt qua hai tầng trên
+ Giấy hẹn giờ + quét tủ — restart không mất lịch
+ Ngưỡng 24h / 15 phút — không gửi bù thứ quá cũ, không coi việc còn sống là kẹt
Cách đọc: mỗi tầng dưới bắt thứ tầng trên không thể bắt. Tầng 1 không bắt được "lỗi 3 lần liên tiếp". Tầng 2 không bắt được "process bị kill giữa chừng". Tầng 3 bắt tất cả nhưng chậm (tới 20 phút). Nên không thay tầng 3 cho tầng 1 — chậm — và không bỏ tầng 3 vì có tầng 1 — hở.
Checklist khi thêm trạng thái "đang làm" hoặc việc hẹn giờ
- Trạng thái này kẹt được không? Ai gỡ, sau bao lâu, dựa vào dấu hiệu gì?
- Gỡ bằng cách làm lại hay bằng cách đóng? Làm lại có trùng không?
- Lịch hẹn có ghi ra chỗ bền chưa? Bật máy có đọc lại chưa?
- Hẹn quá giờ lúc tắt máy → gửi bù tới mức nào (ngưỡng)?
- Bắn xong rồi restart → cái gì chứng minh "đã bắn" để không bắn lại?
- Chạy 2 bản song song thì việc này chạy 2 lần không?
Tự kiểm tra
- Vì sao ghi giấy hẹn là "ném rồi quên" mà vẫn an toàn? Lưới nào bù nếu giấy mất?
claimBeforeFire chống trùng bằng cách nào? Vì sao phải là một lệnh có điều kiện thay vì đọc rồi ghi?
- Reaper gặp thông báo SENDING 20 phút. Nó quyết định "gửi lại" hay "đóng" dựa vào bằng chứng gì? Vì sao bằng chứng đó đáng tin?
- Nếu đổi ngưỡng reaper từ 15 phút xuống 1 phút, chuyện gì xảy ra? Nếu lên 24 giờ?
Bài tập
- Liệt kê mọi trạng thái trung gian trong
schema.prisma (SENDING, PROCESSING, PENDING…). Với mỗi cái: ai đặt, ai kết thúc, ai gỡ nếu kẹt? Tìm cái nào chưa có người gỡ.
- Giả sử scale lên 2 bản. Đọc
restoreScheduledJobs và chỉ ra chính xác dòng nào sẽ chạy 2 lần và hậu quả. Phác thảo khoá Redis để chỉ 1 bản phục hồi.
08 · Checklist senior
Bài 08 — Checklist senior / teamlead
Bài này trả lời câu hỏi gì
Bảy bài trước dạy vì sao. Bài này nén lại thành câu hỏi để hỏi — khi review PR, khi thiết kế tính năng, khi hệ thống đang cháy. Mỗi câu đều có ví dụ thật trong notify-service để đối chiếu.
Cách dùng checklist cho đúng
Checklist không phải để tick cho xong. Nó là bộ nhớ ngoài cho những câu hỏi bạn sẽ quên hỏi lúc 5 giờ chiều thứ Sáu. Ba nguyên tắc:
- Mỗi ô là một bug đã từng xảy ra. Không có ô nào "lý thuyết".
maxParamLength là bug thật. isProjectAtAppCapacity là sự cố thật. Khi bạn thấy một ô vô lý, tìm câu chuyện đằng sau nó ở bài tương ứng.
- Không áp dụng được ≠ bỏ qua. Nếu PR không đụng Redis thì mục A3 không áp dụng — ghi "N/A" trong đầu, không ghi "OK". Khác nhau: "OK" nghĩa là đã kiểm tra và đúng.
- Câu hỏi quan trọng hơn câu trả lời. Khi review, hỏi tác giả PR "khoá thật của bảng này là gì?" có giá trị hơn bạn tự tìm ra. Người viết phải trả lời được — nếu không, thiết kế chưa xong, dù code có chạy.
Bốn phần: A review PR (7 nhóm), B thiết kế mới (10 câu), C dấu hiệu đáng ngờ (14 mẫu), D thứ tự khi cháy (5 bước).
A. Khi đọc một PR
Đọc PR theo thứ tự này — từ dữ liệu ra ngoài. Vì lỗi ở tầng dữ liệu (thiếu cột shop, sai khoá) đắt nhất để sửa sau; lỗi ở tầng comment rẻ nhất.
A1. Dữ liệu (bài 02)
Tầng nền. Sai ở đây thì mọi tầng trên đúng cũng vô nghĩa.
- [ ] Bảng mới có cột shop? Index đầu tiên bắt đầu bằng shop?
- [ ] Khoá thật của hồ sơ là gì, đã @@unique? Ghi kho bằng "có rồi thì cập nhật" chưa?
- [ ] Bảng này khi shop gỡ app: xoá mềm hay cứng? Đã thêm vào một trong hai danh sách?
- [ ] Có query nào không kèm shopDomain trong where?
- [ ] Có vòng lặp gọi database từng dòng (N+1)? → gom findMany + Map, updateMany.
- [ ] Có findMany không take/không giới hạn? → limit trong DTO, take trong query.
Ô thứ 4 là ô hay bị bỏ qua nhất trong review — vì where: { id } trông hoàn toàn bình thường. Nó chỉ sai khi bạn nhớ rằng id không chứng minh quyền sở hữu (IDOR, bài 02).
A2. Cửa vào (bài 03)
- [ ] Route mới có guard đúng loại? Webhook có xác minh +
@SkipThrottle() nếu bên gửi phạt lỗi?
- [ ] DTO có
@Max cho mọi limit, @IsEnum cho mọi trường "chọn một trong"?
- [ ] Handler có gọi hệ thống ngoài không? → chuyển sang băng chuyền.
- [ ] Body/URL/header có bí mật mới? → thêm vào
redact-*.ts.
- [ ] Lỗi ném ra đúng loại (400/401/404 vs 500)?
Ô "redact" là ô không có test nào bắt được (bài 03). Chỉ có review bắt được. Đó là lý do nó nằm đây.
A3. Bảng trắng Redis (bài 04)
- [ ] Tên ô sinh từ
CacheKey? Đúng tiền tố (riêng / chung)?
- [ ] Có hạn dùng? Không → ai xoá, comment ghi chưa?
- [ ] Ô bay dưới tải có giẫm đạp không? →
wrap + lock + stale.
- [ ] Redis lỗi thì đường này chạy tiếp được không? (Phải được, trừ khoá chống trùng tiền.)
- [ ] Đọc nhiều mục →
MGET/pipeline? Xoá theo mẫu → SCAN, không KEYS?
A4. Băng chuyền & việc làm sau (bài 05)
- [ ] Việc mất có sao không? → BullMQ / bảng nháp / ném-rồi-quên, chọn đúng.
- [ ] Làm 2 lần có sao không? →
jobId + upsert.
- [ ] Thời gian chờ thử lại có khớp nguyên nhân lỗi không? (cửa sổ rate-limit, thời gian deploy)
- [ ] Lần thử cuối hỏng thì trạng thái nào kẹt? Gỡ trong
catch?
- [ ] Có
Promise.all không giới hạn? → cắt lô + concurrency.
- [ ] Ném-rồi-quên: hàm được gọi có tự
catch không?
Hai ô đầu là hai câu hỏi phải hỏi trước mọi việc bất đồng bộ. Trả lời được hai câu này là chọn được đúng kiểu giao việc trong bảng 4 kiểu (bài 05).
A5. Gọi ra ngoài (bài 06)
- [ ] Có timeout?
- [ ] Bên kia giới hạn bao nhiêu? Mình đếm trước chưa, đếm theo đơn vị đúng chưa (tài khoản vs shop)?
- [ ] 401/403 → ai xin lại chìa khoá, có debounce?
- [ ] Lỗi nào "vĩnh viễn"? Quyết định dựa trên nội dung lỗi hay chỉ mã HTTP? (bài học Firebase 429)
- [ ] Hằng số phụ thuộc nhau có suy ra từ nhau không, hay ghi 2 nơi?
A6. Trạng thái & phục hồi (bài 07)
- [ ] Trạng thái "đang làm" mới → ai gỡ khi kẹt, sau bao lâu?
- [ ] Hẹn giờ mới → ghi chỗ bền? Bật máy đọc lại? Chống bắn trùng?
- [ ] Chạy 2 bản song song thì chạy 2 lần không?
A7. Comment & lý do
Tầng "mềm" nhưng quyết định code sống được bao lâu.
- [ ] Mọi ngoại lệ (tắt throttle, không TTL, fail-open, chọn xoá cứng) có comment vì sao ngay cạnh?
- [ ] Shortcut có chủ đích có đánh dấu (ponytail: hoặc tương đương) kèm "nâng cấp khi nào"?
- [ ] Bug đã sửa có ghi lại triệu chứng cũ để người sau không quay lại? (ví dụ comment maxParamLength, isProjectAtAppCapacity)
Vì sao tầng này quan trọng: ngoại lệ không có lý do sẽ bị người sau "sửa" thành quy tắc (bài 03, @SkipThrottle). Bug đã sửa không ghi triệu chứng sẽ được người sau "tối ưu" ngược lại. Comment ở đây không phải giải thích code làm gì — mà là bảo vệ quyết định khỏi bị đảo ngược vô tình.
B. Khi thiết kế tính năng mới — 10 câu hỏi
Hỏi trước khi viết dòng code nào. Thứ tự có ý: câu 1–2 định hình kiến trúc; 3–5 định hình dữ liệu; 6–10 định hình khả năng chịu lỗi.
- Ai gọi, bao nhiêu lần, dồn hay đều? (app mobile 100k lần/ngày đều ≠ webhook 5k/phút dồn)
- Việc nào phải xong trước khi trả lời, việc nào để sau? Vẽ ranh giới quầy / xưởng.
- Dữ liệu này là nguồn sự thật hay bản sao? Nguồn → Postgres. Bản sao → Redis có hạn. Hồ sơ dày chỉ đọc nguyên khối → Mongo/JSON.
- Khoá thật là gì? Nếu không trả lời được câu này, chưa thiết kế xong.
- Việc lặp lại (retry, webhook đến 2 lần, cron chạy lại) có tạo ra kết quả khác không? Phải là "không".
- Bên ngoài hỏng thì mình còn chạy được phần nào? Liệt kê từng bên.
- Redis hỏng thì sao? Postgres hỏng thì sao? Cái nào fail-open, cái nào fail-closed.
- Trạng thái nào có thể kẹt? Ai gỡ?
- Restart giữa chừng thì mất gì? Phải là "không mất gì, cùng lắm làm lại".
- Chạy 2 bản thì sao? Nếu chưa cần scale thì ghi rõ "chưa hỗ trợ, nâng cấp bằng X".
Ba câu có đáp án bắt buộc: câu 5 phải là "không", câu 9 phải là "không mất gì", câu 4 phải trả lời được. Thiết kế nào không đạt ba câu này thì chưa được code.
Câu 1 hay bị coi nhẹ. Nhưng "dồn hay đều" quyết định có cần bảng nháp gom lô (bài 04) hay không. Webhook đơn hàng dồn 5.000/phút lúc flash sale → cần. API đọc hộp thư đều → không cần. Cùng số lượng/ngày, kiến trúc khác nhau.
C. Dấu hiệu cần dừng lại hỏi kỹ
Bảng này để quét nhanh một diff. Mỗi mẫu bên trái là thứ mắt bắt được trong 2 giây; bên phải là câu hỏi phải hỏi tiếp. Không phải mẫu nào cũng sai — nhưng mẫu nào cũng cần một câu trả lời.
| Thấy trong code |
Nghi ngờ |
await gọi Shopify/FCM/HTTP ngoài trong controller |
Quầy đang tự đi làm việc xưởng |
catch (e) {} trống |
Nuốt lỗi không log — bug sẽ im lặng |
catch (e) { throw e } bọc quanh mọi thứ |
Lỗi hạ tầng và lỗi dữ liệu bị gộp — retry sẽ sai |
Promise.all(list.map(...)) với list không giới hạn |
Bão song song |
redis.keys(...) |
Treo Redis |
setTimeout/CronJob không ghi chỗ bền |
Restart mất việc |
findMany không take, DTO không @Max |
Kéo cả bảng |
where: { id } trên bảng có shopDomain |
Rò dữ liệu giữa shop |
| TTL không có, không có hàm xoá |
Rác vĩnh viễn, hoặc dữ liệu cũ mãi |
Số cố định (attempts: 3, delay: 2000) cho việc gọi bên ngoài có rate-limit |
Retry rơi vào cùng cửa sổ, hỏng hẳn |
| Đánh dấu "vĩnh viễn" (tắt, đầy, hỏng) dựa trên mã lỗi chung |
Sai một lần lan cả hệ thống |
Trạng thái PROCESSING/SENDING không có cron dọn |
Sẽ có bản ghi kẹt mãi |
Log request.body / headers nguyên văn |
Lộ bí mật vào log |
=== để so token/chữ ký |
Đo thời gian đoán được |
Hai mẫu đáng chú ý vì trông vô hại:
- catch (e) { throw e } — nhìn như "không làm gì", nhưng nó là dấu hiệu tác giả chưa nghĩ lỗi nào thử lại được, lỗi nào không. Bài 04 (isTransientPrismaError) là ví dụ phải phân loại.
- Số cố định attempts: 3, delay: 2000 — hoàn toàn bình thường cho database, sai cho Gorgias. Cùng một dòng code, đúng hay sai tuỳ đích gọi.
D. Thứ tự ưu tiên khi hệ thống đang cháy
Lúc cháy, não không hoạt động tốt. Thứ tự này để không phải nghĩ — đi từ ngoài vào trong, từ nơi dễ nhìn tới nơi khó nhìn.
- Cửa vào có đang bị nghẽn không? (429 tăng? timeout tăng?) → tạm siết throttle, tắt tính năng phụ.
- Băng chuyền có phình không? (số phiếu chờ) → worker chết? Redis đầy?
- Bảng nháp có phình không? (
HLEN orders:*) → cron chết? Postgres chậm?
- Bên ngoài nào đang từ chối? (401/429 theo shop) → cờ lỗi có đang cắm đúng không?
- Trạng thái kẹt có tăng không? (đếm
SENDING > 15 phút) → reaper có chạy không?
Vì sao thứ tự này: bước 1 là cầm máu — giảm tải trước, tìm nguyên nhân sau. Bước 2–3 là hai vùng đệm của hệ thống; đệm phình = phía sau chậm hơn phía trước, nhìn đệm là biết chỗ nghẽn. Bước 4 là nguyên nhân phổ biến nhất của "phía sau chậm". Bước 5 là hậu quả — kiểm tra cuối vì nó tự lành nếu 1–4 được sửa.
Mỗi câu hỏi ở trên đều nên có một biểu đồ/cảnh báo sẵn — đó là việc của teamlead trước khi cháy. Nếu lúc cháy bạn phải SSH vào chạy HLEN bằng tay, tức là bài 11 (metrics, alert) chưa làm.
Tự kiểm tra
- Chọn 3 ô bất kỳ trong phần A. Với mỗi ô, kể tên bug hoặc sự cố thật trong 7 bài trước sinh ra ô đó.
- Ba câu trong phần B có đáp án bắt buộc — là câu nào, đáp án gì, và vì sao không thoả hiệp được?
- Trong bảng C, mẫu nào có thể đúng tuỳ ngữ cảnh? Cho ví dụ ngữ cảnh đúng và sai.
- Vì sao bước 1 khi cháy là "siết throttle" chứ không phải "tìm nguyên nhân"?
Bài tập
- Lấy PR gần nhất bạn viết hoặc review. Đi qua toàn bộ phần A. Ghi lại: bao nhiêu ô OK, bao nhiêu N/A, bao nhiêu phát hiện mới.
- Với 5 bước phần D: mở dashboard/monitoring hiện tại của dự án. Bước nào đã có biểu đồ? Bước nào phải chạy lệnh tay? Liệt kê thứ cần thêm.
09 · Database triệu dòng
Bài 09 — Tối ưu database khi bảng có hàng triệu dòng
Bài này trả lời câu hỏi gì
Bài này rời khỏi notify-service để trả lời câu hỏi chung: database chậm thì làm gì, theo thứ tự nào?
Nguyên lý tổ chức: xếp kỹ thuật theo thứ tự rẻ → đắt, và làm hết bậc rẻ trước khi nghĩ tới bậc đắt. Sai lầm phổ biến của người mới là nhảy thẳng tới "sharding" hay "thêm replica" khi thực ra thiếu một index. 90% vấn đề "database chậm" chết ở 3 bậc đầu. Bậc 4–5 là chuyện của hệ thống đã lớn thật — biết để không sợ, không phải để làm ngay.
Con số tham chiếu — thuộc lòng
(Postgres, máy 4 CPU / 16GB, SSD)
- Tra theo mục lục đúng: 1–5ms, bất kể bảng 1 nghìn hay 100 triệu dòng.
- Quét cả bảng 10 triệu dòng: 5–30 giây.
- Một máy chịu ~5.000–20.000 truy vấn đơn giản/giây nếu đều đi qua mục lục.
- Ghi: ~2.000–10.000 dòng/giây từng dòng; 50.000+/giây nếu gom lô.
Vì sao phải thuộc: khi thấy một truy vấn mất 2 giây, bạn phải biết ngay đó là bất thường (nhanh gấp 400 lần là mức bình thường). Không có con số trong đầu thì không biết cái gì là "chậm".
Bậc 1 — Mục lục (index): 80% vấn đề nằm đây
Tình huống
Bảng Order 20 triệu dòng. Dashboard hỏi "đơn của shop X, tháng này, mới nhất trước". Không có mục lục → Postgres đọc cả 20 triệu dòng rồi lọc. 15 giây. 50 chủ shop mở cùng lúc → database 100% CPU → mọi thứ khác chờ.
Để ý hậu quả lan: không chỉ dashboard chậm. CPU database bị 50 truy vấn quét bảng chiếm hết → API hộp thư (không liên quan gì) cũng chậm theo. Một query thiếu index làm chậm cả hệ thống.
Mục lục là gì
Như mục lục sách: thay vì lật 1.000 trang tìm chữ "Redis", mở mục lục thấy "Redis: trang 412". Postgres dùng cây B-tree: tìm 1 trong 100 triệu mất ~27 bước so sánh (log₂ của 100 triệu). Đó là lý do "1–5ms bất kể kích thước bảng" — chi phí tăng theo log, không theo tuyến tính.
Quy tắc đặt mục lục
1. Cột trong WHERE với dấu = đứng đầu; cột lọc khoảng / sắp xếp đứng sau.
-- Câu hỏi: WHERE shop = ? AND created_at > ? ORDER BY created_at DESC
CREATE INDEX idx_order_shop_created ON "Order" (shop, created_at DESC);
Sai thứ tự (created_at, shop) → mục lục gần như vô dụng vì phải quét mọi ngày rồi mới lọc shop.
Cách nhớ: mục lục composite giống danh bạ sắp theo (họ, tên). Tìm "Nguyễn, Văn A" → nhảy tới vần N rồi tìm Văn A: nhanh. Tìm "mọi người tên Văn A" → phải đọc cả danh bạ: index vô dụng. Cột = là "họ" — thu hẹp mạnh nhất, đứng đầu.
2. Mục lục kép (composite) tốt hơn hai mục lục đơn. (shop) + (created_at) riêng → Postgres phải ghép hai kết quả. (shop, created_at) → một lần đi.
3. Mục lục "bao trọn" (covering / INCLUDE): nếu câu hỏi chỉ cần 3 cột, để cả 3 cột trong mục lục → không phải mở bảng chính.
CREATE INDEX ... ON "Order" (shop, created_at DESC) INCLUDE (total_price, status);
4. Mục lục một phần (partial): chỉ lập mục lục cho dòng hay hỏi.
CREATE INDEX idx_noti_sending ON "Notification" (updated_at) WHERE status = 'SENDING';
Bảng 10 triệu thông báo nhưng chỉ ~100 đang SENDING → mục lục 100 dòng. Cron gỡ kẹt (bài 07) tra tức thì. Đây là kỹ thuật ít người biết nhưng rất hợp với mọi trạng thái trung gian.
5. Mảng / JSON / tìm chữ → mục lục GIN, không phải B-tree.
CREATE INDEX ON "ShopProductCollection" USING GIN (product_tags);
CREATE INDEX ON "Order" USING GIN (line_items jsonb_path_ops);
6. Đừng lập mục lục bừa. Mỗi mục lục làm mỗi lần ghi chậm thêm (phải cập nhật mục lục) và tốn đĩa. Bảng ghi nhiều đọc ít (log, tracking event) → ít mục lục. Xoá mục lục không ai dùng (pg_stat_user_indexes cho biết mục lục nào 0 lần dùng).
Quy tắc 6 là đối trọng của 1–5. Người mới học index xong hay lập cho mọi cột. Bảng 20 index → mỗi INSERT phải cập nhật 20 cây → ghi chậm gấp nhiều lần. Index là đánh đổi đọc/ghi, không phải miễn phí.
Cách biết mục lục có được dùng không: EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT ... WHERE shop = 'x' ORDER BY created_at DESC LIMIT 20;
Đọc dòng đầu:
- Index Scan using idx_... → tốt.
- Seq Scan on "Order" + rows=20000000 → quét cả bảng, thiếu mục lục.
- Sort với external merge Disk → sắp xếp tràn ra đĩa, cần mục lục có DESC sẵn.
- actual time=... → thời gian thật. So với rows= ước lượng; lệch xa → thống kê cũ, chạy ANALYZE.
Đừng đoán index có được dùng không — nhìn. Postgres có thể bỏ qua index bạn vừa tạo vì thống kê cũ, vì bảng nhỏ (quét nhanh hơn), vì hàm trên cột. EXPLAIN là cách duy nhất biết chắc.
Bật slow query log (log_min_duration_statement = 200ms) → mỗi tuần đọc top 10 câu chậm nhất, sửa. Thói quen này quan trọng hơn mọi kỹ thuật bên dưới. Vì bạn không thể tối ưu thứ không đo — và slow log cho bạn danh sách việc, xếp sẵn theo mức đau.
Bậc 2 — Viết câu hỏi cho đúng
Index đúng mà query sai vẫn chậm. Bậc này là những lỗi trong code, ORM che đi nên khó thấy.
N+1: lỗi phổ biến nhất trong code dùng ORM
Lấy 100 thông báo → 1 câu hỏi
Với MỖI thông báo, lấy segment → 100 câu hỏi nữa
101 câu thay vì 2. Ở 10.000 dòng là 10.001 câu. Sửa: lấy danh sách id rồi hỏi một lần WHERE id IN (...), hoặc include/JOIN. Dự án làm đúng ở processFCMBatchResponse (1 findMany + Map).
Vì sao ORM che: notification.segment trông như truy cập thuộc tính, nhưng là một query. Trong vòng lặp → N query. Test với 5 dòng: 6 query, không ai để ý. Production 10.000 dòng: 10.001 query. Cách bắt: bật log query của ORM lúc dev, đếm.
Chỉ lấy cột cần
SELECT * trên bảng Order 80 cột kèm JSON to → chuyển 5KB/dòng. Cần 3 cột thì select: { id, total, status }. Nhanh gấp 10 ở bảng rộng. Và cho phép index covering (bậc 1, quy tắc 3) phát huy.
Phân trang: bỏ OFFSET khi sâu
-- Trang 10.000: Postgres đọc 200.000 dòng rồi VỨT 199.980
SELECT ... ORDER BY created_at DESC LIMIT 20 OFFSET 199980;
-- Keyset: nhớ mốc trang trước, đi thẳng
SELECT ... WHERE created_at < :last_seen ORDER BY created_at DESC LIMIT 20;
Keyset (cursor) luôn 1–5ms dù trang 1 hay trang 1 triệu. Dùng cho feed, inbox, log. Offset chỉ cho bảng nhỏ hoặc trang đầu.
OFFSET là ví dụ của thứ đúng về kết quả, sai về chi phí: trang 1 và trang 10.000 trả về cùng 20 dòng, nhưng chi phí chênh 10.000 lần. Người dùng bấm "trang cuối" là database quét cả bảng.
Đếm: COUNT(*) trên bảng lớn là quét cả bảng
"Tổng 3.245.671 đơn" mỗi lần mở trang = 5 giây. Cách khác: đếm ước lượng (pg_class.reltuples), đếm cache trong Redis cộng dồn, hoặc hiện "hơn 1.000" thay vì số chính xác. Chỉ đếm chính xác khi có WHERE đi qua mục lục hẹp.
Hỏi ngược: người dùng có cần số chính xác không? "3.245.671" và "hơn 3 triệu" — với dashboard, câu sau đủ, và rẻ hơn 1.000 lần. Nhiều tối ưu database thực ra là tối ưu yêu cầu.
Ghi theo lô
1.000 INSERT riêng = 1.000 lần đi-về mạng ≈ 1–2 giây. INSERT ... VALUES (...), (...), ... 1.000 dòng một câu ≈ 50ms. Prisma: createMany. Cập nhật nhiều dòng cùng giá trị: updateMany WHERE id IN, không loop. (Cùng bài học round-trip với MGET ở bài 04.)
Giao dịch ngắn
Mở transaction → gọi API bên ngoài 3 giây → commit. Suốt 3 giây đó dòng bị khoá, người khác chờ. Quy tắc: không gọi mạng trong transaction. Lấy dữ liệu → gọi ngoài → mở transaction ngắn ghi kết quả.
Hậu quả thật của vi phạm: API ngoài treo 15 giây (timeout, bài 06) → transaction giữ khoá 15 giây → mọi request khác đụng dòng đó chờ 15 giây → pool cạn → toàn hệ thống treo. Một lời gọi ngoài trong transaction biến timeout của họ thành downtime của mình.
Tránh hàm trên cột được lọc
WHERE LOWER(email) = '[email protected]' → mục lục trên email vô dụng. Hoặc lập mục lục trên LOWER(email), hoặc lưu sẵn cột đã lowercase.
Bậc 3 — Kết nối và cấu hình
Bể kết nối (connection pool)
Mỗi kết nối Postgres tốn ~5–10MB RAM và một tiến trình. 500 kết nối = database ngộp dù chưa làm gì. Quy tắc: pool = (số CPU × 2) + số đĩa, thường 10–30 cho một app instance. Chạy 10 instance × 20 = 200 kết nối → cần PgBouncer đứng giữa gom lại còn 30 kết nối thật.
Vì sao pool nhỏ lại nhanh hơn: database có 4 CPU thì chỉ chạy được 4 truy vấn thật sự song song. 200 kết nối cùng hoạt động = 200 truy vấn tranh 4 CPU = context switch liên tục, cái nào cũng chậm. 20 kết nối, 180 chờ ở pool phía app → 20 cái chạy nhanh, xong nhường. Tổng throughput cao hơn.
Triệu chứng thiếu pool: lỗi too many connections hoặc app chờ lấy kết nối (P2024 trong Prisma). Triệu chứng thừa: CPU database cao mà truy vấn nào cũng nhanh.
Cấu hình Postgres đáng chỉnh (mặc định rất bảo thủ)
shared_buffers = 25% RAM (cache dữ liệu trong RAM).
effective_cache_size = 50–75% RAM (cho planner biết OS cache).
work_mem = 16–64MB (sắp xếp/ghép trong RAM thay vì đĩa).
random_page_cost = 1.1 trên SSD (mặc định 4 là cho HDD, làm planner ngại dùng mục lục).
Mục cuối đáng nhớ nhất: mặc định Postgres tưởng đang chạy trên HDD (đọc ngẫu nhiên đắt gấp 4 đọc tuần tự) nên thích quét bảng hơn dùng index. Trên SSD điều đó sai. Một dòng cấu hình có thể làm planner đổi ý ở hàng loạt truy vấn.
VACUUM & bloat
Postgres không xoá dòng ngay khi DELETE/UPDATE, đánh dấu chết, autovacuum dọn sau. Bảng ghi/xoá nhiều (tracking event, history) mà autovacuum không theo kịp → bảng phình gấp 3, mục lục chậm. Theo dõi pg_stat_user_tables.n_dead_tup; bảng nóng thì chỉnh autovacuum_vacuum_scale_factor nhỏ hơn (0.01).
Bậc 4 — Tách đọc / ghi và tách bảng
Tới bậc này là hệ thống đã lớn thật: index đúng, query đúng, pool đúng, vẫn chậm. Mỗi kỹ thuật ở đây thêm một bộ phận vào kiến trúc — thêm thứ để vận hành, thêm chỗ hỏng.
Bản sao chỉ đọc (read replica)
Một bản chính nhận ghi, 1–3 bản sao nhận đọc (dashboard, báo cáo, export). Bản sao trễ vài chục ms → không đọc "vừa ghi xong" từ bản sao (ghi xong đọc lại phải vào bản chính). Cách rẻ nhất để nhân đôi sức đọc.
Bẫy "trễ vài chục ms": user tạo đơn → redirect sang trang danh sách → đọc từ replica → không thấy đơn vừa tạo. Lỗi này ngẫu nhiên, khó tái hiện. Quy tắc: đọc sau ghi trong cùng luồng → bản chính.
Bảng tổng hợp (aggregate / materialized view)
Đã nói ở bài 02: báo cáo đọc bảng chốt sổ theo ngày, không cộng lại triệu dòng. Postgres có MATERIALIZED VIEW + REFRESH ... CONCURRENTLY theo cron — cùng ý, ít code hơn.
Phi chuẩn hoá có chủ đích (denormalization)
Chuẩn: Order → customer_id → JOIN Customer lấy tên. Ở 20 triệu đơn, JOIN mỗi lần đọc danh sách tốn. Chép tên khách vào Order.customer_name lúc ghi → đọc không JOIN. Đổi lại phải cập nhật 2 chỗ khi khách đổi tên (hoặc chấp nhận tên "lúc mua"). Dùng khi đọc gấp 100 lần ghi.
Chữ "có chủ đích" quan trọng: phi chuẩn hoá là quyết định có ghi lý do, không phải "lười JOIN". Phải trả lời: dữ liệu chép có được phép lệch không? Lệch thì ai đồng bộ?
Chia bảng theo thời gian (partitioning)
Bảng AppTrackingEvent 500 triệu dòng, hỏi luôn "30 ngày qua". Chia thành bảng con theo tháng (PARTITION BY RANGE (created_at)): truy vấn chỉ đụng 1–2 bảng con; xoá dữ liệu cũ = DROP bảng con (tức thì, không DELETE 400 triệu dòng). Postgres 12+ làm tự động, code không đổi.
Lợi ích thứ hai (DROP thay DELETE) thường lớn hơn lợi ích thứ nhất. DELETE 400 triệu dòng = giờ đồng hồ + bloat khổng lồ + autovacuum chạy cả ngày. DROP PARTITION = 1 giây.
Lưu trữ (archiving)
Dữ liệu > 1 năm chuyển sang bảng/DB lạnh hoặc S3 dạng Parquet. Bảng nóng nhỏ → mục lục nằm gọn trong RAM → mọi thứ nhanh. Đa số hệ thống "chậm dần theo năm" chỉ vì không bao giờ dọn.
Bảng nóng tách khỏi bảng rộng
Order 80 cột nhưng danh sách chỉ cần 6 cột → tách OrderSummary (6 cột, mục lục gọn) và OrderDetail (JSON to). Quét danh sách đọc bảng hẹp, mở chi tiết mới đụng bảng rộng. (Dự án đi hướng này bằng Mongo AllOrder cho phần dày.)
Bậc 5 — Nhiều database (sharding): chỉ khi bậc 1–4 hết đường
Khi nào thật sự cần
Một máy Postgres lớn (32 CPU, 256GB) + replica + partition chịu được hàng trăm triệu dòng và vài chục nghìn truy vấn/giây. Đa số sản phẩm không bao giờ tới ngưỡng này. Shard khi: bản chính nhận ghi > ~20.000/giây bền vững, hoặc dữ liệu > vài TB.
Nói rõ để không sợ: sharding là chủ đề "nghe oai" trong phỏng vấn, nhưng trong thực tế là bậc cuối cùng, và cái giá của nó (mất JOIN, mất transaction chéo, vận hành N database) rất đắt. Ai đề xuất shard mà chưa có slow query log là đang giải sai bài.
Cách chia
Theo tenant (shop A–M ở DB1, N–Z ở DB2) là tự nhiên nhất cho multi-tenant: mọi truy vấn đã có shopDomain → biết đi DB nào. Cái giá: báo cáo toàn hệ thống phải gộp từ N DB; chuyển shop giữa DB là việc lớn; JOIN chéo shard không có.
Đây là lý do thứ hai vì sao bài 02 bắt mọi bảng có cột shop và mọi query lọc theo shop: hôm nay để cách ly dữ liệu; ngày mai để biết đi shard nào. Kỷ luật nhỏ hôm nay mở đường lớn sau này.
Công cụ: Citus (Postgres phân tán), Vitess (MySQL), hoặc tự route ở tầng app.
Bậc 0 — Trước tất cả: đừng hỏi database
Thứ tự tìm câu trả lời: RAM app → Redis → bản sao đọc → bản chính. Hộp thư khách trong Redis (bài 04) là ví dụ: 200.000 lượt/ngày không đụng Postgres. Mọi câu hỏi lặp lại cùng kết quả trong vài giây/phút → cache. Database là nơi đắt nhất, hỏi cuối cùng.
Gọi là "bậc 0" vì nó đứng trước cả index: truy vấn nhanh nhất là truy vấn không chạy.
Checklist bảng triệu dòng
- Mọi
WHERE thường dùng có mục lục đúng thứ tự cột? EXPLAIN không có Seq Scan?
- Slow query log bật, có người đọc hàng tuần?
- Không có N+1? Không
SELECT * bảng rộng? Phân trang sâu dùng keyset?
- Ghi theo lô? Transaction không chứa gọi mạng?
- Pool đúng cỡ, có PgBouncer khi nhiều instance?
- Autovacuum theo kịp bảng nóng?
- Dashboard đọc bảng tổng hợp / replica, không đọc bảng gốc?
- Bảng log/tracking có partition theo thời gian và lịch dọn?
- Thứ gì hỏi lặp đi lặp lại đã nằm trong Redis?
Tự kiểm tra
- Vì sao index composite
(shop, created_at) khác (created_at, shop)? Query nào dùng được cái đầu mà không dùng được cái sau?
- Một truy vấn có index nhưng
EXPLAIN vẫn ra Seq Scan. Kể 3 lý do có thể.
- Pool 20 kết nối nhanh hơn pool 200 — giải thích bằng số CPU.
- Khi nào không nên dùng read replica cho một truy vấn đọc?
Bài tập
- Mở
schema.prisma của dự án. Với 5 bảng lớn nhất (đoán theo nghiệp vụ: Order, NotificationHistory, AppTrackingEvent…), liệt kê @@index hiện có. Với mỗi index, tìm query trong code dùng nó. Tìm query nào chưa có index khớp.
- Chạy
EXPLAIN ANALYZE cho query danh sách thông báo của một shop trên database dev. Đọc kết quả: Index Scan hay Seq Scan? actual time bao nhiêu? Nếu bảng dev nhỏ, tự sinh 1 triệu dòng rồi chạy lại.
10 · Server triệu request
Bài 10 — Server chịu hàng triệu request: từ một máy tới nhiều máy
Bài này trả lời câu hỏi gì
"Hệ thống của tôi có chịu được 1 triệu khách không?" — câu hỏi nghe to, nhưng trả lời được bằng một phép tính và sáu lớp kỹ thuật. Bài này dạy cách quy đổi khách thành request/giây, rồi đi qua 6 lớp từ trong ra ngoài: một request phải rẻ → cache theo tầng → nhân bản → chống sập dây chuyền → mạng → đo.
Điểm nhấn của cả bài: hệ thống chết ở 200 request/giây không phải vì thiếu máy, mà vì một request làm việc quá nặng hoặc chờ thứ chậm. Thêm máy là bước 6 trong 10 bước, không phải bước 1.
Trước hết: "triệu khách hàng" là bao nhiêu request?
Đừng thiết kế cho con số tưởng tượng. Tính:
1.000.000 khách cài app
× 20% mở app mỗi ngày = 200.000 người/ngày
× 10 request mỗi lần mở = 2.000.000 request/ngày
÷ 86.400 giây ≈ 23 request/giây trung bình
× 10 (giờ cao điểm) ≈ 230 request/giây đỉnh
× 5 (flash sale, push đồng loạt) ≈ 1.000–1.500 request/giây đỉnh của đỉnh
Ba con số cần tách bạch: trung bình (23) để tính chi phí; đỉnh ngày (230) để chọn cỡ máy; đỉnh của đỉnh (1.500) để thiết kế chống sập (lớp 4). Thiết kế cho trung bình → chết mỗi tối. Thiết kế cho đỉnh của đỉnh bằng máy → trả tiền 50 lần cho 1 giờ mỗi tháng. Cách đúng: máy cho đỉnh ngày, cơ chế (queue, backpressure, shedding) cho đỉnh của đỉnh.
Một process Node/Fastify làm việc nhẹ (tra Redis, trả JSON) chịu 5.000–15.000 request/giây. Nghĩa là 1 triệu khách ≈ 1–2 máy nhỏ nếu làm đúng.
Đo trước, thêm máy sau. Công cụ: k6, autocannon, wrk — bắn 1.000 request/giây vào staging, xem cái gì gãy trước. Cái gãy trước cho biết bước tiếp theo — đoán không thay được đo.
Lớp 1 — Một request phải rẻ
Không chặn vòng lặp sự kiện (Node)
Node xử lý mọi request trên một luồng. Một việc tính toán nặng 200ms (băm mật khẩu, nén ảnh, parse JSON 50MB, regex phức tạp) → mọi request khác đứng im 200ms. Triệu chứng: CPU 100% một core, mọi API chậm đều nhau.
- Việc nặng CPU → worker_threads hoặc đẩy sang queue.
- JSON.parse body lớn → giới hạn bodyLimit (dự án: 10MB).
- Theo dõi event loop lag (perf_hooks.monitorEventLoopDelay): > 50ms là có vấn đề.
Chỗ hay hiểu sai: "Node bất đồng bộ nên không bị chặn". Bất đồng bộ chỉ cho I/O (mạng, đĩa). Tính toán thuần (vòng lặp, regex, JSON.parse) vẫn chạy trên luồng chính và chặn tất cả. async không làm CPU work thành song song.
Mọi thứ chờ đều phải có giới hạn
Request chờ DB, chờ Redis, chờ Shopify. Chờ càng lâu càng nhiều request nằm trong RAM. Timeout mọi thứ (bài 06). Kết nối DB dùng pool. Gọi HTTP ra ngoài dùng keep-alive (agent tái dùng kết nối, tiết kiệm bắt tay TLS ~50–100ms mỗi lần).
Trả ít, nén
Danh sách 20 sản phẩm không cần trả 80 trường mỗi món. Bật gzip/brotli (@fastify/compress) — JSON nén còn 10–20%. Với app mobile, ít byte = nhanh hơn trên 4G.
Việc > 100ms → không làm trong request
Đã nói xuyên suốt: queue. Request chỉ ghi phiếu.
Lớp 2 — Cache theo tầng: gần khách nhất trước
Điện thoại (cache trong app) 0ms ← "đã tải rồi thì đừng tải lại"
CDN / edge (CloudFront, Cloudflare) 10–30ms ← ảnh, file tĩnh, API công khai không đổi theo người
Bộ nhớ process (Map trong RAM) 0,01ms ← cấu hình, danh sách shop, vài KB đọc triệu lần
Redis 0,3–1ms ← hộp thư, session, cache theo người
Database 1–5ms+ ← nguồn sự thật, hỏi cuối cùng
Mỗi tầng chặn một phần request khỏi tầng dưới. Hệ thống tốt: 90%+ request không tới database.
Cách đọc bảng: từ trên xuống, mỗi tầng chậm hơn ~10–100 lần và chứa được nhiều loại dữ liệu hơn. Cache trong app: chỉ dữ liệu của riêng người đó. CDN: chỉ dữ liệu ai cũng giống nhau. RAM process: nhỏ, mất khi restart, mỗi bản một bản sao (không đồng bộ). Redis: chia sẻ giữa mọi bản. Chọn tầng = trả lời "dữ liệu này giống nhau cho ai, chịu cũ bao lâu, to bao nhiêu".
Quy tắc cache
- Key phải chứa mọi thứ làm kết quả khác nhau (shop, user, trang, ngôn ngữ). Thiếu một → người này thấy dữ liệu người kia. Đây là lỗi cache nguy hiểm nhất vì nó là rò dữ liệu, không phải chậm.
- TTL theo mức chịu cũ: giá sản phẩm 60s, cấu hình shop 5 phút, hồ sơ khách 1 giờ.
- Xoá chủ động khi ghi (write-through / invalidate) cho thứ không chịu cũ được.
- Cache stampede (bài 04): lock + stale.
- Cache negative: "không tìm thấy" cũng cache 30s, không thì bot quét id lạ đánh thẳng DB. Ít người nghĩ tới: cache chỉ giúp khi có kết quả; id không tồn tại thì mỗi lần hỏi đều xuống DB, và kẻ quét id ngẫu nhiên toàn hỏi id không tồn tại.
- HTTP:
Cache-Control, ETag → app gửi "tôi có bản X", server trả 304 Not Modified không có body.
Lớp 3 — Nhân bản: stateless + load balancer
Stateless
Process không giữ gì giữa hai request: không session trong RAM, không file tạm cục bộ, không đếm trong biến toàn cục. Mọi trạng thái → Redis/DB. Khi đó chạy 1 hay 20 bản như nhau, bản nào chết bản khác thay, deploy = bật bản mới tắt bản cũ.
Kiểm tra: tắt đột ngột một bản, khách có mất gì không? Nếu có → có trạng thái trong RAM. (Dự án: scheduler trong RAM là điểm này, nên mới cần ghi giấy ra Redis — bài 07.)
Stateless là điều kiện tiên quyết của cả lớp này. Load balancer, cluster, autoscale đều giả định bản nào cũng thay được bản nào. Có trạng thái trong RAM → request 1 vào bản A, request 2 vào bản B, B không biết A đã làm gì → lỗi ngẫu nhiên, chỉ xuất hiện khi >1 bản.
Load balancer
nginx / ALB chia request cho N bản, health check mỗi 5–10 giây (GET /health trả 200 khi DB + Redis ok), bản nào fail 3 lần thì rút khỏi vòng. Round-robin đủ dùng; least-connections khi request dài ngắn không đều.
Health check phải thật: /health trả 200 cứng thì bản mất DB vẫn nhận request và trả 500 cho 1/N khách. Health check kiểm tra DB + Redis → bản hỏng tự rút, khách không thấy.
Node cluster
Một máy 8 CPU chạy 1 process Node = dùng 1 CPU. cluster module hoặc PM2 -i max → 8 process, load balancer nội bộ. Hoặc chạy 8 container 1 CPU — Kubernetes/ECS thích cách này hơn.
Autoscale
Đo CPU/latency → > 70% trong 2 phút thì thêm bản, < 30% trong 10 phút thì bớt. Cần stateless trước, và pool DB tổng không vượt PgBouncer.
Điều kiện thứ hai hay bị quên: autoscale lên 20 bản × pool 20 = 400 kết nối → database chết vì quá nhiều kết nối đúng lúc đang cần nhất. Autoscale app mà không có PgBouncer là tự chuyển nút thắt từ app sang DB.
Lớp 4 — Chống sập dây chuyền (resilience)
Hệ thống lớn chết vì một phần chậm kéo tất cả chậm, không phải vì hết CPU. Lớp này là bộ công cụ cắt dây chuyền.
Circuit breaker (cầu dao)
Gọi Shopify lỗi 50% trong 10 giây → ngắt cầu dao: 30 giây tiếp theo không gọi nữa, trả lỗi ngay (hoặc bản cache). Sau 30s thử 1 cuộc; ok thì đóng lại. Không có cầu dao: 1.000 request × 15s timeout = 15.000 giây chờ tích luỹ. Thư viện: opossum, cockatiel.
Khác timeout thế nào: timeout bảo vệ một request khỏi chờ vô hạn. Cầu dao bảo vệ hệ thống khỏi 1.000 request cùng chờ 15 giây. Timeout là điều kiện cần; cầu dao là lớp trên.
Bulkhead (vách ngăn tàu)
Mỗi bên ngoài một pool riêng: tối đa 20 cuộc đồng thời tới Shopify, 10 tới Gorgias. Shopify treo chỉ chiếm 20 chỗ, 480 chỗ còn lại vẫn phục vụ hộp thư. Không vách ngăn → Shopify treo ăn hết 500 chỗ.
Backpressure (áp lực ngược)
Hàng đợi dài quá ngưỡng → từ chối sớm (503 + Retry-After) thay vì nhận rồi chết. nginx limit_req burst, BullMQ đếm getWaitingCount(). Từ chối 5% request lúc đỉnh tốt hơn chết 100%.
Load shedding & giảm cấp (graceful degradation)
Quá tải → tắt tính năng phụ trước: gợi ý sản phẩm, đếm chưa đọc, analytics. Giữ tính năng chính: xem hộp thư, thanh toán. Thiết kế sẵn cờ tắt (feature flag) cho từng phần.
Cả backpressure lẫn shedding đều dựa trên một nhận thức: "phục vụ tất cả" không phải lựa chọn lúc quá tải. Lựa chọn thật là "phục vụ 95% tốt" hay "phục vụ 100% tệ rồi chết". Không thiết kế trước → hệ thống tự chọn cái sau.
Retry có kỷ luật
Retry với jitter (ngẫu nhiên ±30%) để 1.000 client không cùng thử lại đúng giây thứ 2. Retry budget: tối đa 10% tổng request là retry; hơn → ngừng retry. Retry không kỷ luật biến sự cố nhỏ thành retry storm.
Retry storm: DB chậm 1 giây → 1.000 request timeout → 1.000 retry → DB nhận 2.000 → chậm 2 giây → 2.000 retry… Sự cố tự khuếch đại. Jitter rải đều; budget cắt vòng xoáy.
Idempotency key
Client gửi Idempotency-Key: <uuid> với mọi request ghi tiền/đơn. Server lưu key + kết quả 24h; gặp lại key → trả kết quả cũ, không làm lại. Mạng 4G rớt giữa chừng, app gửi lại → không tạo 2 đơn.
Giới hạn theo tenant, không chỉ theo IP
Một shop 500.000 khách gửi push đồng loạt → app của shop đó mở cùng lúc → 500.000 request từ 500.000 IP khác nhau. Throttle IP vô nghĩa. Cần: đếm theo shop (Redis INCR shop:x:rps), shop vượt hạn mức thì hàng của họ chờ, shop khác không ảnh hưởng. Fair queuing.
Đây là điểm bài 03 để lại: throttle IP (có sẵn trong dự án) chặn máy lẻ; throttle tenant chặn "một shop ăn hết". Multi-tenant mà không có throttle theo tenant thì shop lớn nhất quyết định chất lượng dịch vụ của mọi shop khác.
Lớp 5 — Giao thức & mạng
- HTTP keep-alive giữa load balancer ↔ app, app ↔ Redis/DB: tránh bắt tay lại mỗi request.
- HTTP/2 ở edge: nhiều request chung một kết nối, app mobile mở 10 API cùng lúc không tốn 10 kết nối.
- CDN cho ảnh thông báo/sản phẩm: 1 triệu khách tải cùng ảnh banner → 1 lần từ origin, còn lại từ edge.
- DNS TTL ngắn (60s) để chuyển hướng nhanh khi failover.
- Region: server ở Singapore phục vụ khách Việt Nam ~30ms; ở US ~200ms. Mỗi request mobile qua 3–4 API tuần tự → chênh 0,5 giây cảm nhận được.
Lớp này thường cho ít nhất so với công sức — trừ region. Tối ưu code từ 50ms xuống 20ms rồi đặt server cách khách 200ms là vô nghĩa. Kiểm tra region trước khi tối ưu code.
Lớp 6 — Đo để biết đang ở đâu
Không đo thì mọi tối ưu là đoán. Tối thiểu:
| Đo gì |
Ngưỡng báo động |
Vì sao |
| p50 / p95 / p99 latency mỗi endpoint |
p99 > 1s |
Trung bình che giấu; p99 là 1% khách khổ nhất |
| Request/giây, lỗi 5xx/giây |
5xx > 1% |
Tỉ lệ lỗi, không phải số tuyệt đối |
| Event loop lag |
> 50ms |
Có việc chặn luồng |
| Pool DB: đang dùng / chờ |
chờ > 0 kéo dài |
Thiếu pool hoặc query chậm |
| Redis: ops/giây, latency, memory |
latency > 5ms |
Redis là single-thread, chậm là do lệnh nặng |
| Queue: đang chờ, đang làm, thất bại |
chờ tăng liên tục 5 phút |
Worker không theo kịp |
| Cache hit ratio |
< 80% |
Cache vô dụng hoặc TTL quá ngắn |
| CPU / RAM / đĩa từng bản |
CPU > 70% |
Ngưỡng autoscale |
p99 quan trọng hơn trung bình: trung bình 50ms nhưng p99 3 giây nghĩa là 1 trong 100 lần mở app khách chờ 3 giây — ở 200.000 người/ngày là 2.000 người. Và người khổ nhất là người kêu to nhất.
Mỗi dòng trong bảng ứng với một lớp ở trên: event loop lag → lớp 1; cache hit → lớp 2; CPU → lớp 3; queue/pool → lớp 4. Bảng này là bảng điều khiển của 5 lớp trước.
Thứ tự làm khi hệ thống bắt đầu chậm
- Bật đo (nếu chưa). Không đo, dừng ở đây.
- Tìm endpoint p99 tệ nhất →
EXPLAIN query của nó → sửa mục lục / N+1. Thường xong ở đây.
- Cache câu hỏi lặp lại.
- Việc > 100ms → queue.
- Timeout + circuit breaker cho mọi bên ngoài.
- Stateless → thêm bản → load balancer.
- Read replica cho dashboard.
- Throttle theo tenant.
- Partition / archive bảng lớn.
- Shard — chỉ khi 1–9 đã hết.
Đa số hệ thống 1 triệu khách dừng ở bước 6–7.
Để ý "thêm máy" là bước 6. Năm bước trước đó không tốn tiền hạ tầng — chỉ tốn công. Người mới hay làm ngược: thêm máy trước (dễ, tốn tiền, hiệu quả thấp vì nút thắt ở DB), rồi mới tối ưu.
Tự kiểm tra
- Tính lại bảng quy đổi cho 5 triệu khách, 30% mở mỗi ngày, 15 request/lần. Ra bao nhiêu rps trung bình, đỉnh, đỉnh của đỉnh? Cần mấy process Node?
async có làm CPU work chạy song song không? Vì sao? Việc nào trong dự án có thể chặn event loop?
- Timeout, circuit breaker, bulkhead — mỗi cái bảo vệ khỏi gì? Có cái này rồi có cần cái kia không?
- Autoscale từ 2 lên 20 bản mà không có PgBouncer — chuyện gì xảy ra?
Bài tập
- Dùng
autocannon bắn 500 rps vào endpoint hộp thư trên máy dev trong 30 giây. Ghi p50/p95/p99. Xem cái gì gãy trước: CPU? Redis? DB pool?
- Liệt kê mọi tính năng trong dự án theo hai cột: "chính" (tắt là khách kêu ngay) và "phụ" (tắt được vài phút lúc quá tải). Với cột phụ, tính năng nào đã có cờ tắt? Cái nào chưa?
11 · DevOps & vận hành
Bài 11 — DevOps & vận hành: giữ hệ thống sống khi không ai ngồi nhìn
Bài này trả lời câu hỏi gì
Mười bài trước nói về code. Bài này nói về mọi thứ quanh code để nó chạy được ở production lúc 3 giờ sáng khi không ai nhìn: đóng gói, đưa lên, quan sát, cảnh báo, sao lưu, bí mật, hạ tầng, sự cố, chi phí.
Dev mobile chuyển sang backend hay coi phần này là "việc của ops". Không phải. Ở team nhỏ, người viết code là người bị gọi lúc 3 giờ sáng. Và ở team lớn, người hiểu vận hành viết code khác hẳn: có health check thật, có graceful shutdown, có log tìm được.
Bài này chia 10 bài nhỏ, mỗi bài theo mạch hỏi – ví dụ – khái niệm – làm – tự kiểm tra. Câu "tự kiểm tra" là thước đo thật: trả lời được là hiểu, trả lời không được là biết cần học gì.
11.1 — Đóng gói: "chạy trên máy em được mà"
Hỏi: Em viết code chạy ngon trên laptop. Đưa lên server thì lỗi. Vì sao?
Ví dụ đời thường: Em nấu phở ở nhà ngon. Sang bếp quán, nồi khác, bếp gas khác, nước mắm khác → vị khác. Giải pháp: mang nguyên cái bếp của em sang quán.
Khái niệm — Docker: đóng toàn bộ "bếp" (Node đúng phiên bản, thư viện, biến môi trường, file cấu hình) vào một hộp (image). Hộp chạy ở đâu cũng y hệt. Dự án có Dockerfile — đó là công thức đóng hộp.
Làm thực tế:
Dockerfile: FROM node:20 → COPY code → pnpm install → pnpm build → CMD node dist/main.js
docker build -t notify:1.2.3 . ← đóng hộp, dán nhãn phiên bản
docker run notify:1.2.3 ← chạy hộp
Quy tắc: một hộp = một phiên bản, không sửa hộp đã đóng. Lỗi thì đóng hộp mới 1.2.4, không SSH vào sửa tay.
Chỗ hay hiểu sai: "SSH vào sửa nhanh một dòng rồi mai đóng hộp sau." Ngày mai không tới. Ba tháng sau, hộp 1.2.3 được deploy lại (rollback, thêm máy) → dòng sửa tay biến mất → bug "đã sửa" quay lại, không ai hiểu vì sao. Image bất biến không phải kỷ luật suông — nó là thứ làm cho "đang chạy gì" luôn trả lời được.
Tự kiểm tra: Nếu server cháy, em dựng lại toàn bộ từ đầu mất bao lâu? Nếu > 1 giờ hoặc "không nhớ đã cài gì" → chưa đóng hộp đủ.
11.2 — CI/CD: máy kiểm tra hộ, máy đưa lên hộ
Hỏi: Ai đảm bảo code em push lên không làm hỏng cái đang chạy?
Ví dụ: Nhà máy có dây chuyền kiểm tra: mỗi sản phẩm qua 5 trạm (nhìn, cân, đo, thử, đóng gói). Sản phẩm hỏng bị gạt ra ở trạm nào thì dừng ở đó. Không có dây chuyền → người bán phát hiện hỏng khi khách trả lại.
Khái niệm:
- CI (Continuous Integration): mỗi lần push, máy tự chạy: lint → build → test → quét lỗ hổng. Đỏ thì không cho merge.
- CD (Continuous Delivery/Deployment): merge vào main → tự đóng hộp → tự đưa lên staging → (bấm nút hoặc tự động) lên production.
Làm thực tế (GitHub Actions / GitLab CI):
on: push
jobs:
test: pnpm lint && pnpm build && pnpm test
build: docker build → push lên registry với tag = git sha
deploy: kéo image tag mới về server → chạy theo cách ở 11.3
Thời gian mục tiêu: push → biết pass/fail < 10 phút. Lâu hơn người ta bỏ không chờ, và CI chậm dần thành CI bị bỏ qua.
Chi tiết nhỏ có ý: tag image = git sha, không phải latest. latest hôm nay và latest tuần sau là hai thứ khác nhau; khi cần rollback, không biết latest nào là bản ổn. Sha trỏ về đúng commit — truy vết được.
Tự kiểm tra: Em có dám merge PR của người khác lúc 5h chiều thứ Sáu không? Nếu không → CI chưa đủ tin.
11.3 — Deploy không làm khách thấy: rolling, blue-green, canary
Hỏi: Đang có 1.000 khách online. Em đưa bản mới lên. Làm sao họ không thấy "đang bảo trì"?
Ví dụ: Quán có 3 đầu bếp. Thay đầu bếp mới: đừng đuổi cả 3 rồi thuê 3 người mới (quán đóng 10 phút). Thay từng người một, người mới nấu thử một bát trước.
Ba cách:
| Cách |
Làm gì |
Khi nào |
| Rolling |
Có 3 bản đang chạy. Bật bản mới thứ 4 → chờ health check xanh → tắt 1 bản cũ → lặp. Luôn có ≥ 3 bản sống |
Mặc định cho app stateless |
| Blue-green |
Dựng cả bộ mới (green) cạnh bộ cũ (blue). Xanh hết → chuyển load balancer sang green trong 1 giây. Lỗi → chuyển ngược lại 1 giây |
Khi cần quay lui tức thì; tốn gấp đôi máy trong lúc deploy |
| Canary |
Đưa bản mới cho 1% khách. Theo dõi lỗi/latency 10 phút. Ổn → 10% → 50% → 100%. Xấu → rút |
Thay đổi lớn, sợ lỗi chỉ lộ ở tải thật |
Điều kiện tiên quyết — bốn thứ này không có thì cả ba cách trên đều không dùng được:
- Stateless (bài 10) — không thì tắt bản cũ là mất session.
- Health check thật: /health phải kiểm tra DB + Redis kết nối được, không chỉ trả ok. Bản mới boot xong nhưng chưa nối DB mà nhận traffic → lỗi hàng loạt.
- Graceful shutdown: nhận tín hiệu SIGTERM → ngừng nhận request mới → làm nốt request đang dở (≤ 30s) → đóng DB/Redis → thoát. NestJS: app.enableShutdownHooks(). Dự án có onModuleDestroy ở scheduler, SQS, Redis — đúng chỗ (bài 07).
- Migration DB tương thích ngược: bản cũ và mới cùng chạy vài phút. Đổi tên cột = bản cũ chết. Cách đúng: thêm cột mới → deploy code ghi cả hai → chuyển đọc sang cột mới → xoá cột cũ ở lần deploy sau. ("expand → migrate → contract").
Điều kiện thứ tư hay bị bỏ quên nhất. Rolling deploy làm mọi người quên rằng trong 2 phút, hai phiên bản code cùng chạy trên một database. Migration nào làm bản cũ lỗi là migration làm 1/3 khách lỗi trong 2 phút đó — và nếu phải rollback code, cột đã đổi tên không tự đổi lại.
Tự kiểm tra: Em deploy lúc 12h trưa thứ Hai được không? Nếu chỉ dám deploy 2h sáng → chưa đạt.
11.4 — Quan sát: log, số đo, dấu vết
Hỏi: Khách báo "app chậm". Em tìm ở đâu?
Ví dụ: Bệnh viện có 3 thứ: sổ bệnh án (log — chuyện gì đã xảy ra, từng dòng), máy đo (metrics — nhịp tim, huyết áp theo thời gian), và hồ sơ theo dõi một ca (trace — bệnh nhân này đi qua khoa nào, mỗi khoa bao lâu).
Ba trụ:
Log — ghi sự kiện. Quy tắc:
- JSON có cấu trúc, không phải câu văn: {"level":"error","shop":"x","notiId":"y","msg":"FCM failed","code":"..."}. Máy lọc được shop=x.
- Mỗi request có request id, truyền xuống mọi log của request đó và sang cả worker (đặt vào job data). Tìm một id ra cả hành trình.
- Không log bí mật (bài 03). Không log body to. Mức: error (cần người), warn (bất thường tự xử lý được), info (mốc), debug (tắt ở prod).
- Gom về một chỗ (Loki, CloudWatch, Axiom, Datadog). Log nằm trên từng máy = mất khi máy chết.
Request id sang worker là điểm hay bị đứt. Request tạo thông báo có id abc; phiếu lên băng chuyền; worker nhặt phiếu, log với id mới → hành trình bị cắt đôi ở ranh giới quầy/xưởng. Đặt id vào job data là mối nối.
Metrics — số theo thời gian. Bốn tín hiệu vàng cho mỗi dịch vụ:
1. Latency (p50/p95/p99)
2. Traffic (request/giây)
3. Errors (tỉ lệ 5xx)
4. Saturation (CPU, RAM, pool DB, độ dài queue)
Công cụ: Prometheus + Grafana (tự nuôi), CloudWatch, Datadog. NestJS: prom-client xuất /metrics.
Trace — một request đi qua đâu, mất bao lâu ở mỗi chặng: nginx 1ms → app 5ms → Redis 0,5ms → Postgres 45ms → FCM 1.200ms. Nhìn là biết chặng nào chậm. Công cụ: OpenTelemetry + Jaeger/Tempo/X-Ray.
Ba trụ trả lời ba câu khác nhau. Metrics: "có vấn đề không?" (p99 tăng). Trace: "vấn đề ở đâu?" (chặng FCM). Log: "chuyện gì đã xảy ra?" (FCM trả mã lỗi X cho shop Y). Thiếu một trụ là thiếu một câu trả lời.
Tự kiểm tra: Cho em một notificationId, em tìm được trong 2 phút: tạo lúc nào, đặt phiếu lúc nào, worker nhặt lúc nào, FCM trả gì, bao nhiêu máy nhận? Không → thiếu request id hoặc thiếu gom log.
11.5 — Cảnh báo: chuông kêu đúng lúc, không kêu bừa
Hỏi: Có 50 biểu đồ. Ai nhìn lúc 3h sáng?
Ví dụ: Báo cháy kêu khi có khói, không kêu khi nướng bánh. Kêu bừa → người ta tắt chuông → cháy thật không ai biết.
Nguyên tắc:
- Cảnh báo theo triệu chứng khách thấy (lỗi > 1%, p99 > 2s, queue tăng 10 phút liên tục), không theo nguyên nhân (CPU 80% — có thể bình thường).
- Mỗi cảnh báo có runbook: 5 dòng "thấy X → kiểm tra Y → làm Z". Người trực không phải nhớ.
- Hai mức: page (gọi người ngay: hệ thống đang chết) và ticket (sáng mai xem: đĩa còn 20%).
- SLO: cam kết "99,9% request < 1s trong 30 ngày". Ngân sách lỗi = 0,1% = 43 phút/tháng. Dùng hết ngân sách → ngừng ra tính năng, chỉ sửa ổn định.
Vì sao "triệu chứng, không nguyên nhân": CPU 80% lúc flash sale là bình thường — hệ thống đang làm việc. Kêu chuông lúc đó là kêu bừa. Nhưng p99 > 2s lúc nào cũng là khách đang khổ — không có ngoại lệ. Chuông theo nguyên nhân cần người diễn giải; chuông theo triệu chứng tự nói lên mức nghiêm trọng.
SLO và ngân sách lỗi là công cụ đàm phán giữa dev và ops: còn ngân sách → dev được deploy tính năng mới, chấp nhận rủi ro. Hết ngân sách → mọi người sửa ổn định. Con số thay cho tranh cãi.
Cảnh báo tối thiểu cho hệ thống như notify-service:
| Chuông | Ngưỡng |
|---|---|
| Lỗi 5xx | > 1% trong 5 phút |
| p99 endpoint mobile | > 1s trong 5 phút |
| Queue notifications chờ | > 1.000 và tăng 10 phút |
| Note orders:* trên Redis | tổng > 50.000 |
| Thông báo SENDING > 15 phút | > 10 |
| SQS consumer dừng | log "Stopping consumer" |
| Pool DB chờ | > 0 trong 2 phút |
| Redis memory | > 80% |
| Đĩa DB | > 80% |
| Backup thất bại | bất kỳ |
Bảng này khớp với bước D của bài 08: mỗi chuông ứng với một câu hỏi lúc cháy. Có chuông = lúc cháy không phải chạy lệnh tay.
Tự kiểm tra: Tuần vừa rồi có chuông nào kêu mà em tắt không làm gì không? Có → xoá hoặc chỉnh chuông đó.
11.6 — Sao lưu: không phải "có backup", mà là "đã khôi phục thử"
Hỏi: Ai đó chạy nhầm DELETE FROM "Order" không có WHERE. Em mất bao nhiêu?
Ví dụ: Mua bình cứu hoả để trong kho 5 năm chưa xịt thử. Cháy mới biết hết hạn.
Làm:
- Backup tự động hàng ngày + WAL/PITR (Postgres ghi nhật ký liên tục → khôi phục về đúng phút trước khi xoá nhầm). RDS có sẵn; tự nuôi thì pgBackRest/wal-g.
- Lưu ở nơi khác (S3 khác region). Máy chết thì backup trên cùng máy cũng chết.
- Diễn tập khôi phục hàng quý: kéo backup về máy trống, dựng lên, chạy app, đo mất bao lâu. Con số đó là RTO (thời gian khôi phục). Dữ liệu mất tối đa bao nhiêu phút là RPO.
- Redis: bật AOF nếu Redis giữ thứ không tái tạo được (hộp thư, phiếu BullMQ). Cache thuần thì không cần.
- Xoá mềm + isDeleted (bài 02) là lớp bảo vệ đầu tiên trước khi cần tới backup.
Vì sao PITR khác backup hàng ngày: backup 0:00, xoá nhầm 15:00 → backup hàng ngày mất 15 giờ dữ liệu (RPO = 24h). PITR khôi phục về 14:59 → mất 1 phút (RPO ≈ 0). Với bảng đơn hàng, chênh lệch đó là tiền thật.
Chỗ hay hiểu sai: "Có backup tự động là xong." Backup chưa từng khôi phục thử = không biết có khôi phục được không. Backup hỏng vì: quyền S3 sai, file bị cắt, phiên bản Postgres không khớp, mật khẩu mã hoá mất. Tất cả chỉ lộ ra lúc cần thật. Diễn tập là cách duy nhất biết trước.
Tự kiểm tra: RTO và RPO của em là bao nhiêu? Không biết → chưa có backup thật.
11.7 — Bí mật và quyền
Hỏi: File .env.prod chứa mật khẩu DB, khoá Firebase, HASH_KEY. Ai xem được?
Ví dụ: Chìa khoá két để trong ngăn kéo không khoá. Ai vào phòng cũng lấy được.
Làm:
- Bí mật không nằm trong git, không nằm trong image Docker. Nằm ở kho bí mật (AWS Secrets Manager, Vault, Doppler) hoặc ít nhất là biến môi trường do hệ thống deploy bơm vào lúc chạy.
- Xoay định kỳ (rotate): khoá DB 90 ngày, khoá API khi có người rời team.
- Quyền tối thiểu: process app chỉ được đọc hòm SQS của nó, không được xoá S3; DB user của app không có quyền DROP.
- Kiểm tra lúc boot: thiếu bí mật bắt buộc → không boot (dự án làm ở dòng đầu bootstrap — bài 03).
- Quét lộ bí mật trong CI (gitleaks, trufflehog).
"Không nằm trong image" đáng nhấn: image được push lên registry, nhiều người pull, đôi khi registry công khai nhầm. Bí mật trong image = bí mật trong mọi bản sao của image, mãi mãi (lịch sử layer không xoá được). Bơm lúc chạy thì image sạch, đổi bí mật không cần build lại.
Tự kiểm tra: Một dev nghỉ việc hôm nay. Em phải đổi những khoá nào, mất bao lâu? Không liệt kê được → chưa quản lý bí mật.
11.8 — Hạ tầng viết thành code
Hỏi: Server dựng tay 2 năm trước. Người dựng nghỉ. Em dựng cái thứ hai giống hệt được không?
Ví dụ: Công thức nấu ăn viết ra giấy → ai nấu cũng ra vị đó. Nấu theo trí nhớ → mỗi người một vị.
Khái niệm — IaC (Infrastructure as Code): mô tả máy chủ, mạng, DB, queue, DNS bằng file (Terraform, Pulumi, CloudFormation, hoặc docker-compose.yml cho quy mô nhỏ). Chạy file → hạ tầng dựng lên y hệt. Đổi hạ tầng = sửa file + review + apply, có lịch sử git.
Bắt đầu nhỏ: docker-compose.yml (dự án có sẵn) cho dev/staging → Terraform cho prod khi > 3 máy.
Cùng ý với 11.1 nhưng ở tầng trên: Docker làm app tái tạo được; IaC làm môi trường quanh app (mạng, DB, queue, DNS) tái tạo được. Có Docker mà không có IaC = hộp chuẩn đặt trên bếp dựng tay.
Tự kiểm tra: Dựng môi trường staging mới từ đầu mất bao lâu? > 1 ngày → cần IaC.
11.9 — Khi sự cố: bình tĩnh theo kịch bản
Hỏi: 2h sáng, chuông kêu "5xx 40%". Em làm gì đầu tiên?
Thứ tự (in ra dán tường):
1. Xác nhận — sự cố thật hay chuông hỏng? Mở dashboard 4 tín hiệu vàng.
2. Thông báo — kênh #incident: "đang xem, ảnh hưởng X, cập nhật sau 15 phút". Đừng im lặng.
3. Cầm máu trước, tìm nguyên nhân sau — vừa deploy? Rollback. Một bên ngoài chết? Ngắt cầu dao / tắt tính năng. Quá tải? Thêm máy / throttle. Không cần hiểu vì sao mới được rollback.
4. Khôi phục — xác nhận khách dùng lại được. Cập nhật kênh.
5. Hôm sau: post-mortem không đổ lỗi — timeline, nguyên nhân gốc, vì sao không phát hiện sớm hơn, 3 việc làm để không lặp lại (mỗi việc có người + hạn). Lỗi là của hệ thống, không của người bấm nút.
Bước 3 là bước dev hay làm sai nhất: bản năng kỹ sư là hiểu trước khi làm. Lúc sự cố, bản năng đó sai. Mỗi phút tìm nguyên nhân là một phút khách lỗi. Rollback trước — mất 2 phút — rồi tìm nguyên nhân thong thả với hệ thống đã sống.
"Không đổ lỗi" không phải để tử tế. Nó là để lần sau người ta dám nói thật. Post-mortem đổ lỗi → lần sau người gây lỗi giấu → nguyên nhân gốc không bao giờ lộ → lặp lại.
Tự kiểm tra: Rollback bản deploy gần nhất mất bao nhiêu phút, bằng lệnh gì? Không trả lời được trong 10 giây → luyện.
11.10 — Chi phí: tối ưu cuối cùng
Hỏi: Hoá đơn cloud tăng gấp đôi mà khách không tăng. Vì sao?
Thường gặp: máy bật mà không dùng; log giữ 2 năm; Redis to gấp 5 cần thiết; đọc DB thay vì cache; traffic ra ngoài (egress) vì ảnh không qua CDN; instance cỡ to chạy 10% CPU.
Làm: gắn nhãn (tag) mọi tài nguyên theo dịch vụ; xem hoá đơn theo nhãn hàng tháng; đặt ngân sách + chuông; right-size theo số đo thật; reserved/spot cho phần ổn định.
Gọi là "tối ưu cuối cùng" vì làm trước là sai: tối ưu chi phí khi chưa có metrics (11.4) là đoán; cắt máy khi chưa có autoscale (bài 10) là tự gây sự cố. Chi phí là hệ quả của 9 bài trước làm đúng.
Tự kiểm tra: Chi phí mỗi 1.000 khách hoạt động là bao nhiêu? Không biết → chưa nhìn hoá đơn theo nhãn.
Tóm tắt 10 bài bằng 10 câu hỏi
- Dựng lại từ đầu mất bao lâu? (Docker)
- Dám merge chiều thứ Sáu? (CI)
- Dám deploy trưa thứ Hai? (rolling + health + graceful)
- Từ một id tìm ra cả hành trình trong 2 phút? (log có cấu trúc + request id)
- Chuông nào kêu mà em bỏ qua? (alert theo triệu chứng)
- RTO/RPO là bao nhiêu, đã diễn tập chưa? (backup)
- Dev nghỉ, đổi khoá nào? (secrets)
- Dựng staging mới mất bao lâu? (IaC)
- Rollback bằng lệnh gì? (incident)
- Chi phí mỗi 1.000 khách? (cost)
Trả lời được 10 câu → em vận hành được hệ thống triệu khách. Chưa → biết cần học gì tiếp.
Bài tập
- Trả lời 10 câu trên cho dự án hiện tại, thật thà. Đánh dấu câu nào trả lời được, câu nào không. Chọn câu "không" đau nhất và làm nó trong tuần này.
- Viết runbook 5 dòng cho chuông "Thông báo
SENDING > 15 phút: > 10". Thấy gì → kiểm tra gì (lệnh cụ thể) → làm gì. Dùng bài 07 để biết reaper hoạt động thế nào.
12 · Lộ trình & con số
Bài 12 — Lộ trình học + những con số phải thuộc
Bài này trả lời câu hỏi gì
Hai câu, cho hai thời điểm khác nhau:
- Ngay bây giờ: những con số nào phải nằm trong đầu để ước lượng mà không cần đo? (Phần A)
- Sáu tháng tới: học gì, theo thứ tự nào, làm gì để biến đọc thành biết? (Phần B, C, D)
Vì sao bắt đầu bằng con số: kỹ sư hệ thống giỏi khác người mới không phải ở chỗ biết nhiều kỹ thuật hơn, mà ở chỗ nhìn một thiết kế là ước được nó chịu bao nhiêu và gãy ở đâu — trước khi viết dòng code nào. Ước được là nhờ thuộc vài chục con số. Không thuộc thì mọi quyết định là đoán.
Phần A — Con số thuộc lòng (để ước lượng mà không cần đo)
Độ trễ: cái gì nhanh, cái gì chậm
| Việc |
Thời gian |
So sánh nếu 1ns = 1 giây |
| Đọc RAM |
100 ns |
1,5 phút |
| Đọc SSD |
100 µs |
1 ngày |
| Redis cùng datacenter |
0,3–1 ms |
10 ngày |
| Postgres qua mục lục |
1–5 ms |
1–2 tháng |
| Mạng cùng datacenter |
0,5 ms |
6 ngày |
| Mạng Việt Nam ↔ Singapore |
30–50 ms |
1–1,5 năm |
| Mạng Việt Nam ↔ Mỹ |
150–250 ms |
5–8 năm |
| Gọi API bên ngoài (Shopify, FCM) |
200–2.000 ms |
6–60 năm |
| Quét cả bảng 10 triệu dòng |
5–30 s |
150–1.000 năm |
Cột thứ ba để cảm được tỉ lệ. Người ta không cảm được "100ns với 200ms", nhưng cảm được "1,5 phút với 6 năm". Nếu một request đọc RAM 1,5 phút rồi chờ API ngoài 6 năm — bạn hiểu ngay tối ưu chỗ đọc RAM là vô nghĩa.
Bài học: một lần gọi ra ngoài = 1.000 lần hỏi Redis. Mọi thứ trong bài 04–06 xoay quanh bảng này: cache (đổi 1 lần gọi ngoài lấy 1.000 lần Redis), queue (đẩy 6 năm ra khỏi đường chính), timeout (không cho 6 năm thành vô hạn).
Sức chịu của một máy (4 CPU / 16GB, làm đúng)
| Thành phần |
Chịu được |
| Node/Fastify, việc nhẹ |
5.000–15.000 request/giây |
| Postgres, truy vấn qua mục lục |
5.000–20.000 truy vấn/giây |
| Postgres, ghi từng dòng |
2.000–10.000 dòng/giây |
| Redis |
50.000–100.000 lệnh/giây |
| nginx |
50.000+ request/giây |
| BullMQ worker |
1.000–5.000 phiếu/giây (tuỳ việc) |
Chữ "làm đúng" là điều kiện. Postgres 20.000 truy vấn/giây là qua mục lục; một query quét bảng làm con số đó về 0. Node 15.000 rps là việc nhẹ; một JSON.parse 50MB chặn event loop là về 0. Bảng này là trần — code sai kéo xuống bao nhiêu cũng được.
Đọc bảng còn thấy nút thắt ở đâu: Redis chịu gấp 5–10 lần Postgres, nginx gấp 5 lần Node. Nên trong chuỗi nginx → Node → Redis → Postgres, Postgres gãy trước. Đó là lý do bài 09 dài hơn bài 04, và "bậc 0: đừng hỏi database".
Quy đổi khách → tải
Khách cài app × 20% mở/ngày = người hoạt động/ngày (DAU)
DAU × 10 request ÷ 86.400 = request/giây trung bình
× 10 = đỉnh giờ
× 5 = đỉnh sự kiện (push đồng loạt, flash sale)
1 triệu khách ≈ 200.000 DAU ≈ 23 rps trung bình ≈ 230 đỉnh giờ ≈ 1.000–1.500 đỉnh sự kiện. Hai máy nhỏ đủ, nếu làm đúng.
Ba hệ số (20%, 10, ×10, ×5) là giả định, không phải hằng số. App mạng xã hội: 60% mở/ngày, 100 request/lần. App tiện ích: 5%, 3 request. Việc của bạn là biết hệ số của app mình — đo từ analytics — rồi thay vào. Công thức không đổi, hệ số đổi.
Dung lượng
| Thứ |
Cỡ |
Một dòng Order phẳng |
~1 KB; kèm JSON line items ~5–20 KB |
| Một sự kiện tracking |
~0,5 KB |
| 1 triệu dòng × 1 KB |
1 GB |
| Một thông báo trong Redis |
~0,5 KB; 50.000 máy × 1 id trong ZSET ≈ 5 MB |
| Log JSON một request |
~0,5–1 KB → 2 triệu request/ngày ≈ 2 GB/ngày |
Bài học: log và tracking phình nhanh hơn dữ liệu nghiệp vụ 10–100 lần. Phải có lịch dọn từ ngày đầu.
Tính thử: 2 GB log/ngày × 365 = 730 GB/năm — cho 1 triệu khách. Đơn hàng cùng kỳ: 200.000 DAU × 2% mua × 1 KB × 365 ≈ 1,5 GB. Log gấp 500 lần dữ liệu thật. Không dọn log = trả tiền lưu trữ cho thứ không ai đọc sau 30 ngày.
Phần B — Lộ trình 6 tháng từ mobile sang backend/system
Mỗi tháng một trọng tâm. Mỗi trọng tâm: đọc → làm trên project thật → viết lại bằng lời mình. Ba bước, không bỏ bước nào: đọc mà không làm thì quên sau 2 tuần; làm mà không viết lại thì không biết mình hiểu tới đâu.
Thứ tự tháng có lý do: tháng 1 đọc hệ thống thật để có bối cảnh cho mọi thứ sau; tháng 2 database vì đó là nơi gãy trước (bảng sức chịu); tháng 3–4 các lớp quanh database; tháng 5 vận hành; tháng 6 tổng hợp. Đi ngược (học DevOps trước khi hiểu code chạy gì) là học công cụ mà không biết dùng vào việc gì.
Tháng 1 — Đọc hiểu một backend thật
- Đọc hết bộ tài liệu này, mỗi bài tự vẽ lại sơ đồ bằng tay.
- Chạy
notify-service local (docker-compose Postgres + Redis), bắn request bằng Swagger, xem log.
- Bài tập: theo dấu một notification từ bấm gửi tới điện thoại rung, ghi từng file, từng hàm, từng bảng/ô Redis nó đi qua.
Bài tập tháng 1 là bài quan trọng nhất cả lộ trình. Làm xong, bạn có một đường đi hoàn chỉnh trong đầu — quầy, tủ, bảng trắng, băng chuyền, worker, Firebase. Mọi khái niệm sau này đều gắn vào đường đi đó thay vì lơ lửng.
Tháng 2 — Database
- Đọc: Use The Index, Luke (miễn phí, online) — cuốn duy nhất cần về index.
- Làm: tạo bảng 5 triệu dòng giả (
generate_series), viết 5 câu hỏi, EXPLAIN ANALYZE trước/sau khi thêm mục lục. Cảm nhận 15 giây → 3ms bằng tay mình.
- Làm: bật slow query log trên local, tìm N+1 trong một service bất kỳ.
- Bài tập: chuyển một endpoint phân trang từ OFFSET sang keyset.
"Cảm nhận bằng tay mình" — không thay thế được. Đọc "index nhanh gấp 5.000 lần" là một chuyện. Ngồi chờ 15 giây, thêm một dòng CREATE INDEX, chạy lại thấy 3ms — là chuyện khác. Sau lần đó bạn không bao giờ quên kiểm tra index nữa.
Tháng 3 — Cache, queue, bất đồng bộ
- Đọc: tài liệu Redis về data types (string, hash, list, set, zset, stream) — 1 buổi.
- Làm: viết một endpoint có cache + stampede lock; tắt Redis giữa chừng xem còn chạy không.
- Làm: một BullMQ queue + worker, job cố tình lỗi, xem retry/backoff hoạt động; đặt
jobId xem dedup.
- Bài tập: nhận webhook giả (script bắn 1.000 request/10s bằng
autocannon) → ghi Redis Stream → worker đọc. Đo endpoint không chết.
Tháng 4 — Chịu tải & chống sập
- Đọc: chương "Reliability" trong Designing Data-Intensive Applications (Kleppmann) — cuốn kinh thánh, đọc dần cả năm.
- Làm:
k6 bắn 500 rps vào local, xem p99, xem event loop lag, tìm cái gãy đầu tiên.
- Làm: thêm circuit breaker (
opossum) cho một cuộc gọi ngoài; giả lập bên ngoài treo (timeout) → xem cầu dao ngắt.
- Bài tập: viết throttle theo tenant bằng Redis
INCR + EXPIRE.
Tháng 5 — DevOps
- Làm: Dockerfile đa tầng (build/runtime), image < 300MB.
- Làm: GitHub Actions: lint → test → build image → push registry.
- Làm: deploy 2 bản sau nginx, health check, tắt một bản giữa lúc bắn tải → khách không thấy.
- Làm: Prometheus + Grafana local, xuất
/metrics, vẽ 4 tín hiệu vàng, đặt 1 chuông.
- Bài tập: diễn tập restore Postgres từ backup về đúng một thời điểm.
Tháng 6 — Thiết kế & lãnh đạo kỹ thuật
- Đọc: System Design Primer (GitHub, miễn phí) — lướt, dùng làm mục lục tra.
- Làm: tự thiết kế lại
notify-service cho 10 triệu khách: vẽ sơ đồ, ghi rõ mỗi thành phần chịu bao nhiêu, gãy ở đâu trước, nâng cấp gì. Trình bày cho một người khác nghe 20 phút.
- Làm: review 3 PR của người khác bằng checklist bài 08, viết nhận xét.
- Làm: viết một post-mortem cho sự cố (thật hoặc giả định) theo mẫu 11.9.
"Trình bày 20 phút cho người khác" là bài kiểm tra cuối. Không phải để khoe — mà vì lúc nói ra, chỗ nào mình chưa hiểu sẽ lộ ngay: nói vòng vo, không trả lời được "vì sao không dùng X". Người nghe không cần giỏi; chỉ cần hỏi "vì sao" đủ nhiều.
Phần C — Tài liệu đáng đọc (ít mà chất)
| Tên |
Về gì |
Ghi chú |
| Use The Index, Luke (use-the-index-luke.com) |
Index SQL |
Miễn phí, 1 tuần, thay đổi cách nhìn DB |
| Designing Data-Intensive Applications — Kleppmann |
Toàn bộ nền tảng hệ thống dữ liệu |
Đọc chậm, mỗi chương 1 tuần |
| System Design Primer (github.com/donnemartin) |
Tổng quan kiến trúc lớn |
Dùng như từ điển |
| Google SRE Book (sre.google/books) |
Vận hành, SLO, on-call, post-mortem |
Chương 1–6 và chương về alerting |
| 12-Factor App (12factor.net) |
12 quy tắc app chạy trên cloud |
1 giờ, áp dụng được ngay |
| Redis docs → Data types |
Redis |
1 buổi |
| Postgres docs → Performance Tips, EXPLAIN |
Postgres |
Khi cần tra |
| Release It! — Nygard |
Circuit breaker, bulkhead, các kiểu sập dây chuyền |
Đọc sau DDIA |
| Latency Numbers Every Programmer Should Know |
Bảng phần A |
In ra dán tường |
Chín mục, cố ý ít. Danh sách 50 cuốn là danh sách không ai đọc. Thứ tự đọc gợi ý: 12-Factor (1 giờ, cho khung) → Use The Index (1 tuần, hiệu quả ngay) → DDIA (cả năm, nền tảng) → SRE (khi bắt đầu on-call) → Release It! (khi hệ thống có > 3 bên ngoài). Còn lại tra khi cần.
Phần D — Cách tự học để thành người dựng hệ thống
- Mỗi khái niệm phải có một lần "tự tay làm gãy": tắt Redis, đầy pool DB, bắn 1.000 rps, kill process giữa transaction. Đọc 10 lần không bằng thấy nó chết 1 lần.
- Mọi con số phải đo, không đoán. "Chậm" không phải câu trả lời; "p99 = 1,8s, 80% ở Postgres query X" mới là.
- Vẽ trước khi viết. Hộp và mũi tên. Mỗi mũi tên hỏi: đồng bộ hay phiếu? Mất được không? Chạy 2 lần được không? Bên kia chết thì sao?
- Đọc code có comment "vì sao".
notify-service đầy comment kiểu "bản cũ làm X → chết kiểu Y → giờ làm Z". Đó là giáo trình thật, tốt hơn sách.
- Viết lại bằng lời mình. Giải thích cho người non-tech hiểu được = mình đã hiểu. Bộ tài liệu này là bài tập đó — em viết tiếp phần 14 cho hệ thống tiếp theo em dựng.
Năm điều này có chung một gốc: hiểu = có thể dự đoán. Làm gãy để biết nó gãy thế nào (dự đoán lỗi). Đo để biết con số (dự đoán tải). Vẽ để thấy mũi tên nào nguy hiểm (dự đoán chỗ kẹt). Đọc "vì sao" để biết quyết định nào đã thử và thất bại (dự đoán cái bẫy). Viết lại để kiểm tra mình dự đoán được chưa. Người dựng hệ thống là người dự đoán được hệ thống sẽ làm gì trước khi nó làm.
Tự kiểm tra
- Không nhìn bảng: Redis, Postgres qua index, gọi API ngoài — mỗi cái mất bao nhiêu? Chênh nhau bao nhiêu lần?
- App của bạn có 300.000 khách cài. Ước DAU, rps trung bình, rps đỉnh giờ. Cần mấy máy? Cái gì gãy trước?
- Vì sao lộ trình đặt Database ở tháng 2, trước cache/queue?
- Trong 5 nguyên tắc phần D, cái nào bạn chưa từng làm? Bắt đầu từ đó.
Bài tập
- In bảng độ trễ phần A, dán cạnh màn hình. Mỗi lần viết một hàm gọi ra ngoài, nhìn bảng, tự hỏi: cái này 6 năm — có cần nằm trên đường chính không?
- Chọn tháng 1. Làm bài tập "theo dấu một notification" ngay tuần này. Ghi ra file
docs/one-notification-journey.md: từng bước, từng file, từng bảng, từng ô Redis. Đó là bài 14 đầu tiên của bạn.
13 · Bài giảng tổng hợp
13 — Bài giảng tổng hợp: toàn bộ tech-backend trong một mạch
Đây là bài giảng gom 13 tài liệu trước (00a → 12) thành một câu chuyện liền mạch. Mục tiêu: đọc xong bài này, bạn nhìn notify-service — hay bất kỳ backend nào — và thấy được bộ xương của nó, hiểu vì sao từng khúc xương nằm ở đó, và biết khi nào nó gãy.
Cách đọc: tôi giảng theo thứ tự từ nền tảng lên. Mỗi phần có ví dụ thật lấy từ code, và cuối mỗi phần có một câu hỏi để bạn tự trả lời trước khi đọc tiếp. Bài dài — chia làm 3-4 buổi. Đọc phần I và II trong buổi đầu, không đọc nhanh hơn.
Mở đầu: backend là gì, và nó chết vì cái gì
Trước khi vào kỹ thuật, hãy nói về câu hỏi lớn nhất: backend tồn tại để làm gì?
Câu trả lời ngây thơ: "nhận request, trả response". Đúng nhưng vô dụng. Câu trả lời đúng: backend là một tổ chức tiếp nhận yêu cầu từ nhiều phía cùng lúc, làm việc thay họ, và không được sập, không được mất việc, không được làm nhầm của người này sang người kia — dù đông đến đâu, dù đối tác bên ngoài hỏng, dù máy chủ restart giữa chừng.
Bộ tài liệu này dùng hình ảnh bưu điện: shop đến gửi thư (notification), khách đến lấy thư, Shopify liên tục gọi báo tin, bưu điện có kho (Postgres), bảng ghi nhớ trên tường (Redis), băng chuyền (BullMQ), nhân viên đi tuần (cron). Tôi sẽ dùng lại hình ảnh này suốt bài, vì nó không phải cách nói cho dễ hiểu — nó là mô hình tư duy đúng. Backend giỏi nghĩ về hệ thống như một tổ chức có người, có quy trình, có chỗ kẹt, chứ không phải như một đống hàm.
Và đây là ba câu hỏi mà toàn bộ 13 tài liệu xoay quanh:
- Đông thì sao? — 50.000 khách, 1.000 webhook trong 10 giây, flash sale.
- Hỏng thì sao? — Redis chết, Shopify treo, máy chủ restart, worker crash giữa chừng.
- Lẫn thì sao? — 5.000 shop chung một hệ thống, shop A không được thấy dữ liệu shop B.
Mọi kỹ thuật bạn học được — queue, cache, index, retry, timeout, idempotent — đều là câu trả lời cho một trong ba câu hỏi này. Khi gặp một kỹ thuật mới, hãy hỏi: nó trả lời câu nào?
Từ ba câu hỏi đó, tài liệu rút ra ba nguyên tắc chạy xuyên suốt. Tôi nêu trước để bạn thấy chúng lặp lại ở mọi phần:
- Nguyên tắc 1: Nhận việc và làm việc là hai người khác nhau. Quầy chỉ ghi phiếu rồi trả biên lai. Xưởng phía sau làm việc nặng. Không ai đứng chờ ở quầy.
- Nguyên tắc 2: Cái phụ hỏng thì bỏ qua, cái chính hỏng thì dừng. Cache mất → tra kho, chậm hơn nhưng chạy. Chữ ký giả → từ chối thẳng. Đây là fail-open / fail-closed.
- Nguyên tắc 3: Việc nào có thể kẹt thì phải có người đi gỡ. Trạng thái "đang làm" mà không ai dọn sẽ có bản ghi kẹt vĩnh viễn.
Phần I — Nền tảng: ba cách giao việc (từ 00a)
Nếu chỉ được dạy một điều về backend, tôi dạy điều này. Tài liệu 00a nói "hiểu ba cách này là hiểu 80% kiến trúc" — và đó không phải nói quá.
1.1 Gọi trực tiếp (synchronous)
Khách gọi món, chủ quán đứng chờ bếp nấu xong, bưng ra, rồi mới tiếp khách sau.
Trong code: request HTTP vào, handler làm hết mọi việc, trả response. Đơn giản, khách nhận kết quả thật ngay. Nhưng: khách sau phải chờ khách trước; việc chậm làm cả quán đứng im.
Dùng khi: việc nhanh, dưới 1 giây, khách đang nhìn màn hình chờ. Ví dụ: "hộp thư của tôi có gì?" — tra Redis, vài mili giây, trả ngay.
1.2 Để lại phiếu (queue)
Khách gọi món, chủ quán ghi phiếu kẹp lên dây, đưa số bàn, quay sang khách tiếp theo ngay. Đầu bếp đứng ở bếp, thấy phiếu là nấu.
Trong code: request vào, handler ghi một job vào queue, trả "đã nhận". Một process khác (worker) đọc queue và làm. Chủ quán không bao giờ kẹt. Đầu bếp ốm, phiếu vẫn treo đó. Nấu hỏng, nấu lại theo phiếu.
Dùng khi: việc nặng hoặc phải gọi bên ngoài, nhưng cần xong sớm. Ví dụ: shop bấm "gửi 50.000 push" — ghi phiếu 0,5 giây, trả "đã tiếp nhận", worker gửi dần tới Firebase.
Có một hiểu lầm phổ biến mà tài liệu 00a chốt rất hay: "qua queue thì đâu còn realtime?" Sai. Queue không làm chậm. Phiếu kẹp lên dây, đầu bếp nhặt trong dưới 0,1 giây. Cái chậm là bản thân việc nấu — Firebase mất 1-2 giây mỗi lô 500 máy, dù có queue hay không. Không có queue, chủ quán đứng chờ 2 phút; có queue, chủ quán rảnh, bát phở vẫn ra sau đúng chừng đó. Queue giúp quán không kẹt, không phải giúp nấu nhanh hơn.
1.3 Hẹn giờ (cron)
Không ai gọi. Cứ 5 phút nhân viên đi một vòng gom bát bẩn. Cuối ngày kế toán cộng sổ.
Trong code: @Cron('*/5 * * * *'), đến giờ thì chạy. Gom nhiều việc nhỏ thành một lần, rẻ. Nhưng chậm: việc xảy ra 12:01 thì 12:05 mới xử lý.
Dùng khi: không ai đang chờ, và gom lô rẻ hơn làm từng cái. Ví dụ: đơn hàng mới ghi nháp lên Redis, 5 phút gom vào Postgres và tính lại báo cáo ngày một lần thay vì tính lại 5.000 lần trong flash sale.
1.4 Ba cách này ghép lại thế nào
Đây là điểm mấu chốt. Một sự kiện thật thường đi qua cả ba cách song song. Tài liệu 00a kể chuyện khách Lan mua áo lúc 12:00:00:
- Shopify bỏ thư vào SQS (webhook). Cửa SQS đọc — đó là gọi trực tiếp ở tầng nhận tin, nhanh.
- Cửa SQS dán note đơn hàng lên Redis, và đặt phiếu "gửi push cảm ơn" lên BullMQ — đường push đi queue, worker nhặt ngay, điện thoại rung lúc 12:00:03.
- Note đơn hàng nằm trên Redis chờ cron 5 phút gom vào Postgres, tính báo cáo — dashboard cập nhật lúc 12:05.
Ba đường song song từ một lá thư, không đường nào chặn đường nào. Push 3 giây. Báo cáo 5 phút. Mỗi đường có độ trễ đúng với mức chấp nhận của nó.
Quy tắc chọn, một dòng: Khách chờ màn hình và việc dưới 1 giây → trực tiếp. Khách chờ nhưng việc nặng hoặc gọi ngoài → trả "đã nhận" + queue. Không ai chờ, gom được → cron, chu kỳ = độ trễ chấp nhận được.
Tự hỏi: Trong notify-service, vì sao push notification KHÔNG đi qua cron? Nếu ai đó đề xuất "gom push lại 5 phút gửi một lần cho đỡ tải", bạn phản biện thế nào?
Phần II — Giải phẫu notify-service (từ 00b, 00c, 01)
Giờ có nền tảng, ta mổ hệ thống thật. Tôi mô tả theo ba tầng của bưu điện: cửa vào → kho → xưởng.
2.1 Cửa vào: ba loại cửa, một nguyên tắc
| Cửa |
Ai đến |
Cửa làm gì |
| HTTP API (Fastify) |
App mobile, chủ shop, hệ thống nội bộ |
Kiểm tra thẻ → kiểm tra phiếu → ghi kho/Redis hoặc đặt phiếu → trả kết quả |
| SQS (2 hòm: webhook Shopify, tracking app) |
Shopify, SDK trong app |
Đọc tin → dán note Redis, hoặc gọi gửi push tự động, hoặc đặt phiếu → xé thư |
| Webhook trực tiếp |
Gorgias, Smile |
Xác minh chữ ký/token → đặt phiếu → luôn trả 200 |
Nguyên tắc chung của mọi cửa: cửa không bao giờ tự đi gọi Google hay Shopify. Việc đó là của xưởng. Cửa chỉ xác minh, ghi, và trả lời. Nếu bạn thấy await shopifyClient.graphql(...) trong một controller, đó là quầy đang tự đi làm việc xưởng — dấu hiệu cần dừng lại (doc 08, mục C).
Vì sao SQS đứng giữa Shopify và mình, thay vì Shopify gọi thẳng API? Tài liệu 00c kể rất rõ: flash sale 1.000 đơn trong 10 giây, mỗi đơn 2-3 webhook → 3.000 request đánh thẳng vào API. Máy chủ có 50 chỗ tiếp nhận → 2.950 xếp hàng → timeout → Shopify thấy lỗi gửi lại → càng dồn → API chết → khách mở app cũng chết theo vì cùng một cửa. Với SQS: Shopify bỏ 3.000 thư vào hòm (hòm của Amazon chịu hàng triệu), mình lấy 10 lá một lần theo nhịp mình, cửa API cho khách không dính gì. Đang deploy 30 giây? Thư vẫn nằm trong hòm, mở lên đọc tiếp, không mất.
Một chi tiết SQS bạn phải nhớ: lấy thư ra mà chưa xé (chưa ack) thì 30 giây sau Amazon cho người khác lấy. Nghĩa là một lá thư có thể được đọc 2 lần — mọi việc phía sau phải chịu được điều đó. Đây là mầm của khái niệm idempotent ở phần III.
2.2 Kho: ba loại, ba việc
Postgres là sổ cái có luật: ghi gì vào là chắc, có ràng buộc (@@unique), có transaction. Mọi thứ không được mất, không được trùng nằm đây: notification, lịch sử gửi, thiết bị, khách, đơn, báo cáo ngày, credential.
Redis là bảng trắng dán tường: ghi xóa tức thì, đặt được hạn tự bay. Mất điện là trắng bảng. Dùng cho: cache, hộp thư khách, note nháp chờ gom, khóa "đang có người làm", bộ đếm, và cả dây phiếu BullMQ.
MongoDB là thùng hồ sơ dày: đơn hàng 80 trường × triệu dòng, chỉ đọc nguyên cục, chỉ cộng số. Để riêng cho Postgres gọn.
Câu hỏi quyết định "để đâu": dữ liệu này là nguồn sự thật hay bản sao? Nguồn → Postgres. Bản sao có hạn → Redis. Hồ sơ dày chỉ đọc nguyên khối → Mongo hoặc cột JSON.
2.3 Xưởng: hai loại nhân viên khác nhau
Đây là chỗ nhiều người nhầm. Xưởng có worker và cron, và chúng khác nhau về bản chất:
- Worker BullMQ: đứng sẵn ở dây phiếu, có phiếu là nhặt trong dưới 100ms. Chạy liên tục, không theo giờ. 6 worker: gửi push, đồng bộ khách từ Shopify, nhập đơn cũ, xử lý webhook Gorgias, webhook Smile, dọn shop gỡ app. Cộng 6 băng
cross-* mà notify-service đặt phiếu, hệ thống quản trị nhặt.
- Cron: xem đồng hồ, đến giờ mới làm. 9 cron: 4 cái 5 phút (gom đơn, gom đơn all, gom tracking event, gỡ push kẹt), 1 giờ (tính session), 6 giờ (Google Analytics), 3 cái hàng ngày (marketing, sync cache, voucher Smile).
Và có loại thứ ba ít người để ý: đồng hồ hẹn giờ trong RAM (NotificationSchedulerService, CartSchedulerService) — hẹn một lần "gửi lúc 9h sáng mai", "nhắc giỏ bỏ quên sau 24h". Đây là loại nguy hiểm nhất vì nó sống trong RAM — phần III mục 6 nói cách làm nó sống qua restart.
2.4 Đường đi của một push notification — thuộc lòng
Shop bấm "Gửi" (HTTP)
→ guard kiểm thẻ → validation → lọc 50.000 device
→ ghi 50.000 dòng NotificationHistory trạng thái SENDING (Postgres)
→ dán vào hộp thư từng máy trên Redis (Lua script, 1 lệnh)
→ đặt 1 phiếu lên BullMQ `notifications` (mang theo cả chìa khóa Firebase)
→ trả "đã tiếp nhận" — tổng < 1 giây
┆
Worker nhặt phiếu (< 100ms) ┆ song song, không ai chờ
→ gọi Firebase theo lô 500, tuần tự (100 lô)
→ đọc kết quả từng máy: DELIVERED / FAILED + lý do
→ token chết → tắt máy; sai chứng chỉ → KHÔNG tắt (lỗi cấu hình)
→ đổi notification sang DONE, ghi metrics
→ lỗi → retry 2s/4s/8s; lần cuối vẫn lỗi → gỡ SENDING → FAILED rồi mới ném
┆
Cron reaper 5 phút ┆ lưới an toàn
→ tìm SENDING > 15 phút → chưa có history thì gửi lại, có rồi thì đóng DONE
Thời gian thật: máy đầu tiên rung sau ~2-3 giây, 50.000 máy xong trong 1-3 phút. Trần hiện tại: 100 lô gọi tuần tự. Muốn dưới 30 giây thì gọi 5-10 lô song song hoặc tách một phiếu lớn thành 100 phiếu nhỏ.
Tự hỏi: Nếu Redis chết đúng lúc worker đang gửi lô thứ 50, chuyện gì xảy ra với 50 lô còn lại? (Gợi ý: phiếu mang theo chìa khóa Firebase.)
Phần III — Bảy bài học lớn (từ 02 → 07)
Đây là phần lõi. Bảy bài học này không riêng notify-service — chúng là điều mọi backend đa tenant, có queue, có bên ngoài, đều phải giải. Tôi giảng từng bài với cùng cấu trúc: vấn đề → cách ngây thơ chết thế nào → cách đúng → vì sao đúng.
Bài học 1 — Khóa thật, dấu shop, và idempotent (doc 02)
Ba ý này là một, và nó là nền của mọi thứ khác.
Dấu shop. 5.000 shop chung một tủ hồ sơ. Nếu tra cứu chỉ theo id, ai đoán được số là đọc được thông báo shop khác. Cách đúng: mọi tờ hồ sơ đóng dấu shopDomain, mọi truy vấn bắt buộc kèm dấu — where: { id, shopDomain }, không bao giờ where: { id }. Và index luôn bắt đầu bằng shopDomain — giống thư viện xếp sách theo khoa trước rồi mới theo tên. Nhờ vậy shop 200 hồ sơ tra nhanh như nhau dù kho có 5 triệu hồ sơ của shop khác.
Khóa thật. Shopify báo đơn #1001 ba lần (tạo, thanh toán, giao). App đăng ký thiết bị mỗi lần mở. Nếu mỗi lần nhận là một dòng mới → 3 bản đơn, doanh thu gấp 3, 50 bản cùng một điện thoại. Cách đúng: mỗi loại hồ sơ có một cặp thông tin ngoài đời không thể trùng — thiết bị = shop + mã thiết bị; đơn = shop + mã đơn Shopify; khách = shop + mã khách. Khai @@unique để database tự chặn. Nhận lần 2, lần 3 → upsert ("có rồi thì cập nhật, chưa có thì tạo").
Tài liệu 08 nói một câu tôi muốn bạn nhớ: "Khóa thật là gì? Nếu không trả lời được câu này, chưa thiết kế xong."
Idempotent. Làm 1 lần hay 10 lần, kết quả như nhau. Đây là hệ quả của khóa thật + upsert, và nó là điều kiện tiên quyết cho mọi cơ chế thử lại. SQS đọc thư 2 lần, BullMQ retry, cron chạy lại — tất cả đều có thể làm một việc 2 lần. Việc nào không idempotent ("cộng thêm 1 đơn") sẽ bị đếm gấp đôi. Nên trong hệ thống này, bạn sẽ không thấy create cho dữ liệu từ bên ngoài, chỉ thấy upsert.
Thêm một ý từ doc 02 về xóa mềm: xóa mềm (isDeleted = true) giữ được lịch sử, nhưng nếu 100 chỗ tra cứu đều phải nhớ thêm "trừ tờ đã xóa" thì quên một chỗ là shop cài lại thấy 200 thông báo cũ. Cách đúng: bộ lọc cài ở cửa tủ (prisma/soft-delete.extension.ts), một chỗ, mọi truy vấn đọc tự động bị lọc. 30 dòng code bảo vệ toàn bộ đường đọc. Đây là ví dụ của nguyên tắc chung: ràng buộc an toàn đặt ở một chỗ mọi đường đều đi qua, không rải ở từng người dùng.
Bài học 2 — Năm lớp cửa, lớp ngoài rẻ nhất (doc 03)
Mỗi request đi qua 5 lớp, lớp ngoài rẻ nhất, lớp trong đắt nhất. Mục tiêu: request xấu bị chặn càng sớm, càng tốn ít sức.
- Cân hành lý ở cổng — body quá 10MB trả 413 ngay, chưa ai kịp mở. Có một bug thật đáng nhớ: đường dẫn mặc định tối đa 100 ký tự, mà token ảnh hỗ trợ dài 380 → router trả 404 im lặng, không log → tính năng xem ảnh trong chat hỗ trợ chưa từng chạy được kể từ ngày ra đời. Sửa bằng
maxParamLength: 2000. Bài học: giới hạn mặc định của framework là thứ bạn phải biết.
- Đếm người qua cổng (throttle) — một app lỗi vòng lặp gọi 1.000 lần/giây từ một IP → chặn ở cổng, chưa tốn gì bên trong. Nhưng có ngoại lệ có chủ đích: webhook Gorgias tắt throttle, vì Gorgias tắt kết nối cả tài khoản nếu thấy lỗi vài lần, và một tài khoản Gorgias dùng chung cho nhiều shop. Cửa đó bảo vệ bằng token thay vì đếm IP. Mọi ngoại lệ phải có comment "vì sao" ngay cạnh.
- Kiểm tra thẻ — mật khẩu tự đổi mỗi giờ:
hash(bí_mật + giờ_UTC + tên_shop). Không cần bảng vé, không cần Redis, chỉ tính toán. Mã lộ sống tối đa 1 giờ, chỉ cho đúng shop. Và so sánh phải đều tay (timingSafeEqual) — so sánh thường dừng ở ký tự đầu khác, kẻ tấn công đo thời gian là đoán dần từng ký tự.
- Kiểm tra phiếu (validation) — DTO khai rõ từng mục.
limit tối đa 100, sortBy chỉ 3 giá trị, và mục lạ bị cắt (whitelist). Không có whitelist, app gửi kèm shopDomain: "shop-khac" khi cập nhật → thông báo chuyển sang shop khác. Đây là mass assignment, một trong những lỗ hổng phổ biến nhất.
- Làm việc — và bọc ngoài là exception filter: trả lỗi gọn (500 chỉ nói "Internal Server Error"), log đủ để debug nhưng che bí mật (
[REDACTED] cho authorization, token, serviceAccountKey). Không che, một lỗi validation khi shop nhập cấu hình Firebase là khóa riêng tư nằm trong log.
Cái quan trọng nhất trong doc 03 là ở lớp 3: thiếu bí mật là không mở cửa. Dòng đầu tiên của bootstrap() kiểm tra ATTACHMENT_TOKEN_SECRET; thiếu → dừng ngay với thông báo rõ, thay vì boot xanh rồi khách đầu tiên mới 500. Đây là fail-closed cho thứ thuộc về an toàn.
Bài học 3 — Redis là năm công cụ, không phải một cái cache (doc 04)
Người mới thấy Redis là "cache". Doc 04 cho thấy notify-service dùng nó cho 5 việc khác nhau, mỗi việc một kiểu ghi:
Hộp thư của khách. 200.000 lần mở app mỗi ngày, mỗi lần hỏi "20 thông báo mới nhất + đếm chưa đọc". Nếu tra Postgres bảng hàng trăm triệu dòng → database quỳ. Cách đúng: mỗi thiết bị 3 ô Redis (inbox ZSET, unread SET, nội dung dùng chung). Mở app = 3 lần hỏi Redis, không đụng Postgres. Nội dung lưu một bản cho 50.000 máy, cá nhân hóa lúc đọc. Và khi gửi, 4 lệnh gói trong một Lua script — nếu không, máy chết giữa chừng → inbox có mã mà không có nội dung → app hiện ô trống.
Note nháp chờ gom. Flash sale 5.000 đơn/phút, mỗi đơn báo 3 lần. Dán lên HASH orders:{shop} với tên dòng = mã đơn → 3 tin cùng mã đè lên nhau thành 1. Dedup miễn phí nhờ cấu trúc dữ liệu. Cron 5 phút gom vào Postgres theo lô 50.
Khóa chống giẫm đạp (cache stampede). Cache Smile hết hạn đúng lúc 500 khách mở màn hình → 500 lần gọi Smile → Smile chặn → 500 lỗi. Cache sinh ra để chặn đúng chuyện này mà lại gây ra nó. Cách đúng: chỉ một người giành được khóa đi hỏi, 499 người còn lại trả bản cũ (stale) hoặc chờ 1 giây. Và trả khóa phải là "xóa nếu vẫn là khóa của tôi" (Lua so sánh rồi xóa) — không thì mình làm lâu quá 5 giây, khóa tự bay, người khác lấy khóa mới, mình xóa nhầm khóa của họ.
Cờ và bộ đếm. authFail:{shop} 5 phút, token-refresh-req:{shop} 60 giây, gorgias:rl:{account}:{bucket} 21 giây — phần III bài 5 nói kỹ.
Dây phiếu BullMQ — cũng nằm trên Redis.
Ba quy tắc thực hành từ doc 04: tên ô sinh từ một chỗ (CacheKey), đổi format sửa một nơi; ô không có hạn thì phải có hàm xóa tường minh và comment ai gọi khi nào; quét bảng dùng SCAN từng trang, không bao giờ KEYS — Redis single-thread, KEYS trên triệu ô làm mọi khách, mọi băng chuyền treo.
Và nguyên tắc bao trùm: mọi thao tác Redis kiểu cache/cờ/đếm đều "lỗi thì bỏ qua". Redis chết = mất cache, không mất tính năng.
Bài học 4 — Bốn kiểu bất đồng bộ, và retry phải khớp nguyên nhân (doc 05)
Doc 05 mở đầu bằng bảng 4 kiểu giao việc, và chọn sai kiểu là mất việc hoặc làm trùng:
| Kiểu |
Mất được? |
Tự làm lại? |
Chống trùng? |
Dùng cho |
| BullMQ |
Không |
Có |
Có (jobId) |
Việc có kết quả cần theo dõi |
| SQS |
Không |
Thư hiện lại |
Không |
Nhận tin từ ngoài |
| Note + cron |
Redis giữ |
Lần sau |
Có (cùng tên đè) |
Ghi rất nhiều, gom lô |
| Ném rồi quên |
Có thể |
Không |
Không |
Việc phụ, mất không sao |
Hai ý sâu nhất trong doc 05:
Chống trùng bằng jobId. Khách mở app 5 lần → 5 phiếu "đồng bộ khách #123" → 5 lần gọi Shopify. Đặt phiếu với jobId = sync:{shop}:{customer} → BullMQ thấy phiếu này đang chờ hoặc đang làm thì bỏ qua phiếu mới. 5 lần đặt = 1 phiếu. Không cần code kiểm tra "có chưa" — cấu trúc dữ liệu làm việc đó.
Thời gian chờ retry không phải con số mặc định. Đây là comment dài nhất trong repo, và là bài học đắt: Gorgias cho 40 lần/20 giây. Phiếu webhook Gorgias dùng backoff mặc định 2s, 6s → cả 3 lần thử (0s, 2s, 8s) rơi trong cùng cửa sổ 20 giây vừa hết trần → cả 3 bị từ chối → phiếu hỏng hẳn → Gorgias không gửi lại → tin nhắn của khách mất vĩnh viễn. Sửa: chờ đúng 20 giây cố định, chắc chắn rơi sang cửa sổ kế tiếp.
Bài học tổng quát: thời gian retry suy ra từ thứ gây lỗi. Bên kia giới hạn theo cửa sổ → chờ ≥ cửa sổ. Database bận thoáng qua → chờ ngắn, ngẫu nhiên. Bên kia đang deploy → chờ dài, nhiều lần (phiếu cross-service: 5 lần, 5s → 80s). Số attempts: 3, delay: 2000 copy từ ví dụ trên mạng là dấu hiệu chưa nghĩ.
Một ý nữa từ 5.4 rất tinh: cửa SQS xé thư kể cả khi xử lý lỗi. Vì việc bền (dán note đơn hàng) làm đầu tiên; việc sau (gửi push tự động) lỗi thì để thư hiện lại cũng không giúp — lỗi nghiệp vụ không tự hết, mà còn gửi push 2 lần. Nguyên tắc: ghi bền trước → ack → phần còn lại cố gắng hết sức.
Bài học 5 — Gọi bên ngoài: họ hỏng, mình chậm; không để họ hỏng, mình chết (doc 06)
Bốn bên ngoài: Shopify, Firebase, Gorgias, Smile. Mỗi bên đều có lúc chậm, từ chối, đổi luật.
Timeout mọi thứ. Shopify treo không trả lời, không ngắt. 200 request chờ → 2.000 → hết kết nối, hết RAM → mình chết vì Shopify chết, dù khách mở hộp thư không cần Shopify. Timeout 15 giây → tài nguyên kẹt tối đa = số request trong 15 giây, không tăng vô hạn.
Tự đếm trước khi họ đếm. Gorgias 40 lần/20 giây cho cả tài khoản, hai shop dùng chung. Gọi thẳng để Gorgias từ chối → shop kia vạ lây. Cách đúng: bộ đếm trên Redis theo cửa sổ 20 giây, tự đặt trần 36 (chừa 4), vượt thì từ chối tại nhà, không gọi. Gửi tin của khách ưu tiên hơn đọc (trần 39). Đếm mỗi lần gọi mạng, không phải mỗi request — một request retry 3 lần tốn 3 đơn vị. Redis hỏng → bỏ qua bộ đếm, gọi thẳng (fail-open).
Bị từ chối chìa khóa thì xin lại, nhưng không xin dồn. Shopify 401 giữa lúc đang gọi 50 lần → cắm cờ SET NX 60 giây "vừa xin rồi" → 50 từ chối = 1 lần xin. Cắm cờ authFail 5 phút → cửa đăng ký thiết bị thấy cờ thì không đặt phiếu đồng bộ nữa, đỡ 150 lần gọi vô ích. Mọi lần gọi thành công → nhổ cờ. Shop tự lành trong ~1 phút.
Đọc lý do lỗi, không chỉ mã. Đây là sự cố thật đáng học nhất trong repo: Firebase trả 429 RESOURCE_EXHAUSTED cho cả hai trường hợp — dự án đã đủ 30 app, và tạo app nhanh quá. Bản cũ thấy 429 là đánh dấu dự án "đầy" vĩnh viễn. Hai shop cài cùng lúc → 429 loại "nhanh quá" → dự án còn 22 chỗ bị đánh dấu đầy → dần dần mọi dự án đầy → shop mới không cài được. Sửa: chỉ coi là đầy khi nội dung lỗi nói về giới hạn số app. Bài học: quyết định "vĩnh viễn" phải dựa trên bằng chứng chắc, vì sai là lan cả hệ thống.
Và một sự cố tương tự: chìa khóa Firebase không khớp dự án → Firebase từ chối từng máy mismatched-credential → bản cũ coi là "máy hỏng", tắt máy → sửa cấu hình xong vẫn không gửi được ai. Sửa: kiểm tra project_id trước khi gửi, khác thì nổ một lỗi rõ. Lỗi cấu hình phải nổ to một lần, không rải thành 50.000 lỗi nhỏ.
Bài học 6 — Ba tầng tự phục hồi, và hẹn giờ phải ghi ra giấy (doc 07)
Hẹn giờ trong RAM chết khi restart. Shop hẹn "gửi 9h sáng mai", 2h sáng deploy → RAM trắng → 9h không có gì xảy ra, notification ghi SCHEDULED mãi mãi. Cách đúng: ngoài đồng hồ trong RAM, ghi một tờ giấy lên Redis (hạn 30 ngày) + thêm vào danh mục. Khi bật máy: đọc danh mục → kiểm tra tủ → giờ còn tương lai thì đặt lại đồng hồ; quá giờ thì gửi bù; quá hơn 24 tiếng thì vô hiệu, không gửi — không có luật này thì mỗi lần deploy lại bắn cả đống hẹn cũ.
Claim trước khi bắn. Đồng hồ kêu 9h, đặt phiếu, restart trước khi worker đổi sang DONE. Bật lại thấy giấy "9h, quá giờ" → gửi lần nữa → khách nhận 2. Cách đúng: ngay lúc đồng hồ kêu, đổi trạng thái SCHEDULED → SENDING bằng một lệnh có điều kiện ("đổi nếu đang là SCHEDULED"). Chỉ một người đổi được. Bật lại thấy đã SENDING → biết có người bắn rồi. Nếu phiếu thật sự mất → reaper lo.
Reaper: nhân viên gỡ kẹt. Cron 5 phút tìm SENDING quá 15 phút (gửi thật chỉ vài giây, 15 phút là chắc kẹt). Hai trường hợp xử lý khác nhau: không có dòng history nào → chết trước khi đặt phiếu → gửi lại từ đầu; có history → đã gửi một phần → không gửi lại (sẽ trùng), đóng DONE, dòng còn SENDING → FAILED.
Ghép lại thành ba tầng:
Tầng 1 BullMQ retry — lỗi thoáng qua
Tầng 2 Worker lần thử cuối — gỡ trạng thái kẹt trước khi bỏ cuộc
Tầng 3 Reaper 5 phút — mọi thứ lọt qua hai tầng trên
Mỗi tầng bắt một loại nguyên nhân khác nhau. Không tầng nào thừa.
Giới hạn đã ghi rõ trong code: chưa có "bầu trưởng ca". Chạy 2 bản notify-service → cả hai cùng bật máy, cùng đọc giấy, cron chạy ở cả hai. Hẹn thủ công an toàn nhờ claim có điều kiện, nhưng nhắc tự động lặp lại có thể gửi 2 lần. Nâng cấp: khóa Redis "ai giành được thì làm" hoặc chuyển sang BullMQ delayed job.
Bài học 7 — Fail-open và fail-closed: sợi chỉ xuyên suốt
Tôi tách riêng vì nó xuất hiện ở mọi bài trên, và nó là quyết định bạn sẽ phải đưa ra hàng ngày.
Fail-open = hỏng thì cho qua. Fail-closed = hỏng thì chặn. Quy tắc: thứ gì là tối ưu (cache, đếm, cờ, cảnh báo) → open. Thứ gì là an toàn / đúng đắn (xác thực, khớp chứng chỉ, không gửi trùng tiền) → closed.
Trong notify-service:
- Redis chết → cache, cờ authFail, bộ đếm Gorgias, hộp thư: open — bỏ qua, chạy tiếp, chậm hơn.
- Thiếu ATTACHMENT_TOKEN_SECRET lúc boot → closed — không boot.
- Thẻ nội bộ thiếu khóa cấu hình → closed — từ chối tất cả, không mở toang.
- Chìa khóa Firebase không khớp dự án → closed — không gửi, nổ lỗi rõ.
- Discord cảnh báo lỗi → open — log rồi bỏ qua.
Khi review code, mỗi catch bạn gặp, hỏi: đây là open hay closed, và có đúng không?
Tự hỏi (cho cả phần III): Lấy 7 bài học này, mỗi bài chỉ ra một dòng code trong notify-service thể hiện nó. Không tìm được thì chưa hiểu bài đó.
Phần IV — Vượt ra ngoài project (từ 09, 10, 11)
Ba tài liệu này không mô tả notify-service. Chúng là kỹ thuật chuẩn khi hệ thống lớn hơn. Tôi tóm cái khung của mỗi bài — chi tiết bạn tra lại tài liệu gốc khi cần.
4.1 Database triệu dòng: năm bậc, rẻ trước đắt sau (doc 09)
Doc 09 xếp kỹ thuật theo thứ tự rẻ → đắt, và nói 90% vấn đề chết ở 3 bậc đầu. Tôi muốn bạn nhớ thứ tự này hơn là nhớ từng kỹ thuật:
Bậc 0 — Đừng hỏi database. RAM app → Redis → replica → primary. Hộp thư trên Redis là ví dụ: 200.000 lượt/ngày không đụng Postgres.
Bậc 1 — Index. 80% vấn đề nằm đây. Quy tắc: cột = trong WHERE đứng đầu, cột khoảng/sắp xếp đứng sau; index kép tốt hơn hai index đơn; partial index cho dòng hay hỏi (WHERE status = 'SENDING' → index 100 dòng thay vì 10 triệu); GIN cho mảng/JSON. Và EXPLAIN ANALYZE là công cụ duy nhất bạn cần: thấy Seq Scan + rows=20000000 là thiếu index. Bật slow query log, đọc top 10 mỗi tuần — thói quen này quan trọng hơn mọi kỹ thuật.
Bậc 2 — Viết query đúng. N+1 (101 câu thay vì 2 — gom findMany + Map); SELECT * bảng rộng; phân trang sâu dùng keyset thay OFFSET; COUNT(*) bảng lớn là quét cả bảng; ghi theo lô (createMany); không gọi mạng trong transaction.
Bậc 3 — Pool và cấu hình. Pool = (CPU × 2) + đĩa, thường 10-30; nhiều instance thì cần PgBouncer. Chỉnh shared_buffers, random_page_cost = 1.1 trên SSD. Theo dõi autovacuum.
Bậc 4 — Tách đọc/ghi, tách bảng. Read replica; bảng tổng hợp (đã làm với SaleAnalytics); partition theo thời gian cho bảng tracking; archive dữ liệu cũ.
Bậc 5 — Sharding. Chỉ khi 1-4 hết đường. Một máy Postgres lớn + replica + partition chịu hàng trăm triệu dòng. Đa số sản phẩm không bao giờ tới.
4.2 Server triệu request: từ một máy tới nhiều máy (doc 10)
Bắt đầu bằng phép tính mà tôi muốn bạn thuộc: 1 triệu khách × 20% mở/ngày × 10 request ÷ 86.400 ≈ 23 rps trung bình, ×10 giờ cao điểm ≈ 230, ×5 flash sale ≈ 1.000-1.500 đỉnh. Một process Fastify làm việc nhẹ chịu 5.000-15.000 rps. 1 triệu khách ≈ 1-2 máy nhỏ nếu làm đúng. Hệ thống chết ở 200 rps không phải vì thiếu máy, mà vì một request làm việc quá nặng hoặc chờ thứ chậm.
Sáu lớp: (1) một request phải rẻ — không chặn event loop, timeout mọi thứ, việc trên 100ms đẩy queue; (2) cache theo tầng — điện thoại → CDN → RAM → Redis → DB, 90% request không tới DB; (3) stateless + load balancer + health check thật; (4) chống sập dây chuyền — circuit breaker, bulkhead, backpressure, retry có jitter, idempotency key, throttle theo tenant chứ không chỉ IP; (5) giao thức — keep-alive, HTTP/2, CDN, region gần khách; (6) đo — p99 quan trọng hơn trung bình, vì p99 3 giây ở 200.000 người/ngày là 2.000 người khổ.
Thứ tự làm khi chậm: bật đo → sửa query p99 tệ nhất → cache → queue → timeout + circuit breaker → stateless + thêm bản → replica → throttle tenant → partition → shard. Đa số dừng ở bước 6-7.
4.3 DevOps: mười câu hỏi (doc 11)
Doc 11 dạy 10 bài, và tóm lại thành 10 câu hỏi. Tôi chỉ liệt kê câu hỏi — trả lời được là bạn vận hành được hệ thống triệu khách, chưa thì biết học gì:
- Server cháy, dựng lại từ đầu mất bao lâu? (Docker — một hộp một phiên bản, không sửa hộp đã đóng)
- Dám merge PR chiều thứ Sáu? (CI — lint, build, test dưới 10 phút)
- Dám deploy trưa thứ Hai? (rolling/blue-green/canary + health check thật + graceful shutdown + migration tương thích ngược "expand → migrate → contract")
- Từ một
notificationId tìm ra cả hành trình trong 2 phút? (log JSON có cấu trúc + request id truyền sang cả worker + gom về một chỗ)
- Chuông nào kêu mà bạn tắt không làm gì? (alert theo triệu chứng khách thấy, không theo CPU; mỗi chuông có runbook)
- RTO/RPO là bao nhiêu, đã diễn tập restore chưa? (backup + PITR + diễn tập hàng quý)
- Dev nghỉ hôm nay, đổi khóa nào, mất bao lâu? (secrets ngoài git, xoay định kỳ, quyền tối thiểu)
- Dựng staging mới mất bao lâu? (IaC)
- Rollback bằng lệnh gì? (incident: xác nhận → thông báo → cầm máu trước tìm nguyên nhân sau → post-mortem không đổ lỗi)
- Chi phí mỗi 1.000 khách hoạt động? (tag tài nguyên, xem hóa đơn theo tag)
Phần V — Cách dùng những gì đã học (từ 08)
Doc 08 là công cụ, không phải bài đọc. Ba cách dùng:
Khi review PR — 7 nhóm câu hỏi (dữ liệu, cửa vào, Redis, băng chuyền, gọi ngoài, phục hồi, comment). Mỗi câu đối chiếu một doc. Tôi rút 5 câu bắt được nhiều lỗi nhất:
- Có query nào where: { id } không kèm shopDomain?
- Handler có await gọi bên ngoài không?
- Thời gian retry có khớp nguyên nhân lỗi không?
- Trạng thái "đang làm" mới → ai gỡ khi kẹt?
- Chạy 2 bản song song thì việc này chạy 2 lần không?
Khi thiết kế tính năng mới — 10 câu hỏi, trong đó câu 4 ("khóa thật là gì?") và câu 9 ("restart giữa chừng mất gì? phải là không mất gì") là hai câu quyết định.
Khi hệ thống đang cháy — thứ tự 5 bước: cửa vào nghẽn? → băng chuyền phình? → note nháp phình? → bên ngoài nào từ chối? → trạng thái kẹt tăng? Mỗi câu nên có sẵn một biểu đồ trước khi cháy — đó là việc của teamlead.
Và bảng "dấu hiệu cần dừng lại hỏi kỹ" — 14 mẫu code đáng nghi. Tôi khuyên in ra: catch (e) {} trống, Promise.all không giới hạn, redis.keys(...), setTimeout không ghi chỗ bền, findMany không take, === so token, số cố định attempts: 3, delay: 2000 cho việc có rate-limit, đánh dấu "vĩnh viễn" dựa trên mã lỗi chung.
Phần VI — Con số phải thuộc và lộ trình (từ 12)
Con số
Tôi chỉ nhắc ba nhóm, vì chúng thay đổi cách bạn ước lượng:
Độ trễ: Redis 0,3-1ms. Postgres qua index 1-5ms. Gọi API ngoài 200-2.000ms. Quét cả bảng 10 triệu dòng 5-30 giây. Bài học: một lần gọi ra ngoài = 1.000 lần hỏi Redis. Mọi thứ trong doc 04-06 xoay quanh câu này.
Sức chịu một máy 4 CPU / 16GB: Fastify việc nhẹ 5.000-15.000 rps. Postgres qua index 5.000-20.000 truy vấn/giây. Redis 50.000-100.000 lệnh/giây. Nhớ để biết "chậm" là do code, không phải do máy.
Dung lượng: log và tracking phình nhanh hơn dữ liệu nghiệp vụ 10-100 lần. 2 triệu request/ngày ≈ 2GB log/ngày. Phải có lịch dọn từ ngày đầu.
Lộ trình 6 tháng
Tháng 1 đọc hiểu backend thật (theo dấu một notification từ bấm tới rung). Tháng 2 database (Use The Index, Luke, tự tạo bảng 5 triệu dòng và cảm nhận 15 giây → 3ms). Tháng 3 cache/queue (tắt Redis giữa chừng xem còn chạy không). Tháng 4 chịu tải (k6 bắn 500 rps, tìm cái gãy đầu tiên). Tháng 5 DevOps (Docker, CI, 2 bản sau nginx, Prometheus). Tháng 6 thiết kế lại notify-service cho 10 triệu khách, trình bày 20 phút.
Nguyên tắc học từ doc 12 mà tôi đồng ý nhất: mỗi khái niệm phải có một lần "tự tay làm gãy". Tắt Redis, đầy pool DB, kill process giữa transaction. Đọc 10 lần không bằng thấy nó chết 1 lần.
Kết: bộ xương của mọi backend
Nếu gom 13 tài liệu thành một trang, đây là nó:
ĐÔNG THÌ SAO? HỎNG THÌ SAO? LẪN THÌ SAO?
───────────── ───────────── ────────────
Cửa vào SQS đệm bão Timeout, throttle Thẻ theo giờ, dấu shop
Throttle IP Fail-closed cho an toàn Whitelist DTO
Redact log
Kho Redis chặn DB Postgres = nguồn sự thật shopDomain + @@unique
Bảng tổng hợp Redis = bản sao, mất được Soft-delete ở cửa tủ
Index đúng thứ tự Upsert, idempotent
Xưởng BullMQ tách quầy/xưởng Retry khớp nguyên nhân jobId theo shop
Cron gom lô 3 tầng tự phục hồi Fair queue theo tenant
Lô 500, song song 10 Hẹn giờ ghi giấy, claim
Bên ngoài Tự đếm rate-limit Timeout, circuit breaker Cờ lỗi theo shop
Debounce xin token Đọc lý do, không chỉ mã Debounce theo shop
Ba nguyên tắc, ba câu hỏi, ba tầng. Khi bạn thiết kế hệ thống tiếp theo — hay khi đọc source một hệ thống lạ — vẽ bảng này ra và điền vào. Ô nào trống là chỗ hệ thống sẽ gãy.
Tự kiểm tra toàn bộ
Trả lời bằng lời mình, không nhìn lại. Mỗi câu 3-5 câu văn.
- Vì sao push notification đi qua queue mà vẫn "realtime"? Cái gì chậm, cái gì không?
- Một webhook "đơn đã thanh toán" đi qua những đường nào, mỗi đường mất bao lâu, và vì sao chúng không chặn nhau?
- "Khóa thật" là gì? Nêu khóa thật của 3 loại hồ sơ, và giải thích vì sao nó là điều kiện tiên quyết cho retry.
- Retry Gorgias vì sao phải chờ đúng 20 giây? Rút ra quy tắc chung.
- Firebase 429: bản cũ sai ở đâu, hậu quả lan thế nào, sửa dựa trên nguyên tắc gì?
- Ba tầng tự phục hồi bắt ba loại nguyên nhân nào? Vì sao không tầng nào thừa?
- Kể 3 chỗ fail-open và 3 chỗ fail-closed trong notify-service, và tiêu chí phân biệt.
- Hệ thống đang chậm. Bạn làm gì đầu tiên, và 3 bước tiếp theo?
- Chạy 2 bản notify-service thì cái gì gãy? Nâng cấp thế nào?
- Lấy một tính năng bạn định làm tuần này. Trả lời 10 câu hỏi thiết kế của doc 08 cho nó.
Trả lời được 8/10 → bạn đã có bộ xương. Hai câu còn lại chỉ vào tài liệu gốc, đọc lại đúng mục đó.
AI 00 · Mở đầu
AI Engineering — từ "dùng AI" lên "xây trên AI"
Bài mở đầu: bạn đang đứng ở đâu, và sẽ đi đâu
Hiện tại bạn làm việc với AI theo cách này: mở Claude Code hoặc Hermes, gõ yêu cầu, nó chạy, bạn xem kết quả, sửa prompt nếu chưa ưng. Bạn biết cài skill, biết viết CLAUDE.md. Đó là tầng người dùng thành thạo. Không có gì sai với tầng này — nhưng nó có một trần: bạn chỉ dùng được thứ người khác xây, và khi nó sai, bạn không biết vì sao.
Tầng tiếp theo là người xây: bạn hiểu bên trong Claude Code là một vòng lặp gọi model rồi gọi tool, bạn tự viết được vòng lặp đó, tự viết tool cho nó, tự đo được nó đúng bao nhiêu phần trăm. Khi đó, mỗi "phép màu" bạn thấy hàng ngày đều có tên gọi, có lý do, và bạn tái tạo được.
Bộ tài liệu này tổng hợp 8 nguồn (Anthropic engineering blog, 12-factor agents, Claude Agent SDK, MCP spec, Hamel Husain, Eugene Yan, source Hermes, sách Chip Huyen) và giảng lại bằng tiếng Việt. Thuật ngữ kỹ thuật giữ nguyên tiếng Anh vì đó là từ bạn sẽ gặp trong docs, trong code, trong lỗi.
Năm khái niệm nền — đọc kỹ trước khi vào bài 1
Tôi sẽ dùng 5 từ này liên tục. Nếu bạn hiểu chúng thật chắc, mọi bài sau đều dễ.
1. LLM call — một lần gọi model
Một LLM call là một HTTP request. Bạn gửi một mảng messages (system prompt, các lượt user/assistant trước đó), model trả về một đoạn text (hoặc một yêu cầu gọi tool). Hết. Model không nhớ gì sau khi trả lời. Lần gọi tiếp theo, nếu bạn không gửi lại lịch sử, nó không biết bạn là ai.
Điều này nghe hiển nhiên nhưng là gốc của rất nhiều hiểu lầm. Khi bạn chat với Claude Code 2 tiếng, cảm giác như nó "nhớ" — thực ra mỗi lần bạn gõ, phần mềm gửi lại toàn bộ hội thoại từ đầu. "Trí nhớ" là do phần mềm xếp lại, không phải do model.
Hệ quả thực tế: mọi thứ model biết về task của bạn phải nằm trong request. Không nằm trong request = không tồn tại.
2. Tool / function calling — model không chạy code
Khi bạn thấy Claude Code "đọc file", "chạy lệnh", hãy hiểu đúng: model không đọc file. Model trả về một đoạn JSON kiểu { "name": "Read", "input": { "file_path": "/src/app.ts" } }. Phần mềm bao ngoài (Claude Code) nhận JSON đó, tự đọc file bằng Node.js, rồi gửi nội dung file về cho model trong lượt tiếp theo.
Vậy "tool" thực chất là: bạn khai báo với model một danh sách hàm (tên, mô tả, tham số), model chọn hàm và điền tham số, còn bạn chạy hàm. Model chỉ là người ra quyết định, không phải người thực thi.
Tại sao điều này quan trọng? Vì nó có nghĩa là bạn — người viết code — nắm toàn quyền: tool nào tồn tại, tool nào được chạy, chạy thế nào, trả gì về. Model không thể làm gì ngoài danh sách bạn cho.
3. Agent loop — vòng lặp
Ghép hai khái niệm trên lại, bạn có agent:
messages = [system, user_request]
lặp:
response = gọi_model(messages)
nếu response là text thường → xong, trả cho user
nếu response là tool call:
result = chạy_tool(response.name, response.input)
thêm response và result vào messages
(quay lại đầu vòng lặp)
Đó là toàn bộ Claude Code, Hermes, Cursor, Devin. Khác nhau ở: có bao nhiêu tool, tool viết tốt cỡ nào, system prompt dạy model làm gì, và cách quản lý messages khi nó dài ra. Nhưng cái lõi là 10 dòng trên. Bài 5 và bài 6 sẽ cho bạn tự viết nó.
4. Context window — thứ model "nhìn thấy"
Context window là toàn bộ token model đọc trong một lần gọi: system prompt + định nghĩa các tool + lịch sử hội thoại + kết quả tool + câu hỏi hiện tại. Nó có giới hạn (200k, 1M token tùy model), và quan trọng hơn: càng đầy, model càng kém — kể cả khi chưa chạm giới hạn. Bài 2 nói kỹ hiện tượng này.
Hình dung: bạn đưa một junior dev một tài liệu 3 trang, họ làm tốt. Đưa 300 trang và bảo "thông tin cần ở đâu đó trong này", họ sẽ bỏ sót. Model cũng vậy.
5. Eval — bộ đo
Eval là bộ test cho prompt hoặc agent: một danh sách input, với mỗi input có cách xác định output đúng hay sai, chạy tự động, ra con số. Ví dụ: 30 ticket hỗ trợ đã gắn nhãn tay, chạy prompt phân loại, đếm đúng bao nhiêu.
Không có eval, mỗi lần sửa prompt bạn chỉ có cảm giác "hình như tốt hơn". Có eval, bạn có "78% → 91%". Bài 7 dành trọn cho việc này, và nó là thứ phân biệt người làm AI nghiêm túc với người viết prompt.
Lộ trình
Cách học cho hiệu quả
Đọc từng bài theo thứ tự — bài sau dùng khái niệm bài trước. Sau mỗi bài có phần Tự kiểm tra: trả lời được bằng lời của mình thì mới sang bài tiếp. Bài 6, 7, 8 phải gõ code — đọc không thay được. Dùng Claude Code làm cùng thì được, nhưng phải đọc hiểu từng dòng nó viết, vì mục tiêu là bạn hiểu vòng lặp, không phải có sản phẩm.
AI 01 · Building effective agents
Bài 1 — Building Effective Agents: khi nào cần agent, khi nào không
Nguồn: Anthropic Engineering, "Building effective agents" (12/2024).
https://www.anthropic.com/engineering/building-effective-agents
Tại sao bài này đứng đầu
Anthropic viết bài này sau một năm làm việc với hàng chục team xây agent. Kết luận của họ đi ngược trực giác: những hệ thống thành công nhất không dùng framework phức tạp, mà dùng các pattern đơn giản ghép lại. Còn những team thất bại thường là những team nhảy thẳng vào "agent tự chủ" khi task của họ thực ra chỉ cần một lần gọi model.
Bài này cho bạn một cái thang. Mỗi bậc thang là một mức phức tạp. Quy tắc duy nhất: đứng ở bậc thấp nhất còn giải quyết được việc. Lên bậc cao hơn nghĩa là chịu thêm chi phí, thêm độ trễ, thêm chỗ hỏng — chỉ đáng khi bậc dưới thật sự không đủ.
Phần 1 — Phân biệt hai từ hay bị dùng lẫn: workflow và agent
Người ta gọi mọi thứ có LLM bên trong là "agent". Anthropic tách rõ:
Workflow là hệ thống mà LLM và tool được nối với nhau theo đường đi code của bạn định sẵn. Ví dụ: bước 1 gọi model tóm tắt, bước 2 code kiểm tra độ dài, bước 3 gọi model dịch. Model làm việc ở từng bước, nhưng thứ tự và số bước do bạn viết.
Agent là hệ thống mà model tự quyết làm gì tiếp theo: gọi tool nào, gọi bao nhiêu lần, khi nào dừng. Bạn chỉ đưa mục tiêu và danh sách tool.
Hãy nghĩ về nó như hai cách giao việc cho một nhân viên mới:
- Workflow: bạn đưa checklist 5 bước, họ làm đúng 5 bước. Dự đoán được, dễ kiểm tra, nhưng gặp tình huống ngoài checklist thì kẹt.
- Agent: bạn nói "làm cho xong việc X, đây là các công cụ", họ tự nghĩ cách. Linh hoạt, nhưng có thể đi lạc, tốn thời gian, làm thứ bạn không muốn.
|
Workflow |
Agent |
| Ai quyết bước tiếp |
Code của bạn |
Model |
| Kết quả có dự đoán được |
Cao |
Thấp |
| Chi phí, độ trễ |
Thấp, cố định |
Cao, dao động |
| Khi nào dùng |
Task rõ ràng, tách được thành bước |
Task mở, không biết trước cần bao nhiêu bước |
Điểm mấu chốt cần nhớ: hầu hết việc thực tế là workflow. Phân loại ticket, sinh nội dung theo template, tóm tắt, trích xuất thông tin — tất cả đều là workflow. Agent chỉ cần khi bạn thật sự không thể viết trước được đường đi, ví dụ "sửa bug này" (không biết bug ở đâu, cần đọc bao nhiêu file).
Phần 2 — Khối xây dựng cơ bản: augmented LLM
Trước khi nói pattern, cần biết "viên gạch". Viên gạch là một LLM call được gắn thêm ba thứ:
- Retrieval — khả năng lấy dữ liệu ngoài (tìm trong DB, search tài liệu) để đưa vào prompt.
- Tools — khả năng gọi hàm (như bài mở đầu đã giải thích).
- Memory — khả năng nhớ qua nhiều lượt (thực chất là bạn lưu và nạp lại).
Mọi pattern phía dưới đều là cách sắp xếp nhiều viên gạch này. Nếu một viên gạch (một call có retrieval + tool) đã giải quyết được việc, dừng ở đó.
Phần 3 — Năm workflow pattern
Đây là phần quan trọng nhất của bài. Với mỗi pattern tôi sẽ nói: nó là gì, tại sao nó tốt hơn một call đơn, và một ví dụ trên hệ thống của bạn.
3.1 Prompt chaining — nối chuỗi
Là gì: Tách task thành các bước nối tiếp, output bước trước là input bước sau. Giữa các bước có thể đặt gate — một đoạn code kiểm tra, nếu không đạt thì dừng hoặc quay lại.
Tại sao tốt hơn một call: Một call làm 3 việc cùng lúc thường làm cả 3 ở mức trung bình. Ba call, mỗi call một việc, mỗi call chính xác hơn — vì prompt ngắn hơn, mục tiêu rõ hơn. Bạn đổi một chút độ trễ lấy độ chính xác.
Ví dụ trên notify-service: Sinh push notification cho đơn hàng mới.
- Bước 1: model sinh tiêu đề + nội dung từ thông tin đơn.
- Gate (code): tiêu đề ≤ 50 ký tự? Không chứa từ cấm? Không thì yêu cầu sinh lại.
- Bước 2: model dịch sang ngôn ngữ của shop.
- Gate (code): kết quả là JSON hợp lệ có đủ 2 trường?
Nếu gộp thành một call "sinh notification ngắn gọn, không từ cấm, bằng tiếng Nhật", bạn sẽ khó biết lỗi ở khâu nào khi nó sai.
3.2 Routing — phân luồng
Là gì: Một bước phân loại input, rồi đẩy vào nhánh xử lý riêng. Mỗi nhánh có prompt riêng, có thể model riêng.
Tại sao: Một prompt phục vụ nhiều loại input sẽ phình to và mâu thuẫn — hướng dẫn cho loại A làm nhiễu loại B. Tách nhánh cho phép tối ưu từng nhánh độc lập. Thêm nữa: câu dễ đẩy sang model rẻ (Haiku), câu khó sang model mạnh (Sonnet/Opus) — tiết kiệm đáng kể.
Ví dụ: Ticket Gorgias vào. Bước phân loại: refund / shipping / product_question / other. Ticket shipping đi vào prompt có sẵn tool tra vận đơn; ticket refund đi vào prompt có chính sách hoàn tiền. other chuyển người.
3.3 Parallelization — chạy song song
Là gì: Hai biến thể.
- Sectioning: chia task thành các phần độc lập, chạy đồng thời, ghép kết quả.
- Voting: chạy cùng một task nhiều lần, lấy kết quả theo đa số hoặc theo ngưỡng.
Tại sao: Sectioning cho tốc độ (3 việc cùng lúc thay vì tuần tự) và cho độ tập trung (mỗi call chỉ lo một khía cạnh). Voting cho độ tin cậy: model có yếu tố ngẫu nhiên, hỏi 3 lần lấy 2/3 giảm rủi ro một lần trả lời lệch.
Ví dụ: Review một pull request: một call tìm bug logic, một call tìm lỗ hổng bảo mật, một call tìm vấn đề hiệu năng — chạy song song, gộp thành một báo cáo. Đây chính là cách lệnh /code-review trong Claude Code hoạt động. Voting: kiểm duyệt nội dung notification do merchant nhập — 3 call chấm "có vi phạm không", 2/3 nói có mới chặn.
3.4 Orchestrator-workers — người điều phối và thợ
Là gì: Một LLM trung tâm (orchestrator) tự quyết cần tách task thành những việc con nào, giao mỗi việc cho một LLM khác (worker), rồi tổng hợp.
Khác parallelization ở đâu? Ở parallelization, bạn định trước có 3 phần. Ở orchestrator-workers, model quyết lúc chạy: có thể 2 phần, có thể 7 phần, tùy input. Đây là bước đầu tiên model có quyền tự chủ về cấu trúc công việc.
Tại sao: Vì có những task bạn không biết trước cấu trúc. "Thêm field priority vào notification" — đụng bao nhiêu file? Orchestrator đọc code, quyết "cần sửa schema, DTO, service, controller, test" rồi giao mỗi cái cho một worker.
Ví dụ: Đây chính là Agent tool trong Claude Code, và bài 4 sẽ mổ xẻ một hệ thống thật dùng pattern này.
3.5 Evaluator-optimizer — người làm và người chấm
Là gì: Một LLM sinh kết quả, một LLM khác chấm và góp ý theo tiêu chí, lặp lại cho tới khi đạt (hoặc hết số vòng).
Tại sao: Giống như bạn viết nháp rồi tự đọc lại và sửa — lần 2 thường tốt hơn lần 1. Model cũng vậy, với điều kiện tiêu chí chấm rõ ràng và việc góp ý thực sự dẫn đến cải thiện. Nếu tiêu chí mơ hồ ("hay hơn"), vòng lặp chỉ tốn tiền.
Ví dụ: Sinh copy notification → model chấm theo rubric: có call-to-action rõ? đúng giọng brand? ≤ 80 ký tự? → nếu chưa đạt, gửi nhận xét về cho model sinh, sửa → chấm lại. Tối đa 3 vòng.
Phần 4 — Agent thực thụ
Khi nào một hệ thống được gọi là agent đúng nghĩa? Khi model đủ mạnh để: hiểu yêu cầu phức tạp, tự lập kế hoạch, dùng tool đáng tin cậy, và tự sửa khi sai. Vòng lặp:
nhận yêu cầu → lập kế hoạch → gọi tool → nhận kết quả THẬT từ môi trường
→ dựa vào đó quyết bước tiếp → ... → hoàn thành, hoặc dừng lại hỏi người
Cụm từ quan trọng nhất ở đây là "kết quả thật từ môi trường" — Anthropic gọi là ground truth. Agent chạy test và đọc output test. Agent chạy lệnh và đọc lỗi. Agent đọc file và thấy nội dung thật. Nhờ đó nó biết mình đúng hay sai và tự điều chỉnh.
Ngược lại, một agent không có feedback thật — chỉ tự suy luận "chắc là đúng rồi" — sẽ trôi xa dần khỏi thực tế mà không hề biết. Đây là lý do Claude Code luôn có tool chạy lệnh và đọc file: không phải để tiện, mà để agent có chỗ bám vào sự thật.
Khi nào dùng agent: task mở, không đoán được số bước, và bạn tin được model trong môi trường đó. Luôn kèm: giới hạn số vòng lặp, điểm dừng hỏi người, chạy trong sandbox. Chi phí cao, lỗi cộng dồn qua các bước — nên phải test kỹ.
Phần 5 — Ba nguyên tắc thiết kế
- Đơn giản. Giữ thiết kế nhỏ nhất còn chạy được. Mỗi lớp phức tạp thêm là một lớp bạn phải debug lúc 3 giờ sáng.
- Minh bạch. Cho người dùng thấy bước lập kế hoạch của agent. Khi thấy được kế hoạch, người ta phát hiện sai sớm hơn và tin tưởng hơn.
- ACI — Agent-Computer Interface. Đây là thuật ngữ Anthropic đặt, mượn từ HCI (Human-Computer Interface). Ý là: giao diện giữa agent và máy — tức các tool — cần được thiết kế cẩn thận như thiết kế UI cho người. Tài liệu tool, tên tham số, ví dụ dùng, tất cả ảnh hưởng trực tiếp đến việc agent làm đúng hay sai. Bài 3 dành riêng cho việc này.
- Chọn format model dễ viết đúng. Ví dụ thật từ Anthropic: khi làm tool sửa file cho Claude Code, họ thử nhiều format. Format kiểu diff (cần đếm số dòng chính xác) model hay sai; format "ghi lại toàn bộ file" hoặc "tìm đoạn này thay bằng đoạn kia" model làm tốt hơn nhiều. Bạn sẽ thấy Edit tool của Claude Code dùng đúng cách "tìm-thay" này.
- Viết mô tả tool như docstring cho một junior developer: khi nào dùng, ví dụ, edge case, khác gì tool bên cạnh.
- Poka-yoke (thuật ngữ từ Toyota: thiết kế để không thể làm sai). Ví dụ: agent hay nhầm đường dẫn tương đối khi đã
cd sang thư mục khác — Anthropic sửa bằng cách bắt buộc absolute path. Không dạy model "hãy cẩn thận", mà đổi tham số để không thể sai.
Áp vào việc của bạn
Nhìn lại hệ thống bạn dùng hàng ngày với lens của bài này:
- Claude Code là agent (phần 4) có kèm orchestrator-workers (subagent) và tool được thiết kế cực kỳ kỹ. Khi bạn đọc system prompt của nó trong repo
system-prompts-and-models-of-ai-tools, hãy tìm: chỗ nào dạy nó lập kế hoạch, chỗ nào ép nó kiểm tra ground truth, chỗ nào giới hạn nó.
- Skill superpowers (brainstorm → plan → TDD → review) là prompt chaining có gate. Đó là workflow, không phải agent — và đúng nên là workflow, vì quy trình phát triển phần mềm có cấu trúc rõ.
- Ở notify-service, phần lớn chỗ có thể thêm AI là workflow: phân loại ticket (routing), sinh copy (chaining + evaluator), tóm tắt lịch sử khách (một call). Đừng dựng agent cho những việc này.
Tự kiểm tra
Trả lời bằng lời của bạn, không nhìn lại bài:
1. Workflow và agent khác nhau ở một điểm cốt lõi nào?
2. Orchestrator-workers khác parallelization ở chỗ nào? Cho một ví dụ mỗi loại.
3. "Ground truth từ môi trường" nghĩa là gì, và tại sao agent không có nó thì nguy hiểm?
4. Poka-yoke là gì? Nghĩ ra một tham số tool trong notify-service có thể áp dụng.
Bài tập
- Liệt kê 5 việc bạn từng nhờ Claude Code. Với mỗi việc: nó cần một call, workflow (pattern nào), hay agent thật? Viết lý do một dòng.
- Vẽ workflow "sinh push notification từ order mới" bằng prompt chaining với ít nhất một gate bằng code. Ghi rõ mỗi bước input gì, output gì.
- Tìm một chỗ trong CLAUDE.md hoặc skill của bạn đang để model tự quyết mà thực ra một quy trình cố định làm tốt hơn. Sửa nó.
AI 02 · Context engineering
Bài 2 — Context Engineering: cho model nhìn cái gì
Nguồn: Anthropic Engineering, "Effective context engineering for AI agents" (9/2025).
https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
Tại sao có bài này
Hai năm trước, kỹ năng số một là "prompt engineering" — viết instruction sao cho model làm đúng. Nhưng khi agent chạy 50 lượt, gọi 30 tool, kéo về hàng chục nghìn token kết quả, câu hỏi không còn là "prompt viết hay chưa" mà là: ở lượt thứ 40, model đang nhìn thấy cái gì? Có cái gì thừa? Thiếu cái gì? Cái quan trọng nhất có bị chôn giữa đống tool output không?
Anthropic gọi kỹ năng trả lời câu hỏi đó là context engineering: nghệ thuật chọn ra tập token nhỏ nhất, tín hiệu cao nhất, ở mỗi bước. Câu chốt của bài: "Find the smallest set of high-signal tokens that maximize the likelihood of your desired outcome."
Phần 1 — Context là tài nguyên hữu hạn, và nó "mục" dần
Đây là phần lý thuyết duy nhất, nhưng nó giải thích mọi thứ phía sau.
Model ngôn ngữ dùng kiến trúc transformer. Trong transformer, mỗi token "nhìn" mọi token khác trong context để quyết định ý nghĩa — cơ chế gọi là attention. Với n token, có n² cặp quan hệ. Model được train chủ yếu trên chuỗi ngắn, nên khi context dài ra, nó có ít "kinh nghiệm" xử lý các quan hệ xa, và attention bị loãng: sự chú ý phân bổ mỏng ra trên quá nhiều token.
Kết quả là hiện tượng có tên context rot (context mục): khi số token tăng, khả năng model nhớ và dùng đúng một thông tin cụ thể trong context giảm — kể cả khi vẫn còn xa giới hạn cứng. Model không báo lỗi, không từ chối. Nó chỉ lặng lẽ kém đi.
Hình ảnh dễ nhớ: bạn đưa đồng nghiệp một tin nhắn 5 dòng, họ nắm hết. Đưa một email 5 trang có 1 dòng quan trọng ở giữa, họ có thể bỏ sót dòng đó — dù họ hoàn toàn đọc được. Không phải họ kém, mà là sự chú ý là hữu hạn.
Hệ quả cho bạn: mỗi token trong context phải có lý do tồn tại. "Nhét thêm cho chắc" không vô hại — nó làm loãng những token thực sự quan trọng.
Phần 2 — System prompt: tìm đúng "độ cao"
System prompt là phần bạn kiểm soát nhiều nhất. Anthropic mô tả hai cách viết sai, ở hai cực:
Cực quá thấp — hardcode logic. Bạn viết prompt như code: "Nếu user hỏi về đơn hàng thì làm A. Nếu hỏi về hoàn tiền thì làm B. Nếu hỏi về vận chuyển thì làm C..." Prompt kiểu này giòn: gặp câu hỏi thứ D không có trong danh sách, model không biết làm gì, hoặc cố ép vào A/B/C. Bạn cũng sẽ phải sửa prompt mỗi khi có case mới, và nó phình ra vô tận.
Cực quá cao — mơ hồ. "Hãy là một trợ lý hữu ích, cẩn thận, chính xác." Không có gì để model bám. Nó phải đoán bạn muốn gì, và mỗi lần đoán khác nhau.
Đúng độ cao: đủ cụ thể để dẫn hướng, đủ linh hoạt để model tự dùng phán đoán. Thay vì liệt kê case, bạn mô tả vai trò, mục tiêu, ràng buộc, và vài heuristic. Ví dụ, thay vì 20 dòng if/else về ticket, viết: "Bạn xử lý ticket hỗ trợ cho merchant Shopify. Ưu tiên giải quyết trong một lần trả lời. Khi thiếu thông tin đơn hàng, dùng tool tra cứu trước khi hỏi khách. Chuyển người khi liên quan hoàn tiền trên $500 hoặc khách tỏ ra giận dữ."
Cách làm thực tế Anthropic khuyên: bắt đầu với prompt tối thiểu, chạy trên model tốt nhất, xem nó sai ở đâu, rồi mới thêm instruction hoặc ví dụ cho đúng chỗ sai đó. Không viết trước cho mọi tình huống tưởng tượng. Về hình thức: chia section bằng XML tag (<background>, <instructions>) hoặc Markdown header để model phân biệt được loại thông tin.
Phần 3 — Tool: ít, rõ, không chồng nhau
Tool cũng chiếm context (định nghĩa tool được gửi kèm mỗi request). Nhưng vấn đề lớn hơn là sự mơ hồ: nếu bạn có search_orders, find_orders, query_orders làm việc gần giống nhau, model sẽ phân vân và chọn sai. Anthropic đưa một phép thử đơn giản: nếu chính bạn (một kỹ sư) không nói chắc được nên gọi tool nào trong tình huống X, thì model cũng không.
Tool tốt: tự chứa (không cần gọi tool khác trước mới dùng được), chịu được lỗi (input sai trả lỗi rõ chứ không crash), mục đích không trùng với tool khác, tham số ít và tên rõ. Bài 3 đi sâu.
Phần 4 — Ví dụ trong prompt: ít nhưng đa dạng
Nhiều người nhét vào prompt 30 ví dụ để "cover mọi edge case". Sai. Model học từ ví dụ giống người học từ ví dụ: vài ví dụ điển hình, khác nhau rõ rệt dạy được nguyên tắc; 30 ví dụ na ná nhau chỉ tốn token và làm model bắt chước hình thức thay vì hiểu ý. Anthropic viết: với LLM, "a picture is worth a thousand words" — một ví dụ tốt hơn một đoạn mô tả dài.
Phần 5 — Just-in-time: lấy khi cần, đừng nạp sẵn tất cả
Đây là thay đổi tư duy lớn nhất của bài.
Cách cũ (pre-loading): trước khi gọi model, dùng embedding tìm mọi tài liệu "liên quan", nhét hết vào prompt. Đây là RAG cổ điển. Vấn đề: bạn phải đoán trước cái gì liên quan, thường đoán thừa (làm loãng context) hoặc thiếu (model không có thứ cần).
Cách mới (just-in-time): chỉ đưa model những định danh nhẹ — đường dẫn file, tên bảng, URL — và cho nó tool để tự tải khi cần. Model đọc cấu trúc thư mục, quyết định mở file nào, đọc, rồi quyết mở tiếp file nào.
Tại sao cách này tốt hơn? Vì nó giống cách người làm việc: bạn không thuộc lòng toàn bộ codebase, bạn biết chỗ tìm. Và bản thân "chỗ tìm" đã mang thông tin: file tên test_utils.py nằm trong tests/ nói lên mục đích của nó mà chưa cần mở. Cấu trúc thư mục, tên file, timestamp — tất cả là tín hiệu rẻ.
Đánh đổi: tự khám phá chậm hơn nạp sẵn. Thực tế người ta làm lai: nạp sẵn thứ chắc chắn cần và nhỏ (CLAUDE.md, tóm tắt project), còn lại để model tự tìm.
Claude Code làm đúng như vậy: CLAUDE.md nạp sẵn, còn lại là Glob, Grep, Read theo nhu cầu. Codegraph bạn đang dùng là bước tiến của cách này: có index sẵn, nhưng chỉ trả về đúng phần được hỏi.
Phần 6 — Task dài: ba kỹ thuật giữ context sạch
Khi agent chạy hàng giờ, context sẽ đầy. Ba cách xử lý, mỗi cách hợp một loại task.
6.1 Compaction — nén lại
Khi context gần đầy, gọi model tóm tắt toàn bộ hội thoại, rồi bắt đầu context mới chỉ với bản tóm tắt. Nghệ thuật ở chỗ giữ gì, bỏ gì: giữ quyết định kiến trúc, bug chưa sửa, chi tiết cần cho bước tiếp; bỏ tool output đã dùng xong, message qua lại vụn vặt.
Kỹ thuật compaction rẻ nhất, không cần gọi model: xóa kết quả tool cũ. Khi agent đã đọc file và sửa xong, nội dung file đó trong context không còn giá trị — xóa đi, giữ lại dòng "đã đọc file X". Claude Code làm việc này tự động.
Hợp với: hội thoại dài cần liên tục, không có mốc dừng rõ.
6.2 Structured note-taking — ghi chú ra ngoài
Agent tự ghi ghi chú ra ngoài context (file NOTES.md, todo list, bảng tiến độ), rồi đọc lại khi cần. Bộ nhớ bền qua nhiều lần reset context, tốn rất ít token.
Ví dụ Anthropic kể: Claude chơi Pokémon, ghi chú "đã train 1234 bước ở khu X, mục tiêu lên level 10" — sau khi context bị reset, đọc ghi chú và tiếp tục đúng chiến lược, không lặp lại.
Memory của Claude Code (MEMORY.md), memory của Hermes, file plan trong skill superpowers — đều là pattern này.
Hợp với: phát triển lặp có mốc rõ, task nhiều phiên.
6.3 Sub-agent — tách context
Thay vì một agent giữ mọi thứ, agent chính giao việc con cho sub-agent có context sạch. Sub-agent đọc 20 file, gọi 30 tool, rồi trả về bản tóm tắt 1-2 nghìn token. Agent chính chỉ giữ tóm tắt đó.
Lợi ích: tách bạch — agent chính giữ kế hoạch và bức tranh lớn, sub-agent giữ chi tiết kỹ thuật. Chi tiết không bao giờ làm loãng context của agent chính.
Hợp với: nghiên cứu, phân tích song song nhiều hướng. Bài 4 là một hệ thống thật dùng pattern này.
Áp vào việc của bạn
- CLAUDE.md của bạn đang khá dài và có nhiều rule dạng "PHẢI làm X trước Y". Đọc lại với phần 2: dòng nào là logic hardcode (quá thấp)? Dòng nào chung chung (quá cao)? Dòng nào model chưa từng vi phạm — có thể xóa? Nhớ: mỗi dòng đó nạp vào mọi request.
- Rule "codegraph FIRST" là just-in-time có index — tốt. Nhưng để ý: mỗi lần
codegraph_explore trả về source của 6 file vào context. Không miễn phí. Khi chỉ cần 1 file, hỏi hẹp.
- Task dài trong Claude Code: chủ động ghi plan ra file (đúng như skill superpowers bắt) thay vì giữ trong hội thoại. Đó là note-taking, và nó sống sót qua compaction.
- Hermes: nó có cơ chế compress session. Khi đọc source (bài 8), xem nó giữ gì bỏ gì — đó là compaction thật.
Tự kiểm tra
- Context rot là gì? Tại sao nó xảy ra dù chưa chạm giới hạn token?
- Cho một ví dụ system prompt "quá thấp" và viết lại nó ở "đúng độ cao".
- Just-in-time retrieval khác pre-loading ở đâu? Khi nào nên lai?
- Ba kỹ thuật cho task dài — mỗi kỹ thuật hợp loại task nào?
Bài tập
- Mở một session Claude Code dài. Đếm số tool result còn nằm trong hội thoại mà bạn thấy đã hết giá trị. Ước lượng chúng chiếm bao nhiêu token.
- Viết lại CLAUDE.md của bạn ngắn hơn 40% mà không mất hành vi quan trọng. Chạy 3 task quen thuộc, so kết quả trước/sau.
- Thiết kế một sub-agent cho notify-service: "tìm mọi chỗ ghi vào bảng
NotificationHistory". Ghi rõ: input là gì, output tóm tắt có dạng gì, tối đa bao nhiêu token trả về, tool nào được dùng.
AI 03 · Writing tools for agents
Bài 3 — Writing Tools for Agents: tool cho model khác tool cho người
Nguồn: Anthropic Engineering, "Writing effective tools for AI agents — using AI agents" (9/2025).
https://www.anthropic.com/engineering/writing-tools-for-agents
Bạn là backend developer. Bạn viết hàm, viết API cả ngày. Phản xạ tự nhiên khi cần "tool cho agent" là: wrap API có sẵn thành tool, xong. Anthropic nói: phản xạ đó sai, và hầu hết tool tệ trên đời đều sinh ra từ phản xạ đó.
Lý do nằm ở một câu trong bài: tool là "a contract between deterministic systems and non-deterministic agents" — hợp đồng giữa hệ thống xác định và tác nhân không xác định. Hãy hiểu kỹ câu này.
Khi bạn viết hàm getShopInfo(shopDomain), caller là code. Code gọi đúng lúc, đúng tham số, vì bạn viết cả hai đầu. Nếu sai, compiler hoặc test báo.
Khi bạn viết tool get_shop_info cho agent, caller là model. Model không đọc source của bạn. Nó đọc tên và mô tả tool, rồi quyết: có gọi không, gọi lúc nào, điền tham số gì. Cùng một tool, hai lần chạy có thể gọi khác nhau, vì model suy luận khác nhau.
Hệ quả: mô tả tool chính là prompt. Sửa mô tả là sửa hành vi agent. Tên mơ hồ → gọi nhầm tool bên cạnh. Mô tả thiếu → gọi sai lúc. Tham số khó hiểu → điền sai. Bạn không thể "fix bằng code" những lỗi này; phải fix bằng cách viết lại hợp đồng.
Anthropic không đưa ra lý thuyết trước. Họ đưa một vòng lặp làm việc.
Bước 1 — Prototype nhanh và tự dùng
Dựng tool ở dạng đơn giản nhất (một MCP server local, hoặc một hàm trong loop), cắm vào Claude Code, rồi tự dùng với case thật. Bạn sẽ cảm nhận ngay: tool này gọi có tiện không, output có đọc được không, lỗi có hiểu được không. Đây là bước rẻ nhất để phát hiện thiết kế tệ.
Bước 2 — Eval nghiêm túc
Đây là bước hầu hết người ta bỏ qua. Bạn cần hàng chục task thực tế, mỗi task có kết quả kiểm chứng được bằng code.
Anthropic phân biệt task mạnh và task yếu bằng ví dụ:
- Task mạnh: "Khách Sarah Chen gửi yêu cầu hủy. Chuẩn bị offer giữ chân: (1) vì sao họ rời, (2) offer nào thuyết phục, (3) rủi ro." — cần nhiều tool, nhiều bước, giống việc thật.
- Task yếu: "Tìm yêu cầu hủy của customer ID 45892." — một call, không đo được gì về khả năng phối hợp tool.
Chạy bộ task này bằng một agent loop đơn giản, đo tự động: đúng/sai, số tool call, số token, thời gian. Rồi — quan trọng — đọc transcript. Chỗ agent loay hoay, gọi đi gọi lại, gọi nhầm — chính là chỗ tool tệ.
Bước 3 — Để agent sửa tool
Đưa transcript cho Claude Code, hỏi: "Tại sao agent fail ở đây? Sửa mô tả hay tham số thế nào?" Anthropic thú nhận: "Most of the advice in this post came from repeatedly optimizing our internal tool implementations with Claude Code." Họ còn có một agent chuyên test tool, tự viết lại mô tả, giảm 40% thời gian hoàn thành task.
Ý nghĩa: tối ưu tool là việc lặp, có số đo, và model giúp được. Không phải việc "viết một lần cho hay".
3.1 Chọn đúng tool — không phải wrap API
Nhiều tool không đồng nghĩa tốt hơn. Mỗi tool thêm vào là thêm token định nghĩa, thêm một lựa chọn để model phân vân.
Cách nghĩ đúng: agent cần làm workflow gì? rồi thiết kế tool theo workflow, không theo bảng trong DB. Ví dụ Anthropic: thay vì list_users + list_events + create_event (bắt agent gọi 3 lần, tự ghép), làm một tool schedule_event tự tìm người, tìm slot trống, tạo lịch. Agent gọi một lần.
Lý do sâu hơn: model có context hữu hạn (bài 2). Bắt nó đọc 1000 dòng danh bạ để tìm một người là lãng phí context. Làm tool search_contacts trả đúng người.
3.2 Namespace rõ
Khi agent có hàng chục tool từ nhiều nguồn, tên phải cho biết thuộc về đâu, tác động lên gì: asana_projects_search, asana_users_search, notify_shops_get. Prefix giúp model chọn đúng nhóm trước khi chọn tool, và giúp bạn đọc log biết ngay tool của ai.
Bạn đã thấy điều này: mobile_take_screenshot, codegraph_explore, mcp__figma__.... Đó không phải tình cờ.
3.3 Trả về thứ có nghĩa
Tool trả về gì cũng đi vào context. Output của bạn nên chứa tín hiệu cao — tên, trạng thái, mô tả ngắn — không phải UUID, MIME type, timestamp nano giây, metadata kỹ thuật mà model không dùng.
Nhưng đôi khi bước sau cần ID. Giải pháp Anthropic dùng: tham số response_format: "concise" | "detailed", mặc định concise. Agent cần ID thì tự yêu cầu detailed. Bạn không phải chọn giữa "quá ít" và "quá nhiều".
Đừng trả JSON thô của API nếu model chỉ cần 3 trường trong đó.
3.4 Tiết kiệm token
- Pagination, filter, truncation với default hợp lý (20 kết quả, không phải 1000).
- Khi cắt bớt, nói cho agent biết và gợi ý cách thu hẹp: "Showing 20 of 340 results. Refine with
status= or date_from=." Agent đọc dòng đó và biết phải làm gì. Cắt im lặng thì agent tưởng chỉ có 20.
- Lỗi phải hành động được. Không phải
ERR_INVALID_ARGS. Mà là: "To search logs, use: search_logs(service='payment', start_date='2025-01-15', error_type='timeout')". Agent đọc lỗi đó và gọi lại đúng ngay lần sau. Đây là điểm khác biệt lớn với API cho người — người sẽ mở docs, agent chỉ có cái bạn trả về.
3.5 Prompt-engineer phần mô tả
Mô tả tool nạp vào context của mọi request. Nó là prompt. Hãy viết như hướng dẫn cho đồng nghiệp mới vào công ty, không phải cho compiler.
Điều hay bị bỏ sót nhất: kiến thức ngầm. Bạn biết "shopDomain phải có .myshopify.com", biết "status SENDING quá 5 phút là kẹt", biết "tool này chỉ có dữ liệu 90 ngày". Model không biết. Anthropic khuyên: nghĩ xem bạn đang ngầm hiểu gì — format đặc thù, thuật ngữ nội bộ, quan hệ giữa các resource, khi nào dùng tool này thay tool kia — và viết ra.
- [ ] Tên:
<service>_<resource>_<verb>, không trùng ý với tool khác trong bộ
- [ ] Mô tả: làm gì, khi nào dùng, khi nào không dùng, một ví dụ gọi
- [ ] Tham số: ít nhất có thể, tên tự giải thích, dùng enum khi được, có default
- [ ] Output: trường có nghĩa, không ID thô (trừ khi detailed), ghi tổng số và đã cắt bao nhiêu
- [ ] Lỗi: câu tiếng người kèm cách gọi đúng
- [ ] Poka-yoke: tham số nào hay bị nhầm? Đổi kiểu hoặc tên để không nhầm được (absolute path, ISO date, enum thay string tự do)
- [ ] Có ít nhất 10 eval task dùng tool này
- So sánh tool bạn dùng hàng ngày với checklist:
codegraph_explore có mô tả dài, có đoạn "gọi cái này thay vì Read/Grep" — đó là nguyên tắc 3.5. mobile_* có namespace — nguyên tắc 3.2. Khi thấy một tool khó dùng, giờ bạn biết gọi tên vấn đề.
- Khi làm MCP server cho notify-service (bài 8): đừng biến 24 controller thành 24 tool. Hỏi: người vận hành hay cần biết gì? Có lẽ 4-5 tool: thông tin shop, tìm notification, thống kê lịch sử gửi, trạng thái queue.
- Skill bạn viết cũng là "tool" theo nghĩa rộng: phần
description quyết định Claude Code có kích hoạt nó đúng lúc không. Áp nguyên tắc 3.5 vào đó.
- Vì sao mô tả tool được coi là prompt? Điều gì xảy ra nếu mô tả sai?
- Task eval "mạnh" khác "yếu" ở đâu?
- Tại sao lỗi trả về cho agent phải khác lỗi trả về cho developer?
response_format: concise | detailed giải quyết mâu thuẫn gì?
- Chọn một tool bất kỳ trong
~/.claude (MCP hoặc skill). Chấm theo checklist phần 4. Sửa mô tả, chạy lại 3 task, ghi lại khác biệt.
- Thiết kế 4 tool cho notify-service trên giấy: tên, mô tả đầy đủ, tham số, output mẫu ở cả hai chế độ concise và detailed.
- Viết 10 eval task cho 4 tool đó. Mỗi task ghi rõ cách kiểm tra đúng/sai bằng code (SQL, so sánh số, kiểm tra trường).
AI 04 · Multi-agent research
Bài 4 — Multi-agent Research System: nhiều agent phối hợp thật sự chạy thế nào
Nguồn: Anthropic Engineering, "How we built our multi-agent research system" (6/2025).
https://www.anthropic.com/engineering/multi-agent-research-system
Tại sao có bài này
Bài 1 giới thiệu pattern orchestrator-workers trên lý thuyết. Bài này là hậu trường một sản phẩm thật dùng pattern đó: tính năng Research của Claude (bạn hỏi một câu phức tạp, Claude tự tìm nhiều nguồn, tổng hợp có trích dẫn). Giá trị của bài không phải kiến trúc — kiến trúc khá đơn giản — mà là những con số, những lỗi họ gặp, và bài học vận hành mà chỉ khi chạy production mới biết.
Phần 1 — Kiến trúc
Câu hỏi của user
→ Lead agent (Claude Opus)
đọc câu hỏi, lập chiến lược, lưu kế hoạch vào memory
→ spawn 3 đến 5+ Subagent (Claude Sonnet), chạy SONG SONG
mỗi subagent nhận một hướng nghiên cứu riêng
lặp: search → đọc → suy nghĩ → search tiếp
trả về kết quả cô đọng
← Lead tổng hợp, quyết cần tìm thêm không → lặp nếu cần
→ CitationAgent: gắn nguồn cho từng khẳng định
→ Kết quả trả user
Bạn nhận ra: đây là orchestrator-workers (bài 1, mục 3.4) cộng với sub-agent context sạch (bài 2, mục 6.3). Lead giữ bức tranh lớn, subagent giữ chi tiết.
Phần 2 — Vì sao multi-agent thắng, và thắng bao nhiêu
Đây là phần có số liệu, hiếm thấy trong các bài về agent.
Token là yếu tố quyết định. Khi Anthropic phân tích cái gì làm research tốt hơn, họ thấy lượng token dùng giải thích 80% phương sai kết quả. Nói cách khác: agent "đọc nhiều hơn" thì trả lời tốt hơn, gần như tuyến tính. Nhưng một agent bị giới hạn bởi một context window. Nhiều agent = nhiều context window = nhiều "dung lượng suy nghĩ" hơn cộng lại.
Con số: cấu hình Opus làm lead + Sonnet làm subagent vượt Opus chạy một mình 90.2% trên bộ eval nội bộ. Không phải vì Sonnet giỏi hơn Opus, mà vì tổng context của cả hệ thống lớn hơn nhiều.
Song song giảm thời gian. Subagent chạy cùng lúc, và mỗi subagent gọi nhiều tool cùng lúc, giảm tới 90% thời gian so với tuần tự.
Cái giá: chat thường dùng x token. Agent đơn dùng khoảng 4x. Multi-agent dùng khoảng 15x. Nên chỉ đáng khi giá trị câu trả lời đủ lớn — research thị trường, phân tích kỹ thuật, không phải "hôm nay thời tiết thế nào".
Loại task hợp: breadth-first — nhiều hướng độc lập cần khám phá song song. "So sánh 10 công ty", "khảo sát toàn bộ một lĩnh vực". Không hợp task có các bước phụ thuộc chặt nhau.
Phần 3 — Tám nguyên tắc prompt cho multi-agent
Đây là phần thực hành nhất. Mỗi nguyên tắc là một bài học họ trả giá mới có.
1. Nghĩ như agent. Trước khi sửa prompt, hãy chạy mô phỏng và xem từng bước agent làm gì. Anthropic phát hiện các lỗi kiểu: lead spawn 50 subagent cho câu hỏi đơn giản; subagent search mãi một thứ không tồn tại; agent lạc hướng vì mô tả tool sai. Bạn không đoán được những lỗi này từ ghế; phải nhìn.
2. Dạy cách giao việc. Lead nói với subagent "research về semiconductors" → hai subagent làm trùng nhau, ba subagent hiểu ba kiểu. Prompt giao việc phải có: mục tiêu cụ thể, format output mong muốn, tool nên dùng, ranh giới việc. Giống bạn giao việc cho junior: càng rõ càng ít phải làm lại.
3. Scale nỗ lực theo độ khó. Agent không tự biết câu hỏi này đáng bao nhiêu công. Phải viết thẳng vào prompt: tra một fact = 1 agent, 3-10 tool call; so sánh trực tiếp = 2-4 subagent, 10-15 call mỗi cái; research lớn = 10+ subagent chia rõ trách nhiệm. Không có rule này, agent hoặc làm quá ít hoặc đốt token cho câu dễ.
4. Thiết kế tool là sống còn. Mô tả tool tệ → agent đi sai đường từ bước đầu, không cứu được. Họ dạy agent heuristic: xem hết tool có sẵn trước, khớp tool với ý định, ưu tiên tool chuyên biệt hơn tool chung. (Đây là bài 3 áp dụng.)
5. Để agent tự cải thiện. Claude 4 rất giỏi chẩn đoán "vì sao agent kia fail". Họ làm một agent chuyên test tool: chạy thử, phát hiện mô tả gây nhầm, tự viết lại. Kết quả: thời gian hoàn thành task giảm 40%. Bài học: khi eval fail, đưa transcript cho model và hỏi tại sao — nhanh hơn tự mò.
6. Rộng trước, hẹp sau. Agent có xu hướng query rất cụ thể ngay từ đầu ("giá cổ phiếu NVDA ngày 15/3/2025 lúc mở cửa") và nhận về rỗng. Chuyên gia thật làm ngược: query ngắn, rộng, xem có gì, rồi mới thu hẹp. Phải dạy agent làm vậy.
7. Dẫn dắt quá trình suy nghĩ. Extended thinking (chế độ model suy nghĩ trước khi trả lời) là một scratchpad bạn điều khiển được. Lead dùng nó để lập kế hoạch. Subagent dùng interleaved thinking — suy nghĩ xen kẽ sau mỗi tool result — để đánh giá: kết quả này có tốt không, thiếu gì, query tiếp nên là gì. Không có bước nghĩ này, agent chỉ nhận kết quả rồi gọi tiếp máy móc.
8. Gọi tool song song. Ban đầu lead spawn subagent tuần tự, subagent gọi tool tuần tự. Đổi sang: lead spawn 3-5 subagent cùng lúc, mỗi subagent gọi 3+ tool cùng lúc. Chỉ thay đổi này giảm thời gian tới 90%.
Phần 4 — Đo chất lượng agent thế nào
Output research là văn tự do — không thể so sánh chuỗi. Họ làm thế này:
Bắt đầu nhỏ. Khoảng 20 câu hỏi đại diện là đủ để thấy khác biệt lớn khi sửa prompt (họ thấy nhảy từ 30% lên 80% chỉ với 20 case). Đừng chờ có 1000 case mới bắt đầu đo.
LLM-as-judge. Một model đọc output và chấm theo rubric: đúng sự thật không (factual accuracy), trích dẫn khớp nguồn không (citation accuracy), đủ ý không (completeness), nguồn có uy tín không (source quality), dùng tool hiệu quả không (tool efficiency). Mỗi tiêu chí 0-1, tổng hợp pass/fail. Họ thấy một call chấm tất cả tiêu chí ổn định hơn nhiều call chấm từng tiêu chí.
End-state evaluation. Agent có nhiều đường đi đúng đến cùng một kết quả. Nếu bạn chấm "có đi đúng 7 bước như tôi nghĩ không", bạn phạt oan những đường đi khác nhưng đúng. Thay vào đó chấm trạng thái cuối: kết quả có đúng không. Task phức tạp thì chia thành các checkpoint — tại mốc này state phải như thế này — và chấm từng mốc.
Người vẫn phải test. Eval tự động bỏ sót những thứ tinh tế: agent hallucinate ở câu hỏi lạ; agent thích nguồn tối ưu SEO (content farm) hơn nguồn học thuật vì chúng lên đầu kết quả tìm kiếm. Chỉ người đọc mới thấy. Thấy rồi thì thêm heuristic vào prompt.
Bài 7 sẽ đi sâu hơn về eval.
Phần 5 — Bài học production
Đây là phần khác biệt so với demo.
Agent có state, lỗi cộng dồn. Agent chạy 10 phút, gọi 100 tool. Tool thứ 87 lỗi. Nếu restart từ đầu: tốn 10 phút nữa, user bực. Cần durable execution: lưu checkpoint, resume từ chỗ lỗi. Và một điều bất ngờ: cho agent biết tool đang lỗi rồi để nó tự thích nghi (thử tool khác, đổi cách) — "works surprisingly well".
Debug cần cách mới. Cùng prompt, hai lần chạy khác nhau — vì model không xác định. Không thể "reproduce bug" kiểu cũ. Họ dùng full tracing production: mọi quyết định, mọi tool call, mọi kết quả được ghi. Từ đó mới biết agent fail vì query tệ, chọn nguồn tệ, hay tool lỗi. Quan trọng: quan sát mẫu quyết định ở mức cao (spawn bao nhiêu, gọi gì, theo thứ tự nào) mà không cần đọc nội dung hội thoại — vừa đủ debug, vừa bảo vệ riêng tư.
Đồng bộ là nút thắt. Lead hiện phải chờ tất cả subagent xong mới đi tiếp. Subagent chậm nhất quyết định thời gian. Và lead không thể điều chỉnh giữa chừng khi một subagent đi lạc. Async giải quyết được, nhưng đổi lại: phối hợp kết quả về không theo thứ tự, giữ state nhất quán, lan truyền lỗi — khó hơn nhiều.
Rainbow deployment. Agent đang chạy giữa chừng thì bạn deploy bản mới → nó chết hoặc hành xử lẫn lộn. Giải pháp: chạy song song bản cũ và mới, chuyển traffic dần, để agent đang chạy được kết thúc trên bản cũ.
Phần 6 — Khi nào KHÔNG dùng multi-agent
Phần này quan trọng không kém phần "vì sao dùng":
- Mọi agent cần cùng một context — phối hợp chặt, chia ra chỉ tốn công đồng bộ.
- Nhiều phụ thuộc giữa các phần — hầu hết việc coding rơi vào đây: sửa file A ảnh hưởng file B, không song song được. Đó là lý do Claude Code dùng subagent cho tìm kiếm và nghiên cứu chứ không cho sửa 5 file liên quan nhau.
- Cần phối hợp thời gian thực giữa các agent — model hiện chưa giỏi việc này.
- Giá trị task không bù nổi 15x token.
Áp vào việc của bạn
- Config của bạn có rule "không dùng Agent tool trừ khi được yêu cầu". Giờ bạn biết lý do: coding ít song song được, và mỗi lần spawn là nhân chi phí.
- Khi bạn có dùng
Agent tool hoặc cavecrew: áp nguyên tắc 2. "Tìm chỗ X" là giao việc tệ. "Trả về bảng file:line của mọi chỗ gọi hàm X, tối đa 20 dòng, không đề xuất sửa" là giao việc tốt.
- Nguyên tắc 3 chính là điều CLAUDE.md của bạn đang làm với "task nhỏ → ponytail, task lớn → superpowers": scale quy trình theo độ khó.
Tự kiểm tra
- Vì sao multi-agent với Sonnet làm worker lại thắng Opus đơn? Yếu tố nào quyết định?
- Nêu 3 loại task không nên dùng multi-agent và lý do.
- End-state evaluation là gì? Nó giải quyết vấn đề gì của cách chấm từng bước?
- Interleaved thinking dùng ở đâu trong subagent, để làm gì?
Bài tập
- Viết prompt giao việc cho một subagent: "audit mọi endpoint trong notify-service không có guard" theo nguyên tắc 2 — mục tiêu, tool được dùng, format trả về, giới hạn độ dài.
- Viết rubric LLM-as-judge 5 tiêu chí cho output "tóm tắt kiến trúc một service". Mỗi tiêu chí mô tả rõ thế nào là 0, 0.5, 1 điểm.
- Lấy một task bạn hay giao Claude Code. Chấm theo phần 6: có nên chia subagent không? Vì sao?
AI 05 · 12-factor agents
Bài 5 — 12-Factor Agents: đưa agent lên production
Nguồn: humanlayer, "12-Factor Agents — Principles for building LLM-powered software that's actually good enough to put in the hands of production customers".
https://github.com/humanlayer/12-factor-agents
Tại sao có bài này
Tên bài mượn từ "12-factor app" — bộ nguyên tắc nổi tiếng cho ứng dụng cloud (config qua env, stateless process, log ra stdout...). Tác giả, Dex Horthy, phỏng vấn hàng chục team xây agent và thấy một mẫu lặp lại: dùng framework (LangChain, CrewAI, AutoGen...) thì demo lên rất nhanh, đến 70-80% chất lượng rồi kẹt. Muốn đi tiếp phải mổ framework ra, và cuối cùng viết lại từ đầu.
Luận điểm của ông: sản phẩm AI tốt là phần mềm bình thường, có vài chỗ nhét LLM vào một cách có kiểm soát. Đừng "xây agent", hãy đưa vài khái niệm agent vào phần mềm bạn đang có. Bài này liệt kê 12 khái niệm đó.
Với bạn — người viết NestJS, BullMQ, state machine hàng ngày — bài này sẽ rất quen. Bạn sẽ thấy agent production chẳng khác gì một job processor có một bước gọi LLM.
12 nguyên tắc
Factor 1 — Natural language to tool calls
Việc cốt lõi mà LLM làm tốt nhất: đọc một câu tiếng người và trả về JSON có cấu trúc mô tả ý định. "Tạo link thanh toán $750 cho Terri cho buổi meetup tháng 2" → { "function": "create_payment_link", "amount": 750, "customer": "cust_128934ddasf9", "memo": "..." }. Rồi code của bạn quyết làm gì với JSON đó.
Đây là nguyên tử. Mọi thứ khác xây trên nó. Nếu bạn chỉ nhớ một điều từ bài này: LLM là bộ chuyển đổi "tiếng người → cấu trúc", không phải bộ thực thi.
Factor 2 — Own your prompts
Framework thường sinh prompt cho bạn từ vài tham số. Tiện lúc đầu, tai hại về sau: khi cần chỉnh một câu, bạn không chỉnh được. Prompt là thứ quan trọng nhất trong hệ thống — hãy viết tay, đặt trong repo, version control, review như code. Bạn phải đọc được từng token gửi lên model.
Factor 3 — Own your context window
Đừng mặc định dùng format [system, user, assistant, tool_call, tool_result...] chuẩn nếu nó không tối ưu cho việc của bạn. Bạn được tự do serialize lịch sử theo cách model đọc tốt nhất: XML có thẻ rõ ràng, danh sách sự kiện có timestamp, tóm tắt thay vì nguyên văn. Mọi thứ đi vào context là quyết định của bạn. (Bài 2 là mở rộng của factor này.)
Factor 4 — Tools are just structured outputs
Đừng thần thánh hóa "tool calling". Nó chỉ là: model trả JSON, code bạn switch (json.intent). Nhìn vậy thì bạn xử lý tool call bằng mọi kỹ thuật code thường: validate schema, retry, log, test. Không cần abstraction đặc biệt nào của framework.
Factor 5 — Unify execution state and business state
Đừng giữ hai loại state riêng: "agent đang ở bước mấy" và "đơn hàng đang trạng thái gì". Gộp thành một luồng sự kiện. Mọi thứ agent làm — gọi tool, nhận kết quả, hỏi người — là một event trong lịch sử của đối tượng nghiệp vụ. Khi đó: debug là đọc log, resume là nạp lại log, replay là chạy lại log. Không có "state agent bị lệch với state DB".
Factor 6 — Launch / pause / resume với API đơn giản
Agent phải: khởi động được từ một API call, tạm dừng khi cần chờ (người duyệt, tool chạy lâu, webhook về), và tiếp tục từ đúng chỗ dừng bằng một API call khác. Không phải "chạy trong một process cho tới khi xong" — vì process sẽ chết, deploy sẽ xảy ra, người duyệt sẽ mất 2 ngày mới trả lời.
Factor 7 — Contact humans with tool calls
Cần hỏi người? Đó là một tool call: request_human_input({ question, urgency, format }) hoặc request_approval({ action, reason }). Model trả JSON, hệ thống gửi Slack/email, agent tạm dừng (factor 6), người trả lời, agent tiếp tục với câu trả lời như một tool result. Không phải kiểu "nếu output kết thúc bằng dấu hỏi thì chắc nó đang hỏi người".
Bạn đã thấy: AskUserQuestion trong Claude Code chính là factor này.
Factor 8 — Own your control flow
Vòng lặp while (true) { llm(); tool(); } là của bạn, viết tay, khoảng 30 dòng. Vì nó của bạn nên bạn chèn được: break khi cần duyệt, giới hạn số vòng, rate limit, log từng bước, chờ event ngoài, tóm tắt context khi dài. Framework giấu vòng lặp này → bạn không chèn được gì.
Factor 9 — Compact errors into context window
Tool lỗi → đưa lỗi vào context ngắn gọn để model tự sửa ("file không tồn tại, thử đường dẫn khác"). Đây là sức mạnh: agent tự phục hồi. Nhưng phải có ngưỡng: 3 lỗi liên tiếp → thoát vòng lặp, báo người. Không có ngưỡng, agent lặp vô hạn với 50 stack trace trong context, đốt tiền và không đi đâu.
Factor 10 — Small, focused agents
Agent 3-10 bước, tối đa 20. Vì context ngắn → model ổn định, dễ test, dễ đoán. Task lớn = nhiều agent nhỏ ghép bằng code thường (chính là workflow trong bài 1). Khi model tốt hơn, bạn nới dần giới hạn. Đừng bắt đầu bằng một agent 200 bước rồi hy vọng.
Factor 11 — Trigger from anywhere, meet users where they are
Agent không chỉ sống trong chat box. Kích hoạt từ webhook, cron, Slack, email, một event trong queue. Và trả kết quả về đúng kênh người dùng đang ở — họ hỏi trong Slack thì trả lời trong Slack.
Factor 12 — Make your agent a stateless reducer
Đây là factor gói tất cả: agent là hàm thuần (state, event) → newState. Nhận thread hiện tại (toàn bộ lịch sử), nhận event mới (user nhắn, tool trả về, người duyệt), trả ra bước tiếp theo. Không giữ gì trong RAM giữa các lần gọi. Nhờ vậy: test được bằng cách feed state giả, scale ngang được (instance nào cũng xử lý được), resume được (nạp state từ DB rồi gọi).
Nếu bạn từng viết Redux hoặc state machine, đây chính xác là nó.
Factor 13 (bonus) — Pre-fetch context you might need
Nếu 90% trường hợp agent sẽ gọi get_shop_info đầu tiên, đừng để nó gọi — code gọi sẵn và nhét vào context trước khi gọi model. Bớt một vòng lặp, bớt độ trễ, bớt cơ hội model gọi sai. Cân bằng với just-in-time ở bài 2: nạp sẵn thứ chắc chắn cần, để model tự lấy thứ có thể cần.
Bức tranh ghép lại
[event vào: webhook / cron / user message] (factor 11)
→ nạp thread (state) từ DB (5, 12)
→ prefetch context chắc chắn cần (13)
→ serialize thread thành prompt (tự viết) (2, 3)
→ gọi LLM → nhận JSON { intent, args } (1, 4)
→ switch (intent):
tool thường → chạy, append kết quả, lặp (8)
lỗi → append lỗi ngắn, đếm; quá 3 → thoát (9)
hỏi người → lưu thread, gửi Slack, return (6, 7)
done → trả kết quả về đúng kênh (11)
→ tổng số bước ≤ 10-20 (10)
Nhìn kỹ: đây là BullMQ processor + Prisma + state machine. Bạn viết cái này hàng tuần ở notify-service. Agent production = job processor có một bước là gọi LLM. Không có phép màu.
Áp vào việc của bạn
notify-service có sẵn mọi mảnh:
- BullMQ = launch/pause/resume, trigger từ SQS/cron, stateless worker (factor 6, 11, 12)
- Prisma = thread store, gộp state (factor 5)
- ErrorWebhookService = kênh báo người (factor 7, 9)
Ví dụ cụ thể — "agent xử lý ticket Gorgias" là một processor trong GORGIAS_WEBHOOK_QUEUE:
1. Job vào, nạp ticket + lịch sử khách + đơn gần nhất (factor 13).
2. Gọi LLM, nhận { intent: "reply" | "escalate" | "need_order_info", args } (factor 1, 4).
3. reply → gửi qua Gorgias API, done. need_order_info → gọi tool tra đơn, append, gọi LLM lại (factor 8). escalate → gửi Slack, job kết thúc với state waiting_human (factor 6, 7).
4. Người trả lời trong Slack → webhook → job mới với thread cũ + câu trả lời → tiếp tục (factor 12).
5. Mỗi bước ghi vào bảng SupportThreadEvent (factor 5). Quá 8 bước → escalate bắt buộc (factor 10).
Khi đọc source Hermes (bài 8), hãy chấm nó theo 12 factor: nó own control flow không? state lưu đâu? lỗi xử lý thế nào?
Tự kiểm tra
- "LLM là bộ chuyển đổi, không phải bộ thực thi" — giải thích bằng factor 1 và 4.
- Vì sao gộp execution state và business state (factor 5) giúp debug và resume?
- Factor 12 nói agent là stateless reducer. Điều đó cho phép gì mà agent giữ state trong RAM không làm được?
- Factor 9 có hai vế. Vế nào hay bị quên và hậu quả là gì?
Bài tập
- Vẽ state machine cho "agent xử lý ticket Gorgias" trên notify-service: các state, event chuyển state, chỗ gọi LLM, chỗ hỏi người, chỗ thoát.
- Viết hàm
reduce(thread, event) giả bằng TypeScript (không gọi API thật, mock LLM trả JSON cố định), 50-80 dòng, kèm 3 test: đường reply, đường tool, đường escalate.
- Chấm Claude Code theo 12 factor: factor nào thấy rõ (ví dụ 7 =
AskUserQuestion, 9 = lỗi tool vào context), factor nào không thấy hoặc không áp dụng.
AI 06 · Claude Agent SDK
Bài 6 — Claude Agent SDK: tự dựng mini Claude Code bằng TypeScript
Nguồn: Claude Agent SDK docs — overview, quickstart, TypeScript reference.
https://code.claude.com/docs/en/agent-sdk/overview
Tại sao có bài này
Năm bài trước là lý thuyết. Bài này là lúc bạn tự cầm cái agent loop trong tay. Mục tiêu cụ thể: kết thúc bài, bạn có một agent TypeScript chạy trên notify-service, có tool riêng đọc Prisma, có log mọi tool call, và bạn hiểu từng option cấu hình nó. Sau bài này, khi mở Claude Code, bạn nhìn nó như "SDK này cộng một cái UI terminal" — không hơn.
Bài này dài vì có code. Đọc từng phần, gõ theo, chạy, rồi mới sang phần tiếp.
Phần 1 — Agent SDK là gì, và chọn nó khi nào
Claude Agent SDK là Claude Code đóng gói thành thư viện (Python và TypeScript). Bạn nhận nguyên: agent loop, built-in tools (Read, Edit, Bash, Glob, Grep, WebSearch), quản lý context và compaction tự động, subagent, hooks, hệ thống permission, session, MCP, và cả cơ chế nạp skill/CLAUDE.md từ thư mục .claude/.
Anthropic có 4 công cụ, dễ nhầm. Chọn theo bảng này:
| Bạn muốn |
Dùng |
Vì sao |
| Xây agent, không muốn tự viết loop |
Agent SDK |
Loop, tool, context có sẵn; bạn chỉ cấu hình và đọc stream |
| Làm việc tương tác ở terminal |
Claude Code CLI |
Giao diện cho người dùng hàng ngày |
| Gọi API thẳng, tự viết loop |
Client SDK (@anthropic-ai/sdk) |
Kiểm soát tuyệt đối, hiểu sâu; nhưng tự lo mọi thứ |
| Agent chạy lâu, không muốn quản sandbox/session |
Managed Agents |
Anthropic host và chạy giúp |
Lời khuyên của tôi: làm cả hai đường. Viết loop tay bằng Client SDK một lần (bài 5, bài tập 2 là bước chuẩn bị) để hiểu ruột gan. Rồi dùng Agent SDK cho việc thật, vì bạn không muốn tự maintain compaction, permission, subagent.
Một lưu ý về giấy phép: sản phẩm bạn xây không được gọi là "Claude Code", và không được dùng claude.ai login cho user — phải dùng API key.
Phần 2 — Cài đặt
mkdir notify-agent && cd notify-agent
npm init -y
npm pkg set type=module # để dùng top-level await
npm install @anthropic-ai/claude-agent-sdk
npm install --save-dev tsx # chạy .ts trực tiếp
export ANTHROPIC_API_KEY=sk-ant-...
Hai điều cần biết:
- SDK bundle sẵn binary Claude Code qua npm optional dependency. Nếu bạn npm ci --omit=optional, sẽ không có binary → lỗi. Cài bình thường là được.
- SDK không đọc .env tự động. Nó chỉ đọc process.env. Muốn dùng .env, tự load bằng dotenv trước khi gọi SDK — giống hệt bài học về thứ tự nạp env trong main.ts của notify-service.
Phần 3 — Agent đầu tiên: đọc từng dòng
Code quickstart nguyên bản của Anthropic (tạo file utils.py có 2 bug: chia cho 0 khi list rỗng, và .upper() trên None):
import { query } from "@anthropic-ai/claude-agent-sdk";
// Agentic loop: streams messages as Claude works
for await (const message of query({
prompt: "Review utils.py for bugs that would cause crashes. Fix any issues you find.",
options: {
allowedTools: ["Read", "Edit", "Glob"], // Auto-approve these tools
permissionMode: "acceptEdits" // Auto-approve file edits
}
})) {
// Print human-readable output
if (message.type === "assistant" && message.message?.content) {
for (const block of message.message.content) {
if ("text" in block) {
console.log(block.text); // Claude's reasoning
} else if ("name" in block) {
console.log(`Tool: ${block.name}`); // Tool being called
}
}
} else if (message.type === "result") {
console.log(`Done: ${message.subtype}`); // Final result
}
}
Chạy: npx tsx agent.ts. Bạn sẽ thấy nó in suy nghĩ, in Tool: Read, Tool: Edit, rồi Done: success. Mở utils.py, bug đã được sửa.
Giờ hiểu từng phần:
query() — điểm vào duy nhất. Nó tạo agent loop và trả về một async iterator. Đây chính là vòng while ở bài 5, nhưng SDK viết giúp. Mỗi vòng lặp yield một message: text model nói, một tool call, một tool result, hoặc kết quả cuối.
prompt — việc cần làm. Bạn không nói "đọc file rồi sửa"; bạn nói mục tiêu, model tự chọn tool. Đó là định nghĩa agent (bài 1, phần 4).
options — cấu hình hành vi. Hai option trong ví dụ:
- allowedTools: ["Read", "Edit", "Glob"] — ba tool này được tự chạy không hỏi. Tool ngoài danh sách (như Bash) sẽ bị hỏi hoặc chặn tùy permission mode.
- permissionMode: "acceptEdits" — tự duyệt sửa file. Không có dòng này, mỗi lần Edit sẽ chờ bạn xác nhận.
Vòng for await — chạy tới khi model xong hoặc lỗi. Bạn chỉ đọc stream. SDK lo: gọi model, chạy tool, nối kết quả vào context, retry khi rate limit, compaction khi context dài.
Điểm quan trọng: đoạn if bên trong chỉ để lọc cho dễ đọc. Nếu bỏ lọc, bạn thấy cả message system (init, thông tin session), tool result thô, usage token — rất hữu ích để học, nhiễu khi dùng thật. Bài tập 1 sẽ bắt bạn xem bản không lọc.
Phần 4 — Các option quan trọng
Đây là bảng bạn sẽ tra lại nhiều lần. Mỗi option tương ứng một khái niệm ở bài trước.
| Option |
Ý nghĩa |
Liên hệ |
allowedTools |
Danh sách tool tự chạy không hỏi. ["Read","Glob","Grep"] = chỉ đọc; thêm Edit = được sửa; thêm Bash = toàn quyền |
Bài 1: giới hạn agent |
permissionMode |
default (hỏi khi tool không trong allowlist), acceptEdits (tự duyệt sửa file), bypassPermissions (không hỏi gì — chỉ dùng trong sandbox) |
Bài 5 factor 7 |
canUseTool |
Callback của bạn: nhận tên tool + input, trả cho phép/từ chối. Đây là chỗ chèn logic riêng (chặn rm, chặn ghi ngoài thư mục) |
Bài 5 factor 8 |
systemPrompt |
Chuỗi thay toàn bộ, hoặc { type: "preset", preset: "claude_code", append: "..." } để giữ prompt Claude Code và thêm của bạn |
Bài 2 phần 2 |
mcpServers |
Gắn MCP server — đây là cách thêm tool của bạn |
Bài 3, bài 8 |
agents |
Định nghĩa subagent: { tên: { description, prompt, tools, model } }. Lead tự quyết khi nào giao việc |
Bài 4 |
hooks |
Chạy code của bạn tại mốc lifecycle: PreToolUse, PostToolUse, v.v. — để audit, chặn, log |
Bài 4 phần 5 (tracing) |
cwd |
Thư mục agent được đụng. Built-in tool chỉ hoạt động trong đây |
Sandbox |
maxTurns |
Giới hạn số vòng lặp |
Bài 5 factor 10 |
resume |
Tiếp tục session cũ theo ID, hoặc fork |
Bài 5 factor 6 |
Về tool riêng: SDK có helper createSdkMcpServer và tool() để định nghĩa tool in-process (chạy trong cùng process Node, không cần spawn server riêng). Schema tham số viết bằng zod. Tool sẽ có tên dạng mcp__<server>__<tool> khi khai vào allowedTools.
Phần 5 — Project: notify-agent
Mục tiêu: agent trả lời câu hỏi vận hành về notify-service. Chỉ đọc, có tool truy vấn Prisma, có log.
Bước 1 — Định nghĩa tool in-process
// tools.ts
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import { prisma } from "./prisma.js"; // PrismaClient trỏ vào DB dev
export const notifyTools = createSdkMcpServer({
name: "notify",
tools: [
tool(
"notify_history_summary",
// Mô tả là prompt (bài 3): làm gì, khi nào dùng, khi nào không
"Đếm push notification theo trạng thái (SENT/FAILED/...) cho một shop trong khoảng ngày. " +
"Dùng khi hỏi 'shop X gửi bao nhiêu', 'tỷ lệ fail'. " +
"Không dùng để lấy nội dung notification — dùng notify_notifications_search cho việc đó.",
{
shopDomain: z.string().describe("Ví dụ: demo.myshopify.com"),
from: z.string().describe("ISO date, ví dụ 2026-09-01"),
to: z.string().describe("ISO date, ví dụ 2026-09-17"),
},
async ({ shopDomain, from, to }) => {
const rows = await prisma.notificationHistory.groupBy({
by: ["status"],
where: { shopDomain, createdAt: { gte: new Date(from), lte: new Date(to) } },
_count: true,
});
// Output có nghĩa, không thô (bài 3, nguyên tắc 3.3)
const text = rows.map(r => `${r.status}: ${r._count}`).join("\n") || "Không có bản ghi.";
return { content: [{ type: "text", text }] };
},
),
],
});
Trước khi chạy: mở prisma/schema.prisma của notify-service, kiểm tra tên bảng và trường thật (NotificationHistory có shopDomain? có status? tên trường ngày là gì?). Code trên là minh họa cấu trúc, không phải copy được ngay.
Bước 2 — Chạy agent với tool đó
// agent.ts
import { query } from "@anthropic-ai/claude-agent-sdk";
import { notifyTools } from "./tools.js";
for await (const m of query({
prompt: "Shop demo.myshopify.com tuần này gửi bao nhiêu push, tỷ lệ fail bao nhiêu?",
options: {
mcpServers: { notify: notifyTools },
allowedTools: ["mcp__notify__notify_history_summary", "Read", "Grep", "Glob"],
permissionMode: "default",
cwd: "/Users/khanhlc/Documents/Company/shopify/notify-service",
maxTurns: 10,
systemPrompt: {
type: "preset",
preset: "claude_code",
append: "Bạn là trợ lý vận hành notify-service. Chỉ đọc. Không sửa file, không chạy lệnh ghi. " +
"Khi thiếu dữ liệu, nói rõ thiếu gì thay vì đoán.",
},
},
})) {
// in như phần 3
}
Chạy và quan sát: model có gọi đúng tool không? Có tự tính "tuần này" thành from/to không? Nếu nó gọi sai — sửa mô tả tool, không sửa code tool. Đó là bài 3 trong thực tế.
Bước 3 — Hook ghi log mọi tool call
Thêm vào options:
hooks: {
PostToolUse: [{
hooks: [async (input) => {
await fs.appendFile("trace.jsonl", JSON.stringify({
t: Date.now(), tool: input.tool_name, args: input.tool_input,
}) + "\n");
return {};
}],
}],
},
File trace.jsonl này là trace — thứ bài 4 và bài 7 nói là bắt buộc để debug và eval. Bạn vừa có nó với 6 dòng code.
Bước 4 — Thêm subagent
agents: {
"code-reader": {
description: "Đọc source notify-service để trả lời câu hỏi về cách code hoạt động. Dùng khi câu hỏi về logic, không phải về số liệu.",
prompt: "Bạn đọc code TypeScript. Trả lời ngắn, kèm file:line. Không đề xuất sửa.",
tools: ["Read", "Grep", "Glob"],
},
},
Hỏi một câu vừa cần số liệu vừa cần hiểu code ("vì sao push fail nhiều, logic retry ở đâu?"). Xem lead có tự giao phần code cho subagent không, và subagent trả về gì. Đó là bài 4 thu nhỏ.
Bước 5 — Eval
Viết 10 câu hỏi vận hành, mỗi câu tính sẵn đáp án bằng SQL thẳng vào DB. Chạy agent, so đáp án. Ghi bảng: câu nào đúng, câu nào gọi sai tool, câu nào bịa. Bài 7 sẽ dạy làm việc này bài bản; ở đây chỉ cần bảng thủ công.
Phần 6 — Đọc thêm trong docs, theo thứ tự
- Agent loop — Claude lập kế hoạch, gọi tool, quyết "xong" thế nào; giải thích từng permission mode.
- Permissions — thứ tự đánh giá allow/deny rule khi có nhiều rule mâu thuẫn.
- Hooks — đủ các mốc lifecycle và payload mỗi mốc.
- Sessions — multi-turn, resume, fork.
- MCP — gắn server ngoài (bài 8 sẽ viết một cái).
- Hosting — chạy trong Docker, CI.
- Example agents: https://github.com/anthropics/claude-agent-sdk-demos — email assistant, research agent.
Tự kiểm tra
query() trả về gì, và vì sao lại là async iterator chứ không phải Promise?
allowedTools và permissionMode phối hợp thế nào? Nếu Bash không trong allowedTools và mode là default, chuyện gì xảy ra?
- Vì sao tool riêng phải khai qua
mcpServers chứ không phải truyền hàm thẳng?
- Hook
PostToolUse cho bạn cái gì mà bài 4 nói là bắt buộc ở production?
Bài tập
- Chạy quickstart nguyên bản. Bỏ hết
if lọc, in toàn bộ message stream ra. Đọc và ghi lại: có những type nào, message system chứa gì, usage token nằm ở đâu.
- Làm project phần 5 tới hết bước 3. Commit vào một repo riêng.
- Đổi
permissionMode thành default, implement canUseTool: từ chối mọi Bash có chứa rm hoặc git push, cho phép còn lại. Test bằng prompt cố tình yêu cầu xóa file — agent phải báo bị chặn chứ không crash.
AI 07 · Evals
Bài 7 — Evals: đo được mới sửa được
Nguồn:
- Hamel Husain, "Your AI Product Needs Evals". https://hamel.dev/blog/posts/evals/
- Eugene Yan, "Patterns for Building LLM-based Systems & Products". https://eugeneyan.com/writing/llm-patterns/
- anthropics/courses — khóa "Prompt Evaluations" và "Tool Use". https://github.com/anthropics/courses
Tại sao bài này là bài quan trọng nhất
Hãy nhớ lại cách bạn sửa prompt hiện nay: chạy, thấy chưa ưng, sửa vài chữ, chạy lại, "hình như tốt hơn". Rồi tuần sau gặp case khác lại sửa, và không biết lần sửa này có làm hỏng case tuần trước không.
Đó là làm việc không có eval. Hamel Husain — người đã tư vấn cho nhiều team AI — nói thẳng: sản phẩm AI thành công cần ba việc quay vòng liên tục: evaluate → debug → modify. Sửa mà không đo là đoán. Và ông chỉ ra sai lầm phổ biến nhất: tập trung hết vào prompt engineering, bỏ qua hai việc kia.
Với bạn — người viết Jest test cho notify-service — bài này sẽ rất tự nhiên: eval chính là test, chỉ khác là đối tượng test không xác định.
Phần A — Hệ thống eval ba tầng (Hamel Husain)
Tầng 1 — Unit test và assertion
Đây là kiểm tra bằng code trên output của LLM. Rẻ, chạy trong vài giây, chạy mỗi lần đổi prompt hoặc code, giống unit test thường.
Kiểm tra gì? Những thứ code kiểm tra được:
- Output có chứa/không chứa chuỗi nào đó: không lộ UUID nội bộ ra user, không có "As an AI language model".
- Output đúng schema JSON, đủ trường bắt buộc.
- Số kết quả đúng: hỏi "tìm listing giá dưới $500k" thì phải trả về đúng số listing thỏa điều kiện.
- Tool call đúng tên, đúng tham số.
Ví dụ thật từ Rechat (sản phẩm Lucy — trợ lý bất động sản): tính năng "tìm listing" có 3 kịch bản — một kết quả, nhiều kết quả, không có kết quả. Mỗi kịch bản một assertion về số lượng trả về. Đơn giản vậy thôi, nhưng bắt được đa số regression.
Cách tạo test case: dùng LLM sinh. "Sinh 20 câu user có thể hỏi về listing, đa dạng cách diễn đạt." Rồi người lọc và gắn đáp án.
Sai lầm: nhảy qua tầng này để làm tầng 2 "xịn hơn". Không. Tầng 1 là nền.
Tầng 2 — Đánh giá bởi người và bởi model
Có những thứ code không kiểm tra được: câu trả lời có lịch sự không, có đúng giọng brand không, có hữu ích không. Cần người đánh giá. Và để làm được, cần log trace — bản ghi đầy đủ input, output, mọi tool call của một lượt.
Quy trình:
1. Người đọc trace, chấm tốt/xấu, ghi lý do.
2. Khi có vài trăm mẫu đã chấm, viết một prompt cho model mạnh (Opus) chấm thay người — gọi là LLM-as-judge.
3. Căn chỉnh: người và judge cùng chấm một bộ mẫu độc lập, đo mức đồng thuận. Chưa cao → sửa prompt judge → đo lại. Cao rồi → judge chạy thay người ở quy mô lớn.
Một cạm bẫy Hamel nhấn mạnh: nếu 90% mẫu là "tốt", một judge luôn nói "tốt" sẽ có accuracy 90% — vô nghĩa. Với lớp lệch, phải nhìn precision và recall riêng cho lớp "xấu": judge bắt được bao nhiêu mẫu xấu thật (recall), và trong số nó nói xấu có bao nhiêu xấu thật (precision).
Rechat làm gì để người chịu chấm nhiều? Họ dựng một màn hình bằng Shiny: trace, thông tin CRM liên quan, loại tool, loại kịch bản, bộ lọc, nút chấm, ô sửa — tất cả trên một trang. Không phải chuyển qua lại 4 hệ thống. Bài học: bỏ ma sát thì người mới chịu nhìn data, và chỉ khi nhìn đủ nhiều mới thấy lỗi thật. Không cần platform đắt tiền; một trang HTML là đủ.
Tầng 3 — A/B test
Khi sản phẩm đã có user thật, đo hành vi thật: user có dùng tiếp không, có click không, có phàn nàn không. Tầng này chỉ dành cho sản phẩm trưởng thành; đừng bắt đầu ở đây.
Vòng thiện
Có eval → sửa prompt nhanh và tự tin → có bộ data đã chấm → sinh synthetic data rồi lọc qua eval → fine-tune (nếu cần) gần như miễn phí. Eval là hạ tầng mở khóa mọi thứ khác.
Phần B — Bảy pattern hệ thống LLM (Eugene Yan)
Eugene Yan (Amazon) tổng hợp 7 pattern xuất hiện lặp lại trong hệ thống LLM production. Eval là pattern số 1; sáu pattern còn lại là bức tranh bạn cần biết để đặt eval vào đúng chỗ.
1. Evals. Như phần A. Thêm một điểm: các metric cổ điển như BLEU, ROUGE (so trùng từ) gần như vô dụng cho task mở — không tương quan với đánh giá người. Task đóng (phân loại) dùng precision/recall. Task mở dùng LLM-judge. Ông gọi cách làm việc đúng là "eval-driven development".
2. RAG — Retrieval-Augmented Generation. Lấy dữ liệu ngoài, nhét vào prompt trước khi model trả lời. Giảm bịa, rẻ hơn train lại, cập nhật được. Ba lưu ý thực chiến: (a) dùng hybrid search — kết hợp BM25 (khớp từ khóa) với embedding (khớp nghĩa) — vì embedding rất tệ với tên riêng, mã sản phẩm, ID; (b) chất lượng retrieval quyết định chất lượng câu trả lời — tìm sai thì model trả lời sai dù giỏi đến đâu; (c) lọc bằng metadata (shop, ngày, loại) trước khi tìm nghĩa.
3. Fine-tuning. Train thêm model trên data của bạn. Cần nhiều data gắn nhãn, có "alignment tax" (giỏi việc này, kém việc khác), tốn công. Để cuối cùng, chỉ khi prompt + RAG đã hết đường.
4. Caching. Cache theo input có cấu trúc (product ID, tham số) chứ đừng theo "câu hỏi giống giống" — semantic cache dễ trả nhầm câu trả lời của câu hỏi khác. Chỉ đáng khi request thực sự lặp.
5. Guardrails. Kiểm tra output: cú pháp (JSON đúng schema, URL hợp lệ, code chạy được), an toàn (từ ngữ, moderation), ngữ nghĩa (có bám nguồn không). Kiểm tra input chống prompt injection. Quá tay thì hệ thống vô dụng — cân bằng.
6. Defensive UX. Thừa nhận model sẽ sai và thiết kế cho điều đó: disclaimer, dễ bỏ qua gợi ý, có citation để user tự kiểm tra, dùng UI quen thuộc thay vì bắt chat. Chat UX đòi user nỗ lực cao → kỳ vọng cao → dễ thất vọng. Đôi khi một nút "gợi ý" tốt hơn một ô chat.
7. Collect feedback. Thumbs up/down, chấp nhận/từ chối gợi ý, thời gian đọc. Thành data flywheel: feedback → data chấm → eval tốt hơn → sản phẩm tốt hơn. Feedback thu mà không dùng thì user thấy và thôi không cho nữa.
Bảy pattern nối nhau: eval đo RAG và fine-tune có tốt hơn không; guardrail bảo vệ cache khỏi trả rác; UX tốt mới có feedback; feedback nuôi eval.
Phần C — anthropics/courses: học gì, bỏ gì
Repo có 5 khóa dạng Jupyter notebook Python, dùng Haiku cho rẻ. Repo đã archive (9/2026) nhưng đọc vẫn tốt. Với bạn, đáng học hai khóa:
Prompt Evaluations. Dạy viết eval production theo ba cách chấm: code-graded (exact match, regex, schema — tầng 1), model-graded (rubric — tầng 2), human-graded. Dạy dựng bộ test, chạy bằng Promptfoo. Đọc rồi port sang TypeScript + Jest — notify-service đã có Jest sẵn.
Tool Use. Dạy định nghĩa tool schema, vòng xử lý tool_use → chạy → tool_result, ép model dùng tool, tool nhiều bước. Đây chính là thứ Agent SDK giấu đi ở bài 6. Đọc để biết bên dưới có gì.
Bỏ qua: API Fundamentals và Prompt Engineering Tutorial — bạn đã qua tầng đó.
Phần D — Mẫu eval tối thiểu bằng Jest
Đây là tầng 1 có con số, bằng công cụ bạn đã có:
// evals/classify-ticket.eval.test.ts
import { classifyTicket } from "../src/ai/classify-ticket";
import cases from "./classify-ticket.cases.json"; // [{ input: string, expected: string }]
const CATEGORIES = ["refund", "shipping", "product", "other"];
describe("classifyTicket", () => {
const results: boolean[] = [];
for (const c of cases) {
it(c.input.slice(0, 60), async () => {
const out = await classifyTicket(c.input);
expect(CATEGORIES).toContain(out.category); // assertion: đúng schema
results.push(out.category === c.expected); // ghi đúng/sai để tính accuracy
});
}
afterAll(() => {
const acc = results.filter(Boolean).length / results.length;
console.log(`accuracy=${acc.toFixed(2)} n=${results.length}`);
expect(acc).toBeGreaterThanOrEqual(0.85); // ngưỡng: dưới là regression
});
});
Ba mươi ticket thật (đã xóa thông tin cá nhân) gắn nhãn tay. Chạy mỗi khi đổi prompt. Bạn có con số thay vì cảm giác. Tầng 2 thêm sau: log input/output vào một bảng, một trang HTML để đọc và chấm.
Áp vào việc của bạn
- Bạn đã có trace miễn phí: transcript Claude Code và Hermes. Mở 5 session gần nhất, đọc với tinh thần phần A2: agent đi vòng ở đâu? skill kích hoạt sai lúc nào? Đó là "nhìn data".
- Skill bạn viết chưa có eval. Viết 10 prompt mẫu, với mỗi prompt ghi "skill X phải được kích hoạt / không được kích hoạt". Chạy thủ công, ghi bảng. Đó là tầng 1 cho skill.
- Ở notify-service, mọi chỗ định thêm LLM (phân loại ticket, sinh copy) — viết eval trước khi viết prompt. TDD cho prompt, đúng như skill test-driven-development bắt bạn làm với code.
Tự kiểm tra
- Ba tầng eval khác nhau ở chi phí và ở cái gì đo được?
- Vì sao accuracy gây hiểu lầm khi lớp lệch? Thay bằng gì?
- "Căn chỉnh LLM-judge với người" nghĩa là làm gì cụ thể?
- Vì sao embedding search tệ với mã sản phẩm và tên riêng? Giải pháp?
Bài tập
- Lấy 30 ticket Gorgias thật (redact PII). Gắn nhãn tay. Viết
classifyTicket và eval Jest như phần D. Ghi accuracy của prompt v1.
- Sửa prompt 3 lần, ghi accuracy mỗi lần. Chọn bản tốt nhất bằng số.
- Viết LLM-judge chấm "copy push notification có đúng giọng brand không" với rubric 3 tiêu chí. Chấm 20 mẫu bằng người và bằng judge độc lập. Tính precision/recall của judge cho lớp "không đạt".
AI 08 · MCP & đọc source agent
Bài 8 — MCP server, đọc source agent thật, và sách tổng quan
Nguồn:
- MCP architecture. https://modelcontextprotocol.io/docs/learn/architecture
- Hermes Agent (NousResearch). https://github.com/NousResearch/hermes-agent
- Chip Huyen, "AI Engineering" (O'Reilly 2025). https://huyenchip.com/books/
Tại sao bài này khép lại lộ trình
Bảy bài trước cho bạn khái niệm và một agent chạy được. Bài này làm ba việc để bạn đứng vững một mình: (A) viết tool theo chuẩn mở để cắm vào bất kỳ agent nào, không chỉ của Anthropic; (B) đọc source một agent người khác viết và biết nó tốt hay tệ ở đâu; (C) biết cuốn sách nào để ghép mọi mảnh khi cần bức tranh lớn.
Phần A — MCP: viết một server thật
A1. MCP là gì và tại sao tồn tại
Trước MCP, mỗi AI app có cách riêng để gắn tool. Bạn viết tool cho Claude Desktop thì Cursor không dùng được. Model Context Protocol là chuẩn mở giải quyết việc đó: viết server một lần, Claude Code, Claude Desktop, Cursor, VS Code, ChatGPT đều cắm được. Anthropic ví nó như cổng USB-C cho AI.
Bạn đang dùng MCP mỗi ngày mà có thể chưa để ý: codegraph, mobile-mcp, figma, Claude Docs — tất cả là MCP server.
A2. Ba vai
- Host — ứng dụng AI: Claude Code, Claude Desktop, VS Code. Host quản lý nhiều kết nối.
- Client — một kết nối tới một server. Host tạo một client cho mỗi server. Client giữ kết nối, hỏi server có gì, chuyển tool call.
- Server — chương trình cung cấp context và tool. Có thể chạy local (host spawn nó) hoặc remote (trên server của bạn).
Ví dụ: VS Code (host) nối tới Sentry MCP (remote) và filesystem MCP (local) → VS Code tạo hai client, mỗi client một kết nối riêng.
A3. Hai tầng
Data layer — nội dung nói chuyện. Dùng JSON-RPC 2.0. Gồm: khám phá (server có gì), các primitive (tool, resource, prompt), thông báo thay đổi. Đây là phần bạn cần hiểu.
Transport layer — cách chuyển message. Hai loại:
- stdio — host spawn server như process con, nói qua stdin/stdout. Nhanh, local, không cần mạng. Đa số server bạn dùng là loại này.
- Streamable HTTP — HTTP POST cho client → server, SSE cho server → client khi cần stream. Dùng cho remote, xác thực bằng bearer token hoặc OAuth.
Cùng một data layer chạy trên cả hai transport. Bạn viết logic một lần.
A4. Các primitive
Server cung cấp cho client ba thứ:
- Tools — hàm model gọi được. Có tools/list (liệt kê) và tools/call (gọi). Đây là thứ quan trọng nhất và bài 3 đã dạy cách viết cho tốt.
- Resources — dữ liệu để đọc: file, schema DB, một record. resources/list, resources/read. Khác tool ở chỗ: resource là "cho xem", tool là "làm việc".
- Prompts — template tương tác có sẵn: system prompt, few-shot. prompts/list, prompts/get.
Client cung cấp ngược lại cho server:
- Elicitation — server cần hỏi user (xác nhận, nhập thêm) thì gửi yêu cầu, host hiện cho user, trả câu trả lời về.
- Sampling (server nhờ host gọi LLM) và Logging — đã deprecated từ bản spec 2026-07-28. Server cần LLM thì gọi thẳng API; cần log thì ghi stderr hoặc OpenTelemetry. Nhiều tài liệu cũ trên mạng vẫn nói hai thứ này còn — biết để không bị lạc.
A5. Luồng thật, nhìn bằng JSON
Hiểu luồng này thì mọi MCP server đều đọc được:
-
Khám phá. Client gửi server/discover, kèm version và capability của mình trong _meta. Server trả supportedVersions, và capabilities như { tools: { listChanged: true }, resources: {} } — nghĩa là "tôi có tool, có resource, và tôi sẽ báo khi tool thay đổi". Kết quả cache được (ttlMs).
-
Liệt kê tool. Client gửi tools/list. Server trả mảng, mỗi tool có name, title, description, inputSchema (JSON Schema). Host gom tool từ mọi server lại thành một danh sách, đưa cho model. Đây là lúc mô tả tool đi vào context (bài 3).
-
Gọi tool. Model quyết gọi → host tìm client đúng → gửi tools/call { name: "weather_current", arguments: { location: "San Francisco", units: "imperial" } }. Server trả content: [{ type: "text", text: "..." }]. Host nhét content vào context làm tool result. Chính là một vòng của agent loop bài 1.
-
Thông báo. Tool của server thay đổi → server bắn notifications/tools/list_changed (client phải đăng ký trước bằng subscriptions/listen). Client gọi tools/list lại. Không có id — notification không cần trả lời.
Một điểm thiết kế đáng chú ý: protocol stateless — mỗi request tự mang version và capability. Server không cần nhớ "client này đã handshake chưa". Giống REST hơn giống WebSocket.
A6. Project: notify-mcp
Server stdio bằng TypeScript cho notify-service. Áp bài 3: không phải 24 controller thành 24 tool, mà 4 tool theo việc người vận hành hay cần:
notify_shop_get(shopDomain)
→ tên shop, plan, đã cấu hình Firebase chưa, số device token active
notify_notifications_search(shopDomain, status?, from?, to?, limit = 20)
→ danh sách notification, mặc định 20, ghi rõ "showing N of M"
notify_history_summary(shopDomain, from, to)
→ đếm theo status
notify_queue_status()
→ waiting / active / failed của từng BullMQ queue
Thêm một resource: notify://schema trả nội dung prisma/schema.prisma — để agent hiểu cấu trúc data khi cần.
Dùng @modelcontextprotocol/sdk: McpServer, StdioServerTransport, schema bằng zod. Đăng ký vào Claude Code: claude mcp add notify -- node dist/server.js. Test bằng MCP Inspector trước (công cụ chính thức, mở UI web để gọi tool tay) — rẻ hơn debug qua Claude Code.
Với mỗi tool: mô tả có "khi nào dùng / khi nào không", output concise, lỗi kèm gợi ý gọi đúng — đúng checklist bài 3.
Khi xong, quay lại bài 6: thay tool in-process bằng server này qua mcpServers. Giờ cùng một tool chạy được ở Claude Code, ở agent của bạn, ở Cursor.
Phần B — Đọc source Hermes Agent
Bạn dùng Hermes hàng ngày. Đọc source nó là bài học sát nhất và miễn phí: mọi khái niệm 7 bài trước đều có mặt trong đó, dưới dạng code chạy thật.
Hermes là gì
Agent của Nous Research, tự gọi là "self-improving". Điểm khác biệt là learning loop: sau task phức tạp nó tự tạo skill; khi dùng skill nó tự sửa; nó tự nhắc mình ghi memory; nó search lại session cũ bằng FTS5; nó xây dựng mô hình về user qua nhiều session (Honcho). Ngoài ra: 40+ tool, 7 terminal backend (local, Docker, SSH, Modal, Daytona...), một gateway nhận Telegram/Discord/Slack/WhatsApp/Email về cùng một agent.
Đọc theo thứ tự, mỗi phần đối chiếu một bài
| Thư mục / file |
Đọc để hiểu gì |
Đối chiếu |
agent/ |
Vòng lặp chính: nhận message → gọi model → parse tool call → chạy → nối kết quả → lặp. Tìm điều kiện dừng và giới hạn số vòng |
Bài 1 phần 4, bài 5 factor 8 và 10 |
tools/ |
Registry tool, toolset cấu hình được, cách mô tả tool được lắp vào prompt, các terminal backend |
Bài 3 |
hermes_state_*.py |
State: messages, sessions, memory, FTS search, compression (chính là compaction), schema, repair |
Bài 2 phần 6, bài 5 factor 5 và 12 |
skills/ |
Skill là procedural memory. Cách agent tự tạo và sửa skill. Chuẩn agentskills.io |
Bài 2 note-taking |
gateway/ |
Trigger từ nhiều kênh về một backend |
Bài 5 factor 11 |
| Chỗ spawn subagent |
Cách tách context cho subagent, subagent trả gì về |
Bài 4 |
Bốn câu hỏi mang theo khi đọc
- System prompt của Hermes được lắp từ những mảnh nào, theo thứ tự nào? (bài 2 phần 2 — nó ở "độ cao" nào?)
- Khi tool lỗi, lỗi vào context dạng gì, có ngưỡng thoát không? (factor 9)
- Compression kích hoạt khi nào, giữ gì, bỏ gì? (bài 2 phần 6.1)
- Nó có own control flow không, hay dựa framework? (factor 8)
Làm y hệt với repo system-prompts-and-models-of-ai-tools: đọc system prompt của Claude Code, Cursor, Devin với bốn câu hỏi trên. Bạn sẽ thấy mỗi team trả lời khác nhau, và bắt đầu có ý kiến riêng về cái nào hợp lý.
Phần C — Sách: Chip Huyen, "AI Engineering"
Đọc khi muốn ghép mọi mảnh thành bức tranh lớn. Đừng đọc trước bài 1-7 — sẽ thấy trừu tượng. Sau bài 7, mỗi chương sẽ "à, cái này tôi đã chạm rồi".
Sách về xây ứng dụng trên foundation model có sẵn: framework phát triển và triển khai, cách điều hướng giữa vô số model, dataset, benchmark, pattern.
Các chương (bản 2025):
1. Introduction to Building AI Applications with Foundation Models
2. Understanding Foundation Models — model được train thế nào, sampling, vì sao hallucinate
3. Evaluation Methodology — vì sao eval khó, các loại metric, LLM-judge và giới hạn của nó
4. Evaluate AI Systems — chọn model, benchmark, dựng pipeline eval (bài 7 sâu hơn)
5. Prompt Engineering — có phần đáng đọc về prompt injection và phòng thủ
6. RAG and Agents — retrieval, agent, tool, planning, các failure mode
7. Finetuning — khi nào, PEFT/LoRA, tính toán bộ nhớ
8. Dataset Engineering — chất lượng data, synthetic data
9. Inference Optimization — latency, throughput, cache, batching
10. AI Engineering Architecture and User Feedback — kiến trúc tổng thể, guardrail, observability, vòng feedback (Eugene Yan 7 pattern, mở rộng)
Đọc kỹ: chương 3, 4, 6, 10. Lướt: 2, 5. Để sau khi cần: 7, 8, 9.
Áp vào việc của bạn
- Xong phần A: bạn có tool riêng cắm vào Claude Code, Hermes, Cursor. Mọi câu hỏi vận hành notify-service không cần mở DB tay nữa.
- Xong phần B: bạn đọc được bất kỳ agent nào và có ý kiến về nó. Viết skill và CLAUDE.md sẽ khác hẳn, vì bạn biết chúng được lắp vào chỗ nào trong prompt và ảnh hưởng thế nào.
- Sau đó: quay lại bài 6 bước 5, làm eval tử tế theo bài 7 cho agent của bạn. Lúc đó bạn đã đi trọn một vòng: xây → đo → sửa.
Tự kiểm tra
- Host, client, server trong MCP — mỗi cái làm gì? Vì sao một host có nhiều client?
- Tool và resource khác nhau ở đâu? Cho ví dụ mỗi loại trên notify-service.
- Khi model gọi một tool MCP trong Claude Code, chuỗi sự kiện từ model đến server và ngược lại là gì?
- Vì sao stateless là lựa chọn thiết kế tốt cho protocol này?
Bài tập
- Viết
notify-mcp với hai tool đầu. Test bằng MCP Inspector. Đăng ký vào Claude Code, hỏi 5 câu vận hành. Ghi câu nào nó gọi sai tool, sửa mô tả, thử lại.
- Đọc
agent/ của Hermes. Viết một trang: vòng lặp của nó khác Claude Agent SDK ở ba điểm cụ thể.
- Đọc system prompt Claude Code trong repo system-prompts. Liệt kê 5 kỹ thuật muốn mang vào CLAUDE.md của bạn, và 3 dòng trong CLAUDE.md hiện tại nên xóa vì bài 2.