Khi hai tiến trình chạy trên cùng một máy cần nói chuyện — ứng dụng với cơ sở dữ liệu, nginx với backend, một dịch vụ với Redis — bạn có hai lựa chọn phổ biến: một Unix domain socket (kết nối qua một tệp như /tmp/app.sock), hay một socket TCP qua loopback (127.0.0.1:cổng). Cả hai đều không ra khỏi máy, nhưng chúng đi hai con đường khác nhau trong nhân. Câu hỏi thực tế: chọn cái nào nhanh hơn, và nhanh hơn bao nhiêu? Tôi đo trực tiếp trong container — và cả hai kỳ vọng tôi mang vào đều bị phép đo bác bỏ.

Hai cách nói chuyện nội máy

Một Unix domain socket là cơ chế liên lạc giữa các tiến trình (IPC) thuần túy trong nhân: hai đầu trao dữ liệu cho nhau qua bộ đệm trong nhân, không có địa chỉ IP, không có header TCP hay IP, không checksum, không máy trạng thái kết nối phức tạp. Nó về bản chất là một đường ống hai chiều có tên.

Một socket TCP qua loopback thì vẫn là TCP đầy đủ: dữ liệu được đóng thành segment TCP, gắn header, đi qua toàn bộ ngăn xếp TCP/IP của nhân — chỉ khác là ở cuối, thay vì ra card mạng thật, nó vòng lại qua giao diện loopback lo. Nghĩa là nó gánh toàn bộ logic của TCP (đánh số thứ tự, cửa sổ, ACK, kiểm soát tắc nghẽn) dù không có một mét cáp nào.

Trực giác nói: Unix socket bỏ qua cả cái ngăn xếp đó, nên phải nhanh hơn hẳn. Tôi vào đo để xác nhận, và mang theo con số kỳ vọng "nhanh hơn cả chục lần". Cái tôi mang ra khỏi bài đo lại là một con số khiêm tốn hơn nhiều — và một bài học về việc đừng tin trực giác của chính mình.

Đo: Unix nhanh hơn, nhưng chỉ khoảng rưỡi

Tôi dựng một echo server và cho client ping-pong 3000 vòng với tin nhắn 64 byte, lấy trung vị độ trễ mỗi vòng (micro giây):

Cách nối Độ trễ (trung vị)
Unix socket 18,8 µs
TCP loopback + NODELAY 30,2 µs
TCP loopback (Nagle bật, ghi 1 lần) 29,3 µs
TCP loopback (Nagle bật, ghi 2 lần) 29,4 µs

Unix socket nhanh hơn TCP loopback, nhưng chỉ khoảng 1,6 lần (18,8 so với ~30 µs) — không phải chục lần. Và về thông lượng, khi chuyển 200 MB:

Unix socket : 5403 MB/s
TCP loopback: 4162 MB/s   (Unix nhanh ~1,3 lần)

Cũng chỉ nhanh hơn ~1,3 lần. Cả hai khác biệt đều thật và đo được, nhưng khiêm tốn hơn nhiều so với con số tôi tưởng tượng.

Hai kỳ vọng, hai lần trượt

Tôi bước vào bài này với hai niềm tin, và phép đo bác cả hai.

Kỳ vọng một: "Unix bỏ qua ngăn xếp mạng nên phải nhanh hơn cả chục lần". Đo ra chỉ ~1,5 lần. Lý do nằm ở chữ loopback. TCP qua loopback vốn đã được nhân rút gọn gần hết những chi phí mà người ta hay gán cho "TCP": không có card mạng, không có truyền vật lý, không có độ trễ lan truyền, và trên loopback nhân thường bỏ qua cả checksum vì dữ liệu không rời khỏi bộ nhớ. Nên "TCP loopback" không hề gánh phần lớn cái giá của TCP thật ngoài mạng. Phần mà Unix socket tiết kiệm thêm — dựng header, chạy máy trạng thái — là có thật nhưng nhỏ, chỉ vài micro giây. Tôi đã tưởng tượng Unix đang tránh một ngăn xếp đắt đỏ, trong khi thực ra loopback đã tự tránh gần hết rồi.

Kỳ vọng hai: "Nagle sẽ làm TCP loopback treo, khuếch đại khác biệt". Tôi còn chủ động giăng bẫy: thêm một phép đo trong đó client ghi tin nhắn bằng hai lần send() (header rồi thân) với Nagle bật, hòng tái hiện cú treo 40 mili giây kinh điển của Nagle cộng delayed-ACK. Kết quả: 29,4 µs — y hệt như ghi một lần. Không có cú treo nào. Thú thật lúc đó tôi hơi cụt hứng — đã dựng sẵn cả một cái bẫy công phu mà nó không thèm sập.

Vì sao bẫy không sập? Vì một ping-pong chặt chỉ có đúng một tin bay giữa hai bên tại mỗi thời điểm: client gửi, rồi chờ trả lời trước khi gửi tiếp. Khi server echo lại, gói trả lời mang luôn ACK về — nên client không bao giờ rơi vào cảnh "gửi thêm dữ liệu trong khi đang chờ một ACK bị trì hoãn". Cú treo Nagle+delayed-ACK cần một kiểu tải rất riêng: ghi nhiều mẩu nhỏ dồn dập không chờ trả lời, chứ không phải mọi lần dùng TCP. Tôi đã nhớ đúng cơ chế Nagle nhưng gán sai điều kiện kích hoạt nó.

Điều rút ra: hai trực giác nghe rất hợp lý — "bỏ ngăn xếp thì nhanh vọt" và "Nagle luôn rình treo" — đều đếm thừa khi gặp thực tế cụ thể. Loopback đã bỏ sẵn phần lớn ngăn xếp, nên cái Unix tiết kiệm thêm là nhỏ; và Nagle chỉ cắn đúng kiểu tải của nó, không phải mọi kết nối TCP. Đo trên chính tình huống mình quan tâm, đừng ngoại suy từ một câu chuyện chung.

Vì sao điều này quan trọng khi lập trình

Điều đầu tiên là dùng Unix socket cho liên lạc nội máy khi có thể, vì nó rẻ hơn và còn an toàn hơn — nhưng đừng kỳ vọng phép màu tốc độ. ~1,5 lần độ trễ và ~1,3 lần thông lượng là lợi ích thật, đáng lấy khi hai bên chắc chắn ở cùng máy (ứng dụng với PostgreSQL/MySQL/Redis cục bộ đều hỗ trợ Unix socket). Nhưng nếu ai đó hứa "chuyển sang Unix socket sẽ nhanh gấp mười", hãy hoài nghi — với loopback, phần lớn chi phí bạn định tránh vốn đã không tồn tại. Lợi ích lớn nhất của Unix socket thường không phải tốc độ, mà là bảo mật và đơn giản: nó dùng quyền tệp của hệ thống để kiểm soát ai kết nối được, và không mở một cổng mạng nào ra ngoài để lỡ bị truy cập từ xa.

Điều thứ hai là hiểu vì sao độ trễ nội máy đo bằng micro giây, còn mạng thật đo bằng mili giây. Cả hai con số ở đây (~19 và ~30 µs) đều nhỏ hơn một nghìn lần so với một RTT mạng LAN điển hình (~0,5 ms) và nhỏ hơn chục nghìn lần so với một RTT Internet (~50 ms, như các phần đầu sê-ri đã đo). Nghĩa là: nếu hai dịch vụ nói chuyện rất nhiều, đặt chúng cùng máy (Unix socket hay loopback đều được) thắng đậm so với tách ra hai máy — không phải vì chọn loại socket khéo, mà vì tránh được cả một chuyến đi mạng. Chọn Unix hay loopback là tinh chỉnh vài micro giây; đặt cùng máy hay khác máy là khác biệt cả nghìn lần.

Điều cuối là một nếp đo lường lặp lại suốt sê-ri: một con số "nghe hợp lý" không thay được một phép đo, và một phép đo phải chạy đúng điều kiện bạn quan tâm. Gói lại bằng số: cùng máy, Unix socket nhanh hơn TCP loopback ~1,6 lần độ trễ (18,8 so 30,2 µs) và ~1,3 lần thông lượng (5403 so 4162 MB/s) — thật nhưng khiêm tốn, vì loopback đã lược bỏ phần lớn ngăn xếp; và Nagle không làm treo một ping-pong chặt vì kiểu tải đó không kích hoạt nó. Chọn socket theo bảo mật và ngữ cảnh trước, theo vài micro giây sau.

Cái phản xạ "đo đi đừng đoán" này áp thẳng vào các ngôn ngữ cấp cao mà bạn dùng hằng ngày, vì chúng đều để lộ đúng hai con đường này. Trong Java, một SocketChannel tới 127.0.0.1 là loopback, còn JDK 16+ đã thêm hẳn UnixDomainSocketAddress để bạn nối qua Unix socket — và cùng lời khuyên: đổi sang nó là một cải thiện nhỏ về tốc độ nhưng một cải thiện lớn về bảo mật (không mở cổng ra ngoài). Go phân biệt hai con đường chỉ bằng tham số net.Dial: net.Dial("unix", "/tmp/app.sock") so với net.Dial("tcp", "127.0.0.1:8080") — cùng interface net.Conn, chỉ khác chuỗi mạng. Cơ sở dữ liệu là nơi lựa chọn này thường mặc định đúng mà ta không để ý: psql không có -h sẽ tự nối qua Unix socket, và nhiều hướng dẫn "tăng tốc Postgres" khuyên dùng socket — giờ bạn biết cái lợi thật đến từ đâu (một chút tốc độ, và bỏ được lớp xác thực qua mạng), chứ không phải một phép màu chục lần. Mẫu số chung: khoảng cách vật lý giữa hai bên (cùng máy hay khác máy) quyết định độ lớn thật của độ trễ; loại socket chỉ là tinh chỉnh bên trong cái khoảng cách đó — nên câu hỏi kiến trúc đáng giá luôn là "hai thứ này có cần ở cùng máy không", còn "Unix hay TCP" chỉ là chi tiết vặn sau.

Muốn tự thấy thì nếu bạn dùng PostgreSQL, MySQL hay Redis cục bộ, thử cả hai đường kết nối và cảm nhận. Với Postgres: psql -h /var/run/postgresql (đường dẫn Unix socket) so với psql -h 127.0.0.1 (TCP loopback) — cùng một cơ sở dữ liệu, hai con đường. Với redis-benchmark, cờ -s /tmp/redis.sock (Unix) so với -h 127.0.0.1 -p 6379 (TCP) sẽ in ra số lệnh mỗi giây cho từng loại; bạn sẽ thấy Unix nhỉnh hơn một chút — nhưng chỉ một chút, đúng như bài này đo. Và nếu chuỗi kết nối của bạn đang trỏ 127.0.0.1 tới một dịch vụ chắc chắn cùng máy, đổi sang Unix socket là một cải thiện nhỏ và miễn phí — vừa nhanh hơn tí, vừa bớt một cổng mạng hở ra ngoài.