Khi một dịch vụ TCP "nghẽn", nguyên nhân thường không nằm ở code ứng dụng mà ở hai hàng đợi trong kernel mà ít lập trình viên nhìn tới: socket buffer (nhận/gửi) và listen backlog. Buffer quyết định giữ được bao nhiêu dữ liệu chưa xử lý; backlog quyết định chịu được bao nhiêu kết nối tới dồn dập trước khi rớt. Hiểu hai cái này giải thích được rất nhiều sự cố: throughput thấp bất thường, kết nối bị từ chối lúc cao điểm. Bài này (phần 4 loạt Mạng) chạy thật để thấy chính xác chúng gây nghẽn khi nào.
Cơ chế: buffer chặn người gửi, backlog chặn kết nối
Socket buffer: mỗi kết nối TCP có một buffer nhận và một buffer gửi trong kernel. Khi bên đọc chậm, buffer nhận đầy dần; TCP báo về "cửa sổ 0" (zero window), và bên gửi phải dừng — Write bị chặn cho tới khi có chỗ. Đây là flow control, và là điểm khác cốt lõi với UDP (phần 2): UDP đầy buffer thì mất gói, còn TCP thì chặn nguồn để không mất.
tc.SetReadBuffer(n) // SO_RCVBUF: kích thước buffer nhận
tc.SetWriteBuffer(n) // SO_SNDBUF: kích thước buffer gửi
Listen backlog: khi một kết nối hoàn tất bắt tay 3 bước, nó vào một hàng đợi chờ ứng dụng gọi Accept(). Nếu ứng dụng chấp nhận chậm hơn tốc độ kết nối tới, hàng này đầy — và kết nối mới bị drop/từ chối. Kích thước hàng bị chặn bởi net.core.somaxconn của hệ thống.

Hình 1: Buffer nhận đầy → TCP báo cửa sổ 0 → chặn người gửi (flow control, khác UDP mất gói); listen backlog là hàng đợi kết nối đã bắt tay chờ Accept, đầy thì rớt kết nối mới.
Đo thật: flow control và giới hạn backlog
Mình dựng hai thí nghiệm: (1) server không đọc, client gửi tới khi bị chặn; (2) server không Accept, client mở kết nối tới khi backlog đầy:

Hình 2: Chạy thật — flow control: buffer 4096 → client gửi 44KB rồi bị chặn, buffer 65536 → 269KB rồi bị chặn (khác UDP: TCP chặn nguồn); backlog: mở được đúng 4097 kết nối rồi bị chặn (somaxconn=4096).
Đọc kết quả đo được:
- Flow control chặn người gửi, không mất dữ liệu: khi server không đọc, client gửi được 44KB (buffer nhỏ 4096) hoặc 269KB (buffer 65536) rồi
Writebị chặn (timeout). Lượng gửi được tỉ lệ với kích thước buffer. Đây chính là điểm khác UDP: TCP không mất một byte nào — nó dừng nguồn lại. Lưu ý con số lớn hơn buffer đặt vì kernel auto-tune và cộng cả buffer hai đầu, nhưng quan hệ "buffer lớn hơn → chứa nhiều hơn" là rõ ràng. - Backlog đầy đúng ở somaxconn: server chỉ
Listenmà khôngAccept, client mở được đúng 4097 kết nối rồi bị chặn — khớpsomaxconn=4096(cộng một khe). Khi hàng đợi Accept đầy, kết nối mới bị drop. Đây là lý do một server chấp nhận kết nối chậm (Accept trễ vì bận xử lý) sẽ rớt kết nối lúc tải đỉnh, dù CPU và mạng vẫn còn dư.
Đánh đổi cần cân nhắc
Buffer lớn tăng throughput nhưng tốn RAM và tăng độ trễ hàng đợi. Buffer nhận/gửi lớn cho phép nhiều dữ liệu "đang bay" cùng lúc, cần thiết để đạt throughput cao trên đường có độ trễ lớn (bài băng thông–độ trễ sau sẽ đo). Nhưng mỗi kết nối nhân với buffer lớn = nhiều RAM; và buffer quá lớn có thể gây bufferbloat — dữ liệu xếp hàng lâu làm tăng độ trễ. Linux mặc định auto-tune buffer khá tốt; chỉ chỉnh tay khi đo được nó là nút cổ chai.
Backlog nhỏ không phải lúc nào cũng xấu — nhưng Accept chậm thì luôn xấu. Tăng somaxconn giúp hàng đợi chịu được đợt kết nối dồn dập (burst), nhưng nó chỉ hoãn vấn đề nếu server không kịp Accept. Nguyên nhân gốc của rớt kết nối thường là vòng lặp Accept bị chặn — ví dụ xử lý request đồng bộ ngay trong luồng Accept. Cách đúng: Accept thật nhanh rồi giao việc cho goroutine/worker (Go làm điều này tự nhiên), giữ hàng đợi luôn thoáng.
Hai giới hạn này tương tác với nhau và với tầng trên. Buffer đầy (flow control) là tín hiệu bên nhận xử lý chậm — nếu bạn thấy Write chậm, đừng vội tăng buffer, hãy hỏi vì sao bên kia đọc chậm. Backlog đầy là tín hiệu server Accept không kịp. Cả hai thường là triệu chứng của một nút cổ chai ở tầng ứng dụng, không phải nguyên nhân — chỉnh tham số kernel chỉ mua thêm thời gian.
Ba ý mang về
- Buffer đầy thì TCP chặn người gửi (flow control), khác UDP mất gói: đo thật client gửi được 44KB (buffer nhỏ) hay 269KB (buffer lớn) rồi
Writebị chặn — TCP không mất byte nào, nó dừng nguồn lại; buffer lớn hơn cho throughput cao hơn nhưng tốn RAM. - Backlog là hàng đợi kết nối chờ Accept, đầy thì rớt kết nối: đo thật mở được đúng 4097 kết nối rồi bị chặn (somaxconn=4096) — server Accept chậm sẽ rớt kết nối lúc tải đỉnh dù CPU/mạng còn dư.
- Hai giới hạn thường là triệu chứng, không phải nguyên nhân:
Writechậm → hỏi vì sao bên nhận đọc chậm; kết nối rớt → hỏi vì sao Accept không kịp; chỉnhSetReadBuffer/somaxconnchỉ mua thời gian, sửa gốc ở tầng ứng dụng (Accept nhanh, giao việc cho worker).
Nguồn
- man7.org — listen(2) và backlog: https://man7.org/linux/man-pages/man2/listen.2.html
- Kernel docs — TCP buffer tuning (net.ipv4.tcp_rmem/wmem): https://docs.kernel.org/networking/ip-sysctl.html
- Go docs — net.TCPConn.SetReadBuffer/SetWriteBuffer: https://pkg.go.dev/net#TCPConn.SetReadBuffer
Phần sau ta lên tầng HTTP: keep-alive — tái dùng một kết nối TCP cho nhiều request thay vì mở mới mỗi lần; đo thật số kết nối TCP thực sự mở ra với và không có keep-alive, nối tiếp câu chuyện bắt tay tốn kém ở bài 1.