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.

Ảnh chụp đoạn mã Go nền tối minh hoạ socket buffer và listen backlog, buffer nhận gửi flow control chặn người gửi mỗi socket có buffer nhận cộng gửi trong kernel bên đọc chậm buffer nhận đầy TCP báo cửa sổ 0 bên gửi phải dừng Write block flow control tc SetReadBuffer n SO_RCVBUF buffer nhận tc SetWriteBuffer n SO_SNDBUF buffer gửi khác UDP bài 2 mất gói TCP không mất nó chặn nguồn, đo flow control server không đọc client gửi tới khi bị chặn go func c ln Accept không đọc time Sleep 3 giây c net Dial tcp addr for c SetWriteDeadline 400ms n err c Write chunk total cộng n if err break timeout bằng đã bị chặn buffer đầy, listen backlog hàng đợi kết nối đã bắt tay chờ Accept kết nối bắt tay xong nằm trong hàng đợi tới khi code gọi Accept hàng đầy bằng somaxconn kết nối mới bị drop từ chối ln net Listen tcp addr backlog bằng min somaxconn không Accept mở nhiều kết nối tới khi hàng đầy đo con số

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:

Ảnh chụp bảng kết quả chạy thật socket buffer backlog output thật, một flow control server không đọc client gửi tới khi bị chặn SO_RCVBUF 4096 SO_SNDBUF 4096 gửi được 45029 byte 44 KB rồi bị chặn SO_RCVBUF 65536 SO_SNDBUF 65536 gửi được 275456 byte 269 KB rồi bị chặn buffer càng lớn gửi được càng nhiều trước khi chặn khác UDP mất gói TCP chặn nguồn khi buffer đầy flow control, hai listen backlog server không Accept mở kết nối tới khi bị chặn mở được 4097 kết nối rồi bị chặn timeout somaxconn 4096 hàng đợi Accept đầy đúng ở somaxconn kết nối mới bị drop backlog thực tế bằng min backlog của Listen net.core.somaxconn, kết luận buffer nhỏ chặn sớm giới hạn throughput buffer lớn chứa nhiều dữ liệu đang bay throughput cao hơn backlog nhỏ cộng server Accept chậm rớt kết nối lúc tải đỉnh chỉnh SetReadBuffer SetWriteBuffer cộng net.core.somaxconn server chấp nhận kết nối chậm Accept trễ thủ phạm rớt kết nối thật

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 Write bị 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ỉ Listen mà không Accept, client mở được đúng 4097 kết nối rồi bị chặn — khớp somaxconn=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ề

  1. 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 Write bị 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.
  2. 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ư.
  3. Hai giới hạn thường là triệu chứng, không phải nguyên nhân: Write chậ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ỉnh SetReadBuffer/somaxconn chỉ mua thời gian, sửa gốc ở tầng ứng dụng (Accept nhanh, giao việc cho worker).

Nguồn

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.