一个运行良好的 WebSocket 服务,突然在凌晨三点开始大面积掉线。日志里只有 “connection reset” 的报错,没有任何其他线索。你检查了服务器状态——CPU 正常,内存充足,网络畅通。重启服务后一切恢复,但第二天凌晨同一时间,问题再次出现。
这不是什么灵异事件,而是无数运维和开发者都遭遇过的真实场景。罪魁祸首往往不是应用代码,而是网络中间设备对"空闲"连接的无情收割。
TCP 协议本身提供了 Keepalive 机制,按理说应该能防止这种情况。但现实是,在绝大多数生产环境中,TCP Keepalive 根本救不了你的长连接。
TCP Keepalive 的设计初衷与默认值
TCP Keepalive 并非为了"保活"而设计,它的真正目的是检测对端是否已经彻底失联——比如机器断电、网线被拔、操作系统崩溃等极端情况。
Linux 内核中,TCP Keepalive 由三个参数控制:
- net.ipv4.tcp_keepalive_time:连接空闲多久后开始发送探测包,默认 7200 秒(2 小时)
- net.ipv4.tcp_keepalive_intvl:探测包之间的间隔,默认 75 秒
- net.ipv4.tcp_keepalive_probes:失败多少次后判定连接断开,默认 9 次
按照默认配置,TCP 需要等待 2 小时 + 75 秒 × 9 次 = 约 2 小时 11 分钟,才能判定一条连接已经死亡。这个时间跨度对于任何需要实时响应的应用来说都太长了。
更关键的问题是:TCP Keepalive 只在连接真正断开时才起作用。如果中间设备(如 NAT 网关、负载均衡器、防火墙)因为"空闲"而提前清理了连接状态,TCP Keepalive 根本来不及做出反应。
网络中间设备的超时机制
现代网络架构中,数据包从客户端到服务器往往要经过多层中间设备。每一层都有自己的连接状态表和超时机制。
NAT 设备
NAT(网络地址转换)设备需要维护一张连接表,记录内部地址与外部地址的映射关系。这张表的大小有限,为了节省资源,NAT 会定期清理长时间没有数据传输的连接条目。
不同类型 NAT 的超时时间差异巨大:
| 设备类型 | TCP 连接超时 | UDP 连接超时 |
|---|---|---|
| 家用路由器 | 5-30 分钟 | 30-180 秒 |
| 企业防火墙 | 1-4 小时 | 30-60 秒 |
| 运营商级 NAT (CGNAT) | 30-60 分钟 | 30-120 秒 |
| 云平台 NAT 网关 | 5-10 分钟 | 60-350 秒 |
对于 4G/5G 移动网络,情况更加复杂。运营商为了节省稀缺的公网 IPv4 地址,普遍采用 CGNAT(Carrier-Grade NAT)。根据 2011 年发表的一项研究,蜂窝网络中的中间设备往往会采用激进的超时策略来快速回收资源——某些运营商甚至将空闲 TCP 连接的超时设置为 5 分钟以内。
当 NAT 设备清理掉一条连接的映射条目后,这条连接在应用看来仍然是"活着"的——TCP 状态机处于 ESTABLISHED 状态,Socket 文件描述符仍然有效。但一旦任何一方尝试发送数据,NAT 会因为找不到映射而丢弃数据包,或者返回 RST 包强制关闭连接。
云负载均衡器
在云原生架构中,负载均衡器是另一个"隐形杀手"。以 AWS 为例:
- Application Load Balancer (ALB):空闲连接超时默认 60 秒,可配置范围 1-3600 秒
- Network Load Balancer (NLB):TCP 流空闲超时默认 350 秒,2024 年 9 月后支持配置为 60-6000 秒
- Classic Load Balancer:空闲超时默认 60 秒
Azure 和 GCP 的负载均衡器也有类似的默认配置,通常在 1-4 分钟之间。
这意味着,如果你的 WebSocket 连接在 60 秒内没有任何数据传输,AWS ALB 就会主动断开这条连接——完全不考虑 TCP Keepalive 的设置。
Kubernetes 环境的特殊性
在 Kubernetes 集群中,kube-proxy 通过 iptables 或 IPVS 维护服务发现规则。Linux 内核的 conntrack 模块会跟踪所有连接状态。对于 TCP 连接,conntrack 的默认超时配置如下:
net.netfilter.nf_conntrack_tcp_timeout_established = 432000 秒(5 天)
看起来很长?但 Istio、Envoy 等 Service Mesh 组件有自己的超时配置。Envoy 的默认 stream_idle_timeout 为 5 分钟,超过这个时间没有数据传输,连接就会被关闭。
为什么 TCP Keepalive 派不上用场
理解了中间设备的超时机制,TCP Keepalive 失效的原因就清晰了。
时间错位
TCP Keepalive 默认 2 小时才开始探测,而中间设备可能在几分钟甚至几十秒内就清理连接状态。即使你调整内核参数,将 tcp_keepalive_time 设为 60 秒:
// 设置 TCP Keepalive 参数
int keepalive = 1;
int keepidle = 60; // 60秒后开始探测
int keepintvl = 10; // 探测间隔10秒
int keepcnt = 3; // 探测3次
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
这能解决问题吗?部分能,但代价很高:
- 所有 TCP 连接都被影响:内核参数是全局的,所有应用的连接都会按照这个频率发送探测包
- 探测包可能被拦截:某些防火墙或 NAT 可能会拦截不带数据的纯 ACK 包
- 应用层仍无感知:TCP Keepalive 只能检测连接层面的"死活",无法检测应用进程是否卡死
中间设备的处理逻辑
当 NAT 设备收到一个 TCP Keepalive 探测包时,会发生什么?
TCP Keepalive 包本质是一个不包含数据的 ACK 包,序列号为当前序列号减一。它的目的是触发对端回复一个 ACK。
问题是:NAT 设备判断"连接活跃"的标准是什么?
大多数 NAT 设备只检查是否有数据传输,而不关心是否有 Keepalive 探测包。原因很简单——Keepalive 包不带数据载荷,不会刷新连接的"活动时间"。
更糟糕的是,某些网络设备会主动过滤这类"无意义"的包,认为它们是网络噪音。
半开连接问题
TCP Keepalive 能够检测的一种情况是:对端机器彻底失联(断电、崩溃)。但对于另一种常见情况——半开连接,TCP Keepalive 也无能为力。
半开连接发生在一端已经关闭或重启,但另一端仍认为连接有效。如果关闭方发送了 RST 包但该包在网络中丢失,存活方就会持有半开连接。
TCP Keepalive 的探测包发出去后,因为对端已经不存在,会触发 RST 响应。但这个 RST 要经过 NAT 设备——如果 NAT 已经清理了这条连接的映射,RST 包会被丢弃,发送 Keepalive 的一方永远不会收到任何响应。
应用层心跳的设计原理
正因为 TCP Keepalive 的上述局限,几乎所有需要长连接的应用层协议都实现了自己的心跳机制。
RabbitMQ 的设计选择
RabbitMQ 官方文档明确指出:
Heartbeats’ goal is to detect peer unavailability as soon as possible, because with TCP keepalive and default Linux settings it can take over 30 minutes.
RabbitMQ 默认心跳超时为 60 秒,实际心跳帧每隔约 30 秒发送一次。心跳帧是 AMQP 协议层的消息,会穿过所有中间设备,刷新它们的超时计时器。
更重要的是,RabbitMQ 的心跳机制可以检测应用层面的故障——即使 TCP 连接正常,如果 RabbitMQ 节点因为 GC 暂停或高负载无法响应,心跳超时同样会触发连接关闭。
gRPC 的 HTTP/2 PING 机制
gRPC 基于 HTTP/2 协议,利用 HTTP/2 原生的 PING 帧实现保活:
| 参数 | 客户端默认值 | 服务端默认值 |
|---|---|---|
| KEEPALIVE_TIME | INT_MAX(禁用) | 7200000ms(2小时) |
| KEEPALIVE_TIMEOUT | 20000ms | 20000ms |
| PERMIT_KEEPALIVE_TIME | N/A | 300000ms(5分钟) |
gRPC 的设计谨慎得多——客户端默认不启用心跳,需要显式配置。服务端默认 2 小时发送一次 PING,但会限制客户端的心跳频率不得低于 5 分钟,以防止过度消耗资源。
WebSocket 的 Ping/Pong 帧
WebSocket 协议(RFC 6455)定义了两种控制帧:
- Ping 帧(Opcode 0x9):可携带任意数据
- Pong 帧(Opcode 0xA):必须返回 Ping 帧中的数据
// WebSocket 心跳示例
let heartbeatInterval;
function startHeartbeat(ws) {
heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping(); // 发送 Ping 帧
}
}, 30000); // 每 30 秒
}
ws.on('pong', () => {
// 收到 Pong 响应,连接正常
console.log('Heartbeat OK');
});
WebSocket 的 Ping/Pong 机制比 TCP Keepalive 更可靠,因为它们是协议层的消息,会被中间设备正确处理。
心跳间隔的设计权衡
心跳间隔应该设为多少?这需要平衡三个因素:
- 中间设备超时:心跳间隔必须小于所有中间设备的超时时间
- 资源消耗:心跳频率越高,网络和 CPU 开销越大
- 故障检测速度:心跳间隔越短,能越快发现连接问题
一个实用的经验公式:
心跳间隔 ≤ min(中间设备超时) × 0.6
故障检测时间 ≤ 心跳间隔 × 2 + 网络延迟
对于 AWS ALB 环境(60 秒超时),合理的心跳间隔应该在 30-40 秒之间。移动网络环境则需要更短的间隔——微信的心跳策略是 WiFi 下 4 分 45 秒,移动网络下更短。
实战:正确实现长连接保活
1. 确定环境中的最短超时
在部署环境中最短的中间设备超时决定了你的心跳间隔上限。可以通过以下方式排查:
# 检查 Linux 内核 TCP 参数
sysctl net.ipv4.tcp_keepalive_time
sysctl net.ipv4.tcp_keepalive_intvl
sysctl net.ipv4.tcp_keepalive_probes
# 检查 conntrack 超时
cat /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established
# 对于 Kubernetes 环境,检查 Istio/Envoy 配置
kubectl get envoyproxy -o yaml | grep -A5 idle
2. 双向心跳与超时检测
一个健壮的心跳机制应该同时实现:
- 主动发送心跳:定期发送心跳包刷新中间设备
- 被动响应心跳:对端发来的心跳必须及时回复
- 超时检测:连续多次未收到心跳响应,判定连接失效
// Go 语言心跳实现示例
type HeartbeatManager struct {
interval time.Duration
timeout time.Duration
lastResponse time.Time
missedCount int
maxMissed int
}
func (h *HeartbeatManager) Start(conn net.Conn) {
ticker := time.NewTicker(h.interval)
go func() {
for range ticker.C {
// 发送心跳
if err := sendHeartbeat(conn); err != nil {
h.missedCount++
if h.missedCount >= h.maxMissed {
// 超过最大失败次数,关闭连接
conn.Close()
return
}
}
}
}()
}
func (h *HeartbeatManager) OnHeartbeatResponse() {
h.lastResponse = time.Now()
h.missedCount = 0
}
3. 连接池的特殊处理
数据库连接池面临的挑战更复杂——连接可能在池中空闲很长时间,使用时才发现已经失效。
主流连接池的解决方案:
# HikariCP 配置示例
spring:
datasource:
hikari:
# 连接存活超时,必须小于数据库或中间设备的超时
max-lifetime: 1800000 # 30 分钟
# 空闲连接保活检测间隔
keepalive-time: 300000 # 5 分钟
# 连接验证查询
connection-test-query: SELECT 1
HikariCP 从 3.4.0 版本开始支持 keepalive-time 配置,会定期对池中的空闲连接执行验证查询,确保它们在被取出时仍然有效。
4. 负载均衡器配置协调
如果你的应用跑在云负载均衡器后面,需要协调多层超时配置:
客户端超时 ≥ 负载均衡器超时 ≥ 应用服务器超时 ≥ 心跳间隔 × 2
以 AWS ALB 为例:
# Kubernetes Ingress 配置
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
# ALB 空闲超时
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=300
spec:
# ...
同时确保应用的心跳间隔小于 150 秒(300 × 0.5)。
调试长连接问题的工具
当长连接出现问题时,抓包是最直接的诊断手段:
# 捕获特定端口的 TCP 流量
tcpdump -i any port 8080 -w connection.pcap
# 过滤 Keepalive 相关包
tcpdump -i any port 8080 'tcp[tcpflags] & tcp-ack != 0 and (tcp[tcpflags] & tcp-syn = 0 and tcp[tcpflags] & tcp-fin = 0 and tcp[tcpflags] & tcp-rst = 0) and len < 10'
# 捕获 RST 包(连接被强制关闭)
tcpdump -i any port 8080 'tcp[tcpflags] & tcp-rst != 0'
分析抓包文件时,重点关注:
- 心跳包是否正常收发
- RST 包的来源 IP(判断是哪一层设备发送的)
- 心跳包与连接断开之间的时间间隔
总结:为什么应用层心跳不可或缺
TCP Keepalive 是操作系统提供的底层机制,它解决了"连接是否物理上可达"的问题。但在现代网络架构中,这个问题的答案远远不够。
网络中间设备不会等待 2 小时来判定一条连接是否应该清理——它们有自己的生存法则。负载均衡器、NAT 网关、防火墙,每一层都在为资源效率和安全性而主动干预连接状态。
应用层心跳的存在,不是为了替代 TCP Keepalive,而是为了满足 TCP Keepalive 无法覆盖的需求:
- 刷新中间设备的超时计时器
- 检测应用层面的存活状态
- 提供可配置的故障检测速度
理解这一点,才能在长连接架构设计中做出正确的选择。不要再指望 TCP Keepalive 能拯救你的连接——在它开始工作之前,中间设备早就把你的连接清理掉了。
参考资料
- RabbitMQ Documentation: Detecting Dead TCP Connections with Heartbeats and TCP Keepalives. https://www.rabbitmq.com/docs/heartbeats
- gRPC Documentation: Keepalive. https://grpc.io/docs/guides/keepalive/
- RFC 6455: The WebSocket Protocol. https://datatracker.ietf.org/doc/html/rfc6455
- Wang et al. “An Untold Story of Middleboxes in Cellular Networks.” ACM SIGCOMM Workshop on Hot Topics in Middleboxes and Network Function Virtualization, 2011.
- AWS Documentation: Connection Idle Timeout. https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html
- Envoy Documentation: Timeout Configuration. https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts
- TCP Keepalive HOWTO. https://tldp.org/HOWTO/TCP-Keepalive-HOWTO/
- Redis Documentation: Client Handling. https://redis.io/docs/latest/develop/reference/clients/