Có một loại lỗi hiệu năng khiến kỹ sư mất hàng giờ gãi đầu: một dịch vụ request-response bỗng chậm đúng ~40ms mỗi lần gọi, không phải do CPU, không phải do mạng, không phải do database. Thủ phạm là hai tối ưu TCP hoàn toàn hợp lý — thuật toán Nagle và delayed ACK — nhưng khi gặp nhau chúng chờ lẫn nhau và tạo ra một khoảng dừng cố định 40ms. Bài này (phần 3 loạt Mạng) chạy thật để bắt tận tay cú trễ này và hiểu cách tránh.
Cơ chế: hai tối ưu tốt gặp nhau thành xấu
- Nagle (phía gửi): nếu còn dữ liệu nhỏ chưa được ACK, đừng gửi gói nhỏ mới — hãy giữ lại và gom, để không phí một header TCP 20+ byte cho vài byte dữ liệu. Ý tốt: gộp nhiều mẩu tí hon thành một gói.
- Delayed ACK (phía nhận): đừng gửi ACK ngay — trì hoãn tới ~40ms để có thể gộp ACK vào gói dữ liệu trả về, tiết kiệm một gói ACK riêng. Cũng là ý tốt.
Vấn đề xảy ra khi cả hai cùng bật trong mẫu write-write-read: bên gửi giữ gói thứ hai để chờ ACK gói thứ nhất; bên nhận lại giữ ACK để chờ dữ liệu trả về. Hai bên chờ nhau cho tới khi bộ đếm delayed-ACK hết hạn — ~40ms trôi qua vô ích.
c.Write(mau1) // gói 1 gửi ngay
c.Write(mau2) // Nagle GIỮ: mau1 chưa được ACK -> chờ
c.Read(reply) // server nhận mau1, delayed-ACK giữ ~40ms rồi mới ACK
// -> mau2 kẹt tới khi ACK về -> mỗi vòng dính ~40ms
Cách tắt Nagle là đặt TCP_NODELAY — trong Go là SetNoDelay(true). Điều đáng chú ý: Go mặc định đã bật NoDelay (tức tắt Nagle), vì phần lớn ứng dụng Go là request-response cần độ trễ thấp.
tc := c.(*net.TCPConn)
tc.SetNoDelay(true) // TẮT Nagle -> gửi ngay (mặc định của Go)
tc.SetNoDelay(false) // BẬT Nagle -> gom gói nhỏ

Hình 1: Nagle (giữ gói nhỏ chờ ACK) gặp delayed ACK (giữ ACK chờ dữ liệu) tạo stall ~40ms trong mẫu write-write-read; SetNoDelay(true) tắt Nagle — Go bật sẵn mặc định.
Đo thật: bắt tận tay cú trễ 40ms
Mình dựng một mẫu write-write-read (gửi hai mẩu 4 byte rồi chờ hồi) và đo với Nagle bật vs tắt:

Hình 2: Chạy thật — mẫu write-write-read: Nagle bật (SetNoDelay=false) dính 40,918ms/vòng, Nagle tắt (SetNoDelay=true, mặc định Go) chỉ 5µs/vòng — chậm hơn ~8769 lần; 40ms đúng bằng bộ đếm delayed-ACK của Linux.
Đọc kết quả đo được:
- Cú trễ 40ms có thật và đo được ngay trên localhost: với Nagle bật, mỗi vòng request-response dính 40,918ms — con số này không phải ngẫu nhiên, nó đúng bằng bộ đếm delayed-ACK mặc định của Linux (~40ms). Nhiều người tưởng cú trễ này chỉ xuất hiện trên mạng thực; thực ra chỉ cần đúng mẫu write-write-read là tái hiện được cả trên loopback.
- Tắt Nagle: 5µs/vòng — nhanh hơn ~8769 lần: khi
SetNoDelay(true), gói thứ hai được gửi ngay lập tức, không chờ ACK, nên không có stall. Chênh lệch gần 9000 lần cho thấy đây không phải tối ưu vi mô mà là khác biệt sống-còn cho hệ nhạy độ trễ. - Nguyên nhân gốc là mẫu write-write-read: hai lời
Writeliên tiếp rồi mớiRead. Nếu bạn gộp dữ liệu thành mộtWrite(dùngbufio.Writerhaynet/httpvốn đã gộp), Nagle không có gì để giữ và cú trễ biến mất — kể cả khi Nagle bật.
Đánh đổi cần cân nhắc
Sửa gốc là gộp ghi, không chỉ tắt Nagle. Tắt Nagle (SetNoDelay(true)) chữa triệu chứng, nhưng nguyên nhân thật thường là code ghi dữ liệu thành nhiều mẩu nhỏ rời rạc. Cách sạch hơn: gom một thông điệp hoàn chỉnh vào một buffer rồi ghi một lần (bufio.Writer + Flush). Vừa tránh Nagle, vừa giảm số syscall write. net/http của Go đã làm việc này nên request HTTP thường không dính bẫy.
Nagle không phải kẻ xấu — nó đúng cho một loại tải khác. Đừng vội tắt Nagle ở mọi nơi. Với ứng dụng ghi liên tục nhiều mẩu nhỏ mà không cần độ trễ thấp từng gói (ví dụ đẩy log, ghi dữ liệu dạng luồng mà bên nhận không chờ từng mẩu), Nagle gom chúng thành ít gói hơn, tiết kiệm băng thông và giảm tải header. Tắt Nagle ở đó làm tăng số gói nhỏ vô ích. Chọn theo mẫu giao tiếp, không theo mặc định máy móc.
Delayed ACK ở phía server, bạn không luôn điều khiển được. Cú trễ này là tương tác giữa Nagle (phía bạn) và delayed ACK (phía kia). Bạn thường chỉ chỉnh được phía mình (tắt Nagle hoặc gộp ghi). Trên Linux có TCP_QUICKACK để giảm delayed ACK nhưng nó không bền (kernel reset lại). Vì vậy hướng thực dụng nhất luôn là: sửa mẫu ghi ở ứng dụng của bạn.
Ba ý mang về
- Nagle + delayed ACK tạo cú trễ đúng ~40ms trong mẫu write-write-read: đo thật Nagle bật dính 40,918ms/vòng vs tắt 5µs/vòng (~8769 lần) — tái hiện ngay trên localhost, 40ms đúng bằng bộ đếm delayed-ACK của Linux.
- Go mặc định tắt Nagle (
SetNoDelay=true): nên đa số ứng dụng Go không gặp bẫy; chỉ khi bạn cố bật Nagle hoặc dùng ngôn ngữ/socket khác mặc định bật, cộng mẫu write-write-read, mới dính. - Sửa gốc là gộp ghi thành một Write, và biết khi nào Nagle có ích: dùng
bufio.Writergộp thông điệp rồi mới đọc; đừng tắt Nagle máy móc — nó tiết kiệm header cho tải ghi-liên-tục-nhiều-mẩu-nhỏ không cần độ trễ thấp.
Nguồn
- RFC 896 — Congestion Control (thuật toán Nagle): https://www.rfc-editor.org/rfc/rfc896.html
- Go docs — net.TCPConn.SetNoDelay: https://pkg.go.dev/net#TCPConn.SetNoDelay
- John Nagle — giải thích trên Hacker News về Nagle vs delayed ACK: https://news.ycombinator.com/item?id=10608356
Phần sau ta xuống tầng đệm: socket buffer và listen backlog — SO_RCVBUF/SO_SNDBUF quyết định giữ được bao nhiêu dữ liệu chưa đọc, và hàng đợi backlog quyết định chịu được bao nhiêu kết nối tới dồn dập trước khi từ chối; đo thật khi nào chúng gây nghẽn.