如何在香港服务器运行 Linux 时启用 TCP Quick ACK,压缩 CN2 链路下的握手延迟

凌晨 2:20,我在香港葵涌机房盯着一串 TLS 握手延迟的曲线发呆。北方多个城市经 CN2 GIA 进来的用户,在 TLS ClientHello→ServerHello 的往返上,总会“慢一拍”——一段稳定在 ~40ms 的锯齿。回放 pcap 的时候,我看到非常熟悉的节奏:对方小包上来、我这边回的 ACK 有时候会延后一个 Delayed ACK 周期(典型 40ms 左右),接着对端又在等 ACK 才继续小包……就这么一拖一等,TLS 握手阶段被硬生生拉长了几个周期。
直觉告诉我:该把内核的 ACK 策略“拨到灵敏档”。而在 Linux 里,这个旋钮就叫 TCP_QUICKACK。
重要认知先说在前:
- Linux 没有一个全局的 sysctl 可以“一键开启 Quick ACK”。
- 正确做法是针对连接级的 socket 调 setsockopt(TCP_QUICKACK),并且——这是关键——在收包阶段持续触发,因为 quickack 标志不是永久位,内核会在若干事件后把它复位。
- 如果你跑的是自研服务,最好在应用层主动 setsockopt;如果你跑的是 Nginx/HAProxy 这类通用软件,实践上有两条路:eBPF 强制注入,或 LD_PRELOAD 轻量注入。
下面我把环境、方法、坑和效果完整放出来。
环境与拓扑
硬件/OS
- 机房位置:香港(CN2 GIA 直连上游)
- 机型:Dell R6525(2× AMD EPYC 7402P,64C128T)
- 内存/存储:256GB DDR4 ECC,2× 1.92TB NVMe(RAID1)
- 网卡:Intel X710(10GbE,驱动 i40e)
- OS:CentOS 7.9(最初内核 3.10.0-1160,后升级到 5.10 LTS,原因见后文)
- 业务栈:外层 Nginx TLS 终止(HTTP/2 + TLS1.3),后接内部 Go 服务
网络拓扑(简化)
[Mainland Clients] -- CN2 GIA --> [HK Border Router] -> [TOR Switch]
-> [Edge Nginx (443/TCP)] -> [Go Upstream]
观测工具
- tcpdump + wireshark 定位握手阶段的 ACK/PSH 节奏
- strace -fp <nginx pid> -e trace=setsockopt 验证是否调用 TCP_QUICKACK
- ss -tin sport = :443 观测 TCP 运行态(rtt/ato/cwnd 等)
- eBPF 侧:bpftool、perf、bcc/libbpf(按你的口味选)
现象复盘与基线数据
我抓了 10 分钟的 443 端口 pcap,只看 TLS 握手(ClientHello → ServerHello/EncryptedExtensions/Finished),统计了 5000 次会话的关键延迟:
| 指标 | p50 | p95 | 备注 |
|---|---|---|---|
| TCP 3WHS(SYN→ACK 完成) | 25ms | 41ms | 纯三次握手本身并不糟糕 |
| TLS CH→SH 首包间隙(含 ACK) | 52ms | 92ms | 多数受 Delayed ACK 影响 |
| TLS 握手总时长(到 1st HTTP) | 145ms | 248ms | 抖动明显,呈 40ms 梯级 |
特征非常明显:不是网络路径不通,而是小包阶段被 Delayed ACK 垫高了步频。CN2 的基础 RTT 本来就比本地大几十毫秒,Delayed ACK 的 40ms 再叠一层,就很显了。
原理小结(给要点党)
Delayed ACK:Linux 为减少 ACK 包数量,会延迟 ACK(典型 ~40ms,取决于内核/实现)以等待数据合并或双向数据触发。
TCP_QUICKACK:通过 setsockopt(TCP_QUICKACK, 1) 短期启用“快 ACK”模式——大多数数据段立即 ACK。这不是永久位,会被内核在若干事件后复位(例如应用读写、RTO、拥塞状态等)。
为何对 TLS 握手有效:TLS 握手阶段是密集小包、往返频繁的短交易。去掉 ACK 的 40ms 延迟台阶,能显著压缩首包/首字节时间(尤其跨 CN2 这类高 RTT 路径)。
三次握手本身不受 Delayed ACK 影响(SYN/SYN-ACK/ACK 都是立即发出的),我们优化的是TCP 建连后的 TLS 交握阶段的 ACK 策略。
三条落地路径(按“可执行性”排序)
路径 A:应用内直接 setsockopt(首选)
如果你能改服务代码,这是最干净也最稳定的方式:每次 accept() 后,或每次 recv() 后,调用一次 TCP_QUICKACK。对于持续小包往返的阶段(TLS 握手、HTTP/2 头部交换等),建议在收到数据包后立即触发。
C 示例(服务端):
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
static inline void enable_quickack(int fd) {
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, &one, sizeof(one));
}
// 伪代码:accept 新连接后、每次小包到来后,触发 quickack
void on_accept(int fd) {
enable_quickack(fd);
}
ssize_t on_recv(int fd, void *buf, size_t len) {
ssize_t n = recv(fd, buf, len, 0);
if (n > 0 && n <= 1460) { // 小包
enable_quickack(fd);
}
return n;
}
Go(netpoll + RawControl):Go 标准库没直接导出 QuickAck,可用 SyscallConn().Control 做 raw setsockopt(略去平台差异处理):
import (
"golang.org/x/sys/unix"
"net"
)
func quickAck(conn *net.TCPConn) {
rc, _ := conn.SyscallConn()
rc.Control(func(fd uintptr) {
one := 1
unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_QUICKACK, one)
})
}
Nginx/HAProxy 本身不提供“开启 Quick ACK”的配置开关(有 tcp_nodelay,那是 Nagle)。因此如果是它们在最外层终止 TLS,请看路径 B/C。
路径 B:eBPF 注入(内核 ≥ 4.17,推荐 ≥ 5.3)
我们可以在 cgroup sockops 程序里,针对 443/TCP 的连接,在 被动建立(PASSIVE_ESTABLISHED) 和 数据接收(Rcv) 等回调中调用 bpf_setsockopt(TCP_QUICKACK)。这样无需改应用,就能对整组进程生效,而且可以按端口/进程做白名单。
核心思路:
- 创建 cgroup,把 nginx/业务进程放进去(systemd slice 或手动 cgcreate)。
- 加载 BPF_PROG_TYPE_SOCK_OPS 程序,关心两个事件:
- BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB:服务端 accept 完成时启用一遍 quickack。
- BPF_SOCK_OPS_RWND_INIT 或用 sk_msg/tracepoint/tcp/tcp_rcv:在收小包后再触发一次(保证 quickack 不被复位拖慢)。
- 利用 bpf_setsockopt(skops, SOL_TCP, TCP_QUICKACK, &one, sizeof(one))。
eBPF(简化版,libbpf CO-RE 思路):
SEC("sockops")
int quickack_sockops(struct bpf_sock_ops *skops)
{
int op = (int) skops->op;
if (op == BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB) {
if (skops->local_port == bpf_htons(443)) {
int one = 1;
bpf_setsockopt(skops, SOL_TCP, TCP_QUICKACK, &one, sizeof(one));
}
}
return 0;
}
SEC("tracepoint/tcp/tcp_receive_reset") // 仅示例,实际可选 tcp_rcv 相关 tp/kprobe
int tp_rcv(struct trace_event_raw_tcp_event_sk_s* ctx)
{
// 可以根据五元组过滤 443,调用 bpf_sk_lookup_* 获取 sock,再 setsockopt
return 0;
}
char _license[] SEC("license") = "GPL";
上线步骤(提要):
- 升级内核到 ≥5.x(CentOS 7 建议见后文)。
- bpftool gen + clang -target bpf 编译;
- mount -t bpf none /sys/fs/bpf,bpftool cgroup attach ... sock_ops;
- 把 nginx.service 放入绑定的 cgroup;
- 通过 bpftool prog tracelog/perf top 看触发频率;
- strace -fp $(pidof nginx) -e trace=setsockopt 验证 443 fd 上有 TCP_QUICKACK 调用。
优点:不侵入应用;可精细白名单。缺点:对内核版本有要求,调试门槛略高。
路径 C:LD_PRELOAD 注入(老内核兜底)
CentOS 7 默认 3.10 内核没有 bpf_setsockopt 这类“好用的” eBPF 能力。不能升级内核时,我用过一个笨但有效的小技巧:LD_PRELOAD 一个动态库,在 accept/read 之后对 fd 调一次 TCP_QUICKACK。
libquickack.c(节选):
#define _GNU_SOURCE
#include <dlfcn.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/tcp.h>
#include <unistd.h>
static int (*real_accept4)(int, struct sockaddr *, socklen_t *, int);
static ssize_t (*real_recv)(int, void *, size_t, int);
static inline void quickack(int fd) {
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, &one, sizeof(one));
}
int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags) {
if (!real_accept4) real_accept4 = dlsym(RTLD_NEXT, "accept4");
int fd = real_accept4(sockfd, addr, addrlen, flags);
if (fd >= 0) quickack(fd);
return fd;
}
ssize_t recv(int fd, void *buf, size_t len, int flags) {
if (!real_recv) real_recv = dlsym(RTLD_NEXT, "recv");
ssize_t n = real_recv(fd, buf, len, flags);
if (n > 0 && n <= 1460) quickack(fd);
return n;
}
编译与使用:
gcc -shared -fPIC -o libquickack.so libquickack.c -ldl
LD_PRELOAD=/opt/lib/libquickack.so nginx -g 'daemon off;'
风险提示:
- 这招对高并发场景性能有轻微影响(多一次 setsockopt);
- 需验证与进程守护方式兼容(systemd 的 Environment=LD_PRELOAD=...);
- 不建议长期依赖,作为老内核环境的过渡更合适。
内核与网卡调优(配套完成度)
升级内核(CentOS 7 → 5.10 LTS)
为了上 eBPF,我把内核从 3.10 升到了 5.10(ELRepo kernel-ml 分支),步骤非常标准(这里放要点):
yum install https://www.elrepo.org/elrepo-release-7.el7.elrepo.noarch.rpm -y
yum --enablerepo=elrepo-kernel install kernel-ml -y
grub2-set-default 0 && reboot
uname -r # 验证已经是 5.x
升级后要确认 i40e 驱动版本和 irqbalance 亲和性是否正常,避免引入新的抖动。
sysctl(围绕小包/握手友好)
# 拥塞控制/时间戳/选择性ACK
sysctl -w net.ipv4.tcp_congestion_control=cubic
sysctl -w net.ipv4.tcp_timestamps=1
sysctl -w net.ipv4.tcp_sack=1
# TFO:首字节更早(服务端+客户端位)
sysctl -w net.ipv4.tcp_fastopen=3
# RTO/探测更稳健(根据场景微调)
sysctl -w net.ipv4.tcp_mtu_probing=1
sysctl -w net.ipv4.tcp_syn_retries=5
sysctl -w net.ipv4.tcp_synack_retries=5
Nginx 配置(配合 TFO、禁用 Nagle):
server {
listen 443 ssl http2 fastopen=4096; # 需要内核支持
ssl_early_data on; # TLS1.3 早期数据视业务风险谨慎启用
tcp_nodelay on; # 关闭 Nagle,避免与对端 delayed ack 相互伤害
}
fastopen 的参数语法会随版本变化,老版本可能用 fastopen=on,以你现有版本文档为准。
网卡/队列(去掉多余合并,避免小包“被藏起来”)
对于握手这种以低延迟为王的阶段,GRO/LRO 在少数情况下会让小包粘连得更狠,影响 recv() 触发节奏。做法要谨慎、按业务时段 A/B:
ethtool -K eth0 gro off lro off gso on tso on
# 仅关闭接收侧合并,发送侧保留分段加速,避免 CPU 飙升
验证与观测
验证 quickack 是否真的在发生
strace(最直观):
strace -fp $(pidof nginx) -e trace=setsockopt,你应该看到针对于 443 fd 的 setsockopt(TCP_QUICKACK, 1) 调用在握手小包阶段被频繁触发。
ss(侧写):
ss -tin sport = :443 观察 ato(ack timeout)、rcv_rtt 的收敛,握手时段的 rtt/ato 抖动会明显收窄。
(不同内核输出字段略有差异,以你系统为准。)
pcap(终极裁判):
抓取 1 分钟 443 会话,筛选 TLS 握手段,统计 CH→SH 首包间隙与 ACK 间隔的分布。
对比数据(上线当天)
| 指标 | 优化前 p50 / p95 | 优化后 p50 / p95 | 改善幅度 |
|---|---|---|---|
| TLS CH→SH 首包间隙 | 52 / 92 ms | 28 / 46 ms | ~46% |
| TLS 握手总时长 | 145 / 248 ms | 98 / 171 ms | ~32% |
| 首字节时间(TTFB, 静态小文件) | 165 / 285 ms | 115 / 195 ms | ~30% |
| 失败重试率(握手阶段超时) | 0.62% | 0.19% | -69% |
备注:不同城市的改善幅度不同。CN2 路由好的城市提升没那么夸张,但抖动(p95/p99)被明显拉平。
部署中遇到的坑与解法
“开了又关”的错觉
你可能只在 accept() 时调用了一次 TCP_QUICKACK,过一会儿就失效,抖动又回来了。
解法:在每次小包到达后再触发一次(eBPF/LD_PRELOAD/应用层都行)。握手阶段小包密集,这样能稳定保持 quickack 语义。
老内核 eBPF 能力不足
CentOS 7 的 3.10 内核对 sockops、bpf_setsockopt 支持不完善。
解法:短期用 LD_PRELOAD;中期上 ELRepo kernel-ml 到 5.x;长期升级操作系统。
Nagle 与 Delayed ACK 的“互相伤害”
如果你留着 Nagle(默认 on),对端又开着 Delayed ACK,小包会更难“对点”。
解法:tcp_nodelay on;,应用层有小包就立刻发,配合 quickack 更顺畅。
GRO/LRO 一刀切导致 CPU 飙升
全关带来 CPU 上扬,尤其在大流量下。
解法:只针对握手关键路径、或在低峰测试;保留 GSO/TSO,别全关。
TFO 不是银弹
TFO 能把“带数据的 SYN”提前出去,但需要客户端也支持且拿到 cookie。
解法:作为锦上添花启用,别把希望都押在它上面。
让结果更稳的“组合拳”清单
- 业务层/注入层 持续触发 TCP_QUICKACK(accept + 小包 recv)
- tcp_nodelay on(Nginx/应用)
- TCP Fast Open(net.ipv4.tcp_fastopen=3 + 配置)
- sysctl 基线(timestamps、sack、mtu probing)
- 内核升级(便于 eBPF 稳定落地)
- 网卡合并谨慎关闭(握手窗口内)
- 观测闭环(pcap/ss/strace 三件套)
优化上线 20 分钟后,北方几个城市的首字节图像明显“顺了”。我给对接同学打了电话,他那头也在看曲线,把我叫了声“魔术师”。我笑着回他:这不是魔术,是 ACK 该来的时候别慢一拍。
把“快 ACK”做好其实不复杂,难的是把这件事工程化:在你的环境里选择合适的入口(应用/eBPF/LD_PRELOAD),做持续触发,有可验证的观测,最后把它变成可复制的 SOP。网络的事,从来都是“小步快跑、闭环验证”;尤其在 CN2 这种高 RTT 的跨境路径上,握手这点“小心思”,往往就是用户体感的“大不同”。