Phần lớn sự cố production không đến từ lỗi hiếm gặp, mà từ những thứ không có điểm kết thúc, không có giới hạn và không có đường lui.Most production incidents do not come from rare bugs. They come from things with no end, no limit and no way back.

Tài liệu này gộp ba infographic của CodeCrackAI — 10 kiểu rò rỉ bộ nhớ, 10 lỗi xác thực, 10 lỗi triển khai — thành một bộ duy nhất, đã được kiểm chứng và sắp xếp lại theo cơ chế gây lỗi thay vì theo thứ tự trong ảnh. Mỗi mục giữ mã gốc để tiện đối chiếu: M là bộ nhớ, A là xác thực, D là triển khai, còn con số là vị trí trong ảnh gốc, ví dụ M5 là mục 5 của ảnh memory leak. Có 12 mục mang ghi chú hiệu đính: đó là những chỗ ảnh gốc sai hoặc nói chưa đủ, và nội dung đã được thay bằng thông tin chính xác. Ở mỗi lỗi, phần lợi ích, hạn chế và đánh đổi nói về cách sửa, vì cách sửa mới là thứ bạn phải cân nhắc.This document merges three CodeCrackAI infographics — 10 memory leak patterns, 10 authentication mistakes, 10 deployment mistakes — into one set, fact-checked and regrouped by the mechanism behind each failure rather than the order in the images. Every entry keeps its original code for cross-reference: M for memory, A for authentication, D for deployment, and the number is its position in the source image, so M5 is item 5 of the memory leak image. Twelve entries carry a correction note: those are places where the source was wrong or incomplete, and the content has been replaced with accurate information. For each mistake, the benefits, limitations and trade-off describe the fix, because the fix is what you actually have to decide on.

Bộ nhớMemory
listener, timer, cache không có điểm kết thúclisteners, timers, caches with no end cleanup, max, TTLcleanup, max, TTL
Xác thựcAuth
token và mật khẩu dùng được mãitokens and passwords that work forever exp, revoke, MFAexp, revoke, MFA
Triển khaiDeploy
release 100%, không đường lui100% releases with no way back canary, smoke test, rollbackcanary, smoke test, rollback

Nét đứt chạy mãi là thứ không có điểm kết thúc. Nét liền có vạch chặn là khi đã có cleanup, hạn dùng hoặc đường lui.A dashed line that keeps running is something with no end. A solid line with a stop bar has a cleanup, an expiry or a way back.

Bắt đầu từ đâuWhere to start

Đọc phần này trước phần tra cứu. Câu hỏi đầu tiên luôn là: bạn có cần tự làm không?Read this before the reference cards. The first question is always: do you need to build this yourself?

Kiến thức nền trong một phútOne-minute background

Bộ nhớ: “với tới được” không có nghĩa là “còn cần”Memory: reachable is not the same as needed

JavaScript, Java, Go hay C# dùng garbage collector kiểu mark-and-sweep. GC bắt đầu từ các root — biến toàn cục, call stack, timer đang chạy, listener gắn trên window — rồi đánh dấu mọi thứ đi tới được. Cái gì không đi tới được thì bị thu hồi. Leak trong những ngôn ngữ này vì thế không phải là “quên free”, mà là một đường tham chiếu còn sót từ root tới dữ liệu bạn đã hết cần.

JavaScript, Java, Go and C# use a mark-and-sweep garbage collector. It starts from roots — globals, the call stack, active timers, listeners on window — and marks everything reachable. Whatever is unreachable gets freed. So a leak in these languages is not a forgotten free; it is a leftover reference path from a root to data you no longer need.

root (global, stack, active timer, listener on window)
  └─ closure
       └─ unmounted component   ← still reachable → never freed

Xác thực và phân quyền là hai việc khác nhauAuthentication and authorization are different jobs

Xác thực (authentication) trả lời “bạn là ai”; phân quyền (authorization) trả lời “bạn được làm gì”. Đăng nhập đúng không có nghĩa là được xem dữ liệu của người khác. Sau khi xác thực, server cấp một thứ đại diện cho phiên: hoặc một session ID trỏ tới bản ghi phía server (thu hồi dễ), hoặc một token tự chứa như JWT (không cần tra cứu, nhưng khó thu hồi). Hầu hết các lỗi nhóm A xoay quanh việc bảo vệ, giới hạn thời gian sống và thu hồi thứ đại diện đó.

Authentication answers “who are you”; authorization answers “what may you do”. Logging in correctly does not entitle anyone to another user's data. After authentication, the server issues something that represents the session: either a session ID pointing to a server-side record (easy to revoke), or a self-contained token like a JWT (no lookup, but hard to revoke). Most A-group mistakes are about protecting, time-limiting and revoking that representation.

Triển khai: artifact, vùng ảnh hưởng, MTTD và MTTRDeployment: artifacts, blast radius, MTTD and MTTR

Artifact là sản phẩm build bất biến — container image, bundle — được đem đi chạy ở các môi trường. Vùng ảnh hưởng (blast radius) là phần người dùng hoặc hệ thống bị chạm tới khi một thay đổi hỏng. MTTD là thời gian trung bình để phát hiện sự cố, MTTR là thời gian trung bình để khôi phục. Mọi cách sửa ở nhóm D đều làm một trong ba việc: thu nhỏ vùng ảnh hưởng, rút ngắn MTTD, hoặc rút ngắn MTTR.

An artifact is an immutable build output — a container image, a bundle — that gets run in each environment. Blast radius is how much of your users or system a broken change touches. MTTD is mean time to detect an incident, MTTR mean time to recover. Every fix in the D group does one of three things: shrinks the blast radius, shortens MTTD, or shortens MTTR.

Khi nào không cần bận tâmWhen you need none of this

Bộ nhớMemory

Process sống ngắn — script CLI, job cron, hàm serverless chạy vài giây, trang web truyền thống tải lại sau mỗi lần điều hướng — chết trước khi leak kịp gây hại. Ở đó không cần săn leak. Chỉ lưu ý một điều: môi trường serverless giữ container “ấm” và tái sử dụng, nên biến cấp module vẫn sống qua nhiều lần gọi. Cache và mảng toàn cục ở đó vẫn cần giới hạn (M2, M6).

Short-lived processes — CLI scripts, cron jobs, serverless functions running for seconds, traditional multi-page sites that reload on every navigation — die before a leak can hurt. No leak hunting needed there. One caveat: serverless platforms keep containers warm and reuse them, so module-level variables survive across invocations. Caches and global arrays still need bounds (M2, M6).

Xác thựcAuthentication

Phương án rẻ nhất là không tự viết. Một identity provider có sẵn (Auth0, Clerk, Cognito, Firebase Auth, Supabase Auth, hoặc Keycloak nếu tự host) đã lo phần lớn A1, A3, A5, A6, A7 và A10. Tool nội bộ thì đặt sau SSO của công ty qua một proxy như Cloudflare Access hay oauth2-proxy, và gần như không cần hệ thống đăng nhập riêng. Có hai việc không bao giờ giao được cho bên ngoài: phân quyền trên từng request (A8) và quản lý secret của chính bạn (A9).

The cheapest option is not building it. An off-the-shelf identity provider (Auth0, Clerk, Cognito, Firebase Auth, Supabase Auth, or self-hosted Keycloak) already handles most of A1, A3, A5, A6, A7 and A10. Internal tools can sit behind company SSO via a proxy such as Cloudflare Access or oauth2-proxy and need no login system of their own. Two things can never be outsourced: per-request authorization (A8) and managing your own secrets (A9).

Triển khaiDeployment

Nền tảng được quản lý như Vercel, Netlify, Cloud Run, Render hay Fly cho sẵn deploy từ git, chuyển phiên bản nguyên khối, health check và rollback một cú bấm — tức phần lớn D2, D4, D5, D8. App nội bộ ít người dùng thì canary và blue/green là quá tay; vài giây gián đoạn vào buổi tối là chấp nhận được. Thứ vẫn phải tự lo ở mọi quy mô là migration tương thích ngược (D7), vì không nền tảng nào rollback được schema thay bạn.

Managed platforms such as Vercel, Netlify, Cloud Run, Render or Fly give you git-based deploys, atomic version switches, health checks and one-click rollback out of the box — most of D2, D4, D5 and D8. For low-traffic internal apps, canaries and blue/green are overkill; a few seconds of evening downtime is acceptable. What you still own at any scale is backward-compatible migrations (D7), because no platform can roll back a schema for you.

Nguyên tắc chung: mức đầu tư vào 30 cách sửa này tỉ lệ với thời gian sống của process, giá trị của tài khoản và số người dùng chịu ảnh hưởng khi có sự cố.The general rule: how much you invest in these 30 fixes scales with process lifetime, account value and the number of users hurt when something breaks.

Chọn trong 30 giâyPick in 30 seconds

Bạn đang gặp chuyện gì?
│
├─ RAM tăng dần, không giảm, restart là hết
│   ├─ tăng theo số lần mở/đóng màn hình ─→ M1, M7, M8, M3
│   ├─ tăng theo số user hoặc URL khác nhau ─→ M2, M5
│   ├─ tăng đều theo thời gian chạy ─────────→ M6, M10, M9
│   └─ báo "too many clients", không phải RAM ─→ M4
│
├─ Đang xây hoặc rà soát đăng nhập
│   ├─ dùng được identity provider có sẵn? ─ có ─→ dùng nó, rồi lo A8, A9
│   └─ không ─→ theo thứ tự: A1 → A8 → A3 → A4 → A2 → A7 → A5 → A6 → A9 → A10
│
└─ Deploy hay gây sự cố
    ├─ hỏng ngay lúc deploy ─────────→ D5, D8, D7
    ├─ hỏng mà rất lâu mới biết ─────→ D9, D10
    ├─ biết rồi nhưng khôi phục lâu ─→ D2, D4
    └─ mỗi lỗi đều chạm mọi người ───→ D3, D1
What are you dealing with?
│
├─ RAM climbs, never drops, restart "fixes" it
│   ├─ grows with each screen open/close ─→ M1, M7, M8, M3
│   ├─ grows with distinct users or URLs ─→ M2, M5
│   ├─ grows steadily with uptime ────────→ M6, M10, M9
│   └─ "too many clients", not RAM ───────→ M4
│
├─ Building or reviewing login
│   ├─ can you use an identity provider? ─ yes ─→ use it, then own A8, A9
│   └─ no ─→ in order: A1 → A8 → A3 → A4 → A2 → A7 → A5 → A6 → A9 → A10
│
└─ Deploys keep causing incidents
    ├─ breaks the moment you deploy ───→ D5, D8, D7
    ├─ breaks, found out much later ───→ D9, D10
    ├─ found fast, recovers slowly ────→ D2, D4
    └─ every bug hits everyone ────────→ D3, D1

Rò rỉ bộ nhớMemory leaks

Vì sao bộ nhớ tăng mà không bao giờ giảmWhy memory climbs and never drops

Thứ gì được tạo ra cũng phải có điểm kết thúc.Everything you create needs an end.

Đăng ký mà không huỷSubscriptions that outlive their owner

Thứ gì đã đăng ký vào một đối tượng sống lâu thì phải có lệnh huỷ đi kèm.Anything registered on a long-lived object needs a matching unregister.

M1

Listener không được gỡUnremoved event listener

Đăng ký mà không huỷSubscriptions that outlive their owner
Listener sống lâu hơn componentListener outlives component Gỡ khi unmountClean up on unmount

Mỗi lần mount mà không có cleanup tương ứng là thêm một listener ma, kéo theo cả component đã chết.Every mount without a matching cleanup adds a ghost listener that drags the dead component along.

Gặp ởSeen in
SPA chuyển route qua lại nhiều lần, mỗi lần mount lại cùng một màn hình.An SPA where users bounce between routes, remounting the same screen.
ĐượcYou gain
Heap quay về mức nền sau mỗi lần unmount.The heap returns to baseline after every unmount.
MấtYou pay
removeEventListener cần đúng cùng tham chiếu hàm; hàm inline tạo mới mỗi lần render sẽ không gỡ được.removeEventListener needs the exact same function reference; an inline function recreated each render will not match.

Đánh đổiTrade-off Chọn gỡ listener tường minh là đổi vài dòng cleanup trong mỗi effect lấy một heap ổn định bất kể người dùng điều hướng bao nhiêu lần.Choosing explicit listener removal trades a few cleanup lines per effect for a stable heap no matter how often users navigate.

Dấu hiệuTell-tale sign: Trong heap snapshot, số instance của một component tăng đúng bằng số lần bạn mở rồi đóng nó.In a heap snapshot, the instance count of a component grows by exactly the number of times you opened and closed it.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Component đăng ký listener lên một đối tượng sống lâu hơn nó — window, document, một store hay socket — nhưng không gỡ ra khi unmount. Listener là một closure, nên nó giữ lại component cùng mọi thứ component đang tham chiếu.A component registers a listener on something that lives longer than it does — window, document, a store, a socket — and never removes it on unmount. The listener is a closure, so it keeps the component and everything the component references.

// Leaks: nothing removes the listener
useEffect(() => {
  window.addEventListener('resize', onResize);
}, []);

// Fixed: one abort() removes every listener tied to the signal
useEffect(() => {
  const ctrl = new AbortController();
  window.addEventListener('resize', onResize, { signal: ctrl.signal });
  return () => ctrl.abort();
}, []);

Hoạt động thế nàoHow it works. GC chỉ thu hồi những gì không còn đường tham chiếu từ root, và window chính là một root. Chuỗi giữ chặt trông thế này: window → danh sách listener → closure → state, props, DOM của component đã unmount. Chừng nào listener còn đó, cả chuỗi còn sống.The GC only frees what is unreachable from a root, and window is a root. The retaining chain is: window → listener list → closure → state, props and DOM of the unmounted component. As long as the listener stays, so does the whole chain.

Mẹo triển khaiImplementation tips

  • Ưu tiên option { signal } của AbortController: không phải giữ tham chiếu hàm, một lệnh abort() gỡ hết.
  • Trong console của Chrome, getEventListeners(window) cho biết đang có bao nhiêu listener trên window.
  • Test leak nhanh nhất: mở rồi đóng màn hình 20 lần, chụp heap, so với snapshot đầu tiên.
  • Prefer the { signal } option: no function reference to keep, one abort() removes everything.
  • In the Chrome console, getEventListeners(window) shows how many listeners window currently holds.
  • Fastest leak test: open and close the screen 20 times, take a heap snapshot, compare with the first one.
M7

Event emitter không giới hạnUnbounded event emitter

Đăng ký mà không huỷSubscriptions that outlive their ownerĐã hiệu đínhCorrected
Listener chồng vô hạnInfinite listeners Huỷ đăng ký: off / once / signalUnsubscribe: off / once / signal

Mỗi lần subscribe lại mà không unsubscribe là thêm một handler, và sự kiện bắt đầu được xử lý nhiều lần.Each re-subscribe without an unsubscribe stacks another handler, and events start getting handled more than once.

Gặp ởSeen in
Server Node gắn listener theo từng request lên một object toàn cục.A Node server attaching a per-request listener to a global object.
ĐượcYou gain
Số handler đứng yên dù process chạy bao lâu.Handler count stays flat however long the process runs.
MấtYou pay
Phải giữ tham chiếu handler để gỡ đúng cái.You must keep the handler reference to remove the right one.

Đánh đổiTrade-off Chọn quản lý vòng đời subscription là đổi một điểm huỷ cho mỗi điểm đăng ký lấy bảo đảm mỗi sự kiện chỉ được xử lý đúng một lần.Choosing managed subscription lifecycles trades one teardown per subscribe for the guarantee that each event is handled exactly once.

Dấu hiệuTell-tale sign: Log xuất hiện “MaxListenersExceededWarning: Possible EventEmitter memory leak detected”.Your logs show “MaxListenersExceededWarning: Possible EventEmitter memory leak detected”.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. Ảnh đề xuất sửa bằng “Listener Limits”. Trong Node, setMaxListeners (mặc định 10) chỉ in cảnh báo MaxListenersExceededWarning, không chặn việc thêm listener. Nó là công cụ phát hiện chứ không phải cách sửa. Cách sửa thật là huỷ đăng ký: off(), once(), hoặc truyền AbortSignal.The image suggests “Listener Limits” as the fix. In Node, setMaxListeners (default 10) only prints a MaxListenersExceededWarning; it never stops listeners from being added. It is a detector, not a fix. The real fix is unsubscribing: off(), once(), or passing an AbortSignal.

Nó là gìWhat it is. Code đăng ký handler vào một EventEmitter, bus pub/sub hay observable dùng chung, nhưng không bao giờ huỷ. Khác với M1 ở chỗ nguồn phát nằm trong code của bạn chứ không phải DOM.Code subscribes a handler to a shared EventEmitter, pub/sub bus or observable and never unsubscribes. Unlike M1, the source is an object in your own code, not the DOM.

// Leaks: every request adds a listener to a process-wide bus
bus.on('config', (c) => apply(c, req));

// Fixed: remove it when the request ends
const handler = (c) => apply(c, req);
bus.on('config', handler);
res.on('close', () => bus.off('config', handler));

Hoạt động thế nàoHow it works. Emitter giữ một mảng handler cho mỗi tên sự kiện. Không ai gỡ thì mảng chỉ dài ra, và mỗi handler giữ closure của lần đăng ký — trong ví dụ trên là cả đối tượng request. Triệu chứng đi kèm: một sự kiện phát ra một lần nhưng handler chạy N lần.The emitter keeps an array of handlers per event name. If nobody removes them, the array only grows, and each handler holds the closure from its registration — in the example, the whole request. The side symptom: an event fires once, handlers run N times.

Mẹo triển khaiImplementation tips

  • Đừng bao giờ gọi setMaxListeners(0) để dập cảnh báo; chạy node --trace-warnings để thấy stack của chỗ đăng ký.
  • Đưa listenerCount lên metrics; đồ thị phải nằm phẳng.
  • Với RxJS, gom mọi subscription qua takeUntil(destroy$) hoặc một Subscription cha.
  • Never call setMaxListeners(0) to silence the warning; run node --trace-warnings to get the stack of the registering call.
  • Export listenerCount to metrics; the line should be flat.
  • In RxJS, route every subscription through takeUntil(destroy$) or a parent Subscription.
M8

Timer không bao giờ được clearTimer never cleared

Đăng ký mà không huỷSubscriptions that outlive their owner
setInterval chạy mãisetInterval forever clearInterval khi cleanupclearInterval on cleanup

Timer sống lâu hơn component thì giữ luôn closure của nó, và thường kéo theo cả request mạng lặp lại.A timer that outlives its component keeps its closure alive, and usually keeps firing network requests too.

Gặp ởSeen in
Dashboard polling số liệu mỗi vài giây.Dashboards polling metrics every few seconds.
ĐượcYou gain
Chặn cùng lúc leak bộ nhớ và leak request mạng.Stops the memory leak and the network leak together.
MấtYou pay
Chỉ clear được khi còn giữ ID của timer.You can only clear a timer if you still have its ID.

Đánh đổiTrade-off Chọn clear timer khi cleanup là đổi việc giữ ID và thêm một hàm dọn dẹp lấy bảo đảm callback không bao giờ chạy trên dữ liệu đã chết.Choosing to clear timers on cleanup trades keeping an ID and a teardown function for the guarantee that callbacks never run on dead data.

Dấu hiệuTell-tale sign: Tab Network cho thấy cùng một endpoint bị gọi 2, 3, 4 lần trong mỗi chu kỳ, và con số tăng theo thời gian dùng app.The Network tab shows the same endpoint hit 2, 3, 4 times per cycle, and the count grows the longer the app is open.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. setInterval hoặc setTimeout tạo trong component hay module nhưng không có lệnh clear tương ứng. Timer đang hoạt động được runtime giữ như một root.A setInterval or setTimeout created in a component or module with no matching clear. An active timer is held by the runtime like a root.

useEffect(() => {
  const id = setInterval(poll, 5000);
  return () => clearInterval(id);
}, []);

Hoạt động thế nàoHow it works. Event loop giữ timer cho đến khi nó bị clear. Interval không tự dừng, nên callback cùng closure của nó sống mãi. Trong Node, timer đang chạy còn giữ process không thoát được, trừ khi bạn gọi unref().The event loop holds a timer until it is cleared. An interval never stops on its own, so its callback and closure live forever. In Node, an active timer also keeps the process from exiting unless you call unref().

Mẹo triển khaiImplementation tips

  • Với polling, setTimeout đệ quy sau khi request xong tốt hơn setInterval: không chồng request khi mạng chậm.
  • Tạm dừng polling khi tab bị ẩn (document.visibilityState === 'hidden').
  • Trong Node, timer.unref() cho những timer không nên chặn việc tắt process.
  • For polling, a recursive setTimeout scheduled after the request finishes beats setInterval: requests never pile up on a slow network.
  • Pause polling while the tab is hidden (document.visibilityState === 'hidden').
  • In Node, timer.unref() for timers that should not block shutdown.
M10

Tác vụ bất đồng bộ không bao giờ kết thúcAsync work that never settles

Đăng ký mà không huỷSubscriptions that outlive their ownerĐã hiệu đínhCorrected
Gọi rồi bỏ mặcFire and forget Timeout + AbortControllerTimeout + AbortController

Request không có timeout và không bị huỷ sẽ treo cùng closure của nó cho đến khi upstream chịu trả lời, có khi là không bao giờ.A request with no timeout and no cancellation hangs on to its closure until upstream answers, which may be never.

Gặp ởSeen in
Gọi API bên thứ ba không đặt timeout.Calling third-party APIs with no timeout.
ĐượcYou gain
Mọi tác vụ treo đều có thời hạn sống.Every hanging operation gets a maximum lifetime.
MấtYou pay
Không phải API nào cũng nhận AbortSignal.Not every API accepts an AbortSignal.

Đánh đổiTrade-off Chọn timeout và huỷ tường minh là đổi một số request bị cắt ngang lấy bảo đảm không tác vụ nào sống lâu hơn người cần kết quả của nó.Choosing timeouts and explicit cancellation trades some cut-off requests for the guarantee that no operation outlives whoever needed its result.

Dấu hiệuTell-tale sign: Khi upstream chậm, heap và số socket đang mở tăng cùng nhau rồi không giảm khi upstream hồi phục.When an upstream slows down, heap and open socket count climb together and do not fall back when it recovers.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. Ảnh nói promise bị bỏ rơi giữ cả context. Chính xác hơn: một promise pending mà không còn ai tham chiếu sẽ bị GC thu hồi như object thường. Leak xảy ra khi tác vụ bên dưới (fetch, socket, timer) không bao giờ kết thúc và vẫn giữ callback, hoặc khi promise bị cất vào một Map sống lâu. “Await” không giải phóng gì; thứ cần là timeout và huỷ thật sự bằng AbortController. Rủi ro chính của fire-and-forget lại là unhandled rejection — từ Node 15, mặc định nó làm process crash.The image says abandoned promises pin their context. More precisely: a pending promise that nothing references is collected like any object. The leak happens when the underlying operation (fetch, socket, timer) never finishes and still holds its callbacks, or when promises are parked in a long-lived Map. “Awaiting” frees nothing; what helps is a timeout and real cancellation via AbortController. The main risk of fire-and-forget is actually unhandled rejections — since Node 15 they crash the process by default.

Nó là gìWhat it is. Gọi mạng hoặc chạy tác vụ async mà không đặt timeout, và không huỷ khi người gọi không còn cần kết quả. Callback trong .then giữ closure, thường là state của component hoặc cả context của request.Network calls or async work with no timeout and no cancellation once the caller stops caring. The .then callback holds a closure, typically component state or a whole request context.

const ctrl = new AbortController();          // abort on unmount / client disconnect
const res = await fetch(url, {
  signal: AbortSignal.any([ctrl.signal, AbortSignal.timeout(5000)]),
});

Hoạt động thế nàoHow it works. Chừng nào socket còn mở chờ phản hồi, runtime còn giữ callback hoàn tất, và callback giữ closure. Upstream treo mà không có timeout thì hàng nghìn tác vụ treo cộng dồn. Một Map “pending requests” dùng để gộp request trùng mà quên xoá khi xong cũng là leak kiểu M2.While a socket waits for a response, the runtime keeps the completion callback, and the callback keeps its closure. A hung upstream with no timeout piles up thousands of pending operations. A “pending requests” Map used for deduplication that never deletes finished keys is an M2-style leak as well.

Mẹo triển khaiImplementation tips

  • Mọi lời gọi mạng ra ngoài đều phải có timeout, không ngoại lệ.
  • Đăng ký process.on('unhandledRejection') để log có ngữ cảnh thay vì crash câm.
  • Map gộp request: luôn .finally(() => pending.delete(key)).
  • Every outbound network call gets a timeout. No exceptions.
  • Register process.on('unhandledRejection') to log with context instead of crashing silently.
  • Dedupe maps: always .finally(() => pending.delete(key)).

Bộ sưu tập chỉ lớn lênCollections that only grow

Map, mảng, cache ở cấp module phải trả lời được câu hỏi: ai xoá, khi nào?Every module-level map, array or cache must answer: who removes entries, and when?

M2

Cache phình mãiGrowing cache

Bộ sưu tập chỉ lớn lênCollections that only grow
Map không giới hạnUnbounded Map LRU có max + TTLLRU with max + TTL

Cache không có chính sách loại bỏ chỉ là một vụ leak chậm được đặt tên đẹp.A cache without an eviction policy is just a slow leak with a good name.

Gặp ởSeen in
Memoize theo tham số đầu vào tự do.Memoizing on free-form input arguments.
ĐượcYou gain
Bộ nhớ có trần biết trước: max × kích thước mỗi mục.Memory has a known ceiling: max × entry size.
MấtYou pay
Chọn max sai thì hit rate thấp, cache thành chi phí thuần.A wrong max gives a low hit rate, turning the cache into pure overhead.

Đánh đổiTrade-off Chọn cache có giới hạn là đổi một phần cache miss lấy trần bộ nhớ biết trước.Choosing a bounded cache trades some cache misses for a memory ceiling you know in advance.

Dấu hiệuTell-tale sign: Heap tăng theo số user hoặc URL khác nhau đã từng truy cập, không theo tải hiện tại.Heap grows with the number of distinct users or URLs ever seen, not with current load.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Dùng Map hoặc object làm cache trong bộ nhớ mà không giới hạn kích thước và không có thời hạn. Mỗi key mới là một mục ở lại vĩnh viễn.A Map or plain object used as an in-memory cache with no size bound and no expiry. Every new key is an entry that stays forever.

// Leaks: one entry per distinct id, forever
const cache = new Map();

// Fixed: bounded and expiring (npm: lru-cache)
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 5000, ttl: 60_000 });

Hoạt động thế nàoHow it works. Khi key không có tập giới hạn (userId, URL, query string), kích thước cache bằng số key khác nhau từng xuất hiện. Traffic thật có đuôi dài nên cache không bao giờ bão hoà. LRU loại mục ít dùng nhất khi đầy; TTL loại mục quá cũ.When keys come from an unbounded set (user IDs, URLs, query strings), cache size equals the number of distinct keys ever seen. Real traffic has a long tail, so the cache never saturates. LRU evicts the least recently used entry when full; TTL evicts stale ones.

Mẹo triển khaiImplementation tips

  • Đo hit rate trước khi tăng max; dưới khoảng 50% nghĩa là key quá phân tán, nên hỏi lại có đáng cache không.
  • Nhiều instance cần dùng chung thì chuyển sang Redis với maxmemory-policy allkeys-lru.
  • Đừng lấy WeakMap làm cache: nó không có kích thước, không loại bỏ theo thời gian, và chỉ nhận object làm key.
  • Measure hit rate before raising max; below roughly 50% means keys are too scattered to be worth caching.
  • If instances must share, move to Redis with maxmemory-policy allkeys-lru.
  • Do not use WeakMap as a cache: no size, no time-based eviction, object keys only.
M6

Mảng toàn cục chỉ pushGlobal array growth

Bộ sưu tập chỉ lớn lênCollections that only grow
Push, không bao giờ xoáPush, never pop Giới hạn kích thước, có chủ xoáBounded size, clear owner

Một mảng ở cấp module chỉ lớn lên là một vụ leak có hẹn giờ.A module-level array that only grows is a leak on a delay timer.

Gặp ởSeen in
Buffer metrics chờ flush, nhưng lần flush bị lỗi và dữ liệu cứ dồn lại.A metrics buffer waiting to flush, where the flush keeps failing and data piles up.
ĐượcYou gain
Kích thước cố định, dự đoán được.Fixed, predictable size.
MấtYou pay
Phải chọn chính sách khi đầy, và bỏ dữ liệu có thể làm mất thông tin quan trọng.You must pick a policy for when it is full, and dropping data can lose something important.

Đánh đổiTrade-off Chọn giới hạn cố định là đổi khả năng mất dữ liệu cũ lấy một process không bao giờ chết vì chính bộ đệm của nó.Choosing a fixed bound trades possibly losing old data for a process that never dies from its own buffer.

Dấu hiệuTell-tale sign: Service phải restart định kỳ “cho khoẻ”; đồ thị memory có hình răng cưa trùng với lịch restart hoặc deploy.The service needs periodic restarts “to stay healthy”; the memory graph is a sawtooth lined up with restarts or deploys.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Mảng hoặc collection ở cấp module chỉ được thêm vào: log buffer, lịch sử sự kiện, hàng đợi retry, danh sách gần đây.A module-level array or collection that only ever gets appended to: log buffers, event history, retry queues, “recent items” lists.

const MAX = 1000;
recent.push(item);
if (recent.length > MAX) recent.splice(0, recent.length - MAX);

Hoạt động thế nàoHow it works. Biến module sống suốt đời process. Tốc độ thêm lớn hơn tốc độ xoá, mà tốc độ xoá bằng 0, thì OOM chỉ là chuyện thời gian — thường vài ngày, đủ lâu để qua mọi bài test ngắn.Module variables live as long as the process. If entries arrive faster than they leave, and they never leave, OOM is a matter of time — usually days, long enough to pass every short test.

Mẹo triển khaiImplementation tips

  • Trong code review, mọi collection toàn cục phải trả lời được: ai xoá, khi nào?
  • Hàng đợi retry phải có số lần thử tối đa và dead-letter queue.
  • Đưa length của các buffer lên metrics để thấy chúng trước khi OOM thấy.
  • In code review, every global collection must answer: who removes entries, and when?
  • Retry queues need a max attempt count and a dead-letter queue.
  • Export buffer lengths as metrics so you see them before the OOM killer does.
M5

Tham chiếu vòng và bảng phụ giữ objectCircular references and side tables

Bộ sưu tập chỉ lớn lênCollections that only growĐã hiệu đínhCorrected
Map giữ object làm keyMap holds objects as keys WeakMap / weak referenceWeakMap / weak references

Trong JS, vòng tham chiếu không gây leak; thứ gây leak là một Map sống lâu lấy object làm key.In JS, cycles do not leak; a long-lived Map keyed by objects does.

Gặp ởSeen in
Gắn metadata cho DOM node hoặc object của thư viện bên ngoài.Attaching metadata to DOM nodes or third-party objects.
ĐượcYou gain
Metadata tự biến mất theo object, không cần code dọn dẹp.Metadata disappears with its object, no cleanup code needed.
MấtYou pay
WeakMap không duyệt được và không có size, nên không dùng cho thứ cần liệt kê.WeakMap is not iterable and has no size, so it cannot back anything you need to list.

Đánh đổiTrade-off Chọn tham chiếu yếu là đổi khả năng liệt kê và kiểm soát thời điểm giải phóng lấy việc không phải tự viết code dọn dẹp.Choosing weak references trades the ability to enumerate and to control release timing for never writing cleanup code.

Dấu hiệuTell-tale sign: JS: trong heap snapshot, mục Retainers cho thấy object bị giữ bởi một Map ở module scope. Swift: deinit không bao giờ được gọi.JS: the heap snapshot's Retainers panel shows the object held by a module-scope Map. Swift: deinit never runs.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. Ảnh nói hai object tham chiếu lẫn nhau sẽ sống lâu hơn chủ của chúng. Với JavaScript hiện đại (cũng như Java, Go, C#), điều này sai: GC mark-and-sweep đi từ root, nên cả một vòng không còn ai với tới vẫn bị thu hồi. Vòng tham chiếu chỉ gây leak ở môi trường đếm tham chiếu: ARC của Swift/Objective-C, Rc/Arc trong Rust, shared_ptr trong C++, và Internet Explorer 6–7 với DOM. Trong JS, bài toán thật mà WeakMap giải là bảng phụ: một Map dùng object làm key để gắn metadata, và giữ object đó sống mãi.The image says two objects referencing each other outlive their owners. For modern JavaScript (and Java, Go, C#) that is false: mark-and-sweep GC starts from roots, so an unreachable cycle is collected as a whole. Cycles only leak under reference counting: Swift/Objective-C ARC, Rc/Arc in Rust, shared_ptr in C++, and old IE 6–7 with the DOM. In JS, the real problem WeakMap solves is side tables: a Map keyed by objects to attach metadata, which keeps those objects alive forever.

Nó là gìWhat it is. Hai dạng khác nhau. Trong ngôn ngữ đếm tham chiếu: A giữ B, B giữ A, bộ đếm không bao giờ về 0. Trong JS: một Map toàn cục lấy DOM node hay object làm key để gắn dữ liệu, nên object không bao giờ được giải phóng.Two distinct forms. Under reference counting: A holds B, B holds A, and neither count reaches zero. In JS: a global Map keyed by DOM nodes or objects to attach data, so those objects are never freed.

// Leaks: the Map keeps every node alive
const meta = new Map();
meta.set(node, { clicks: 0 });

// Fixed: WeakMap does not keep its keys alive
const meta = new WeakMap();
meta.set(node, { clicks: 0 });
// ARC: break the cycle with a weak back-reference
class Child { weak var parent: Parent? }

Hoạt động thế nàoHow it works. WeakMap chỉ giữ giá trị chừng nào key còn được tham chiếu ở nơi khác; key biến mất thì cả cặp bị thu hồi. Trong ARC, weak và unowned không tăng bộ đếm, nên vòng bị phá.A WeakMap keeps a value only while its key is referenced elsewhere; once the key is gone, the pair is collected. Under ARC, weak and unowned do not bump the count, so the cycle breaks.

Mẹo triển khaiImplementation tips

  • Đừng đi “phá vòng” bằng tay trong JS bằng cách gán null khắp nơi; GC đã lo việc đó, bạn chỉ làm code rối.
  • Swift: [weak self] là mặc định an toàn cho mọi callback được lưu lại.
  • Rust: parent giữ con bằng Rc, con trỏ ngược về parent bằng Weak.
  • Do not “break cycles” by hand in JS with nulls everywhere; the GC already handles it and you only add noise.
  • Swift: [weak self] is the safe default for any stored callback.
  • Rust: parents own children with Rc, children point back with Weak.

Tham chiếu bị giữ ngoài ý muốnReferences kept by accident

Dữ liệu lớn bị níu lại bởi một con trỏ nhỏ mà bạn không để ý.Big data pinned by one small pointer you did not notice.

M3

DOM node đã gỡ nhưng vẫn bị giữDetached DOM nodes

Tham chiếu bị giữ ngoài ý muốnReferences kept by accident
Đã gỡ nhưng còn tham chiếuRemoved but referenced Buông tham chiếuRelease references

Một biến JS còn trỏ tới node đã gỡ khỏi trang sẽ giữ cả cây DOM của nó.A JS variable still pointing at a removed node keeps its whole DOM tree alive.

Gặp ởSeen in
Cache phần tử DOM vào biến module “cho nhanh”.Caching DOM elements in module variables “for speed”.
ĐượcYou gain
Giải phóng cả cây DOM lẫn dữ liệu gắn kèm.Frees the whole DOM subtree and its attached data.
MấtYou pay
Tham chiếu có thể nằm ẩn trong closure hoặc thư viện, khó lần.References can hide in closures or libraries and be hard to trace.

Đánh đổiTrade-off Chọn không giữ tham chiếu DOM lâu dài là đổi việc phải query lại phần tử khi cần lấy một heap không chứa cây DOM ma.Choosing not to hold DOM references long-term trades re-querying elements when needed for a heap free of ghost DOM trees.

Dấu hiệuTell-tale sign: Chrome DevTools → Memory → heap snapshot, lọc chữ “Detached”: số Detached HTMLDivElement tăng sau mỗi thao tác.Chrome DevTools → Memory → heap snapshot, filter by “Detached”: the Detached HTMLDivElement count climbs after each interaction.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Node đã bị gỡ khỏi document nhưng vẫn còn biến JS trỏ tới. Cây con của nó, kèm listener và dữ liệu gắn trên đó, ở lại trong heap.A node removed from the document that a JS variable still references. Its subtree, plus listeners and data attached to it, stays in the heap.

let rows = [...table.querySelectorAll('tr')];
table.remove();
rows = null; // or let `rows` go out of scope

Hoạt động thế nàoHow it works. Node con giữ parentNode, nên một tham chiếu tới bất kỳ node nào trong cây cũng giữ cả cây. Một biến trỏ vào một ô td có thể níu lại cả bảng 10.000 dòng.Child nodes hold parentNode, so a reference to any node in a tree retains the whole tree. One variable pointing at a single td can keep a 10,000-row table alive.

Mẹo triển khaiImplementation tips

  • Gọi destroy() hoặc dispose() của thư viện bên thứ ba khi unmount; đây là nguồn leak DOM phổ biến nhất.
  • Cần dùng lâu thì lưu ID hoặc selector, đừng lưu node.
  • Buộc phải gắn dữ liệu vào node thì dùng WeakMap (xem M5).
  • Call the third-party library's destroy() or dispose() on unmount; it is the most common source of DOM leaks.
  • For long-lived lookups, store an ID or selector, not the node.
  • If you must attach data to a node, use a WeakMap (see M5).
M9

Closure giữ dữ liệu lớnLarge closures retained

Tham chiếu bị giữ ngoài ý muốnReferences kept by accidentĐã hiệu đínhCorrected
Chung scope với dữ liệu lớnShares scope with big data Tách hàm, thu hẹp closureSplit functions, narrow closures

Closure sống lâu có thể giữ dữ liệu lớn mà nó chẳng dùng, chỉ vì một closure khác cùng scope có dùng.A long-lived closure can retain big data it never touches, just because a sibling closure in the same scope does.

Gặp ởSeen in
Handler được đăng ký trong một hàm vừa xử lý payload lớn.A handler registered inside a function that just processed a big payload.
ĐượcYou gain
Callback sống lâu chỉ giữ đúng thứ nó cần.Long-lived callbacks keep only what they need.
MấtYou pay
Rất khó thấy bằng mắt khi đọc code.Very hard to spot by reading code.

Đánh đổiTrade-off Chọn thu hẹp closure là đổi thêm một hàm tách riêng lấy việc callback sống lâu không kéo theo dữ liệu nó không dùng.Choosing narrow closures trades one extra function for long-lived callbacks that no longer drag unused data along.

Dấu hiệuTell-tale sign: Trong heap snapshot, object lớn có retainer là context của một hàm (system / Context).In a heap snapshot, the large object's retainer is a function's context (system / Context).

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. Câu “closure giữ toàn bộ scope” không đúng với V8 hiện đại: engine chỉ đưa vào context những biến mà ít nhất một closure trong scope thực sự dùng. Cái bẫy thật là mọi closure tạo trong cùng một scope dùng chung một context. Nếu closure A dùng bigData, thì closure B sống lâu, không hề đụng tới bigData, vẫn giữ nó. Riêng khi có eval bên trong, engine buộc phải giữ tất cả.“A closure holds its entire scope” is not true of modern V8: the engine only puts variables into the context if at least one closure in that scope uses them. The real trap is that all closures created in one scope share a single context. If closure A uses bigData, a long-lived closure B that never touches bigData still retains it. Only when eval is present does the engine have to keep everything.

Nó là gìWhat it is. Một callback sống lâu (listener, timer, giá trị trong cache) vô tình giữ dữ liệu lớn vì được tạo trong cùng scope với đoạn code dùng dữ liệu đó.A long-lived callback (listener, timer, cached value) accidentally keeps large data because it was created in the same scope as code that uses that data.

// Leaks: the returned closure shares a context with `log`, which uses `big`
function setup() {
  const big = loadHugeArray();
  const log = () => console.log(big[0]);
  log();
  const n = big.length;
  return () => n;          // still retains `big`
}

// Fixed: do the heavy work in its own function, return only the small result
const count = () => loadHugeArray().length;
function setup() {
  const n = count();
  return () => n;
}

Hoạt động thế nàoHow it works. Mỗi scope có một context object, và mọi closure trong scope đều trỏ tới đúng context đó. Context chứa hợp của các biến được bất kỳ closure nào dùng. Closure nào sống lâu nhất sẽ quyết định context sống bao lâu.Each scope gets one context object, and every closure in that scope points to it. The context holds the union of variables used by any of those closures. The longest-lived closure decides how long the context lives.

Mẹo triển khaiImplementation tips

  • Đưa phần xử lý dữ liệu lớn vào hàm riêng và chỉ trả ra kết quả nhỏ.
  • Tránh eval và with; chúng buộc engine giữ toàn bộ scope.
  • Trong snapshot, mở retainer đến (closure) rồi context để thấy chính xác biến nào đang bị giữ.
  • Move heavy data processing into its own function and return only the small result.
  • Avoid eval and with; they force the engine to keep the whole scope.
  • In the snapshot, expand the retainer to (closure) then context to see exactly which variable is held.

Tài nguyên ngoài bộ nhớResources beyond the heap

Kết nối và file handle cạn trước khi RAM kịp báo động.Connections and handles run out long before RAM raises an alarm.

M4

Kết nối DB không được trảUnclosed DB connections

Tài nguyên ngoài bộ nhớResources beyond the heapĐã hiệu đínhCorrected
Mở mới mỗi requestOpen per request Pool + luôn release trong finallyPool + always release in finally

Kết nối mở ra mà không đóng hoặc không trả về pool sẽ âm thầm làm cạn pool cho tới khi mọi request treo.Connections opened and never closed or returned drain the pool silently until every request hangs.

Gặp ởSeen in
Transaction bị throw giữa chừng mà không có finally.Transactions that throw midway with no finally.
ĐượcYou gain
Độ trễ mỗi query giảm vì bỏ được bước bắt tay.Lower per-query latency because handshakes disappear.
MấtYou pay
Pool size × số instance vẫn có thể vượt max_connections.Pool size × instance count can still exceed max_connections.

Đánh đổiTrade-off Chọn pool là đổi quyền kiểm soát từng kết nối và vài ràng buộc về session lấy độ trễ thấp và tổng số kết nối có trần.Choosing a pool trades per-connection control and a few session constraints for low latency and a hard cap on total connections.

Dấu hiệuTell-tale sign: Lỗi “timeout acquiring connection” hoặc “too many clients already” xuất hiện trong khi CPU của DB vẫn thấp.“Timeout acquiring connection” or “too many clients already” errors while database CPU is low.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. Pooling không tự sửa lỗi quên đóng. Pool chỉ giới hạn và tái sử dụng kết nối; code mượn mà không trả thì pool vẫn cạn và request đứng chờ. Cách sửa thật là pool cộng với luôn release trong finally, hoặc dùng API tự trả như pool.query(). Nói cho chính xác, đây là leak tài nguyên (kết nối, file descriptor) hơn là leak bộ nhớ.Pooling alone does not fix forgotten releases. A pool bounds and reuses connections; code that borrows without returning still drains it and requests queue forever. The real fix is a pool plus always releasing in finally, or APIs that release for you such as pool.query(). Strictly speaking this is a resource leak (connections, file descriptors) rather than a memory leak.

Nó là gìWhat it is. Mở kết nối DB cho mỗi request, hoặc mượn kết nối từ pool, rồi có một nhánh code — thường là nhánh lỗi — không đóng hay trả lại.Opening a DB connection per request, or borrowing one from a pool, with some code path — usually the error path — that never closes or returns it.

const client = await pool.connect();
try {
  await client.query('BEGIN');
  // ...
  await client.query('COMMIT');
} catch (e) {
  await client.query('ROLLBACK');
  throw e;
} finally {
  client.release();
}

Hoạt động thế nàoHow it works. DB có giới hạn số kết nối (PostgreSQL mặc định max_connections = 100). Mở mới mỗi request tốn bắt tay TCP, TLS và xác thực, và nhanh chóng chạm trần. Pool giữ sẵn N kết nối; request mượn rồi trả. Mỗi kết nối không được trả làm pool nhỏ đi một, lặng lẽ, cho tới 0.Databases cap connections (PostgreSQL defaults to max_connections = 100). Opening one per request pays for TCP, TLS and auth handshakes and hits the cap quickly. A pool keeps N connections open; requests borrow and return. Every unreturned connection shrinks the pool by one, silently, down to zero.

Mẹo triển khaiImplementation tips

  • Tính trước: pool_size × số instance phải nhỏ hơn max_connections, chừa chỗ cho admin và migration.
  • Cảnh báo khi thời gian chờ lấy kết nối lớn hơn 0 kéo dài; đó là dấu hiệu sớm nhất.
  • Câu lệnh đơn thì dùng pool.query(), nó tự trả kết nối; chỉ connect() thủ công khi cần transaction.
  • Do the math: pool_size × instances must stay below max_connections, with headroom for admin and migrations.
  • Alert when connection acquire wait stays above zero; it is the earliest signal.
  • Use pool.query() for single statements since it releases for you; only connect() manually for transactions.

Xác thựcAuthentication

Tài khoản thật sự bị chiếm như thế nàoHow accounts actually get compromised

Giả định bí mật sẽ có ngày bị lộ.Assume every secret leaks one day.

Lưu và kiểm tra bí mậtStoring and checking secrets

Giả định DB và repo sẽ có ngày bị lộ, rồi thiết kế sao cho lúc đó thiệt hại nhỏ nhất.Assume the database and the repo will leak one day, and design so that day costs little.

A1

Mật khẩu lưu dạng thôPasswords in plaintext

Lưu và kiểm tra bí mậtStoring and checking secretsĐã hiệu đínhCorrected
Lưu nguyên vănStored raw Argon2id / bcryptArgon2id / bcrypt

Một lần lộ DB biến mọi mật khẩu dạng thô thành credential dùng được ngay, ở cả dịch vụ khác.One database dump turns every plaintext password into a live credential, on other services too.

Gặp ởSeen in
Hệ thống đăng nhập tự viết.Home-grown login systems.
ĐượcYou gain
Lộ DB không còn đồng nghĩa với lộ mật khẩu.A leaked database no longer means leaked passwords.
MấtYou pay
Tốn CPU và RAM mỗi lần đăng nhập, nên cũng là điểm để DoS nếu thiếu rate limit (A3).Costs CPU and RAM per login, which makes it a DoS target without rate limiting (A3).

Đánh đổiTrade-off Chọn hàm băm chậm là đổi vài trăm mili giây mỗi lần đăng nhập lấy việc kẻ tấn công phải trả đúng cái giá đó cho từng lần đoán.Choosing a slow hash trades a few hundred milliseconds per login for making attackers pay that same price on every single guess.

Dấu hiệuTell-tale sign: Chức năng quên mật khẩu gửi lại đúng mật khẩu cũ, hoặc cột password trong DB đọc được bằng mắt.Password reset emails you your old password, or the password column is human-readable.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. Ảnh ghi “Hash” là đủ, nhưng không phải hash nào cũng được. SHA-256 hay MD5 là hash nhanh: GPU thử hàng tỷ lần mỗi giây, lộ DB gần như bằng lộ mật khẩu. Phải dùng hàm băm chậm có salt: Argon2id (OWASP khuyến nghị hàng đầu), scrypt, hoặc bcrypt; PBKDF2 khi cần tuân thủ FIPS.The image implies any hash will do. It will not. SHA-256 or MD5 are fast hashes: a GPU tries billions per second, so a leaked DB is nearly a leaked password list. Use a slow, salted password hash: Argon2id (OWASP's first choice), scrypt or bcrypt; PBKDF2 when FIPS compliance is required.

Nó là gìWhat it is. Lưu mật khẩu nguyên văn, mã hoá hai chiều, hoặc băm bằng hàm nhanh không salt.Storing passwords as-is, with reversible encryption, or with a fast unsalted hash.

import argon2 from 'argon2';
const hash = await argon2.hash(password);     // argon2id + random salt by default
const ok = await argon2.verify(hash, input);

Hoạt động thế nàoHow it works. Hàm băm mật khẩu cố tình chậm, có tham số chi phí về thời gian và bộ nhớ. Salt ngẫu nhiên cho từng user khiến bảng tra sẵn vô dụng, và hai người cùng mật khẩu vẫn có hash khác nhau. Argon2id đòi nhiều bộ nhớ nên GPU và ASIC khó chạy song song hàng loạt.Password hashes are deliberately slow, with time and memory cost parameters. A random per-user salt makes precomputed tables useless, and two users with the same password get different hashes. Argon2id is memory-hard, so GPUs and ASICs cannot parallelize it cheaply.

Mẹo triển khaiImplementation tips

  • Tham số tối thiểu OWASP cho Argon2id: m = 19 MiB, t = 2, p = 1; bcrypt thì cost từ 10 trở lên.
  • Hash cũ yếu: bọc nó trong Argon2id ngay (argon2(md5)), rồi băm lại bằng mật khẩu thật khi user đăng nhập.
  • Không tự viết crypto; hàm verify của thư viện đã so sánh thời gian hằng.
  • OWASP minimum for Argon2id: m = 19 MiB, t = 2, p = 1; for bcrypt, cost 10 or higher.
  • For weak legacy hashes, wrap them in Argon2id now (argon2(md5)), then rehash with the real password at next login.
  • Never roll your own crypto; library verify functions already compare in constant time.
A6

Chính sách mật khẩu sai hướngWeak or misguided password policy

Lưu và kiểm tra bí mậtStoring and checking secretsĐã hiệu đínhCorrected
Chấp nhận “123456”“123456” allowed Độ dài + chặn mật khẩu đã lộLength + breached-list check

Cho mật khẩu yếu đi qua không bỏ được rủi ro, chỉ chuyển nó sang người dùng.Letting weak passwords through does not remove the risk; it moves it onto your users.

Gặp ởSeen in
Form đăng ký.Sign-up forms.
ĐượcYou gain
Loại bỏ đúng nhóm mật khẩu bị thử đầu tiên.Removes exactly the passwords attackers try first.
MấtYou pay
Kiểm tra danh sách lộ cần gọi API ngoài hoặc tải bộ dữ liệu vài chục GB.Breached-list checks need an external API or a multi-GB dataset.

Đánh đổiTrade-off Chọn chặn theo danh sách lộ thay vì quy tắc thành phần là đổi cảm giác “trông phức tạp” lấy mật khẩu thật sự khó đoán mà người dùng vẫn nhớ được.Choosing breached-list screening over composition rules trades the look of complexity for passwords that are actually hard to guess and still memorable.

Dấu hiệuTell-tale sign: Đăng ký thành công với “123456”, “password” hoặc tên công ty cộng năm hiện tại.You can sign up with “123456”, “password” or the company name plus the current year.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. “Yêu cầu độ mạnh” kiểu bắt chữ hoa, số, ký tự đặc biệt và đổi mật khẩu 90 ngày một lần đã bị NIST SP 800-63B (bản 4, 2025) loại bỏ, vì chúng đẩy người dùng vào những mẫu dễ đoán như Password1!. Khuyến nghị hiện hành: tối thiểu 15 ký tự nếu mật khẩu là yếu tố duy nhất (8 nếu có MFA), cho phép tối đa ít nhất 64 ký tự, không áp quy tắc thành phần, không bắt đổi định kỳ, và bắt buộc đối chiếu với danh sách mật khẩu đã lộ.“Strength requirements” in the sense of forced uppercase, digits, symbols and 90-day rotation were dropped by NIST SP 800-63B (revision 4, 2025), because they push users into guessable patterns like Password1!. Current guidance: at least 15 characters when the password is the only factor (8 with MFA), allow at least 64, no composition rules, no forced periodic changes, and mandatory screening against breached-password lists.

Nó là gìWhat it is. Chấp nhận mật khẩu ngắn hoặc phổ biến; hoặc ngược lại, dựng quy tắc rắc rối khiến người dùng dồn vào vài mẫu quen thuộc.Accepting short or common passwords; or the opposite, piling on rules that funnel users into a handful of familiar patterns.

SHA1("password") = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
GET https://api.pwnedpasswords.com/range/5BAA6
→ look for the remaining 35 characters in the response

Hoạt động thế nàoHow it works. Kẻ tấn công không đoán ngẫu nhiên. Họ thử danh sách mật khẩu đã lộ và biến thể của chúng. Chặn đúng những mật khẩu đó hiệu quả hơn mọi quy tắc thành phần, còn mỗi ký tự độ dài thêm vào nhân không gian tìm kiếm lên nhiều lần.Attackers do not guess randomly. They replay breached lists and their variants. Blocking exactly those passwords beats any composition rule, and every extra character of length multiplies the search space.

Mẹo triển khaiImplementation tips

  • Dùng API k-anonymity của Have I Been Pwned: chỉ 5 ký tự đầu của SHA-1 rời khỏi server bạn.
  • Hiển thị đồng hồ độ mạnh (zxcvbn) thay cho danh sách quy tắc.
  • Cho phép dán mật khẩu để password manager hoạt động bình thường.
  • Use Have I Been Pwned's k-anonymity API: only the first 5 SHA-1 characters leave your server.
  • Show a strength meter (zxcvbn) instead of a list of rules.
  • Allow pasting so password managers work.
A9

Một secret cho mọi môi trườngSame secret everywhere

Lưu và kiểm tra bí mậtStoring and checking secrets
Một key, mọi môi trườngOne key, all envs Secret riêng từng môi trườngPer-environment secrets

Một secret staging bị lộ không được phép làm lộ luôn production.A leaked staging secret should not compromise production too.

Gặp ởSeen in
JWT signing key giống nhau, nên token ký ở staging dùng được ở production.The same JWT signing key, so tokens minted in staging work in production.
ĐượcYou gain
Lộ staging không kéo theo production.A staging leak stays in staging.
MấtYou pay
Số secret cần quản lý nhân lên theo số môi trường.The number of secrets multiplies by the number of environments.

Đánh đổiTrade-off Chọn secret riêng cho từng môi trường là đổi thêm công quản lý lấy việc sự cố ở một môi trường dừng lại ở đó.Choosing per-environment secrets trades extra management work for incidents that stop at the environment where they started.

Dấu hiệuTell-tale sign: Xoay vòng một secret buộc phải cập nhật nhiều môi trường cùng lúc.Rotating one secret forces you to update several environments at once.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Dùng chung JWT secret, API key, mật khẩu DB giữa dev, staging và production. Staging thường được bảo vệ lỏng hơn, nên nó thành cửa sau vào production.Sharing the JWT signing secret, API keys and DB passwords across dev, staging and production. Staging is usually guarded more loosely, so it becomes a back door into production.

Hoạt động thế nàoHow it works. Một secret chỉ an toàn bằng nơi yếu nhất đang giữ nó: laptop của dev, log CI, file .env trong repo staging. Tách secret theo môi trường khoanh vùng thiệt hại trong đúng một môi trường.A secret is only as safe as the weakest place holding it: a developer laptop, CI logs, a .env file in the staging repo. Separate secrets per environment confine the damage to one environment.

Mẹo triển khaiImplementation tips

  • Chạy gitleaks hoặc trufflehog trong CI và quét cả lịch sử git; xoá commit là chưa đủ, secret đã lộ phải được xoay vòng.
  • Đặt iss và aud khác nhau cho từng môi trường để JWT không dùng chéo được.
  • Viết sẵn quy trình xoay vòng và diễn tập một lần trước khi thật sự cần.
  • Run gitleaks or trufflehog in CI and over the full git history; deleting the commit is not enough, a leaked secret must be rotated.
  • Use a different iss and aud per environment so JWTs cannot cross over.
  • Write the rotation runbook and rehearse it once before you need it.

Chặn ở cửa đăng nhậpStopping attackers at the door

Mật khẩu sẽ bị đoán hoặc bị lộ; cửa đăng nhập phải chịu được cả hai.Passwords will be guessed or leaked; the login door has to survive both.

A3

Đăng nhập không giới hạn số lần thửNo rate limit on login

Chặn ở cửa đăng nhậpStopping attackers at the door
Thử vô hạnUnlimited attempts Giới hạn và làm chậmThrottled attempts

Không có giới hạn thì đoán ra mật khẩu chỉ còn là vấn đề thời gian.Without throttling, cracking a password is only a matter of time.

Gặp ởSeen in
/login và /forgot-password./login and /forgot-password.
ĐượcYou gain
Đoán mò OTP 6 số trong thời hạn hiệu lực của mã trở nên bất khả thi.Guessing a 6-digit OTP within its validity window becomes infeasible.
MấtYou pay
Khoá cứng tài khoản sau N lần sai cho phép kẻ tấn công khoá tài khoản của bất kỳ ai.Hard lockout after N failures lets attackers lock anyone out.

Đánh đổiTrade-off Chọn giới hạn tần suất là đổi chút phiền toái cho người gõ sai lấy việc biến tấn công đoán mò từ vài giờ thành vài thế kỷ.Choosing rate limits trades a little friction for people who mistype for turning a guessing attack from hours into centuries.

Dấu hiệuTell-tale sign: Log có hàng nghìn lần đăng nhập thất bại từ một IP, hoặc nhắm vào một tài khoản, trong vài phút.Logs show thousands of failed logins from one IP, or against one account, within minutes.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Endpoint đăng nhập, quên mật khẩu hay xác minh OTP cho phép thử bao nhiêu lần cũng được.Login, password reset or OTP verification endpoints that accept unlimited attempts.

Hoạt động thế nàoHow it works. Không có giới hạn thì brute-force và credential stuffing chỉ bị chặn bởi băng thông. Giới hạn nên theo nhiều chiều: theo tài khoản để chống dò một người, theo IP để chống một nguồn, và toàn cục để phát hiện chiến dịch phân tán.Without limits, brute force and credential stuffing are bounded only by bandwidth. Limit along several axes: per account against targeted guessing, per IP against a single source, and globally to spot distributed campaigns.

Mẹo triển khaiImplementation tips

  • Dùng độ trễ tăng dần hoặc CAPTCHA thay cho khoá cứng tài khoản.
  • Trả cùng thông báo và cùng thời gian phản hồi cho “sai email” và “sai mật khẩu” để không lộ tài khoản nào tồn tại.
  • Lưu bộ đếm trong Redis để giới hạn vẫn đúng khi chạy nhiều instance.
  • Use increasing delays or CAPTCHA instead of hard lockouts.
  • Return the same message and the same response time for “wrong email” and “wrong password” so account existence does not leak.
  • Keep counters in Redis so limits hold across instances.
A5

Không có MFANo MFA

Chặn ở cửa đăng nhậpStopping attackers at the doorĐã hiệu đínhCorrected
Chỉ cần mật khẩuPassword only MFA, ưu tiên passkeyMFA, passkeys first

Một mật khẩu bị lộ không nên đủ để vào được tài khoản.A leaked password alone should not be enough to get in.

Gặp ởSeen in
Tài khoản admin và nhân viên nội bộ: bắt buộc.Admin and staff accounts: mandatory.
ĐượcYou gain
Microsoft báo cáo MFA chặn hơn 99,9% vụ chiếm tài khoản tự động.Microsoft reports MFA blocks over 99.9% of automated account takeovers.
MấtYou pay
Luồng khôi phục khi mất thiết bị thường trở thành lỗ hổng mới.Account recovery after losing a device often becomes the new weak spot.

Đánh đổiTrade-off Chọn MFA là đổi vài giây ma sát mỗi lần đăng nhập lấy việc một mật khẩu bị lộ không còn đủ để vào.Choosing MFA trades a few seconds of friction per login for a leaked password no longer being enough to get in.

Dấu hiệuTell-tale sign: Tài khoản admin đăng nhập được chỉ với email và mật khẩu.An admin account can sign in with email and password alone.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. Ảnh gộp mọi loại MFA làm một, nhưng chúng không ngang nhau. SMS OTP dễ bị SIM swap; TOTP và push vẫn bị phishing theo thời gian thực qua proxy như Evilginx. Chỉ WebAuthn/passkey chống phishing thật sự, vì khoá gắn chặt với tên miền.The image treats all MFA as equal. It is not. SMS codes fall to SIM swaps; TOTP and push approvals can still be phished in real time through proxies like Evilginx. Only WebAuthn/passkeys are truly phishing-resistant, because the key is bound to the domain.

Nó là gìWhat it is. Chỉ cần email và mật khẩu là vào được. Mật khẩu lộ qua phishing, qua rò rỉ ở dịch vụ khác hay qua malware đều đủ để chiếm tài khoản.Email and password are all it takes. A password leaked through phishing, another site's breach or malware is enough to take over the account.

Hoạt động thế nàoHow it works. MFA đòi thêm một yếu tố khác loại, thường là thứ bạn có: điện thoại, khoá bảo mật. Passkey dùng cặp khoá công khai gắn với origin; trang giả ở tên miền khác không thể lấy được chữ ký hợp lệ, kể cả khi người dùng bị lừa.MFA adds a factor of a different kind, usually something you have: a phone, a security key. Passkeys use a public-key pair bound to the origin; a fake site on another domain cannot obtain a valid signature even if the user is fooled.

Mẹo triển khaiImplementation tips

  • Bắt buộc cho admin, khuyến khích cho người dùng thường.
  • Mã khôi phục dùng một lần và lưu dạng hash như mật khẩu.
  • Yêu cầu xác thực lại trước hành động nhạy cảm: đổi email, tắt MFA, rút tiền.
  • Mandatory for admins, encouraged for everyone else.
  • Recovery codes are single-use and stored hashed, like passwords.
  • Require step-up re-authentication before sensitive actions: changing email, disabling MFA, withdrawals.

Vòng đời phiên và tokenSession and token lifetime

Token là tiền mặt: ai cầm cũng tiêu được, nên phải có hạn, cất kỹ và huỷ được.Tokens are cash: whoever holds one can spend it, so they must expire, be stored safely and be revocable.

A2

Token sống quá lâuLong-lived tokens

Vòng đời phiên và tokenSession and token lifetime
Không bao giờ hết hạnNever expires Access ngắn + refresh xoay vòngShort access + rotating refresh

Token bị đánh cắp mà không hết hạn là một cửa sau vĩnh viễn.A stolen token that never expires is a permanent back door.

Gặp ởSeen in
SPA dùng JWT để gọi API.SPAs calling APIs with JWTs.
ĐượcYou gain
Token bị đánh cắp chỉ dùng được vài phút.A stolen access token is useful for minutes.
MấtYou pay
Thêm độ phức tạp: luồng refresh, xử lý nhiều request đồng thời đúng lúc token hết hạn.More moving parts: the refresh flow, concurrent requests at the moment of expiry.

Đánh đổiTrade-off Chọn access token ngắn hạn là đổi thêm một luồng refresh lấy việc token bị đánh cắp tự mất giá trị sau vài phút.Choosing short-lived access tokens trades an extra refresh flow for stolen tokens that go worthless within minutes.

Dấu hiệuTell-tale sign: Giải mã JWT thấy exp cách iat vài tháng, hoặc không có exp.Decoding the JWT shows exp months after iat, or no exp at all.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Access token (thường là JWT) có hạn vài tháng hoặc không có hạn. Ai lấy được token là có quyền cho tới khi nó hết hạn.Access tokens, usually JWTs, valid for months or with no expiry at all. Whoever gets one keeps access until it expires.

Hoạt động thế nàoHow it works. Access token ngắn hạn (5–15 phút) dùng cho mọi request. Refresh token dài hạn hơn, cất kỹ và chỉ gửi tới endpoint cấp token. Với rotation, mỗi lần refresh cấp một refresh token mới và vô hiệu cái cũ; nếu cái cũ bị dùng lại, đó là dấu hiệu bị đánh cắp và cả chuỗi token bị thu hồi.Short-lived access tokens (5–15 minutes) go with every request. A longer-lived refresh token is stored carefully and only sent to the token endpoint. With rotation, each refresh issues a new refresh token and invalidates the old one; if an old one is replayed, that signals theft and the whole token family is revoked.

Mẹo triển khaiImplementation tips

  • Khi verify JWT, kiểm tra exp, iss, aud và cố định thuật toán được phép để chặn alg: none.
  • Đặt refresh token trong cookie HttpOnly với Path giới hạn ở endpoint refresh.
  • Web app thông thường: session cookie phía server thường đơn giản và an toàn hơn JWT.
  • When verifying JWTs, check exp, iss and aud and pin the allowed algorithm to block alg: none.
  • Put the refresh token in an HttpOnly cookie with Path scoped to the refresh endpoint.
  • For a plain web app, server-side session cookies are usually simpler and safer than JWTs.
A4

Lưu phiên trong localStorageSession in localStorage

Vòng đời phiên và tokenSession and token lifetimeĐã hiệu đínhCorrected
Token JS đọc đượcJS-readable token Cookie HttpOnly + SameSiteHttpOnly + SameSite cookie

Mọi script chạy trên trang đều đọc được localStorage; cookie HttpOnly thì không.Any script on the page can read localStorage; an HttpOnly cookie it cannot.

Gặp ởSeen in
SPA tự quản lý JWT.SPAs managing their own JWTs.
ĐượcYou gain
Token không thể bị đọc rồi mang đi dùng ở máy khác.The token cannot be read and replayed from another machine.
MấtYou pay
Phải xử lý CSRF bằng SameSite=Lax hoặc Strict, cộng token CSRF cho luồng đặc biệt.You must handle CSRF with SameSite=Lax or Strict, plus CSRF tokens for special flows.

Đánh đổiTrade-off Chọn cookie HttpOnly là đổi sự tiện lợi của việc JS tự cầm token lấy việc XSS không mang được phiên đăng nhập ra khỏi trình duyệt.Choosing HttpOnly cookies trades the convenience of JS holding the token for XSS being unable to carry the session out of the browser.

Dấu hiệuTell-tale sign: Gõ localStorage trong console thấy một chuỗi bắt đầu bằng eyJ.Typing localStorage in the console shows a string starting with eyJ.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. HttpOnly ngăn script đọc token, nhưng không ngăn script đã chèn gửi request kèm cookie ngay từ trang đó. XSS vẫn là XSS: HttpOnly giảm thiệt hại (token không bị mang đi dùng ở nơi khác) chứ không vô hiệu hoá nó. Chuyển sang cookie cũng mở ra rủi ro CSRF, nên cần SameSite và token CSRF khi phù hợp.HttpOnly stops scripts from reading the token, but not an injected script from sending requests that carry the cookie from that same page. XSS is still XSS: HttpOnly limits the damage (the token cannot be carried off and reused elsewhere) rather than neutralizing it. Moving to cookies also opens CSRF, so you need SameSite and, where appropriate, CSRF tokens.

Nó là gìWhat it is. Lưu access token hoặc session token trong localStorage để JavaScript tự đọc và gắn vào header.Keeping the access or session token in localStorage so JavaScript can read it and attach it to headers.

Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/

Hoạt động thế nàoHow it works. Mọi script chạy trên origin — kể cả script chèn qua XSS hay một package npm bị nhiễm — đều đọc được localStorage và gửi token về máy chủ kẻ tấn công. Cookie HttpOnly nằm ngoài tầm với của JS; trình duyệt tự gắn nó vào request. Tiền tố __Host- buộc cookie phải Secure, Path=/ và không có Domain, nên subdomain không ghi đè được.Any script on the origin — including one injected via XSS or a compromised npm package — can read localStorage and ship the token to an attacker. An HttpOnly cookie is out of JS's reach; the browser attaches it to requests itself. The __Host- prefix forces Secure, Path=/ and no Domain, so subdomains cannot overwrite it.

Mẹo triển khaiImplementation tips

  • Thêm Content-Security-Policy; đó mới là lớp chặn XSS chính.
  • SPA gọi API riêng: cân nhắc mô hình BFF (backend-for-frontend) để token nằm hẳn phía server.
  • SameSite=Lax là mặc định hợp lý; Strict làm hỏng trải nghiệm khi người dùng đến từ link bên ngoài.
  • Add a Content-Security-Policy; that is the primary XSS defense.
  • For SPAs calling a separate API, consider a backend-for-frontend so tokens never reach the browser.
  • SameSite=Lax is a sensible default; Strict breaks arrivals from external links.
A7

Không thu hồi được phiênNo token revocation

Vòng đời phiên và tokenSession and token lifetime
Không huỷ được quyền truy cậpCan't undo access Phiên thu hồi đượcRevocable sessions

Thiết bị bị mất phải cách trạng thái đăng xuất đúng một cú bấm.A stolen device should be one click away from being logged out.

Gặp ởSeen in
Nút “Đăng xuất khỏi mọi thiết bị”.A “Sign out of all devices” button.
ĐượcYou gain
Phản ứng sự cố tính bằng giây.Incident response in seconds.
MấtYou pay
JWT phải tra cứu thêm trạng thái, mất một phần lợi thế stateless.JWTs need a state lookup, giving up part of the stateless advantage.

Đánh đổiTrade-off Chọn phiên thu hồi được là đổi một lần tra cứu trạng thái mỗi request (hoặc mỗi lần refresh) lấy khả năng cắt quyền truy cập ngay lập tức.Choosing revocable sessions trades one state lookup per request (or per refresh) for the ability to cut access immediately.

Dấu hiệuTell-tale sign: Đổi mật khẩu xong, trình duyệt kia vẫn dùng bình thường.After a password change, the other browser keeps working.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Không có cách chấm dứt một phiên đang hoạt động. Người dùng mất máy, đổi mật khẩu, bị khoá tài khoản, nhưng phiên cũ vẫn chạy bình thường.No way to end an active session. The user loses a device, changes their password or gets suspended, and the old session keeps working.

Hoạt động thế nàoHow it works. Với session phía server, xoá bản ghi là thu hồi ngay. Với JWT stateless, cần một trong ba: hạn ngắn kết hợp thu hồi refresh token; denylist theo jti; hoặc một token_version trên user, tăng lên khi cần đăng xuất khỏi mọi nơi.With server-side sessions, deleting the record revokes instantly. With stateless JWTs you need one of three: short expiry plus refresh-token revocation; a denylist keyed by jti; or a token_version on the user that you bump to sign out everywhere.

Mẹo triển khaiImplementation tips

  • Đổi mật khẩu thì thu hồi mọi phiên khác, chỉ giữ phiên hiện tại.
  • Cho người dùng xem danh sách phiên: thiết bị, vị trí gần đúng, lần hoạt động cuối.
  • Denylist chỉ cần giữ tới lúc token hết hạn, nên đặt TTL bằng exp.
  • On password change, revoke every other session and keep only the current one.
  • Show users their sessions: device, approximate location, last active.
  • A denylist entry only needs to live until the token expires, so set its TTL to exp.

Ranh giới tin cậy và dấu vếtTrust boundary and evidence

Server quyết định, server ghi lại. Trình duyệt chỉ là giao diện.The server decides and the server records. The browser is just a display.

A8

Chỉ kiểm tra ở phía clientClient-side only checks

Ranh giới tin cậy và dấu vếtTrust boundary and evidence
UI ẩn nútUI hides button Server kiểm tra từng requestServer-side verification

Bất cứ thứ gì chỉ được kiểm tra trong trình duyệt đều có thể bị bỏ qua hoàn toàn.Anything checked only in the browser can be bypassed entirely.

Gặp ởSeen in
Nút Xoá bị ẩn với user thường, nhưng API DELETE không kiểm tra quyền.The Delete button is hidden for regular users, but the DELETE endpoint never checks.
ĐượcYou gain
Quyền được thực thi bất kể client nào gọi tới.Authorization holds regardless of which client calls.
MấtYou pay
Validate lặp ở cả hai phía: client để trải nghiệm tốt, server để đúng.Validation is duplicated: client for UX, server for correctness.

Đánh đổiTrade-off Chọn kiểm tra phía server là đổi code kiểm tra trùng lặp lấy việc giao diện có bị qua mặt cũng không mở được gì.Choosing server-side checks trades duplicated validation for a UI that unlocks nothing even when bypassed.

Dấu hiệuTell-tale sign: Đổi một ID trong URL hoặc body và nhận về dữ liệu không phải của mình.Change an ID in the URL or body and get back data that is not yours.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Ẩn nút, chặn route, kiểm tra quyền hay validate dữ liệu chỉ ở frontend, trong khi API phía sau tin mọi request.Hiding buttons, guarding routes, checking permissions or validating data only in the frontend, while the API behind it trusts every request.

-- Filter by owner inside the query, not after fetching
SELECT * FROM orders WHERE id = $1 AND owner_id = $2;

Hoạt động thế nàoHow it works. Client nằm trong tay người dùng: DevTools, curl, Postman gọi thẳng vào API. Mọi quyết định về quyền phải diễn ra ở server, trên từng request, dựa trên danh tính đã xác thực, chứ không dựa trên tham số client gửi lên như role=admin hay userId của người khác.The client belongs to the user: DevTools, curl and Postman call the API directly. Every authorization decision must happen on the server, per request, based on the authenticated identity — never on client-supplied parameters like role=admin or someone else's userId.

Mẹo triển khaiImplementation tips

  • Lọc theo chủ sở hữu ngay trong truy vấn thay vì lấy ra rồi mới kiểm.
  • Mặc định từ chối: endpoint mới phải khai báo quyền thì mới chạy được.
  • Viết test gọi API bằng user A trên tài nguyên của user B.
  • Filter by owner inside the query instead of fetching first and checking later.
  • Deny by default: new endpoints must declare their permission to run at all.
  • Write tests that call the API as user A against user B's resources.
A10

Không có nhật ký truy cậpNo audit trail

Ranh giới tin cậy và dấu vếtTrust boundary and evidence
Không có lịch sử đăng nhậpNo login history Audit log có cấu trúcStructured audit log

Không có dấu vết thì không phân biệt được lần đăng nhập thật với lần của kẻ tấn công.Without a trail, you cannot tell a real login from an attacker's.

Gặp ởSeen in
Đăng nhập thành công và thất bại.Successful and failed logins.
ĐượcYou gain
Phát hiện bất thường, ví dụ đăng nhập từ quốc gia chưa từng thấy.Spots anomalies, such as a login from a country never seen before.
MấtYou pay
Log chứa dữ liệu cá nhân, nên chịu các quy định bảo vệ dữ liệu cá nhân.Logs hold personal data and fall under data-protection rules.

Đánh đổiTrade-off Chọn ghi audit log là đổi chi phí lưu trữ và nghĩa vụ bảo vệ chính log đó lấy khả năng trả lời “chuyện gì đã xảy ra” khi có sự cố.Choosing audit logging trades storage cost and the duty to protect the logs themselves for being able to answer “what happened?” during an incident.

Dấu hiệuTell-tale sign: Khi được hỏi “tuần trước tài khoản này bị truy cập từ đâu?”, không ai trả lời được.When asked “where was this account accessed from last week?”, nobody can answer.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Không ghi lại ai đăng nhập, khi nào, từ đâu, thành công hay thất bại, cũng như các hành động nhạy cảm sau đó.Not recording who signed in, when, from where, whether it succeeded, or which sensitive actions followed.

{"ts":"2026-09-29T08:12:03Z","event":"login.failed","user":"u_812",
 "ip":"203.0.113.7","ua":"Mozilla/5.0 …","reason":"bad_password"}

Hoạt động thế nàoHow it works. Log bảo mật là sự kiện có cấu trúc: thời điểm, user, IP, user agent, loại sự kiện, kết quả. Ghi vào nơi mà chính ứng dụng không sửa hay xoá được, và giữ đủ lâu để điều tra.Security logs are structured events: timestamp, user, IP, user agent, event type, outcome. Write them somewhere the application itself cannot edit or delete, and keep them long enough to investigate.

Mẹo triển khaiImplementation tips

  • Không bao giờ ghi mật khẩu, token hay mã OTP, kể cả trong log lỗi.
  • Gửi email “đăng nhập từ thiết bị mới” cho người dùng; họ là hệ thống phát hiện rẻ nhất bạn có.
  • Đặt cảnh báo cho vài mẫu cụ thể thay vì dựng dashboard không ai xem.
  • Never log passwords, tokens or OTP codes, not even in error logs.
  • Email users on “sign-in from a new device”; they are the cheapest detection system you have.
  • Alert on a few specific patterns instead of building a dashboard nobody opens.

Triển khaiDeployment

Sự cố thật sự bắt đầu thế nàoHow outages actually start

Thu nhỏ vùng ảnh hưởng và luôn giữ đường lui.Keep the blast radius small and a way back open.

Giai đoạnStage 1

Trước khi release: chuẩn bị để lặp lại đượcBefore release: make it repeatable

Build một lần, config tách riêng, schema tương thích cả hai phiên bản.Build once, keep config outside, keep the schema compatible with both versions.

D4

Deploy thủ công qua SSHManual SSH deploy

Trước khi release: chuẩn bị để lặp lại đượcBefore release: make it repeatable
ssh + các bước làm tayssh + manual steps Pipeline CI/CDCI/CD pipeline

Một bước làm tay là một bước mà sớm muộn sẽ có người làm sai.A manual step is a step someone eventually gets wrong.

Gặp ởSeen in
Team nhỏ với vài server.Small teams with a few servers.
ĐượcYou gain
Deploy lặp lại được và ai trong team cũng bấm được.Deploys are repeatable and anyone on the team can run them.
MấtYou pay
Tốn thời gian dựng ban đầu.Up-front setup time.

Đánh đổiTrade-off Chọn pipeline tự động là đổi thời gian dựng ban đầu lấy việc mỗi lần deploy không còn phụ thuộc vào trí nhớ của ai.Choosing an automated pipeline trades up-front setup time for deploys that no longer depend on anyone's memory.

Dấu hiệuTell-tale sign: Chỉ một người trong team biết cách deploy.Only one person on the team knows how to deploy.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Deploy bằng cách SSH vào server, git pull, cài dependency rồi restart, theo trí nhớ hoặc theo một file ghi chú.Deploying by SSHing into servers, running git pull, installing dependencies and restarting, from memory or a notes file.

commit → build artifact (once) → test → staging → production
                 └─ same image, same checksum at every stage

Hoạt động thế nàoHow it works. Pipeline CI/CD biến các bước thành code: build một artifact bất biến (container image, bundle) đúng một lần, chạy test, rồi đẩy chính artifact đó qua từng môi trường. Mọi lần deploy giống hệt nhau và đều để lại lịch sử.A CI/CD pipeline turns the steps into code: build one immutable artifact (container image, bundle) exactly once, run tests, then promote that same artifact through each environment. Every deploy is identical and leaves a history.

Mẹo triển khaiImplementation tips

  • Build một lần, promote cùng một artifact; không build lại riêng cho production.
  • Giữ pipeline dưới khoảng 10–15 phút, nếu không hotfix sẽ quay về đường SSH.
  • Dùng OIDC từ CI tới cloud thay cho access key dài hạn lưu trong CI.
  • Build once, promote the same artifact; never rebuild for production.
  • Keep the pipeline under roughly 10–15 minutes or hotfixes drift back to SSH.
  • Use OIDC from CI to your cloud instead of long-lived access keys stored in CI.
D6

Config viết cứng trong codeConfig in code

Trước khi release: chuẩn bị để lặp lại đượcBefore release: make it repeatable
Giá trị hardcodeHardcoded values Config theo môi trườngEnvironment-based config

Đổi một giá trị config không nên cần build và deploy lại toàn bộ.A config change should not require a full rebuild and redeploy.

Gặp ởSeen in
Connection string của DB.Database connection strings.
ĐượcYou gain
Một artifact cho mọi môi trường.One artifact for every environment.
MấtYou pay
Đổi biến môi trường vẫn cần restart process.Changing an env var still needs a process restart.

Đánh đổiTrade-off Chọn config theo môi trường là đổi việc kiểm tra lúc build lấy khả năng chạy cùng một artifact ở mọi nơi.Choosing environment-based config trades build-time checking for running the same artifact everywhere.

Dấu hiệuTell-tale sign: Code rải rác if (env === 'production'), hoặc repo có file .env.production.Code is sprinkled with if (env === 'production'), or the repo contains a .env.production file.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. URL, credential, feature flag, giới hạn… viết thẳng trong mã nguồn. Đổi một con số phải sửa code, build và deploy lại.URLs, credentials, feature flags and limits written straight into source. Changing one number means editing code, building and redeploying.

import { z } from 'zod';
export const env = z.object({
  DATABASE_URL: z.string().url(),
  POOL_SIZE: z.coerce.number().int().positive().default(10),
}).parse(process.env);        // crash at boot, not at 3 a.m.

Hoạt động thế nàoHow it works. Theo 12-Factor, config là những gì thay đổi giữa các môi trường; nó phải tách khỏi code và được đọc từ môi trường lúc khởi động. Cùng một artifact chạy ở staging và production, chỉ khác config. Secret đi qua secret manager, không nằm trong repo.Per 12-Factor, config is whatever varies between environments; it lives outside the code and is read from the environment at startup. The same artifact runs in staging and production with different config. Secrets come from a secret manager, never the repo.

Mẹo triển khaiImplementation tips

  • Validate toàn bộ config khi khởi động và dừng ngay nếu thiếu; lỗi sớm rẻ hơn lỗi lúc 3 giờ sáng.
  • Frontend: biến build-time như VITE_* hay NEXT_PUBLIC_* bị nhúng thẳng vào bundle, không bao giờ để secret ở đó.
  • Tách config (URL, con số) khỏi secret (credential); chúng có vòng đời khác nhau.
  • Validate all config at startup and exit immediately if something is missing; early failure is cheaper than a 3 a.m. one.
  • Frontend build-time vars like VITE_* or NEXT_PUBLIC_* are baked into the bundle; never put secrets there.
  • Keep config (URLs, numbers) apart from secrets (credentials); they have different lifecycles.
D7

Không có kế hoạch migrationNo migration plan

Trước khi release: chuẩn bị để lặp lại đượcBefore release: make it repeatable
Schema và code đổi cùng lúcSchema + code together Migration tương thích ngượcBackward-compatible migration

Code cũ chạm vào schema mới giữa lúc deploy là lỗi chắc chắn xảy ra.Old code hitting a new schema mid-deploy is a guaranteed error.

Gặp ởSeen in
Đổi tên cột.Renaming a column.
ĐượcYou gain
Không cần downtime.No downtime required.
MấtYou pay
Một thay đổi thành hai hoặc ba release.One change becomes two or three releases.

Đánh đổiTrade-off Chọn migration tương thích ngược là đổi một thay đổi thành nhiều release lấy việc code cũ và mới cùng chạy an toàn.Choosing backward-compatible migrations trades one change for several releases so old and new code can run together safely.

Dấu hiệuTell-tale sign: Kế hoạch release ghi “chạy migration rồi deploy code” mà không nói gì về khoảng thời gian ở giữa.The release plan says “run migration, then deploy code” and says nothing about the window in between.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Đổi schema và code trong cùng một bước. Trong lúc deploy luôn có một khoảng thời gian code cũ và code mới cùng chạy, và một bên sẽ gặp schema không hợp.Changing schema and code in one step. During any deploy there is a window where old and new code run side by side, and one of them meets a schema it does not understand.

Release 1           Release 2              Release 3
ADD new_col   →     code uses new_col  →   DROP old_col
(old code OK)       backfill in batches    (nobody reads it)

Hoạt động thế nàoHow it works. Mẫu expand/contract: (1) expand — thêm cột hoặc bảng mới, không xoá gì; (2) deploy code ghi cả hai, đọc cái mới; (3) backfill dữ liệu theo lô; (4) deploy code chỉ dùng cái mới; (5) contract — xoá cái cũ ở một release sau.The expand/contract pattern: (1) expand — add the new column or table, remove nothing; (2) deploy code that writes both and reads the new one; (3) backfill in batches; (4) deploy code that uses only the new one; (5) contract — drop the old one in a later release.

Mẹo triển khaiImplementation tips

  • PostgreSQL: dùng CREATE INDEX CONCURRENTLY, và kiểm tra lệnh nào sẽ khoá bảng trước khi chạy.
  • Đặt lock_timeout cho migration để nó bỏ cuộc thay vì treo cả hệ thống khi chờ khoá.
  • Không bao giờ rename cột trực tiếp: thêm mới, chép dữ liệu, rồi mới xoá cũ.
  • PostgreSQL: use CREATE INDEX CONCURRENTLY, and check which statements take heavy locks before running them.
  • Set lock_timeout on migrations so they give up instead of stalling everything while waiting for a lock.
  • Never rename a column in place: add, copy, then drop.
Giai đoạnStage 2

Trong lúc release: thu nhỏ vùng ảnh hưởngDuring release: shrink the blast radius

Bản mới chỉ nhận traffic khi đã sẵn sàng, và ban đầu chỉ nhận một phần.The new version gets traffic only when ready, and only a slice of it at first.

D3

Release một lần cho tất cảBig bang release

Trong lúc release: thu nhỏ vùng ảnh hưởngDuring release: shrink the blast radius
100% cùng lúc100% at once Canary rolloutCanary rollout

Một bản release lỗi nên chạm tới 1% người dùng, không phải tất cả.A bad release should hit 1% of users, not all of them.

Gặp ởSeen in
Service có nhiều người dùng.Services with many users.
ĐượcYou gain
Lỗi chỉ chạm một phần nhỏ người dùng.Bugs reach only a small share of users.
MấtYou pay
Hai phiên bản chạy song song nên phải tương thích API và schema (D7).Two versions run side by side, so APIs and schema must be compatible (D7).

Đánh đổiTrade-off Chọn canary là đổi thời gian release dài hơn lấy việc lỗi chỉ chạm một phần nhỏ người dùng.Choosing canaries trades longer releases for bugs that reach only a small share of users.

Dấu hiệuTell-tale sign: Mỗi sự cố sau deploy đều ảnh hưởng toàn bộ người dùng ngay lập tức.Every post-deploy incident hits every user immediately.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Đưa phiên bản mới tới toàn bộ người dùng cùng một lúc. Có lỗi thì tất cả cùng gặp.Shipping the new version to every user at once. If it is broken, everyone is affected.

1% ──5 min──▶ 5% ──15 min──▶ 25% ──▶ 100%
 │ error rate or p99 worse than baseline?
 └────────────▶ automatic rollback

Hoạt động thế nàoHow it works. Canary chuyển một phần nhỏ traffic (1%, 5%, 25%…) sang bản mới, so sánh chỉ số như tỉ lệ lỗi và độ trễ với bản cũ, tăng dần nếu ổn và tự lùi nếu xấu. Vùng ảnh hưởng tỉ lệ với phần trăm traffic.A canary sends a small slice of traffic (1%, 5%, 25%…) to the new version, compares metrics such as error rate and latency against the old one, ramps up if healthy and rolls back if not. The blast radius scales with the traffic percentage.

Mẹo triển khaiImplementation tips

  • Canary không có so sánh chỉ số tự động là vô nghĩa; viết tiêu chí dừng trước khi bắt đầu.
  • Gắn người dùng vào một phiên bản (sticky) để trải nghiệm nhất quán.
  • Feature flag là canary ở tầng tính năng, rẻ hơn nhiều so với canary hạ tầng.
  • A canary without automated metric comparison is theater; write the stop criteria before you start.
  • Pin users to one version (sticky) so their experience is consistent.
  • Feature flags are canaries at the feature level and far cheaper than infrastructure canaries.
D8

Deploy có downtimeDowntime deploy

Trong lúc release: thu nhỏ vùng ảnh hưởngDuring release: shrink the blast radiusĐã hiệu đínhCorrected
Dừng, deploy, khởi độngStop, deploy, start Blue/green hoặc rolling updateBlue/green or rolling update

Người dùng không nên cảm nhận được lịch release của bạn.Users should not feel your release schedule.

Gặp ởSeen in
Service cần chạy liên tục 24/7.Services that must run 24/7.
ĐượcYou gain
Chuyển sang bản mới và lùi lại chỉ trong vài giây.Switch and switch back in seconds.
MấtYou pay
Blue/green cần gấp đôi hạ tầng trong lúc chuyển.Blue/green needs double infrastructure during the switch.

Đánh đổiTrade-off Chọn blue/green là đổi gấp đôi tài nguyên trong lúc chuyển lấy khả năng chuyển và lùi trong vài giây.Choosing blue/green trades double resources during the switch for switching and reverting in seconds.

Dấu hiệuTell-tale sign: Mỗi lần release lại có thông báo “hệ thống bảo trì”.Every release comes with a “scheduled maintenance” banner.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. Ảnh chỉ nêu blue/green. Đó không phải cách duy nhất để deploy không downtime: rolling update (mặc định của Kubernetes Deployment) thay dần từng instance, rẻ hơn vì không cần gấp đôi hạ tầng, và đủ tốt cho phần lớn trường hợp.The image names only blue/green. It is not the only zero-downtime option: a rolling update (the Kubernetes Deployment default) replaces instances gradually, costs less because it needs no duplicate environment, and is enough for most cases.

Nó là gìWhat it is. Dừng bản cũ, deploy, rồi khởi động bản mới. Trong khoảng đó người dùng nhận lỗi.Stopping the old version, deploying, then starting the new one. Users get errors in between.

Hoạt động thế nàoHow it works. Blue/green dựng đầy đủ môi trường mới (green) bên cạnh môi trường đang chạy (blue), kiểm tra green, chuyển load balancer sang green, và giữ blue lại để lùi tức thì. Rolling update thì thay từng instance một, chỉ tiến khi instance mới báo sẵn sàng (D5).Blue/green builds a full new environment (green) next to the live one (blue), checks green, switches the load balancer to it, and keeps blue around for instant rollback. A rolling update replaces instances one by one and only advances when each new instance reports ready (D5).

Mẹo triển khaiImplementation tips

  • Trên Kubernetes, rolling update với readiness probe và maxUnavailable: 0 thường là đủ.
  • Xử lý SIGTERM đúng cách: ngừng nhận request mới, hoàn tất request đang chạy rồi mới thoát.
  • Giữ môi trường cũ đủ lâu để bắt được cả những lỗi lộ ra chậm.
  • On Kubernetes, a rolling update with a readiness probe and maxUnavailable: 0 is usually enough.
  • Handle SIGTERM properly: stop accepting new requests, finish in-flight ones, then exit.
  • Keep the old environment long enough to catch slow-burning bugs.
D5

Không có health checkNo health check

Trong lúc release: thu nhỏ vùng ảnh hưởngDuring release: shrink the blast radius
Cứ coi là đã chạyAssume it's up Readiness probeReadiness probe

Traffic gửi vào một instance chưa sẵn sàng sẽ lỗi trước cả khi nó kịp bắt đầu.Traffic sent to a not-ready instance fails before it even starts.

Gặp ởSeen in
App khởi động chậm: JVM, nạp model, làm nóng cache.Slow-starting apps: JVMs, model loading, cache warm-up.
ĐượcYou gain
Instance chưa sẵn sàng không nhận traffic.Unready instances receive no traffic.
MấtYou pay
Probe sai có thể gây restart dây chuyền.A misconfigured probe can cause cascading restarts.

Đánh đổiTrade-off Chọn readiness probe là đổi vài giây khởi động lâu hơn lấy việc không request nào rơi vào instance chưa sẵn sàng.Choosing readiness probes trades a few seconds of slower startup for no request ever landing on an unready instance.

Dấu hiệuTell-tale sign: Mỗi lần deploy lại có một đợt lỗi 502 hoặc 503 ngắn.Every deploy brings a short burst of 502s or 503s.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Gửi traffic vào instance ngay khi process khởi động, dù app chưa kết nối xong DB, chưa nạp cache, chưa thật sự sẵn sàng.Routing traffic to an instance as soon as the process starts, before it has connected to the database, warmed caches or become ready.

readinessProbe:            # ready for traffic? may check dependencies
  httpGet: { path: /ready, port: 8080 }
livenessProbe:             # process stuck? never check the DB here
  httpGet: { path: /live, port: 8080 }
startupProbe:              # slow boot: holds the other probes back
  httpGet: { path: /live, port: 8080 }
  failureThreshold: 30
  periodSeconds: 2

Hoạt động thế nàoHow it works. Kubernetes có ba loại probe. Startup: đã khởi động xong chưa. Readiness: nhận traffic được chưa; fail thì bị rút khỏi load balancer. Liveness: có bị treo không; fail thì bị restart. Chỉ readiness nên nhìn tới phụ thuộc bên ngoài.Kubernetes has three probes. Startup: has it finished booting? Readiness: can it take traffic? Failing removes it from the load balancer. Liveness: is it stuck? Failing restarts it. Only readiness should look at external dependencies.

Mẹo triển khaiImplementation tips

  • Liveness không bao giờ kiểm tra DB hay dịch vụ ngoài; DB chậm sẽ khiến Kubernetes restart cả đội pod.
  • Endpoint health phải rẻ, không chạy truy vấn nặng.
  • App khởi động chậm thì dùng startupProbe thay vì tăng initialDelaySeconds.
  • Liveness must never check the DB or external services; a slow DB would make Kubernetes restart your whole fleet.
  • Health endpoints must be cheap, with no heavy queries.
  • For slow starters, use a startupProbe instead of inflating initialDelaySeconds.
Giai đoạnStage 3

Sau khi release: phát hiện nhanh, lùi nhanhAfter release: detect fast, retreat fast

Biết bản mới hỏng trước người dùng, và quay lại trong vài phút.Know the release is broken before users do, and get back within minutes.

D9

Không có smoke testNo smoke test

Sau khi release: phát hiện nhanh, lùi nhanhAfter release: detect fast, retreat fast
Mong là chạyHope it works Smoke test sau deployPost-deploy smoke test

Một bài kiểm tra 30 giây bắt được thứ mà staging bỏ sót.A 30-second check catches what staging didn't.

Gặp ởSeen in
Sau mỗi lần deploy production.After every production deploy.
ĐượcYou gain
Bắt lỗi chỉ có trên production: config, secret, mạng, phân quyền.Catches production-only failures: config, secrets, networking, permissions.
MấtYou pay
Chỉ phủ luồng chính, không thay được bộ test đầy đủ.Covers main paths only, not a full test suite.

Đánh đổiTrade-off Chọn smoke test sau deploy là đổi thêm một phút trong pipeline lấy việc biết bản mới hỏng trước người dùng.Choosing post-deploy smoke tests trades one extra minute of pipeline for knowing the release is broken before users do.

Dấu hiệuTell-tale sign: Người dùng luôn là người báo lỗi đầu tiên.Users are always the first to report a problem.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Deploy xong là coi như xong, không kiểm tra bản vừa lên có chạy trên môi trường thật hay không.Treating “deployed” as “done” without checking the new version actually works in the real environment.

Hoạt động thế nàoHow it works. Smoke test là vài kiểm tra nhanh, chạy tự động ngay sau deploy trên production: trang chủ trả 200, đăng nhập được, luồng chính như tìm kiếm hay thanh toán thử vẫn chạy. Fail thì dừng rollout hoặc tự rollback.A smoke test is a handful of fast checks run automatically right after a production deploy: the homepage returns 200, login works, the main flows such as search or a test checkout still run. On failure, halt the rollout or roll back.

Mẹo triển khaiImplementation tips

  • Giữ dưới 10 kiểm tra và dưới một phút.
  • Dùng tài khoản test riêng, gắn cờ để loại khỏi số liệu analytics.
  • Chạy lại chính bộ kiểm tra đó theo lịch để có synthetic monitoring miễn phí.
  • Keep it under 10 checks and under a minute.
  • Use dedicated test accounts, flagged so analytics ignores them.
  • Rerun the same checks on a schedule to get synthetic monitoring for free.
D10

Lỗi im lặngSilent failure

Sau khi release: phát hiện nhanh, lùi nhanhAfter release: detect fast, retreat fastĐã hiệu đínhCorrected
Không ai được báoNobody told Thông báo deploy + cảnh báo theo triệu chứngDeploy notices + symptom alerts

Một lần deploy hỏng mà không ai biết còn tệ hơn một lần deploy hỏng có báo động.A failed deploy nobody notices is worse than one that pages.

Gặp ởSeen in
Pipeline CI/CD.CI/CD pipelines.
ĐượcYou gain
Thời gian phát hiện giảm từ giờ xuống phút.Time to detect drops from hours to minutes.
MấtYou pay
Quá nhiều thông báo thì mọi người tắt tiếng.Too many notifications and people mute the channel.

Đánh đổiTrade-off Chọn thông báo và cảnh báo sau deploy là đổi thêm tiếng ồn trong kênh chat lấy việc sự cố được phát hiện trong vài phút.Choosing post-deploy notices and alerts trades some channel noise for incidents found within minutes.

Dấu hiệuTell-tale sign: Trả lời câu hỏi “cái này hỏng từ lúc nào?” mất hơn năm phút.Answering “when did this break?” takes more than five minutes.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. Thông báo deploy (ai deploy gì, lúc nào) là cần nhưng chưa đủ. Thứ thật sự cứu bạn là cảnh báo dựa trên triệu chứng mà người dùng cảm nhận — tỉ lệ lỗi, độ trễ — cộng với mốc deploy được đánh dấu trên đồ thị, để nhìn vào là thấy “lỗi tăng ngay sau bản X”.Deploy notifications (who shipped what, when) are necessary but not sufficient. What saves you is alerting on user-facing symptoms — error rate, latency — plus deploy markers on the graphs, so you can see “errors jumped right after release X” at a glance.

Nó là gìWhat it is. Deploy thất bại, hoặc bản mới làm tăng lỗi, mà không ai được báo. Sự cố được phát hiện sau nhiều giờ, qua khiếu nại.A deploy fails, or the new version raises errors, and nobody is told. The incident surfaces hours later through complaints.

Hoạt động thế nàoHow it works. Pipeline gửi trạng thái (bắt đầu, thành công, thất bại, rollback) vào kênh của team, kèm commit và người deploy. Hệ thống giám sát đánh dấu thời điểm deploy trên dashboard và cảnh báo khi chỉ số lệch khỏi mức nền.The pipeline posts status (started, succeeded, failed, rolled back) to the team channel with the commit and the deployer. Monitoring marks each deploy on dashboards and alerts when metrics leave their baseline.

Mẹo triển khaiImplementation tips

  • Tách kênh thông báo deploy khỏi kênh cảnh báo cần hành động.
  • Mỗi cảnh báo phải có người trực và một runbook ngắn.
  • Đẩy annotation deploy lên Grafana hoặc Datadog từ chính pipeline.
  • Keep the deploy-notice channel separate from the actionable-alert channel.
  • Every alert needs an on-call owner and a short runbook.
  • Push deploy annotations to Grafana or Datadog from the pipeline itself.
D2

Không có kế hoạch rollbackNo rollback plan

Sau khi release: phát hiện nhanh, lùi nhanhAfter release: detect fast, retreat fast
Chỉ sửa tiếnFix forward only Rollback một lệnhOne-click rollback

Dưới áp lực, cách sửa nhanh nhất thường là quay lại, không phải vá thêm.Under pressure, the fastest fix is often reverting, not patching.

Gặp ởSeen in
Sự cố xuất hiện ngay sau deploy.Incidents that appear right after a deploy.
ĐượcYou gain
Thời gian khôi phục bằng thời gian deploy, không phải thời gian debug.Recovery time equals deploy time, not debugging time.
MấtYou pay
Migration phá huỷ như DROP COLUMN không lùi được.Destructive migrations such as DROP COLUMN cannot be undone.

Đánh đổiTrade-off Chọn rollback làm phản xạ đầu tiên là đổi việc tạm mất tính năng mới lấy thời gian khôi phục tính bằng phút.Choosing rollback as the first reflex trades temporarily losing the new feature for recovery measured in minutes.

Dấu hiệuTell-tale sign: Không ai trong team biết lệnh rollback, hoặc nó chưa từng được chạy thử.Nobody knows the rollback command, or it has never been exercised.

Cơ chế, code và mẹoMechanism, code and tips

Nó là gìWhat it is. Không có cách quay lại bản trước một cách nhanh chóng. Khi có lỗi, lựa chọn duy nhất là viết bản vá trong lúc hệ thống đang cháy.No fast way back to the previous version. When something breaks, the only option is writing a patch while the system is on fire.

kubectl rollout undo deployment/api      # back to the previous ReplicaSet
kubectl rollout status deployment/api

Hoạt động thế nàoHow it works. Nếu artifact bất biến và được lưu lại (D4), rollback chỉ là deploy lại artifact trước, mất vài phút, không cần build. Điều kiện đi kèm: schema DB và config phải tương thích với bản cũ (D7, D6).If artifacts are immutable and kept (D4), rollback is just redeploying the previous one, in minutes, with no build. The conditions: the database schema and config must still be compatible with the old version (D7, D6).

Mẹo triển khaiImplementation tips

  • Diễn tập rollback trên production ít nhất mỗi quý một lần; rollback chưa từng chạy coi như không có.
  • Rollback trước, điều tra sau.
  • Feature flag cho phép tắt một tính năng mà không phải deploy gì cả.
  • Rehearse rollback in production at least once a quarter; a rollback never exercised does not exist.
  • Roll back first, investigate second.
  • Feature flags let you switch a feature off without deploying anything.
D1

Deploy lúc không ai theo dõiDeploying when nobody is watching

Sau khi release: phát hiện nhanh, lùi nhanhAfter release: detect fast, retreat fastĐã hiệu đínhCorrected
Ship 5 giờ chiều thứ SáuFriday 5pm ship Deploy khi còn người ở lại quan sátDeploy when someone stays to watch

Bug lộ ra lúc không ai trực nghĩa là một đêm hoặc một cuối tuần mất trắng, không phải một bản vá nhẹ nhàng.A bug that surfaces when nobody is around means a lost night or weekend, not a quick fix.

Gặp ởSeen in
Thay đổi lớn về hạ tầng hoặc migration.Large infrastructure changes or migrations.
ĐượcYou gain
Người hiểu thay đổi có mặt khi lỗi lộ ra.Whoever understands the change is present when bugs surface.
MấtYou pay
Dồn thay đổi sang đầu tuần tạo ra những batch to hơn, rủi ro hơn.Pushing changes to Monday creates bigger, riskier batches.

Đánh đổiTrade-off Chọn giới hạn thời điểm deploy là đổi tốc độ phát hành lấy việc luôn có người theo dõi khi lỗi lộ ra.Choosing to restrict deploy timing trades release speed for someone always watching when bugs surface.

Dấu hiệuTell-tale sign: Lịch sử sự cố dồn vào đêm và cuối tuần.Incident history clusters on nights and weekends.

Cơ chế, code và mẹoMechanism, code and tips
Hiệu đínhCorrection. “Không deploy thứ Sáu” là mẹo quản lý rủi ro, không phải nguyên tắc kỹ thuật. Vấn đề thật là deploy khi không có ai theo dõi sau đó, trên một pipeline chưa đủ an toàn để tin. Nhiều team deploy hằng ngày, kể cả thứ Sáu, nhờ canary, smoke test và rollback tự động. Luật hữu ích hơn: chỉ deploy khi người deploy còn ở lại đủ lâu để quan sát, và đóng băng thay đổi trước kỳ nghỉ hay sự kiện lớn.“No Friday deploys” is a risk-management heuristic, not an engineering principle. The real problem is deploying when nobody watches afterwards, on a pipeline not safe enough to trust. Many teams ship daily, Fridays included, thanks to canaries, smoke tests and automatic rollback. A more useful rule: deploy only when the deployer stays long enough to watch, and freeze changes before holidays or major events.

Nó là gìWhat it is. Release một thay đổi có rủi ro ngay trước khi cả team rời đi.Shipping a risky change right before the whole team leaves.

Hoạt động thế nàoHow it works. Lỗi thường lộ ra sau vài giờ: lúc traffic lên đỉnh, lúc job đêm chạy, lúc cache hết hạn. Nếu khi đó không ai trực, hoặc người trực không biết có thay đổi, thời gian phát hiện và khôi phục kéo dài ra nhiều lần.Bugs often surface hours later: at traffic peak, when nightly jobs run, when caches expire. If nobody is on call then, or the on-call person does not know about the change, detection and recovery stretch out many times over.

Mẹo triển khaiImplementation tips

  • Thay luật “cấm thứ Sáu” bằng “không deploy nếu không ở lại được 1–2 giờ sau đó”.
  • Đóng băng thay đổi trước sự kiện lớn, với ngày bắt đầu và kết thúc cụ thể.
  • Theo dõi change failure rate của DORA; khi nó thấp và ổn định, đó là lý do để nới quy định.
  • Replace “no Fridays” with “no deploy unless you can stay 1–2 hours afterwards”.
  • Freeze changes before major events, with explicit start and end dates.
  • Track DORA's change failure rate; when it is low and stable, that is your case for relaxing the rule.

Bảng đánh đổi và ưu tiênTrade-off and priority table

Bảng xếp theo nhóm. Đọc cột đầu và cột hai cùng nhau: hậu quả cao, công sức thấp là thứ nên làm trước.

Rows follow the grouping. Read the first two level columns together: high impact with low effort goes first.

MãCodeLỗiMistakeHậu quả nếu bỏ quaImpact if ignoredCông sức sửaEffort to fixChi phí khi chạyRuntime costCách phát hiệnHow to detect
M1 Listener không được gỡUnremoved event listenerTrung bìnhMediumThấpLow Gần như khôngNear zeroSo sánh heap snapshotHeap snapshot diff
M7 Event emitter không giới hạnUnbounded event emitterCaoHighThấpLow KhôngNoneCảnh báo MaxListeners, listenerCountMaxListeners warning, listenerCount
M8 Timer không bao giờ được clearTimer never clearedTrung bìnhMediumThấpLow KhôngNoneTab Network, heap snapshotNetwork tab, heap snapshot
M10 Tác vụ bất đồng bộ không bao giờ kết thúcAsync work that never settlesCaoHighTrung bìnhMedium KhôngNoneSố socket mở, độ trễ upstreamOpen sockets, upstream latency
M2 Cache phình mãiGrowing cacheCaoHighThấpLow Một ít CPU cho evictionA little CPU for evictionHeap tăng theo số key khác nhauHeap tracks key cardinality
M6 Mảng toàn cục chỉ pushGlobal array growthCaoHighThấpLow KhôngNoneHeap tăng chậm theo thời gian chạyHeap climbs slowly with uptime
M5 Tham chiếu vòng và bảng phụ giữ objectCircular references and side tablesTrung bìnhMediumThấpLow KhôngNoneRetainers trong heap snapshotHeap snapshot retainers
M3 DOM node đã gỡ nhưng vẫn bị giữDetached DOM nodesTrung bìnhMediumThấpLow KhôngNoneLọc “Detached” trong snapshotFilter “Detached” in snapshot
M9 Closure giữ dữ liệu lớnLarge closures retainedTrung bìnhMediumTrung bìnhMedium KhôngNoneRetainer là context của hàmRetainer is a function context
M4 Kết nối DB không được trảUnclosed DB connectionsRất caoCriticalThấpLow Âm: nhanh hơnNegative: fasterThời gian chờ lấy kết nốiConnection acquire wait
A1 Mật khẩu lưu dạng thôPasswords in plaintextRất caoCriticalThấpLow CPU mỗi lần đăng nhậpCPU per loginXem schema bảng userInspect the user table
A6 Chính sách mật khẩu sai hướngWeak or misguided password policyCaoHighThấpLow Một lời gọi API khi đặt mật khẩuOne API call on setThử đăng ký bằng “123456”Try signing up with “123456”
A9 Một secret cho mọi môi trườngSame secret everywhereCaoHighTrung bìnhMedium Thêm một secret managerOne more secret managerSecret scanner, so sánh giá trịSecret scanner, compare values
A3 Đăng nhập không giới hạn số lần thửNo rate limit on loginCaoHighThấpLow Một lần đọc Redis mỗi lần thửOne Redis hit per attemptĐếm số lần đăng nhập thất bạiCount failed logins
A5 Không có MFANo MFARất caoCriticalTrung bìnhMedium Ma sát khi đăng nhậpLogin frictionTỉ lệ tài khoản bật MFAShare of accounts with MFA
A2 Token sống quá lâuLong-lived tokensCaoHighTrung bìnhMedium Một lần refresh mỗi vài phútA refresh every few minutesGiải mã token, đọc expDecode the token, read exp
A4 Lưu phiên trong localStorageSession in localStorageCaoHighTrung bìnhMedium Cấu hình CSRF/CORSCSRF/CORS setupDevTools → Application → StorageDevTools → Application → Storage
A7 Không thu hồi được phiênNo token revocationCaoHighTrung bìnhMedium Một lần tra cứu mỗi requestOne lookup per requestĐổi mật khẩu rồi thử phiên cũChange password, try old session
A8 Chỉ kiểm tra ở phía clientClient-side only checksRất caoCriticalTrung bìnhMedium Thêm điều kiện trong truy vấnExtra query predicateTest IDOR: đổi IDIDOR test: swap IDs
A10 Không có nhật ký truy cậpNo audit trailTrung bìnhMediumTrung bìnhMedium Lưu trữ logLog storageThử trả lời một câu hỏi điều traTry answering one forensic question
D4 Deploy thủ công qua SSHManual SSH deployCaoHighTrung bìnhMedium Vài phút pipelineMinutes of pipeline timeHỏi: ai biết deploy?Ask: who can deploy?
D6 Config viết cứng trong codeConfig in codeTrung bìnhMediumThấpLow KhôngNonegrep URL và key trong repogrep for URLs and keys
D7 Không có kế hoạch migrationNo migration planCaoHighCaoHigh Nhiều release hơnMore releasesReview migration trước khi mergeReview migrations before merge
D3 Release một lần cho tất cảBig bang releaseCaoHighCaoHigh Release chậm hơnSlower releasesSự cố luôn ảnh hưởng 100%Incidents always hit 100%
D8 Deploy có downtimeDowntime deployTrung bìnhMediumTrung bìnhMedium Hạ tầng thêm lúc chuyểnExtra capacity during switchCó cửa sổ bảo trìMaintenance windows exist
D5 Không có health checkNo health checkTrung bìnhMediumThấpLow Vài giây khởi độngSeconds of startup502/503 mỗi lần deploy502/503 on every deploy
D9 Không có smoke testNo smoke testTrung bìnhMediumThấpLow Thêm khoảng một phútAbout a minuteAi báo lỗi đầu tiên?Who reports bugs first?
D10 Lỗi im lặngSilent failureCaoHighThấpLow Tiếng ồn trong kênh chatChannel noiseHỏi: hỏng từ lúc nào?Ask: since when?
D2 Không có kế hoạch rollbackNo rollback planCaoHighTrung bìnhMedium Lưu artifact cũKeeping old artifactsHỏi: lệnh rollback là gì?Ask: what's the rollback command?
D1 Deploy lúc không ai theo dõiDeploying when nobody is watchingTrung bìnhMediumThấpLow Tốc độ releaseRelease speedSự cố dồn vào đêm/cuối tuầnIncidents cluster off-hours

Anti-pattern khi áp dụng cách sửaAnti-patterns when applying the fixes

Sai lầmMistakeVì sao saiWhy it's wrongSửa thế nàoHow to fix
Tăng --max-old-space-size hoặc maxListeners cho hết lỗiRaising --max-old-space-size or maxListeners to make errors go awayChỉ che triệu chứng; OOM vẫn đến, chỉ muộn hơn và khó lần hơn.It hides the symptom; the OOM still comes, just later and harder to trace.Chụp ba heap snapshot, so sánh, đọc Retainers để tìm root đang giữ.Take three heap snapshots, diff them, read Retainers to find the root holding on.
Dùng WeakMap làm cacheUsing a WeakMap as a cacheKhông có kích thước, không hết hạn theo thời gian, key phải là object.No size, no time-based expiry, keys must be objects.LRU có max và TTL (M2).An LRU with max and TTL (M2).
Gán null khắp nơi để “giúp GC”Setting everything to null to “help the GC”GC đã xử lý vòng tham chiếu; code rối thêm mà chỗ thật sự giữ vẫn còn.The GC already handles cycles; the code gets noisier while the real retainer stays.Chỉ cắt tham chiếu từ thứ sống lâu: biến toàn cục, listener, timer.Cut only references from long-lived holders: globals, listeners, timers.
Pool size 100 cho mỗi instanceA pool size of 100 per instance10 instance × 100 vượt xa max_connections của DB.10 instances × 100 blows past the database's max_connections.Tính tổng trước; scale ngang nhiều thì dùng pooler ngoài.Do the total first; for wide fan-out, use an external pooler.
Mã hoá mật khẩu bằng AES “cho an toàn”Encrypting passwords with AES “to be safe”Ai có key là giải được toàn bộ, và key thường nằm ngay cạnh DB.Whoever gets the key decrypts everything, and the key usually sits next to the DB.Băm một chiều bằng Argon2id (A1).One-way hashing with Argon2id (A1).
Khoá tài khoản sau 5 lần saiLocking accounts after 5 failuresKẻ tấn công khoá được tài khoản của bất kỳ ai chỉ bằng cách gõ sai.Attackers can lock anyone out just by typing wrong passwords.Độ trễ tăng dần và CAPTCHA (A3).Increasing delays and CAPTCHA (A3).
Bắt ký tự đặc biệt và đổi mật khẩu 90 ngày một lầnRequiring symbols and 90-day password rotationNgười dùng chuyển sang mẫu Tet2025! rồi Tet2026!.Users fall into patterns like Summer2025! then Summer2026!.Độ dài tối thiểu và chặn mật khẩu đã lộ, theo NIST (A6).Minimum length and breached-list screening, per NIST (A6).
Chuyển token sang cookie mà quên CSRFMoving tokens to cookies and forgetting CSRFSite khác gửi request giả mạo và trình duyệt tự gắn cookie.Another site forges requests and the browser attaches the cookie.SameSite=Lax cộng token CSRF cho luồng đặc biệt (A4).SameSite=Lax plus CSRF tokens for special flows (A4).
Liveness probe kiểm tra DBA liveness probe that checks the databaseDB chậm khiến Kubernetes restart toàn bộ pod, biến sự cố nhỏ thành lớn.A slow DB makes Kubernetes restart every pod, turning a small incident into a big one.Liveness chỉ kiểm tra process; phụ thuộc để cho readiness (D5).Liveness checks only the process; dependencies belong in readiness (D5).
Rollback sau khi đã DROP cộtRolling back after dropping a columnCode cũ cần cột đã không còn tồn tại.The old code needs a column that no longer exists.Expand/contract, DROP ở một release sau (D7).Expand/contract, drop in a later release (D7).
Canary không có tiêu chí dừngA canary with no stop criteria5% người dùng gặp lỗi mà không ai hay.5% of users hit errors and nobody notices.So sánh tự động tỉ lệ lỗi và độ trễ với bản cũ (D3).Automatically compare error rate and latency against the old version (D3).
Ghi toàn bộ request body vào audit logLogging full request bodies for auditMật khẩu và token nằm trong log, và log thành nguồn rò rỉ mới.Passwords and tokens end up in logs, which become a new leak source.Sự kiện có cấu trúc với trường được chọn, lọc dữ liệu nhạy cảm (A10).Structured events with chosen fields, sensitive data filtered (A10).

Ba câu cần nhớThree things to remember

  1. Mọi thứ bạn tạo ra — listener, cache, kết nối, token, bản release — cần một điểm kết thúc được viết sẵn trong code, không phải trong trí nhớ của ai đó.Everything you create — listeners, caches, connections, tokens, releases — needs an end written into the code, not into someone's memory.
  2. Thu nhỏ vùng ảnh hưởng trước khi cố giảm xác suất lỗi: canary, secret theo môi trường và MFA đều giả định sự cố chắc chắn sẽ xảy ra.Shrink the blast radius before trying to lower the odds: canaries, per-environment secrets and MFA all assume the incident will happen.
  3. Không có số liệu thì không có sửa lỗi: heap snapshot, audit log và mốc deploy trên đồ thị biến phỏng đoán thành bằng chứng.No data, no fix: heap snapshots, audit logs and deploy markers on graphs turn guesses into evidence.