
Networking — Nền tảng
5 tháng 9, 2026
Mục lục
- Latency — thứ quyết định mọi thứ
- TCP
- UDP
- DNS
- TLS
- HTTP/1.1
- HTTP/2
- HTTP/3 và QUIC
- Connection pool trong Node
- Timeout và retry
Chương 1 — Latency, thứ quyết định mọi thứ
Hai đại lượng hay bị gộp làm một
| Là gì | Đơn vị | |
|---|---|---|
| Bandwidth | Bao nhiêu byte đi qua được trong 1 giây | Mbps |
| Latency | Một gói tin đi từ A tới B mất bao lâu | ms |
Ví dụ dễ hình dung: bandwidth là số làn đường, latency là chiều dài con đường. Thêm làn giúp chở được nhiều xe hơn, nhưng không rút ngắn quãng đường.
Đây là lý do nâng băng thông từ 100 Mbps lên 1 Gbps gần như không làm API nhanh hơn: request/response của bạn chỉ vài KB, nó không nghẽn ở làn đường, nó nghẽn ở quãng đường.
RTT — đơn vị đếm thực tế
RTT (Round-Trip Time) là thời gian đi và về một vòng. Đây là đơn vị bạn nên dùng để ước lượng, thay vì đếm ms rời rạc.
| Tuyến | RTT thực tế |
|---|---|
| Cùng datacenter | 0.5 – 1 ms |
| Cùng vùng (HCM ↔ HCM) | 5 – 15 ms |
| HCM ↔ Singapore | 30 – 50 ms |
| HCM ↔ US East | 200 – 250 ms |
| 4G di động (chỉ riêng chặng radio) | 50 – 100 ms |
Giới hạn cứng: ánh sáng trong sợi quang đi khoảng 200.000 km/s. Vòng quanh trái đất một vòng đã mất ~200 ms. Không có tối ưu phần mềm nào phá được con số này.
Vì sao dev backend cần quan tâm
Mỗi thao tác mạng trong request của bạn đều tốn ít nhất 1 RTT:
Client → API Gateway → Service A → Service B → Database
↑ 1 RTT ↑ 1 RTT ↑ 1 RTT ↑ 1 RTT
Một API gọi 5 service tuần tự trong cùng datacenter tốn ~5 ms chỉ riêng phần mạng — chấp nhận được. Nhưng nếu database nằm khác vùng, cùng một kiến trúc đó thành 250 ms. Kiến trúc không đổi, chỉ vị trí đổi.
Quy tắc thực dụng: số vòng mạng quan trọng hơn số byte. Gộp 10 query thành 1 query luôn thắng, kể cả khi query gộp trả về nhiều dữ liệu hơn.
Đo thử
ping google.com # RTT thô, ICMP
traceroute google.com # từng chặng trên đường đi
mtr google.com # traceroute + ping liên tục, dễ đọc hơn
Đo RTT tầng ứng dụng (chính xác hơn ICMP vì nhiều nơi chặn hoặc ưu tiên ICMP khác):
curl -w "@-" -o /dev/null -s https://api.example.com <<'EOF'
dns: %{time_namelookup}s
connect: %{time_connect}s
tls: %{time_appconnect}s
ttfb: %{time_starttransfer}s
total: %{time_total}s
EOF
Đây là lệnh đáng nhớ nhất chương này. Nó tách rõ thời gian nằm ở DNS, TCP, TLS hay ở chính server — thay vì chỉ biết "API chậm 800 ms".
Chương 2 — TCP
TCP cho bạn cái gì
TCP là giao thức có kết nối, đảm bảo thứ tự, đảm bảo tới nơi. Bạn ghi vào socket một chuỗi byte, đầu kia đọc ra đúng chuỗi byte đó, đúng thứ tự.
Cái giá phải trả: bắt tay trước khi gửi, và chờ gửi lại khi mất gói.
Bắt tay ba bước
Client Server
│──────── SYN ─────────────────▶│
│◀─────── SYN + ACK ────────────│
│──────── ACK ─────────────────▶│ ← hết 1 RTT, giờ mới gửi được data
│──────── GET /api ────────────▶│
Kết nối TCP mới luôn tốn 1 RTT trước khi byte dữ liệu đầu tiên đi được. Cộng thêm TLS nữa thì thành 2 RTT. Đây là toàn bộ lý do tồn tại của keep-alive và connection pool ở chương 9.
Slow start — vì sao request đầu tiên luôn chậm
TCP không biết đường mạng chịu được bao nhiêu, nên nó dò dần. Nó giữ một biến gọi là congestion window (cwnd) — số byte được phép gửi ra mà chưa cần chờ xác nhận.
Vòng 1: gửi 10 gói (~14 KB) ← giá trị khởi tạo chuẩn
Vòng 2: gửi 20 gói (~28 KB)
Vòng 3: gửi 40 gói (~56 KB)
Vòng 4: gửi 80 gói (~112 KB)
Mỗi "vòng" tốn 1 RTT. Hệ quả cụ thể: gửi một response 200 KB qua kết nối mới với RTT 50 ms mất khoảng 4 vòng, tức 200 ms, dù băng thông dư thừa hoàn toàn.
Hai hệ quả cho backend:
- Kết nối vừa mở luôn "chậm hơn năng lực thật". Kết nối dùng lại đã ấm sẵn cwnd lớn — thêm một lý do nữa cho keep-alive.
- Response nhỏ hơn ~14 KB đi trọn trong vòng đầu tiên. Đây là ngưỡng đáng nhớ khi cân nhắc gzip hay cắt bớt field trả về.
Head-of-line blocking
TCP đảm bảo thứ tự bằng cách: gói số 5 mất thì gói 6, 7, 8 dù đã tới nơi vẫn phải nằm trong buffer chờ, không được giao lên tầng ứng dụng.
Đã nhận: [1][2][3][4][ ✗ ][6][7][8]
↑
gói 5 mất → 6,7,8 bị giữ lại
chờ truyền lại 5 (mất thêm 1 RTT)
Cụm từ head-of-line blocking nghĩa là "cái đứng đầu hàng bị kẹt thì cả hàng kẹt theo". Nó sẽ xuất hiện lại ở chương 7 như giới hạn cứng của HTTP/2, và là lý do HTTP/3 bỏ TCP.
TIME_WAIT
Sau khi đóng kết nối, bên chủ động đóng trước giữ socket ở trạng thái TIME_WAIT khoảng 60 giây, để nuốt nốt các gói đến muộn.
ss -tan | awk '{print $1}' | sort | uniq -c # đếm socket theo trạng thái
Thấy hàng chục nghìn TIME_WAIT trên server của bạn nghĩa là bạn đang mở/đóng kết nối liên tục thay vì tái sử dụng — thường là do một HTTP client không bật keep-alive. Sửa ở tầng ứng dụng, đừng vội chỉnh kernel.
TCP keep-alive khác HTTP keep-alive
Hai thứ trùng tên, hoàn toàn khác nhau. Đây là chỗ nhầm rất phổ biến:
| TCP keep-alive | HTTP keep-alive | |
|---|---|---|
| Tầng | Transport | Ứng dụng |
| Làm gì | Gửi gói dò để phát hiện kết nối chết | Giữ kết nối mở để gửi request tiếp theo |
| Trong Node | socket.setKeepAlive(true, ms) | new http.Agent({ keepAlive: true }) |
Xem trên máy
ss -tanp # tất cả socket TCP + process sở hữu
ss -tanp state established # chỉ kết nối đang mở
ss -ti # kèm cwnd, rtt — xem slow start thật
lsof -i :3000 # ai giữ port 3000
Chương 3 — UDP
Khác biệt cốt lõi
UDP chỉ làm một việc: gửi gói đi kèm số port. Không bắt tay, không thứ tự, không đảm bảo tới nơi, không kiểm soát tắc nghẽn.
| TCP | UDP | |
|---|---|---|
| Bắt tay | 1 RTT | Không |
| Đảm bảo tới nơi | Có | Không |
| Đúng thứ tự | Có | Không |
| Head-of-line | Có | Không |
| Dùng cho | API, DB, hầu hết mọi thứ | DNS, QUIC, video, game, metrics |
Vì sao vẫn quan trọng với backend
Ba chỗ bạn chắc chắn đang dùng UDP mà có thể không để ý:
- DNS — mỗi lần phân giải tên miền là một gói UDP. Không phản hồi thì client tự timeout và thử lại, không có ai báo lỗi.
- QUIC / HTTP/3 — chạy trên UDP, tự cài lại phần tin cậy ở tầng trên (chương 8).
- Metrics / log — StatsD, một số agent APM gửi UDP cố tình: mất vài gói metric chấp nhận được, còn hơn để việc gửi metric làm chậm request.
Nguyên tắc chọn: UDP hợp lý khi dữ liệu cũ trở nên vô giá trị. Frame video của 2 giây trước, hay giá trị metric của phút trước, gửi lại cũng vô ích. Còn một dòng ghi vào database thì không bao giờ vô giá trị — dùng TCP.
Chương 4 — DNS
Chuỗi phân giải
Trước khi kết nối được, tên miền phải thành địa chỉ IP:
App → OS cache → Resolver (ISP/8.8.8.8) → Root → .com → NS của domain
↑ ~0ms ↑ ~10-50ms ↑ chỉ khi resolver chưa cache
Trong đa số trường hợp resolver đã cache sẵn, tốn 10–50 ms. Trường hợp cache miss hoàn toàn có thể tốn hàng trăm ms — và đó là trước khi TCP handshake bắt đầu.
Các record cần biết
| Record | Trả về | Ghi chú |
|---|---|---|
A | IPv4 | Phổ biến nhất |
AAAA | IPv6 | Node thử cả hai, thứ tự tùy cấu hình |
CNAME | Tên miền khác | Thêm một vòng phân giải nữa |
MX | Mail server | |
TXT | Text tự do | SPF, DKIM, xác thực sở hữu domain |
NS | Name server | Ai có quyền trả lời cho domain này |
TTL
Mỗi record có TTL — số giây được phép cache. Đây là con số quyết định "bao lâu thì DNS đổi mới ăn".
dig api.example.com # kết quả + TTL
dig +trace api.example.com # đi từ root, xem toàn bộ chuỗi
dig @8.8.8.8 api.example.com # hỏi resolver cụ thể
dig api.example.com AAAA # hỏi record IPv6
Trước khi chuyển server: hạ TTL xuống 60 giây, chờ qua TTL cũ, rồi mới đổi IP. Đổi IP trong khi TTL còn 86400 nghĩa là một phần người dùng vẫn vào server cũ suốt một ngày.
Cạm bẫy DNS trong Node
Đây là chương có nhiều bẫy nhất với dev Node.
1. dns.lookup chiếm thread pool
Node có hai đường phân giải DNS, hành xử khác hẳn nhau:
dns.lookup() | dns.resolve() | |
|---|---|---|
| Cơ chế | getaddrinfo của OS | Tự gửi query qua c-ares |
| Chạy ở đâu | Thread pool của libuv | Bất đồng bộ thật, không tốn thread |
Đọc /etc/hosts | Có | Không |
| Ai dùng | http, https, net — tức là mọi thư viện HTTP | Chỉ khi bạn gọi tay |
Thread pool mặc định chỉ có 4 luồng, dùng chung với fs, crypto, zlib. Khi DNS chậm, 4 request đang phân giải tên miền sẽ chặn luôn cả việc đọc file và hash password của toàn app — dù event loop hoàn toàn rảnh.
UV_THREADPOOL_SIZE=16 node server.js # giảm nhẹ, không phải cách sửa gốc
2. Node không cache DNS
Trình duyệt cache. OS cache. Node thì không. Mỗi lần gọi HTTP tới cùng một host, nếu không tái sử dụng kết nối, là một lần phân giải DNS mới.
Cách sửa gốc là bật keep-alive (chương 9) để không phải phân giải lại, hoặc gắn một cache tôn trọng TTL:
import CacheableLookup from 'cacheable-lookup';
new CacheableLookup().install(https.globalAgent);
3. Kết nối giữ quá lâu khi IP đổi
Mặt trái của keep-alive: kết nối đã mở thì không quan tâm DNS nữa. Backend scale, IP cũ biến mất, mà pool vẫn ôm kết nối chết. Giới hạn tuổi thọ kết nối (maxCachedSessions, hoặc timeout ở tầng pool) là cách xử lý.
Chương 5 — TLS
TLS làm ba việc
| Việc | Nghĩa là |
|---|---|
| Mã hoá | Người ở giữa đọc được gói tin nhưng không hiểu |
| Toàn vẹn | Sửa một byte trên đường là bị phát hiện |
| Xác thực | Chứng minh server đúng là chủ của domain đó |
Việc thứ ba mới là phần hay hỏng. Mã hoá gần như không bao giờ lỗi; sai certificate thì mỗi tuần.
Handshake tốn bao nhiêu vòng
TLS 1.2 (2 RTT sau TCP):
│──── ClientHello ────────────▶│
│◀─── ServerHello + cert ──────│
│──── key exchange ───────────▶│
│◀─── Finished ────────────────│
│──── GET /api ───────────────▶│
TLS 1.3 (1 RTT sau TCP):
│──── ClientHello + key share ▶│
│◀─── ServerHello + cert ──────│
│──── Finished + GET /api ────▶│ ← gửi luôn cùng lượt
Tổng chi phí mở một kết nối HTTPS mới:
| Phiên bản | TCP | TLS | Tổng |
|---|---|---|---|
| TLS 1.2 | 1 | 2 | 3 RTT |
| TLS 1.3 | 1 | 1 | 2 RTT |
| TLS 1.3 nối lại phiên (0-RTT) | 1 | 0 | 1 RTT |
Với RTT 50 ms, con số này là 150 ms so với 100 ms — chỉ để chào hỏi, chưa xử lý gì. Nhân với hàng nghìn request không tái sử dụng kết nối thì đây là khoản lãng phí lớn nhất trong hệ thống của bạn.
Lưu ý về 0-RTT: dữ liệu gửi ở vòng 0-RTT có thể bị phát lại (replay). Chỉ dùng cho request idempotent, đừng dùng cho POST /payments.
Chuỗi certificate
Cert của bạn không tự đứng một mình. Nó được ký bởi một cert trung gian, cert đó lại được ký bởi root nằm sẵn trong OS:
Root CA (có sẵn trong hệ điều hành)
└── Intermediate CA
└── Cert của api.example.com
↑ server phải gửi cả cert này VÀ intermediate
Lỗi kinh điển: server chỉ gửi cert lá, quên intermediate. Trình duyệt thường tự tải bù nên trông vẫn ổn, nhưng Node thì không — kết quả là code báo lỗi trong khi Chrome vào bình thường.
openssl s_client -connect api.example.com:443 -servername api.example.com
# đọc phần "Certificate chain" — phải thấy đủ mắt xích
echo | openssl s_client -connect api.example.com:443 2>/dev/null \
| openssl x509 -noout -dates # ngày hết hạn
Hai từ khoá xuất hiện trong output cần biết:
- SNI — client báo tên miền muốn nối ngay trong ClientHello, để một IP phục vụ được nhiều domain HTTPS. Thiếu
-servernamekhi test bằngopensslsẽ ra sai cert. - ALPN — client và server thoả thuận dùng HTTP/1.1 hay HTTP/2 ngay trong handshake, không tốn thêm vòng nào.
Lỗi TLS thường gặp trong Node
| Lỗi | Nguyên nhân thật |
|---|---|
UNABLE_TO_VERIFY_LEAF_SIGNATURE | Server thiếu intermediate cert |
DEPTH_ZERO_SELF_SIGNED_CERT | Cert tự ký (thường gặp ở môi trường nội bộ) |
CERT_HAS_EXPIRED | Hết hạn, hoặc đồng hồ máy sai |
ERR_TLS_CERT_ALTNAME_INVALID | Domain đang gọi không có trong SAN của cert |
Đừng dùng NODE_TLS_REJECT_UNAUTHORIZED=0. Nó tắt xác thực cho toàn bộ process, bao gồm cả các kết nối tới bên thứ ba. Nếu thật sự cần chấp nhận CA nội bộ, nạp đúng CA đó:
const agent = new https.Agent({ ca: fs.readFileSync('./internal-ca.pem') });
Chương 6 — HTTP/1.1
Vấn đề gốc: mỗi lúc một request
Một kết nối HTTP/1.1 chỉ xử lý được một request tại một thời điểm. Muốn song song thì phải mở nhiều kết nối.
Kết nối 1: [req A]────[res A]────[req D]────[res D]
Kết nối 2: [req B]────[res B]
Kết nối 3: [req C]──────────[res C]
HTTP pipelining (gửi nhiều request liên tiếp không chờ) từng được đặc tả cho phép, nhưng response vẫn phải về đúng thứ tự nên một response chậm chặn cả hàng. Thực tế đã bị bỏ, không nên dùng.
Keep-alive
HTTP/1.1 mặc định giữ kết nối mở sau khi trả response. Đây là tối ưu lớn nhất của phiên bản này: tiết kiệm 2–3 RTT cho mỗi request tiếp theo.
Không keep-alive: [TCP+TLS][req][res] [TCP+TLS][req][res] [TCP+TLS][req][res]
Có keep-alive: [TCP+TLS][req][res]──────────[req][res]──────────[req][res]
Header liên quan:
Connection: keep-alive
Keep-Alive: timeout=5, max=1000
Chunked transfer
Khi server chưa biết trước độ dài response (stream, dữ liệu sinh dần), nó dùng Transfer-Encoding: chunked — gửi từng khối kèm độ dài, kết thúc bằng khối rỗng.
Đây là cơ chế đứng sau mọi API streaming trong Node. Điểm cần nhớ: một khi đã gửi header đi thì không sửa được status code nữa. Lỗi xảy ra giữa chừng stream không thể biến response 200 thành 500 — chỉ có thể ngắt kết nối đột ngột, và client thấy response cụt.
Chương 7 — HTTP/2
Thay đổi chính
HTTP/2 giữ nguyên ngữ nghĩa của HTTP (method, status, header) nhưng đổi hoàn toàn cách truyền:
| HTTP/1.1 | HTTP/2 | |
|---|---|---|
| Định dạng | Text | Binary frame |
| Song song | Nhiều kết nối | Một kết nối, nhiều stream |
| Header | Gửi lại đầy đủ mỗi lần | Nén bằng HPACK |
| Ưu tiên | Không có | Có |
Multiplexing là điểm cốt lõi: nhiều request/response đan xen trên cùng một kết nối, cắt nhỏ thành frame và ghép lại ở đầu kia.
Một kết nối TCP:
[S1][S3][S1][S2][S3][S1][S2] ← frame của 3 stream đan xen
│ │ │ │ │
└───┴───┴───┴───┴─ ghép lại thành 3 response độc lập
Nén header đáng giá hơn tưởng tượng với API: cookie, Authorization, User-Agent lặp lại y nguyên ở mọi request, HTTP/1.1 gửi lại toàn bộ mỗi lần, HTTP/2 chỉ gửi phần thay đổi.
Giới hạn không vượt qua được
HTTP/2 giải quyết head-of-line blocking ở tầng HTTP, nhưng nó vẫn chạy trên TCP — mà TCP vẫn đảm bảo thứ tự byte của cả kết nối.
Mất một gói TCP
↓
TCP giữ lại mọi byte đến sau nó
↓
Toàn bộ stream trên kết nối đó dừng, kể cả stream không liên quan
↓
HTTP/2 trên mạng mất gói có thể chậm hơn HTTP/1.1 nhiều kết nối
Nghịch lý này chính là lý do HTTP/3 ra đời. Trên mạng ổn định (giữa các service trong datacenter), HTTP/2 thắng rõ. Trên 4G chập chờn, chưa chắc.
Trong Node
import http2 from 'node:http2';
const server = http2.createSecureServer({ key, cert });
Ba điều thực dụng cần biết:
- Module
httptích hợp sẵn không nói được HTTP/2 ở phía client. Muốn gọi h2 ra ngoài thì dùngundicihoặc thư viện hỗ trợ. - Rất nhiều hệ thống chỉ chạy HTTP/2 ở chặng client ↔ load balancer, còn chặng LB ↔ Node vẫn là HTTP/1.1. Kiểm tra trước khi tối ưu nhầm chỗ.
- Server Push đã chết. Trình duyệt đã bỏ hỗ trợ. Dùng
103 Early Hintshoặc<link rel=preload>thay thế.
Chương 8 — HTTP/3 và QUIC
Ý tưởng
QUIC lấy toàn bộ phần "đảm bảo tin cậy" ra khỏi kernel và cài lại ở tầng ứng dụng, chạy trên UDP.
HTTP/2: HTTP/3:
HTTP/2 HTTP/3
TLS QUIC (đã bao gồm TLS 1.3)
TCP UDP
IP IP
Nghe như đi lùi, nhưng nó mở ra bốn thứ TCP không làm được:
1. Không còn head-of-line blocking ở tầng transport. QUIC biết ranh giới giữa các stream, nên mất gói của stream 1 không chặn stream 2.
TCP: mất 1 gói → mọi stream đứng chờ
QUIC: mất 1 gói → chỉ stream chứa gói đó chờ
2. Bắt tay 1 RTT. QUIC gộp bắt tay transport và TLS làm một. Nối lại phiên thì 0 RTT.
3. Connection migration. Kết nối định danh bằng connection ID, không phải cặp IP/port. Đổi từ WiFi sang 4G, kết nối vẫn sống. TCP thì đứt và phải bắt tay lại từ đầu.
4. Cập nhật được. TCP nằm trong kernel, sửa một thuật toán tắc nghẽn phải chờ cả thế giới nâng cấp OS. QUIC nằm trong thư viện, deploy là xong.
Trạng thái thực tế với backend
Cần thực tế: hôm nay bạn gần như không tự chạy HTTP/3 ở tầng Node. Hỗ trợ QUIC trong Node vẫn ở dạng thử nghiệm và không nên dùng cho production.
Con đường thực tế là: CDN hoặc load balancer (Cloudflare, Nginx bản mới, Envoy) nói HTTP/3 với client, rồi nói HTTP/1.1 hoặc HTTP/2 với server Node của bạn. Bạn hưởng lợi mà không phải đổi code.
Có hai thứ đáng chú ý ở phía hạ tầng: một số mạng doanh nghiệp chặn UDP 443 khiến client tự lùi về TCP (nên vẫn phải giữ đường HTTP/2 hoạt động), và header Alt-Svc là cách server báo cho client biết "tôi có nói được HTTP/3, lần sau qua đường đó".
Chương 9 — Connection pool trong Node
Đây là chương có tác động thực tế lớn nhất tới hệ thống bạn đang chạy.
Vấn đề
Mọi thứ ở chương 2 và 5 gom lại thành một con số: mở một kết nối HTTPS mới tốn 2–3 RTT cộng thêm chi phí slow start. Một service gọi service khác 1000 lần/phút mà không tái sử dụng kết nối là đang đốt 2–3 RTT × 1000.
Agent trong Node
import http from 'node:http';
const agent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 1000, // tần suất gửi gói dò TCP keep-alive
maxSockets: 50, // tối đa kết nối đồng thời tới MỖI host
maxFreeSockets: 10, // giữ rảnh tối đa bao nhiêu
timeout: 60000,
});
Từ Node 19, agent mặc định đã bật keepAlive: true. Nhưng đừng cho rằng thế là xong: rất nhiều thư viện HTTP tự tạo agent riêng, và nhiều codebase vẫn chạy trên phiên bản Node cũ hơn. Kiểm tra thay vì giả định.
Bẫy maxSockets
maxSockets tính theo host, không phải tổng toàn app. Đặt quá thấp thì request xếp hàng trong bộ nhớ chờ socket rảnh — biểu hiện là latency tăng vọt trong khi CPU và RAM đều nhàn. Đặt quá cao thì bạn tự DDoS service phía sau.
Điểm cần nhớ: hàng đợi này nằm trong app của bạn và mặc định không có giới hạn. Request nằm chờ không hiện lên bất kỳ metric nào của server đích. Nhìn dashboard của service kia thấy "vẫn khoẻ" trong khi hệ thống của bạn đang tắc là chuyện bình thường.
ECONNRESET ngẫu nhiên — bẫy kinh điển
Đây là cuộc đua giữa hai bên khi kết nối idle sắp hết hạn:
Server quyết định đóng kết nối idle
↓
Đúng lúc đó client gửi request vào chính kết nối đó
↓
Hai gói tin đi ngược chiều nhau trên đường
↓
Client nhận RST → ECONNRESET, không phải lỗi của ai cả
Không thể loại bỏ hoàn toàn, chỉ giảm xác suất:
- Đặt idle timeout phía client thấp hơn phía server, để client luôn là bên chủ động bỏ kết nối trước.
- Retry request idempotent khi gặp lỗi này (chương 10).
undici
undici là HTTP client thế hệ mới của Node, cũng là thứ chạy dưới fetch() tích hợp sẵn. Nó quản lý pool tốt hơn và hỗ trợ hàng đợi rõ ràng:
import { Pool } from 'undici';
const pool = new Pool('https://api.example.com', {
connections: 50,
pipelining: 1,
});
Lưu ý về fetch() toàn cục: nó dùng pool mặc định của undici. Với service gọi nội bộ tần suất cao, tạo Pool riêng cho từng host để kiểm soát được số kết nối, thay vì dùng chung một pool cho mọi thứ.
Chương 10 — Timeout và retry
Một request có sáu chỗ để chờ
Nói "timeout 5 giây" là chưa đủ. Cần biết đang nói tới chặng nào:
│── DNS ──│── TCP ──│── TLS ──│──── server xử lý ────│── tải response ──│
↑
TTFB
│←──────────────────────── total ─────────────────────────────────────→│
| Loại | Chờ cái gì | Hay hỏng khi |
|---|---|---|
| DNS timeout | Phân giải tên miền | Resolver chết |
| Connect timeout | TCP handshake | Server sập, firewall nuốt gói |
| TLS timeout | Bắt tay TLS | Cert hoặc cấu hình sai |
| TTFB timeout | Byte đầu tiên của response | Server nhận nhưng xử lý lâu |
| Idle timeout | Byte tiếp theo giữa stream | Kết nối treo giữa chừng |
| Total timeout | Toàn bộ | Chốt chặn cuối cùng |
Sai lầm phổ biến nhất: chỉ đặt total timeout. Server treo hoàn toàn thì phải chờ hết total timeout mới biết, trong khi connect timeout đã đủ phát hiện trong 1 giây.
Timeout phía server Node
server.headersTimeout = 65000; // chờ nhận đủ header
server.requestTimeout = 300000; // chờ nhận đủ request
server.keepAliveTimeout = 61000; // giữ kết nối idle bao lâu
Bẫy 502 kinh điển sau load balancer
Đây là lỗi mọi đội deploy Node sau ALB/ELB đều gặp ít nhất một lần:
Node đóng kết nối idle ở giây thứ 5 (mặc định)
↓
ALB có idle timeout 60 giây nên vẫn giữ kết nối đó trong pool
↓
ALB đẩy request mới vào một kết nối đã chết
↓
Client nhận 502, log của Node hoàn toàn sạch
Triệu chứng đặc trưng: 502 rải rác, tỉ lệ nhỏ, không tái hiện được, và không có gì trong log ứng dụng — vì request chưa bao giờ tới được Node.
Nguyên tắc sửa: bên phía sau phải giữ kết nối lâu hơn bên phía trước.
keepAliveTimeout của Node > idle timeout của LB
headersTimeout > keepAliveTimeout
Timeout budget
Timeout phải giảm dần khi đi sâu vào chuỗi service. Ngược lại là vô nghĩa:
Client 10s
└── API 8s
└── Service A 5s
└── Database 2s
Nếu database timeout 10 giây trong khi client chỉ chờ 5 giây, thì 5 giây cuối server làm việc cho một người đã bỏ đi — vẫn chiếm connection pool, vẫn chiếm bộ nhớ, hoàn toàn lãng phí.
Retry đúng cách
Ba quy tắc, thiếu cái nào cũng gây hại:
1. Chỉ retry cái an toàn để lặp lại.
| Có thể retry | Cân nhắc kỹ |
|---|---|
| GET, HEAD, PUT, DELETE | POST |
| Connect timeout (chưa tới server) | TTFB timeout (server có thể đã xử lý rồi) |
| 502, 503, 504 | 500 (có thể đã thay đổi dữ liệu) |
Với POST, dùng idempotency key: client sinh một khoá duy nhất, server nhớ khoá đó và trả về kết quả cũ nếu thấy lặp lại. Đây là cách duy nhất retry POST mà an toàn.
2. Backoff theo cấp số nhân, kèm jitter.
const delay = Math.min(1000 * 2 ** attempt, 30000);
const jitter = delay * (0.5 + Math.random() * 0.5); // 50–100% của delay
Không có jitter, mọi client cùng retry đúng một thời điểm — hệ thống vừa hồi phục đã bị đánh sập lại. Hiện tượng này gọi là thundering herd.
3. Giới hạn tổng số retry đang chạy.
Service quá tải mà mọi client cùng retry 3 lần là tự nhân ba tải lên đúng lúc nó yếu nhất. Circuit breaker giải quyết chuyện này: đếm tỉ lệ lỗi, vượt ngưỡng thì ngừng gọi hẳn trong một khoảng thời gian, rồi thử lại dè dặt.
Bình thường ──lỗi vượt ngưỡng──▶ Ngắt (fail nhanh, không gọi)
▲ │
│ hết thời gian chờ
│ ▼
└────── thử thành công ──────── Thăm dò (cho qua vài request)
Bảng tra nhanh
Chẩn đoán
ping host # RTT thô
mtr host # traceroute liên tục, thấy chặng nào mất gói
dig +trace domain # toàn bộ chuỗi phân giải DNS
dig domain # kết quả + TTL
ss -tanp # socket TCP + process
ss -ti # kèm rtt, cwnd
ss -tan state time-wait | wc -l # đếm TIME_WAIT
lsof -i :3000 # ai giữ port
Đo từng chặng
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"
curl -v --http1.1 https://api.example.com # ép phiên bản để so sánh
curl -v --http2 https://api.example.com
curl -I https://api.example.com # chỉ header
TLS
openssl s_client -connect host:443 -servername host # xem chuỗi cert
# hạn dùng + chủ sở hữu
echo | openssl s_client -connect host:443 2>/dev/null \
| openssl x509 -noout -dates -subject
Con số cần thuộc
TCP handshake 1 RTT
TLS 1.2 handshake 2 RTT → tổng 3 RTT
TLS 1.3 handshake 1 RTT → tổng 2 RTT
TCP cwnd khởi tạo ~14 KB → response nhỏ hơn đi trọn vòng đầu
Thread pool libuv 4 luồng → dns.lookup nằm ở đây
TIME_WAIT trên Linux ~60 giây
Học tiếp
Đọc tiếp HPBN: phần II (hiệu năng mạng không dây) giải thích vì sao latency 4G/5G có sàn cứng do cơ chế cấp phát sóng, không phải do băng thông.
Thực hành có giá trị nhất: bật tcpdump trên chính máy dev, bắt một request HTTPS, mở bằng Wireshark và chỉ đúng ra ba gói SYN/SYN-ACK/ACK rồi tới ClientHello. Nhìn thấy tận mắt một lần bằng đọc mười bài viết.
sudo tcpdump -i any -w capture.pcap host api.example.com
Tiếp theo: dựng Nginx làm reverse proxy trước app Node, cố tình đặt keepAliveTimeout của Node thấp hơn idle timeout của Nginx, rồi bắn tải cho tới khi thấy 502 xuất hiện. Tự tay tạo ra lỗi ở chương 10 rồi tự sửa.
Sau đó học observability mạng: distributed tracing, và cách đọc biểu đồ phân bố latency theo phân vị. p50 đẹp mà p99 xấu gần như luôn là vấn đề mạng hoặc hàng đợi, hiếm khi là vấn đề thuật toán.
Tài nguyên
| Nguồn | Dùng khi |
|---|---|
| hpbn.co | Bản online miễn phí của HPBN |
| Wireshark | Nhìn tận mắt từng gói tin |
curl -w | Tách latency theo từng chặng, dùng hằng ngày |
| ssllabs.com/ssltest | Chấm điểm cấu hình TLS của server |
| mtr | Tìm chặng mạng gây mất gói |
| undici docs | HTTP client và connection pool trong Node |