ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux网络层深度解析:从收包路径到socket的每一道关卡

Linux网络层深度解析:从收包路径到socket的每一道关卡

1. 网络层到底是哪一层:先把你脑子里的分层模型对齐

做Linux网络排查的兄弟,十有八九都被同一个问题搞晕过:明明用tcpdump看到包已经进来了,为什么应用就是收不到?明明ping不通,为什么同网段的另一台机器却通?这些问题最后基本都指向同一个元凶——我们对"Linux网络层"这个说法的理解太模糊了。

网上论Linux网络层,有人讲的是ISO七层里的第三层,也就是IP层;有人说的是内核网络协议栈的整条链路;还有人特指netfilter那一堆钩子。这个口径不统一,是很多教程看着有用、一上手就废的根本原因。我个人的习惯是,把"Linux网络层"理解成操作系统内核中从网卡驱动到用户态socket之间的全部数据通路。它既包括数据链路层的收包分发,也包括IP层、TCP/UDP层,还包括netfilter这样挂在中间做处理的东西。换句话说,它是包从硬件进来到被应用读走这一整段旅程。

这篇文章不是教科书复读,我想从实际排查和调优的角度,把这几个真正决定性能的关卡拆开来看:

  • 数据包进内核之后走了哪几条路,每一站是谁在干活;
  • NAPI和软中断为什么是吞吐量的命根子;
  • netfilter钩子到底挂在什么位置,连接跟踪吃掉了多少CPU;
  • 硬件卸载(offload)怎么做到"看似没干活,实则干了大活";
  • 最后,作为应用开发者,你在socket层看到的延迟到底是怎么攒出来的。

适合谁来读?我默认你懂基本TCP/IP,会用tcpdump、ss这类工具,但不确定内核内部发生了什么。读完你至少能回答:一个包从网线进来到程序recv()返回,谁先谁后,谁可能丢它,谁在拖慢它。

2. 收包路径:中断下半部、NAPI与协议栈分发

2.1 为什么老式中断收包扛不住万兆

在NAPI出现之前,网卡每收到一个数据包,就触发一次硬件中断,CPU打断当前任务,进中断处理函数,把包从DMA环形缓冲区拷出来,交给协议栈,然后恢复之前的任务。这套逻辑在百兆网卡、包速率每秒几万的年代没问题。可到了千兆甚至万兆,情况就失控了。

假设一个万兆网卡跑满小包(64字节),线速每秒大约1488万包,保守点算100万包/秒。每包触发一次中断,CPU就要每秒被打断100万次。一次中断的代价,包括保存上下文、执行中断处理、中断控制器操作、缓存失效,大约需要2到5微秒的CPU时间。你算一下:100万 × 2微秒 = 2秒的CPU时间,单核直接被打满,而且全花在"切换到中断上下文再把上下文切回去"这种纯开销上。

NAPI(New API)把思路反过来了:硬件中断只用来"敲门"。第一个包到了,触发中断,中断处理函数把网卡的IRQ暂时关掉,然后把网卡挂到CPU的poll列表里,唤醒软中断(ksoftirqd)或者直接调度NET_RX_SOFTIRQ。之后驱动通过poll方式批量从DMA环形缓冲区把一批包捞出来处理,直到本次预算耗尽或者队列空了,才重新开启硬件中断。

从效果层面看,NAPI把收包从"每包一次中断"变成了"一次中断处理一批包"。"中断合并"带来的直接结果,就是CPU使用率大幅下降,吞吐量大幅上升。当然代价是延迟略微上升,因为包在DMA环里等了一段时间才被批量取走。所以现在很多高性能网卡的驱动会把中断聚合阈值和NAPI预算做成可调参数,目的就是在延迟和吞吐之间找平衡。

2.2 包里从环形缓冲区出来之后的归宿

NAPI解决了"CPU怎么拿包"的问题,下一个问题是"拿到包之后往哪儿送"。驱动会把sk_buff(简称skb)从DMA环里取出来,调用napi_gro_receive进入GRO(Generic Receive Offload)处理。GRO做的事情是把多个小包合并成一个大的skb,这样上层协议栈要处理的包数量就大幅减少。

GRO不是协议栈的唯一入口。如果驱动没开NAPI,或者包被GRO判定为不能合并(比如带了特殊标志、是异常包),就会走netif_receive_skb这条老路。这两条路最终都汇合到__netif_receive_skb_core这个函数,它才是协议栈分发的中枢:

  • 先检查是否有AF_PACKET套接字(也就是tcpdump、抓包工具)注册在ptype_all链上。如果有,复制一份skb扔给它。这就是为什么tcpdump能看到包,但抓包对性能有明显损耗——每个包都要多一次skb复制。
  • 然后检查这个包是否要经过桥接处理。如果skb的dev是桥接端口,会走br_handle_frame。这也是Docker等容器网络里,bridge模式流量转发效率不如macvlan的原因之一。
  • 最后根据以太网类型,从ptype_base哈希表里找到对应的协议处理函数。IP包对应ip_rcv,ARP包对应arp_rcv,IPv6包对应ipv6_rcv。

很多人在排查问题时,用tcpdump抓到了包,但应用收不到,就怀疑是内核丢弃了。实际上,如果tcpdump能抓到,说明包已经越过了驱动和GRO,到达了协议栈分发点。这时候丢包只可能发生在ip_rcv之后,也就是路由决策、netfilter、协议处理这些环节。所以抓包能抓到,不等于包能送到应用,这是我排查时提醒自己的第一句话。

2.3 协议栈入口的两次碰壁:数据包在这里会被丢掉

包进入ip_rcv之后,并不是直接往上送。IP层有两个明显的丢弃点,几乎每次线上丢包都能在这两个地方看到计数器上涨:

第一个是非法包检查。ip_rcv会检查IP头长度、校验和、版本号,如果校验和不合法直接丢。这个逻辑日常不会出问题,但网卡如果开了硬件校验和卸载,而又配置了错误的GSO分段参数,就会出现一种诡异现象:tcpdump正常显示,协议栈却在校验和检查这关把它丢了,因为驱动告诉协议栈"校验和已经算好了",但实际算错了。

第二个是路由决策失败。ip_rcv_finish里会调用ip_route_input_noref做一次路由查询。如果查不到对应的路由表项,包会被丢进Drop。常见场景是:源地址不在直连路由内,也没有默认路由;还有一个经典场景是rp_filter反向路径过滤——当包到达的接口不是路由系统认为的"最佳返回路径"时,被当作用户伪造的源IP丢掉。

早期遇到多网卡服务器上某些机器ping不通、但同网段其他机器正常,基本都是rp_filter在搞鬼。Linux默认很多发行版在sysctl里开了net.ipv4.conf.all.rp_filter = 1。如果你这个机器是多网卡、或者用了策略路由,包从eth1进来,但路由表觉得应该从eth0回来,rp_filter就把这个包干掉了。解决方法是把这个值改成0,或者给特定接口改成0。

实操要点:排查协议栈丢包,我通常依次看/proc/net/softnet_stat(软中断丢包)、netstat -s里的IP层计数、以及/proc/net/snmp。哪个计数器的数值在上涨,就顺着对应代码路径去查,这条路比我以前瞎猜快得多。

3. Netfilter钩子:数据包在这里被审问、修改或拦截

3.1 五个钩子的位置决定了iptables和nftables的能力边界

Netfilter是Linux内核里最容易被低估又最容易被滥用的组件。凡是做过防火墙、QoS、端口转发、容器网络隔离的人,实际上都在和netfilter打交道,但很多人并不知道它到底挂在哪里。

它一共在协议栈里埋了五个大钩子:

  • NF_INET_PRE_ROUTING:在ip_rcv完成基础检查、做了初步路由(决定包是本地还是转发)之后触发。这里是最先接触入站包的地方,适合做DNAT和raw表操作。
  • NF_INET_LOCAL_IN:路由判定为"发给本机"之后触发。这里适合过滤发往本机的包。
  • NF_INET_FORWARD:路由判定为"转发到其他机器"之后触发。这里决定了这台机器是否充当路由器。
  • NF_INET_LOCAL_OUT:本机进程发出的包,第一次进入协议栈时触发。
  • NF_INET_POST_ROUTING:本机发出的包或转发的包,在准备交给驱动之前触发。这里适合做SNAT和源路由标记。

我画过无数次这张图,每次都有新感悟。优先级顺序不是从0到5依次执行,而是根据每个钩子点注册的处理函数的优先级数值排序;数值越小越先执行。iptables的五个表和链的优先级顺序就建立在netfilter的优先级之上。nftables虽然命令风格变了很多,但底层的钩子位置完全一样。

3.2 连接跟踪是netfilter里最贵的那个零件

实际生产中,netfilter最影响性能的不是iptables规则本身,而是连接跟踪表(conntrack)。一条iptables规则里只要出现-m state或者-m conntrack,内核就会为每个通过的新连接创建一条状态记录,后续的所有包都要去查找这张表。

conntrack表的查询代价不是O(1)的哈希查一下这么简单。每个新连接创建时要分配struct nf_conn结构体,这个结构体在64位系统上占用大约300字节,还要拿锁维护哈希表。如果服务器上跑着高并发的短连接业务,比如HTTP健康检查、RPC重连,你会看到conntrack表的条数极快上涨,然后碰触到nf_conntrack_max上限,新连接直接被丢弃,表现为"间歇性连不上"。

我的调试经历里,好几起"数据库连接池频繁重建"事故最终都指向conntrack表满。处理办法无非三条路:

  • 提高nf_conntrack_max并同时提高nf_conntrack_buckets,让哈希表变宽,减小冲突。
  • 缩短nf_conntrack_tcp_timeout_established,默认是5天,对短连接场景来说太长了,改成几小时甚至几分钟都行。
  • 如果根本不依赖状态匹配,就卸载nf_conntrack模块,彻底绕过这个成本。

在这里补一个很多人不知道的细节:即使你一条iptables规则都没有写,只要内核加载了nf_conntrack模块,并且某个服务主动读取了连接状态相关数据,钩子就会生效。因为netfilter框架本身在协议栈里是编译进去的,模块加载之后会主动把钩子挂上,包每经过一个钩子点都要做一次空跑遍历。这也是为什么有些基于原始套接字的高性能程序会建议去掉netfilter框架——省掉的是钩子遍历本身的开销,哪怕钩子函数一个都没注册,遍历也要花几十纳秒到几百纳秒。

3.3 透明代理和重定向到底是怎么"截胡"的

很多人在配置透明代理或者端口转发时会遇到一个疑问:为什么用了REDIRECT之后,应用看到的还是原来的IP?为什么服务端要主动设置IP_TRANSPARENT才能处理这些流量?

原理在于,REDIRECT做的是在钩子点修改数据包的目的地址。在PRE_ROUTING钩子里,它把目的IP和端口改成透明代理监听的地址和端口;在LOCAL_OUT钩子里,它把本机要发出去的包的目的地址改掉。因为是直接改包,而不是把包重新注入协议栈,所以接收端socket本来应该看到原始目的地址,但由于包已经被改了,它只会看到被改过的地址。想保留原始地址信息,要么用TPROXY目标(它在不修改包的前提下把包转发给本地socket),要么让应用解析socket optionSO_ORIGINAL_DST。

这个"到底是在改包,还是做路由选择"的差别,是很多网络编程老手都绕不过去的弯。理解了netfilter的钩子位置,这个弯就自然直了。

4. 卸载机制:硬件是怎么帮忙把协议栈变快的

4.1 GRO、GSO、TSO、RSS、RPS这一堆缩写都是干什么的

谈协议栈性能,绕不开"卸载"这个词。卸载的通俗理解就是:本来CPU要干的活,让网卡硬件去干。这一块有几个关键缩写,我按作用范围从大到小排一下:

  • GRO:收包方向,把多个小skb合并成一个大skb再上交协议栈。减少上层协议栈处理的包数量。
  • LRO:GRO的前身,只针对TCP,但由于合并太激进会破坏TCP语义(比如合并后丢掉了TCP头里的标志位),现代驱动默认建议关闭,用GRO替代。
  • GSO:发送方向,允许协议栈把一个大包(通常64KB)直接交给驱动,驱动在把它拆成MTU大小的分片发给网卡。相比传统做法,协议栈不用为每个分片各构建一个skb。
  • TSO:GSO在TCP协议里的具体实现,网卡硬件直接对TCP段做分段并计算校验和。GSO和TSO经常放在一起说,但GSO更通用,也支持UDP。
  • RSS:网卡多队列,硬件层面根据哈希把不同流的包分发到不同的DMA环形队列,从而让多个CPU核心可以并行收包。
  • RPS:RSS的软件模拟。虚拟网卡或者不带多队列的物理网卡,可以用RPS在软中断里把包分发到不同CPU。
  • RFS:RPS的进阶版,尽量把属于某个流的包分发到正在处理该流应用的那个CPU,提高CPU缓存命中率。

实测下来,这些功能叠加起来,能把万兆小包转发场景的CPU占用率从"满载"降到"20%上下"。我做过一次对比:同样一台机器跑DPDK应用不用,只测内核协议栈的UDP小包转发,关掉GRO和GSO之后转发性能只有打开时的三分之一。这就是卸载机制最直观的价值。

4.2 开启了卸载功能之后带来的tcpdump盲区

很多人被卸载机制坑过:明明tcpdump抓包显示一个很大的TCP包(比如超过MTU),但应用收到的数据顺序乱了,或者对端的窗口总是快速收缩。原因就是tcpdump抓到的实际上是协议栈在GSO/TSO之前合成的超级大skb,它被交给网卡后,由网卡硬件切成符合MTU的分片发送。抓包工具抓的是"未分割的总包",不是"线路上真实发送的分片序列"。

这对日常排障有一个直接启示:tcpdump显示的包长度和发送缓冲区的数据长度在卸载开启时不是线缆上的真实形态。若你的抓包分析涉及TCP窗口、分片延迟、乱序重组,就要么打开驱动里面的TSO/GSO禁用选项,要么用支持解析分片信息的抓包设置,要么在管理交换机上做镜像口抓取真实链路包。实战中我95%的情况都建议先关掉TSO再看延迟问题:

ethtool -K eth0 tso off gso off gro off sysctl -w net.core.wmem_max=... # 顺手调一下缓冲区

这样抓到的包才最接近"网络上真实跑的形态",分析乱序和重传才有价值。

4.3 发送路径上的另一个关键点:不只靠驱动,还靠排队规则

把眼光放到发送方向。应用调用send()写数据时,内核把skb挂到qdisc(排队规则)上,然后由qdisc决定什么时候真的把数据交给驱动。这里的核心是qdisc和delay的关系。

默认的pfifo_fast qdisc对大部分场景已经够用,但延迟敏感型服务通常需要换成fq_codel或者cake这类带公平队列和主动拥塞管理的qdisc。它们的逻辑是:通过控制队列长度和丢包策略,避免缓冲区膨胀(bufferbloat)造成的假延迟。如果应用对网络延迟极其敏感,同时又要保证吞吐,我一般建议测试tc qdisc replace dev eth0 root fq_codel之后的P99延迟,往往比默认qdisc低一个数量级,特别是在高并发短连接场景。

5. 从应用视角看协议栈:socket、系统调用与用户态边界

5.1 send()和recv()为什么会几次调用就卡住

网络层说到最后,落到应用层执行,"为什么系统调用会卡"是最好的切入点。你调用send(),它在内核里经历的关键路径是:

  • socket层根据协议族调用核心的inet_sendmsg,在这里做socket锁、校验状态。
  • TCP层调tcp_sendmsg,把用户态缓冲区的数据拷贝成skb,然后塞进发送队列,尝试触发送出。
  • 如果发送窗口满了(接收方通告的窗口为0,或者本端拥塞窗口打满),数据就只能挂在发送队列里出不去。这时如果socket是非阻塞的,send()立刻返回EAGAIN;如果是阻塞的,进程挂起,直到窗口恢复或者超时。

recv()路径类似,只是方向相反。核心就是:recv()没数据时,阻塞等待,直到数据到达并完成一系列协议状态检查。很多新手拿"recv()一直阻塞"来判定网络有问题,但很可能是那个连接的对端早就半关闭了,只是你还没收到FIN——TCP的半关闭状态不会给你报错,你只能通过IO复用机制判断"可读事件到来但读到的字节数为0"。这个细节值一块硬盘。

5.2 缓冲区大小、零拷贝和批量IO其实都写在内核参数里

应用开发者真正能直接影响的网络层参数不多,但每一个都冥冥中决定了P99:

  • net.core.rmem_max/net.core.wmem_max:进程能传给内核的最大接收/发送缓冲区。应用调用setsockopt设置SO_RCVBUF超过这个值时,会被内核静默截断到该上限。
  • net.ipv4.tcp_rmem/net.ipv4.tcp_wmem:TCP协议栈自动调整缓冲区的当前值、初始值和最大值范围。
  • net.ipv4.tcp_fastopen:TCP快速打开,允许在SYN包中携带数据,省去一次RTT。有些业务在短连接上能明显降低页面往返时间。

另外,sendfile、splice、MSG_ZEROCOPY这些零拷贝机制,本质上都是在内核里把"内存到内存的复制"替换成"页表映射和引用计数"。以MSG_ZEROCOPY为例,它允许用户进程把数据页借给内核,网卡直接DMA去读这些页,而省掉一次从用户态到内核态的拷贝。代价是进程不能在下一次send()之前改写这些缓冲区,否则会撞上竞态。我实测过在UDP大包发送场景,MSG_ZEROCOPY能把发送CPU占用降低30%左右,但对小包基本没有收益,因为拷贝开销占比小,加锁和引用计数反而还贵了。

5.3 epoll是怎么把"等数据"这件事变成O(1)的

任何一个高并发网络服务最终都要面对IO事件通知。select和poll的问题在于:每次都要把整套fd列表从用户态复制到内核态,内核再遍历所有fd检查状态变化,事件数一多就线性退化。

epoll一刀切把这件事拆成两段:

  • epoll_ctl负责长期注册,内核维护一棵红黑树存放关注的fd;
  • 当某个fd上发生事件时,内核把事件塞进就绪链表,并唤醒阻塞在epoll_wait上的进程;
  • epoll_wait只负责把就绪链表里的内容拷贝给用户,复杂度只跟"实际发生事件的fd数量"相关,而不是跟"注册的fd总数"相关。

从网络层视角看,epoll处理的是socket缓冲区和进程等待队列的交互关系。每一个新数据包到达,协议栈同时检查这个socket有没有在等数据的进程,有的话请求调度唤醒它。这个"检查—唤醒"的动作如果经常发生"惊群效应"(多个进程同时被唤醒,但只有一个能取到数据),就说明你的accept模型还停留在fork-per-listener上,要么换成SO_REUSEPORT,要么在内核版本低于4.5时自己做锁处理。

6. 排查实战:从"不知道从哪查"到"直接看计数器"

6.1 先学会读这些统计文件,再开始怀疑一切

很多人拿到一个网络问题,第一反应是抓包,这当然没错,但抓包看到的只是现象,不是根因。我的建议是先读计数器,用数字定位到协议栈的准确阶段,再针对这个阶段去抓包验证。常用的几个计数来源:

文件/命令能看什么我常用它解决什么问题
/proc/net/softnet_stat软中断收包统计、drop统计、time_squeeze定位CPU软中断处理不过来、NAPI预算耗尽
/proc/net/snmpIP层收包、转发、丢包、重组统计看IP层丢包发生在哪个环节
/proc/net/netstatTCP层连接状态变化、丢包重传统计定位TCP重传率异常、连接重置原因
/proc/net/netfilter/nf_conntrack当前连接跟踪条目判断conntrack是否吃满
ss -tinp每个socket的发送/接收队列、拥塞窗口、RTT看单个连接的健康状态
nstat -az内核网络计数器增量配合监控系统看趋势变化

具体到排查,我的习惯路径是:

  1. 先看总的丢包有没有涨:nstat -az | grep -i drop;
  2. 再看是哪个阶段在丢:区分softnet层面的丢包、IP层丢包、TCP层丢包;
  3. 对照网络拓扑和最近变更(比如刚调过内核参数、刚上线新防火墙规则),把嫌疑圈定到一个钩子点;
  4. 最后才用tcpdump验证具体的包行为。

6.2 一个典型的"收包丢包"定位过程

举个例子,某天你发现一台机器的应用偶发超时,但应用进程的CPU、内存都正常。看softnet_stat,第二列(dropped)在缓慢上涨。这说明问题出在软中断收包这层。可能的原因:

  • 某个CPU的软中断处理能力不足,NAPI的budget被耗尽,在重新开启硬件中断之前,网卡来的新包没地方放,DMA环形缓冲区满,驱动丢弃了新包。
  • 中断被irqbalance错误地分摊到多个CPU导致缓存失效(早期内核和某些旧版irqbalance确实存在这种问题)。
  • ksoftirqd的调度延迟太高,说明CPU整体负载过重,软中断排队时间过长。

对应的解决思路也清晰:看/proc/interrupts,确认网卡队列中断都绑定在哪些CPU核上;用smp_affinity把队列中断固定到专用的处理核;如果虚拟网卡没有RSS,就开启RPS并合理设置rps_cpus;最后调节somaxconn和netdev_budget这两个参数,保证NAPI预算跟实际的包速率匹配。

6.3 抓包、perf和ftrace的分工:到底该用哪个

真要说排查网络层问题的终极武器,我会选perf。tcpdump告诉你"包是什么样",perf告诉你"内核把时间花在哪了"。

我在性能剖析时常用的命令组合:

perf record -a -- sleep 10 perf report -g graph

如果命中热点在tcp_recvmsg、copy_user_enhanced_fast_string这类函数上,说明瓶颈在数据拷贝;如果热点在nf_conntrack_in,说明conntrack是罪魁祸首;如果热点在queued_spin_lock_slowpath,说明有锁竞争,很可能是socket锁或者路由表的锁。

ftrace和bpftrace则更适合在不需要全局采样时,精确地跟踪某个函数的调用链。比如想确认一个包进netfilter的哪个钩子之后被丢掉,可以用kprobe挂到nf_hook_slow和ip_rcv_finish之间,打印返回值和skb信息。这些工具学起来有门槛,但一旦掌握,排障效率是质的飞跃。

我自己的经验是:不要追求一次定位,而是先建立"协议栈各阶段计数器→函数热点→具体数据包"的三级证据链,永远用上一级的证据缩小下一级的范围。这样比打开tcpdump瞎猜快得多,也不会被某一个孤立现象带偏。

7. 结尾前说点掏心窝子的话

写了这么多,其实最想强调的一点是:Linux网络层并不存在一个"标准答案式"的优化配置。我见过很多人在网上抄了一堆sysctl参数就往生产机器上贴,结果延迟反而变高。比如tcp_sack、tcp_timestamps、tcp_tw_reuse这几个参数,单拿出来都有人在吹,但它们对延迟和兼容性的影响在千兆内网和跨机房长肥网络里是相反的。

我自己现在处理网络问题时的习惯,是先用标准工具链做基线测量:

ss -tinp nstat -az ethtool -S eth0

再把关键参数备份、逐项调整、逐项重新压测。网络层的改动不像应用代码,它影响的是整机所有连接,一次配置错误可能让全机房都在遭罪,所以"小步快跑、严格对比"是底线。

最后送大家一个调试技巧:遇到无法理解的丢包,先把rp_filter关掉试试,再查conntrack表,最后再怀疑驱动bug。这三个原因加起来,基本能解释我遇到过70%以上的"诡异网络问题"。至于剩下30%,那就得打开内核源码,一行一行读了——那也是我们做这行最幸福的时刻。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进