4. 传输层冗余:TCP与UDP的冗余策略、多连接管理

好,咱们进入第四讲。传输层冗余,说白了就是解决一个问题:一个连接挂了,另一个能顶上

我在做SOME/IP项目时,遇到过最头疼的事就是——明明应用层做了冗余,结果传输层单点故障,整个通信链路段了。你想想看,这多冤?所以,传输层的冗余设计,是整条链路里最不能省的一环。

4.1 TCP vs UDP:冗余策略的底层差异

先搞清楚一个基本问题:TCP和UDP,冗余策略能一样吗?

当然不一样。TCP是面向连接的,有状态。UDP是无连接的,无状态。这个差异决定了它们的冗余方式完全不同。

特性 TCP冗余 UDP冗余
连接状态 需要维护连接状态 无连接状态
冗余粒度 连接级冗余 报文级冗余
切换开销 较高(需重建连接) 较低(直接切换)
典型场景 长连接、控制指令 周期性数据、事件通知

我个人习惯是:关键控制信号走TCP冗余,高频数据走UDP冗余。为什么?因为控制信号丢不得,但可以慢一点;数据信号可以丢一点,但不能断流。

4.2 TCP冗余策略:多连接与热备份

TCP的冗余,核心思路就是多连接。一个主连接,一个或多个备用连接。

我在项目中遇到过一种情况:主连接因为网络抖动断了,备用连接还没建立好,结果ECU直接进入了安全模式。嗯,这其实是可以避免的。

4.2.1 主备连接模式

最简单的做法:同时建立两条TCP连接。一条主用,一条备用。

// 伪代码示例:主备连接管理
tcp_conn_t main_conn = tcp_connect(ip_primary, port);
tcp_conn_t backup_conn = tcp_connect(ip_backup, port);

// 主连接正常时,所有数据走主连接
if (main_conn.is_alive()) {
    send_data(main_conn, data);
} else {
    // 主连接挂了,自动切到备用
    send_data(backup_conn, data);
    // 同时尝试重连主连接
    reconnect(main_conn);
}

这里有个坑:备用连接不能一直闲着。我曾经见过一个设计,备用连接建立后就不管了,结果真正切换时才发现连接已经超时断开。所以,备用连接也要定期发心跳,保持活跃。

注意:TCP备用连接的心跳间隔不能和主连接一样。我建议备用连接的心跳频率可以低一些,比如主连接100ms一次,备用连接500ms一次。这样既省带宽,又能保证连接存活。

4.2.2 多路复用模式

还有一种更高级的做法:多路复用。把多个SOME/IP服务的通信复用到同一条TCP连接上。

这样做的好处是节省端口资源。但坏处也很明显——单点故障风险更大。所以,我一般建议:关键服务单独走一条连接,非关键服务可以复用。

4.3 UDP冗余策略:多路径与报文复制

UDP的冗余,思路和TCP完全不同。因为UDP没有连接的概念,所以冗余的重点在报文层面

4.3.1 多路径冗余

最简单的做法:同一个报文,通过两条不同的网络路径发送。

// 伪代码示例:UDP多路径发送
void send_udp_redundant(uint8_t* data, uint32_t len) {
    // 路径1:通过主网卡发送
    sendto(sock_primary, data, len, 0, 
           (struct sockaddr*)&addr_primary, sizeof(addr_primary));
    
    // 路径2:通过备用网卡发送
    sendto(sock_backup, data, len, 0, 
           (struct sockaddr*)&addr_backup, sizeof(addr_backup));
}

接收端呢?需要做去重处理。同一个报文收到两次,只处理一次。

小技巧:去重可以用序列号。每个报文带一个递增的序列号,接收端维护一个最近收到的序列号列表。重复的报文直接丢弃。

4.3.2 多端口冗余

还有一种做法:同一个服务,在多个UDP端口上监听。发送端随机选择一个端口发送,或者轮询发送。

我试过这种方法,效果不错。但要注意:接收端需要把所有端口的报文汇聚到一个处理线程,否则会出现乱序。

4.4 多连接管理:状态机与切换策略

多连接管理,说白了就是什么时候切、怎么切、切完怎么办

4.4.1 连接状态机

我习惯用一个简单的状态机来管理连接:

// 连接状态定义
typedef enum {
    CONN_INIT,      // 初始状态
    CONN_ACTIVE,    // 主连接活跃
    CONN_BACKUP,    // 备用连接活跃
    CONN_FAILED,    // 所有连接都挂了
    CONN_RECOVERY   // 正在恢复
} conn_state_t;

状态切换的逻辑:

  • CONN_INIT → CONN_ACTIVE:主连接建立成功
  • CONN_ACTIVE → CONN_BACKUP:主连接超时或断开,切换到备用
  • CONN_BACKUP → CONN_ACTIVE:主连接恢复,切回去
  • CONN_BACKUP → CONN_FAILED:备用也挂了
  • CONN_FAILED → CONN_RECOVERY:启动重连机制
核心原则:切换时不能丢数据。TCP切换前要把发送缓冲区里的数据发完,或者做好重传标记。UDP切换前要确保序列号连续。

4.4.2 切换策略:先通后优

我个人推荐先通后优的策略。什么意思?

  • 先保证通信不中断(通)
  • 再考虑切换到质量更好的连接(优)

举个例子:主连接延迟低但偶尔断,备用连接延迟高但稳定。这时候,我建议不要频繁切换。主连接断了,切到备用;主连接恢复了,也不要立刻切回去,等它稳定一段时间再说。

我曾经犯过这个错:主连接一恢复就切回去,结果网络还在抖动,切来切去反而导致通信不稳定。后来我加了一个稳定计时器,主连接恢复后至少稳定5秒才切回去,问题就解决了。

4.5 实战建议:冗余设计的取舍

最后,给几个实战中的建议:

  1. 不要过度冗余。冗余越多,系统越复杂。我见过一个项目,TCP和UDP都做了冗余,结果调试时自己都搞不清数据走的是哪条路。
  2. 冗余要有优先级。关键信号(如制动指令)做全冗余,非关键信号(如温度上报)做轻量冗余甚至不做。
  3. 监控要跟上。冗余不是为了隐藏问题,而是为了争取修复时间。所以,冗余切换一定要有日志和告警。
我的习惯:每个冗余连接都打上标签,比如"主连接-TCP-通道1"、"备用连接-UDP-通道2"。这样看日志时一目了然,不用猜数据是从哪来的。

好了,传输层冗余就讲到这里。下一讲咱们聊聊应用层的冗余设计,那又是另一番天地了。