3. TCP三次握手与并发:握手过程详解、半连接队列与全连接队列、SYN Flood攻击原理与防范、高并发下的握手优化(TCP_DEFER_ACCEPT)

聊到服务并发,TCP三次握手是绕不开的坎。很多新手觉得握手就是三次报文交换,没啥好讲的。但我在一线摸爬滚打这些年,发现握手阶段恰恰是服务端最容易翻车的地方。今天咱们就把这块掰开揉碎了讲清楚。

3.1 三次握手,你真的懂了吗?

先快速过一遍标准流程。客户端发SYN,服务端回SYN+ACK,客户端再回ACK。嗯,教科书上就这么写的。但实际在服务端视角,事情要复杂得多。

服务端收到SYN后,会创建一个半连接,放入半连接队列。等收到客户端的ACK后,这个连接才变成全连接,移入全连接队列。应用层调用accept(),就是从全连接队列里取一个连接出来。

我当年刚接手一个网关项目时,发现线上偶尔出现连接超时。查了半天,原来是半连接队列满了,新来的SYN直接被内核丢掉。那时候我才真正意识到,这两个队列的容量和交互逻辑,直接决定了服务的并发上限。

核心要点:三次握手不是简单的三次报文交换,而是服务端通过两个队列(半连接队列、全连接队列)来管理并发连接的过程。队列容量和溢出策略,是握手阶段性能的关键。

TCP三次握手与队列交互流程 客户端 服务端 半连接队列 (SYN_RCVD状态) 全连接队列 (ESTABLISHED状态) 应用层 accept() ① SYN ② SYN+ACK ③ ACK 完成握手 取出连接 ⚠ 注意 半连接队列满 → 丢SYN 全连接队列满 → 丢ACK 应用层慢 → 队列堆积

3.2 半连接队列与全连接队列

这两个队列是服务端并发能力的命门。我见过太多线上事故,根源就是队列配置不合理。

半连接队列(SYN Queue)

服务端收到SYN后,连接进入SYN_RCVD状态,放在半连接队列里。这个队列的大小由内核参数net.ipv4.tcp_max_syn_backlog控制,默认一般是1024。如果短时间内涌入大量SYN,队列满了,后续SYN就会被丢弃。

我的经验:我曾经在压测一个短连接服务时,发现QPS上不去,反而大量连接超时。查了半连接队列的统计,发现丢包严重。调大tcp_max_syn_backlog后,问题立刻缓解。但注意,这个值不是越大越好,它会占用内核内存。

全连接队列(Accept Queue)

三次握手完成后,连接进入ESTABLISHED状态,移入全连接队列。应用层调用accept(),就从这里取。全连接队列的大小由listen(fd, backlog)的backlog参数决定,但实际还受net.core.somaxconn限制。

全连接队列满了会怎样?内核行为取决于tcp_abort_on_overflow参数:

参数值 行为 后果
0(默认) 直接丢弃ACK,客户端重试 客户端超时重试,体验变差
1 发送RST复位报文 客户端收到RST,连接立即失败

我个人习惯把tcp_abort_on_overflow设为0。为什么呢?因为丢ACK让客户端重试,至少还有机会连上。直接发RST,客户端就彻底断了。你想想看,哪个更友好?

避坑指南:我曾经遇到一个诡异问题——服务偶尔返回"Connection reset by peer"。查了半天,发现是应用层处理太慢,全连接队列满了,内核直接发RST。后来优化了应用层处理速度,同时调大了backlog,问题才彻底解决。

3.3 SYN Flood攻击原理与防范

SYN Flood是经典的DDoS攻击方式。攻击者伪造大量SYN报文,但收到SYN+ACK后不回复ACK。服务端傻傻地等,半连接队列很快被填满,正常用户连不上。

说白了,攻击者就是利用三次握手的漏洞——服务端需要为每个半连接分配资源,而攻击者几乎零成本。

防范手段

  1. SYN Cookie(最常用):半连接队列满时,不分配资源,而是用Cookie编码信息。收到ACK后验证Cookie,合法才建立连接。开启方式:net.ipv4.tcp_syncookies = 1。
  2. 增大半连接队列:调大tcp_max_syn_backlog,但治标不治本。
  3. 缩短超时时间:减少tcp_synack_retries,让半连接尽快释放。
  4. 反向代理层防护:在Nginx或LVS层面做SYN代理,过滤恶意流量。

我的建议:SYN Cookie是必开的,几乎零成本。但注意,开启后内核会禁用一些高级TCP特性(如大窗口、时间戳)。不过对于绝大多数业务场景,这点损失可以忽略。

3.4 高并发下的握手优化:TCP_DEFER_ACCEPT

这是今天要讲的重点。默认情况下,三次握手一完成,服务端就唤醒应用层。但这时候客户端可能还没发数据。应用层被唤醒后,调用accept(),然后read()阻塞等待数据。这中间有两次上下文切换,浪费CPU。

TCP_DEFER_ACCEPT的作用是:延迟accept()的触发时机,直到收到客户端的第一笔数据。说白了,就是把三次握手和数据接收合并成一次唤醒。

开启方式很简单:

int fd = socket(AF_INET, SOCK_STREAM, 0);
int optval = 1;
setsockopt(fd, IPPROTO_TCP, TCP_DEFER_ACCEPT, &optval, sizeof(optval));
listen(fd, backlog);

或者设置等待时间(秒):

int timeout = 3;  // 最多等3秒
setsockopt(fd, IPPROTO_TCP, TCP_DEFER_ACCEPT, &timeout, sizeof(timeout));

效果对比:

场景 默认行为 开启TCP_DEFER_ACCEPT
握手完成 立即唤醒应用层 不唤醒,继续等待数据
数据到达 再次唤醒应用层 一次唤醒,直接处理数据
空闲连接 占用全连接队列 不占用,减少资源浪费
CPU上下文切换 2次(accept + read) 1次(直接处理数据)

我的实战经验:在某个高并发网关项目中,我们开启了TCP_DEFER_ACCEPT,配合epoll的ET模式,整体吞吐量提升了约15%。但注意,如果客户端建立连接后长时间不发数据(比如HTTP长连接的空闲期),服务端会一直等,直到超时。所以超时时间要设置合理,我一般设3-5秒。

避坑指南:我曾经在一个WebSocket服务上开启TCP_DEFER_ACCEPT,结果发现握手阶段卡住了。原因是WebSocket客户端建立TCP连接后,先发HTTP Upgrade请求,但服务端在等数据,而客户端在等服务端响应...死锁了。后来排查发现,WebSocket的握手流程和普通HTTP不一样,不适合用这个优化。所以,TCP_DEFER_ACCEPT只适用于服务端主动等待客户端数据的场景,比如HTTP服务。

最后总结一下。三次握手看似简单,但队列管理、攻击防范、性能优化,每个点都能挖出坑来。我个人习惯在项目初期就把这些参数调好,而不是等线上出问题再救火。你想想看,一个连接超时问题,排查起来可能花半天,但提前配好参数只需要几分钟。这笔账,划算。