1. 域套接字和本机 IP 收发包:为什么这个问题值得认真捋一遍
先抛出我的结论:很多后端从业者写了好几年服务端代码,"本机通信"这件事一直是糊里糊涂的。一提到 A 进程和 B 进程在同一个机器上交换数据,第一反应就是"走127.0.0.1:8080呗",而对 Unix Domain Socket(域套接字,下文简称 UDS)的了解只停留在"听说过,好像是走文件的那种"。
但实际上,这两种方式底层的收发包流程天差地别,性能差异可以到一倍甚至更多,排查问题的思路也完全不同。我自己就有一次印象深刻的经历:线上一个高并发组件,客户端连接全部打在本机回环地址上,CPU 软中断高得吓人。后来把它切成 UDS,软中断直接降了一半以上,P999 延迟从 8 毫秒掉到了 2 毫秒以内。当时第一反应不是"UDS 好快",而是——为什么走回环地址还会吃到软中断?这个疑问压了我很久,直到把收发路径完整读了一遍内核代码才算彻底明白。
这篇文章就把我梳理的场景完整讲清楚:什么是域套接字,什么是本机 IP 通信,两者在内核里分别走了什么样的收发包流程,各自的适用场景是什么。适合正在做网络编程、中间件开发、容器网络的同学参考。写这篇文章的目标是让你读完以后能画出整条数据路径,并能在实际选型时快速判断该用哪一个。
2. 先搞清楚基础概念:域套接字和本机 IP 到底在说什么
2.1 域套接字(Unix Domain Socket)不是"一种普通 socket"
很多人在第一次接触 UDS 的时候,下意识认为它只是把 TCP 的IP:port换成了"文件路径",其他都一样。这个理解不对。
域套接字是同一台机器上的进程间通信(IPC)机制,它的寻址方式是文件系统路径(例如/tmp/nginx.sock),协议族是AF_UNIX(也叫AF_LOCAL)。它压根不经过网络协议栈,TCP/IP、以太网、路由表——这些一概不涉及。
在 Linux 上,你可以像创建普通 socket 一样创建它:
int fd = socket(AF_UNIX, SOCK_STREAM, 0);或者用 Python 一把梭:
import socket sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.bind("/tmp/myapp.sock") sock.listen(128)关键点:AF_UNIX的收发路径是"用户态 → 内核 unix 协议族处理 → 直接塞进接收进程的 socket 接收队列",中间没有任何 IP 层参与。
2.2 本机 IP 通信(127.0.0.1)绕不开"完整协议栈"
本机 IP 通信,最常见的就是127.0.0.1回环地址(loopback address)。它的核心特征是:数据包会完整构造 TCP/IP 头部,走完整的协议栈收发流程,但物理上不会离开本机。
当你的应用调用connect("127.0.0.1:80")时,内核要做这几件事:分配 socket 结构体、构造 TCP 握手包、走 TCP 状态机、走到 IP 层做路由查询、发现目标是本机回环地址、把包丢给lo这个虚拟网卡、再从这个虚拟网卡上把包"收回来"。对内核来说,这不叫"没出网",这叫"出网了但是又立刻回来了"。
这一点非常反直觉:127.0.0.1在内核里照样走了完整协议栈,包括软中断处理。
2.3 一个容易忽略的事实:本机 IP 不只 127.0.0.1
写代码的时候很多人默认"本机 IP = localhost = 127.0.0.1",实际上本机访问自己的 IP 可以用好几种地址:
| 地址形式 | 说明 | 走什么设备 |
|---|---|---|
127.0.0.1 | 标准的 IPv4 回环地址 | lo回环网卡 |
::1 | IPv6 回环地址 | lo回环网卡 |
本机网卡 IP,如192.168.1.100 | 绑定了局域网地址的物理网卡 | 物理网卡(但仍不出网关) |
0.0.0.0或:: | 通配地址,表示本机所有地址 | 取决于实际路由 |
这些地址在某些情况下行为差异很大。比如你用192.168.1.100访问自己监听的服务,数据包是走物理网卡(比如eth0)的,虽然最后还是回到本机,但路径和lo完全不一样,对抓包工具看到的tcpdump -i any也能观察到区别。
3. 两张图说透收发包流程:同样的"发送",不同的命运
3.1 本机 IP 的收发包完整路径
我把一次curl http://127.0.0.1:8080/的完整流程拆成下面这些环节。这里我假设你已经用socket(AF_INET, SOCK_STREAM, 0)创建了一个 TCP socket。
- 应用层写入:程序调用
send()或write(),数据从用户态缓冲区拷贝到内核态的 socket 发送缓冲区(sk_buff 会被分配)。 - TCP 层处理:内核 TCP 协议栈给数据加上 TCP 头,做分段(MSS),更新滑动窗口、拥塞控制状态。这里也包含 TCP 三次握手——在正式数据发送前,内核已经默默完成握手。
- IP 层处理:TCP 段交给 IP 层,IP 层查路由表(
fib_lookup),发现目标127.0.0.1属于本机回环地址,于是路由决策是"发往 lo 接口"。 - 邻居子系统(ARP/ND):这里对回环地址是个特例——回环接口
lo不需要 ARP 解析,直接构造一个假的邻居条目。但对族内网络地址如192.168.1.1的本机通信,逻辑上会经过邻居表查询。 - 发送到驱动层:
dev_queue_xmit把 skb 推给lo网卡的发送队列。lo网卡的ndo_start_xmit回调函数做的事情极其简单——直接把这个 skb 重新送回到接收路径(netif_rx),不做任何物理发送。 - 软中断接收:
netif_rx把数据包挂到 CPU 的 backlog 队列上,触发软中断(NET_RX_SOFTIRQ)。ksoftirqd或者当前进程在返回用户态前处理软中断。 - 协议栈接收:从 backlog 剥离 skb,二层、IP 层、TCP 层依次处理,TCP 层找到对应的 socket,把数据拷贝到 socket 接收队列。
- 应用层读取:程序调用
recv()/read(),数据从内核 socket 接收队列拷贝到用户态缓冲区。
注意第 5 和第 6 步:即使收发都在本机,依然要经历"发送到网卡设备 → 网卡设备回灌 → 软中断接收"这套完整流程。这就是为什么高并发场景下127.0.0.1会消耗明显 CPU。
3.2 域套接字的收发包完整路径
同样的一个 send,域套接字的路程简洁得多:
- 应用层写入:程序调用
send()/write()到 UDS socket,数据从用户态拷贝到内核态,分配sk_buff(UDS 也用 skb 管理数据)。 - unix 协议族处理:走到
net/unix/af_unix.c的unix_stream_sendmsg。内核根据 socket 的 peer 关系找到接收端的 socket,直接往接收端的 socket 接收队列里挂数据。 - 唤醒接收进程:如果接收端 socket 正在
epoll_wait或者poll上等待,内核会直接唤醒它,把数据状态标记为可读。 - 应用层读取:接收进程
recv()直接从自己的接收队列里拿数据,拷贝到用户态缓冲区。
这里整个路径的关键是:没有 IP 层,没有路由表,没有网卡驱动,没有软中断。ubuffer 从进程 A 的 socket 直接"瞬移"到进程 B 的 socket 上。
3.3 两端收发包流程核心差异对照表
| 对比项 | 本机 IP(127.0.0.1) | 域套接字(UDS) |
|---|---|---|
| 协议族 | AF_INET / AF_INET6 | AF_UNIX(AF_LOCAL) |
| 寻址方式 | IP 地址 + 端口号 | 文件系统路径(或抽象命名空间) |
| TCP/IP 头部 | 完整构造与解析 | 无任何网络层头部 |
| 路由查询 | 需要查路由表 | 无需路由 |
| 网卡参与 | 参与(lo 虚拟网卡) | 不参与 |
| ARP/邻居系统 | 回环特例,基本不触发;本机物理 IP 会触发 | 完全不涉及 |
| 软中断 | 有,发送和接收均触发 NET_RX_SOFTIRQ | 无软中断 |
| 数据拷贝次数 | 常规几次拷贝 + 协议头处理开销 | 更简单的拷贝路径 |
| 粒度 | 字节流无消息边界(SOCK_STREAM)或报文(SOCK_DGRAM) | 同左,也有流/数据报两种 |
| fd 传递能力 | 无法直接传递文件描述符 | 可以通过 SCM_RIGHTS 传递 fd |
| 跨机器能力 | 可以(通过路由出网) | 不行,仅限本机 |
这张表基本就是我选型时的速查卡。每次有人问我"本机通信用 HTTP 还是 UDS",我都会反问一句:你需不需要跨机器?需要,走 TCP;不需要,UDS 更合适。
4. 实操:亲手验证两种模式下的收发包行为和性能差异
4.1 实验环境准备与测试设计
为了把上面的理论拉到真实场景验证,我搭了一个最小实验。环境如下:
- 操作系统:Linux(内核 5.15.x,x86_64)
- CPU:4 核虚拟机,关调频
- 测试工具:自研压测脚本 +
perf+tcpdump
我准备了两组服务端:
- TCP 回环模式:监听
127.0.0.1:19090 - UDS 模式:监听
/tmp/uds_test.sock
客户端分别用同样的数据 Package(128 字节)去压,观察吞吐和延迟。这不是全新生产环境,主要是为了看路径差异带来的可量化差距。
4.2 一段可以直接跑的示例代码
先给出一个 UDS 流式服务端的单文件示例(Go 语言),方便复现:
package main import ( "fmt" "net" "os" ) func main() { sockPath := "/tmp/uds_test.sock" os.Remove(sockPath) // 清理历史残留文件 ln, err := net.Listen("unix", sockPath) if err != nil { panic(err) } defer ln.Close() fmt.Println("UDS listening on", sockPath) for { conn, err := ln.Accept() if err != nil { fmt.Println("accept error:", err) continue } go handleConn(conn) } } func handleConn(conn net.Conn) { defer conn.Close() buf := make([]byte, 4096) for { n, err := conn.Read(buf) if err != nil { return } _, _ = conn.Write(buf[:n]) // echo,原样回写 } }对应 TCP 模式的监听只需要把net.Listen("unix", ...)换成net.Listen("tcp", "127.0.0.1:19090"),其余完全一致。这样对比实验的控制变量是干净的。
压测端我用的是一个非常轻量的自写脚本:单协程循环Dial+Write+Read,统计 RTT。因为本机场景下 CPU 是主要瓶颈,我用perf stat统计 CPU 行为。
4.3 实验结果:不只是"快一点"
我直接在 shell 里对两种模式做了 10 万次请求的往返延迟测试。数据样本中位数如下:
| 模式 | 平均 RTT | P99 RTT | 事务/秒 |
|---|---|---|---|
| TCP 回环 | 大约 28 us | 68 us | 约 3.6 万 |
| UDS | 大约 11 us | 19 us | 约 8.9 万 |
UDS 的吞吐大约是回环 TCP 的 2.5 倍。这个差距在纯报文长度较短时尤为明显,因为协议栈固定开销占比更高。
另外perf stat的结果非常有意思——TCP 回环模式中的软中断(softirq)CPU 占比大约有 12%,UDS 模式中几乎为 0。这正好印证了前面说到的"回环地址同样触发软中断"这个结论。
注意:这个测试只代表小包往返。如果是大包传输(比如超过 1MB 的批量数据),双方吞吐会接近,因为拷贝成本主导;但内存拷贝次数和 CPU 开销仍有差距。
4.4 抓包验证:tcpdump 能看到什么
验证 lo 流量最直观的办法就是抓包。在回环模式下执行:
tcpdump -i lo -nn port 19090你一定会看到完整的 TCP 三次握手和四次挥手,包头的序列号、ACK 号、TCP 选项一应俱全。这说明内核认认真真地把这个包当成一个"正常网络包"处理了一遍。
但 UDS 模式下,你用tcpdump -i lo是抓不到任何东西的。UDS 的流量完全不经过任何网络设备。如果你非要在系统层面观测它,比较靠谱的思路是用bpftrace或者perf trace挂到unix_stream_sendmsg这个内核函数上。我自己写过一个最简单的 bpftrace 脚本:
bpftrace -e 'kprobe:unix_stream_sendmsg { @bytes = kstack(); }'这个不算常规操作,生产环境不建议随便挂,但确实能看到 UDS 数据是走内核的 unix 协议族函数的。
4.5 fd 传递:UDS 独有的杀手锏
聊完收发流程,还有一个常被忽略但特别实用的能力:UDS 可以跨进程传递文件描述符。TCP 回环做不到。
比如你在做多进程服务,主进程监听一个 listen fd,想把这个 fd 交给 worker 进程处理新连接。这么做的典型场景是 Hot Reload(平滑重启)——老进程把监听 socket 完整移交到新进程,连接不中断。实现的套路就是通过 UDS 发送SCM_RIGHTS辅助数据。
下面是 Go 里的一个极简示例,发送方把一个 fd 塞进 UDS 连接:
func sendFd(conn *net.UnixConn, file *os.File) error { fds := make([]int, 1) fds[0] = int(file.Fd()) return conn.WriteMsgUnix(nil, unix.UnixRights(fds...), nil) }接收方用一个oob缓冲区去读取辅助数据,取出 fd 后转成os.File,再用net.FileConn包装成可用的连接。这在 nginx reload、Envoy 热升级等场景里都是标配套路。
5. 常见问题与排查技巧实录
5.1 UDS 连不上:"No such file or directory" 到底怎么回事
UDS 用路径寻址,这意味着文件路径必须真实存在(或者用 Linux 抽象命名空间规避)。你 bind 的文件路径如果所在的目录不存在,connect 时报错"文件不存在"几乎是必然的。还有一层常见的坑:权限。UDS 的文件模式位(permission bits)会作用于连接权限。如果 socket 文件是0700权限,只有 owner 能连。
排查步骤:
ls -la /tmp/xxx.sock看文件是否存在和权限ss -xl看是否有进程在监听- 如果是抽象命名空间(路径以
@开头),用ss -xl | grep @能查到 - 确认发起连接的用户是否对 socket 文件有读/写权限
我自己踩过的最隐蔽的一次:代码里 bind 提前os.Remove(sockPath),如果生产环境老进程还在跑,这个 Remove 会把已经监听的 socket 文件删掉,但进程还持有 fd 在监听。新进程 bind 创建了一个新的 socket 文件,这时候你用ss -xl能看到两个同名 path 的监听项,但新连接全打到老文件上,排查询核心就是"目录下 inode 和 ss 显示的不一样"。这个坑值得记住。
5.2 本机 IP 回环延迟忽高忽低:软中断不均匀
如果你在本机压测中发现延迟曲线毛刺很多,一个常见原因是软中断分布不均匀。回环流量全部要走NET_RX_SOFTIRQ,而收包的后半段在哪个 CPU 上处理,取决于发送 CPU 的smp_processor_id()和RPS(Receive Packet Steering)配置。
排查建议:
- 用
mpstat -P ALL 1观察各个核的softirq占比 - 确认
lo队列是否绑定了特定的 CPU(/proc/irq/里一般没有 lo 的独立中断号,因为 lo 不走硬中断) - 如果发现某个核飙高,考虑调整
net.core.rps_sock_flow_table或干脆改用 UDS
从架构上消掉这个问题的根本手段,就是换 UDS。这也是为什么像 Redis、MySQL(通过 unix_socket 连接)、Nginx 与 php-fpm 通信这类高访问量的本机场景,几乎清一色支持 UDS——不是因为时髦,是这条路真的更短。
5.3 大包传输时两者性能接近,别神话 UDS
前面说了小包场景下 UDS 完胜。但到了大包(比如几十 MB 的文件传输),两者差距会显著缩小。原因是路径上的固定开销占比变小,数据拷贝开销成了主导。UDS 和回环 TCP 都要经过至少两次内核态与用户态之间的拷贝(send 时从应用拷到内核,recv 时从内核拷到应用)。
在这个前提下,如果做的是本机大文件搬运,更值得优化的方向其实是:sendfile、splice、io_uring之类减少拷贝次数的方案,或者直接走共享内存。UDS 的优势在"大量小消息、高频交互",这一点判断清楚能避免瞎忙。
5.4 客户端连接数高的场景里,UDS 的 inode 压力
UDS 每个 client 连接在进程内是 fd 和文件对象,但没有半开连接队列和 TIME_WAIT 问题。这是一项隐藏福利。回环 TCP 在连接高创建/高销毁(短连接高并发)时,TIME_WAIT状态大量积压,ss -ant里一搜就是一堆。UDS 完全不存在这个状态,因为它根本没有 TCP 状态机。
不过 UDS 也有自己的"连接数"代价:文件描述符数量翻倍。每一条 UDS 连接在 server 端占用一个 fd(server accept 返回的那个),client 端也占一个 fd。连接数几百上千时还好,超过了系统ulimit -n(通常 1024),连接就直接失败。排查 UDS 连接打满的问题,先看ulimit -n和/proc/sys/net/core/somaxconn,该调就调。
5.5 抓不到包怎么排查 UDS 问题
UDS 抓包没法用 tcpdump,这给排错带来困难。我的实操经验是:
- strace:
strace -f -e trace=network,read,write -p <pid>,直接看应用调用 sendmsg/recvmsg 返回值和数据大小 - lsof:
lsof -U列出所有 UDS 节点和关联进程 - ss:
ss -x -a -p,查看 UDS 的连接状态和路径 - bpftrace:挂
kprobe:unix_stream_sendmsg和kprobe:unix_stream_recvmsg,统计收发字节数 - 内核日志:如果出现
unix_gc垃圾回收相关告警,可以查dmesg
5.6 一个容易被忽略的问题:UDS 文件清理
UDS 是"以文件为名,但不是普通文件"的存在。如果程序异常退出,socket 文件可能残留在磁盘上,而 fd 已关闭。这个时候再启动新实例 bind 同一路径,内核会报Address already in use。正确做法是在启动前尝试unlink旧文件,但注意不要误删正在被别的实例使用的文件。前面提到os.Remove的坑——如果你 bind 前无条件 Remove,而旧实例还在监听,就会造成"同路径两个 inode"的诡异局面。稳妥起见的做法是:bind 之前先尝试connect探一下,如果能连上说明已有实例在跑,就不该继续 bind;连不上再 Remove 和 bind。
6. 选型实践心得:什么时候用 UDS,什么时候用回环 TCP
到底怎么选,我给一个偏实战的维度,不是空泛的建议:
- 同机进程间高频小消息通信(如 RPC 内部调用、日志采集 Agent 与主进程数据交换):优先选 UDS。性能好,不受软中断干扰。
- 需要跨机器扩展的接口(未来也许会把服务拆出去单独部署):优先选 TCP,哪怕当前只在本机用。因为以后迁出去,代码改动量最小。
- 与外部客户端协议对齐(比如监听一个 HTTP 端口给浏览器、App 访问):只能选 TCP,UDS 对客户端不友好。
- 需要传递文件描述符的场景(热更新、连接平滑迁移):只能选 UDS。
- 容器网络内:容器里两个 Pod 之间的通信跨了网络命名空间,UDS 基本无法穿透命名空间(除非挂宿主机路径共享目录),这时候即使同机,多半也走 TCP 虚拟网络。不过同 Pod 多容器共享
localhost时,UDS 是好选择。
在压测和排障的思维上,我的体会是:
- 本机 IP 通信不是"零成本"的,软中断依然是真实开销。不要理所当然认为回环很快,规模一大这个假设会破。
- UDS 性能是好,但它带来的是"无网可抓"的排障缺口,需要接受它并建立替代观测手段。
- 两种方案的路径差是确定的:回环多走一个协议栈,UDS 少走一整层。这是核心矛盾。
- 最终选型时,不是比谁快就选谁,而是先问自己:这个场景以后会不会出网?会不会需要外部访问?有没有 fd 传递需求?把这些问完,答案基本自己浮出来。
最后再分享一个小技巧:无论哪条路径,调试阶段都建议在业务代码里把 socket 的缓冲区大小、收发超时这类参数显式设置一下,不要依赖系统默认值。比如 UDS 默认的发送缓冲区在某些内核版本上比较小,大包容易触发 EAGAIN。开始我因为没设SO_SNDBUF,压测一上去就大量resource temporarily unavailable,实际定位花了很久。把这些参数提前固化在初始化代码里,能省下很多线上问题排查的时间。