HARRY-7 — GĐ8 — API thực chiến: security, jobs, realtime, caching, pagination, observability
GĐ8 — 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.
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):
// JWT verify (stateless) — không chạm DB
const 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 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):
async function refresh(oldToken: string) {
const row = await db.refreshTokens.findByToken(oldToken);
if (!row) throw new UnauthorizedException('unknown token');
if (row.used) {
// Token cũ bị dùng lại → nghi bị đánh cắp → thu hồi cả family
await db.refreshTokens.revokeFamily(row.familyId);
throw new UnauthorizedException('token reuse detected');
}
await db.refreshTokens.markUsed(row.id);
const next = await db.refreshTokens.create({ familyId: row.familyId, userId: row.userId });
return { accessToken: signAccess(row.userId), refreshToken: next.token };
}Pitfall / case thực tế.
- Lưu refresh token plaintext trong DB → DB rò rỉ = mất hết. Nên hash refresh token trước khi lưu (giống password).
- 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. Cần khoá theo token hoặc cho grace window ngắn để tránh false-positive theft.
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):
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ỗi
const ok = await argon2.verify(hash, inputPassword); // true/falsePitfall / 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:
- A01 — Broken Access Control (số 1 hiện nay). 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.
const order = await orders.findById(id);
if (order.userId !== req.user.id) throw new ForbiddenException();A03 — Injection (SQL/NoSQL/command). Ghép input người dùng vào query/lệnh. Xem mục 5.
A07 — Identification & 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).
A10 — SSRF (Server-Side Request Forgery). 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ỳ ý.
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, ::1Pitfall / 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ề.
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ụ:
// ❌ SAI — ghép chuỗi
db.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
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).
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):
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.
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:
// 1) Cookie: SameSite=Lax/Strict chặn phần lớn CSRF cross-site
res.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 statePitfall / 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ị.
// 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Đ9 — 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):
// producer trong request — trả về ngay
await 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 });
// repeatable (cron)
await cleanupQueue.add('purge', {}, { repeat: { pattern: '0 2 * * *' } });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.
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:
// ❌ Cả hai thứ tự đều hỏng
await 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ẠI
await 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á đơnTrườ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.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL, -- 'order.created'
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ -- NULL = chưa đẩy vào queue
);
CREATE INDEX ON outbox (created_at) WHERE processed_at IS NULL; -- partial: index luôn nhỏ// 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: { type: '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() {
const rows = await prisma.$queryRaw<Outbox[]>`
SELECT * FROM outbox WHERE processed_at IS NULL
ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED`; -- SKIP LOCKED: chạy nhiều relay song song không giẫm chân
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.type, row.payload, { jobId: `outbox-${row.id}` });
await prisma.outbox.update({ where: { id: row.id }, data: { processedAt: new Date() } });
}
}Đả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 processed_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):
@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.
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.
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):
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
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.
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):
Lần 1: GET /posts/1 → 200 + ETag: "abc123" + Cache-Control: max-age=60
Lần 2: GET /posts/1
If-None-Match: "abc123"
→ 304 Not Modified (không body) hoặc 200 + ETag mới nếu đã đổi@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ớ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):
// URI versioning
app.enableVersioning({ type: VersioningType.URI }); // → /v1/...
@Controller({ path: 'users', version: '1' })
// hoặc Header versioning
app.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 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):
// GET /posts?after=<cursor>&limit=20
const 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:
-- ĐÚNG: row-value comparison — Postgres so sánh cả bộ, và dùng được index kép
SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
CREATE INDEX idx_posts_keyset ON posts (created_at DESC, id DESC);-- SAI: bỏ sót mọi hàng cùng created_at nhưng id lớn hơn cursor
WHERE 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Đ4).
Mã hoá cursor — đừng trả id thô.
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ả 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ó?
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ề:
// 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.
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Đ10 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):
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.
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ý).
- 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):
import * as crypto from 'crypto';
function verify(rawBody: Buffer, sig: string, secret: string) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
// constant-time compare chống timing attack
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
@Post('webhook')
async handle(@Req() req, @Headers('x-signature') sig) {
if (!verify(req.rawBody, sig, SECRET)) throw new UnauthorizedException();
const event = JSON.parse(req.rawBody.toString());
if (await seen(event.id)) return { ok: true }; // idempotent — đã xử lý
await markSeen(event.id);
await process(event);
return { ok: true }; // trả 2xx nhanh; việc nặng → đẩy queue
}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ùngtimingSafeEqual. - 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.
- 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):
// nestjs-pino tự gắn reqId vào mọi log của request
app.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.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.
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ụ:
@Get('/health/live') live() { return { status: 'ok' }; } // nhẹ, luôn ok nếu process chạy
@Get('/health/ready') // kiểm dependency
async ready() {
await db.query('SELECT 1');
await redis.ping();
return { status: 'ready' }; // fail → trả 503
}- 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ụ:
// Graceful shutdown
process.on('SIGTERM', async () => {
server.close(); // ngừng nhận connection mới
await queue.close();
await db.destroy();
process.exit(0);
});
// (Nest: app.enableShutdownHooks() + onModuleDestroy)
// Timeout + retry có backoff
async 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.
- 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). - 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).
- 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). - 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. - 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). - 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). - 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). - API: URL versioning
/v1, cursor pagination cho danh sách đơn/feed (mục 12, 13). - 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). - Reliability: graceful shutdown khi deploy (drain queue + đóng DB); timeout + retry + circuit breaker cho call tới payment provider (mục 18).
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.
- [ ] Không bao giờ lưu password plaintext; dùng argon2/bcrypt; hiểu salt & slow hash.
- [ ] 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á.
- [ ] 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 đề.
- [ ] Việc nặng chạy trong BullMQ worker (không trong request), có retry+backoff, DLQ, job idempotent.
- [ ] Realtime chạy đúng khi scale nhiều pod (Redis adapter), auth khi handshake.
- [ ] Cache-aside có TTL + invalidation đúng + chống stampede; biết dùng ETag/304.
- [ ] API có versioning; chọn đúng cursor vs offset; POST thanh toán có idempotency key.
- [ ] Webhook verify HMAC trên raw body + idempotent + trả 2xx nhanh.
- [ ] Structured logging + request id; health check liveness/readiness tách bạch; Sentry + metrics RED.
- [ ] Graceful shutdown, timeout/retry/circuit breaker, transaction rollback on failure đều có.
