哈喽各位,我是内核喵。
最近在排查一个线上集群的压测性能瓶颈时,又看到了熟悉的场景:运维同事在看到海量 TIME_WAIT 之后,熟练地在某度上搜出十年前的博客,抄上一句 net.ipv4.tcp_tw_recycle = 1 准备往 /etc/sysctl.conf 里怼。我赶紧伸手拦住:“兄弟,手下留情!Linux 4.12 内核早就把这玩意儿彻底拔掉了,而且在 NAT 网关下开这个就是灾难!”
今天我们不谈玄学调优,直接把探针插到内核协议栈里,深入剖析 TCP TIME_WAIT 的轻量化设计,以及被很多人当作性能神药的 SO_REUSEPORT 到底在底层做了什么。
一、 TIME_WAIT:不是洪水猛兽,内核到底在等什么?
很多同学一看到 ss -s 里面有好几万个 TIME_WAIT 就慌得不行,觉得内存要爆、端口要被占满。先等等,内核设计者比我们精明得多。
1. 为什么必须有 2MSL?
从 TCP 状态机来看,主动关闭连接的一方才会进入 TIME_WAIT 状态,并维持 2MSL(Maximum Segment Lifetime,Linux 源码里硬编码为 60 秒,见 include/net/tcp.h 的 TCP_TIMEWAIT_LEN):
- 防止“迷途报文”污染新连接:避免上一次连接的延迟分节在新连接建立后(相同四元组)到达,导致数据混乱。
- 保证全双工连接可靠关闭:如果最后的 ACK 丢失,对端重发 FIN,处于
TIME_WAIT的端可以再次重传 ACK。如果过早关闭,收到 FIN 就会直接回 RST。
2. 内核结构压缩:inet_timewait_sock
很多人以为处于 TIME_WAIT 的套接字和普通套接字一样占资源。实际上,当连接进入该状态时,内核会调用 tcp_time_wait(),将庞大的 struct tcp_sock(约 2KB+)销毁,换成轻量级的 struct inet_timewait_sock(只有 200 多字节),挂在独立的 tcp_hashinfo.ehash 中。
我们可以用 bpftrace 来抓一下这个状态转换和分配开销:
# bpftrace -e 'kprobe:tcp_time_wait { @[kstack] = count(); }'
你会看到调用链直接走到了这里:
tcp_fin
tcp_data_queue
tcp_rcv_state_process
tcp_v4_do_rcv
tcp_v4_rcv
...
在 Linux 中,几万个 TIME_WAIT 占用的内存不过几十兆,真正致命的是客户端高频短连接打满可用端口号,导致 EADDRNOTAVAIL。
二、 治理迷思:别再盲目抄 sysctl
1. tcp_tw_recycle 为什么被扔进历史垃圾堆?
这个参数曾依赖 TCP 时间戳选项(PAWS),在服务器端记录对端主机的最新时间戳。如果是单个客户端没问题,但一旦客户端走 SNAT 网关(如办公室局域网出口),不同机器的时间戳不一致,后面的报文时间戳如果小于前一个客户端的时间戳,内核直接静默丢包! 线上报障表现为“部分人偶发连不上”。在内核 4.12 之后,这个选项被彻底移除。
2. tcp_tw_reuse 是如何工作的?
这是很多生产环境(作为客户端/反向代理)的解药,但注意:它只对客户端(主动发起 connect)有效。
内核判断一个处于 TIME_WAIT 的 socket 是否能被复用,核心逻辑在 net/ipv4/tcp_ipv4.c 的 tcp_twsk_unique() 中:
int tcp_twsk_unique(struct sock *sk, struct sock *sktw, void *twp)
{
const struct tcp_timewait_sock *tcptw = tcp_twsk(sktw);
struct tcp_sock *tp = tcp_sk(sk);
/* 必须开启 tcp_timestamps 且新连接的 TS 递增 */
if (tcptw->tw_ts_recent_stamp &&
(!twp || (tp->write_seq != tcptw->tw_snd_nxt && (s32)(tp->write_seq - tcptw->tw_snd_nxt) > 0))) {
// 允许安全复用
return 1;
}
return 0;
}
治理结论:
- 如果你是服务端被大量
TIME_WAIT淹没:说明是你在主动关连接!先去排查是不是你的 HTTP 服务没开启 Keep-Alive,或者上游代理没有长连接复用。 - 如果你是网关/反向代理(如 Nginx):开启
net.ipv4.tcp_tw_reuse = 1,同时确保net.ipv4.tcp_timestamps = 1,并适当调大net.ipv4.ip_local_port_range。
三、 SO_REUSEPORT:打破单 Listen Socket 的自旋锁瓶颈
在高并发多核服务器上,处理海量新建连接时,另一个常见瓶颈是单一 Listen Socket。
1. 传统多进程模型痛点
传统架构下(如旧版 Nginx / 多线程服务器),所有 Worker 共享同一个 listen fd。多个进程同时 epoll_wait 唤醒争抢连接,早年内核有严重的“惊群效应”;即使后来内核用 EPOLLEXCLUSIVE 优化了惊群,依然存在一个核心痛点:所有 Worker 在调用 accept() 时,必须竞争该 Listen Socket 的自旋锁(listen_lock)。
用 perf top 抓一下,你可以看到在巨量新建连接压迫下,锁竞争有多剧烈:
Samples: 42K of event 'cycles', 4000 Hz, Event count: 18273618
Overhead Shared Object Symbol
........ .................. ...................................
42.15% [kernel] [k] _raw_spin_lock
|
--- _raw_spin_lock
|
|--38.20%-- inet_csk_accept
| __sys_accept4
| sys_accept4
2. SO_REUSEPORT 底层原理
Linux 3.9 引入了真正的 SO_REUSEPORT 支持。它的核心逻辑是:允许多个独立套接字 bind 到完全相同的 IP + Port 上。
内核为这些套接字分配一个共享的数组结构 sock_reuseport。当网卡收到三次握手的第一段 SYN 报文时,在查找 Listen Socket 阶段(inet_lookup_listener),内核通过哈希算法将流量均匀打散:
// 简化自 net/core/sock_reuseport.c: reuseport_select_sock()
struct sock *reuseport_select_sock(struct sock *sk,
u32 hash,
struct sk_buff *skb,
int hdr_len)
{
struct sock_reuseport *reuse = rcu_dereference(sk->sk_reuseport_cb);
int socks;
socks = reuse->num_socks;
// 经典对四元组 Hash 结果做求模
return reuse->socks[reciprocal_scale(hash, socks)];
}
每个 Worker 进程有自己独立的 Listen Socket、独立的队列,彻底消除了内核层的锁竞争。
四、 实战避坑:SO_REUSEPORT 的灰度与丢包陷阱
SO_REUSEPORT 虽好,但也绝非银弹。在实施连接治理时,有两个硬坑必须小心:
1. 进程动态平滑重启(Reload)丢包
当你的服务升级,退出旧 Worker 并启动新 Worker 时:
- 如果一个 Worker 进程挂掉或退出(关闭了它的 listen fd),内核会将其从
reuse->socks数组中移除。 - 但此时该 socket 的全连接队列(Accept Queue)里可能还残留着已经完成握手、但尚未被用户态
accept()走的连接! - 这一瞬间,这些连接会被内核直接丢弃,向对端客户端发送 RST。
2. 终极解法:挂载 eBPF SK_REUSEPORT
现代 Linux 提供了 BPF_PROG_TYPE_SK_REUSEPORT。我们可以编写 eBPF 程序注入到套接字选择阶段,完全接管内核默认的哈希分发逻辑:
SEC("sk_reuseport")
int select_by_custom_logic(struct sk_reuseport_md *ctx)
{
// 可以实现:
// 1. 如果旧 Worker 正在 draining,不再分配新连接
// 2. 将特定 IP 的流量定向到指定的 Worker 进程
// 3. 实现真正的零丢包热升级
return BPF_OK;
}
总结
深入到内核层,你会发现网络调优从来不是改几个数值那么简单:
- 对待 TIME_WAIT:它是 TCP 可靠性的屏障。如果是服务端主动关连接,改 Keep-Alive;如果是客户端端口枯竭,结合
tcp_tw_reuse与时间戳选项,而不是迷信已被内核废弃的tcp_tw_recycle。 - 对待新建连接吞吐瓶颈:先用
perf抓一抓是不是卡在_raw_spin_lock和 Listen 队列争抢;如果是,果断开启SO_REUSEPORT。但在热升级场景下,务必配合优雅退出机制或借助 eBPF 进行连接导流,防患于未然。
调优千万条,压测第一条;不看 perf 和源码盲目上线,最终只能是一地鸡毛。随时欢迎在评论区贴出你们的内核堆栈,我们一起较真推演!
许可协议:CC BY-NC 4.0
更新于 2 天前
觉得文章有帮助?点个赞吧!
0 条评论


