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

Networking — Nền tảng

5 tháng 9, 2026

Mục lục

  1. Latency — thứ quyết định mọi thứ
  2. TCP
  3. UDP
  4. DNS
  5. TLS
  6. HTTP/1.1
  7. HTTP/2
  8. HTTP/3 và QUIC
  9. Connection pool trong Node
  10. 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ị
BandwidthBao nhiêu byte đi qua được trong 1 giâyMbps
LatencyMột gói tin đi từ A tới B mất bao lâums

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ếnRTT thực tế
Cùng datacenter0.5 – 1 ms
Cùng vùng (HCM ↔ HCM)5 – 15 ms
HCM ↔ Singapore30 – 50 ms
HCM ↔ US East200 – 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-aliveHTTP keep-alive
TầngTransportỨng dụng
Làm gìGửi gói dò để phát hiện kết nối chếtGiữ kết nối mở để gửi request tiếp theo
Trong Nodesocket.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.

TCPUDP
Bắt tay1 RTTKhông
Đảm bảo tới nơiCóKhông
Đúng thứ tựCóKhông
Head-of-lineCóKhông
Dùng choAPI, 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 để ý:

  1. 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.
  2. 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).
  3. 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

RecordTrả vềGhi chú
AIPv4Phổ biến nhất
AAAAIPv6Node thử cả hai, thứ tự tùy cấu hình
CNAMETên miền khácThêm một vòng phân giải nữa
MXMail server
TXTText tự doSPF, DKIM, xác thực sở hữu domain
NSName serverAi 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 OSTự gửi query qua c-ares
Chạy ở đâuThread pool của libuvBất đồng bộ thật, không tốn thread
Đọc /etc/hostsCóKhông
Ai dùnghttp, https, net — tức là mọi thư viện HTTPChỉ 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ệcNghĩa là
Mã hoáNgười ở giữa đọc được gói tin nhưng không hiểu
Toàn vẹnSửa một byte trên đường là bị phát hiện
Xác thựcChứ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ảnTCPTLSTổng
TLS 1.2123 RTT
TLS 1.3112 RTT
TLS 1.3 nối lại phiên (0-RTT)101 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 -servername khi test bằng openssl sẽ 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ỗiNguyên nhân thật
UNABLE_TO_VERIFY_LEAF_SIGNATUREServer thiếu intermediate cert
DEPTH_ZERO_SELF_SIGNED_CERTCert tự ký (thường gặp ở môi trường nội bộ)
CERT_HAS_EXPIREDHết hạn, hoặc đồng hồ máy sai
ERR_TLS_CERT_ALTNAME_INVALIDDomain đ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.1HTTP/2
Định dạngTextBinary frame
Song songNhiều kết nốiMột kết nối, nhiều stream
HeaderGửi lại đầy đủ mỗi lầnNén bằng HPACK
Ưu tiênKhô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 http tí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ùng undici hoặ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 Hints hoặ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ạiChờ cái gìHay hỏng khi
DNS timeoutPhân giải tên miềnResolver chết
Connect timeoutTCP handshakeServer sập, firewall nuốt gói
TLS timeoutBắt tay TLSCert hoặc cấu hình sai
TTFB timeoutByte đầu tiên của responseServer nhận nhưng xử lý lâu
Idle timeoutByte tiếp theo giữa streamKết nối treo giữa chừng
Total timeoutToà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ể retryCân nhắc kỹ
GET, HEAD, PUT, DELETEPOST
Connect timeout (chưa tới server)TTFB timeout (server có thể đã xử lý rồi)
502, 503, 504500 (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ồnDùng khi
hpbn.coBản online miễn phí của HPBN
WiresharkNhìn tận mắt từng gói tin
curl -wTách latency theo từng chặng, dùng hằng ngày
ssllabs.com/ssltestChấm điểm cấu hình TLS của server
mtrTìm chặng mạng gây mất gói
undici docsHTTP client và connection pool trong Node

Tham khảo

  • 1.High Performance Browser Networking
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