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. helmet 8.3.0 trên Express 5.2.1: header mặc định lấy bằng một request thật. @nestjs/throttler 6.7.1 khai báo peer @nestjs/common/@nestjs/core tớ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 sessionId random, lưu state (userId, quyền...) ở phía server (Redis/DB). Client chỉ giữ sessionId trong cookie. Mỗi request server tra state theo sessionId.
  • 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):

typescriptReady
// JWT verify (stateless) — không chạm DBconst payload = jwt.verify(token, process.env.JWT_SECRET); // throws nếu sai/hết hạn// { sub: 'user-123', role: 'admin', exp: 1735689600 }

Trade-off / khi nào chọn cái nào.

Tiêu chíJWT (stateless)Session (stateful)
Scale ngangTố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 / microservicesHợpKém tiện
Kích thước requestLớ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).

  1. Login → cấp access (ngắn) + refresh R1 (lưu DB: userId, familyId, used=false).
  2. Client dùng R1 để refresh → server đánh dấu R1.used=true, cấp R2 cùng familyId.
  3. Nếu R1 bị 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):

typescriptReady
// DB chỉ giữ sha256(token), không giữ token thô. Thiết kế đầy đủ (session, family): GĐ07 mục 11.// Bản giản lược: UPDATE chưa kiểm family đã bị thu hồi (nếu revokeFamily chỉ đặt cờ thì phải thêm điều kiện đó)// và UPDATE với create() chưa nằm trong cùng một transaction.async function refresh(oldToken: string) {  const tokenHash = sha256(oldToken);  // Một câu nguyên tử: hai request song song cùng token, chỉ một request nhận được 1 dòng  const [row] = await db.query(    `UPDATE refresh_token SET used_at = now()     WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()     RETURNING id, user_id, family_id`,    [tokenHash],  );  if (!row) {    // Không có token chưa dùng: lạ, hết hạn, hoặc đã dùng rồi    const old = await db.refreshTokens.findByHash(tokenHash);    if (old?.usedAt) {      // Token cũ bị dùng lại → nghi bị đánh cắp → thu hồi cả family      await db.refreshTokens.revokeFamily(old.familyId);      throw new UnauthorizedException('token reuse detected');    }    throw new UnauthorizedException('invalid token');  }  // create() sinh token ngẫu nhiên, lưu sha256 của nó và trả bản thô đúng một lần  const next = await db.refreshTokens.create({ familyId: row.family_id, userId: row.user_id });  return { accessToken: signAccess(row.user_id), refreshToken: next.token };}

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.

textReady
 Request A (R1)              Postgres                  Request B (R1)   | UPDATE ... used_at IS NULL |                           |   |--------------------------->| khoá hàng R1              |   |                            |<--- UPDATE ... (cùng R1) -|  chờ khoá   |<-- 1 dòng (RETURNING) -----| commit                    |   |                            |--- re-check: used_at != NULL   |                            |--- 0 dòng --------------->|   | cấp R2                     |                      | R1 đã dùng   |                            |                      | => revokeFamily, 401
  • 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ận usedAt mới hơn vài giây mà không coi là theft.
  • Replay sau đó (kẻ cắp dùng R1 muộn hơn): 0 dòng, usedAt có giá trị, family bị thu hồi, R2 cũng chết.
  • Sai thường gặp: đọc used bằng SELECT rồi mới UPDATE (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, 1 UnauthorizedException.

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

typescriptReady
import * as argon2 from 'argon2';const hash = await argon2.hash(password, { type: argon2.argon2id });// $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>  ← salt nằm sẵn trong chuỗiconst ok = await argon2.verify(hash, inputPassword); // true/false

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ùng argon2.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/:id chỉ check "đã login" mà không check order.userId === req.user.id → IDOR. Fix: luôn kiểm quyền ở tầng dữ liệu, không tin id từ 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.
typescriptReady
// Truy vấn đã ràng buộc chủ sở hữu: không thấy = không tồn tại với người nàyconst order = await orders.findByIdAndUser(id, req.user.id);if (!order) throw new NotFoundException();
  • 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ặc http://localhost:6379). Fix: allowlist domain, chặn IP nội bộ/metadata, không cho redirect tuỳ ý.

typescriptReady
const url = new URL(input);if (!ALLOWED_HOSTS.has(url.hostname)) throw new BadRequestException('host not allowed');// + chặn 127.0.0.1, 169.254.x, 10.x, 192.168.x, ::1

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ómVới backend Node thì nghĩ tớiXem
A01Broken Access Controlkiểm quyền theo chủ sở hữu, SSRFmục 4
A02Security Misconfigurationheader mặc định, CORS lỏng, debug bật ở productionmục 4b, 7
A03Software Supply Chain Failureslockfile + npm ci, npm audit, ghim versionGĐ15
A04Cryptographic Failureshash mật khẩu đúng, TLS, không tự chế mã hoámục 3
A05Injectiontham số hoá, allowlist ORDER BYmục 5
A06Insecure Designthiếu rate limit, thiếu idempotency ngay từ thiết kếmục 6, 14
A07Authentication Failuresbrute force, session/refresh không thu hồimục 1, 2, 6
A08Software or Data Integrity Failureswebhook không verify chữ ký, nạp code/dữ liệu không kiểmmục 15
A09Security Logging and Alerting Failureskhông log sự kiện đăng nhập/từ chối quyền, hoặc log lộ secretmục 16
A10Mishandling of Exceptional Conditionslỗi không bắt lộ stack trace, "fail-open" khi dependency chếtmụ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
API1Broken Object Level Authorization (BOLA)GET /orders/:id không kiểm chủ sở hữu: chính là IDOR ở mục 4
API3Broken Object Property Level Authorization (BOPLA)trả dư field (passwordHash, role) hoặc nhận dư field (mass assignment: { role: 'admin' } trong body)
API4Unrestricted Resource Consumptionkhô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).

typescriptReady
// BOPLA: trả bằng DTO công khai, nhận bằng danh sách field cho phépconst toPublic = (u: User) => ({ id: u.id, name: u.name })await users.update(id, { name: dto.name }) // không bao giờ: { ...req.body }

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:

typescriptReady
import helmet from 'helmet'app.use(helmet())

Đã 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: nosnifftrì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-origincô lập cửa sổ; chặn origin khác nhúng tài nguyên của bạn
referrer-policy: no-referrerkhông gửi URL nguồn sang trang khác
x-xss-protection: 0tắ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-origin là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ì đặt helmet({ 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ụ:

typescriptReady
// ❌ SAI — ghép chuỗidb.query(`SELECT * FROM users WHERE email = '${email}'`);// ✅ ĐÚNG — tham số hoá ($1 là placeholder)db.query('SELECT * FROM users WHERE email = $1', [email]);// ✅ ORM (Prisma) tự tham số hoáprisma.user.findUnique({ where: { email } });

Pitfall / case thực tế.

  • Nghĩ "dùng ORM là an toàn tuyệt đối" → sai khi dùng queryRaw ghép chuỗi, hoặc truyền tên cột/ORDER BY từ 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":

textReady
Ghép chuỗi : SELECT * FROM users WHERE email = '' OR '1'='1'              -> trả MỌI user (bypass)Tham số hoá: SELECT * FROM users WHERE email = $1, với $1 = chuỗi trên              -> 0 dòng

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:

typescriptReady
const COL = new Map([['createdAt', 'created_at'], ['name', 'name']])const orderBy = (sort: string) => `ORDER BY ${COL.get(sort) ?? 'created_at'}`

Đã 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):

typescriptReady
async function allow(key: string, limit = 100, windowSec = 60) {  const n = await redis.incr(key);  if (n === 1) await redis.expire(key, windowSec); // set TTL cho lần đầu  return n <= limit;}// key = `rl:login:${ip}` hoặc `rl:api:${userId}`

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

typescriptReady
const FIXED_WINDOW = `local n = redis.call('INCR', KEYS[1])if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) endreturn n`async function allow(key: string, limit = 100, windowSec = 60) {  const n = (await redis.eval(FIXED_WINDOW, 1, key, windowSec)) as number  return n <= limit}

Token bucket đúng nghĩa (cho burst, nạp đều), cũng một script để đọc-tính-ghi nguyên tử:

typescriptReady
const BUCKET = `local cap, rate, now = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])local s = redis.call('HMGET', KEYS[1], 't', 'ts')local t = tonumber(s[1]) or caplocal ts = tonumber(s[2]) or nowt = math.min(cap, t + (now - ts) * rate)          -- nạp theo thời gian trôilocal ok = 0if t >= 1 then t = t - 1; ok = 1 endredis.call('HSET', KEYS[1], 't', t, 'ts', now)redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate * 1000) * 2)return { ok, tostring(t) }`   -- t là số thực: trả dạng chuỗi (Redis cắt số thực thành số nguyên)// cap = 10 token, rate = 5 token/giâyconst [okFlag, left] = (await redis.eval(  BUCKET, 1, `rl:api:${userId}`, 10, 5, Date.now() / 1000)) as [number, string]const ok = okFlag === 1const retryAfter = ok ? 0 : Math.ceil((1 - Number(left)) / 5)   // giây

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ánCá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ì reset100 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 - windowtốn bộ nhớ theo số request trong cửa sổ
Token bucket (mục 6)xô token nạp đềukhó 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ử):

typescriptReady
const SLIDING = `local now, win, limit, id = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), ARGV[4]redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - win)if redis.call('ZCARD', KEYS[1]) < limit then  redis.call('ZADD', KEYS[1], now, id)  redis.call('PEXPIRE', KEYS[1], win)  return {1, 0}endlocal oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')return {0, tonumber(oldest[2]) + win - now}`let seq = 0async function allowSliding(key: string, limit: number, windowMs: number) {  const now = Date.now()  const [ok, waitMs] = (await redis.eval(    SLIDING, 1, key, now, windowMs, limit, `${now}-${seq++}`)) as [number, number]  return { ok: ok === 1, retryAfterSec: Math.ceil(waitMs / 1000) }}

Đã 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.

typescriptReady
@Module({  imports: [ThrottlerModule.forRoot({ throttlers: [{ ttl: 60_000, limit: 5 }] })],  controllers: [AppController],  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],})class AppModule {}// ghi đè theo route, hoặc bỏ qua route (health check)@Throttle({ default: { limit: 2, ttl: 60_000 } }) @Get('login') login() { /* ... */ }@SkipThrottle() @Get('health') health() { /* ... */ }

Đã 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-Origin sai 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:

typescriptReady
// 1) Cookie: SameSite=Lax/Strict chặn phần lớn CSRF cross-siteres.cookie('sid', id, { httpOnly: true, secure: true, sameSite: 'lax' });// 2) CSRF token (double-submit) cho form nhạy cảm// 3) Kiểm Origin/Referer header cho request đổi state

Pitfall / case thực tế.

  • Đặt Access-Control-Allow-Origin: * cùng Allow-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.

Đị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 httpOnlylocalStorage
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 cookieKhô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ệtVụ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ị.

typescriptReady
// Mặc định cho web app: refresh token trong cookie httpOnly, access token ngắn hạn trong bộ nhớres.cookie('refresh_token', token, {  httpOnly: true,  secure: true,                  // chỉ qua HTTPS  sameSite: 'lax',               // 'strict' nếu không có luồng OAuth redirect trở về  path: '/auth/refresh',         // giới hạn: cookie CHỈ được gửi tới đúng endpoint này  maxAge: 30 * 24 * 3600 * 1000,});// Access token (5–15 phút) trả trong body → client giữ TRONG BỘ NHỚ, không persist.// Mất khi refresh trang là chấp nhận được: gọi /auth/refresh để lấy lại.
  • 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 process job; thành công → xoá; lỗi → retry theo attempts + 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):

typescriptReady
// producer trong request — trả về ngayawait emailQueue.add('welcome', { userId }, {  attempts: 5,  backoff: { type: 'exponential', delay: 1000 },  removeOnComplete: 1000,});// worker (tiến trình riêng)new Worker('email', async (job) => {  await mailer.sendWelcome(job.data.userId); // ném lỗi → BullMQ tự retry}, { connection });// lịch định kỳ (cron) — Job Scheduler, key ổn định nên gọi lại lúc boot không sinh lịch trùngawait cleanupQueue.upsertJobScheduler(  'purge-nightly',  { pattern: '0 2 * * *', tz: 'Asia/Ho_Chi_Minh' },  { name: 'purge', data: {} },);

Từ BullMQ 6, option repeat của queue.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ùng upsertJobScheduler (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òn noeviction (BullMQ yêu cầu) làm cache ghi lỗi thay vì tự dọn. Tách hai instance: cache dùng allkeys-lru hoặc volatile-lru, queue dùng maxmemory-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:

typescriptReady
// ❌ Cả hai thứ tự đều hỏngawait prisma.$transaction(async (tx) => {  await tx.order.create({ data });  await queue.add('send-invoice', { orderId });   // (A) queue.add THÀNH CÔNG,});                                               //     rồi transaction ROLLBACK                                                  // → gửi hoá đơn cho đơn hàng KHÔNG TỒN TẠIawait prisma.order.create({ data });               // (B) DB commit xong,await queue.add('send-invoice', { orderId });      //     Redis chết ở đây                                                   // → đơn hàng tồn tại nhưng KHÔNG BAO GIỜ có hoá đơn

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.

textReady
CREATE TABLE outbox (  id            BIGSERIAL PRIMARY KEY,  topic         TEXT NOT NULL,             -- 'order.created'  payload       JSONB NOT NULL,  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),  published_at  TIMESTAMPTZ,               -- NULL = chưa đẩy vào queue  attempts      INT NOT NULL DEFAULT 0);-- partial theo đúng truy vấn của relay (ORDER BY id): index chỉ chứa hàng chưa đẩy nên luôn nhỏCREATE INDEX idx_outbox_pending ON outbox (id) WHERE published_at IS NULL;
typescriptReady
// 1) Ghi nghiệp vụ + ý định trong CÙNG transaction — nguyên tử, không có khe hởawait prisma.$transaction(async (tx) => {  const order = await tx.order.create({ data });  await tx.outbox.create({ data: { topic: 'order.created', payload: { orderId: order.id } } });});// 2) Relay chạy riêng (mỗi 1–2 giây, hoặc dùng LISTEN/NOTIFY để gần như tức thì)async function relay() {  // Khoá hàng chỉ sống đến hết transaction → SELECT ... FOR UPDATE phải nằm TRONG $transaction  await prisma.$transaction(async (tx) => {    // SKIP LOCKED: chạy nhiều relay song song, relay sau bỏ qua hàng relay trước đang giữ    const rows = await tx.$queryRaw<Outbox[]>`      SELECT id, topic, payload FROM outbox      WHERE published_at IS NULL      ORDER BY id LIMIT 50      FOR UPDATE SKIP LOCKED`;    for (const row of rows) {      // jobId = id outbox → BullMQ tự khử trùng lặp nếu relay chạy lại      await queue.add(row.topic, row.payload, { jobId: `outbox-${row.id}` });      await tx.$executeRaw`UPDATE outbox SET published_at = now() WHERE id = ${row.id}`;    }  }, { timeout: 30_000 }); // Prisma 7: mặc định 5 giây (đọc từ mã nguồn 7.10). Giữ khoá suốt lúc gọi Redis nên LIMIT đừng lớn}

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 Upgrade rồi chuyển sang WS.

Ví dụ (Nest Gateway):

typescriptReady
@WebSocketGateway({ cors: true })export class ChatGateway {  @WebSocketServer() server: Server;  @SubscribeMessage('join')  onJoin(@ConnectedSocket() socket: Socket, @MessageBody() room: string) {    socket.join(room);  }  @SubscribeMessage('message')  onMsg(@MessageBody() { room, text }: { room: string; text: string }) {    this.server.to(room).emit('message', text); // đẩy tới cả phòng  }}

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.

typescriptReady
import { createAdapter } from '@socket.io/redis-adapter';io.adapter(createAdapter(pubClient, subClient));

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

typescriptReady
async function getUser(id: string) {  const key = `user:${id}`;  const cached = await redis.get(key);  if (cached) return JSON.parse(cached);         // hit  const user = await db.users.findById(id);      // miss → DB  await redis.set(key, JSON.stringify(user), 'EX', 300); // TTL 5 phút  return user;}async function updateUser(id, data) {  await db.users.update(id, data);  await redis.del(`user:${id}`);                 // invalidate}

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 del cache → 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:

typescriptReady
const inflight = new Map<string, Promise<unknown>>()async function cached<T>(key: string, ttlSec: number, load: () => Promise<T>): Promise<T> {  const hit = await redis.get(key)  if (hit) return JSON.parse(hit) as T  let p = inflight.get(key) as Promise<T> | undefined  if (!p) {    p = (async () => {      const value = await load()                                   // chỉ MỘT lần xuống DB      const jitter = Math.floor(Math.random() * ttlSec * 0.1)      // +0..10% TTL      await redis.set(key, JSON.stringify(value), 'EX', ttlSec + jitter)      return value    })().finally(() => inflight.delete(key))    inflight.set(key, p)  }  return p}

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

textReady
Lần 1:  GET /posts/1        → 200 + ETag: "abc123" + Cache-Control: max-age=60Lần 2:  GET /posts/1        If-None-Match: "abc123"        → 304 Not Modified (không body)  hoặc 200 + ETag mới nếu đã đổi
typescriptReady
@Get(':id')getPost(@Param('id') id, @Req() req, @Res() res) {  const post = this.svc.find(id);  const etag = `"${hash(post.updatedAt)}"`;  res.set('Cache-Control', 'public, max-age=60');  res.set('ETag', etag);  if (req.headers['if-none-match'] === etag) return res.status(304).end();  return res.json(post);}

Pitfall / case thực tế.

  • Đặt Cache-Control: public, max-age=lớn cho response chứa dữ liệu riêng tư của user → CDN/proxy cache và phục vụ cho user khác. Dùng private cho dữ liệu cá nhân.
  • Không set no-store cho 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ấyRõ, dễ test bằng browser/curlẨn, cần đọc header
Cache/routeDễ (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):

typescriptReady
// URI versioningapp.enableVersioning({ type: VersioningType.URI }); // → /v1/...@Controller({ path: 'users', version: '1' })// hoặc Header versioningapp.enableVersioning({ type: VersioningType.HEADER, header: 'X-API-Version' });

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 100000 bắ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):

typescriptReady
// GET /posts?after=<cursor>&limit=20const rows = await db.query(  'SELECT * FROM posts WHERE id < $1 ORDER BY id DESC LIMIT $2',  [cursor ?? Number.MAX_SAFE_INTEGER, limit],);const nextCursor = rows.length === limit ? rows.at(-1).id : null;return { data: rows, nextCursor };

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:

textReady
-- ĐÚNG: row-value comparison — Postgres so sánh cả bộ, và dùng được index képSELECT * FROM postsWHERE (created_at, id) < ($1, $2)ORDER BY created_at DESC, id DESCLIMIT 20;CREATE INDEX idx_posts_keyset ON posts (created_at DESC, id DESC);
textReady
-- SAI: bỏ sót mọi hàng cùng created_at nhưng id lớn hơn cursorWHERE created_at < $1 AND id < $2

Đ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ô.

typescriptReady
type Cursor = { createdAt: string; id: string }const encode = (c: Cursor) => Buffer.from(JSON.stringify(c)).toString('base64url')const decode = (raw: string): Cursor => {  const c = CursorSchema.parse(JSON.parse(Buffer.from(raw, 'base64url').toString()))  return c                     // Zod validate — cursor là INPUT NGƯỜI DÙNG}

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ả hasNextPageRẻ nhất. Lấy limit + 1 hàng để biết còn trang sau
Ước lượng từ EXPLAIN / pg_class.reltuplesNhanh, xấp xỉ. Đủ cho "khoảng 12.000 kết quả"
Đếm có trần: COUNT(*) trên subquery LIMIT 1000Hiển thị "999+" như GitHub/Google làm
Bảng đếm cập nhật bằng trigger/jobChí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):

textReady
 vị trí  created_at  id   1     10:00       9   2     10:00       5     <- cursor = (10:00, 5)   3     10:00       2   4     09:59       8     <- thuộc trang sau, nhưng id 8 > 5
  • Đú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_at khô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ùng id < $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ọn Seq Scan + Sort, không phải lỗi); khi đó EXPLAIN (ANALYZE) SELECT ... phải có Index Scan using idx_posts_keyset, không có node Sort.

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ề:

typescriptReady
// before: (created_at, id) > cursor  ORDER BY created_at ASC, id ASC  → rồi rows.reverse()

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.
  • limit không có trần. ?limit=100000 là 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ế.

  1. Client sinh key (UUID) cho ý định thanh toán, gửi header Idempotency-Key.
  2. 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.

Ví dụ (TS):

typescriptReady
async function charge(key: string, body: ChargeDto) {  const existing = await redis.get(`idem:${key}`);  if (existing) return JSON.parse(existing); // trả kết quả cũ, không làm lại  // SETNX để chống 2 request song song cùng key  const locked = await redis.set(`idem:lock:${key}`, '1', 'NX', 'EX', 30);  if (!locked) throw new ConflictException('in progress');  const result = await paymentProvider.charge(body);  await redis.set(`idem:${key}`, JSON.stringify(result), 'EX', 86400);  return result;}

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.

textReady
 10 request song song cùng key, chưa ai xong:   1 request giữ khoá -> charge     9 request -> 409 "in progress" Retry sau khi xong (trong 24h)   : trả lại kết quả cũ, không charge Retry trong 30 s sau khi charge lỗi: 409 (khoá chưa hết), sau đó chạy lại được

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ộp t= và v1= trong một header Stripe-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):

textReady
CREATE TABLE webhook_event (  id          TEXT PRIMARY KEY,                  -- event.id của provider  received_at TIMESTAMPTZ NOT NULL DEFAULT now() -- để dọn bản ghi cũ theo lịch);
typescriptReady
import * as crypto from 'crypto';const TOLERANCE_S = 300; // 5 phútfunction verify(rawBody: Buffer, timestamp: string | undefined, sig: string | undefined, secret: string) {  if (!timestamp || !sig) return false;                    // thiếu header → từ chối, không ném lỗi  const ts = Number(timestamp);  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_S) return false; // chống replay  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();  const given = Buffer.from(sig, 'hex');  if (given.length !== expected.length) return false;      // timingSafeEqual ném RangeError nếu độ dài khác  return crypto.timingSafeEqual(given, expected);          // constant-time, chống timing attack}@Post('webhook')async handle(@Req() req, @Headers('x-timestamp') ts, @Headers('x-signature') sig) {  if (!verify(req.rawBody, ts, sig, SECRET)) throw new UnauthorizedException();  const event = JSON.parse(req.rawBody.toString());  await prisma.$transaction(async (tx) => {    // "đã thấy" và "xử lý" nằm trong CÙNG transaction: lỗi giữa chừng → rollback cả hai,    // provider retry sẽ được xử lý lại thay vì bị bỏ qua vì "đã thấy"    const claimed = await tx.$executeRaw`      INSERT INTO webhook_event (id) VALUES (${event.id}) ON CONFLICT DO NOTHING`;    if (claimed === 0) return;                             // 0 dòng = event.id đã xử lý → idempotent    await applyEvent(tx, event);                           // chỉ ghi DB (cộng tiền, ghi đơn, dòng outbox)  });  return { ok: true };                                     // trả 2xx nhanh; việc nặng đi qua outbox → queue}

Đã 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ùng timingSafeEqual sau 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).
  • markSeen trước rồi process sau, hai bước rời nhau → process lỗ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 (process rồi mới markSeen) → 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:

RESTGraphQLgRPCtRPC
Định dạngJSONJSONProtobuf (nhị phân)JSON
Hợp đồngOpenAPI (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-fetchingCóGiải quyết đượcCóCó
Cache HTTP (ETag/CDN)✅ tự nhiên❌ khó (POST một endpoint)❌❌
Độ phức tạp vận hànhThấpCaoTrung bìnhRấ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-id từ 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):

typescriptReady
// nestjs-pino tự gắn reqId vào mọi log của requestapp.useLogger(app.get(Logger));logger.info({ userId, orderId, latencyMs: 42 }, 'order created');// → {"level":30,"reqId":"c1a...","userId":"u1","orderId":"o9","latencyMs":42,"msg":"order created"}

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.log chuỗ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 traceparent qua 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ụ:

typescriptReady
@Get('/health/live')  live() { return { status: 'ok' }; }        // nhẹ, luôn ok nếu process chạy@Get('/health/ready')                                            // kiểm dependencyasync ready() {  try {    await db.query('SELECT 1');    await redis.ping();  } catch {    throw new ServiceUnavailableException();                      // lỗi không bắt sẽ thành 500, LB cần 503  }  return { status: 'ready' };}
  • 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.

typescriptReady
// Graceful shutdown (http thuần; Nest: app.enableShutdownHooks() + onModuleDestroy, cùng nguyên tắc)let shuttingDown = false;                  // /health/ready đọc cờ này và trả 503 khi true (chỉ thấy được nếu app còn nhận kết nối, xem quy tắc 6)async function shutdown(signal: string) {  if (shuttingDown) return;                // SIGTERM và SIGINT có thể đến cùng lúc  shuttingDown = true;  // Quá hạn thì thoát cưỡng bức. Phải NHỎ HƠN grace period của orchestrator.  setTimeout(() => process.exit(1), 10_000).unref();  try {    // close() chỉ ngừng nhận kết nối MỚI; callback chạy khi MỌI kết nối đã đóng → phải await    const closed = new Promise<void>((resolve, reject) =>      server.close((err) =>                // server chưa listen → ERR_SERVER_NOT_RUNNING, coi như đã đóng        err && (err as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING' ? reject(err) : resolve()));    server.closeIdleConnections();         // tuỳ chọn từ Node 19 (xem quy tắc 2)    await closed;                          // request đang chạy được làm nốt    await queue.close();                   // đóng dependency SAU khi HTTP đã drain    await db.destroy();    process.exit(0);  } catch (err) {    console.error('shutdown lỗi', err);    // không để thành unhandled rejection    process.exit(1);  }}process.on('SIGTERM', () => void shutdown('SIGTERM'));process.on('SIGINT', () => void shutdown('SIGINT'));

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

  1. server.close() phải await. 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.
  2. 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ọi closeIdleConnections() cùng close(), 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).
  3. Đóng dependency (queue, DB) sau khi HTTP đã drain, không phải trước: request đang chạy còn cần DB.
  4. 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ửi SIGKILL khi hết terminationGracePeriodSeconds, và thời gian preStop cũng nằm trong khoảng đó, nên ngân sách của app = grace − preStop − một khoảng đệm.
  5. Cờ shuttingDown chặn gọi lặp, và là nguồn để /health/ready trả 503 (xem quy tắc 6).
  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/ready chuyển sang fail ngay khi nhận SIGTERM (cờ shuttingDown) rồi chờ vài giây trước khi gọi server.close() (nếu đóng ngay, LB còn gửi request tới cổng đã đóng), hoặc preStop (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.
typescriptReady
@Get('/health/ready')async ready() {  if (shuttingDown) throw new ServiceUnavailableException();   // 503: LB ngừng gửi request mới  await db.query('SELECT 1');  await redis.ping();  return { status: 'ready' };}
typescriptReady
// Timeout + retry có backoffasync function callWithRetry(fn, tries = 3) {  for (let i = 0; i < tries; i++) {    try { return await withTimeout(fn(), 3000); }    catch (e) { if (i === tries - 1) throw e; await sleep(2 ** i * 100); }  }}// Transaction rollback (Prisma)await prisma.$transaction(async (tx) => {  await tx.account.update({ where: { id: a }, data: { balance: { decrement: 100 } } });  await tx.account.update({ where: { id: b }, data: { balance: { increment: 100 } } });}); // ném lỗi giữa chừng → rollback cả hai
  • 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ông await, hoặc db.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":

  1. 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ì 429 kèm Retry-After. Lệnh nghiệm thu số 2 ở Khung và mã dùng chung. Code tham chiếu, chưa chạy.

  2. 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/:id dùng findFirst({ where: { id, userId } }) rồi 404 nếu null (xem get ở 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.

  3. 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/orders bắt buộc Idempotency-Key, bọc bằng idem.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.

  4. 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òng outbox (topic: 'order.created') cùng transaction, relay SKIP LOCKED đẩy vào BullMQ với jobId: outbox-<id>, attempts + backoff: exponential; worker idempotent theo orderId (mục 8, 8b). Code tham chiếu, chưa chạy.

  5. Webhook thanh toán từ provider: verify HMAC trên raw body (timingSafeEqual), idempotent theo event.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.

  6. 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 room user:<id> qua Gateway; handleConnection verify JWT và chỉ join room 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.

  7. Caching: cache-aside cho danh mục sản phẩm (TTL + jitter chống stampede), del key 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; DEL key 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ùng private, auth dùng no-store. Code tham chiếu, chưa chạy.

  8. 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, limit kẹ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.

  9. Observability: Pino structured log + reqId correlation xuyên request (mục 16); /health/live nhẹ + /health/ready check DB/Redis; Sentry cho exception; metrics RED (mục 17).

    Đáp án

    nestjs-pino với redact cho authorization, password, token; /health/live không chạm dependency; /health/ready bắ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.

  10. Reliability: graceful shutdown khi deploy (drain HTTP rồi mới đóng queue + DB, /health/ready trả 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ơ đồ.

textReady
 client --POST /v1/orders (Idempotency-Key, Bearer JWT)--> API   |        rate limit IP -> auth -> rate limit user -> validate   |                       1 transaction: INSERT order + INSERT outbox   |<-- 201 {id} ---------- COMMIT                 relay (SKIP LOCKED) -> BullMQ (Redis queue)                         | email xác nhận, PDF hoá đơn (worker) provider --POST /v1/webhooks/pay (HMAC, raw body)--> API   1 transaction: INSERT webhook_event + UPDATE order + INSERT outbox   outbox -> worker -> Socket.IO room user:<id> -> client

Cấu trúc thư mục (theo tính năng, GĐ08 mục 5):

textReady
src/modules/orders/    orders.controller.ts  orders.service.ts                       orders.repository.ts  dto/src/modules/payments/  webhook.controller.ts  webhook-verify.tssrc/modules/realtime/  orders.gateway.tssrc/platform/          rate-limit.guard.ts  idempotency.ts  outbox-relay.ts                       health.controller.ts  shutdown.ts

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.

typescriptReady
@Post('orders')async create(@Req() req, @Headers('idempotency-key') key: string, @Body() dto: CreateOrderDto) {  if (!key) throw new BadRequestException('Idempotency-Key required')  return this.idem.run(req.user.id, key, dto, async () => {    return this.prisma.$transaction(async (tx) => {      const order = await tx.order.create({ data: { userId: req.user.id, totalMinor: dto.totalMinor } })      await tx.outbox.create({ data: { topic: 'order.created', payload: { orderId: order.id } } })      return { id: order.id }    })  })}@Get('orders/:id')async get(@Req() req, @Param('id', ParseUUIDPipe) id: string) {  const o = await this.prisma.order.findFirst({ where: { id, userId: req.user.id } })  if (!o) throw new NotFoundException()          // đơn của người khác: 404, không lộ sự tồn tại  return o}

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

bashReady
# 1) Idempotency: hai lần cùng key => cùng id, DB chỉ có 1 đơncurl -s -XPOST localhost:3000/v1/orders -H "Authorization: Bearer $TOKEN" \  -H 'Idempotency-Key: 7f1c9d9e-0b53-4c0e-a1a4-2d7d1d9a1e11' -H 'content-type: application/json' -d '{"totalMinor":5000}'# (chạy lại y hệt) mong đợi: cùng {"id": "..."}# 2) Rate limit tạo đơn (giả sử hạn 100/phút/user): mỗi vòng một Idempotency-Key mới#    (cùng key sẽ ra kết quả replay, không tính là đơn mới); request thứ 101 là 429for i in $(seq 1 101); do curl -s -o /dev/null -w '%{http_code}\n' -XPOST localhost:3000/v1/orders \  -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)" \  -H 'content-type: application/json' -d '{"totalMinor":5000}'; done | sort | uniq -c# mong đợi: 100 x 201, 1 x 429 (có Retry-After)# Chạy lệnh này sau khi cửa sổ 1 phút của lệnh 1 đã qua, hoặc dùng $TOKEN của user khác: lệnh 1 đã tiêu# 2 lượt của cùng user, nên chạy liền sau nó ra 98 x 201 + 3 x 429 (cửa sổ cố định theo user).# 3) Webhook sai chữ ký => 401; không có header => 401 (không phải 500)curl -s -o /dev/null -w '%{http_code}\n' -XPOST localhost:3000/v1/webhooks/pay -d '{"id":"evt_1"}'# mong đợi: 401# 4) Đơn của người khác => 404curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/v1/orders/<uuid-của-user-khác> -H "Authorization: Bearer $TOKEN"# 5) Shutdown: gửi SIGTERM khi có request chậm đang chạy: request kia vẫn hoàn tất, process thoát exit 0.#    /health/ready chỉ thấy 503 ở biến thể "đặt cờ, chờ vài giây, rồi mới server.close()" (mục 18, quy tắc 6).#    Với mẫu preStop + server.close() ngay, probe sau SIGTERM bị từ chối kết nối (curl exit 7), không có 503.

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: grep không thấy template string trong query( 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).emit tớ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ồi DEL; TTL có jitter; single-flight hoặc khoá chống stampede; ETag + If-None-Match trả 304 không body, private cho dữ liệu cá nhân. Sai thường gặp: quên DEL; 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

    /v1 cho 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, timingSafeEqual sau khi kiểm độ dài, từ chối timestamp lệch quá 5 phút; ghi event.id bằng INSERT ... ON CONFLICT DO NOTHING trong 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, reqId trong AsyncLocalStorage, redact field nhạy cảm. /health/live không chạm dependency; /health/ready kiể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ửi SIGTERM khi 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 helmet có 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ần limit phâ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ì đổi crossOriginResourcePolicy sang cross-origin. Tự kiểm: curl -i thấy x-content-type-options: nosniff, không còn x-powered-by. Sai thường gặp: tưởng helmet thay đượ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 BY bằng Map và tsc --strict sạch.

    Đáp án

    const COL = new Map([['createdAt', 'created_at']]) rồi COL.get(sort) ?? 'created_at'. Map không có thuộc tính kế thừa nên sort=constructor rơi về mặc định thay vì lỗi SQL 500, và không cần ép kiểu. Tự kiểm: truyền constructor, __proto__, toString đều ra created_at; tsc --strict không báo TS7053. Sai thường gặp: COL[sort] trên object thường với sort: string. Xem mục 5.