设备通信与协议编程(六):超时、重连与幂等性设计

设备通信最危险的失败不是明确返回错误,而是:命令已发出,连接随后中断,上位机不知道设备是否执行。简单地“超时就重试”可能让运动轴重复移动、阀门重复动作或配方重复下发。

本篇建立一套与串口、TCP 都适用的故障模型。

1. 把一次调用拆成阶段

1
排队 → 编码 → 发送 → 设备接收 → 设备执行 → 响应发送 → 客户端接收

客户端观察到超时,只能证明最后期限内没有得到期望响应,不能反推出失败发生在哪一步。尤其在“执行完成、响应丢失”时,无条件重试会重复副作用。

2. 三类结果

结果含义典型处理
已成功收到可验证的成功响应提交上层状态
已失败收到明确拒绝,且设备保证未执行修正参数或进入故障处理
未知超时、断线、响应无法关联查询状态、对账或人工确认

把“未知”强行映射成失败,是许多重复动作事故的根源。API 可以显式返回 Outcome.Unknown,迫使上层选择恢复策略。

3. 幂等键与命令语义

幂等不是“重试次数小”。同一个逻辑操作重复提交后,设备端应识别它并返回原结果,而不是再次执行。

常见设计:

1
2
去重键 = StableClientId + CommandId
校验属性 = Operation + Parameters

StableClientId 来自持久配置或已认证的客户端身份,不能使用每次重连都会变化的传输 Session ID。客户端生成的 CommandId 在该身份范围内保持唯一,设备在约定窗口内保存去重键、操作参数与结果:重复请求的操作和参数完全相同则重放原结果;同一去重键携带不同内容必须拒绝。若要求跨设备重启重试,设备端去重记录也必须按协议要求持久化;仅在内存中保存无法覆盖重启窗口。

并非所有命令天然幂等:

  • SetPosition(100) 可能是幂等的,但仍受坐标系和会话状态影响;
  • MoveRelative(+10) 通常不是幂等的;
  • Start() 在已运行时是否成功,必须由契约定义;
  • “写配置”若伴随闪存计数或触发重启,也不能只看最终值。

4. 没有设备端幂等支持怎么办

可按风险从低到高选择:

  1. 重连后查询设备状态,确认目标是否已达到;
  2. 使用设备提供的任务号、事件号或最后命令记录对账;
  3. 将非幂等动作改造成“设置绝对目标 + 查询完成状态”;
  4. 无法证明安全时停止自动恢复,要求人工确认。

客户端本地去重无法覆盖“设备执行后客户端崩溃”的窗口,不能替代设备端协议支持。

5. 重试策略

只有同时满足以下条件时才自动重试:

  • 错误被判定为瞬时;
  • 操作天然幂等或具有端到端幂等键;
  • 剩余截止时间足够;
  • 重试不会违反设备状态机和安全约束。

退避可使用带抖动的指数策略,并设置最大间隔、最大次数和总体截止时间。所有客户端若固定每秒同时重连,会在设备恢复瞬间形成惊群。

协议错误、认证失败、参数非法和安全互锁通常不是“多试几次”能解决的。

6. 重连是新会话

连接恢复后,不要直接把状态标为 Ready。稳健流程通常是:

1
2
断线 → 取消旧请求 → 退避 → 建连 → 握手 → 身份核对
     → 能力/版本核对 → 查询设备状态 → 必要的重新同步 → Ready

每次连接分配 ConnectionGeneration。接收、超时和回调都携带世代号,旧连接迟到的完成事件不能改变新连接状态。

串口重开前还可能需要等待线路静默或丢弃残留输入;TCP 重连则得到新的字节流,旧连接的事务 ID 不能无条件沿用。

7. 超时预算而不是孤立超时

一次业务操作可能经过队列、发送、设备执行和响应。用单调时间计算每一步剩余预算:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public sealed class Deadline
{
    private readonly TimeProvider _timeProvider;
    private readonly long _startedAt;
    private readonly TimeSpan _budget;

    public Deadline(TimeProvider timeProvider, TimeSpan budget)
    {
        _timeProvider = timeProvider;
        _startedAt = timeProvider.GetTimestamp();
        _budget = budget;
    }

    public TimeSpan Remaining
    {
        get
        {
            TimeSpan value = _budget - _timeProvider.GetElapsedTime(_startedAt);
            return value > TimeSpan.Zero ? value : TimeSpan.Zero;
        }
    }
}

TimeProvider.GetTimestamp() 提供单调时间戳,避免墙上时钟校准造成跳变;测试中还可以注入可控的时间提供器。

8. 熔断与限流放在哪里

设备通常是低并发、强状态资源。比通用 HTTP 客户端更重要的是:

  • 有界命令队列,满时明确拒绝或合并;
  • 同一设备的并发度符合协议能力;
  • 连续失败后进入 Faulted,避免无限轰炸设备;
  • 恢复探测只允许少量安全命令;
  • 急停和安全回路不依赖普通应用重试链路。

熔断器不能代替设备状态机,也不能把未知结果变成失败。设备级故障闭环见《设备软件架构与控制模型(六):故障处理、报警与安全恢复》。

9. 可观测性

一次操作至少关联这些字段:

1
2
DeviceId, ConnectionGeneration, CommandId, Operation,
Attempt, QueueDuration, RoundTripDuration, Outcome, ErrorCategory

指标应区分连接失败、协议坏帧、设备拒绝、业务超时和未知结果。只统计一个“通信失败率”,无法指导恢复。

10. 测试故障窗口

模拟器应能在每个阶段注入故障:收到命令前断开、执行前断开、执行后响应前断开、发送半帧、重复响应、迟到响应、重启后去重表丢失。然后验证:

  • 非幂等动作不会自动重复;
  • 旧世代响应不能完成新请求;
  • 队列和等待表最终清空;
  • 未知结果被显式上报;
  • 停止信号能中断退避和重连。

11. 小结

超时只是观察,不是结论;重连是新会话,不是恢复旧 socket;重试只有在端到端幂等成立时才安全。把“未知结果”建模出来,并通过查询、对账和连接世代恢复,设备软件才能在最棘手的故障窗口中保持可控。

参考资料

Licensed under CC BY-NC-SA 4.0