4、TCP异常处理与心跳:连接断开检测、心跳机制(Keep-Alive)、超时重传、异常场景模拟
TCP 看起来是个可靠的协议,对吧?三次握手建立连接,四次挥手优雅关闭。但现实网络环境可没那么理想。我见过太多线上事故,都是因为「连接断了,但应用层不知道」导致的。
说白了,TCP 的可靠性是有前提的——它假设网络链路是通的。一旦中间某个路由器挂了、网线被拔了、对端进程突然崩溃了,TCP 自己并不会立刻通知你。你还在傻傻地发数据,结果全丢了。
这一章,我们就来聊聊 TCP 的异常处理机制。包括怎么检测连接断开、心跳机制怎么用、超时重传的细节,以及如何模拟这些异常场景。
4.1 连接断开检测:TCP 的「死亡通知」
先问一个问题:当对端进程突然崩溃(比如被 kill -9),你的 TCP 连接会怎样?
答案是:你的进程不会立刻知道。TCP 协议本身没有「对端挂了,快通知我」的机制。它只能通过后续的数据发送或接收来间接感知。
具体来说,TCP 检测连接断开的途径有这几种:
| 检测方式 | 触发条件 | 通知方式 | 延迟 |
|---|---|---|---|
| read() 返回 0 | 对端正常关闭(FIN) | read 返回 0,表示 EOF | 立即 |
| write() 触发 RST | 对端已崩溃,本端尝试发送 | 收到 RST,后续 read/write 报错 | 取决于发送时机 |
| write() 超时 | 网络完全中断,无 RST 返回 | 多次重传后超时,返回 ETIMEDOUT | 几分钟到十几分钟 |
| Keep-Alive 探测 | 连接空闲超过阈值 | 发送探测包,无响应则断开 | 默认 2 小时 |
关键点:read() 返回 0 只代表对端调用了 close(),不代表对端进程还活着。对端可能已经挂了,但只要它的操作系统没发 FIN,你就收不到 0。
我记得有一次排查线上问题,服务端进程被 OOM killer 干掉了,但客户端一直没感知到。客户端还在等响应,等了 30 分钟才超时。这就是典型的「半开连接」问题。
4.2 心跳机制:应用层的「脉搏」
既然 TCP 自带的检测机制要么太慢(超时重传),要么不可靠(RST 不一定能收到),那怎么办?
答案就是:应用层心跳。
说白了,就是应用层自己定期发一个「我还活着」的消息。如果连续几次没收到心跳回复,就认为连接断了。
4.2.1 心跳设计原则
我做过几个高并发网关项目,心跳这块踩过不少坑。总结下来,设计心跳机制要注意这几点:
- 心跳间隔要合理:太频繁浪费带宽,太慢检测不及时。一般建议 5-30 秒。
- 超时次数要容忍:不要一次没收到就断开。建议连续 3-5 次无响应才判定断开。
- 心跳和业务数据要区分:心跳包不能影响业务逻辑。最好用独立的消息类型。
- 心跳要轻量:包体尽量小,甚至可以是空包。
我的经验:我曾经把一个心跳间隔设成了 3 秒,结果线上 100 万连接,每秒多出 33 万次心跳包,直接把带宽打满了。后来改成 15 秒,问题解决。别小看心跳的带宽消耗。
4.2.2 心跳实现示例
这里给一个简单的 Go 语言心跳实现。注意,这只是演示逻辑,生产环境需要更完善的错误处理。
// 心跳检测器
type Heartbeat struct {
interval time.Duration // 心跳间隔
timeout int // 超时次数
failCount int // 当前失败次数
lastRecv time.Time // 上次收到回复的时间
sendCh chan struct{} // 发送心跳的触发信号
closeCh chan struct{} // 关闭信号
}
func (h *Heartbeat) Start(conn net.Conn) {
ticker := time.NewTicker(h.interval)
go func() {
for {
select {
case <-ticker.C:
// 发送心跳包
_, err := conn.Write([]byte("PING"))
if err != nil {
h.failCount++
if h.failCount >= h.timeout {
// 判定连接断开
conn.Close()
return
}
}
case <-h.closeCh:
ticker.Stop()
return
}
}
}()
}
// 收到心跳回复时调用
func (h *Heartbeat) OnPong() {
h.failCount = 0
h.lastRecv = time.Now()
}
4.3 TCP Keep-Alive:系统自带的心跳
其实 TCP 协议本身也提供了一个类似心跳的机制,叫 Keep-Alive。不过它默认是关闭的,而且参数比较保守。
Keep-Alive 的工作原理是这样的:
- 连接空闲一段时间后(默认 2 小时),内核开始发送探测包。
- 每隔一段时间(默认 75 秒)发送一次。
- 连续发送一定次数(默认 9 次)无响应,就断开连接。
算一下,默认情况下,一个空闲连接要等 2 小时 + 75 秒 × 9 = 2 小时 11 分 15 秒 才能被检测到断开。这个时间太长了,大多数应用都等不了。
注意:Keep-Alive 不是 TCP 规范强制要求的。有些操作系统或网络设备可能会忽略 Keep-Alive 包。所以,不要完全依赖它。
不过,我们可以调整 Keep-Alive 的参数。在 Linux 上,通过 socket 选项设置:
// 启用 Keep-Alive
int keepalive = 1;
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
// 设置空闲时间(秒)
int idle = 60;
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
// 设置探测间隔(秒)
int interval = 10;
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
// 设置探测次数
int count = 3;
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));
这样配置后,连接空闲 60 秒就开始探测,每 10 秒一次,连续 3 次无响应就断开。总检测时间缩短到 60 + 10×3 = 90 秒。
4.4 超时重传:TCP 的「自救」机制
超时重传是 TCP 保证可靠性的核心机制。当发送方发出一个数据段后,会启动一个定时器。如果在定时器超时前没收到 ACK,就重传这个数据段。
这里有个关键点:超时时间不是固定的。TCP 会根据网络状况动态调整,这个算法叫 RTO(Retransmission Timeout)。
4.4.1 RTO 计算
RTO 的计算基于 RTT(Round-Trip Time,往返时间)。简单来说:
- 测量每个数据段的 RTT
- 计算平滑后的 RTT 和偏差
- RTO = 平滑 RTT + 4 × 偏差
这个算法保证了:网络好的时候,超时时间短;网络差的时候,超时时间长。避免了不必要的重传。
4.4.2 重传策略
TCP 的重传策略有几种:
| 策略 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 超时重传 | 等待 RTO 超时后重传 | 实现简单 | 效率低,等待时间长 |
| 快速重传 | 收到 3 个重复 ACK 时立即重传 | 不用等超时,效率高 | 需要接收方配合 |
| SACK | 选择性确认,只重传丢失的段 | 减少不必要的重传 | 需要双方支持 |
实际效果:快速重传 + SACK 是现代 TCP 栈的标配。它们能显著减少网络波动时的数据重传量。我优化过一个文件传输服务,开启 SACK 后,弱网环境下的传输速度提升了 40%。
4.5 异常场景模拟:动手验证
光说不练假把式。我们来看看怎么模拟这些异常场景。我习惯用 iptables 和 tcpdump 来模拟网络故障。
4.5.1 模拟连接断开
场景:服务端进程崩溃,客户端尝试发送数据。
# 启动一个简单的 TCP 服务端
nc -l 8888
# 客户端连接
nc localhost 8888
# 在服务端 kill 掉 nc 进程
# 此时客户端不会立刻感知
# 客户端发送数据
echo "hello" | nc localhost 8888
# 会收到 Connection reset by peer
4.5.2 模拟网络中断
场景:中间网络设备故障,连接完全断开。
# 在服务端模拟网络断开
iptables -A INPUT -p tcp --dport 8888 -j DROP
# 客户端发送数据
# 会卡住,直到 TCP 超时重传耗尽
# 最终返回 Connection timed out
4.5.3 模拟半开连接
场景:客户端网线被拔,服务端不知道。
# 建立连接后,在客户端拔掉网线
# 服务端尝试发送数据
# 数据会进入重传循环
# 直到 Keep-Alive 或应用层心跳检测到断开
调试技巧:用 ss -t -o 可以查看连接的定时器状态。比如看到 timer:(on,2.5sec,0) 就表示正在重传等待中。这个命令我几乎天天用。
4.6 本章知识体系
下面这张图总结了 TCP 异常处理的核心机制和它们之间的关系:
4.7 避坑指南
最后,分享几个我踩过的坑:
- 不要只依赖 TCP Keep-Alive:默认参数太保守,而且有些云环境会过滤 Keep-Alive 包。
- 心跳包不要和业务数据混在一起:我曾经见过一个系统,心跳和业务用同一个消息 ID 空间,结果心跳包被当成业务数据处理了,出了大问题。
- 超时重传不是万能的:如果网络完全断开,重传只会浪费带宽。配合心跳检测,尽早发现断开才是正道。
- 模拟测试要覆盖所有场景:不要只测正常关闭,一定要测 kill -9、拔网线、iptables DROP 这些极端情况。
血的教训:我曾经有一个支付网关服务,只依赖 TCP 的 read() 返回 0 来判断连接断开。结果有一次对端负载过高,内核来不及发 FIN,直接挂了。我的服务等了 15 分钟才超时,期间所有支付请求都卡住了。从那以后,我所有项目都强制加上了应用层心跳。