GĐ09 — API thực chiến: security, jobs, realtime, caching, pagination, observability
Dành cho FE (JS/TS mạnh) chuyển sang BE. Mỗi khái niệm: định nghĩa → tại sao quan trọng → cơ chế → ví dụ ngắn → pitfall/case thực tế. Đây là giai đoạn "phủ case thực tế" — ưu tiên tính thực dụng.
Kiểm chứng ngày 2026-10-05. Danh sách OWASP Top 10:2025 (top10.owasp.org/2025) và OWASP API Security Top 10 2023 (api-security.owasp.org) đối chiếu từ trang gốc.
helmet8.3.0 trên Express 5.2.1: header mặc định lấy bằng một request thật.@nestjs/throttler6.7.1 khai báo peer@nestjs/common/@nestjs/coretới^12.0.0(npm registry), chạy thử với Nest 12.1.2. Script Lua chạy trên Redis 8.6.1 cục bộ. Gói lưu trạng thái Throttler trên Redis: chưa xác minh với Nest 12 (xem mục 6b).
Phần A — Security#
1. JWT vs Session: stateless vs stateful#
Định nghĩa.
- Session (stateful): server tạo một
sessionIdrandom, lưu state (userId, quyền...) ở phía server (Redis/DB). Client chỉ giữsessionIdtrong cookie. Mỗi request server tra state theosessionId. - JWT (stateless): server ký một token chứa sẵn payload (
sub,role,exp...). Server KHÔNG lưu gì. Mỗi request chỉ verify chữ ký + hạn.
Tại sao quan trọng. Đây là quyết định kiến trúc auth ảnh hưởng tới scale, khả năng revoke (thu hồi), và độ phức tạp vận hành. Chọn sai → hoặc không revoke được token bị lộ, hoặc phải query Redis mỗi request (mất lợi ích stateless).
Cơ chế.
- JWT =
base64url(header).base64url(payload).signature. Signature = HMAC-SHA256 (secret) hoặc RS256 (private/public key). Payload chỉ được encode, KHÔNG mã hoá → ai cũng đọc được, đừng để secret trong đó. - Session: cookie
Set-Cookie: sid=...; HttpOnly; Secure; SameSite. Server có "danh bạ" session → xoá 1 dòng là logout ngay.
Ví dụ (Nest + TS):
Trade-off / khi nào chọn cái nào.
| Tiêu chí | JWT (stateless) | Session (stateful) |
|---|---|---|
| Scale ngang | Tốt (không shared store) | Cần shared store (Redis) |
| Revoke tức thì | Khó (token còn hạn vẫn valid) | Dễ (xoá dòng) |
| Mobile / microservices | Hợp | Kém tiện |
| Kích thước request | Lớn hơn (token dài) | Nhỏ (chỉ id) |
- Chọn JWT cho: API cho mobile, microservices (mỗi service tự verify), server không state.
- Chọn Session cho: web app truyền thống, cần logout tức thì, cần "đá thiết bị khác".
- Thực tế phổ biến: dùng access token JWT ngắn hạn (5-15 phút) + refresh token có state (revoke được) → lấy cái tốt của cả hai.
Pitfall / case thực tế. Team để exp 30 ngày cho JWT access token → user bị lộ token, không revoke được suốt 30 ngày. Fix: access token ngắn (10 phút), refresh token dài + lưu server để revoke.
2. Refresh token rotation & revoke; phát hiện token theft#
Định nghĩa. Refresh token là token dài hạn dùng để xin access token mới khi access token hết hạn (user không phải login lại). Rotation = mỗi lần dùng refresh token, cấp refresh token MỚI và vô hiệu cái cũ.
Tại sao quan trọng. Refresh token sống lâu → nếu bị đánh cắp, kẻ tấn công dùng mãi. Rotation + detection biến "token bị lộ" từ thảm hoạ thành sự cố phát hiện được.
Cơ chế (rotation + theft detection).
- Login → cấp access (ngắn) + refresh
R1(lưu DB:userId,familyId,used=false). - Client dùng
R1để refresh → server đánh dấuR1.used=true, cấpR2cùngfamilyId. - Nếu
R1bị dùng lần 2 (đãused=true) → nghĩa là có kẻ replay token cũ → thu hồi cả family (mọi refresh token của user) → buộc login lại. Đây chính là theft detection.
Ví dụ (TS, giản lược):
Pitfall / case thực tế.
- Lưu refresh token plaintext trong DB → DB rò rỉ = mất hết. Lưu hash của nó: refresh token là chuỗi ngẫu nhiên entropy cao nên SHA-256 là đủ (không cần salt hay slow hash); bcrypt/argon2 dành cho mật khẩu do người dùng chọn. Thiết kế đầy đủ (một hàng cho mỗi session,
tokenHash,usedAt) ở GĐ07 mục 11. - Không rotate → refresh token 90 ngày bị lộ = kẻ tấn công có quyền 90 ngày, im lặng.
- Race condition: mobile gửi 2 refresh song song → cả hai thấy
used=false. Đánh dấu dùng phải là một câu nguyên tử (UPDATE ... SET used_at = now() WHERE token_hash = $1 AND used_at IS NULL, chỉ request được 1 dòng bị ảnh hưởng mới được cấp token mới; xem GĐ07 mục 11), có thể kèm grace window ngắn để tránh false-positive theft.
Sơ đồ và kết quả mong đợi: hai refresh song song, rồi replay
Code tham chiếu theo bài, chưa chạy; kết quả dưới đây là suy ra từ câu UPDATE nguyên tử của mục này.
- A nhận
R2, B nhận 401. Lưu ý: B có thể là chính client của bạn (mobile gửi hai refresh song song), nên thu hồi cả family ngay có thể đá người dùng thật. Đó là lý do có ghi chú "grace window" ở phần Pitfall của mục này: chấp nhậnusedAtmới hơn vài giây mà không coi là theft. - Replay sau đó (kẻ cắp dùng
R1muộn hơn): 0 dòng,usedAtcó giá trị, family bị thu hồi,R2cũng chết. - Sai thường gặp: đọc
usedbằngSELECTrồi mớiUPDATE(hai bước) cho cả hai request cùng thấy chưa dùng. - Cách tự kiểm (khi bạn dựng):
Promise.all([refresh(r1), refresh(r1)])phải cho đúng 1 thành công, 1UnauthorizedException.
3. Password hashing: argon2/bcrypt, salt, vì sao không bao giờ lưu plaintext#
Định nghĩa. Hashing = biến password thành chuỗi một chiều (không giải ngược). Salt = chuỗi random thêm vào mỗi password trước khi hash. argon2 / bcrypt = hàm hash chậm có chủ đích (slow hash) thiết kế riêng cho password.
Tại sao quan trọng. DB bị rò rỉ là chuyện khi nào chứ không phải có hay không. Nếu lưu plaintext → toàn bộ user mất tài khoản (và mất luôn ở các site khác vì họ dùng chung password). Hash đúng cách → kẻ tấn công gần như không crack được.
Cơ chế.
- Không dùng MD5/SHA-256 trần cho password: chúng nhanh → GPU thử hàng tỷ hash/giây (brute force).
- Slow hash (bcrypt/argon2) cố tình tốn CPU/memory → giới hạn số lần thử. argon2id thêm chống tấn công GPU/ASIC bằng cách tốn RAM.
- Salt làm mỗi hash khác nhau dù cùng password → chặn rainbow table và lộ "hai người cùng mật khẩu". bcrypt/argon2 tự sinh salt và nhúng vào output.
Ví dụ (TS, argon2):
Pitfall / case thực tế.
- Dùng
crypto.createHash('sha256')cho password → sai nghiêm trọng. - So sánh hash bằng
===với giá trị tự tính → dễ dính timing attack; luôn dùngargon2.verify/bcrypt.compare(constant-time). - Set cost quá cao (argon2 memory 1GB) → login chậm/OOM khi nhiều request; benchmark ~100-250ms/hash là hợp lý.
4. OWASP Top 10 — tóm tắt (tập trung: injection, broken auth, broken access control, SSRF)#
Định nghĩa. OWASP Top 10 = danh sách 10 nhóm lỗ hổng web phổ biến/nguy hiểm nhất, cập nhật định kỳ. Là "checklist tối thiểu" của BE.
Tại sao quan trọng. Phần lớn breach thực tế rơi vào vài mục top đầu. Biết chúng = tránh được đa số sự cố.
4 nhóm cần nắm chắc:
Nhãn dưới đây theo OWASP Top 10:2025 (kiểm chứng ngày 2026-10-05).
- A01:2025 — Broken Access Control (số 1). User A truy cập dữ liệu user B vì API không kiểm quyền theo chủ sở hữu. Ví dụ
GET /orders/:idchỉ check "đã login" mà không checkorder.userId === req.user.id→ IDOR. Fix: luôn kiểm quyền ở tầng dữ liệu, không tinidtừ client. Trả 404 (không phải 403) cho tài nguyên của người khác để không lộ sự tồn tại.
-
A05:2025 — Injection (SQL/NoSQL/command). Ghép input người dùng vào query/lệnh. Xem mục 5.
-
A07:2025 — Authentication Failures (broken auth). Password yếu, brute force không giới hạn, session không hết hạn, lộ token. Fix: slow hash (mục 3), rate limit login (mục 6), MFA, session/refresh revoke đúng (mục 2).
-
SSRF (Server-Side Request Forgery). Bản 2021 có mục riêng A10; bản 2025 xếp SSRF (CWE-918) vào A01 Broken Access Control (top10.owasp.org/2025). Server nhận URL từ user rồi tự đi fetch → kẻ tấn công ép server gọi tới nội bộ (
http://169.254.169.254/lấy credential cloud, hoặchttp://localhost:6379). Fix: allowlist domain, chặn IP nội bộ/metadata, không cho redirect tuỳ ý.
Pitfall / case thực tế. Feature "import ảnh từ URL" là ổ SSRF kinh điển — attacker nhập URL metadata cloud → server tự lấy IAM credential trả về.
4b. OWASP đầy đủ, API Top 10 2023 và helmet#
Định nghĩa. Mục 4 chỉ đào sâu vài nhóm. Mục này đặt đủ 10 nhóm web, danh sách riêng cho API, và middleware helmet (đặt sẵn các header an toàn).
OWASP Top 10:2025 (đối chiếu ngày 2026-10-05) và chỗ tương ứng trong guide:
| Mã | Nhóm | Với backend Node thì nghĩ tới | Xem |
|---|---|---|---|
| A01 | Broken Access Control | kiểm quyền theo chủ sở hữu, SSRF | mục 4 |
| A02 | Security Misconfiguration | header mặc định, CORS lỏng, debug bật ở production | mục 4b, 7 |
| A03 | Software Supply Chain Failures | lockfile + npm ci, npm audit, ghim version | GĐ15 |
| A04 | Cryptographic Failures | hash mật khẩu đúng, TLS, không tự chế mã hoá | mục 3 |
| A05 | Injection | tham số hoá, allowlist ORDER BY | mục 5 |
| A06 | Insecure Design | thiếu rate limit, thiếu idempotency ngay từ thiết kế | mục 6, 14 |
| A07 | Authentication Failures | brute force, session/refresh không thu hồi | mục 1, 2, 6 |
| A08 | Software or Data Integrity Failures | webhook không verify chữ ký, nạp code/dữ liệu không kiểm | mục 15 |
| A09 | Security Logging and Alerting Failures | không log sự kiện đăng nhập/từ chối quyền, hoặc log lộ secret | mục 16 |
| A10 | Mishandling of Exceptional Conditions | lỗi không bắt lộ stack trace, "fail-open" khi dependency chết | mục 17, 18 |
OWASP API Security Top 10 2023. Danh sách riêng cho API, hay gặp hơn cả là ba mục sau:
| Mã | Nhóm | Ý chính |
|---|---|---|
| API1 | Broken Object Level Authorization (BOLA) | GET /orders/:id không kiểm chủ sở hữu: chính là IDOR ở mục 4 |
| API3 | Broken Object Property Level Authorization (BOPLA) | trả dư field (passwordHash, role) hoặc nhận dư field (mass assignment: { role: 'admin' } trong body) |
| API4 | Unrestricted Resource Consumption | không giới hạn tần suất, kích thước body, limit của phân trang, hay chi phí gọi LLM |
Bảy mục còn lại: API2 Broken Authentication, API5 Broken Function Level Authorization (user thường gọi được endpoint admin), API6 Unrestricted Access to Sensitive Business Flows (lạm dụng luồng như đặt vé, mã giảm giá), API7 SSRF, API8 Security Misconfiguration, API9 Improper Inventory Management (quên tắt /v1 cũ, endpoint debug), API10 Unsafe Consumption of APIs (tin dữ liệu từ API bên thứ ba).
Code tham chiếu, chưa chạy.
helmet (Express 5, helmet 8.3.0). Một dòng app.use(helmet()) thêm nhóm header an toàn và bỏ X-Powered-By:
Đã chạy trên Express 5.2.1: không có helmet, response chỉ có x-powered-by (không có header an toàn nào); có helmet, x-powered-by biến mất và thêm các header sau.
| Header (giá trị mặc định đo được) | Tác dụng |
|---|---|
x-content-type-options: nosniff | trình duyệt không đoán lại kiểu nội dung |
strict-transport-security: max-age=31536000; includeSubDomains | ép HTTPS; chỉ có tác dụng khi trình duyệt nhận nó qua HTTPS |
x-frame-options: SAMEORIGIN, CSP frame-ancestors 'self' | chặn bị nhúng iframe từ origin khác (clickjacking) |
content-security-policy: default-src 'self'; ... | chống XSS cho trang HTML; với API trả JSON thì ít giá trị |
cross-origin-opener-policy: same-origin, cross-origin-resource-policy: same-origin | cô lập cửa sổ; chặn origin khác nhúng tài nguyên của bạn |
referrer-policy: no-referrer | không gửi URL nguồn sang trang khác |
x-xss-protection: 0 | tắt bộ lọc XSS cũ (cố ý, vì nó từng gây lỗ hổng) |
Cũng có origin-agent-cluster, x-dns-prefetch-control, x-download-options, x-permitted-cross-domain-policies.
Pitfall.
helmet()không thay thế auth, CORS hay kiểm quyền: nó chỉ chỉnh header.cross-origin-resource-policy: same-originlàm trang ở origin khác (FE trên domain riêng) không tải được ảnh/file do API này phục vụ. Cần thì đặthelmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }).- HSTS đã gửi qua HTTPS thì trình duyệt nhớ một năm: đừng bật cho domain chưa chắc chắn chạy HTTPS lâu dài.
- Sau reverse proxy, kiểm lại header ở phía client (curl
-i), vì proxy có thể thêm hoặc ghi đè.
5. SQL Injection & cách phòng (parameterized query / ORM)#
Định nghĩa. SQLi = chèn cú pháp SQL qua input để đổi ý nghĩa câu query. Kinh điển: input ' OR '1'='1 biến WHERE email='...' thành luôn đúng.
Tại sao quan trọng. SQLi có thể dump toàn bộ DB, bypass login, xoá bảng. Là lỗ hổng lâu đời nhưng vẫn xuất hiện vì lập trình viên ghép chuỗi.
Cơ chế phòng. Parameterized query (prepared statement): SQL và dữ liệu đi tách kênh — DB compile câu lệnh trước, dữ liệu chỉ là giá trị, không bao giờ được hiểu là cú pháp.
Ví dụ:
Pitfall / case thực tế.
- Nghĩ "dùng ORM là an toàn tuyệt đối" → sai khi dùng
queryRawghép chuỗi, hoặc truyền tên cột/ORDER BYtừ input (không tham số hoá được → phải allowlist). - NoSQL cũng bị injection:
{ password: { $ne: null } }bypass login MongoDB. Validate kiểu dữ liệu input (phải là string).
Kết quả mong đợi: cùng một input, hai cách viết
Suy ra từ cách driver pg hoạt động, chưa chạy. Input email = "' OR '1'='1":
Với ORDER BY/tên cột (không tham số hoá được) dùng allowlist bằng Map, vì Map không có thuộc tính kế thừa và tsc --strict chấp nhận:
Đã chạy (TypeScript --strict, Node 24): sort là name ra name, createdAt ra created_at; constructor, __proto__, toString đều rơi về created_at. Cách cũ COL[sort] trên object thường với sort: string bị tsc --strict từ chối (TS7053), còn COL[sort] ?? ... trên object thường cho sort=constructor lấy được thuộc tính kế thừa và gây lỗi SQL 500. Không bao giờ nối thẳng sort vào SQL.
6. Rate limiting: token bucket, per-IP / per-user, Redis counter#
Định nghĩa. Giới hạn số request trong một khoảng thời gian. Token bucket: mỗi client có "xô" chứa token, mỗi request tiêu 1 token, xô được nạp lại đều theo thời gian; hết token → chặn/429.
Tại sao quan trọng. Chống brute force login, chống lạm dụng API (scraping), bảo vệ tài nguyên/chi phí (mỗi request gọi LLM tốn tiền). Không có rate limit = 1 script có thể quật sập hoặc "đốt tiền" hệ thống.
Cơ chế.
- Token bucket cho phép burst (dùng nhanh token còn dư) nhưng giới hạn tốc độ trung bình (rate nạp).
- Per-IP chặn khách vô danh; per-user chặn theo tài khoản (công bằng hơn, tránh phạt oan user sau NAT chung IP).
- Nhiều instance → cần Redis làm bộ đếm chung (in-memory không share được giữa các pod).
Ví dụ (Redis, sliding-ish counter):
Pitfall / case thực tế.
- Rate limit in-memory khi chạy 4 pod → giới hạn thật gấp 4 lần dự tính (mỗi pod đếm riêng). Dùng Redis.
- Chỉ giới hạn per-IP cho login → attacker xoay IP (botnet) vẫn brute force 1 tài khoản. Thêm per-username.
- Quên đặt TTL → key phình mãi, không reset window.
- Trả 429 kèm header
Retry-Afterđể client biết chờ bao lâu.
Đáp án bổ sung: INCR rồi EXPIRE không nguyên tử, và token bucket thật
Code tham chiếu, chưa chạy. Ví dụ allow() ở phần Ví dụ có một khe hở: process chết giữa INCR và EXPIRE thì key không bao giờ có
TTL, bộ đếm vượt limit và người dùng bị chặn vĩnh viễn. Gộp hai bước vào một script Lua (Redis chạy script như một lệnh):
Token bucket đúng nghĩa (cho burst, nạp đều), cũng một script để đọc-tính-ghi nguyên tử:
Mong đợi: 10 request liền nhau đều qua (burst); request thứ 11 đến ngay sau đó (trong khoảng 0,2 giây, khi xô chưa kịp nạp lại 1 token) bị chặn (ok = 0), còn nếu nó đến muộn hơn thì xô đã nạp thêm token nên vẫn qua.
Khi chặn trả 429 kèm Retry-After = ceil((1 - t) / rate) giây, với t là số token còn lại script trả về. Nếu Redis cache chạy cluster, mọi key của một script phải cùng slot.
6b. Sliding window và Nest Throttler#
Ba thuật toán, ba hành vi.
| Thuật toán | Cách đếm | Điểm yếu |
|---|---|---|
| Cửa sổ cố định (mục 6) | một bộ đếm mỗi cửa sổ, hết cửa sổ thì reset | 100 request cuối cửa sổ + 100 đầu cửa sổ sau = 200 trong vài giây |
| Cửa sổ trượt (log) | giữ dấu thời gian từng request trong sorted set, đếm trong now - window | tốn bộ nhớ theo số request trong cửa sổ |
| Token bucket (mục 6) | xô token nạp đều | khó nói "tối đa N request mỗi phút" chính xác |
Cửa sổ trượt bằng một script Lua (đọc-xoá-đếm-ghi nguyên tử):
Đã chạy (Redis 8.6.1, ioredis 6.0.0, tsc --strict sạch): giới hạn 3 request mỗi 1 giây, request đến ở các mốc 0,0 / 0,1 / 0,2 giây qua; mốc 0,3 bị chặn, còn chờ 700 ms; mốc 0,9 bị chặn, còn chờ 100 ms; mốc 1,0 qua (request đầu đã rời cửa sổ); 1,15 và 1,25 qua. Request bị chặn không được ghi vào sorted set, nên bị chặn không kéo dài thời gian chờ. id phải khác nhau mỗi request (member trùng thì ZADD chỉ ghi đè, đếm thiếu); Date.now() lấy từ app, nên nhiều pod lệch đồng hồ sẽ lệch cửa sổ: cần chặt thì lấy giờ từ Redis (TIME) trong script.
Nest Throttler. @nestjs/throttler 6.7.1 (peer hỗ trợ Nest 12, đã chạy với Nest 12.1.2): đăng ký module và guard toàn cục, ttl tính bằng mili giây.
Đã chạy (hai instance Nest trong một process, cổng ngẫu nhiên, tsc --strict sạch): mỗi instance cho 5 request 200 rồi 429 với Retry-After: 60; route login giới hạn 2: 200, 200, 429; route @SkipThrottle() 8 lần đều 200. Hai instance cho tổng 10 request qua dù giới hạn là 5.
Cạm bẫy: lưu trạng thái mặc định ở bộ nhớ process. Đây đúng là lỗi "4 pod, giới hạn thật gấp 4 lần" của mục 6. Throttler cần storage dùng chung (Redis). Gói cộng đồng @nest-lab/throttler-storage-redis 1.2.0 khai báo peer tới Nest 11 trong npm registry, không có Nest 12: chưa xác minh với Nest 12. Kiểm peer dependency trước khi cài; nếu không khớp thì tự viết storage bằng Lua ở mục 6 hoặc dùng cửa sổ trượt ở trên. Throttler đếm theo req.ip (suy luận từ tài liệu, chưa chạy sau proxy): sau reverse proxy phải đặt trust proxy, nếu không mọi người dùng chung một IP của proxy; login nên đếm thêm theo username, như mục 6.
7. CORS & CSRF: khác nhau, khi nào CSRF là vấn đề#
Định nghĩa.
- CORS (Cross-Origin Resource Sharing): cơ chế của trình duyệt cho phép/chặn JS ở origin A đọc response từ API origin B. Nó nới lỏng same-origin policy một cách có kiểm soát.
- CSRF (Cross-Site Request Forgery): tấn công lợi dụng việc trình duyệt tự động gửi cookie — site độc hại khiến trình duyệt nạn nhân gửi request "thật" tới API của bạn (kèm cookie đăng nhập) mà nạn nhân không biết.
Tại sao quan trọng. Hai khái niệm hay bị nhầm. Hiểu sai → hoặc mở CORS quá rộng (rủi ro), hoặc lo CSRF sai chỗ (JWT header không dính CSRF).
Cơ chế / phân biệt.
- CORS không phải cơ chế bảo mật server — nó chỉ kiểm soát việc JS đọc được response hay không.
Access-Control-Allow-Originsai không "bảo vệ" API khỏi curl. - CSRF chỉ là vấn đề khi auth dựa trên cookie tự-gửi. Nếu auth bằng
Authorization: Bearer <JWT>(JS phải chủ động gắn header) → site khác không gắn được header của bạn → không dính CSRF.
Khi nào CSRF là vấn đề: dùng cookie-based session cho state-changing request (POST/PUT/DELETE).
Phòng CSRF:
Pitfall / case thực tế.
- Đặt
Access-Control-Allow-Origin: *cùngAllow-Credentials: true→ trình duyệt từ chối (và về nguyên tắc là cấu hình nguy hiểm). Với cookie credential phải allowlist origin cụ thể. - App SPA + cookie auth mà quên CSRF protection → dính CSRF; hoặc lầm tưởng "có CORS rồi thì an toàn CSRF" — hai thứ khác nhau.
7b. Chốt: token lưu ở đâu — cookie httpOnly hay localStorage#
Định nghĩa. Sau khi đăng nhập, access token/refresh token phải nằm đâu đó phía client. Hai lựa chọn: cookie httpOnly (server set, JS không đọc được) hay localStorage (JS đọc và tự gắn vào header Authorization).
Tại sao đây là quyết định của backend. Nó quyết định: bạn set cookie hay trả token trong body, có cần CSRF protection không, cấu hình CORS ra sao, và refresh token rotation hoạt động thế nào. Mọi thứ đó nằm ở phía server.
Cơ chế — đánh đổi thật:
Cookie httpOnly | localStorage | |
|---|---|---|
| Bị XSS đọc mất token? | Không (JS không truy cập được) | Có — một lỗ XSS là mất sạch token |
| Bị CSRF? | Có — trình duyệt tự gửi kèm cookie | Không (header phải do JS gắn thủ công) |
| Cross-domain (API khác domain) | Phức tạp: SameSite=None; Secure + CORS credentials | Đơn giản |
| Mobile app / client không phải trình duyệt | Vụng về | Tự nhiên |
Điểm mấu chốt hay bị nói sai. Nhiều người kết luận "hai bên đều có rủi ro nên như nhau". Không đúng. Khác biệt nằm ở mức độ thiệt hại:
- Với
localStorage, một lỗ XSS = kẻ tấn công lấy được token và mang đi dùng ở nơi khác, kể cả sau khi bạn đã vá lỗi. Token bị đánh cắp vĩnh viễn cho tới khi hết hạn. - Với cookie
httpOnly, cùng lỗ XSS đó vẫn nguy hiểm (kẻ tấn công gọi API thay mặt người dùng trong lúc họ còn phiên), nhưng không cầm được token đi. Thiệt hại bị giới hạn trong phiên và trong trình duyệt đó. - Và CSRF là bài toán đã được giải triệt để (
SameSite=Lax/Strict+ token chống CSRF), trong khi XSS thì không — bạn không bao giờ chắc chắn 100% rằng ứng dụng không có lỗ XSS nào, nhất là khi có dependency bên thứ ba.
Khuyến nghị.
- Mobile app / API cho máy gọi máy → Bearer token là đúng; không có trình duyệt thì không có CSRF, và secure storage của OS thay thế cookie.
- Dù chọn cách nào: access token phải ngắn hạn, refresh token phải xoay vòng và thu hồi được (mục 2).
Pitfall. Chọn localStorage "cho tiện" rồi cũng không phòng XSS (không CSP, render HTML thô, dangerouslySetInnerHTML với dữ liệu người dùng). Nếu đã chọn localStorage, việc phòng XSS trở thành nghĩa vụ tuyệt đối — không còn lớp bảo vệ nào phía sau.
Phần B — Jobs & async#
Phần này là bản nhập môn. Toàn bộ chiều sâu — đảm bảo giao nhận, idempotency, DLQ, cronjob, leader election, fairness giữa tenant — nằm ở GĐ10 — Queues, Jobs, Workers & Cronjob.
8. Background jobs với BullMQ + Redis#
Định nghĩa. Background job = việc chạy ngoài vòng đời HTTP request. BullMQ = thư viện queue trên Redis với các khái niệm: Queue (hàng đợi job), Worker (tiến trình lấy job ra xử lý), Job (một đơn vị việc + payload), retry/backoff, repeatable/cron.
Tại sao quan trọng — vì sao không xử lý nặng trong request.
- Request nặng (gửi email, xuất PDF, resize ảnh, gọi API bên thứ ba) làm user chờ lâu, dễ timeout, giữ connection → nghẽn.
- Nếu process crash giữa chừng → mất việc, không retry được.
- Queue tách nhận việc (nhanh, trả 202) khỏi làm việc (chậm, có retry, scale worker riêng).
Cơ chế.
- Producer
add()job vào Queue (lưu ở Redis). - Worker
processjob; thành công → xoá; lỗi → retry theoattempts+backoff(thường exponential: chờ 1s, 2s, 4s... tránh dồn tải khi service phụ thuộc đang sập). - Hết attempts → vào DLQ (dead-letter / failed) để điều tra thủ công.
- Repeatable/cron cho việc định kỳ (dọn dữ liệu 2h sáng).
Ví dụ (BullMQ + Nest):
Từ BullMQ 6, option
repeatcủaqueue.add()đã bị xoá. Kiểu TypeScript từ chối; trong JS thuần, đã chạy thử trên 6.3.11: job không lặp lại, nó chỉ vào hàng đợi chạy một lần ngay. DùngupsertJobScheduler(chi tiết ở GĐ10 mục 8).
Pitfall / case thực tế.
- Job không idempotent + retry → gửi email chào 5 lần khi lỗi tạm. Thiết kế job an toàn với chạy lại (kiểm "đã gửi chưa").
- Payload nhét cả object lớn → phình Redis; chỉ đưa
id, worker tự query. - Worker chung process với API → job nặng ăn CPU làm chậm request. Tách worker.
- Không giới hạn
attempts/không có DLQ → job lỗi vĩnh viễn retry vô hạn, đốt tài nguyên. - Dùng chung một Redis cho cache và queue → khi đầy bộ nhớ,
allkeys-lru(hợp với cache) có thể xoá khoá nội bộ của BullMQ, cònnoeviction(BullMQ yêu cầu) làm cache ghi lỗi thay vì tự dọn. Tách hai instance: cache dùngallkeys-lruhoặcvolatile-lru, queue dùngmaxmemory-policy noeviction(xem GĐ10 mục 2).
8b. Transactional Outbox — nối DB và queue cho đúng#
Định nghĩa. Outbox pattern: thay vì gọi thẳng queue.add(), bạn ghi ý định ("cần gửi mail X") vào một bảng outbox trong cùng transaction với thay đổi nghiệp vụ. Một tiến trình riêng (relay) đọc bản ghi đã commit và đẩy vào queue thật.
Tại sao quan trọng. Đây là lỗ hổng ai cũng mắc ngay sau khi học BullMQ. Postgres và Redis là hai hệ thống khác nhau — không có transaction chung. Nên đoạn code tưởng chừng hiển nhiên này sai theo cả hai chiều:
Trường hợp (B) tệ hơn vì im lặng: không lỗi, không log, chỉ là một việc lẽ ra phải xảy ra mà không xảy ra. Vài tháng sau kế toán phát hiện thiếu hoá đơn.
Cơ chế. Đưa "ý định" vào cùng một transaction với dữ liệu nghiệp vụ → hai thứ hoặc cùng tồn tại, hoặc cùng không.
Vì sao phải nằm trong transaction. Khi $queryRaw chạy ngoài transaction, câu SELECT ... FOR UPDATE là một transaction tự động, kết thúc ngay khi trả kết quả → khoá nhả liền, relay thứ hai đọc cùng các hàng và đẩy trùng. PostgreSQL: "Row-level locks are released at transaction end" (Explicit Locking). Đã chạy thật trên PostgreSQL 17.9: 60 hàng, 3 relay song song (mỗi relay một connection và một transaction, LIMIT 20, giữ khoá 300 ms) lấy 20 + 20 + 20 hàng khác nhau, không trùng hàng nào; relay thứ hai bỏ qua đúng các hàng đang bị khoá, và hàng của transaction bị ROLLBACK trở lại hàng đợi. Chạy bằng driver pg, chưa qua Prisma $transaction/$queryRaw, chưa đẩy vào BullMQ/Redis thật trong lần chạy này.
Đảm bảo bạn nhận được: at-least-once. Nếu relay chết sau khi queue.add nhưng trước khi đánh dấu published_at, job sẽ được đẩy lại lần nữa. Đó là lý do worker bắt buộc phải idempotent (mục 14) — outbox không loại bỏ nhu cầu đó, nó chỉ đảm bảo việc không bao giờ bị mất.
Khi nào không cần. Việc mà mất cũng không sao (làm ấm cache, gửi số liệu thống kê). Với những việc đó, gọi thẳng queue.add() sau khi commit là chấp nhận được.
Pitfall.
- Quên dọn bảng
outbox→ phình vô hạn. Xoá bản ghi đã xử lý quá 7 ngày (hoặc partition theo tháng). - Chạy relay trong cùng process với API rồi scale lên 3 instance → 3 relay đẩy trùng. Dùng
FOR UPDATE SKIP LOCKED(như trên) hoặc chạy relay như một tiến trình đơn lẻ. - Nhét payload khổng lồ vào outbox. Chỉ lưu
id+ thông tin tối thiểu; worker tự query dữ liệu đầy đủ.
Phần C — Realtime#
9. WebSocket / Socket.IO (Nest Gateway)#
Định nghĩa. WebSocket = kết nối TCP song công, giữ mở lâu dài giữa client và server → server chủ động đẩy (push) dữ liệu. Socket.IO = thư viện trên WebSocket, thêm reconnect, rooms, fallback. Nest Gateway = lớp Nest bọc Socket.IO.
Tại sao quan trọng — khác HTTP & khi nào cần.
- HTTP là request/response: client hỏi, server đáp, rồi đóng. Server không tự nói được → muốn cập nhật phải polling (tốn kém, trễ).
- WebSocket giữ kênh mở → server đẩy tức thì. Dùng khi cần realtime: chat, thông báo, presence (online/offline), giá cả live, collaborative editing, dashboard live.
- Nếu chỉ cần cập nhật thưa/không gấp → HTTP polling hoặc SSE đơn giản hơn.
Cơ chế.
- Rooms: nhóm socket theo phòng để broadcast có chọn lọc (
room:project-42) thay vì gửi mọi client. - Handshake bắt đầu bằng HTTP
Upgraderồi chuyển sang WS.
Ví dụ (Nest Gateway):
Scale với Redis adapter. Nhiều instance BE → socket của user A ở pod-1, user B ở pod-2. server.to(room).emit ở pod-1 không tới được socket ở pod-2. Redis adapter dùng Redis pub/sub để phát broadcast xuyên mọi pod.
Pitfall / case thực tế.
- Scale 3 pod nhưng quên Redis adapter → tin nhắn "lúc tới lúc không" (chỉ tới ai cùng pod). Bug rất khó lần.
- Không auth khi handshake → ai cũng join room người khác nghe lén. Verify JWT trong
handleConnection. - Không dọn khi disconnect / gửi payload lớn liên tục → rò rỉ bộ nhớ, quá tải.
- Load balancer không bật sticky session/không hỗ trợ WS upgrade → kết nối rớt liên tục.
- Chỉ cần đẩy một chiều server → client (ví dụ stream token LLM) thì SSE đủ dùng và đơn giản hơn WebSocket: xem GĐ22 mục 5; kết nối SSE sống lâu còn ảnh hưởng graceful shutdown (mục 18, quy tắc 2).
Phần D — Caching#
10. Cache-aside, TTL, cache invalidation ("2 hard problems"), stampede#
Định nghĩa.
- Cache-aside (lazy loading): app tự quản cache. Đọc → thử cache trước; miss → query DB → ghi vào cache → trả. Ghi/sửa → cập nhật DB rồi xoá cache key.
- TTL (time-to-live): thời gian sống của một cache entry, hết hạn tự bay.
- "2 hard problems": câu đùa nổi tiếng — "There are only two hard things in CS: cache invalidation and naming things." Invalidation (biết khi nào cache cũ, xoá đúng lúc) là khó thật.
- Cache stampede (thundering herd): một key hot vừa hết hạn, hàng nghìn request đồng loạt miss → tất cả cùng dội xuống DB → DB sập.
Tại sao quan trọng. Cache giảm tải DB và tăng tốc kịch tính. Nhưng cache sai → trả dữ liệu cũ (stale) âm thầm, khó phát hiện; stampede → sập lúc cao điểm.
Cơ chế / ví dụ (cache-aside):
Chống stampede.
- Lock/single-flight: chỉ 1 request được rebuild, số còn lại chờ kết quả.
- Jitter TTL: thêm random vào TTL để các key không hết hạn cùng lúc.
- Stale-while-revalidate: trả bản cũ + refresh nền.
Pitfall / case thực tế.
- Update DB nhưng quên
delcache → user thấy dữ liệu cũ hàng phút. Đây là lỗi "invalidation" kinh điển. - Đặt TTL quá dài cho dữ liệu hay đổi → stale nhiều; quá ngắn → mất tác dụng cache.
- Cache cả lỗi/empty mà không phân biệt → "cache poisoning" nhẹ. Đặt TTL ngắn cho negative cache.
- Key trùng giữa các tenant (thiếu prefix
tenant:) → rò rỉ dữ liệu chéo. - Cache dùng chung Redis instance với BullMQ → chính sách evict của cache có thể xoá dữ liệu của queue. Cache một instance (
allkeys-lru), queue một instance (noeviction); xem mục 8.
Code tham chiếu và kết quả mong đợi: single-flight chống stampede
Code tham chiếu, chưa chạy. Trong một process, gộp các lần miss cùng key vào một Promise; TTL có jitter:
Mong đợi: 50 request đồng thời vào một key vừa hết hạn, trong một process: load chạy 1 lần (không có
single-flight: 50 lần). Với N pod, mỗi pod một lần, tức tối đa N lần; muốn đúng 1 lần toàn hệ thống thì thêm khoá
Redis SET lock:key 1 NX EX 10, request không giữ khoá chờ ngắn rồi đọc lại cache. Sai thường gặp: quên finally
nên lỗi của load được nhớ mãi trong inflight; user:${id} thiếu tiền tố tenant (mục trên).
11. HTTP caching: ETag, Cache-Control, 304#
Định nghĩa. Cache ở tầng HTTP: server nói cho trình duyệt/CDN biết cách và bao lâu được cache một response.
- Cache-Control: chỉ thị caching (
max-age,no-store,private,public,must-revalidate). - ETag: "vân tay" (hash) của nội dung response. Client giữ ETag, lần sau gửi
If-None-Match. - 304 Not Modified: nếu ETag khớp → server trả 304 không kèm body → tiết kiệm băng thông.
Tại sao quan trọng. Giảm tải server và tăng tốc client mà không cần Redis. Tận dụng CDN/browser cache đúng cách tiết kiệm chi phí và độ trễ lớn.
Cơ chế (conditional request):
Pitfall / case thực tế.
- Đặt
Cache-Control: public, max-age=lớncho response chứa dữ liệu riêng tư của user → CDN/proxy cache và phục vụ cho user khác. Dùngprivatecho dữ liệu cá nhân. - Không set
no-storecho endpoint auth/nhạy cảm → token/thông tin bị cache. - Cache HTML nhưng đổi API → client dính bản cũ; tách chiến lược cache tĩnh (hash filename) vs động.
Phần E — API design#
12. Versioning API (URL vs header)#
Định nghĩa. Đánh version cho API để thay đổi breaking mà không phá client cũ. Hai kiểu chính: URL (/v1/users) và header (Accept: application/vnd.app.v2+json hoặc X-API-Version: 2).
Tại sao quan trọng. Client (mobile app đã cài) không update ngay. Đổi contract mà không version → phá app đang chạy của user.
Cơ chế / so sánh.
URL (/v1) | Header | |
|---|---|---|
| Nhìn thấy | Rõ, dễ test bằng browser/curl | Ẩn, cần đọc header |
| Cache/route | Dễ (path khác nhau) | Khó hơn |
| "Thuần REST" | Bị chê (URL nên chỉ định danh resource) | Thuần hơn |
| Phổ biến thực tế | Rất phổ biến, đơn giản | Ít hơn |
Ví dụ (Nest hỗ trợ sẵn):
Pitfall / case thực tế. Khuyến nghị: URL versioning cho public API (đơn giản, rõ ràng nhất). Đừng version quá sớm/quá nhỏ; version cho breaking change, thêm field mới thì không cần version mới. Có kế hoạch deprecate v cũ (thông báo, sunset date).
13. Pagination: cursor vs offset (khi nào dùng cursor)#
Định nghĩa.
- Offset:
LIMIT 20 OFFSET 40— bỏ qua N dòng, lấy 20 tiếp. Kiểu "trang số". - Cursor (keyset): "cho tôi 20 dòng sau con trỏ này" — con trỏ thường là giá trị sort của dòng cuối (
WHERE id < :lastId ORDER BY id DESC LIMIT 20).
Tại sao quan trọng. Offset đơn giản nhưng chậm dần và lệch dữ liệu trên dataset lớn/động. Feed vô tận (infinite scroll) cần cursor.
Cơ chế / vấn đề của offset.
- Chậm:
OFFSET 100000bắt DB đếm và bỏ 100k dòng mỗi lần → O(N). Cursor dùng index nhảy thẳng → O(log N). - Lệch (drift): đang xem trang 2, có người chèn/xoá dòng → offset bị dịch → lặp hoặc mất item. Cursor neo vào giá trị cố định nên không lệch.
Ví dụ (cursor):
Khi nào dùng cursor. Dataset lớn, dữ liệu thay đổi liên tục, infinite scroll, không cần "nhảy tới trang 57". Dùng offset khi: dữ liệu nhỏ/tĩnh, cần UI trang số, cần "tổng số trang".
Pitfall / case thực tế. Cursor phải sort theo cột duy nhất và ổn định (thường id hoặc (created_at, id)); sort theo cột trùng lặp → nhảy cóc/thiếu item. Không expose cursor dạng dễ đoán nếu nhạy cảm (encode base64).
13b. Pagination đi sâu: sort ghép, tổng số bản ghi, và mã hoá cursor#
Mục 13 cho bạn khái niệm. Mục này là những chi tiết làm hỏng pagination ở production.
Sort ghép — cách viết đúng. Sort theo một cột không duy nhất (created_at) thì
phải thêm tie-breaker duy nhất. Điều kiện WHERE phải là so sánh từ điển
(lexicographic), không phải AND từng cột rời rạc:
Điều kiện bắt buộc để index phát huy tác dụng: thứ tự và chiều của cột trong
index phải khớp ORDER BY. Kiểm tra bằng EXPLAIN ANALYZE — phải thấy Index Scan,
không thấy Sort (→ GĐ05).
Mã hoá cursor — đừng trả id thô.
Ba lý do: (1) client không phụ thuộc vào hình dạng khoá nội bộ nên bạn đổi cột sort được mà không phá API; (2) không lộ id tuần tự (đoán được số lượng bản ghi và dò được record khác); (3) nhét được thông tin phụ (chiều, bộ lọc) vào cursor.
Cursor là input người dùng — phải validate. Ai cũng sửa được base64. Nếu bạn nhét thẳng giá trị đã decode vào query mà không kiểm kiểu, bạn có một lỗ hổng. Và nếu cursor chứa thông tin nhạy cảm hoặc phải chống giả mạo, ký HMAC nó.
Đếm tổng số bản ghi — chi phí bị đánh giá thấp nhất. SELECT COUNT(*) trên
bảng lớn có bộ lọc là quét toàn bộ và thường đắt hơn cả query lấy dữ liệu.
Bốn cách xử lý:
| Cách | Đánh đổi |
|---|---|
Bỏ hẳn tổng số, chỉ trả hasNextPage | Rẻ nhất. Lấy limit + 1 hàng để biết còn trang sau |
Ước lượng từ EXPLAIN / pg_class.reltuples | Nhanh, xấp xỉ. Đủ cho "khoảng 12.000 kết quả" |
Đếm có trần: COUNT(*) trên subquery LIMIT 1000 | Hiển thị "999+" như GitHub/Google làm |
| Bảng đếm cập nhật bằng trigger/job | Chính xác, nhanh đọc, phải bảo trì |
Mặc định nên là cách 1. Hãy hỏi thật: người dùng có dùng con số tổng đó không, hay nó chỉ ở đó vì mọi API đều có?
Ví dụ nhỏ và kết quả mong đợi: vì sao AND từng cột sai
Suy ra từ định nghĩa so sánh, chưa chạy. Dữ liệu (sort created_at DESC, id DESC, id là số cho gọn):
- Đúng,
(created_at, id) < (10:00, 5): trả hàng 3 và 4 (hàng 4 vì09:59 < 10:00). - Sai,
created_at < 10:00 AND id < 5: loại hàng 3 (created_atkhông nhỏ hơn) và loại cả hàng 4 (id 8 >= 5). Cả hai biến mất khỏi mọi trang: mất dữ liệu im lặng. - Với id là UUID (quy ước của lộ trình) giữ nguyên dạng
(created_at, id) < ($1, $2); không dùngid < $1đơn lẻ vì UUID v4 không có thứ tự theo thời gian. - Kiểm tra index: seed khoảng 10 000 hàng rồi
ANALYZE posts(bảng gần rỗng thì planner chọnSeq Scan+Sort, không phải lỗi); khi đóEXPLAIN (ANALYZE) SELECT ...phải cóIndex Scan using idx_posts_keyset, không có nodeSort.
Phân trang hai chiều. Cần cả "trang trước" thì đảo chiều so sánh và ORDER BY,
rồi đảo lại mảng kết quả trước khi trả về:
Chuẩn Relay connection (edges/node/cursor/pageInfo) đáng theo nếu bạn
làm GraphQL hoặc muốn một hình dạng thống nhất cho mọi danh sách — client viết một
lần dùng cho mọi endpoint.
Ba pitfall còn lại:
- Trộn offset và cursor trong cùng API — chọn một. Hỗ trợ cả hai nghĩa là bảo trì hai đường code và hai tập bug.
limitkhông có trần.?limit=100000là một cách DoS. Luôn kẹp:Math.min(limit ?? 20, 100).- Cursor gắn với bộ lọc. Đổi bộ lọc/thứ tự sắp xếp mà giữ cursor cũ cho kết quả vô nghĩa. Nhét chữ ký của bộ lọc vào cursor và từ chối nếu không khớp.
Cùng bài toán ở nơi khác: giới hạn from + size > 10.000 của Elasticsearch và
search_after là chính xác vấn đề này ở hệ phân tán —
→ GĐ11 mục 5.
14. Idempotency key cho POST (thanh toán)#
Định nghĩa. Idempotency = gọi 1 lần hay N lần cho cùng kết quả. GET/PUT/DELETE vốn idempotent; POST thì không (tạo mới mỗi lần). Idempotency key = mã duy nhất client gửi kèm để server nhận ra "cùng một thao tác" và không làm lại.
Tại sao quan trọng. Mạng chập chờn: client gửi "thanh toán", timeout, retry → nếu server xử lý cả hai → charge 2 lần. Idempotency key = charge đúng 1 lần dù client retry bao nhiêu.
Cơ chế.
- Client sinh key (UUID) cho ý định thanh toán, gửi header
Idempotency-Key. - Server: trước khi xử lý, tra key trong store.
- Chưa có → xử lý, lưu
key → kết quả. - Đã có (đang xử lý) → chờ/trả 409.
- Đã có (xong) → trả lại kết quả cũ, không charge lại.
- Chưa có → xử lý, lưu
Ví dụ (TS):
Pitfall / case thực tế.
- Client sinh key mới mỗi lần retry → vô dụng; key phải gắn với ý định, ổn định qua các lần retry.
- Không có lock → 2 request song song cùng key cùng lọt (race) → double charge. Cần atomic
SETNX. - Lưu key vĩnh viễn → phình store; đặt TTL (ví dụ 24h). Stripe/PayPal đều dùng cơ chế này.
Kết quả mong đợi và giới hạn của ví dụ Redis
Code tham chiếu theo bài, chưa chạy; kết quả suy ra từ SET NX.
Giới hạn cần nói thẳng: nếu process chết sau paymentProvider.charge và trước redis.set(result), khoá hết sau 30 giây
và retry sẽ charge lần hai. Redis không tự cứu được chỗ này. Hai lớp bù: (1) truyền chính key làm idempotency key
cho nhà cung cấp (Stripe Idempotency-Key, giữ tối thiểu 24 giờ), (2) trạng thái PENDING → DONE trong DB theo
GĐ10 mục 3. Nên băm thêm body và lưu cùng key: cùng key mà body khác thì trả 422, đừng chạy.
Với API nhiều user, khoá lưu phải kèm userId (idem:${userId}:${key}), nếu không user B gửi trùng key sẽ nhận kết quả của user A. Và Redis allkeys-lru có thể đẩy idem:* ra khỏi bộ nhớ, mở lại khả năng charge hai lần: đừng coi mất key idempotency là chấp nhận được, dùng instance noeviction hoặc lưu trong DB.
15. Webhook: nhận, verify signature (HMAC), retry, idempotency#
Định nghĩa. Webhook = bên thứ ba (Stripe, GitHub, SePay...) chủ động POST tới URL của bạn khi có sự kiện (thanh toán thành công...). Đảo ngược so với việc bạn gọi API họ.
Tại sao quan trọng. Đây là cửa ngõ vào hệ thống từ bên ngoài → nếu không verify, ai cũng giả webhook "đã thanh toán" để nhận hàng miễn phí. Và mạng không tin cậy → provider retry → phải idempotent.
Cơ chế.
- Verify signature (HMAC): provider ký payload bằng shared secret (HMAC-SHA256), gửi trong header. Bạn tự tính HMAC trên raw body và so khớp (constant-time) → chứng minh đúng người gửi + body không bị sửa.
- Raw body: phải verify trên bytes gốc, không phải object đã parse (parse rồi stringify lại → khác byte → sai chữ ký).
- Timestamp + cửa sổ chống replay: kẻ xen vào mạng bắt được một request hợp lệ rồi gửi lại y nguyên thì chữ ký vẫn đúng. Provider đưa timestamp vào phần được ký (
"<timestamp>.<body>"); bạn từ chối nếu lệch giờ hiện tại quá ngưỡng. Thư viện của Stripe mặc định 5 phút (docs.stripe.com/webhooks); Stripe gộpt=vàv1=trong một headerStripe-Signature, ví dụ dưới tách hai header cho gọn. - Retry: provider gửi lại nếu bạn không trả 2xx → xử lý phải idempotent (theo
event.id).
Ví dụ (TS):
Đã chạy thật bằng Node: phiên bản cũ ném RangeError khi chữ ký ngắn và TypeError khi thiếu header (→ 500 cho kẻ tấn công); phiên bản mới trả false với chữ ký ngắn/không phải hex/thiếu header/sai một byte body, và từ chối cả request hợp lệ có timestamp cũ 1 giờ. Câu INSERT ... ON CONFLICT DO NOTHING đã chạy thử trên PostgreSQL 17 (bảng tạm): lần 1 trả 1 dòng, lần 2 trả 0, và sau ROLLBACK thì chèn lại được. Chưa chạy hai request song song trên PostgreSQL thật; theo đặc tả, INSERT thứ hai sẽ chờ transaction đầu kết thúc rồi mới quyết định.
Đừng gọi queue.add() bên trong transaction đó — đó chính là lỗi hai-hệ-thống ở mục 8b. Cần đẩy việc nặng thì ghi một dòng outbox trong applyEvent, relay sẽ lo phần còn lại.
Pitfall / case thực tế.
- Framework auto-parse JSON → mất raw body → verify HMAC luôn sai. Phải cấu hình giữ raw body cho route webhook.
- So sánh chữ ký bằng
===→ timing attack; dùngtimingSafeEqualsau khi kiểm độ dài (và kiểm header có tồn tại) — nếu không, một request thiếu chữ ký làm bạn trả 500 thay vì 401. - Không kiểm timestamp → request hợp lệ bị bắt được có thể gửi lại mãi mãi. Không đặt ngưỡng bằng 0 (Stripe: tolerance 0 tắt hẳn việc kiểm độ mới).
markSeentrước rồiprocesssau, hai bước rời nhau →processlỗi nhưng event đã bị đánh dấu "đã thấy" → provider retry bị bỏ qua, mất sự kiện. Hoặc ngược lại (processrồi mớimarkSeen) → crash giữa hai bước làm xử lý hai lần. Gộp vào một transaction.- Xử lý nặng đồng bộ trong webhook → timeout → provider tưởng fail → retry → nhân đôi. Trả 2xx ngay, đẩy việc vào queue (qua outbox).
- Không idempotent → provider retry → cộng tiền/ghi đơn 2 lần.
15b. Chọn kiểu API: REST vs GraphQL vs gRPC vs tRPC#
Định nghĩa. Bốn kiểu contract phổ biến giữa server và client:
- REST — tài nguyên + method HTTP, JSON. Chuẩn mặc định của web.
- GraphQL — một endpoint, client mô tả chính xác dữ liệu cần, có schema mạnh.
- gRPC — RPC nhị phân trên HTTP/2, contract bằng Protocol Buffers, sinh code cho nhiều ngôn ngữ.
- tRPC — RPC cho hệ TypeScript, kiểu dữ liệu suy ra trực tiếp từ code server, không sinh code, không schema riêng.
Tại sao quan trọng. Đây là câu hỏi phỏng vấn thường gặp, và là quyết định khó đảo ngược của sản phẩm. Chọn sai làm bạn trả chi phí phức tạp mà không nhận lại gì.
Cơ chế — so sánh:
| REST | GraphQL | gRPC | tRPC | |
|---|---|---|---|---|
| Định dạng | JSON | JSON | Protobuf (nhị phân) | JSON |
| Hợp đồng | OpenAPI (tuỳ chọn) | Schema (bắt buộc) | .proto (bắt buộc) | Chính code TS |
| Đa ngôn ngữ | ✅ | ✅ | ✅ | ❌ chỉ TS |
| Trình duyệt gọi thẳng | ✅ | ✅ | ❌ cần proxy | ✅ |
| Over/under-fetching | Có | Giải quyết được | Có | Có |
| Cache HTTP (ETag/CDN) | ✅ tự nhiên | ❌ khó (POST một endpoint) | ❌ | ❌ |
| Độ phức tạp vận hành | Thấp | Cao | Trung bình | Rất thấp |
Chọn thế nào — theo tình huống:
- REST — mặc định. API công khai, nhiều loại client, cần cache HTTP/CDN, cần dễ debug bằng
curl. Nếu phân vân, chọn cái này. - GraphQL — nhiều client rất khác nhau (web, iOS, Android) cần các tập trường khác nhau, hoặc dữ liệu có quan hệ sâu mà REST phải gọi 5 lần. Cái giá: N+1 (cần DataLoader), giới hạn độ sâu/độ phức tạp truy vấn để chống DoS, rate limit khó, cache khó, phân quyền phải làm ở từng field.
- gRPC — giao tiếp giữa các service nội bộ, đặc biệt khi khác ngôn ngữ và cần thông lượng cao. Không dùng cho trình duyệt (phải qua gRPC-Web/proxy).
- tRPC — monorepo TypeScript, cùng một team làm cả hai đầu, không có client bên ngoài. An toàn kiểu tuyệt vời, chi phí gần bằng 0 — nhưng khoá chặt vào TypeScript: có client Android hay đối tác tích hợp là bạn phải viết thêm một lớp REST.
Pitfall. Chọn GraphQL cho một sản phẩm chỉ có một web client, vì "linh hoạt hơn". Bạn nhận đủ chi phí (DataLoader, giới hạn truy vấn, authz theo field, mất cache HTTP, khó rate limit) để đổi lấy sự linh hoạt mà chính bạn là người duy nhất dùng — trong khi bạn kiểm soát được cả hai đầu và có thể chỉ cần thêm một endpoint REST. Và nhớ: cả bốn kiểu đều không miễn nhiễm với các vấn đề ở Phần A — authz, rate limit, validate vẫn phải làm đủ.
Phần F — Observability & reliability#
16. Structured logging (Pino), correlation/request id#
Định nghĩa. Structured logging = log dạng JSON có field ({level, msg, userId, latency}) thay vì chuỗi văn xuôi. Pino = logger JSON siêu nhanh cho Node. Correlation/request id = một id duy nhất gắn cho mỗi request, đi xuyên mọi log/service liên quan.
Tại sao quan trọng. Sản phẩm chạy nhiều instance, log đổ về tập trung (Loki/ELK/Datadog). Log văn xuôi không query/filter được. Structured log cho phép "lọc mọi log của request X" hay "p95 latency endpoint Y". Không có correlation id → không lần được một request đi qua nhiều service.
Cơ chế.
- Mỗi request: lấy
x-request-idtừ header (do gateway/LB set) hoặc tự sinh UUID. - Bỏ id vào context (AsyncLocalStorage) → mọi log trong request tự kèm id → dễ join.
Ví dụ (Nest + Pino):
Pitfall / case thực tế.
- Log PII/secret (password, token, số thẻ) → rò rỉ qua log. Cấu hình redact các field nhạy cảm.
console.logchuỗi tự do trong production → không parse được, chậm. Dùng logger JSON.- Log quá nhiều (mỗi query) → tốn tiền lưu trữ + nhiễu. Log có mục đích, dùng log level.
- Log cho biết chuyện gì xảy ra, không cho biết thời gian đi đâu qua nhiều service. Câu đó là việc của distributed tracing (OpenTelemetry, truyền
traceparentqua HTTP và job): xem GĐ19 mục 9.
17. Health check (liveness/readiness), Sentry, metrics cơ bản#
Định nghĩa.
- Liveness: "process còn sống không?" — nếu không, orchestrator (K8s) restart nó.
- Readiness: "sẵn sàng nhận traffic chưa?" (đã kết nối DB/Redis chưa) — nếu chưa, LB ngừng gửi request nhưng không restart.
- Sentry: dịch vụ tập trung error tracking — gom exception, stack trace, gộp theo nhóm, cảnh báo.
- Metrics: số đo định lượng theo thời gian (request rate, error rate, p95 latency, memory) — thường Prometheus scrape.
Tại sao quan trọng. Không có health check → K8s không biết pod hỏng để restart / rút khỏi LB. Không có Sentry → lỗi production im lặng cho tới khi user than. Không có metrics → không biết hệ thống "khoẻ" hay sắp sập.
Cơ chế / ví dụ:
- Sentry:
Sentry.init({ dsn }); Sentry.captureException(err);(thường qua interceptor/global filter). - Metrics vàng ("RED"): Rate, Errors, Duration.
Pitfall / case thực tế.
- Liveness probe gọi DB → DB chậm tạm thời → K8s tưởng chết → restart bão làm mọi thứ tệ hơn. Liveness phải nhẹ, chỉ readiness mới check dependency.
- Không set alert trên error rate → Sentry đầy lỗi mà không ai nhìn.
- Log lỗi rồi nuốt (không throw/không báo) → lỗi vô hình.
18. Graceful shutdown, retry/timeout, circuit breaker, transaction rollback#
Định nghĩa.
- Graceful shutdown: khi nhận tín hiệu dừng (SIGTERM), ngừng nhận request mới, xử lý nốt request đang chạy, đóng DB/queue, rồi mới thoát.
- Timeout: giới hạn thời gian chờ một call bên ngoài; retry: thử lại khi lỗi tạm (kèm backoff).
- Circuit breaker: khi một dependency lỗi liên tục, "ngắt cầu dao" — tạm ngừng gọi nó (fail nhanh) một khoảng, rồi thử lại → tránh dồn request vào service đang chết.
- Transaction rollback: nhiều thao tác DB bọc trong 1 transaction; lỗi ở giữa → rollback toàn bộ, không để dữ liệu dở dang.
Tại sao quan trọng. Đây là các cơ chế biến hệ thống "chạy được" thành "chạy đáng tin cậy" khi deploy/restart/dependency chập chờn.
Cơ chế / ví dụ: mục này là nhà của mẫu graceful shutdown; các giai đoạn khác (GĐ10 mục 7, GĐ15, GĐ18) trỏ về đây.
Mẫu giả định pod có preStop (hoặc cơ chế tương đương) lo việc chờ gỡ endpoint. Chạy trên VM với load balancer tự quản thì đặt cờ shuttingDown, chờ vài giây cho LB thôi gửi request mới, rồi mới gọi server.close() (quy tắc 6).
Sáu quy tắc (mẫu này đã chạy thật trên Node 24.21: một request /slow 1,5 giây đang chạy khi gửi SIGTERM — mẫu cũ server.close() không await rồi process.exit(0) cắt request sau 4 ms, curl exit 52, body rỗng; mẫu mới để request hoàn tất, process thoát sau ~1,2 s, curl exit 0):
server.close()phảiawait. Nó chỉ ngừng nhận kết nối mới; callback chỉ chạy khi mọi kết nối đã đóng. Không chờ thìprocess.exit(0)cắt ngang các request đang chạy.- Kết nối keep-alive rảnh. Từ Node 19,
close()đã tự đóng kết nối rảnh (nodejs.org/api/http.html: không cần gọicloseIdleConnections()cùngclose(), gọi vẫn vô hại). Giữ lời gọi để rõ ý và để tương thích bản cũ hơn. Kết nối không bao giờ rảnh (SSE, WebSocket, request treo) thìclose()chờ mãi; đó là việc của hẹn giờ thoát cưỡng bức ở quy tắc 4, hoặc chủ động đóng stream (xem GĐ22 mục 5). - Đóng dependency (queue, DB) sau khi HTTP đã drain, không phải trước: request đang chạy còn cần DB.
- Hẹn giờ thoát cưỡng bức
setTimeout(...).unref()nhỏ hơn grace period của orchestrator.unref()để hẹn giờ không giữ process sống; Kubernetes gửiSIGKILLkhi hếtterminationGracePeriodSeconds, và thời gianpreStopcũng nằm trong khoảng đó, nên ngân sách của app = grace −preStop− một khoảng đệm. - Cờ
shuttingDownchặn gọi lặp, và là nguồn để/health/readytrả 503 (xem quy tắc 6). - Trên Kubernetes, gỡ pod khỏi endpoint cần thời gian lan ra. Chọn một nơi để chờ: hoặc
/health/readychuyển sang fail ngay khi nhậnSIGTERM(cờshuttingDown) rồi chờ vài giây trước khi gọiserver.close()(nếu đóng ngay, LB còn gửi request tới cổng đã đóng), hoặcpreStop(sleep) để pod vẫn nhận traffic trong lúc endpoint được gỡ. Đừng cộng dồn cả hai. Chi tiết ở GĐ18;preStopđã đo trên k3s, còn chuỗi end-to-end (readiness gỡ endpoint,SIGTERM, drain, thoát) chưa chạy.
- Circuit breaker: 3 trạng thái — closed (cho qua), open (chặn, fail nhanh sau khi vượt ngưỡng lỗi), half-open (thử vài request để dò hồi phục). Dùng lib như
opossum.
Pitfall / case thực tế.
- Không graceful shutdown → deploy giết pod giữa lúc đang ghi DB → nửa vời, mất/hỏng dữ liệu, rớt request user.
server.close()khôngawait, hoặcdb.destroy()chạy khi request còn đang xử lý → request đang chạy bị cắt hoặc lỗi giữa chừng (mẫu cũ trong tài liệu này làm đúng việc đó).- Retry không timeout → mỗi lần retry treo 30s, tổng cộng phút; hoặc retry lỗi không phải tạm thời (400 Bad Request) → vô ích.
- Retry storm không có circuit breaker → service phụ thuộc đang sập càng bị dội mạnh → không hồi phục nổi.
- Chuyển tiền A→B mà không transaction → trừ A xong lỗi trước khi cộng B → bốc hơi tiền.
Áp dụng vào Dự án 3#
Ghép các mảnh trên thành một feature "đặt đơn có thanh toán + thông báo realtime":
-
Rate limit endpoint tạo đơn & login: Redis counter per-user + per-IP, 429 kèm
Retry-After(mục 6).Đáp án
Đếm trên Redis cache:
rl:create-order:<userId>vàrl:login:<ip>,rl:login:<username>; dùng script Lua nguyên tử (mục 6); vượt hạn thì429kèmRetry-After. Lệnh nghiệm thu số 2 ở Khung và mã dùng chung. Code tham chiếu, chưa chạy. -
Tạo đơn: verify JWT access token ngắn hạn (mục 1); check ownership/access control ở tầng dữ liệu (mục 4); query tham số hoá/ORM (mục 5).
Đáp án
Guard JWT (access 5-15 phút) chạy trước handler;
GET /orders/:iddùngfindFirst({ where: { id, userId } })rồi 404 nếu null (xemgetở Khung dùng chung); mọi truy vấn tham số hoá hoặc qua Prisma. Lệnh nghiệm thu số 4. Code tham chiếu, chưa chạy. -
Thanh toán: client gửi
Idempotency-Keyđể chống double charge khi retry (mục 14); ghi đơn + trừ số dư trong transaction (mục 18).Đáp án
POST /v1/ordersbắt buộcIdempotency-Key, bọc bằngidem.run(userId, key, dto, fn)(mục 14, khoá lưu có tiền tốuserId) và ghi đơn trong một$transaction. Còn khe hở nếu chết giữa charge và lưu kết quả: truyền chính key sang provider (GĐ10 mục 3). Lệnh nghiệm thu số 1. Code tham chiếu, chưa chạy. -
Job queue (BullMQ): sau khi tạo đơn, đẩy job "gửi email xác nhận" + "xuất hoá đơn PDF" vào queue với
attempts+ exponential backoff, job idempotent (mục 8) — không làm nặng trong request.Đáp án
Không gọi
queue.add()trong transaction: ghi dòngoutbox(topic: 'order.created') cùng transaction, relaySKIP LOCKEDđẩy vào BullMQ vớijobId: outbox-<id>,attempts+backoff: exponential; worker idempotent theoorderId(mục 8, 8b). Code tham chiếu, chưa chạy. -
Webhook thanh toán từ provider: verify HMAC trên raw body (
timingSafeEqual), idempotent theoevent.id, trả 2xx ngay rồi đẩy xử lý vào queue (mục 15).Đáp án
Controller giữ
rawBody,verify()như mục 15 (timestamp, độ dài,timingSafeEqual),INSERT webhook_event ... ON CONFLICT DO NOTHING+ cập nhật đơn + dòng outbox trong một transaction, trả 2xx ngay. Lệnh nghiệm thu số 3 (mong đợi 401, không phải 500). Code tham chiếu, chưa chạy. -
Realtime: Nest Gateway đẩy trạng thái đơn ("đã thanh toán") tới room
user:<id>; nhiều pod → bật Redis adapter (mục 9).Đáp án
Sau khi worker xử lý
order.paid, phát tới roomuser:<id>qua Gateway;handleConnectionverify JWT và chỉjoinroom của chính user; nhiều pod thìcreateAdapter(pubClient, subClient)(mục 9). Tự kiểm: hai pod, client ở pod khác vẫn nhận tin. Code tham chiếu, chưa chạy. -
Caching: cache-aside cho danh mục sản phẩm (TTL + jitter chống stampede),
delkey khi sản phẩm đổi (mục 10); ETag/Cache-Control cho response tĩnh (mục 11).Đáp án
cached()với single-flight và TTL jitter (mục 10) cho danh mục sản phẩm;DELkey khi sản phẩm đổi; key có tiền tố tenant. Response tĩnh:Cache-Control+ETag(mục 11); dữ liệu cá nhân dùngprivate, auth dùngno-store. Code tham chiếu, chưa chạy. -
API: URL versioning
/v1, cursor pagination cho danh sách đơn/feed (mục 12, 13).Đáp án
app.enableVersioning({ type: VersioningType.URI })cho/v1; danh sách đơn dùng cursor(created_at, id)mã hoá base64url, Zod validate cursor,limitkẹp tối đa 100, index(user_id, created_at DESC, id DESC)(mục 12, 13b). Code tham chiếu, chưa chạy. -
Observability: Pino structured log +
reqIdcorrelation xuyên request (mục 16);/health/livenhẹ +/health/readycheck DB/Redis; Sentry cho exception; metrics RED (mục 17).Đáp án
nestjs-pinovớiredactchoauthorization,password,token;/health/livekhông chạm dependency;/health/readybắt lỗi DB/Redis và trả 503; Sentry ở filter toàn cục; metrics RED (mục 16, 17). Code tham chiếu, chưa chạy. -
Reliability: graceful shutdown khi deploy (drain HTTP rồi mới đóng queue + DB,
/health/readytrả 503 khi đang tắt); timeout + retry + circuit breaker cho call tới payment provider (mục 18).
Đáp án
shutdown.ts theo mẫu mục 18 (cờ, hẹn giờ thoát, await server.close(), rồi queue và DB); /health/ready trả 503 khi shuttingDown (chỉ thấy được nếu app còn nhận kết nối lúc chờ, xem mục 18 quy tắc 6); call tới provider bọc timeout + retry có backoff + circuit breaker. Lệnh nghiệm thu số 5. Code tham chiếu, chưa chạy.
Khung và mã dùng chung
Đây là khung và lệnh nghiệm thu, không phải cả dự án. Code tham chiếu, chưa chạy (dự án của bạn mới là nơi chạy); câu chữ "mong đợi" là suy ra từ code.
Sơ đồ.
Cấu trúc thư mục (theo tính năng, GĐ08 mục 5):
Hai redis, hai vai trò. CACHE_REDIS_URL (allkeys-lru: rate limit, cache) và QUEUE_REDIS_URL
(noeviction: BullMQ). Rate limit và cache nằm ở instance cache vì mất key là chấp nhận được; mất key của queue thì không.
Idempotency thanh toán không thuộc nhóm "mất key được": eviction idem:* hoặc idem:lock:* mở lại khả năng charge hai lần.
Lớp đáng tin là trạng thái PENDING → DONE trong DB (GĐ10 mục 3); nếu vẫn lưu key ở Redis thì dùng instance noeviction, không dùng instance cache.
Code then chốt: tạo đơn.
idem.run(userId, key, dto, fn) là mẫu mục 14, nhưng khoá lưu là idem:${userId}:${key} (không dùng key trần của client: user khác gửi trùng key và body sẽ nhận {id} đơn của người trước, và trùng key khác body thì bị 422 oan): khoá NX, băm dto lưu kèm, cùng key khác body thì 422, kết quả lưu 24 giờ.
Tiền là số nguyên (totalMinor). Danh sách đơn: cursor (created_at, id) mã hoá base64url, limit kẹp tối đa 100 (mục 13b).
Lệnh nghiệm thu (API chạy ở localhost:3000, có sẵn $TOKEN):
Lỗi hay gặp: queue.add() ngay trong transaction (mục 8b); parse JSON trước khi verify webhook (mất raw body); relay
chạy trong mọi pod mà không có SKIP LOCKED; cache và queue chung một Redis; quên Redis adapter nên realtime "lúc tới lúc không".
Done khi#
-
Phân biệt được JWT vs session và giải thích được khi nào chọn cái nào + refresh rotation/theft detection.
Đáp án
JWT stateless: server chỉ verify chữ ký + hạn, scale dễ, nhưng khó thu hồi trước hạn. Session stateful: tra store, logout tức thì, cần shared store. Thực tế: access JWT 5-15 phút + refresh token có state. Rotation: mỗi lần dùng cấp token mới, cái cũ đánh dấu
used_at; thấy token đã dùng bị dùng lại thì thu hồi cả family (theft detection). Sai thường gặp: JWT 30 ngày. Xem mục 1, mục 2. -
Không bao giờ lưu password plaintext; dùng argon2/bcrypt; hiểu salt & slow hash.
Đáp án
Chỉ lưu hash argon2id/bcrypt (chậm có chủ đích, salt tự sinh nằm trong chuỗi), kiểm bằng
argon2.verify. Tự kiểm: cột DB bắt đầu bằng$argon2id$, không có SHA-256 trần. Sai thường gặp: SHA-256/MD5 cho password (nhanh nên brute force rẻ); cost đến mức OOM. Xem mục 3. -
Kể được 4 nhóm OWASP (access control, injection, broken auth, SSRF) và cách phòng mỗi cái; mọi query đều tham số hoá.
Đáp án
Access control (A01:2025, gồm IDOR và SSRF): kiểm chủ sở hữu trong truy vấn, trả 404 cho tài nguyên người khác; Injection (A05): tham số hoá, allowlist cho
ORDER BY; Authentication failures (A07): slow hash, rate limit login, MFA, revoke; SSRF: allowlist host, chặn IP nội bộ/metadata, không theo redirect tuỳ ý. Tự kiểm:grepkhông thấy template string trongquery(nhận input người dùng. Xem mục 4, 5. -
Có rate limit bằng Redis (per-IP + per-user), CORS cấu hình chặt, biết khi nào CSRF là vấn đề.
Đáp án
Redis counter hoặc token bucket (script Lua để nguyên tử), key
rl:login:<ip>vàrl:login:<username>, 429 +Retry-After. CORS allowlist origin cụ thể (không*cùng credentials). CSRF chỉ là vấn đề khi auth bằng cookie tự gửi; Bearer header không dính. Xem mục 6, 7, 7b. -
Việc nặng chạy trong BullMQ worker (không trong request), có retry+backoff, DLQ, job idempotent.
Đáp án
Request chỉ
add()rồi trả 202; worker là process riêng;attempts+backoff: exponential; job hết retry nằm ởfailed(đóng vai DLQ, cần kiểm tra/chạy lại có chủ đích); handler idempotent vì giao nhận là at-least-once. Tự kiểm: tắt worker, API vẫn trả nhanh; bật lại, job được xử lý. Xem mục 8, 8b, GĐ10. -
Realtime chạy đúng khi scale nhiều pod (Redis adapter), auth khi handshake.
Đáp án
Socket.IO +
@socket.io/redis-adapterđểserver.to(room).emittới socket ở pod khác; verify JWT ở handshake (handleConnection) và chỉ cho join room của chính user. Tự kiểm: hai pod, hai client ở hai pod, mọi tin đều tới. Xem mục 9. -
Cache-aside có TTL + invalidation đúng + chống stampede; biết dùng ETag/304.
Đáp án
Cache-aside: miss thì đọc DB rồi
SET EX, ghi thì sửa DB rồiDEL; TTL có jitter; single-flight hoặc khoá chống stampede;ETag+If-None-Matchtrả 304 không body,privatecho dữ liệu cá nhân. Sai thường gặp: quênDEL; thiếu tiền tố tenant. Xem mục 10, 11. -
API có versioning; chọn đúng cursor vs offset; POST thanh toán có idempotency key.
Đáp án
/v1cho API công khai, đổi breaking mới tăng version. Cursor(created_at, id)cho dữ liệu lớn và động, offset khi nhỏ/tĩnh và cần số trang. POST thanh toán:Idempotency-Keyổn định theo ý định, khoá nguyên tử, lưu kết quả có TTL; còn khe hở nếu chết giữa charge và lưu nên truyền key sang provider. Xem mục 12, 13b, 14, GĐ10 mục 3. -
Webhook verify HMAC trên raw body + idempotent + trả 2xx nhanh.
Đáp án
HMAC-SHA256 trên raw body cùng timestamp,
timingSafeEqualsau khi kiểm độ dài, từ chối timestamp lệch quá 5 phút; ghievent.idbằngINSERT ... ON CONFLICT DO NOTHINGtrong cùng transaction với xử lý; trả 2xx nhanh, việc nặng qua outbox. Tự kiểm: sai một byte body hoặc thiếu header phải ra 401, không phải 500. Xem mục 15. -
Structured logging + request id; health check liveness/readiness tách bạch; Sentry + metrics RED.
Đáp án
Pino JSON,
reqIdtrong AsyncLocalStorage,redactfield nhạy cảm./health/livekhông chạm dependency;/health/readykiểm DB/Redis và trả 503 (bắt lỗi, hoặc khi đang shutdown). Sentry bắt exception ở filter toàn cục; metrics RED (rate, errors, duration). Sai thường gặp: liveness gọi DB nên DB chậm làm pod bị restart hàng loạt. Xem mục 16, 17. -
Graceful shutdown, timeout/retry/circuit breaker, transaction rollback on failure đều có.
Đáp án
Shutdown: cờ chống gọi lặp, hẹn giờ thoát cưỡng bức nhỏ hơn grace period,
await server.close(), rồi mới đóng queue và DB. Timeout mọi call ra ngoài, retry chỉ với lỗi tạm và có backoff, circuit breaker 3 trạng thái (closed, open, half-open), nhiều bước DB trong một$transaction. Tự kiểm: gửiSIGTERMkhi có request 1,5 giây đang chạy, request hoàn tất, exit code 0. Xem mục 18. -
Đối chiếu được API của mình với OWASP Top 10:2025 và API Top 10 2023 (BOLA, BOPLA, tiêu thụ tài nguyên), và cấu hình
helmetcó chủ đích.Đáp án
Lập bảng mã nhóm (A01 đến A10, API1 đến API10) với một dòng "chỗ nào trong code chặn nó". BOLA: truy vấn luôn kèm chủ sở hữu, trả 404. BOPLA: trả bằng DTO công khai, nhận bằng danh sách field cho phép, không
{ ...req.body }. Tiêu thụ tài nguyên: rate limit, trầnlimitphân trang, trần kích thước body.helmet()bật mặc định rồi chỉnh theo nhu cầu: nếu FE ở origin khác tải ảnh từ API thì đổicrossOriginResourcePolicysangcross-origin. Tự kiểm:curl -ithấyx-content-type-options: nosniff, không cònx-powered-by. Sai thường gặp: tưởnghelmetthay được kiểm quyền; bật HSTS cho domain chưa chắc chạy HTTPS. Xem mục 4, 4b. -
Chọn được giữa cửa sổ cố định, cửa sổ trượt và token bucket, và biết Throttler mặc định không dùng chung giữa các pod.
Đáp án
Cửa sổ cố định rẻ nhất nhưng cho gấp đôi hạn ở ranh giới cửa sổ. Cửa sổ trượt (sorted set + Lua) chính xác "N request trong mọi khoảng 1 phút" nhưng tốn bộ nhớ theo N. Token bucket cho burst có kiểm soát. Throttler lưu trong bộ nhớ process: hai instance trong thử nghiệm cho tổng 10 request khi giới hạn là 5, nên chạy nhiều pod phải dùng storage chung (Redis) và kiểm gói storage có hỗ trợ đúng bản Nest. Tự kiểm: chạy hai instance, bắn vượt hạn vào từng cái. Xem mục 6, 6b.
-
Allowlist tên cột
ORDER BYbằngMapvàtsc --strictsạch.Đáp án
const COL = new Map([['createdAt', 'created_at']])rồiCOL.get(sort) ?? 'created_at'.Mapkhông có thuộc tính kế thừa nênsort=constructorrơi về mặc định thay vì lỗi SQL 500, và không cần ép kiểu. Tự kiểm: truyềnconstructor,__proto__,toStringđều racreated_at;tsc --strictkhông báo TS7053. Sai thường gặp:COL[sort]trên object thường vớisort: string. Xem mục 5.