前段时间帮一位同行排查一台双网卡机器的怪毛病:业务进程在监听,端口通,本地 curl 也对,但从另一台机器发过来的请求,大概三五次里就有一次连不上,超时时间还很规整,几乎卡在同一秒数上。抓包看到的是 SYN 到了网卡,回程的 SYN-ACK 没发出去,ifconfig上 RX 有包、TX 没动静,网卡本身干干净净,dmesg里也没有任何告警。折腾到后半夜才发现,问题出在net.ipv4.conf.all.rp_filter和net.ipv4.conf.eth1.rp_filter这两个同名的参数上——只改了一个,另一个还是默认值,而内核取的是两者里更严的那个。这类"参数明明改了却没生效"的案例,我这些年遇到太多。Linux 系统里的 IPv4 参数不是一张平铺的开关表,它有作用域、有继承关系、有版本差异,很多参数之间还互相咬合。这篇就把这棵参数树从上到下拆一遍:哪些在/proc/sys/net/ipv4下,哪些跑到了/proc/sys/net/core,all和具体网卡之间到底是什么关系,哪几个参数被误传了很多年,以及改完之后怎么验证内核真的认账。不管你是刚开始接触服务器运维,还是已经在扛线上流量,应该都能从里面挑出几条能直接抄的东西。
1. /proc/sys/net/ipv4 不是一张平铺的参数表
很多人第一次进去ls /proc/sys/net/ipv4,看到几十个文件加一个conf目录,会下意识以为这就是"开着就开、关着就关"的一堆布尔开关,挨个echo 1就行了。真动手改过几次就会发现不对劲:有些参数在根目录下找到了,有些怎么找都找不到;有些改完立刻生效,有些改完重启就打回原形;还有些参数在conf/all里改了,具体网卡上却纹丝不动。
1.1 全局项、接口项与 all/default 的三角关系
/proc/sys/net/ipv4/根目录下的文件,绝大多数是协议栈全局参数,比如tcp_max_syn_backlog、tcp_fin_timeout、ip_local_port_range,这些不区分网卡,改了就是整个网络命名空间一起变。真正跟具体网卡绑定的那批参数,全部收在conf/目录里,每个网络接口对应一个同名子目录,eth0、eth1、lo、docker0各有一份独立的拷贝。
conf/下还有两个特殊目录,这是最容易踩坑的地方。
conf/default/是模板。新建一个网络接口时,内核会拿default里的值去初始化这个新接口。它不影响已经存在的接口。conf/all/是批量操作入口。往conf/all/xxx里写值,内核会把这个值同步下发给当前所有已存在的接口,同时也写进default。所以all更像一个"广播写入器",而不是一个独立的配置项。
问题就出在读上。读取conf/all/xxx时,部分内核版本返回的是所有接口里的最大值,部分版本返回的是all自己记录的那份值,不同参数、不同内核的表现并不一致。这就造成一个非常典型的误判:你cat conf/all/rp_filter看到 0,以为全放开了,实际上eth1自己那份还是 1,而内核对rp_filter这种安全类参数取的是两者最大值。真正靠谱的做法是永远去读具体接口的那份:
for i in /proc/sys/net/ipv4/conf/*/rp_filter; do echo "$i = $(cat $i)" done有个反例值得单独拎出来说:conf/all/send_redirects是整棵conf树里唯一一个不参与 all 广播的参数。写conf/all/send_redirects不会改变任何具体接口的设置。这是个明确的文档级例外,遇到"我明明关了发送重定向,怎么还在发"的情况,直接去查对应接口的conf/<iface>/send_redirects。
1.2 参数类型决定你怎么改:布尔、整数、范围、字符串
往/proc/sys里写值的本质是给内核的一个 proc handler 传字符串,handler 自己负责解析和校验。按 handler 的不同,参数大致分四类:
| 类型 | 典型参数 | 写入形式 | 写错的表现 |
|---|---|---|---|
| 布尔 | ip_forward、tcp_syncookies | 0 或 1 | 写 2 一般被当成 1 接受 |
| 整数 | tcp_fin_timeout、tcp_max_syn_backlog | 单个十进制数 | 负数直接报 Invalid argument |
| 双值范围 | ip_local_port_range | 两个数,空格分隔 | 只写一个数会解析失败 |
| 三元组 | tcp_rmem、tcp_wmem | 三个数,空格分隔,必须一次写全 | 少写一个直接报错 |
三元组是新手最容易翻车的地方。sysctl -w net.ipv4.tcp_rmem="4096 131072 6291456"这里的引号不是可选的,去掉引号之后 shell 会把后面两个数字当成独立参数,sysctl 只会把第一个数写进去,然后报个语焉不详的错误。用echo写也一样,echo "4096 131072 6291456" > /proc/sys/net/ipv4/tcp_rmem是对的,三个值必须一次性提交,内核不接受分批设置。
还有一类"表面上成功了"的情况值得警惕:某些整数参数内核内部有上下限钳制,你写了个超出范围的值,写入返回成功,回头cat出来却是个你不知道的数字。这类参数改完必须立刻读回来确认,别信命令的返回码。
1.3 改之前先看清内核版本
参数树是随内核版本演进的,同一个参数名在不同版本里的行为和默认值可能完全不同。最出名的例子是net.ipv4.tcp_tw_recycle——这个参数在 4.12 内核里被彻底删除了。如果你在旧文档里看到"打开 tw_recycle 可以大幅减少 TIME_WAIT",然后把这条配置塞进一台新内核机器的sysctl.conf,结果就是启动时刷一行 "cannot stat /proc/sys/net/ipv4/tcp_tw_recycle: No such file or directory",配置没生效,而且因为这一行报错,后面的配置项在某些发行版的启动脚本里会被跳过。
改参数前先做两件事:uname -r看内核版本,sysctl -a --pattern '^net\.ipv4'把当前这份完整快照存下来。快照的价值不在于备份,而在于出问题时能证明"这个值本来是什么"。我见过太多次事故复盘时谁也说不清默认值是多少,最后只能拿一台新装的机器去对照。
uname -r sysctl -a --pattern '^net\.ipv4' > /tmp/ipv4-baseline-$(date +%F).txt2. 转发、路由校验与分片:流量能不能进得来
这一组参数管的是"包到了网卡之后,内核认不认它、往哪儿送"。它们不涉及 TCP 状态机,出问题的表现形式往往是丢包、不通、单向通,而不是超时或重传,所以排查时容易跟应用层问题混淆。
2.1 ip_forward:路由功能的总闸
net.ipv4.ip_forward是协议栈的总开关,默认 0。值为 0 时,目标地址不是本机的包会被直接丢弃,机器只能当主机用;值为 1 时才具备转发能力,可以当网关、当软路由、跑容器网络。
容器环境里这个参数尤其容易出问题。Docker 启动时会自动把它打开,但如果有人手工改过sysctl.conf把它写死成 0,重启之后容器网络就整个不通了,表现是容器能 ping 通网关、ping 不通外网,iptables规则看起来全对。这种时候别急着查 NAT 表,先cat /proc/sys/net/ipv4/ip_forward。
跟它配套的是接口级的conf/<iface>/forwarding,这个默认值来自conf/all/forwarding。也就是说,开了全局转发之后,单个接口默认也是允许转发的,不需要额外打开。真正需要精细控制的时候才去动接口级的这个开关,比如一台机器同时接了内网和外网,只想在内网口上做转发。
ip_forward还有一个连带影响:开启转发之后,accept_redirects的默认值会从 1 变成 0,send_redirects的行为也会跟着变。这个联动关系在写自动化配置脚本时经常被忽略。
2.2 rp_filter 的三档语义与"最大值取胜"规则
rp_filter全称 reverse path filter,中文一般叫反向路径过滤。它的逻辑很朴素:收到一个包,看源地址,然后查路由表——如果"回包给这个源地址"要走的路由,跟这个包实际进来的网卡对不上,就判定为源地址伪造,丢掉。
这个机制在单网卡的干净环境里几乎无感,在多网卡、非对称路由、策略路由的环境里就是灾难。三档取值的含义:
| 取值 | 名称 | 行为 |
|---|---|---|
| 0 | 关闭 | 完全不校验,任何来源都收 |
| 1 | 严格模式 | 回程路由的出接口必须与入接口完全一致,否则丢包 |
| 2 | 宽松模式 | 只要源地址在路由表中可达(任意接口)就放行,不要求同接口 |
关键点:内核实际生效的值 = max(conf/all/rp_filter, conf/ /rp_filter)。这意味着你把具体接口设成 0 是没用的,只要all里还是 1,最终就是 1。反过来把all设成 0,所有接口一起放开。这就是我在开头提到的那个坑:只改接口级,不改 all,等于没改。
这台机器的场景是典型的不对称路径:请求从eth0进来,但源地址属于对端另一个网段,回程路由走的是eth1。严格模式下这种包会被判为非法直接丢弃,而且不产生任何日志,/proc/net/snmp里也没有对应的错误计数,只有netstat -s里一个容易被忽略的 "InAddrErrors" 或者什么都没有。这就是为什么抓包能看到收包但看不到回包——包在进协议栈之前就没了。
修复上,我的建议是:只有明确知道自己在做非对称路由的机器,才把rp_filter调到 0 或 2,并且尽量用接口级配置缩小影响范围(同时记得把all也调低,或者一开始就别依赖 all)。生产环境下直接全局关掉rp_filter的做法,等于放弃了一个成本极低、收益不小的抗源地址伪造手段。
2.3 重定向与源路由:几项默认该关的开关
conf/<iface>/accept_redirects和send_redirects管的是 ICMP 重定向。前者控制本机是否接受别人发来的"下次请走这个网关"的建议,后者控制本机是否给同一网段的其他机器发这种建议。
ICMP 重定向在设计之初是为了让网络自适应,但在现代网络里它带来的麻烦远大于收益。一台被攻陷的内网机器只要发几个伪造的重定向报文,就能把周围机器的默认网关指向自己,这是非常经典的中转劫持手法。所以服务器场景下我一般两样都关掉。要注意send_redirects就是前面提到的那唯一一个不参与 all 广播的参数,必须逐个接口设置。
conf/<iface>/accept_source_route控制是否接受源路由选项的包。源路由允许发送方指定包经过的路径,正常业务几乎用不到,关掉没有任何副作用。accept_local一般保持默认 0 就行,除非你在做特殊的本地路由实验。
还有一个icmp_echo_ignore_all,在net.ipv4/根目录下,打开之后整台机器不响应 ping。这玩意儿不能当作安全手段——端口扫描、服务探测根本不依赖 ICMP,关掉 ping 只会让你自己排查问题时更痛苦。真正需要隐藏的地方,用防火墙规则做精确控制,别在这儿图省事。
2.4 分片相关参数与 MTU 黑洞
ipfrag_high_thresh、ipfrag_low_thresh、ipfrag_time这三个控制 IP 分片重组队列的水位线。默认情况下ipfrag_high_thresh大约 4MB,ipfrag_time是 30 秒。在正常网络上你几乎不会碰到它们,但只要出现"小包能通、大包不通"或者"HTTPS 握手卡在某个阶段",就该想到分片。
更常见的元凶是 MTU 不匹配导致的黑洞。路径上某台设备 MTU 比两端都小,又不回 ICMP "fragmentation needed",发送方就永远不知道该减小报文,大包一路消失。这类问题的不对称性极强——ping 通、SSH 能连、网页打不开,非常误导。
排查手法是固定的:先用ping -M do -s <size>逐级往下试,找到链路真实 MTU,再决定是调网卡 MTU 还是打开net.ipv4.tcp_mtu_probing。这个参数有三档:
- 0:关闭,不做路径 MTU 探测。
- 1:只在检测到 ICMP black hole 时启用探测。
- 2:始终启用探测。
1 档在日常场景下够用,但要理解它的代价——探测意味着更大的 MTU 报文被拆小,吞吐会有一定损失。如果你的网络链路质量稳定、MTU 全网一致,开它没必要;如果你跑的是跨厂商、跨云、隧道叠加的混合链路,开 1 能省掉大量玄学问题。
3. 握手阶段的队列:SYN 队列、accept 队列与 cookie
TCP 建连时内核维护着两个长度不同、位置不同、被两个不同参数约束的队列。这两个队列搞混,是排查"连接超时但服务没压力"时最常见的分叉点。
3.1 两个队列的物理含义
第一个是SYN 队列(也叫半连接队列)。收到 SYN、发出 SYN-ACK、还没收到对方 ACK 的连接停在这里,状态是SYN_RECV。第二个是accept 队列(全连接队列)。三次握手完成、状态变成ESTABLISHED,但应用还没调用accept()把它取走的连接排在这里。
两个队列的溢出表现完全不同:
- SYN 队列满了,新来的 SYN 会被丢弃,客户端表现为连接超时,重试之后可能才成功。
- accept 队列满了,握手照样能完成(在
tcp_abort_on_overflow=0的情况下),但新连接会被悄悄丢掉,客户端表现为连上了但应用没反应,或者握手完成后立刻收到 RST。
第二个队列的问题尤其隐蔽,因为从网络侧看握手是成功的,ss -s里 established 数也在涨,只有应用侧知道有些请求根本没进来。
3.2 tcp_max_syn_backlog 与 somaxconn 不在同一个目录
这是命名带来的历史包袱,也是我见过最多人找错地方的点:
| 队列 | 约束参数 | 所在位置 | 实际长度 |
|---|---|---|---|
| SYN 队列 | tcp_max_syn_backlog | /proc/sys/net/ipv4/ | 受 backlog 与它共同约束,取较小者 |
| accept 队列 | somaxconn | /proc/sys/net/core/ | min(backlog, somaxconn) |
somaxconn根本不在net.ipv4下面,它在net.core里。很多人调优时只盯着net.ipv4目录,找半天找不到,最后去调tcp_max_syn_backlog,发现 accept 队列溢出的问题一点没改善。
另一个高频混淆点是:listen(fd, backlog)里的backlog参数、somaxconn、tcp_max_syn_backlog三者到底谁听谁的。记住一句话就够了——应用传的 backlog 会被内核用somaxconn截断,然后 SYN 队列再被tcp_max_syn_backlog截断一次。所以哪怕应用写了listen(fd, 1024),如果somaxconn还是默认的 128(老版本默认值),全连接队列上限就死死在 128。
下面这一段是我实际用过的检查方式,看的是监听套接字的当前队列状态:
# LISTEN 状态下:Recv-Q 是当前全连接队列长度,Send-Q 是队列上限 ss -lnt sport = :8080Recv-Q持续贴近Send-Q,就是明确的溢出信号。再配合内核计数器确认:
nstat -az | grep -Ei 'ListenOverflows|ListenDrops|SyncookiesSent'ListenOverflows涨得快,说明全连接队列在丢连接,去调somaxconn和应用 backlog;SyncookiesSent暴涨,说明 SYN 队列压力大,看tcp_max_syn_backlog和是否有攻击流量。
3.3 tcp_syncookies 该不该关
net.ipv4.tcp_syncookies在多数现代发行版上默认就是 1。它的作用是在 SYN 队列被打满时,不保存半连接状态,而是把必要信息编码进 SYN-ACK 的序列号里,等对方 ACK 回来再重建状态。这等于在压力下用 CPU 换内存,是应对 SYN Flood 的经典手段。
关于它有两个流传很广的说法需要纠正。一是"开着 syncookies 会丢 TCP 选项",早期内核确实如此,因为序列号空间不够塞下所有选项,但现代内核只在乎 MSS、窗口缩放这几个关键字段,实际业务影响可以忽略。二是"开了它就不怕 SYN Flood",纯属误解——syncookies 只保证队列打满时还能接受正常连接,带宽被打满、连接表被占满这些层面它一样无能为力。
我的实践是保持默认开启。唯一需要留意的是,如果你的监控里SyncookiesSent长期非零,说明 SYN 队列一直在被打满,这本身就是个需要处理的问题,不能因为"开着 cookie 没出事"就忽略掉。
3.4 tcp_abort_on_overflow 是止痛药不是解药
net.ipv4.tcp_abort_on_overflow默认 0。设成 1 之后,accept 队列满时内核会直接给客户端回 RST,而不是默默丢弃。
这个参数的定位很微妙。打开它,客户端能快速失败并重试,避免"卡住几十秒"的糟糕体验;但它同时也把"服务端处理不过来"这个事实暴露给了客户端,而且 RST 在某些客户端上会触发退避,反而降低整体成功率。更重要的是,它治不了病——队列满的根因永远是应用 accept 太慢或者并发太高,改这个参数只是把症状换了个表现形式。
我见过把它当成标准配置批量下发的做法,结果是无状态短连接场景下客户端重试率上升,业务侧还以为是网络抖动。正确的用法是:只在明确知道队列偶尔瞬时打满、且应用有幂等重试能力时临时打开,用来争取定位时间,事后必须把根因解决掉。
4. 端口、TIME_WAIT 与连接回收:客户端侧最常动的一块
如果说前面的参数是服务端的事,这一组基本是客户端和服务端都要面对的。凡是做过高并发调用外部服务的系统,最后都会走到这几个参数面前。
4.1 ip_local_port_range 的算法与端口耗尽
net.ipv4.ip_local_port_range定义了本机发起主动连接时,可以使用的本地端口区间,默认值一般是32768 60999,也就是 28232 个端口。这个数字看着不少,但端口能不能复用取决于四元组,不是取决于总量。
一个主动连接占用的四元组是:源 IP、源端口、目的 IP、目的端口。只要四元组不重复,连接就能建立。所以真正的容量瓶颈是"本机到某一个特定目标 IP:端口"的连接数——因为目的 IP 和目的端口是固定的,源 IP 也固定,能变化的只有源端口,上限就是端口区间的长度。
这就是为什么单实例高频调同一个下游服务时,很容易撞上 "Cannot assign requested address"。扩大区间是最直接的办法:
sysctl -w net.ipv4.ip_local_port_range="10240 65000"但扩大区间要小心两件事。一是下限别压得太低,很多服务会用到1024以下的知名端口,虽然本地端口不受 1024 限制,但太低容易和别的组件冲突。二是要跟ip_local_reserved_ports打好配合。
另外要注意,端口耗尽只是表象。真正的解法通常是连接池复用——如果一个服务每秒建上千个短连接去调下游,你调参数能撑一阵,但迟早还是会撞墙,而且连接建立本身的 RTT 和握手开销已经吃掉了大量性能。
4.2 ip_local_reserved_ports 的作用
net.ipv4.ip_local_reserved_ports用来从本地端口池里挖掉一段,防止内核随机挑到一个你业务上要用的端口。格式是逗号分隔的区间列表,比如8080,8443,10000-10100。
这个参数的存在感很低,但在一种场景下非它不可:你自己的服务也在监听某些高位端口,同时这台机器又是客户端要主动往外连。理论上内核挑本地端口时会避开已被占用的端口,但存在竞态——检查时端口空闲,绑定前的一瞬间被别的进程抢了,或者反过来,内核挑了个端口,然后你自己的服务启动绑定时发现被占。reserved_ports 是把这个窗口彻底封死的方法。
改动的时候注意,reserved_ports的区间必须落在ip_local_port_range的范围内才有意义,写在范围外不起任何作用。
4.3 tcp_tw_reuse 的边界与 tcp_tw_recycle 的删除
TIME_WAIT 是 TCP 主动关闭方最后停留的状态,持续 2MSL(通常是 60 秒)。它的存在有明确目的:保证最后一个 ACK 真的被对方收到,同时让旧连接的重复报文在网络上自然消亡,不会串到新连接上。
net.ipv4.tcp_tw_reuse控制是否允许主动发起的出站连接复用处于 TIME_WAIT 的端口。注意三个限定词:出站、出站、出站。它对被动接受的连接完全无效,所以那些"调了 tw_reuse 但服务端 TIME_WAIT 还是很多"的抱怨,从一开始就找错了方向。它还有一个隐含前提——tcp_timestamps必须是开着的,因为复用判定依赖时间戳来区分新旧连接。现代内核里 timestamps 默认就是 1,但有些做了安全加固的环境会关掉它,那就别指望 tw_reuse 有什么效果了。
net.ipv4.tcp_tw_recycle是另一回事,它在 Linux 4.12 里被彻底删除了。它当初的设计依赖"同一对端的时间戳单调递增"这个假设,在 NAT 后面完全失效——同一个 NAT 出口后面挂着多台机器,它们的时间戳各自为政,服务端一看到时间戳倒退就把连接丢掉,表现就是"部分用户在特定时间窗口内连不上"。这个问题的爆发面太广,最后只能把参数删掉。如果你手上的文档还在安利它,那份文档至少该更新了。
4.4 tcp_max_tw_buckets 与 tcp_fin_timeout 的联动
net.ipv4.tcp_max_tw_buckets是 TIME_WAIT 套接字的数量上限。超过这个数之后,内核会直接杀掉新的 TIME_WAIT 套接字,并给TcpExt里的TCPTimeWaitOverflow计数加一。
这个参数容易被当成"解决 TIME_WAIT 太多"的银弹,实际上它带来的是风险转移:TIME_WAIT 被强制中断,意味着原本该保留 60 秒的状态没了,后续重复报文可能干扰新连接,而且这个过程本身是有开销的。它更像一道保险丝,防止异常流量把端口和内存吃光,而不是常规调优项。
真要处理 TIME_WAIT 数量,先搞清楚是谁在制造它。ss -tan state time-wait统计一下数量,再用ss -tan state time-wait sport = :xxxx看看集中在哪个服务的端口上。如果集中在某个高频调用的下游服务,去改连接池;如果是服务端产生的,说明是服务端主动关闭连接,看看是不是keepalive配置太短或者应用层主动断连。
net.ipv4.tcp_fin_timeout默认 60,只对一种连接生效:本端主动关闭、且已经进入 FIN_WAIT_2 状态等待对端 FIN。这个状态本质上是"我等对方关闭确认,但对方迟迟不发"。调小它能让这些挂着的连接尽快释放,但如果你遇到的是大量真实 TIME_WAIT(状态确实是 TIME_WAIT 而不是 FIN_WAIT_2),调这个参数一点用都没有。
还有一个相关参数net.ipv4.tcp_orphan_retries,默认 0(内核按 8 处理),控制孤儿套接字的重试次数。孤儿指的是应用已经关闭了 fd,但内核还保持着连接状态。降低这个值能让孤儿连接更快消失,代价是可能提前放弃一个其实还能用的连接。
5. 缓冲区、拥塞控制与延迟优化
到这一层,参数调的是"连接建立之后跑多快"。这类调整的收益往往比想象的更依赖具体业务特征,同一个参数在短连接 API 网关和长连接推送服务上的最优值可能完全相反。
5.1 tcp_rmem / tcp_wmem 三元组怎么读
net.ipv4.tcp_rmem和net.ipv4.tcp_wmem都是三个值,含义是<最小值> <默认值> <最大值>,单位是字节。
- 最小值:即使内存再紧张,每个 socket 的缓冲区也不低于这个数。用于保证低内存下连接不至于完全无法工作。
- 默认值:自动调优的起始点。注意这个"默认值"不代表缓冲区就固定这么大,现代内核默认开着自动调优,会根据连接的实际吞吐动态伸缩。
- 最大值:自动调优的天花板,也是手工
setsockopt时的上限。
自动调优的机制是为每条连接估算 BDP(带宽时延积),也就是"这条链路上同时在途的数据量",然后把缓冲区调到能填满这个管道的大小。所以高延迟长肥管道场景下,如果tcp_rmem的最大值太小,无论网络多好,单连接吞吐都上不去——因为窗口撑不开。
# 典型检查:看看接收缓冲区是不是被动手脚了 cat /proc/sys/net/ipv4/tcp_rmem cat /proc/sys/net/ipv4/tcp_wmem多大会适可而止?不是越大越好。每个 socket 的缓冲区上限直接决定了一条连接在最坏情况下能吃掉多少内存,把它从 6MB 调到 64MB,意味着 1 万个并发连接在最坏情况下可能占用 640GB。真实操作里我一般按"单连接 BDP 的 2 倍"去估算最大值,再乘以预期并发数,跟机器内存对一下,能不能扛住一目了然。
5.2 tcp_mem 的单位是页
net.ipv4.tcp_mem也是三个值,控制整个协议栈可以使用的物理内存页数,分低水位、压力水位、高水位。它不在tcp_rmem旁边,名字也像,但单位是内存页,不是字节。4KB 一页的机器上,写着400000意思是大约 1.6GB。
这个坑我见过不止一次:有人照着某篇博客把tcp_mem设置成三百万,以为在调字节,结果把整台机器四分之三的内存都划给了 TCP 缓冲区,其他进程开始频繁触发内存回收。改之前先getconf PAGESIZE确认页大小,再乘以页数心里才有数。
跟tcp_mem配合的还有net.core.rmem_max、net.core.wmem_max,这两个是所有 socket 类型共用的上限(不只是 TCP),单位才是字节。两个体系都有上限的时候,实际生效的是更小的那个,所以经常出现"我改了tcp_rmem最大值怎么吞吐没变"——因为net.core.rmem_max还在默认值上卡着。
5.3 拥塞控制算法与 tcp_slow_start_after_idle
net.ipv4.tcp_congestion_control决定默认算法,现代发行版基本是cubic。查看可用算法和执行切换:
sysctl net.ipv4.tcp_congestion_control sysctl net.ipv4.tcp_available_congestion_control sysctl -w net.ipv4.tcp_congestion_control=bbr换算法前先确认模块已经加载(tcp_available_congestion_control里能列出来),不然写了也没用。算法选择上没有绝对优劣,得看链路丢包特性、RTT 分布和业务对延迟的敏感程度,这个只能实测,别人的结论搬过来未必成立。
net.ipv4.tcp_slow_start_after_idle默认 1,是个特别值得在长连接场景下检查的参数。它的逻辑是:连接空闲超过一个 RTO 之后,把拥塞窗口重置回初始值,相当于重新慢启动。对短连接、请求密集的服务来说这没影响,但对那种"平时安静、一有事件就推送一大段数据"的长连接服务,这个重置会让每次推送的头几秒速度惨不忍睹。关掉它的代价是:长期空闲的连接再次发送时,用的还是之前的窗口估计,如果链路状况在这段时间里恶化了,可能会引起一小波丢包。权衡下来,长连接推送场景我一般会关掉。
5.4 tcp_notsent_lowat、tcp_fastopen 这类"延迟税"
net.ipv4.tcp_notsent_lowat控制发送缓冲区里"还没发出去的数据"保留多少。设成 -1 是关闭这个机制,设成一个较小的值(比如 16KB 或 32KB)之后,缓冲区里未发送的数据量超过这个阈值,应用层的send()就会开始阻塞。听起来是限流,实际效果是减少排队延迟——数据不会在内核缓冲区里攒一大堆再慢慢发,而是尽快交给网卡。对于交互式协议、RPC 调用这类延迟敏感的流量,收益很明显;对于纯吞吐型的大文件传输,反而可能降低效率。
net.ipv4.tcp_fastopen是 TFO 的开关,取值是个位掩码:1 表示客户端启用,2 表示服务端启用,3 表示双向。它让客户端在 SYN 包里就带上数据,省掉一个 RTT。但要注意这是个端到端特性,中间设备(负载均衡、防火墙、某些老旧的 NAT)对带数据的 SYN 处理不一定友好,实际部署时先在链路两端都开上,然后确认握手成功率没有变化再推广。还有一点:应用层必须显式使用MSG_FASTOPEN或者TCP_FASTOPEN_CONNECT,光开内核参数不会自动生效。
6. 参数落地:持久化加载顺序、命名空间与生效验证
改参数本身是分钟级的事,难的是让它稳定、可复现、并且在出问题时能证明它确实生效了。这一节讲的东西比参数本身更容易埋雷。
6.1 sysctl.d 的加载顺序与覆盖规则
sysctl -w改的是运行时值,重启即失效,只能用来验证。要持久化就得写配置文件,而配置文件有好几个位置,加载顺序决定了谁覆盖谁。
在使用 systemd 的系统上,systemd-sysctl按下面的顺序读取,后面读到的覆盖前面的:
| 顺序 | 路径 | 说明 |
|---|---|---|
| 1 | /etc/sysctl.d/*.conf | 本机配置,优先级最高的目录 |
| 2 | /run/sysctl.d/*.conf | 运行时生成,重启丢失 |
| 3 | /usr/lib/sysctl.d/*.conf | 软件包安装的默认配置 |
| 4 | /usr/local/lib/sysctl.d/*.conf | 手工安装的软件 |
| 5 | /etc/sysctl.conf | 单独处理,最后加载,优先级最高 |
同一目录内部按文件名字典序加载,所以社区习惯用99-前缀让自定义配置排在最后。但注意99-sysctl.conf常常是个指向/etc/sysctl.conf的软链接,跟真正的99-zz-custom.conf之间谁先谁后是按字符串比较的,99-sysctl.conf里的s排在99-zz的z前面,所以99-zz-custom.conf后加载、会赢。这种细节不实测一次根本记不住。
改完之后重新加载:
sysctl --system # 按上述顺序全量重载 sysctl -p /etc/sysctl.d/99-custom.conf # 只加载单个文件有一个坑必须提:sysctl --system遇到不存在的参数会报错,但报错之后是否继续取决于实现。老版本的sysctl -p在某些情况下会中断后续条目,导致排在后面的配置全部没生效。因此配置文件里不要写当前内核不支持的参数,尤其是那些从别的机器复制过来的模板。每加一条,重载一次,cat一次确认,比一次性贴两百行然后祈祷要可靠得多。
6.2 容器里的参数到底归谁管
网络命名空间隔离了大部分net.ipv4参数,但隔离得不彻底,这就形成了三种情况:
- 完全隔离:绝大多数
net.ipv4.conf.*、tcp_*参数是 per-netns 的,容器里改了只影响自己。 - 跟随宿主:有些参数在容器创建时从宿主复制,之后各自独立演化。
- 全局唯一:少数参数在整个系统层面只有一份,容器里改会影响所有命名空间。
所以看到"容器内 sysctl 报 Operation not permitted"不用惊讶。默认情况下容器里的/proc/sys是只读挂载的,要改得在创建时传入,比如容器运行时提供的--sysctl参数:
# 以容器运行时为例,创建时指定,运行中改不了 <container-runtime> run --sysctl net.ipv4.tcp_tw_reuse=1 ...更麻烦的是,即使传进去了,某些参数也会被运行时白名单挡掉,因为放开它们可能影响宿主或其他容器。真正需要精细调参的场景,老老实实在宿主机层面配置,或者给容器开足够的权限——但要清楚这意味着放弃了隔离带来的一部分安全性。
还有一个容易被忽略的点:容器里看到的内核参数值,是创建命名空间那一刻从宿主复制过来的。宿主后来改了某个参数,已经在跑的容器不会跟着变。所以宿主的sysctl.conf改了之后,滚动重启一遍业务容器是必要的,否则新老容器的参数不一致,排查问题时会出现"同样的镜像同样的配置,行为却不一样"。
6.3 用 nstat 和 ss 验证参数是否真的起作用
参数改完,cat出来是新值,不代表行为就变了。有些参数需要重启网络命名空间,有些需要重建连接,有些会被其他参数覆盖。验证只看计数器。
nstat -az是读取/proc/net/netstat和/proc/net/snmp的友好封装,输出的是累计计数器。常用的几个:
| 计数器 | 来源 | 含义 |
|---|---|---|
ListenOverflows | TcpExt | 全连接队列溢出次数 |
ListenDrops | TcpExt | 因队列问题丢弃的连接总数 |
SyncookiesSent | TcpExt | 因 SYN 队列满而启用 cookie 的次数 |
TCPTimeWaitOverflow | TcpExt | TIME_WAIT 被强制销毁的次数 |
TCPAbortOnMemory | TcpExt | 因内存不足中止连接 |
RetransSegs | Tcp | 重传报文段总数 |
InErrs | Tcp | 收到的错误报文数 |
用法是改前记一次,改后过一段时间再记一次,看增量。绝对值毫无意义——一台跑了半年的机器RetransSegs几十万很正常。真正有用的是"改造前后单位时间内的增量变化",以及"增量是否随着流量线性增长"。
# 建议做成定时采集,画出趋势再判断 nstat -az | grep -E 'ListenOverflows|ListenDrops|SyncookiesSent|TCPTimeWaitOverflow'ss则用来看瞬时状态。除了前面提到的ss -lnt看队列水位,还有几个组合很好用:
ss -s看各类套接字的总量分布,判断 TIME_WAIT、orphan 是否异常。ss -tani看单条连接的详细状态,包括重传次数retrans、拥塞窗口cwnd、RTT。ss -tan state syn-recv看半连接队列的瞬时堆积情况。
还有个细节:ss -tani里的retrans是这条连接的历史累计值,不是当前值,看到非零不代表有问题。连续采样几次,看它是稳定不涨还是持续增长,才有判断依据。
7. 一次多网卡丢包的排查链路复盘
回到开头那台双网卡机器。整个过程大概花了四个小时,走的弯路值得拿出来单独说说,因为里面几乎每一步都是典型的错误方向。
7.1 从现象到指标:先确认问题出在哪一层
现象是客户端到服务端偶发连接超时,比例大概 20%,而且越是跨网段越容易出现,同一网段基本不出问题。第一步是分清楚是 TCP 层的问题还是更底层的问题。
第一轮怀疑是服务端应用慢。但客户端用curl -v看得很清楚,超时的时候 TCP 三次握手根本没完成,连接阶段的失败说明还没走到应用层。第二轮怀疑是中间网络丢包,但同网段不出问题、跨网段才出问题,加上超时的时间几乎固定,这不太像随机丢包的特征,更像某种判定后主动丢弃。
接下来是关键的取数环节:
- 在服务端
tcpdump抓两个网卡的包,确认 SYN 确实到达了eth0。 - 确认服务端没有发出 SYN-ACK(不是发了但丢了)。
netstat -s里看InAddrErrors、InHdrErrors这一类收包错误计数,发现确实在涨。ss -lnt看监听队列,状态正常,没有溢出。
到第 3 步基本就把范围缩到"包到了网卡,但没被协议栈正常接收"。这时候才开始怀疑收包校验类参数。
7.2 参数溯源:为什么改了一个值还是不通
第一个动作是查rp_filter,这一步方向是对的,但执行错了:
# 错误示范:以为改了接口级就够了 sysctl -w net.ipv4.conf.eth0.rp_filter=0 sysctl -w net.ipv4.conf.eth1.rp_filter=0 cat /proc/sys/net/ipv4/conf/all/rp_filter # 结果是 0当时看到all是 0,就以为全放开了,结果业务一点没好转。后来逐个接口cat,才发现lo和default这两份的值跟想象的不一样,而且更关键的是——这两台机器的内核版本不同,其中一台上all的读取表现和另一台不一致,误判就发生在这里。
正确的做法是绕过all,直接对所有接口做遍历,同时确认all本身的值:
# 一次性把状态看全,别靠猜 grep '' /proc/sys/net/ipv4/conf/*/rp_filter查下来是conf/all/rp_filter = 1,而具体网卡是 0。内核取最大值,最终生效就是 1。把conf/all/rp_filter也改成 0 或 2 之后,问题当场消失。
这里有个原则值得强调:在内核取最大值/最小值语义的参数上,单个接口的调整不足以覆盖全局。遇到这类参数,先确认它的合并规则,再决定改哪一层。rp_filter用的是最大值,forwarding用的是继承,send_redirects是特例不参与广播——三种规则混在一起,靠记忆是不行的,必须查文档或实测。
7.3 复盘:哪些参数不该动,哪些该早点查
这件事之后我调整了几个习惯,都是踩过之后才养成的。
第一,不要全局关闭安全类参数。当时第一反应是把rp_filter全关,虽然问题解决了,但等于在整台机器上去掉了源地址伪造防护。后来改成:只有确实存在非对称路径的那台接口设成 2(宽松模式),其余接口保持 1,all也跟着收紧。宽松模式只要求源地址在路由表中可达,比完全不校验安全得多,对非对称路由场景又足够宽容。
第二,改动必须一次性对齐所有受影响的作用域。conf/all、conf/default、每个具体接口,这三层都要看一眼。只改一层,在不同内核版本上表现可能完全不同,靠"我记得是这样"的直觉排查网络问题,成本极高。
第三,先把基线快照打下来再动手。那次排查中途有两次,为了验证猜想随手改了十几个参数,改完忘了改回来,后面就分不清哪些是原始值哪些是自己改的,白折腾了一个小时。现在我的习惯是任何改动之前先sysctl -a > baseline.txt,改完 diff 一遍,心里有底。
第四,别忽略dmesg之外的沉默丢包。rp_filter丢弃的包不产生任何日志、不进入协议栈计数器,这是最坑的地方。遇到"抓包能收到、协议栈没反应"的情况,收包校验类参数(rp_filter、accept_local、accept_source_route)永远排在怀疑列表前三。
还有个后续的小技巧:这类参数调整我一般不写进sysctl.conf一把梭,而是单独放在一个语义明确的小文件里,比如/etc/sysctl.d/90-network-asym.conf,文件头写清楚"为什么这么设"和"哪个接口存在非对称路径"。半年后别人接手,或者自己回来看,能省掉重新推理的功夫——参数值本身是死的,设它的理由才是活的,而理由不写下来,通常就永远丢失了。