1. 为什么要把数据流讲成“进城记”
先说说我为什么要写这个系列。这几年帮团队排查线上问题,遇到最多的一类场景就是:服务突然变慢、连接超时、延迟抖动,日志里看应用层没报什么异常,数据库负载也不高,最后定位来定位去,问题都出在网络数据在内核协议栈里堵住了。
但真正让我决定写“数据流”系列的原因,是每次问同事一个问题——一个数据包从网卡进来,到业务代码拿到数据,中间到底经过了多少个环节?大部分人能答出“网卡 → 内核 → socket → 应用”,可再往下追问就卡壳了:中断和软中断是干什么的?NAPI是什么?sk_buff在每层怎么传递?为什么有时网卡没丢包,应用却觉得丢了包?这些细节,教科书上也写了,但写得太散,和真实排障之间隔着一层纱。
所以我想用一篇开篇,把整条链路先串起来。我自己给它起了个名字,叫“数据流的进城记”。你想象一下:数据包从边缘小路(网卡)进城,先过城门(驱动与中断),再上城市环线(协议栈处理),经过立交桥完成转向(路由和连接匹配),最后拐进小区、停到住户门口(socket与用户态)。这条路上一旦哪个路口堵了,整个城市(业务系统)都会跟着出问题。
这篇虽然是开篇,但我会把整条数据流路径完整走一遍,该有的函数名、该看的计数器、该避的坑都会提到。后面的文章,再一篇一篇往深了拆。不管你是做后端开发、中间件维护,还是专职的性能调优,这张全景图打底,后面的所有细节才有落点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据流全景:进城之后要过多少道关
先看收方向,也就是数据从网卡进来、最终交到应用手里的完整链条。如果你用strace跟踪一个普通的recv,你看到的是应用层的一个系统调用,但在这背后,内核至少干了这些事:
网卡收到报文,通过DMA直接写进驱动管理的环形缓冲区(ring buffer),然后触发一次硬中断;硬中断里把收包任务扔给软中断后,软中断上下文调用驱动的NAPI轮询函数收包;收上来的sk_buff经过GRO合并后,进入协议栈;IP层做路由判定,TCP层根据五元组找到对应的socket,把数据放进接收队列;最后唤醒等在socket上的进程,数据通过recv拷到用户态缓冲区。
发方向的路径和收方向是镜像的:应用调用send,数据先拷贝进内核发送缓冲区,TCP层按MSS分段、做拥塞控制和窗口管理,然后交给IP层封装、驱动发送,最终从网卡发出去。整条链路看着很长,但要抓住两个核心对象:sk_buff和socket。
sk_buff就像一个集装箱,里面装着真正的数据负载,还挂着一大堆元信息:这个包从哪个网卡进来、目前处理到哪一层、下一层该往哪送、哪个socket是它的目的地。协议栈处理的过程中,这个容器本身不会在各层间来回复制,而是把指针一路传下去,每层做的只是往容器头上加一个头部信息,或者剥掉一个头部信息。很多刚学网络的人以为数据在每一层都要被复制一遍,实际上绝大多数路径上不复制。真正发生拷贝的地方只有两个:网卡DMA把报文从硬件写进内存是一次硬拷贝;recv和send在内核缓冲区和用户态缓冲区之间的搬运是另一次软拷贝。
socket则相当于住户的门牌号。三次握手建好连接之后,内核里就会有一个struct sock和对应的struct socket,数据包到了TCP层,拿着五元组去连接哈希表里一查,就知道该送进哪个门。理解了这两个核心对象,数据流在你眼里就不再是一堆抽象概念,而是一批货从一个集装箱搬到另一个集装箱、最后按门牌号送进家里的过程。
这里还要强调一个经常被忽略的宏观视角:数据的接收路径和发送路径,不是简单地“反着走”。接收路径的瓶颈更常在软中断、GRO、socket接收队列上;发送路径的瓶颈更常在发送缓冲区、拥塞窗口、qdisc排队上。排查问题的时候,先分清是收还是发,能帮你少走一半弯路。
3. 进城第一关:网卡驱动、硬中断与NAPI
3.1 为什么不能每个包都来一次硬中断
数据进入内核的第一关,是网卡驱动。网卡收到一个完整的以太网帧后,会通过DMA把数据直接写进驱动预先分配好的内存区域,也就是环形缓冲区。这一步完全不需要CPU参与,所以即使线速打满,硬件层面也能接得住。
但内存里有数据,CPU怎么知道?传统做法是:每来一个包,网卡就触发一次硬中断,CPU放下手里正在做的事情,切换到中断上下文把包收走。包少的时候没问题,包一多就麻烦了。硬中断是异步的,可以随时打断当前正在执行的代码,每次打断都涉及上下文切换、寄存器保存、中断控制器交互,这些全是纯开销。当网卡每秒钟进来几十万甚至上百万个包时,CPU几乎把所有时间都花在处理中断上,业务代码反而跑不动了。这就是我们常说的“中断风暴”。
所以内核引入了NAPI(New API)机制。它的核心思路是:把“一个包一次中断”改成“一批包一次中断”。第一个包到达时,仍然触发硬中断,硬中断处理函数并不立即把包收完,而是把网卡的收包例程挂到当前CPU的softnet轮询队列上,然后关闭网卡中断。紧接着,软中断接手,在softirq上下文里反复调用驱动注册的poll函数,一次性把环形缓冲区里攒着的包都收掉。等收完发现没有新包了,才重新打开网卡中断。
这套机制的好处是,高流量下CPU绝大部分精力花在批量收包上,而不是被打断上。现在主流的驱动ixgbe、i40e、mlx5、virtio-net全都走NAPI,包括云上虚拟机最常见的就是virtio-net,逻辑也是一样的。
3.2 环形队列与收包起点
NAPI收包时,驱动从环形缓冲区里取出一个sk_buff,交给上层。这个环形缓冲区的大小决定了驱动在单次突发流量下能暂存多少包。如果缓冲区满了,新来的公告没有地方放,会在驱动层面被直接丢弃。网卡自己会记录这些事件,我们后续可以通过ethtool -S来查。
在实际调优中,有两个操作很关键。一是网卡多队列,现代网卡支持多个接收队列,每个队列可以绑定到不同的CPU,这样多个核能同时收包;二是中断绑核与RPS(Receive Packet Steering),把不同队列的中断分散到不同CPU上,或者把软中断处理负载分散到多核,避免全部流量压在同一个核上。我见过太多明明有16核,但软中断全打在一个核上的案例,业务程序没写错,就是网络收包这个环节挤在了一条车道上。
3.3 NAPI这里最容易踩的坑
第一个坑是ring buffer太小。默认值普遍是512或1024,如果突发流量很大、驱动收包速度跟不上,rx_dropped就会涨。第二个坑是CPU亲和性没配好。中断和软中断处理最好固定在一个或多个特定核上,避免线程在不同核间来回迁移导致缓存失效。第三个坑是误以为关中断就能解决问题,实际上在高吞吐场景下,全关中断用轮询反而会占满CPU。这些我会在最后一章再展开。
4. 环线上的立交桥:GRO、IP路由与TCP状态机
4.1 GRO:把零散小车拼成货车
数据包离开驱动后,先经过GRO(Generic Receive Offload)。这是现代内核默认开启的收包优化:把同一个连接上多个连续的、逻辑上能合并的小包,合并成一个大的sk_buff,让上层协议栈少处理很多次。对应发送方向的GSO(Generic Segmentation Offload),则是把一个大包在软件层拆成多个符合MSS的报文,再交给网卡。
GRO对吞吐非常友好,但它有个副作用:它会把多个小包聚合成一个大包后再派发,所以从应用视角来看,数据到达的“粒度”变大了。如果你的业务是低频次、大报文,GRO几乎无脑收益;但如果是高频小请求、对单次延迟特别敏感,比如网关转发、量化交易行情,合并反而会让延迟尾巴变长。遇到这类场景,可以有针对性地关掉GRO做对比测试。没有绝对好的参数,只有适合你自己业务的参数。
4.2 IP层:查地图,决定走哪条路
从GRO出来后,sk_buff进入netif_receive_skb的派发路径,内核通过ptype_base哈希表匹配协议类型,把IPv4报文交给ip_rcv。IP层的主要任务是:检查报文头合法性、处理TTL、做分片重组(如果需要)、查路由表决定是“本机接收”还是“转发出去”。对服务端进程来说,目标地址是本机IP,最终会走ip_local_deliver,把数据继续上交给TCP或UDP的处理函数。
IP层一般不会成为性能瓶颈,但有一种情况值得关注:大量分片。只要报文在传输路径上被分片,到本机后就可能需要重组,重组需要内存和CPU,而且容易成为攻击面。所以线上一般建议控制MTU,尽量让业务报文在MSS以内,避免IP分片。
4.3 TCP层:可靠运输的调度中心
真正的重头戏在TCP层。IPv4的报文会进到tcp_v4_rcv,这里要做几件事:先通过五元组(源IP、源端口、目的IP、目的端口、协议)在连接哈希表里查找对应的socket。找到就进入已建立连接的数据处理路径;找不到且目标端口在监听,就走三次握手流程;两者都不是,就回一个RST。这个查找过程看起来简单,但在百万连接场景下,哈希表的设计和锁粒度对性能影响很大,这也是为什么老版本内核在连接数极高时会出现“全局锁竞争”的原因。
对于已建立的连接,TCP层要处理的东西包括:滑动窗口(决定发送方能发多少数据)、拥塞控制(决定网络路径是不是拥挤)、ACK确认、乱序重排、状态机转换。我在实际排查中,一大半的延迟问题最后都落在窗口上。要么是接收窗口被应用读取速度拖小,导致对方发送窗口收缩,吞吐上不去;要么是网络拥塞让拥塞窗口一路降低,出现经典的“锯齿形”曲线。看这些数据,用ss -ti最直观,能看到cwnd、rtt、重传次数和收发队列长度。
发送路径的逻辑同样复杂。应用调用tcp_sendmsg,把用户态数据拷贝到内核发送缓冲区,然后由tcp_write_xmit按照拥塞窗口和接收窗口的约束,决定现在能发多少个段,每个段多大。要注意,send成功并不意味着数据已经发到对端,只代表数据进了内核发送缓冲区。如果缓冲区满了,阻塞模式下send会一直等,非阻塞模式下会返回EAGAIN。很多异步网络编程的初学者在这里栽过跟头,误以为EAGAIN就是网络出错,其实只是缓冲区满了,该做的是等待可写事件,而不是关闭连接。
5. 到达目的地:socket接收队列、epoll与用户态
5.1 socket接收队列怎么工作
TCP层处理完毕后,数据会放进socket的接收队列。这个接收队列的设计比很多人想象的要复杂:为了兼顾低延迟和批量处理,现代内核在接收路径上会先尝试把数据直接放上receive_queue;如果当前socket正被进程锁定(比如进程正在执行系统调用),就先放到backlog队列,等锁释放后再补上。这样能减少锁竞争,同时避免数据丢失。
数据进了接收队列之后,内核会唤醒在socket等待队列上睡眠的进程。这个“唤醒”机制就是select、poll、epoll能够工作的基础。每次有数据到达,内核就通过回调把等待中的进程或者事件通知塞进就绪列表。你可以把它理解成小区里的物业:每家每户不用整天盯着门口,包裹到了物业会打电话通知你。
5.2 为什么epoll能扛住百万连接
很多人知道epoll性能好,但说不出好在哪。其实核心就两点:第一,epoll在内核里维护一棵红黑树来管理所有注册的fd,增删改查都是对数级复杂度;第二,内核维护一个就绪链表,只有真正发生事件的fd会被放进链表,用户调用epoll_wait时直接把就绪链表里的fd返回给用户,不需要把几十万个fd全部扫描一遍。
对比select,它每次调用都要把完整的fd集合从用户态拷贝到内核态,再遍历所有fd检查状态,复杂度O(n)。连接数从几百升到上万,损耗立刻变得肉眼可见。epoll则是O(事件数),所以能撑起百万连接的高并发服务。
用epoll时还有一个经典二选一:水平触发(LT)还是边缘触发(ET)。LT模式下,只要缓冲区里还有数据,epoll_wait就一直返回可读;ET模式下,只有状态发生变化时才通知一次,之后如果你没把数据读完,后续可能就不通知了,很容易漏事件。我的建议是:如果你每次读取都能把数据读到返回EAGAIN为止,用ET是安全的;如果拿不准,老老实实用LT,性能差距在绝大多数业务场景下并不明显。
5.3 recv的那一次拷贝
数据从内核到用户态,最终还是要拷贝一次。recv调用时,内核把接收队列sk_buff里的数据拷到用户传进来的缓冲区,然后释放sk_buff。这本来无可厚非,但在高吞吐场景下,这sk_buff释放和分配的开销、cache miss、内存带宽,有时候比协议栈处理本身更耗CPU。所以才会衍生出各种零拷贝手段:mmap、sendfile、splice,以及现在越来越热的io_uring,核心思路就是尽量减少甚至消除内核态和用户态之间的数据搬运。这块我后面会单独写一篇,这里你先知道“那里有一道拷贝”就够了。
6. 实测观测:把看不见的数据流拍下来
讲完原理,没有工具辅助还是等于盲人摸象。我平时排查数据流问题,有一套固定的组合拳,从外到内、从统计到采样,步步为营。
第一层:网卡和驱动统计。用ethtool -S eth0去看各队列收发包数、丢包数。重点看rx_dropped、rx_missed、rx_fifo_errors这类计数器。如果它们持续增长,说明包在进内核之前就已经被丢了。
第二层:内核网络子系统统计。看/proc/net/softnet_stat,每一行对应一个CPU,里面记录了软中断收包次数、丢包次数。如果对应的列在涨,说明包虽然进了内核,但在softnet层没处理过来。TCP层统计看netstat -s或ss -s,里面能查到重传、丢包、溢出、被动打开失败等计数器。
第三层:抓包。tcpdump是定位“数据到底还在不在链路上”的终极方案。建议命令是tcpdump -i any -n -w /tmp/cap.pcap port 8080,先写文件再分析,不要在流量大的时候直接打印到终端,那会严重干扰观测。抓包只能看到链路层到传输层的报文,但足以判断是“没发出去”“没收到”还是“收到了但没处理”。
第四层:动态追踪。如果上面所有统计都正常但问题依然存在,就需要在内核函数层面找原因。bpftrace是当前最顺手的工具,不需要重编内核,直接挂载探针就行。举几个我实际常用的例子:
统计进入TCP接收入口的进程分布:
bash复制bpftrace -e 'kprobe:tcp_v4_rcv { @[comm] = count(); }'
查看tcp_sendmsg发送字节数分布,判断是不是有大块数据传输:
bash复制bpftrace -e 'kprobe:tcp_sendmsg { @bytes = hist(arg2); }'
观察TCP状态机转换频率,排查连接建立/关闭异常:
bash复制bpftrace -e 'tracepoint:sock:inet_sock_set_state { @[args->oldstate, args->newstate] = count(); }'
如果线上环境没有bpftrace权限,就用perf top看热点函数。如果看到tcp_v4_rcv、ip_rcv占用高,说明协议栈处理本身就是瓶颈;如果看到copy_user_enhanced_fast_string占用高,说明用户态与内核态之间的数据拷贝占了大量CPU,这时就该考虑零拷贝了。下面这张表是我排查时的快速对照,很实用:
| 观测命令 | 重点指标 | 说明的问题 |
|---|---|---|
| ethtool -S | rx_dropped / rx_missed | 驱动或ring buffer丢包 |
| /proc/net/softnet_stat | 第三、四列 | softnet层处理不过来 |
| netstat -s | TCP重传/溢出 | 协议栈状态异常 |
| ss -ti | cwnd / rtt / retr | 单连接传输质量 |
| tcpdump | 报文是否到达 | 链路及驱动层面排障 |
| perf top | 热点函数占比 | CPU时间花在哪个环节 |
7. 常见问题与排查技巧
最后一章,我挑几个在真实环境里反复出现、且都和数据流强相关的典型问题,给出定位思路。这些案例不是网上抄来的,都是我实际踩过的。
7.1 软中断全打在一个核上
现象:top看到某个CPU的si占用率接近100%,其他核空闲,业务线程偶尔出现长尾延迟。原因通常是网卡队列中断没有分散到多个CPU,或者驱动不支持多队列。先确认网卡队列数(ethtool -l eth0),再看中断绑核(/proc/interrupts),最后决定用RSS还是RPS。RPS配置很简单,把对应队列的rps_cpus写成目标CPU掩码就行,例如将CPU0和CPU1加入队列0:
bash复制echo 3 > /sys/class/net/eth0/queues/rx-0/rps_cpus
需要提醒的是,RPS会引入CPU间的报文转发开销,适合网卡队列少但CPU核多的场景;如果网卡本身已经支持多队列且都绑核了,一般不需要再开RPS。
7.2 ring buffer丢包一直涨
如果ethtool -S里rx_dropped持续增加,先确认softnet_stat有没有同步增长。如果没有,多半是ring buffer小了。先用ethtool -g eth0看当前值,然后尝试调大:
bash复制ethtool -G eth0 rx 4096
注意,这是运行时调整,重启网卡或机器后会失效,要持久化需要写进网卡配置或systemd服务。如果调到4096还丢,那瓶颈基本就不在驱动了,要去查软中断处理是不是被业务抢占太多,或者考虑把中断绑到更空闲的核上。
7.3 accept队列溢出,连接建立失败
高并发短连接场景下,客户端connect超时,服务端日志又没异常,抓包能看到SYN到达了但应用就是没反应。这种情况十有八九是listen的backlog队列溢出了。判断方法是看netstat -s里的“listen queue overflow”计数。处理上,一方面调大应用层listen backlog,另一方面注意内核参数net.core.somaxconn也要同步调大,生效值取两者较小值。我在实际案例中遇到过,应用把backlog设成了1024,但系统somaxconn还是默认的128,等于白设。
7.4 TIME_WAIT连接太多
本地端口耗尽的情况多出现在高并发短连接客户端上。TIME_WAIT本身是TCP可靠关闭的机制,没有必要见着就怕。真要优化,第一是尽量用连接池或长连接,从根上减少新连接创建;第二是配合SO_REUSEADDR解决服务端重启时端口被占用的bind失败问题。对于tcp_tw_reuse,我只能说一句:使用前必须确认场景,它是针对发起连接的一方,在满足TCP时间戳条件时复用TIME_WAIT连接,但在NAT环境下会引入连接混淆的风险,我不建议作为默认优化手段。
7.5 TCP重传与延迟抖动
抓包看到大量重传时,不要急着改参数,先确定是哪个方向丢包。客户端和服务端同时抓包,如果客户端发出了请求,服务端也收到了请求,但响应丢失,那链路本身没问题,问题出在服务端发送路径,可能是发送缓冲区不足,也可能是拥塞控制把发送窗口压得太小。观察ss -ti里的cwnd和rtt变化,如果cwnd一直在快速下降又恢复,说明网络路径有丢包,这时候该找网络团队;如果cwnd正常但应用延迟高,再往应用层日志和锁竞争方向查。
写在后面
写这篇开篇之前,我又把协议栈源码里几个关键函数重新过了一遍,越看越觉得,网络性能调优这件事,到最后拼的就是对数据流路径的熟悉程度。遇到问题,你知道该看哪个计数器、该挂哪个探针,心里有地图,手里有工具,就不会慌。
这个系列接下来,我打算先沿着收方向走,把“进城第一关”完整拆开:从网卡驱动、NAPI调度,到环形缓冲区深挖一遍,讲讲每个参数背后的硬件原理。后面还会依次覆盖TCP状态机、socket内存管理、零拷贝、io_uring这几个主题。下一篇见。
