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 的工作原理是这样的:

  1. 连接空闲一段时间后(默认 2 小时),内核开始发送探测包。
  2. 每隔一段时间(默认 75 秒)发送一次。
  3. 连续发送一定次数(默认 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 异常处理的核心机制和它们之间的关系:

TCP 异常处理核心机制 连接断开检测 心跳机制 超时重传 read() 返回 0 write() RST 超时 ETIMEDOUT 应用层心跳 TCP Keep-Alive 超时重传 快速重传 SACK 异常场景模拟 进程崩溃 网络中断 半开连接 核心思路:不要依赖单一检测机制 应用层心跳 + TCP Keep-Alive + 超时重传,三层防护最可靠

4.7 避坑指南

最后,分享几个我踩过的坑:

  • 不要只依赖 TCP Keep-Alive:默认参数太保守,而且有些云环境会过滤 Keep-Alive 包。
  • 心跳包不要和业务数据混在一起:我曾经见过一个系统,心跳和业务用同一个消息 ID 空间,结果心跳包被当成业务数据处理了,出了大问题。
  • 超时重传不是万能的:如果网络完全断开,重传只会浪费带宽。配合心跳检测,尽早发现断开才是正道。
  • 模拟测试要覆盖所有场景:不要只测正常关闭,一定要测 kill -9、拔网线、iptables DROP 这些极端情况。

血的教训:我曾经有一个支付网关服务,只依赖 TCP 的 read() 返回 0 来判断连接断开。结果有一次对端负载过高,内核来不及发 FIN,直接挂了。我的服务等了 15 分钟才超时,期间所有支付请求都卡住了。从那以后,我所有项目都强制加上了应用层心跳。