4. 数据传输可靠性:应用层ACK确认、超时重传(RTO计算)、滑动窗口与流量控制

说到Socket通信的可靠性,很多人第一反应就是TCP。没错,TCP确实提供了这些机制。但问题是——我们做跨网络通信时,很多时候用的不是TCP,或者是在TCP之上还要再加一层自己的可靠性保障。

为什么?我遇到过好几次,TCP的ACK到了应用层其实已经晚了。或者更糟,你的数据被中间代理缓存了,TCP告诉你“已送达”,但对方应用根本没收到。所以,应用层的可靠性,必须自己动手。

4.1 应用层ACK确认:别信TCP,信自己

应用层ACK,说白了就是接收方收到数据后,专门发一个“我收到了”的报文。这个报文不是TCP的ACK,是你自己定义的协议包。

核心原则:发送方必须收到接收方显式的应用层确认,才能认为数据已成功交付。

我习惯这样设计ACK包的结构:

// 应用层ACK报文格式
struct AppAckPacket {
    uint32_t magic;          // 魔数,用于校验
    uint16_t packet_id;      // 确认的包序号
    uint16_t ack_flags;      // ACK标志位
    uint32_t timestamp;      // 发送时间戳
    uint32_t checksum;       // 校验和
};

这里有个坑——ACK包本身也可能丢。我曾经在一个项目中,接收方发了ACK,但网络抖动导致ACK丢了,发送方就傻傻地重传。结果接收方收到了重复数据,业务逻辑直接崩了。

避坑指南:接收方必须做去重处理。收到重复包时,直接丢弃,但还是要回ACK。我曾经因为没做这个,导致生产环境数据重复写入,修复了一整晚。

4.2 超时重传与RTO计算:别用固定值

超时重传,听起来简单——发个包,等一会儿没回ACK,就重发。但“等一会儿”是多久?

固定值?别闹。局域网里1ms就能收到ACK,你设个3秒超时,用户早跑了。跨公网时延迟可能200ms,你设个100ms超时,满世界都是重传包。

所以,RTO(Retransmission Timeout)必须动态计算。我推荐用类似TCP的Jacobson算法,但简化一下:

// RTO计算(简化版)
// 初始化
srtt = 0;       // 平滑往返时间
rttvar = 0;     // 往返时间方差
rto = 3000;     // 初始超时,单位ms

// 每次收到ACK时更新
void update_rto(uint32_t measured_rtt) {
    if (srtt == 0) {
        srtt = measured_rtt;
        rttvar = measured_rtt / 2;
    } else {
        rttvar = (3 * rttvar + abs(srtt - measured_rtt)) / 4;
        srtt = (7 * srtt + measured_rtt) / 8;
    }
    rto = srtt + 4 * rttvar;
    if (rto < 200) rto = 200;   // 最小200ms
    if (rto > 60000) rto = 60000; // 最大60s
}

你看,这个算法会根据网络状况自动调整。网络好的时候,RTO可能只有几百毫秒;网络差的时候,它会自动增大,避免盲目重传。

我的经验:初始RTO设3秒比较稳妥。设太短,刚启动时网络还没稳定,容易误判超时。设太长,用户等得心焦。

4.3 滑动窗口:控制并发,别把对方撑死

滑动窗口,说白了就是控制“我能一口气发多少包,不用等ACK”。窗口越大,吞吐量越高,但风险也越大——对方可能处理不过来。

我画个图帮你理解:

滑动窗口工作机制 发送方 接收方 1 2 3 4 5 6 7 8 ← 发送窗口(已发未确认) → 已确认 等待ACK 未发送 接收缓冲区 1 2 3 4 说明: 窗口大小 = 5 已发1-5,等待ACK 收到ACK后窗口右移

窗口大小怎么定?我一般这样算:

场景 推荐窗口大小 说明
局域网(延迟 < 1ms) 10-20 带宽高,延迟低,窗口可以大
同城跨机房(延迟 1-5ms) 8-15 网络稳定,适中即可
跨省公网(延迟 20-50ms) 4-8 延迟高了,窗口太大反而容易拥塞
跨国公网(延迟 > 100ms) 2-4 高延迟下,小窗口更安全

4.4 流量控制:别把接收方撑爆

滑动窗口管的是发送方,流量控制管的是接收方。说白了就是——接收方告诉发送方:“我快撑不住了,你慢点发。”

实现方式很简单:接收方在ACK包里带上自己的剩余缓冲区大小。

// 流量控制:接收方反馈窗口大小
struct AppAckPacket {
    uint32_t packet_id;
    uint16_t ack_flags;
    uint16_t window_size;   // 接收方剩余缓冲区大小
    uint32_t timestamp;
    uint32_t checksum;
};

// 发送方收到ACK后,调整发送窗口
void on_ack_received(AppAckPacket* ack) {
    // 取发送窗口和接收窗口的较小值
    uint16_t send_window = current_send_window;
    uint16_t recv_window = ack->window_size;
    uint16_t effective_window = min(send_window, recv_window);
    
    // 如果接收窗口为0,进入探测模式
    if (recv_window == 0) {
        start_zero_window_probe();
    }
}

注意:接收窗口为0时,发送方不能停止发送。我遇到过这种情况——接收方说窗口为0,发送方就停了,结果接收方处理完数据后忘了更新窗口,两边就死锁了。正确的做法是:窗口为0时,定期发一个探测包(比如每秒一次),直到收到非零窗口。

4.5 综合方案:把这些串起来

好了,现在我们把ACK确认、超时重传、滑动窗口、流量控制整合到一起。这是我常用的一个状态机:

// 发送方状态机
enum SendState {
    IDLE,           // 空闲
    SENDING,        // 发送中
    WAITING_ACK,    // 等待确认
    RETRANSMITTING, // 重传中
    WINDOW_FULL,    // 窗口满
    ZERO_WINDOW     // 接收窗口为0
};

// 核心发送逻辑
void send_data(DataPacket* packet) {
    // 1. 检查窗口
    if (effective_window <= 0) {
        if (recv_window == 0) {
            set_state(ZERO_WINDOW);
            start_probe_timer();
        } else {
            set_state(WINDOW_FULL);
        }
        return;
    }
    
    // 2. 发送数据
    send_packet(packet);
    add_to_unacked_list(packet);
    effective_window--;
    
    // 3. 启动超时定时器
    start_retransmit_timer(packet->id, rto);
    
    // 4. 设置状态
    set_state(WAITING_ACK);
}

// 收到ACK的处理
void on_ack(uint16_t packet_id, uint16_t window_size) {
    // 1. 移除已确认的包
    remove_from_unacked_list(packet_id);
    cancel_retransmit_timer(packet_id);
    
    // 2. 更新RTO
    uint32_t rtt = get_current_time() - get_packet_timestamp(packet_id);
    update_rto(rtt);
    
    // 3. 更新窗口
    recv_window = window_size;
    effective_window = min(send_window, recv_window);
    
    // 4. 发送下一批数据
    if (has_pending_data() && effective_window > 0) {
        send_next_batch();
    }
}

我的习惯:每个包最多重传3次。超过3次还没收到ACK,我就认为连接断了,直接报错。别无限重传,那是在浪费带宽。

4.6 性能调优:几个实战参数

最后,分享几个我常用的参数配置。这些值不是拍脑袋定的,是踩过坑后总结的:

参数 推荐值 调整依据
初始RTO 3000ms 网络刚建立时延迟不稳定
最小RTO 200ms 防止局域网下误判
最大RTO 60000ms 避免极端网络下无限等待
最大重传次数 3次 超过即判定连接断开
初始窗口大小 4 保守起步,逐步增大
零窗口探测间隔 1000ms 避免探测包过多

嗯,这套方案我在好几个项目里都用过,效果还不错。记住一个原则——宁可丢数据,也别乱数据。重传可以接受,但数据乱序、重复写入,那才是真正的灾难。