如果你的服务器在流量冲击中突然连不上,登录上去第一眼看到内核日志里的possible SYN flooding on port 80. Sending cookies.,那你就已经和 SYN Cookies 打过照面了。这个机制在 Linux 内核里安静工作了二十多年,平时几乎感觉不到它的存在,但在半连接队列被塞满的那一瞬间,它就是守护 TCP 服务不被拖垮的最后一道防线。这篇文章想从一次真实的“服务打满超时”排查经历出发,把 SYN Cookies 的设计思路、内核实现细节、配置调优方法以及相关坑位一次讲清楚。适合正在排查连接超时的运维、写网络服务的后端开发,以及想搞懂内核 TCP 协议栈的读者。
1. 它解决的痛点:半连接队列是怎么被塞满的
1.1 三次握手的代价
TCP 建立连接需要三次握手:客户端发 SYN,服务器回 SYN+ACK,客户端再回 ACK。在第二步和第三步之间,服务器不能立刻把连接交给应用层,因为还没有确认客户端真的活着。这个“等 ACK”的阶段,连接处于半开状态,内核需要为它分配一个request_sock结构,记录双方的 IP、端口、初始序列号、MSS 等信息,然后把这个结构挂到监听 socket 的半连接队列里。
这个队列不是无限的。它的大小受到监听时传入的 backlog 参数、net.ipv4.tcp_max_syn_backlog等限制。正常情况下,客户端会在几十毫秒内回 ACK,半连接条目很快被移入全连接队列,进入accept队列等待应用层读取,生命周期非常短。但正因为它有大小限制、又必须等待第三方响应,它就成为 TCP 协议栈里最容易被人为制造瓶颈的环节。
打个比方:半连接队列就像是餐厅前台的一个预约本,正常客人在门口报个号,入座后服务员就把预约划掉。但如果有人雇了一群闲人不停打电话预约却从不到店,预约本很快就被写满,真正想吃饭的客人到了门口,服务员只能无奈地说“本子满了,没有位置”。
1.2 攻击者如何利用半连接队列
攻击者伪造大量源 IP,向目标服务器的某个端口发送海量 SYN 包,但从不回应后续的 ACK。服务器每收到一个 SYN,就在半连接队列里创建一个条目,并回复 SYN+ACK,然后等待超时重传。由于源 IP 是伪造的,SYN+ACK 要么石沉大海,要么发往不存在的地址,队列里的条目只能等内核的 SYN-ACK 重传定时器到时间后才能清理。
一个精心构造的洪流可以在几秒内把半连接队列占满。之后到达的 SYN 包会被直接丢弃,合法用户哪怕是正常的三次握手,也进不了队列,表现在客户端就是连接超时、反复重试、干脆连不上。这种行为就是经典的 SYN Flood——攻击者根本不需要把带宽打满,就能让服务“假死”。
这里有个很容易被忽视的点:攻击者不一定用真实的源 IP,也不一定用固定的攻击端口组合。因为只要队列满了,元凶是谁并不重要,服务器已经无法区分合法 SYN 和恶意 SYN。所以这种攻击特别难靠简单的源地址过滤来防御。
1.3 如何初步判断服务正遭受影响
早期的排查信号往往来自两个地方。第一是内核日志:当你看到possible SYN flooding on port 80. Sending cookies.这行日志时,说明内核的网络子系统已经判断出 SYN 到达速率超出了正常容量,并准备启用应急机制。第二是计数统计:执行netstat -s | grep -i syncookie能看到SyncookiesSent、SyncookiesRecv等计数器开始增长。这两个信号同时出现,基本可以确定服务正处在 SYN 洪流的冲击之下。
但要注意,日志里的possible这个词很关键。内核不是只有在被攻击时才打这行日志,它也可能出现在正常业务突增、瞬时大量新连接涌入的场景。所以不要一看到日志就认定被攻击,而要结合连接成功率、业务响应时间、网络流量曲线一起判断。
2. 从“存状态”到“算状态”:SYN Cookies 的核心设计思路
2.1 无状态的设计转变
传统三次握手必须在服务器端保留半连接状态,这是问题根源。SYN Cookies 的思路很直接:既然我没地方存你的连接信息,那我就把这些信息经过签名和编码后,当成序号直接发给你。客户端再回 ACK 时,会把序号原样带回来,服务器只要重新计算一遍,就能判断这个 ACK 是不是对应一个真实握手。
这一下就把问题从“内存不够、队列满了”变成了“CPU 多算一个哈希”。半连接队列可以不加条目,攻击者再怎么打,也没办法用半连接把资源耗尽。整个机制的本质,是把内存压力转化为 CPU 计算,换取协议栈的存活。
这也是 SYN Cookies 在名字上强调“Cookies”的原因:像 Web 登录场景里服务器在客户端种下一个令牌,之后客户端请求时带着令牌回来,服务器验证令牌即可,而无需为每个会话保存服务端状态。
2.2 Cookie 里到底放了什么
一个标准的 TCP 初始序列号是 32 位。内核在设计 SYN Cookie 时,把可用的信息空间拆成几块来用:
- 一部分位存放哈希签名。这个哈希基于源 IP、目的 IP、源端口、目的端口、一个随机密钥以及当前时间槽计算而来。随机密钥在服务器启动时生成,保存在内核的
syncookie_secret中,所以外部攻击者无法预知签名值。 - 一部分位存放连接参数,最典型的是 MSS 档位。因为握手阶段必须确定 MSS,而 SYN Cookies 不保存状态,MSS 必须跟着 cookie 走。
- 时间槽用于防止 cookie 被长期重放。内核用
tcp_cookie_time()提取当前时间,粒度大约是 64 秒,cookie 只在有限的几个时间槽内有效,过期后自动作废。
哈希算法使用的是siphash,这玩意儿对短输入非常快,而且有密钥参与,输出随机性很好。之所以不用 MD5 或 SHA 这类通用哈希,是因为 TCP 握手路径对性能极其敏感,每个 SYN 都要算一次,必须选轻量级的方案。
2.3 校验流程:确认号就是天然的回执凭证
TCP 三次握手有个天然规则:客户端收到服务器的 SYN+ACK 后,返回的 ACK 包的确认号必须是“服务器初始序列号 + 1”。SYN Cookies 巧妙利用了这一点。
服务器在 SYN 队列满时,不再创建request_sock,而是直接根据当前参数算出一个 cookie,把它作为初始序列号写进 SYN+ACK 包发出去。客户端正常回 ACK 时,确认号就是 cookie+1。内核收到这个 ACK 后,用同样的五元组、同样的密钥、当前的时间槽重新计算一遍 cookie,拿计算值和 ACK 包里的确认号减 1 做比较,一致就说明这个 ACK 对应一个真正经历过握手流程的连接。
校验通过后,内核才真正创建连接结构、进入 established 状态。整个过程没有查半连接表,没有等待队列,只是几十次位运算加一次哈希计算。这就是“用计算换状态”的完整闭环。
2.4 时间戳的妙用:额外参数怎么塞进去
32 位序号空间很紧张,哈希要占大头,剩下的位不足以精细编码所有 TCP 参数,比如 SACK、窗口缩放因子、多档 MSS。这时候 TCP 时间戳选项就派上了用场。
如果客户端在 SYN 里声明支持 TCP 时间戳,服务器可以在 SYN+ACK 的时间戳字段里塞入额外的编码信息,比如更细粒度的 MSS 档位、SACK 许可标志、窗口缩放因子等。客户端并不知道这些位的具体含义,它只负责把时间戳原样回显在 ACK 里。服务器收到 ACK,从时间戳字段里解出这些参数,就能重建出一个信息相对完整的连接。
这个双通道方案非常聪明:一个通道是 32 位序号,另一个通道是 32 位时间戳值,两边合起来能携带的信息就多了不少。但前提是客户端支持时间戳字段。现代操作系统默认都开,所以实际影响不大;如果遇到老设备或某些精简协议栈,客户端不支持时间戳,那 MSS 就只能在少数几个档位里取近似值,甚至退回到 536 字节的兜底值,传输效率会明显下降。
3. 内核实现与关键代码路径
3.1 tcp_syncookies 的三种取值
Linux 通过内核参数net.ipv4.tcp_syncookies控制这个机制,取值含义如下:
| 取值 | 含义 | 适用场景 |
|---|---|---|
| 0 | 完全关闭 SYN Cookies | 极少数对 TCP 扩展特性有严格要求的场景,不推荐 |
| 1 | 只在半连接队列满时才使用 SYN Cookies | 生产环境推荐,兼顾正常连接特性和应急能力 |
| 2 | 无条件对所有 SYN 使用 SYN Cookies | 调试或极端攻击场景,日常不要用 |
默认值是 1,大多数发行版没有改动它。所以很多运维甚至没意识到这个机制一直在后台待命。它就像防火墙的应急通道,平时不介入业务,只在主通道堵死时自动分流。
3.2 触发点与标志性日志
内核处理 SYN 的入口在tcp_conn_request,里面会判断半连接队列是否还有空间。如果inet_csk_reqsk_queue_is_full判断队列已满,并且tcp_syncookies打开了,系统就会进入 SYN Cookies 路径。需要说明的是,现代内核的判定不只看半连接队列,如果 accept 全连接队列也存在持续积压,内核同样可能认为“连接能力不足”,从而激活 cookie 机制。
触发时有几个明显的特征。首先是那条经典的possible SYN flooding on port …日志,它由net_ratelimit做了限速,不会在每秒上百万个包里刷爆内核日志,但会周期性地记录下来。其次是SyncookiesSent计数开始增加,说明确实有连接走了 cookie 路径。再就是半连接队列不再继续膨胀,因为新 SYN 不再入队了。
3.3 代码层面的关键点
核心代码集中在net/ipv4/syncookies.c。入口cookie_v4_init_sequence负责生成初始序列号,内部调用cookie_hash完成哈希计算;校验路径cookie_v4_check反向验证 ACK 中的 cookie。需要重点理解的是校验时的时间窗口逻辑:cookie 内的时间槽是粗粒度的,收到 ACK 时内核会比较当前时间槽和 cookie 携带的时间槽差值,超过允许范围就丢弃。这决定了连接必须在几十秒到几分钟内完成握手,超过这个窗口的旧 cookie 会被视为无效。这个限制平时不是问题,但如果你有跨长链路的高延迟连接,可能需要留意。
另一个细节是密钥管理。syncookie_secret是内核启动后随机生成的,一旦服务器重启,旧 cookie 全部失效。这会导致重启前正在握手的一批连接直接失败,客户端只能重新发起 SYN。这是正常现象,不是 bug。
4. 配置与调优实践
4.1 基础配置清单
生产环境我建议这样配置核心参数:
# /etc/sysctl.d/99-net-stack.conf net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_max_syn_backlog = 4096 net.core.somaxconn = 1024 net.ipv4.tcp_synack_retries = 1 net.ipv4.tcp_abort_on_overflow = 0各项参数的含义和配套逻辑如下:
tcp_syncookies = 1保持默认开启,确保队列满时能兜底。tcp_max_syn_backlog是半连接队列上限,在高并发服务上可以从默认的 1024 提到 4096 甚至更大。但注意这不是越大越好,每一条半连接都要占用内存,过大的队列反而会拖慢SYN+ACK的扫描和处理。somaxconn是全连接队列(accept 队列)的上限,应用层通过listen(fd, backlog)传进来的 backlog 会被它裁顶。两者取较小值作为最终队列长度。tcp_synack_retries控制服务器重发 SYN+ACK 的次数。默认是 5 次,意味着服务器会为每个半连接条目等待很长时间。在遭受 SYN 洪流时,我倾向调成 1 或 2,尽快清理那些等不到 ACK 的僵尸条目。tcp_abort_on_overflow保持默认 0。它表示当 accept 队列满时,内核只是静默丢弃新连接,让客户端重试;如果设成 1,内核会直接回 RST 告知客户端“别试了”,这在正常业务下会造成大量连接报错。
4.2 高并发场景下的联动调优
只调tcp_syncookies是不够的。SYN Cookies 解决的是半连接队列被塞满的问题,但如果你的应用处理不过来,accept 队列同样会爆,此时新连接依然会失败。
我曾在某活动流量峰值前犯过一个错:只把tcp_max_syn_backlog调到了 65535,somaxconn没动。结果活动开始后,ss -lnt看到监听端口的 Recv-Q 一直顶着上限,连接大量超时。用nstat -az一看,SyncookiesSent也在涨,但因为 accept 队列满了,走完 cookie 校验的连接也进不了最终队列,照样被丢。
所以高并发服务的正确姿势是几个参数联动调整:半连接队列、accept 队列、TCP 重传次数、以及程序自身的listen backlog设置。nginx这类事件驱动服务通常会把 backlog 设得很大,这没问题,关键是somaxconn必须同步拉高,否则程序传的 backlog 会被限制住。另外,如果用的是多进程模型,reuseport可以把多个监听 socket 分布到不同 CPU 核上,分散单点队列压力。
4.3 验证配置是否生效
配置完成后不要急着完事,先确认参数加载成功:
sysctl net.ipv4.tcp_syncookies net.ipv4.tcp_max_syn_backlog net.core.somaxconn再看实时计数器。Linux 的 SYN Cookies 计数可以通过以下命令查看:
nstat -az | grep -i syn # 或者 cat /proc/net/netstat | grep -A1 "Tcp" # 或者旧一点的 netstat -s | grep -i syncookie重点看SyncookiesSent、SyncookiesRecv、SyncookiesFailed这三项的走势。这里有个经验:如果SyncookiesSent长期稳定地大于 0,说明你的服务容量确实不够,经常需要靠 cookie 来“接盘”。这不是正常状态,而是容量规划偏保守的信号。应该考虑扩容或者优化内核参数,而不是满足于“反正 syncookies 能兜底”。
5. 优点、代价与大方案对比
5.1 优势在哪里
SYN Cookies 最大的价值是完全不依赖外部设备,是内核自带的应急能力。它不会消耗半连接队列的内存,所以在攻击时内存占用是平稳的。它对合法连接的影响也尽量做到了最小:只要队列没满,正常连接完全走标准流程,只有溢出的连接才走 cookie 路径。
反应速度快。队列满的那一刻,内核立刻切换,没有人工干预,也不需要重启进程。相比等待运维登陆机器、再从外部引流,这个就地防御的响应时间几乎是零。
部署成本为零。不需要装额外模块,不需要改业务代码,改一行 sysctl 就能生效。对于规模不大、没有专职安全团队的团队来说,这是性价比极高的第一道防线。
5.2 代价和边界
代价同样明显。第一,MSS 精度有限。走 cookie 路径时,如果客户端不支持时间戳,MSS 只能从少数几个档位里取近似值,网络包可能因此变小,长连接数据传输效率下降。第二,部分 TCP 扩展选项无法协商。SACK、窗口缩放这些特性可能丢失或降级,对高带宽长链路来说吞吐会受影响。第三,CPU 开销上升。每个 SYN 和 ACK 都要做哈希计算,在千万级 PPS 的压力下,CPU 本身可能成为瓶颈。
更关键的是,SYN Cookies 只保护半连接队列,不保护带宽。攻击流量本身还是实实在在占用了接入带宽和服务器收包能力。如果攻击流量大到把网络链路塞满,或者把 CPU 打到 100%,那再好的 cookie 机制也救不了你。此外,cookie 校验失败的 ACK 会被内核丢弃,攻击者如果发起大量伪造 ACK 来触发校验,也会造成无效的 CPU 消耗。
| 维度 | 优势 | 劣势 |
|---|---|---|
| 内存 | 无状态,不占队列 | 无需额外内存 |
| 网络 | 自动应急,无需人工 | 不防御带宽耗尽 |
| 连接质量 | 队列满时保持可用 | MSS 精度下降,部分选项丢失 |
| 性能 | 快速响应 | 增加哈希计算与校验 CPU 成本 |
5.3 与 SYN Proxy 方案的对比
SYN Proxy 是更重的方案,通常由负载均衡或防火墙实现。它作为中间人,先替后端服务器完成与客户端的握手,确认客户端真实可靠后,再与后端服务器建立连接,整个过程代理层持有完整状态。
SYN Cookies 是尽力而为的本地保护,但它的能力边界也在本地。如果希望在大流量攻击下仍能保证业务毫秒级恢复,通常需要 SYN Proxy、流量清洗、黑洞路由等组合动作。两者不是二选一的关系,而是纵深防御的多个层级。内核里的 SYN Cookies 可以理解为最后一道防线:当其他层都没防住,流量到了服务器本身,至少半连接队列不能被击穿。
6. 常见问题与排查技巧实录
6.1 已经开启了 syncookies,为什么还是大量连接超时
这个问题我踩过好几次。启动 cookie 机制后依然超时,通常有这几个原因:
一是 accept 全连接队列也满了。cookie 只给半连接解了围,连接完成握手后还是要进 accept 队列排队等应用层accept()。如果应用处理不过来,Recv-Q 会持续升高,后来的连接即使过了 cookie 校验也会被丢弃。此时排查要看ss -lnt的 Recv-Q,而不是盯着半连接队列看。
二是 CPU 软中断打满。海量 SYN 包会触发大量软中断,如果网卡队列和 CPU 核数不匹配,单个核被打满,收包能力就到顶了,再多的 cookie 也挡不住包都进不了协议栈。
三是带宽被打满。如果攻击流量已经超过入口带宽,那丢包发生在更早的链路层,内核连看到包的机会都没有。这种场景下,任何内核参数都是杯水车薪,必须先做流量清洗或黑洞。
排查顺序很关键:先看带宽和总体流量,再看 CPU 软中断,然后看ss -lnt的队列深度,最后才轮得到看 syncookies 计数。
6.2 为什么 NAT 场景下 cookie 校验会失败
一种比较隐蔽的情况是:客户端经过 NAT 访问服务,多台后端服务器在负载均衡后面各自维护自己的syncookie_secret。SYN 如果落在机器 A 上,回包也正常,但 ACK 因为某种原因被分发到了机器 B,B 用自己的密钥和五元组重新计算 cookie,结果肯定对不上。
这类问题通常在 LVS 或负载均衡设备上表现为“握手成功率低,但不为 0”。解决方案是确保四层会话保持,让同一五元组的报文固定落在同一台后端,或者在负载均衡层直接启用 SYN Proxy,不让多台后端同时暴露给外部客户端。
另一个容易忽略的点是服务器重启。重启后syncookie_secret变了,之前发给客户端的 cookie 全部作废。正在重试握手的客户端可能在窗口期内反复失败,直到超时后重新发 SYN。这不是配置错误,但做发布时要注意观察,避免误判为新发版导致的问题。
6.3 这个机制需要关闭吗
除非有非常明确的原因,否则我不建议把tcp_syncookies设成 0。它默认工作在半连接队列满溢时,对正常连接的路径影响为零,把应急能力留在那里,代价几乎是零。那种“开了会影响 TCP 性能”的说法,主要是针对tcp_syncookies = 2的强制模式,因为此时所有新连接都走 cookie 路径,MSS、SACK 等参数都会受影响。而默认的 1 不会影响正常连接。
真正需要运维干预的不是关闭它,而是监控它何时被激活。按我的经验,SyncookiesSent见涨,就说明系统的容量已经摸到天花板了。把这次事件当作扩容的触发器,去优化队列参数、加节点、或者调整负载均衡策略,才是根本解法。
7. 一点个人体会
SYN Cookies 是那种“用的时候还没感觉,没它的时候才知道厉害”的机制。我经历过一次凌晨被电话叫醒,业务全部超时,登上机器看到SyncookiesSent已经涨了上千万。当时能快速恢复,靠的就是默认开启的tcp_syncookies = 1扛住了第一波,然后才腾出手来调参数、加节点、做清洗。那次之后,我把所有服务器的 SYN Flood 相关指标都加进了监控,并且定期检查somaxconn和tcp_max_syn_backlog的配套设置。最后分享一个小技巧:监控SyncookiesSent时不要只看绝对值,要看它和正常连接的比率。如果这个比率长期超过 1%,说明你的容量余量已经不足,趁早优化队列参数或者扩容,别等它变成事故才回头看。