DuckDev
Trang chủDự ánBlogLiên hệ
DuckDev
Trang chủDự ánBlogLiên hệ
Networking — Nền tảng phần 2
#learning#networking#developer

Networking — Nền tảng phần 2

5 tháng 9, 2026

Mục lục

  1. Socket và file descriptor
  2. Load balancer
  3. Reverse proxy
  4. WebSocket
  5. SSE và long polling
  6. gRPC và giao tiếp nội bộ
  7. NAT và cạn port
  8. CDN và HTTP caching
  9. CORS, mTLS, rate limit
  10. 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ấyIP, portURL, header, cookie, method
Có giải mã TLSKhôngCó (thường vậy)
Định tuyến theoCặp IP:portPath, host, header
Chi phíRất thấpCao hơn
Ví dụAWS NLB, HAProxy chế độ tcpAWS 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ánCách chọnHợp với
Round robinLần lượtRequest đồng đều
Least connectionsBackend đang ít kết nối nhấtRequest dài ngắn lệch nhau
IP hashBăm IP clientCần dính một backend
Random two choicesBốc ngẫu nhiên 2, chọn cái ít tải hơnMặ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:

LivenessReadiness
Hỏi gìProcess còn sống khôngCó sẵn sàng nhận request không
Thất bại thìRestart containerRút khỏi pool, không restart
Nên kiểm traGầ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ệcVì sao không để Node làm
Kết thúc TLSTốn CPU, và event loop chỉ có một
Phục vụ file tĩnhsendfile của kernel nhanh hơn nhiều
Nén gzip/brotliChiếm thread pool của libuv
Đệm request chậmBảo vệ Node khỏi client mạng yếu
Giới hạn tốc độ, chặn IPRẻ 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 địnhNghĩa
proxy_connect_timeout60sChờ nối được tới Node
proxy_send_timeout60sChờ giữa hai lần ghi tới Node
proxy_read_timeout60sChờ giữa hai lần đọc từ Node
keepalive_timeout75sGiữ 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áchCơ chếĐánh đổi
Sticky sessionBăm để cùng user luôn vào cùng podDeploy là đứt hết; tải lệch
Pub/subMọi pod cùng đăng ký một kênh Redis/NATSThê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

SSEWebSocket
Chiều dữ liệuChỉ server → clientHai chiều
Giao thứcHTTP thườngUpgrade
Tự động nối lạiCó sẵn trong trình duyệtTự viết
Bắt kịp dữ liệu lỡLast-Event-ID có sẵnTự viết
Qua proxy/firewallTrơn truĐôi khi bị chặn
Dữ liệu nhị phânKhôngCó

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/JSONgRPC
Định dạngText, tự mô tảNhị phân, cần schema
Kích thướcLớn hơnNhỏ hơn đáng kể
Hợp đồngTài liệu, dễ lệch thực tếFile .proto, sinh code cả hai bên
StreamingHạn chếBốn kiểu, kể cả hai chiều
Debug bằng curlĐượcKhông trực tiếp
Hợp vớiAPI công khaiService 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áchCơ chế
Proxy L7Envoy/Linkerd cân bằng theo từng request thay vì từng kết nối
Client-side LBClient tự tra danh sách pod và tự phân phối
Headless ServiceBỏ 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:

  1. 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.
  2. Dùng VPC endpoint để traffic nội bộ không đi qua NAT.
  3. Nhiều NAT Gateway hoặc nhiều IP.
  4. 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=NTươi trong N giây
s-maxage=NNhư 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-storeCấm lưu ở mọi nơi
stale-while-revalidate=NTrả bản cũ ngay, âm thầm làm mới ở nền
immutableKhô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:

  1. Thiếu Vary trê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.
  2. Cache response có Authorization ở tầng shared — rò rỉ dữ liệu giữa các tài khoản.
  3. 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 preflightCó preflight
GET, HEAD, POSTPUT, DELETE, PATCH
Content-Type dạng form hoặc textapplication/json
Không có header tự đặtCó 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ầngChặn đượcKhông chặn được
CDN/WAFTấn công lưu lượng lớnLogic theo người dùng
Load balancerLạm dụng theo IPNgười dùng sau NAT chung
AppTheo tài khoản, theo endpointTraffic đã 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ớnNhìn vào
dnsResolver, TTL, thread pool của Node (ch.4)
tcpMạng, accept queue, backlog (ch.11)
tlsChuỗi cert, phiên bản TLS (ch.5)
ttfbServer chậm, hoặc chờ pool (ch.9)
total trừ ttfbBă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ì

  1. Chậm ở chặng nào? Chưa tách được chặng thì mọi giả thuyết đều là đoán mò.
  2. 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.
  3. Đổ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 -n xuống 50 rồi bắn tải, xem EMFILE xuất hiện thế nào.
  • Bỏ phần chờ ở graceful shutdown, deploy liên tục, đếm số 502.
  • Bật proxy_buffering rồ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.

Tham khảo

  • 1.Networking — Nền tảng
Tien NguyenTien Nguyen

Fullstack developer. Tôi xây dựng hệ thống khả năng mở rộng cao và chia sẻ những gì tôi học được.

GitHubLinkedIn

Khám phá

  • Trang chủ
  • Dự án
  • Blog
  • Liên hệ

LIÊN KẾT

  • GitHub
  • Blog
  • RSS
  • Changelog
© 2026 Tien Nguyen. All rights reserved.
PrivacyTerms