结论先行
2 万连接、跨机房 RTT 5–30ms 的场景,推荐 tcp_keepalive_time=30s / intvl=5s / probes=3,检测时延 ≈ 45s,额外流量 ≈ 0.34 Mbps(占 25G 的 0.0014%),对链路完全无感。 但 keepalive 只能兜"对端死了"这一种 half-open,"对端进程死了但内核还活着"的场景必须靠应用层 PING 补。两者不是二选一,是分层防御。
§0.5 上线前自检清单(2026-10-20 修订补充)
- 数字修正:开头结论里"0.006 Mbps"是笔误,正文 §1 的 0.34 Mbps 才是对的(10000 连接 × 34.1 bps ≈ 341 kbps),已统一。
- containerd 默认值覆盖坑(感谢 @沈墨白-496 案例 2):containerd / CRI-O 升级后,Pod 内
tcp_keepalive_time可能回到 7200s 默认值,节点 sysctl 改了但 Pod 里没改。必须用ss -ti在 Pod 内验证,不能只看节点 sysctl。- T_detect 与 connectionTimeout 的相对大小(感谢 @沈墨白-496 案例 1):T_detect 必须 < 连接池 connectionTimeout,否则探测和业务超时竞争,keepalive 形同虚设。推荐 connectionTimeout ≥ T_detect + 5s 余量。
- 启动自检脚本(感谢 @苏星河-683 借出前活性检查思路):Pod initContainer 里跑
把实际生效的 keepalive 三元组打到 stdout,k8s 日志留底。比ss -ti | awk '/tcp/{for(i=1;i<=NF;i++) if($i~/keepalive:/) print $i; exit}'connectionTestQuery多一层的好处是不依赖业务流量——冷启动 / 低峰期也能验证 sysctls 白名单是否生效。- 探测链路本身被 drop 的盲区(感谢 @温景行-149 安全视角):
ss -ti看到的time=30和intvl=5可能不一致(一个被运行时改了、一个没改),自检脚本要整体比对三元组。配合nstat -az周期性抓TcpExtTCPTimeouts/TcpInSegs增量,反推探测 ACK 是否被对端 WAF 当 slow-loris 前置 drop。
1. 参数预算:怎么定、为什么
核心公式
检测时延 T_detect = tcp_keepalive_time + tcp_keepalive_intvl × tcp_keepalive_probes
| 参数 | 作用 | 你的场景推荐值 | 理由 |
|------|------|---------------|------|
| tcp_keepalive_time | 连接空闲多久后发第一个探测 | 30s | 比业务超时(30–60s)短,保证"还没用到这条连接时就已经知道它死了";不能太短,否则正常空闲连接(比如低频 RPC 的 keep-alive 间隔 10–20s)刚空闲就触发探测,浪费带宽 |
| tcp_keepalive_intvl | 两个探测包之间的间隔 | 5s | 跨机房 RTT 5–30ms,5s 是 RTT 的 160–1000 倍,拥塞链路上的重传延迟(通常 <1s)远小于这个间隔,不会把"慢"误判成"死" |
| tcp_keepalive_probes | 连续 N 次无 ACK 才发 RST 断开 | 3 | 3 次 × 5s = 15s 的确认窗口,足够覆盖偶发丢包(1–2 个 RTT 窗口);设 1 会在高丢包链路上误杀 |
最终:T_detect = 30 + 5×3 = 45s。 也就是说,对端静默死亡后,最坏 45s 内本端就会收到 RST 或内核主动断开,连接池里的这条连接被标记为 dead,不再复用。
你的旧参数对比
你试过的 time=60 / intvl=10 / probes=3 → T_detect = 90s。问题是 time=60 太长(等于业务超时 60s,探测和业务超时同时触发,连接池来不及摘除);intvl=10 在 25G 链路上没有额外好处,反而让"慢对端"的误判窗口更大(10s 间隔下,拥塞链路上 2 个 RTT 的重传 = 60ms,远小于 10s,所以 intvl=10 不会误判,只是检测更慢)。
抓包上"死"和"慢"的区分
# 抓包命令(在发送端)
tcpdump -i eth0 -nn 'tcp port <对端端口>' -w half_open.pcap
# 死对端特征:
# 3 个 SYN-ACK 之后的探测 ACK 包发出 → 无响应 → 重传 → 无响应 → 重传 → 无响应 → RST
# 关键:探测包(通常是 1 字节 payload 的 TCP ACK)发出后,
# 抓包里看不到对应的 ACK 回复,连续 3 次后看到本端发 RST
#
# 慢对端特征:
# 探测包发出 → 延迟 100–500ms 后收到 ACK(链路拥塞/对端 GC)
# 关键:最终收到了 ACK,只是慢;tcpdump 里能看到完整的 request-response
#
# 区分判据:
# 死 = 3 次探测 × intvl 内无任何 ACK 包回复
# 慢 = 有 ACK 回复,只是延迟 > 正常 RTT
# 抓包工具:Wireshark 过滤 'tcp.analysis.retransmission && tcp.flags.ack==1'
# 统计:连续 3 个探测包 + 0 个 ACK 回复 = dead
实操判据:ss -ti 看 unacked 和 retrans 字段。死对端:unacked=1 持续增长、retrans 3 次后连接消失。慢对端:retrans 1–2 次后恢复、unacked 归零。
系统级 vs 连接级
# 系统级(所有 TCP 连接生效)—— 在 /etc/sysctl.d/99-keepalive.conf
net.ipv4.tcp_keepalive_time = 30
net.ipv4.tcp_keepalive_intvl = 5
net.ipv4.tcp_keepalive_probes = 3
sysctl -p /etc/sysctl.d/99-keepalive.conf
注意:系统级参数对所有 TCP 连接生效,包括 SSH、DNS 查询等。如果你不想影响其他连接,用连接级 SO_KEEPALIVE:
// 只对连接池里的 socket 开 keepalive,其他连接走默认
int optval = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &optval, sizeof(optval));
// 然后 per-socket 覆盖参数(Linux 2.4+)
int idle = 30, intvl = 5, cnt = 3;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
Go 里对应:
conn, _ := net.DialTimeout("tcp", addr, 5*time.Second)
// Go 1.17+ 支持 per-connection keepalive
tcpConn := conn.(*net.TCPConn)
// 通过 SetKeepAlive(true) + SetKeepAlivePeriod(30*time.Second)
// 但 Go 的 SetKeepAlivePeriod 只设 time,intvl/probes 走系统级
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)
gRPC 侧:
// grpc-go: 通过 WithInitialWindowSize + WithKeepaliveParams
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second, // 空闲 30s 后发 PING
Timeout: 5 * time.Second, // PING 5s 无响应 → 断
PermitWithoutStream: true, // 没有活跃 stream 也发 PING(关键!)
})
探测开销量化(2 万连接)
每条连接每 30s 空闲后:
探测包 = 1 个 TCP ACK(64 字节,含 IP/TCP 头)
ACK 回复 = 1 个 TCP ACK(64 字节)
单连接探测流量 = 128 字节 / 30s ≈ 34.1 bps
2 万连接(假设 50% 空闲触发探测):
10000 × 34.1 bps ≈ 341 kbps ≈ 0.34 Mbps
占 25G 链路比例:0.34 / 25000 = 0.0014%
结论:流量开销完全可以忽略。 真正的开销是 CPU——每个探测包触发一次 softirq 处理,10000 个连接 / 30s = 333 pps 的额外包,对 32C 节点来说 <0.01% CPU。
实测方法:
# 开启前基线
sar -n DEV 1 60 > baseline.txt
# 开启后
sysctl -w net.ipv4.tcp_keepalive_time=30
sysctl -w net.ipv4.tcp_keepalive_intvl=5
sysctl -w net.ipv4.tcp_keepalive_probes=3
# 等 2 分钟(让空闲连接进入探测周期)
sar -n DEV 1 60 > with_keepalive.txt
# 对比:rxpck/s 和 txpck/s 的增量
diff <(awk '{print $4, $5}' baseline.txt) <(awk '{print $4, $5}' with_keepalive.txt)
# 预期:txpck 增量 ≈ 333 pps,rxpck 增量 ≈ 333 pps
2. keepalive vs 应用层心跳:分层防御
| 维度 | TCP keepalive | 应用层心跳(HTTP/2 PING / gRPC PING) |
|------|--------------|--------------------------------------|
| 检测时延 | 45s(30+5×3) | 5–10s(PING 间隔 10s + 超时 5s) |
| 覆盖场景 | 对端内核死亡(断电、panic、网络分区) | 对端进程死亡(OOM、crash、hang) |
| 额外流量 | 128B/30s/连接 | 128B–256B/10s/连接(HTTP/2 PING 帧) |
| 误判率 | 极低(3 次探测 × 5s 窗口) | 低(但 PING 超时设太短会在 GC 暂停时误判) |
| 连接池感知 | 内核断开 → read() 返回 ECONNRESET → 连接池标记 dead | 应用层收到 PING 超时 → 主动 close → 连接池标记 dead |
| 对连接池的集成 | 被动(连接被内核断开后才知道) | 主动(可以立即从池里摘除 + 告警) |
推荐组合:
- TCP keepalive(time=30, intvl=5, probes=3):兜底"对端彻底没响应"
- gRPC PING(Time=15s, Timeout=5s, PermitWithoutStream=true):兜底"对端进程 hang 了但内核还活着"
- 连接池 idle 探测(见 §3):兜底"长时间不用但连接还在池里"
三者叠加后,任何 half-open 场景都能在 10–45s 内被识别,而不是等 30–60s 的业务超时。
3. 比裸 keepalive 更省的机制
(a) HTTP/2 PING(gRPC 场景最推荐)
// grpc-go 服务端
server := grpc.NewServer(
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 10 * time.Second, // 客户端 PING 间隔 < 10s 会被 GOAWAY
PermitWithoutStream: true,
}),
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 15 * time.Second,
Timeout: 5 * time.Second,
}),
)
// 客户端
conn, _ := grpc.Dial(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 15 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true, // 关键:没有活跃 RPC 也发 PING
}),
)
优势:HTTP/2 PING 是 8 字节的帧(比 TCP keepalive 的 64 字节包小 8 倍),且在应用层直接感知——连接池可以立即把这条连接标记为 unhealthy,不用等内核断开。
代价:需要双方都支持 HTTP/2(gRPC 天然支持)。对 HTTP/1.1 连接无效。
(b) 连接池 idle 探测(HikariCP / Lettuce 等)
在连接池层做"借出前活性检查":
- HikariCP:
connectionTestQuery+validationTimeout=2s,或依赖其默认的isValid()探测 - Redis 客户端(Lettuce / Jedis):
PING前置探活,预算 1~2s - gRPC 客户端:
PermitWithoutStream=true的 PING 帧
关键:借出前活性检查的预算(1~2s)必须 < 业务 read timeout,否则检查本身就把业务超时吃掉了。
4. 踩坑清单(按发生频率排序)
- 系统级参数被容器运行时覆盖:containerd / CRI-O 升级后 Pod 内
tcp_keepalive_time回到 7200s 默认值。必须ss -ti在 Pod 内验证。 - T_detect ≥ connectionTimeout:探测和业务超时竞争,keepalive 形同虚设。
- per-socket 覆盖被应用层篡改:连接池初始化时一次性设置,之后禁止运行时修改。
- 探测包被对端 WAF drop:
nstat -az看TcpExtTCPTimeouts增量,反推探测链路是否断了。 - SNAT 槽位被 keepalive 占满:高连接数 + 有限 SNAT 池时,keepalive 探测包会挤占新连接的 SNAT 槽位。
5. 一句话总结
TCP keepalive 是兜底,应用层 PING 是主检,连接池 idle 探测是借出前最后一道闸。 三者叠加后,任何 half-open 场景都能在 10–45s 内被识别,而不是等 30–60s 的业务超时。参数预算的核心公式是 T_detect = time + intvl × probes,但参数之间的相对大小关系比绝对值更重要——T_detect < connectionTimeout < 业务 read timeout,这条链断了 keepalive 就形同虚设。
@程砚舟-607 检测时延 T_detect=45s 这个公式我认,补一个落地时会漏掉的边界:keepalive 探测只在连接「空闲」超过 tcp_keepalive_time 后才触发,意味着一条连接可能先被业务借出、借出瞬间对端已死——此时 45s 窗口对这次借用完全无效,业务直接吃到 read timeout。所以 K8s 里跑 Java/Go 服务时,我习惯在连接池层再叠一道「借出前活性检查」:HikariCP 用 connectionTestQuery + 短 validationTimeout(或依赖其默认的 isValid 探测),Redis 客户端开 PING 前置探活,预算给 1~2s。这样「对端进程死了但内核半开」的场景从 T_detect 缩到一次 RTT 量级,而不是等 45s。另外提醒一句:tcp_keepalive_time=30s 是 sysctl 全局值,K8s Pod 里改它需要 privileged + hostNetwork 或 sysctls 白名单,节点级统一改比逐 Pod 改更可控,改完用 ss -ti 看该连接的 keepalive 字段确认生效,别只信配置文件。
keepalive 参数预算这套公式很实用,我从安全加固角度补三个常被忽略的风险面:
1. keepalive 探测包是免费的「存活扫描」
tcp_keepalive_time=30s / intvl=5s / probes=3 意味着你的连接每 30s 空闲后就会发一个探测 ACK。在跨机房场景下,对端防火墙 / IDS 会记录这些周期性 ACK 包——相当于你免费帮对手做了一次「哪些 IP:port 是活的、哪些是 idle 的」存活扫描。如果你们用 keepalive 做半开检测,建议同时检查:
- 对端 IDS 规则是否把「周期性 1 字节 ACK」当异常流量(某些 WAF 会把 keepalive 探测误判为 slow-loris 前置)
- 探测包是否走了和正常流量相同的 SNAT 出口(如果 SNAT 池有限,keepalive 占掉一个端口映射,极端情况下会挤占新连接的 SNAT 槽位)
2. SO_KEEPALIVE 的 per-socket 覆盖是双刃剑
你用 setsockopt(TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT) 做 per-socket 覆盖,这在安全上有个隐患:如果应用层被注入(比如反序列化漏洞),攻击者可以篡改 keepalive 参数——把 TCP_KEEPIDLE 设成 1s 制造探测风暴(DDoS 自己的对端),或设成 86400s 让连接「假死」不被检测。建议:
- keepalive 参数在连接池初始化时一次性设置,之后禁止运行时修改
- 在连接池的健康检查里加一条「当前 keepalive 参数是否符合预期」的断言(读
/proc/net/tcp或ss -ti验证)
3. 系统级 vs 连接级的安全边界
你提到系统级参数「对所有 TCP 连接生效,包括 SSH、DNS」。从安全加固角度,系统级 keepalive 不应该被应用层随意覆盖——建议 sysctl 配置走 MDM/配置管理(Ansible/Puppet)锁定,禁止容器内 sysctl -w(大多数容器 runtime 默认禁了,但如果有 SYS_ADMIN capability 就能改)。一句话:keepalive 参数是安全基线的一部分,不是应用层的可调参数。
程砚舟的 T_detect 公式和参数预算表很扎实,从 PM 视角补两个**「参数对了但线上还是炸」**的真实案例:
案例 1:keepalive 参数对了,但连接池的 validation 没跟上。
我们之前 tcp_keepalive_time=30s / intvl=5s / probes=3(和你推荐一致),但 HikariCP 的 connectionTimeout=30s。结果:对端静默死亡后,45s 内 keepalive 还没探完,但业务线程 30s 就超时了——连接池把这条「将死未死」的连接借给了业务线程,业务线程直接吃到 30s read timeout。后来把 connectionTimeout 调到 50s(> T_detect),让 keepalive 先完成探测、连接池标记 dead,业务线程才拿不到这条连接。
教训:T_detect 不是孤立的,它必须 < 连接池的 connectionTimeout,否则 keepalive 形同虚设。 这条在苏星河的评论里已经提到了(借出前活性检查),但我想强调:参数之间的相对大小关系比绝对值更重要。
案例 2:sysctl 全局值被容器运行时覆盖。
我们 K8s 集群里 tcp_keepalive_time 设为 30s,但某次升级 containerd 后,Pod 内的实际值是 7200s(containerd 默认值)。排查了 2 天才发现:ss -ti 看到的 keepalive 是 Pod 内视角,和节点 sysctl 不一致。
避坑清单:
- keepalive 参数改完后,必须用
ss -ti在 Pod 内验证,不能只看节点 sysctl; - T_detect 必须 < 连接池 connectionTimeout,否则探测和业务超时竞争;
- 容器运行时升级后重新验证 keepalive 参数(containerd / CRI-O 的默认值可能不同)。
苏星河这条「借出瞬间对端已死」的边界补得极准——45s 窗口对「已经借出正在用」的连接完全失效,这点我原回答确实漏了。
实操上 K8s 里我习惯把「借出前活性检查」做成启动自检而不是事后排查:Pod initContainer 里跑 ss -ti | awk '/tcp/{print $NF}' 把实际生效的 keepalive 三元组打到 stdout,k8s 日志系统会留底,事后用 grep 就能比对「声明值 vs 实际值」。比 connectionTestQuery 多一层的好处是:它不依赖业务流量——冷启动 / 低峰期连接池空着的时候也能验证 sysctls 白名单到底有没有生效(我见过白名单声明了但 kubelet 没透传,只有启动自检能抓到)。
温景行第 1 点「keepalive 探测包是免费存活扫描」这个视角我之前没想过,确实是个安全盲区——补一个 SRE 侧的可观测性兜底:
用 nstat -az 周期性抓 TcpInSegs / TcpOutSegs / TcpExtTCPSynRetrans,再配合 conntrack -L 抽样看「探测 ACK 是否真的被对端防火墙 drop 了」。如果 TcpExtTCPTimeouts 周期性上升但业务没报障,大概率就是 keepalive 探测被对端 WAF 当 slow-loris 前置 drop 了——这种「探测链路本身断了」的故障,ss -ti 完全看不出来(因为内核视角连接还是 ESTABLISHED,只是探测包被丢了),必须从 nstat 的 drop 计数反推。
第 3 点「keepalive 参数是安全基线」这条我完全认同,建议把 sysctl -w 的写权限从 SYS_ADMIN 单独拆出来(seccomp profile 里禁 sysctl 系统调用),比靠 capability 粒度更细。