3. TCP三次握手与并发:握手过程详解、半连接队列与全连接队列、SYN Flood攻击原理与防范、高并发下的握手优化(TCP_DEFER_ACCEPT)
聊到服务并发,TCP三次握手是绕不开的坎。很多新手觉得握手就是三次报文交换,没啥好讲的。但我在一线摸爬滚打这些年,发现握手阶段恰恰是服务端最容易翻车的地方。今天咱们就把这块掰开揉碎了讲清楚。
3.1 三次握手,你真的懂了吗?
先快速过一遍标准流程。客户端发SYN,服务端回SYN+ACK,客户端再回ACK。嗯,教科书上就这么写的。但实际在服务端视角,事情要复杂得多。
服务端收到SYN后,会创建一个半连接,放入半连接队列。等收到客户端的ACK后,这个连接才变成全连接,移入全连接队列。应用层调用accept(),就是从全连接队列里取一个连接出来。
我当年刚接手一个网关项目时,发现线上偶尔出现连接超时。查了半天,原来是半连接队列满了,新来的SYN直接被内核丢掉。那时候我才真正意识到,这两个队列的容量和交互逻辑,直接决定了服务的并发上限。
核心要点:三次握手不是简单的三次报文交换,而是服务端通过两个队列(半连接队列、全连接队列)来管理并发连接的过程。队列容量和溢出策略,是握手阶段性能的关键。
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。服务端傻傻地等,半连接队列很快被填满,正常用户连不上。
说白了,攻击者就是利用三次握手的漏洞——服务端需要为每个半连接分配资源,而攻击者几乎零成本。
防范手段
- SYN Cookie(最常用):半连接队列满时,不分配资源,而是用Cookie编码信息。收到ACK后验证Cookie,合法才建立连接。开启方式:
net.ipv4.tcp_syncookies = 1。 - 增大半连接队列:调大
tcp_max_syn_backlog,但治标不治本。 - 缩短超时时间:减少
tcp_synack_retries,让半连接尽快释放。 - 反向代理层防护:在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服务。
最后总结一下。三次握手看似简单,但队列管理、攻击防范、性能优化,每个点都能挖出坑来。我个人习惯在项目初期就把这些参数调好,而不是等线上出问题再救火。你想想看,一个连接超时问题,排查起来可能花半天,但提前配好参数只需要几分钟。这笔账,划算。