
做Linux网络这块也快十年了从当年只会敲netstat -tlnp查端口的小白到如今能啃着协议栈源码定位线上偶发丢包问题的老油条中间踩过的坑比我写过的代码还多。一直想抽时间把Linux网络模型这条链路完整梳理一遍正好借着这个话题把脑子里那些散落的知识点串成一条线也方便团队里的新人照着这条路去学。Linux网络模型这个词在不同人嘴里含义其实不一样做业务后端的人脑子里是socket和epoll做嵌入式的人脑子里是驱动和PHY芯片做运维的人脑子里是丢包率和TCP重传。但不管从哪个入口进来最终都会汇聚到同一个地方——内核的协议栈。我这里想聊的是最底层、最通用的那套机制数据从网线上进来到进程的socket里收到中间到底经过哪些环节每个环节做了什么哪些地方容易成为瓶颈以及现代Linux为了扛住高并发都用了哪些手段。这篇东西适合几类人看刚接手网络相关工作的后端开发调Linux服务器网络参数但一知半解的运维做嵌入式平台驱动想搞明白数据通路的工程师。我会尽量用大白话讲原理再落到实际的命令、参数和代码行为上这样你既能应付面试也能真正用来排查问题。1. Linux网络架构的分层模型与核心设计逻辑1.1 协议栈为什么长成这个分层样子很多朋友一上来就被TCP/IP四层模型劝退觉得纯粹是考试用的。但你真去追一个数据包的内核旅程会发现分层不是谁拍脑袋定的而是每一层都有自己必须解决的问题且每一层都独立演进出了对应的内核模块。我用一个快递分拣的类比来讲你家楼下的菜鸟驿站收到一大车包裹驿站要做的是按小区分拣这对应网络层的IP路由分拣完交给各楼栋的快递员对应传输层的端口分发快递员按门牌号送到你手上对应应用层的socket读取。每一层只需要关心自己这一段的规矩不需要知道隔壁楼层在忙什么。Linux内核的协议栈就是这么组织的物理网卡驱动负责收发比特流链路层处理以太网帧、MAC地址网络层做IP寻址和路由选择传输层保证数据有序到达应用层只需要面对一个文件描述符一样的socket。分层最大的好处是解耦。你在应用层换了一种协议网卡驱动不用动你换了新网卡socket代码也不用改。做嵌入式的朋友体会更深今天用百兆RTL8211明天换千兆PHY驱动层换一下厂家代码上面协议栈完全不动。这个可持续演进的优势是单层大泥球架构给不了的。但分层的代价也明显每一层都要拆包、查表、复制数据跨层传递有性能损耗。所以后面几十年内核所做的大部分优化核心就一句话——在不破坏分层语义的前提下尽量压缩层与层之间传递的代价。理解了这句话再看NAPI、GRO、DPDK、XDP这些技术你就能一眼看出它们在解决什么问题。1.2 每一层在内核代码里的对应物如果你去看Linux源码文末我也建议了几个路径这套分层模型在代码里是有清晰落点的应用层sys_socket系统调用、struct socket、文件系统挂载点sockfs传输层net/ipv4/tcp_ipv4.c、udp.c核心结构是struct sock网络层net/ipv4/ip_input.c、ip_output.c核心是路由表fib_table链路层与驱动drivers/net/ethernet/下的各厂商标量驱动核心结构是struct net_device通用报文载体include/linux/skbuff.h中的struct sk_buff这里有个特别重要的点Linux的协议栈不止服务于普通PC嵌入式路由器、交换机、防火墙设备上跑的也是同一套代码。所以面试题里常问“Linux为什么能当路由器用”“为什么iptables能拦截流量”答案都藏在这一层一层的钩子点里。网络模型的知识不是孤立的它天然连接着驱动开发、内核调优、性能分析三条线。2. 核心数据结构从socket到sk_buff报文在内核里的家2.1 socket、sock、sk_buff三者到底什么关系我面试别人的时候最喜欢问这个问题能答清楚的人不多。很多人以为socket就是一个int类型的文件描述符其实远不止。struct socket是内核面向应用层暴露的门面它挂在VFS层上让进程可以用read、write、close这些文件操作来收发数据。往下看struct socket内部持有struct sock这是传输层协议的私有状态容器TCP的发送缓冲区、接收缓冲区、拥塞窗口、重传队列全都挂在struct sock上。再往下真正在协议栈里流动的报文载体叫struct sk_buff如果说sock是快递站点的管理台账那sk_buff就是那一车车的包裹内核每收一个包就分配一个sk_buff处理完再释放或回收。三者关系可以简化成一句话socket是用户态拿到的句柄sock是内核传输层的连接实体sk_buff是连接上流动的数据报文。我见过很多性能问题最后都归结为这三种对象分配、锁竞争和缓存失效。比如高并发短连接场景下sock的频繁创建销毁会让slab分配器压力巨大这时候就该上连接复用或者调整slab缓存策略。2.2 为什么sk_buff要搞headroom预留这是一个看起来反直觉但极其精妙的设计。正常情况下你发一个100字节的应用数据从应用层一路往下TCP要加20字节头部IP要加20字节头部以太网要加14字节头部如果链路层还要VLAN标签又得多4字节。如果每层都在原来缓冲区前面重新分配一块内存再拷贝数据性能就废了。内核的做法是在分配sk_buff时就预留一块叫headroom的空间。sk_buff里有几个关键指针head指向整块内存起点data指向实际数据起点tail指向数据末尾end指向内存尽头。每一层协议处理时执行skb_push()把data指针往前挪腾出空间写自己的头部整个过程零拷贝、零重分配。同时协议栈还提供skb_reserve()在分配之初就预留好头部空间skb_pull()在接收路径上剥离头部时往后挪指针。如果你写网络模块或者BPF程序时不注意维护这些指针轻则包头发错位置重则内核直接崩溃。我在内核模块里调试过一个诡异的分段问题查了两天最后才发现是skb_pull之后没有同步修正transport_header导致的。2.3 skb的克隆与共享零拷贝的基石还有一个高频考点是sk_buff的clone操作。注意skb_clone()不是把整个数据包复制一遍它只复制sk_buff这个控制结构本身而真正存放数据的data区域通过引用计数共享。这叫“浅拷贝”代价极小。为什么要这么做典型的场景是一个多播报文需要转发给多个接收者或者一个包同时要被iptables钩子、tcpdump、协议栈各看一遍。如果每次都深拷贝数据吞吐量直接掉一个数量级。所以内核用clone只增加了对data区的引用计数dataref每个消费者在自己处理完后就kfree_skb()释放引用最后一个释放的人负责真正回收内存。你自己写DPDK程序时也会遇到类似设计比如rte_pktmbuf_clone就是这个思路。理解了引用计数你就明白为什么抓包工具对线上性能有影响——tcpdump的BPF过滤器会在驱动层之后复制一份报文它会影响缓存和CPU但不会阻塞协议栈主线。3. 一次收包的完整旅行从网线到进程3.1 硬件中断不能太嚣张NAPI的出现老一代网卡驱动收包方式是每个包到了都触发一次硬件中断中断处理里把包从DMA ring buffer拷出来再交给协议栈。低流量时没问题但流量一大就麻烦了——中断频繁到CPU根本忙不过来这就是有名的“中断风暴”。现在的做法是NAPINew API混合模式第一个包到了先中断通知CPUCPU进入napi_schedule()之后后续的包就不再一个一个触发中断了而是网卡持续把包写进DMA ring bufferCPU以轮询方式批量收包。这个机制是当前Linux网络性能的基石很多调优问题都在围绕它做文章。收包的完整流程我梳理成一条线你可以在/proc/interrupts和/proc/softirqs里看到实证网卡收到帧DMA直接把数据写到内存里预分配的ring buffer。网卡发出中断对应的CPU执行中断处理函数ixgbe_msix_clean_rings()。中断处理里判断包量调用napi_schedule()把napi_struct挂到当前CPU的softnet_data的poll_list上。中断处理结束软中断NET_RX_SOFTIRQ被触发net_rx_action()遍历poll_list调用驱动的poll()函数批量取包。驱动批量把ring buffer里的包包装成sk_buff送进协议栈先过gro_receive()做GRO合并再送ip_rcv()。网络层处理完路由ip_local_deliver()把包上送到传输层tcp_v4_rcv()或udp_rcv()。传输层找到对应的socket把数据拷贝到接收队列唤醒在epoll_wait()或recvfrom()上睡眠的进程。每个环节都有对应的计数器这也是排查瓶颈的基础。比如ethtool -S eth0里的rx_missed_errors高说明ring buffer没及时取走包导致丢包/proc/softirqs里NET_RX数值异常高说明软中断处理压力大。3.2 GRO和GSO把包攒大了再干活刚才提到GROGeneric Receive Offload值得单独说。如果两个TCP段比如一个大文件被拆成多个MTU 1500字节的分段先后到达协议栈默认会每个都走到TCP层去确认、去处理效率极低。GRO的做法是在进入协议栈之前把连续到达的多个分段合并成一个大sk_buff然后一次性上送。这样减少了CPU每包处理的开销吞吐量提升非常明显。发送方向对应的是GSOGeneric Segmentation Offload应用层一次性发送一个64KB大缓冲区如果不用GSO协议栈要把这个缓冲区切成几十个1500字节的段再各发一次用了GSO只有到网卡驱动层才真正的执行分段甚至网卡硬件自己就能分段TSOTCP Segmentation OffloadCPU几乎不参与。我在压测Nginx静态文件时开没开TSO吞吐能差30%以上这还是在网卡已经很新、CPU也不算弱的情况下。这里有个细节容易踩坑GRO合并后的sk_buff可能超过MTU如果网络设备中间路径上有不支持巨型帧的老交换机就可能出现“能ping通但大包传不了”的诡异现象。遇到这种先ping -M do -s 1472测路径MTU或者临时关掉GROethtool -K eth0 gro off验证。3.3 数据怎么到进程手里唤醒、拷贝与epoll包到了传输层之后TCP是把数据挂到socket的接收队列里然后唤醒等待线程UDP则是挂到对应端口的接收队列。这里有个非常关键的“拷贝”成本用户态的应用程序最终是要通过read()或recvmsg()把数据从内核缓冲区搬到用户态缓冲区这是一次实打实的内存拷贝。所以后来有了mmap方式、splice方式、甚至recvmmsg批量接收都是为了减少系统调用次数或者免去拷贝。很多人以为epoll是个“快”的模型其实epoll本身不加快数据收发它只是解决了高并发下“如何高效知道哪个socket可读”的问题。真正的性能大头是事件通知之后的读写操作本身。这也是为什么Netty、Nginx都能调到很高性能但它们依赖的还是内核这套收包路径只不过通过Reactor模式把等待成本摊薄了。4. 性能优化从多队列、内核参数到绕开内核4.1 多队列网卡与RSS、RPS、RFS单队列网卡的时代所有包都进同一个ring buffer一个CPU处理所有收包软中断另一个CPU只负责应用层瓶颈非常明显。现在已经普及多队列网卡每个队列有自己的DMA ring和独立中断号可以绑定到不同CPU这就是RSSReceive Side Scaling接收端缩放。如果你的网卡支持多队列但不知道该怎么绑可以用ethtool -l eth0看当前队列数用ethtool -L eth0 combined 8改成8个队列再用/proc/irq/*/smp_affinity做中断亲和性绑定。SMP affinity的值不是随便写的它是一组十六进制掩码比如绑定到CPU0、CPU2就写0x5。很多运维同学上来就写个0xff也没细想是不是绑对了核结果中断全挤在一个CPU上白忙活。如果硬件不支持多队列内核还可以用软件方案RPSReceive Packet Steering在协议栈入口处按照包的hash值把收包软中断分派到不同的CPU上模仿出RSS的效果。RFSReceive Flow Steering进一步保证同一个连接的处理CPU和实际使用该socket的应用进程CPU一致减少缓存失效。我在压测Redis时亲身试过关闭RPS和开启RPS后sar -u里面单核的softirq占比从接近100%降到30%左右整体qps提升了一个档次。4.2 我常用的内核网络参数清单调参这块水很深网上很多文章直接贴一串sysctl.conf让人照抄非常不负责任。我挑几个高频的讲清楚它们各自的语义你再按自己场景调net.core.rmem_max/net.core.wmem_max所有socket类型的内核收发缓冲区上限。如果你的应用是长连接大吞吐比如文件传输或者视频流不调大这个值即使SO_RCVBUF设置再大也会被内核静静截断。net.ipv4.tcp_rmem/net.ipv4.tcp_wmem一组三元组min、default、max内核在默认值和上限值之间按压力自动调整。很多人只调max而忽视了default导致突发流量时缓冲区没有充足准备。net.core.somaxconn全连接队列上限。高并发短连接的Web服务和网关要调大否则在Nginx日志里能看到“connection reset by peer”的报错。net.ipv4.tcp_tw_reuse允许TIME_WAIT状态的连接重用。注意它只对“出站连接”生效而且现在内核已经把tcp_tw_recycle移除了——因为NAT环境下NAT网关记录和tw_recycle冲突反而造成更严重的连接问题。我个人的建议是调参数前先用ss -lnt、sar -n TCP等工具确认瓶颈类型不要一上来就一把梭。原则是“一项一项调一次只调一项调完压测看效果”。4.3 绕开内核当性能需要走到DPDK和XDP当常规手段榨干后还有人嫌内核协议栈太慢系统调用开销、软中断处理、内存拷贝、锁竞争……于是有人直接把整个网络栈挪到用户态这就是DPDK数据平面开发套件。DPDK的网卡驱动是在用户态轮询的完全绕过内核中断和协议栈用大页内存减少TLB miss用无锁队列做核间通信。我见过做流量分析和DPI设备的同学用DPDK跑满40G线速不丢包这是内核协议栈怎么优化都做不到的。但如果只是想让内核协议栈“少干活”而不是“不干活”还有更轻量的XDPeXpress Data Path它在网卡驱动收到包之后、还没进入协议栈之前就通过BPF程序做过滤、转发、甚至丢包。防御DDoS场景里XDP可以在10Gbps流量下精确丢弃攻击包而正常流量仍然走完整内核协议栈这比DPDK的成本低好几个数量级。选型时我的判断标准是如果应用本身需要TCP/IP语义、需要socket接口就用内核协议栈多队列RPS配置调优如果做纯转发、镜像、过滤优先XDP只有像网络功能虚拟化、流量发生器、核心网关这类方向才值得引入DPDK因为它的开发门槛和维护成本真不低。5. 嵌入式网络里的特殊玩法不用MDIO管理PHY5.1 标准姿势MDIO总线写到这里切入一个嵌入式场景因为“linux phy 不使用mdio、使用i2c”这个词被搜索很多次但相关资料少很多人卡在这个点上。标准以太网PHY芯片就是负责物理层编解码的那颗芯片有一组管理接口叫MDIO/MDC俗称SMI接口用来读写PHY的寄存器实现协商速率、查链路状态、环回测试等功能。在内核里mdio_bus是标准的总线框架驱动通过mdiobus_register注册PHY地址然后phy_read/phy_write读寄存器。绝大多数板子都用这种方式简单稳定。但事情总有例外。5.2 哪些场景下会走I2C管理PHY第一类是某些PHY芯片本身不带标准MDIO接口只有I2C从机接口多见于一些工业级或者集成了变压器的特殊PHY。你要读写配寄存器就只能走I2C。第二类更常见多路网口的主控芯片尤其是交换芯片自己管理PHYBMC板级管理控制器也想监控PHY状态但BMC只引出了I2C总线。这时候为了简化EE要求或者PCB布线设计者干脆把PHY的管理寄存器通过I2C接到BMCMDIO只在主控和PHY之间用。系统层面看Linux看到的“双网络记忆模型”就是这么来的一个控制通道走I2C数据通道走RGMII/SGMII。遇到这种板子驱动的改动思路是不要注册mdio_bus驱动而要在drivers/net/phy/之外写一个i2c客户端驱动用i2c adapter读写PHY寄存器再通过mdiobus_register把I2C读写函数包装成mii_bus的操作函数。这里的关键坑在于MDIO读写的寄存器地址是16位I2C协议一次只能传一两个字节所以驱动里要做地址转换和字节序处理否则读出来的速率协商结果是乱的。5.3 我在调试这类板子时的三条经验一是i2c速率不要太激进我在一块板子上把i2c调到400kHz后PHY寄存器偶尔就读回全0xFF后来降回100kHz就稳了。二是读PHY ID时不要只读一次就信了很多PHY芯片上电后要等一段时间才能响应建议在驱动probe里做三次读取重试。三是用ethtool eth0显示不了链路时先去查驱动里genphy_read_status是不是成功不行就直接I2C工具读寄存器0x01基本状态寄存器看bit2是否为1链路是否真的established。6. 排查网络问题时的实战方法论6.1 先确认问题在哪一层我见过太多人排查网络问题是“这里加个参数那里重试一下”。我的固定套路是先用三分钟确定问题范围再动手。第一看整体流量和错误sar -n DEV 1看rxerrs、rxdropethtool -S eth0看rx_crc_errors、rx_missed_errors、rx_no_buffer。这一步能区分是物理层问题、ring buffer溢出问题还是协议栈丢包。第二看TCP层ss -tin看重传、乱序、rtt变化netstat -s看TCP的listen queue overflow、SYNs to LISTEN sockets dropped。常见的高并发问题都藏在这全连接队列溢出会直接导致客户端握手失败。第三看应用层如果网络指标都正常但业务还是慢大概率瓶颈在应用锁或IO上那就别在网络上死磕了。6.2 我用过一次比较狼狈但收益巨大的排查有一年我们一台Nginx前端在晚高峰偶尔出现“请求白屏几秒”oss系统指标全部正常。后来我用perf top看到softirq占比很大再细分发现所有中断都落在CPU2上而nginx进程被schedtool绑定在CPU3上。包收在CPU2处理应用的却是CPU3跨NUMA访问内存延迟自然高。最后解决很简单把RPS的hash函数接收侧CPU掩码改成和应用CPU同节点同时把网卡队列中断绑到CPU0-3而不是默认的一个核问题就消失了。那次之后我算是彻底明白网络调优不是看单个指标而是要看“收包CPU→协议栈CPU→应用CPU”这条链的亲和性是否一致。你在自己的服务器上用mpstat -P ALL 1观察softirq在哪些核上再用sar -n DEV对个时间点基本就能复现出问题轨迹。6.3 工具清单与避坑提醒简单整理一下我随手用的工具和场景ethtool -S看硬件计数器的第一手来源重点看rx_missed、rx_fifo_errorsdropwatch -l内核协议栈的丢包点回溯比单纯看计数器好用sar -n TCP,ETCP 1看TCP新建连接和错误适合短连接风暴排查ss -tin看连接级RTT和重传适合长连接抖动排查perf top看CPU百分比和调用栈适合确认内核热点在哪里tcpdump -i any -nn port 80确认实际报文交互永远是最后的真相避坑提醒tcpdump在业务机器上跑会带来额外复制如果业务本身对延迟敏感建议先抓镜像口或者临时绑低流量时段。另外不要把net.ipv4.tcp_tw_reuse和tcp_tw_recycle混为一谈后者内核早就不建议用了。我个人在实际排查中体会最深的一句话是Linux网络模型的每个机制都是在“省”字上做文章——省中断、省拷贝、省锁竞争。你顺着这个思路去看每一个参数和每一个函数基本上都能想明白为什么存在、什么时候该用。想深入的朋友建议直接读Documentation/networking/目录下的内核文档配合iproute2源码和一份网卡驱动代码比对着看进步会非常快。最后再分享一个小技巧调完任何网络参数后一定要用ethtool -S和sar同时抓前后对比数据否则你根本不知道改动到底有没有用。