{T}

连接有效性检测:TCP Keep-Alive与应用层心跳

在 TCP 连接的整个生命周期中,连接可能因对端崩溃、网络中断等异常情况而失效,但失效的连接在无数据交互时无法被自动感知。本文将系统分析 TCP Keep-Alive 机制与应用层心跳(Heartbeat)方案,并给出连接有效性检测的最佳实践。

连接失效问题

典型故障案例

在某基于 NATS 消息系统的项目中,多个消息发布者(Publisher)和订阅者(Subscriber)通过 NATS 服务器进行消息投递与订阅处理。某次线上故障表现为:消息正确投递至 NATS 服务器,但订阅者未收到消息,导致业务流程中断。

经排查,发现订阅者到 NATS 服务器的连接虽显示"正常",但实际上该连接已失效——NATS 服务器曾崩溃重启,但服务器发送的 FIN 报文因网络异常未能到达订阅者,导致订阅者维护着一个"过时"(Stale)的连接,无法接收任何消息。

根本原因:订阅者未对连接有效性进行检测,无法及时发现失效连接。

问题本质

在无数据读写的"静默"连接上,TCP 协议本身无法感知连接是否仍然有效。若对端崩溃且 FIN 报文未能到达,本地端可能长时间维护无用连接。

TCP Keep-Alive 机制

工作原理

TCP Keep-Alive 机制通过定期发送探测报文来检测连接的存活性:

图表渲染中…

内核参数

TCP Keep-Alive 机制涉及三个核心系统参数:

参数含义默认值说明
net.ipv4.tcp_keepalive_time保活时间7200 秒(2 小时)连接空闲多久后开始发送探测报文
net.ipv4.tcp_keepalive_intvl保活时间间隔75 秒两次探测报文之间的间隔
net.ipv4.tcp_keepalive_probes保活探测次数9 次判定连接死亡前最大探测次数

内核版本注释

  • 以上默认值在 Linux 2.4 至 7.0 各版本中保持一致。
  • 自 Linux 2.6.37 起,可通过 TCP_KEEPIDLETCP_KEEPINTVLTCP_KEEPCNT 套接字选项在每连接级别覆盖系统默认值,无需修改全局参数。
c
int keepalive = 1;       // 启用 Keep-Alive
int idle = 60;           // 60 秒后开始探测
int interval = 10;       // 每 10 秒探测一次
int count = 3;           // 3 次无响应判定死亡

setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));

探测结果的三种情况

情况对端状态探测结果连接处置
1正常工作收到 ACK 响应保活时间重置
2崩溃并重启收到 RST 报文连接被重置
3崩溃或网络不可达无响应探测次数耗尽后判定死亡

Keep-Alive 的局限性

使用默认参数时,检测一个"死亡"连接所需的最长时间为:

code
tcp_keepalive_time + tcp_keepalive_intvl × tcp_keepalive_probes
= 7200 + 75 × 9 = 7875 秒 ≈ 2 小时 11 分 15 秒

即使通过套接字选项缩短参数,TCP Keep-Alive 机制仍存在以下局限性:

  1. 检测粒度粗:探测报文仅确认 TCP 协议层的连通性,无法检测应用层是否正常响应。
  2. 参数全局性:系统级参数影响所有连接,难以针对不同服务差异化配置(虽可通过套接字选项覆盖,但增加了编程复杂度)。
  3. 误判风险:中间设备(如防火墙、NAT 网关)可能对长连接进行超时清理,Keep-Alive 探测报文可能被中间设备丢弃。

应用层心跳机制

针对 TCP Keep-Alive 的局限性,可在应用层实现更灵活的心跳(Heartbeat)检测方案。

心跳协议设计

图表渲染中…

消息格式定义

定义一个消息对象,包含消息类型字段和数据字段:

c
typedef struct {
    u_int32_t type;
    char data[1024];
} messageObject;

#define MSG_PING          1
#define MSG_PONG          2
#define MSG_TYPE1        11
#define MSG_TYPE2        21

客户端实现

客户端模拟 TCP Keep-Alive 机制,使用 select() 的超时功能作为定时器:

c
#include "lib/common.h"
#include "message_objecte.h"

#define MAXLINE             4096
#define KEEP_ALIVE_TIME     10
#define KEEP_ALIVE_INTERVAL  3
#define KEEP_ALIVE_PROBETIMES 3

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: tcpclient <IPaddress>");
    }

    int socket_fd;
    socket_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, argv[1], &server_addr.sin_addr);

    socklen_t server_len = sizeof(server_addr);
    int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
    if (connect_rt < 0) {
        error(1, errno, "connect failed");
    }

    char recv_line[MAXLINE + 1];
    int n;

    fd_set readmask;
    fd_set allreads;

    struct timeval tv;
    int heartbeats = 0;

    tv.tv_sec = KEEP_ALIVE_TIME;
    tv.tv_usec = 0;

    messageObject messageObject;

    FD_ZERO(&allreads);
    FD_SET(socket_fd, &allreads);

    for (;;) {
        readmask = allreads;
        int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
        if (rc < 0) {
            error(1, errno, "select failed");
        }
        if (rc == 0) {
            if (++heartbeats > KEEP_ALIVE_PROBETIMES) {
                error(1, 0, "connection dead\n");
            }
            printf("sending heartbeat #%d\n", heartbeats);
            messageObject.type = htonl(MSG_PING);
            rc = send(socket_fd, (char *) &messageObject, sizeof(messageObject), 0);
            if (rc < 0) {
                error(1, errno, "send failure");
            }
            tv.tv_sec = KEEP_ALIVE_INTERVAL;
            continue;
        }
        if (FD_ISSET(socket_fd, &readmask)) {
            n = read(socket_fd, recv_line, MAXLINE);
            if (n < 0) {
                error(1, errno, "read error");
            } else if (n == 0) {
                error(1, 0, "server terminated\n");
            }
            printf("received heartbeat, make heartbeats to 0\n");
            heartbeats = 0;
            tv.tv_sec = KEEP_ALIVE_TIME;
        }
    }
}

程序逻辑解析

  1. 初始化阶段:创建 TCP 套接字,建立连接,初始化 select() 的超时时间为 KEEP_ALIVE_TIME
  2. 超时处理select() 返回 0 表示超时,此时发送 PING 消息,探测计数递增。若超过 KEEP_ALIVE_PROBETIMES,判定连接死亡。
  3. 响应处理:收到服务器端数据后,重置探测计数和保活时间。

服务器端实现

服务器端接收客户端消息,对 PING 消息回复 PONG:

c
#include "lib/common.h"
#include "message_objecte.h"

static int count;

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: tcpserver <sleepingtime>");
    }

    int sleepingTime = atoi(argv[1]);

    int listenfd;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(SERV_PORT);

    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed");
    }

    int rt2 = listen(listenfd, LISTENQ);
    if (rt2 < 0) {
        error(1, errno, "listen failed");
    }

    int connfd;
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);

    if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
        error(1, errno, "accept failed");
    }

    messageObject message;
    count = 0;

    for (;;) {
        int n = read(connfd, (char *) &message, sizeof(messageObject));
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed\n");
        }

        printf("received %d bytes\n", n);
        count++;

        switch (ntohl(message.type)) {
            case MSG_TYPE1:
                printf("process MSG_TYPE1\n");
                break;

            case MSG_TYPE2:
                printf("process MSG_TYPE2\n");
                break;

            case MSG_PING: {
                messageObject pong_message;
                pong_message.type = MSG_PONG;
                sleep(sleepingTime);
                ssize_t rc = send(connfd, (char *) &pong_message, sizeof(pong_message), 0);
                if (rc < 0)
                    error(1, errno, "send failure");
                break;
            }

            default:
                error(1, 0, "unknown message type (%d)\n", ntohl(message.type));
        }
    }
}

程序逻辑解析

  1. 监听与连接:创建 TCP 监听套接字,绑定端口,等待客户端连接。
  2. 消息分发:根据消息类型进行不同处理。对 PING 消息,延迟 sleepingTime 后回复 PONG。
  3. 模拟无响应:通过较长的休眠时间模拟服务器端无法及时响应的场景。

实验验证

实验一:服务器端延迟 60 秒

code
# 客户端输出
$ ./pingclient 127.0.0.1
sending heartbeat #1
sending heartbeat #2
sending heartbeat #3
connection dead

# 服务器端输出
$ ./pingserver 60
received 1028 bytes
received 1028 bytes

客户端发送 3 次 PING 探测后未收到 PONG 响应,判定连接死亡并退出。

实验二:服务器端延迟 5 秒

code
# 客户端输出
$ ./pingclient 127.0.0.1
sending heartbeat #1
sending heartbeat #2
received heartbeat, make heartbeats to 0
received heartbeat, make heartbeats to 0
sending heartbeat #1
sending heartbeat #2
received heartbeat, make heartbeats to 0
received heartbeat, make heartbeats to 0

# 服务器端输出
$ ./pingserver 5
received 1028 bytes
received 1028 bytes
received 1028 bytes
received 1028 bytes

服务器端在探测间隔内完成响应,客户端持续维持正常连接状态。

TCP Keep-Alive 与应用层心跳的对比

特性TCP Keep-Alive应用层心跳
检测层次传输层(TCP 协议栈)应用层
检测粒度仅确认协议层连通性可检测应用层可用性
参数灵活性系统级默认参数粗粒度;套接字选项可覆盖(Linux 2.6.37+)完全自定义
响应内容空 ACK 报文可携带应用层状态信息
实现复杂度内核自动处理,无额外编码需设计心跳协议和定时器
防火墙穿透探测报文可能被中间设备丢弃与业务数据共享通道,不易被丢弃

最佳实践

  1. 推荐组合使用:启用 TCP Keep-Alive 作为基础保障,同时实现应用层心跳以检测应用层可用性。
  2. 参数调优:根据业务需求调整 Keep-Alive 参数,建议通过套接字选项在每连接级别设置,避免修改全局参数。
  3. 心跳频率:应用层心跳间隔应综合考虑网络开销与故障检测时效性,通常建议 10~30 秒。
  4. 探测策略:多次探测(通常 3 次)后再判定连接死亡,避免因瞬时网络抖动导致误判。
  5. 异常处理:连接判定死亡后,应执行资源清理、重连或告警等操作。

总结

  • TCP Keep-Alive 机制提供了传输层级别的连接存活性检测,但默认参数下检测延迟过长,且无法检测应用层可用性。
  • 应用层心跳机制可在应用层实现更灵活、更精确的连接有效性检测。
  • 在生产环境中,推荐 TCP Keep-Alive 与应用层心跳组合使用,分别保障传输层和应用层的连通性。

思考题

  1. 本文的心跳检测方案基于 TCP 连接,是否同样适用于 UDP 协议?若适用,需做哪些调整?
  2. 心跳探测报文占用了一定的网络带宽,是否值得?为何需要多次探测才能判定连接死亡,而非一次无响应即判定?

版本信息

项目内容
更新日期2026-06-09
目标内核Linux 7.0
关键特性TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT 套接字选项(2.6.37+)、TCP_USER_TIMEOUT(2.6.37+)