
Networking — Nền tảng phần 2
5 tháng 9, 2026
Mục lục
- Socket và file descriptor
- Load balancer
- Reverse proxy
- WebSocket
- SSE và long polling
- gRPC và giao tiếp nội bộ
- NAT và cạn port
- CDN và HTTP caching
- CORS, mTLS, rate limit
- Playbook chẩn đoán
Chương 11 — Socket và file descriptor
Kết nối là một file
Unix coi mọi thứ là file, kể cả kết nối mạng. Mỗi kết nối TCP mà process của bạn giữ là một file descriptor — một số nguyên trong bảng file của process đó.
ls -l /proc/$(pgrep -f "node server.js")/fd | head # (Linux) xem fd đang mở
lsof -p <pid> | wc -l # đếm tổng fd
Hệ quả trực tiếp: giới hạn số file mở của process cũng chính là giới hạn số kết nối đồng thời.
ulimit -n # giới hạn mềm hiện tại
ulimit -Hn # giới hạn cứng
Vượt giới hạn, Node ném EMFILE: too many open files. Đây là lỗi hay bị chẩn đoán nhầm nhất trong chương này: triệu chứng là app đột nhiên không nhận request mới nữa, nhưng process vẫn sống, CPU vẫn thấp, và log thì đầy lỗi ở những chỗ chẳng liên quan gì tới nhau (đọc file lỗi, gọi API lỗi, ghi log lỗi) — vì fd cạn thì mọi thứ cần fd đều hỏng cùng lúc.
Container mặc định thường có ulimit -n khá thấp. Kiểm tra bên trong container chứ đừng kiểm tra trên máy host.
Hai hàng đợi trước khi app nhìn thấy kết nối
Đây là phần ít người biết nhưng giải thích được rất nhiều hiện tượng lạ. Giữa lúc gói SYN tới và lúc code của bạn chạy handler(req, res), kernel giữ hai hàng đợi:
Gói SYN tới
↓
[ SYN queue ] ← kernel đang bắt tay dở, chưa xong 3 bước
↓ bắt tay xong
[ accept queue ] ← đã là kết nối hoàn chỉnh, chờ app gọi accept()
↓ app accept
Code của bạn chạy
Điểm mấu chốt: kernel bắt tay TCP hộ bạn. Client thấy "kết nối thành công" từ lúc gói vào accept queue, dù app của bạn còn đang bận và chưa hề biết tới kết nối đó.
Đây là lý do một Node app bị chặn event loop lại có biểu hiện timeout chứ không phải connection refused. Client kết nối được, gửi request đi, rồi ngồi chờ mãi không có response.
Khi accept queue đầy, kernel âm thầm bỏ gói SYN. Client không nhận được gì, tự thử lại sau 1 giây, 2 giây, 4 giây — nên biểu hiện là latency nhảy vọt lên đúng những mốc tròn trịa như 1000 ms hay 3000 ms.
ss -tln # cột Send-Q là kích thước accept queue
netstat -s | grep -i listen # đếm số lần overflow
sysctl net.core.somaxconn # trần của hệ thống
Node mặc định xin backlog 511, nhưng giá trị thực tế bị cắt xuống theo somaxconn của hệ thống:
server.listen(3000, '0.0.0.0', 1024); // tham số thứ ba là backlog
Tăng backlog chỉ giúp chịu được đợt bùng phát ngắn. Nếu app chậm một cách bền vững thì tăng backlog chỉ đổi lỗi từ "kết nối bị từ chối" sang "chờ lâu hơn rồi mới timeout" — đó là giấu bệnh, không phải chữa bệnh.
Chương 12 — Load balancer
L4 và L7
Đây là phân biệt quan trọng nhất khi làm việc với hạ tầng.
| L4 (transport) | L7 (application) | |
|---|---|---|
| Nhìn thấy | IP, port | URL, header, cookie, method |
| Có giải mã TLS | Không | Có (thường vậy) |
| Định tuyến theo | Cặp IP:port | Path, host, header |
| Chi phí | Rất thấp | Cao hơn |
| Ví dụ | AWS NLB, HAProxy chế độ tcp | AWS ALB, Nginx, Envoy |
Hệ quả thực tế: L4 cân bằng theo kết nối, L7 cân bằng theo request.
Với HTTP/1.1 ngắn hạn thì hai cái gần như nhau. Nhưng với kết nối sống lâu — HTTP/2, gRPC, WebSocket — L4 phân phối rất lệch:
L4 + gRPC:
Client mở 1 kết nối HTTP/2 → dính chặt vào pod A
10.000 request sau đó đều đi vào pod A
Pod B, C nhàn rỗi
Đây là bẫy kinh điển khi chạy gRPC trong Kubernetes với Service mặc định (vốn là L4). Sẽ nói kỹ ở chương 16.
Thuật toán phân phối
| Thuật toán | Cách chọn | Hợp với |
|---|---|---|
| Round robin | Lần lượt | Request đồng đều |
| Least connections | Backend đang ít kết nối nhất | Request dài ngắn lệch nhau |
| IP hash | Băm IP client | Cần dính một backend |
| Random two choices | Bốc ngẫu nhiên 2, chọn cái ít tải hơn | Mặc định tốt cho hệ phân tán |
Với API mà thời gian xử lý chênh lệch lớn, round robin phân phối số lượng đều nhưng phân phối tải rất lệch. Least connections thường là lựa chọn an toàn hơn.
Health check
Load balancer cần biết backend nào còn sống. Hai loại endpoint, đừng gộp làm một:
| Liveness | Readiness | |
|---|---|---|
| Hỏi gì | Process còn sống không | Có sẵn sàng nhận request không |
| Thất bại thì | Restart container | Rút khỏi pool, không restart |
| Nên kiểm tra | Gần như không gì cả | Database, cache, dependency thiết yếu |
Sai lầm hay gặp: cho liveness check kiểm tra cả database. Database chớp một nhịp là toàn bộ pod bị restart đồng loạt — biến một sự cố nhỏ thành sự cố toàn hệ thống.
Graceful shutdown
Nối thẳng với chương Process ở bài Linux. Khi deploy, thứ tự đúng là:
1. Nhận SIGTERM
2. Readiness trả fail ngay ← LB bắt đầu rút mình khỏi pool
3. Chờ vài giây ← LB cần thời gian nhận biết
4. Ngừng nhận kết nối mới
5. Xử lý nốt request đang dở
6. Đóng DB, thoát
Bước 3 là bước hay bị bỏ. Health check thường chạy mỗi 5–10 giây, nên đóng server ngay khi nhận SIGTERM đồng nghĩa LB vẫn đẩy request vào trong vài giây nữa — và đó chính là những con 502 xuất hiện đều đặn mỗi lần deploy.
process.on('SIGTERM', async () => {
isReady = false; // readiness fail từ đây
await new Promise(r => setTimeout(r, 10_000)); // chờ LB rút mình ra
server.close(async () => {
await db.disconnect();
process.exit(0);
});
});
IP thật của client
Qua proxy, req.socket.remoteAddress là IP của proxy, không phải của người dùng. IP thật nằm trong header:
X-Forwarded-For: 203.0.113.7, 10.0.1.5, 10.0.2.9
↑ client ↑ proxy 1 ↑ proxy 2
Header này client tự đặt được. Tin nó vô điều kiện nghĩa là rate limit và audit log của bạn giả mạo được bằng một dòng curl -H.
// số lượng proxy TIN CẬY đứng trước, đếm từ phải sang
app.set('trust proxy', 1);
Đặt trust proxy thành true là nói "tin toàn bộ chuỗi" — đừng làm vậy nếu app có thể bị gọi trực tiếp. Đặt đúng số hop thì Express chỉ lấy giá trị ở vị trí đáng tin, bỏ qua phần client tự bịa.
Với L4 load balancer thì không có header HTTP để chèn vào. Giải pháp là PROXY protocol: LB gửi một dòng metadata ngay trước byte đầu tiên của kết nối TCP, chứa IP gốc. Cả hai đầu đều phải bật, bật một bên thì dòng đó bị đọc nhầm thành dữ liệu.
Chương 13 — Reverse proxy
Vì sao vẫn cần Nginx trước Node
Node chạy HTTP được, nhưng có mấy việc nên để proxy làm:
| Việc | Vì sao không để Node làm |
|---|---|
| Kết thúc TLS | Tốn CPU, và event loop chỉ có một |
| Phục vụ file tĩnh | sendfile của kernel nhanh hơn nhiều |
| Nén gzip/brotli | Chiếm thread pool của libuv |
| Đệm request chậm | Bảo vệ Node khỏi client mạng yếu |
| Giới hạn tốc độ, chặn IP | Rẻ hơn ở tầng ngoài |
Ý thứ tư đáng nói thêm. Nginx đọc trọn request từ client chậm rồi mới chuyển cả cục cho Node. Nhờ vậy Node không phải giữ một kết nối mở suốt 30 giây chỉ để chờ một client 2G upload xong. Đây là lớp phòng thủ tự nhiên trước kiểu tấn công cố tình gửi request thật chậm.
Buffering — con dao hai lưỡi
Chính cơ chế đệm đó lại phá hỏng streaming. Nginx mặc định gom response của Node lại rồi mới gửi đi:
Node gửi từng dòng: [a][b][c][d]
↓ Nginx đệm
Client nhận một cục: [abcd] ← chỉ tới khi đủ buffer hoặc đóng kết nối
Với SSE hoặc bất kỳ API stream nào, biểu hiện là client không nhận được gì suốt vài phút rồi nhận tất cả cùng lúc. Code Node đúng hoàn toàn, lỗi nằm ở proxy.
location /api/stream {
proxy_pass http://node_app;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
Hoặc để Node tự báo cho Nginx, tiện hơn vì không phải sửa cấu hình hạ tầng:
res.setHeader('X-Accel-Buffering', 'no');
Các timeout của Nginx cần biết
| Chỉ thị | Mặc định | Nghĩa |
|---|---|---|
proxy_connect_timeout | 60s | Chờ nối được tới Node |
proxy_send_timeout | 60s | Chờ giữa hai lần ghi tới Node |
proxy_read_timeout | 60s | Chờ giữa hai lần đọc từ Node |
keepalive_timeout | 75s | Giữ kết nối với client |
proxy_read_timeout là thủ phạm giết WebSocket và SSE: nó không đo tổng thời gian mà đo khoảng lặng giữa hai lần có dữ liệu. Im lặng quá 60 giây là Nginx cắt, dù kết nối hoàn toàn khỏe mạnh.
Hai cách xử lý, nên làm cả hai: nâng proxy_read_timeout cho route đó, và cho ứng dụng gửi heartbeat định kỳ để không bao giờ im lặng quá lâu.
Keep-alive tới upstream
Mặc định Nginx mở kết nối mới tới Node cho mỗi request. Toàn bộ chi phí bắt tay ở phần 1 quay lại đầy đủ:
upstream node_app {
server 127.0.0.1:3000;
keepalive 64; # giữ 64 kết nối rảnh
}
location / {
proxy_pass http://node_app;
proxy_http_version 1.1; # bắt buộc, mặc định là 1.0
proxy_set_header Connection ""; # xóa header "close" mặc định
}
Thiếu hai dòng cuối thì keepalive 64 không có tác dụng gì cả. Đây là cấu hình bị viết thiếu nhiều nhất.
Chương 14 — WebSocket
Nó bắt đầu bằng HTTP rồi thôi là HTTP
WebSocket không phải giao thức riêng biệt từ đầu. Nó mượn một request HTTP để xin nâng cấp:
Client Server
│── GET /ws │
│ Upgrade: websocket │
│ Connection: Upgrade │
│ Sec-WebSocket-Key: dGhlIHNhbXBsZQ== │
│─────────────────────────────────────▶ │
│◀──── 101 Switching Protocols ───────── │
│ │
│◀════ frame nhị phân hai chiều ═══════▶│
Sau mã 101, kết nối TCP đó không còn nói HTTP nữa. Đây là lý do mọi thứ ở giữa — proxy, LB, firewall — phải hiểu và cho phép nâng cấp, nếu không handshake sẽ thất bại hoặc kết nối bị cắt ngay sau đó.
location /ws {
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
Ba điều dev hay quên
1. Kết nối chết mà không ai biết. Client rút mạng đột ngột thì không có gói FIN nào được gửi. Server vẫn tưởng kết nối sống, tiếp tục giữ fd và bộ nhớ cho một người đã đi mất từ lâu.
Cách duy nhất phát hiện là chủ động dò bằng frame ping/pong của giao thức:
const interval = setInterval(() => {
wss.clients.forEach(ws => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30_000);
ws.on('pong', () => { ws.isAlive = true; });
Vòng lặp này còn kiêm luôn việc giữ cho kết nối không im lặng quá lâu — tránh bị proxy_read_timeout cắt ở chương 13. Một mũi tên hai đích.
2. Không có retry sẵn. Khác với EventSource ở chương sau, WebSocket đứt là đứt hẳn. Tự viết reconnect có backoff và jitter, và quan trọng hơn: thiết kế cách bắt kịp trạng thái đã lỡ sau khi nối lại. Nối lại được mà mất mất 30 giây dữ liệu ở giữa thì cũng như không.
3. Scale ngang không tự nhiên. Đây mới là vấn đề kiến trúc thật:
User A ── kết nối ──▶ Pod 1
User B ── kết nối ──▶ Pod 2
Pod 1 muốn gửi tin cho User B → không có kết nối nào tới B cả
Hai hướng giải quyết:
| Cách | Cơ chế | Đánh đổi |
|---|---|---|
| Sticky session | Băm để cùng user luôn vào cùng pod | Deploy là đứt hết; tải lệch |
| Pub/sub | Mọi pod cùng đăng ký một kênh Redis/NATS | Thêm hạ tầng, nhưng scale thật |
Với hệ thống nghiêm túc, chọn pub/sub. Sticky session chỉ hoãn vấn đề tới lúc bạn có nhiều pod hơn.
Khi nào đừng dùng WebSocket
WebSocket là hai chiều. Nếu dữ liệu chỉ chảy từ server xuống client — thông báo, cập nhật tiến độ, giá cả, log trực tiếp — thì SSE đơn giản hơn nhiều và ít thứ để hỏng hơn.
Chương 15 — SSE và long polling
SSE
Server-Sent Events chỉ là một response HTTP không bao giờ kết thúc, với định dạng text quy ước:
Content-Type: text/event-stream
Cache-Control: no-cache
data: {"progress": 10}
data: {"progress": 20}
id: 42
event: done
data: {"ok": true}
Hai dòng trống là ranh giới giữa các sự kiện — thiếu là client không nhận được gì.
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
const send = p => res.write(`data: ${JSON.stringify(p)}\n\n`);
const timer = setInterval(() => send(payload), 1000);
req.on('close', () => clearInterval(timer)); // BẮT BUỘC, nếu không sẽ rò rỉ
Dòng cuối là chỗ hay quên nhất. Client đóng tab, res.write vẫn chạy mãi mãi, mỗi kết nối bỏ lại một setInterval sống vĩnh viễn. Sau vài ngày thì hết bộ nhớ.
SSE hơn WebSocket ở đâu
| SSE | WebSocket | |
|---|---|---|
| Chiều dữ liệu | Chỉ server → client | Hai chiều |
| Giao thức | HTTP thường | Upgrade |
| Tự động nối lại | Có sẵn trong trình duyệt | Tự viết |
| Bắt kịp dữ liệu lỡ | Last-Event-ID có sẵn | Tự viết |
| Qua proxy/firewall | Trơn tru | Đôi khi bị chặn |
| Dữ liệu nhị phân | Không | Có |
Cơ chế Last-Event-ID đáng giá nhất: trình duyệt tự nhớ id cuối nhận được và gửi lại trong header khi nối lại. Server đọc header đó để phát tiếp từ đúng chỗ đứt. Với WebSocket bạn phải tự dựng toàn bộ cơ chế này.
Bẫy giới hạn 6 kết nối
Trình duyệt chỉ cho phép 6 kết nối HTTP/1.1 đồng thời tới mỗi origin. Mỗi SSE chiếm trọn một suất, vĩnh viễn.
Người dùng mở 6 tab → 6 kết nối SSE → hết quota
↓
Tab thứ 7 và mọi request API khác treo vô thời hạn
Chạy qua HTTP/2 thì hết vấn đề, vì tất cả dùng chung một kết nối và nhiều stream. Đây là một trong những lý do thực tế nhất để bật HTTP/2 ở tầng edge.
Long polling
Client gọi request, server giữ lại tới khi có dữ liệu hoặc hết thời gian chờ, trả về, client gọi lại ngay.
Client ──request──▶ Server (giữ 30s)
Client ◀─response── Server (có data)
Client ──request──▶ Server (gọi lại ngay)
Ngày nay chỉ nên coi đây là phương án dự phòng khi SSE và WebSocket đều không dùng được. Điểm cần lưu ý phía server: mỗi request đang bị giữ vẫn chiếm một kết nối và một chỗ trong pool — timeout của long poll phải nhỏ hơn mọi timeout của proxy đứng trước.
Chương 16 — gRPC và giao tiếp nội bộ
Nó là gì
gRPC = HTTP/2 + Protobuf + sinh code tự động. Ba thứ đó giải quyết ba vấn đề khác nhau của giao tiếp nội bộ.
| REST/JSON | gRPC | |
|---|---|---|
| Định dạng | Text, tự mô tả | Nhị phân, cần schema |
| Kích thước | Lớn hơn | Nhỏ hơn đáng kể |
| Hợp đồng | Tài liệu, dễ lệch thực tế | File .proto, sinh code cả hai bên |
| Streaming | Hạn chế | Bốn kiểu, kể cả hai chiều |
| Debug bằng curl | Được | Không trực tiếp |
| Hợp với | API công khai | Service gọi service |
Giá trị lớn nhất trong thực tế không phải tốc độ mà là hợp đồng có kiểu chặt: đổi field trong .proto là phía gọi không biên dịch được nữa, phát hiện lúc build thay vì lúc chạy production.
Deadline — điểm sáng của gRPC
gRPC bắt buộc mỗi lời gọi có deadline, và deadline đó truyền tiếp qua các chặng:
Client đặt deadline 5s
└── Service A còn 4.8s (tự trừ thời gian đã trôi)
└── Service B còn 4.5s
└── Database còn 4.3s
So với timeout budget phải tự đặt tay ở chương 10 của phần 1, đây là cùng ý tưởng nhưng được giao thức lo hộ. Khi deadline hết, cả chuỗi cùng dừng — không còn cảnh service tầng sâu vẫn cày cho một request đã bị bỏ từ lâu.
Bẫy load balancing
Đây là vấn đề số một khi chạy gRPC trong Kubernetes.
Kubernetes Service (L4) + gRPC
↓
Client mở MỘT kết nối HTTP/2 sống lâu
↓
L4 chỉ định tuyến lúc mở kết nối
↓
Toàn bộ request đời sau đi vào đúng một pod
↓
Scale lên 10 pod, tải vẫn dồn vào 2-3 pod
Triệu chứng rất đặc trưng: thêm pod không cải thiện gì, và biểu đồ CPU giữa các pod chênh nhau kinh khủng.
Ba hướng xử lý:
| Cách | Cơ chế |
|---|---|
| Proxy L7 | Envoy/Linkerd cân bằng theo từng request thay vì từng kết nối |
| Client-side LB | Client tự tra danh sách pod và tự phân phối |
| Headless Service | Bỏ VIP, để client thấy toàn bộ IP pod |
Cách phổ biến nhất là chèn một service mesh, vì nó xử lý luôn cả retry, mTLS và tracing.
Khi nào đừng dùng
Ra ngoài internet công cộng thì REST vẫn hợp lý hơn: trình duyệt không nói gRPC trực tiếp (phải qua gRPC-Web và một lớp proxy), debug khó hơn, và người dùng API của bạn phải cài công cụ riêng. Dùng gRPC bên trong, REST ở ngoài rìa — đó là mô hình phổ biến nhất.
Chương 17 — NAT và cạn port
Vì sao một cái máy chỉ mở được ~28.000 kết nối ra ngoài
Một kết nối TCP được định danh bằng bốn thứ:
(IP nguồn, port nguồn, IP đích, port đích)
IP nguồn cố định, IP và port đích cố định (ví dụ database của bạn). Chỉ còn port nguồn là biến thiên, và dải port tạm của Linux mặc định là:
sysctl net.ipv4.ip_local_port_range # thường là 32768 60999
Tức khoảng 28.000 kết nối đồng thời tới cùng một đích. Nghe nhiều, nhưng cộng thêm TIME_WAIT giữ port thêm 60 giây sau khi đóng (chương 2 phần 1) thì con số thực tế nhỏ hơn nhiều.
Lỗi bạn sẽ thấy: EADDRNOTAVAIL, hoặc kết nối treo lúc mở.
NAT Gateway làm vấn đề nặng thêm
Trong VPC, nhiều máy đi ra internet qua chung một NAT Gateway. Lúc này toàn bộ dải port tạm bị chia sẻ giữa tất cả các máy:
10 container ──▶ NAT Gateway (1 IP công khai) ──▶ api.example.com:443
↑
tất cả dùng chung ~55.000 port
Đây là lý do một hệ thống đang chạy tốt bỗng lỗi kết nối ngẫu nhiên sau khi scale lên. Không có gì trong code thay đổi cả.
Thứ tự xử lý, từ hiệu quả nhất xuống:
- Bật keep-alive và dùng connection pool. 50 kết nối tái sử dụng thay cho 50.000 kết nối dùng một lần. Đây gần như luôn là câu trả lời đúng.
- Dùng VPC endpoint để traffic nội bộ không đi qua NAT.
- Nhiều NAT Gateway hoặc nhiều IP.
- Nới
ip_local_port_range— chỉ là biện pháp tình thế.
Để ý là biện pháp số 1 chính là chương 9 của phần 1. Rất nhiều vấn đề hạ tầng nghiêm trọng quy về đúng một dòng cấu hình trong app.
Kiểm tra
ss -tan state established | wc -l # tổng kết nối đang mở
ss -tan | awk '{print $5}' | cut -d: -f1 \
| sort | uniq -c | sort -rn | head # đang mở nhiều nhất tới đâu
Lệnh thứ hai đáng nhớ: nó chỉ thẳng ra service nào đang ngốn port của bạn.
MTU và gói tin bị nuốt
Một lỗi hiếm nhưng cực khó đoán. Mạng có kích thước gói tối đa (MTU, thường 1500 byte). Qua VPN hay overlay network của Kubernetes, con số này nhỏ hơn. Nếu gói quá lớn mà cơ chế báo lại bị firewall chặn, gói to sẽ biến mất im lặng.
Triệu chứng đặc trưng và rất dễ nhận ra khi đã biết: handshake xong xuôi, request nhỏ chạy tốt, request lớn treo tới timeout. Kết nối rõ ràng là thông, chỉ dữ liệu lớn mới hỏng.
ping -M do -s 1472 host # (Linux) gói 1500 byte, cấm phân mảnh
Chương 18 — CDN và HTTP caching
Ba tầng cache
Trình duyệt → CDN edge → Reverse proxy → App → Database
private shared shared
Phân biệt quan trọng nhất: private là cache riêng của một người dùng, shared là cache dùng chung cho mọi người. Nhầm lẫn ở đây gây ra loại lỗi tệ nhất trong nghề — người dùng A nhìn thấy dữ liệu của người dùng B.
Cache-Control: private, max-age=0, no-store ← dữ liệu cá nhân
Cache-Control: public, max-age=3600 ← nội dung dùng chung
Quy tắc an toàn: mọi response có Authorization hoặc cookie phiên đăng nhập đều phải private. Đặt mặc định là private cho toàn bộ API rồi mở ra từng chỗ, đừng làm ngược lại.
Các chỉ thị cần thuộc
| Chỉ thị | Nghĩa |
|---|---|
max-age=N | Tươi trong N giây |
s-maxage=N | Như trên nhưng chỉ áp cho cache dùng chung |
no-cache | Được lưu, nhưng phải hỏi lại trước mỗi lần dùng |
no-store | Cấm lưu ở mọi nơi |
stale-while-revalidate=N | Trả bản cũ ngay, âm thầm làm mới ở nền |
immutable | Không bao giờ đổi, đừng hỏi lại |
no-cache và no-store nghe giống nhau nhưng khác hẳn: no-cache vẫn lưu, chỉ là phải xác nhận. Muốn cấm tuyệt đối thì dùng no-store.
stale-while-revalidate là chỉ thị bị dùng ít nhất so với giá trị nó mang lại: người dùng không bao giờ phải chờ làm mới cache, và backend không bị đợt dồn request mỗi khi cache hết hạn.
Validation
Khi cache hết hạn, không nhất thiết phải tải lại toàn bộ:
Client ── GET /data, If-None-Match: "abc123" ──▶ Server
Client ◀──────── 304 Not Modified ────────────── Server
(không có body)
ETag là dấu vân tay của nội dung, Last-Modified là mốc thời gian. ETag chính xác hơn.
Đây là tối ưu rẻ tiền cho các API trả danh sách ít đổi: response 304 không có body, tiết kiệm băng thông và cả thời gian parse phía client.
Cache key và Vary
CDN quyết định "hai request có phải cùng một thứ không" dựa trên cache key, mặc định là URL. Nếu response thay đổi theo header thì phải khai báo:
Vary: Accept-Encoding, Accept-Language
Đừng bao giờ Vary: User-Agent — mỗi phiên bản trình duyệt thành một mục cache riêng, tỉ lệ trúng cache về gần không.
Ba lỗi cache hay gặp nhất, xếp theo mức độ nguy hiểm:
- Thiếu
Varytrên response phụ thuộc header — người dùng nhận nhầm ngôn ngữ, hoặc tệ hơn, nhầm dữ liệu. - Cache response có Authorization ở tầng shared — rò rỉ dữ liệu giữa các tài khoản.
- Query param không liên quan lọt vào cache key — mỗi
?utm_source=khác nhau là một cache miss.
Cache invalidation
Xóa cache trên CDN luôn chậm và không bảo đảm tuyệt đối. Cách bền vững hơn là không xóa, mà đổi URL:
/app.js?v=123 ← vẫn cần xóa khi đổi
/app.a3f9c1.js ← nội dung đổi thì tên đổi, cache cũ tự vô nghĩa
Đặt immutable, max-age=31536000 cho file có hash trong tên. Không bao giờ phải nghĩ tới invalidation nữa.
Chương 19 — CORS, mTLS, rate limit
CORS không phải cơ chế bảo mật server
Đây là hiểu nhầm phổ biến nhất về networking trong nghề.
CORS là quy tắc trình duyệt tự áp lên chính nó. Server chỉ trả về header nói "tôi cho phép origin nào"; trình duyệt đọc header đó rồi tự quyết định có đưa response cho JavaScript hay không.
curl https://api.example.com → luôn chạy được, curl không quan tâm CORS
fetch từ trang khác origin → trình duyệt chặn nếu header không cho phép
Hệ quả cần khắc cốt: CORS không bảo vệ API của bạn khỏi bất kỳ ai. Kẻ tấn công dùng script, không dùng trình duyệt. Bảo vệ API là việc của xác thực và phân quyền.
Khi nào có preflight — trình duyệt gửi OPTIONS hỏi trước:
| Không preflight | Có preflight |
|---|---|
| GET, HEAD, POST | PUT, DELETE, PATCH |
| Content-Type dạng form hoặc text | application/json |
| Không có header tự đặt | Có Authorization, header tùy chỉnh |
Nghĩa là gần như mọi API JSON hiện đại đều tốn thêm một vòng preflight. Đặt Access-Control-Max-Age để trình duyệt nhớ kết quả preflight, khỏi hỏi lại mỗi request.
Một quy tắc cứng cần nhớ: nếu bật Access-Control-Allow-Credentials: true thì không được dùng * cho origin. Phải liệt kê origin cụ thể. Đây cũng là chỗ hay bị viết ẩu thành "phản chiếu bất kỳ origin nào client gửi lên" — tương đương mở toang.
mTLS
TLS thường: client xác minh server. mTLS: cả hai bên cùng trình certificate.
Client ──── cert của tôi đây ────▶ Server
Client ◀─── cert của tôi đây ───── Server
cả hai cùng xác minh chuỗi ký
Dùng khi cần chứng minh danh tính ở tầng mạng thay vì tầng ứng dụng: service gọi service trong mesh, webhook từ đối tác, thiết bị IoT. Ưu điểm so với API key là certificate có hạn dùng và thu hồi được tập trung; nhược điểm là phải vận hành cả một hệ thống cấp phát và xoay vòng cert. Service mesh tồn tại phần lớn là để lo việc này hộ bạn.
Rate limit đặt ở đâu
| Tầng | Chặn được | Không chặn được |
|---|---|---|
| CDN/WAF | Tấn công lưu lượng lớn | Logic theo người dùng |
| Load balancer | Lạm dụng theo IP | Người dùng sau NAT chung |
| App | Theo tài khoản, theo endpoint | Traffic đã tốn tài nguyên để tới đây |
Đặt ở càng ngoài càng rẻ, càng vào trong càng chính xác. Hệ thống nghiêm túc dùng cả ba tầng với mục đích khác nhau.
Hai thuật toán chính:
Token bucket: đổ token đều đặn, mỗi request tiêu một token
→ cho phép bùng phát ngắn, hợp với API thật
Fixed window: đếm request trong mỗi phút
→ đơn giản, nhưng cho phép gấp đôi hạn mức ở ranh giới cửa sổ
Với nhiều instance, bộ đếm phải nằm ở nơi dùng chung (Redis), nếu không mỗi pod đếm riêng và hạn mức thật bằng hạn mức nhân số pod.
Cuối cùng, trả về đúng thông tin cho client thay vì chỉ ném 429 trống rỗng:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Remaining: 0
Không có Retry-After, client tử tế cũng không biết chờ bao lâu, và sẽ thử lại ngay — biến việc giới hạn thành việc khuếch đại tải.
Chương 20 — Playbook chẩn đoán
Chương này gom mọi thứ hai phần lại thành một quy trình. Nguyên tắc chung: đi từ ngoài vào trong, và luôn tách được chặng nào chậm trước khi đoán nguyên nhân.
Bắt đầu bằng một lệnh
curl -o /dev/null -s https://api.example.com \
-w "dns:%{time_namelookup}
tcp:%{time_connect}
tls:%{time_appconnect}
ttfb:%{time_starttransfer}
total:%{time_total}\n"
Đọc kết quả:
| Chặng nào lớn | Nhìn vào |
|---|---|
dns | Resolver, TTL, thread pool của Node (ch.4) |
tcp | Mạng, accept queue, backlog (ch.11) |
tls | Chuỗi cert, phiên bản TLS (ch.5) |
ttfb | Server chậm, hoặc chờ pool (ch.9) |
total trừ ttfb | Băng thông, kích thước response |
Theo triệu chứng
Chậm ngẫu nhiên, không tái hiện được
Chậm rải rác, đa số request bình thường
├── Chậm đúng mốc 1s / 3s → gói tin bị mất, TCP retransmit (ch.11)
├── Chậm ở chặng dns → cache miss DNS (ch.4)
├── Chậm ở ttfb, CPU thấp → hết socket trong pool, request xếp hàng (ch.9)
└── Chậm theo chu kỳ → GC, cron, hoặc rotate log
502 / 503 rải rác
502 xuất hiện đều đặn, log app hoàn toàn sạch
↓
Request chưa từng tới được app
↓
├── Chỉ xảy ra lúc deploy → thiếu graceful shutdown (ch.12)
├── Xảy ra cả lúc bình thường → keepAliveTimeout < idle timeout của LB (ch.10)
└── Kèm health check chớp tắt → liveness check chạm database (ch.12)
App ngừng nhận request nhưng process vẫn sống
├── Log có EMFILE → cạn file descriptor (ch.11)
├── Log có EADDRNOTAVAIL → cạn port tạm, NAT (ch.17)
├── Không log gì, CPU 100% → event loop bị chặn
└── Không log gì, CPU thấp → deadlock, hoặc promise không bao giờ resolve
Chạy được ở local, hỏng trên production
├── Lỗi certificate → thiếu intermediate cert (ch.5)
├── Stream không ra dữ liệu → proxy đang buffer (ch.13)
├── WebSocket đứt sau 60s → proxy_read_timeout (ch.13)
├── Request lớn treo, nhỏ ok → vấn đề MTU (ch.17)
└── Chỉ hỏng trên trình duyệt → CORS (ch.19)
Nhánh cuối đáng nhớ: thử lại bằng curl. Nếu curl chạy được mà trình duyệt không, thì gần như chắc chắn là CORS hoặc cookie, không phải mạng.
Bộ lệnh chẩn đoán
# Tầng mạng
mtr host # chặng nào mất gói
dig +trace domain # chuỗi DNS
ping -M do -s 1472 host # thử MTU
# Tầng socket
ss -tan | awk '{print $1}' | sort | uniq -c # phân bố trạng thái
ss -tln # accept queue (cột Send-Q)
ss -ti # rtt, cwnd theo kết nối
lsof -p <pid> | wc -l # số fd đang mở
# Tầng ứng dụng
curl -w "..." -o /dev/null -s URL # tách latency từng chặng
curl -v --http1.1 URL # ép phiên bản để so sánh
openssl s_client -connect host:443 -servername host
# Bắt gói khi mọi cách trên đều bó tay
sudo tcpdump -i any -w capture.pcap host api.example.com
Ba câu hỏi trước khi sửa bất cứ thứ gì
- Chậm ở chặng nào? Chưa tách được chặng thì mọi giả thuyết đều là đoán mò.
- Request có tới được app không? Log sạch trơn là dấu hiệu vấn đề nằm ở tầng dưới hoặc tầng ngoài, không phải trong code.
- Đổi gì trước khi hỏng? Phần lớn sự cố mạng trên production không phải do mạng đổi, mà do một thay đổi cấu hình, một lần scale, hoặc một dependency mới.
Bảng tra nhanh phần 2
Giới hạn cần thuộc
File descriptor mặc định thường 1024, container còn thấp hơn
Backlog mặc định của Node 511, bị cắt theo somaxconn
Dải port tạm của Linux 32768–60999 (~28.000)
Kết nối HTTP/1.1 của trình duyệt 6 mỗi origin
proxy_read_timeout của Nginx 60s — thủ phạm giết WebSocket/SSE
MTU thông thường 1500, nhỏ hơn khi qua VPN/overlay
Cấu hình hay viết thiếu
proxy_http_version 1.1; # thiếu là keepalive upstream vô tác dụng
proxy_set_header Connection ""; # cho keep-alive
proxy_set_header Upgrade $http_upgrade; # cho WebSocket
proxy_buffering off; # cho SSE
app.set('trust proxy', 1); // lấy đúng IP client
res.setHeader('X-Accel-Buffering', 'no'); // cho stream
req.on('close', cleanup); // tránh rò rỉ khi client ngắt
Thứ tự ưu tiên khi tối ưu mạng
1. Bật keep-alive và connection pool ← rẻ nhất, tác dụng lớn nhất
2. Giảm số vòng gọi mạng (gộp query)
3. Đặt timeout theo từng chặng, có budget
4. Cache đúng tầng, đúng private/public
5. Đổi giao thức (HTTP/2, gRPC) ← đắt nhất, làm sau cùng
Học tiếp
Thực hành có giá trị nhất của phần này: dựng một chuỗi hoàn chỉnh trên VPS — Nginx đứng trước hai instance Node, có health check và graceful shutdown. Rồi cố tình phá từng thứ một:
- Hạ
ulimit -nxuống 50 rồi bắn tải, xemEMFILExuất hiện thế nào. - Bỏ phần chờ ở graceful shutdown, deploy liên tục, đếm số 502.
- Bật
proxy_bufferingrồi thử một endpoint SSE. - Tắt keep-alive upstream rồi so sánh biểu đồ latency.
Tự tay tạo ra từng lỗi trong bài này một lần thì lần gặp trên production bạn nhận ra trong vài phút thay vì vài ngày.
Hướng đi tiếp theo: service mesh (Envoy, Linkerd) — nó gói gần như toàn bộ chương 12, 16 và 19 vào một lớp hạ tầng. Hiểu những chương này trước rồi mới học mesh thì bạn biết nó đang giải quyết vấn đề gì; học ngược lại thì chỉ là học cách sửa file YAML.