工业网关重试机制:从固定间隔到指数退避的工程实战

工业边缘网络天然不稳定:4G 信号抖动、专线短暂中断、云端服务发布、现场设备忙、DNS 解析失败都可能造成一次调用失败。重试可以提升可用性,也可能把小故障放大成重试风暴。

因此,重试机制的核心不是"失败后再调一次",而是回答四个问题:

  1. 这个错误是临时错误吗?
  2. 重复执行会不会造成副作用?
  3. 什么时候必须停止?
  4. 停止后数据去哪里、谁来处理?

本文按错误分类、退避策略、幂等、预算、熔断、死信队列和监控来拆解工程实践。

一、重试适用的前提

1. 故障是瞬时的

适合重试的典型情况:

  • 网络抖动、连接被重置;
  • DNS 短暂解析失败;
  • 服务过载或短暂不可用;
  • 客户端读取超时,但服务端大概率未处理请求;
  • MQTT / TCP 长连接断开后重新连接。

不适合盲目重试的情况:

  • 认证失败、权限不足;
  • 参数格式错误、协议版本不匹配;
  • 配置缺失、证书无效;
  • 业务规则冲突;
  • 设备明确返回"不支持"或"非法地址";
  • 请求已经到达服务端但结果未知,且操作不幂等。

2. 操作必须可安全重复

读操作通常可以重复;写操作要区分类型:

操作类型 是否适合自动重试 处理方式
查询设备状态 通常适合 可直接重试
上报采样数据 适合,但需幂等键 用测点、时间戳或序列号去重
创建工单 / 订单 不适合无脑重试 使用客户端生成的事务 ID
下发控制指令 高风险 必须确认结果,不能只靠重复发送
配置修改 需版本号 基于版本做条件更新,避免旧值覆盖新值

超时不等于失败。一个写请求超时后,可能服务端已经执行成功,只是响应没有回来。对这种"结果不确定"的操作,应该先查询结果或使用幂等键,而不是直接重复提交。

3. 有明确的停止条件

每个重试任务都必须有边界:

  • 最大尝试次数;
  • 最大耗时;
  • 每次尝试的超时时间;
  • 退避上限;
  • 重试预算;
  • 取消信号;
  • 最终失败后的落地策略。

没有边界的重试不是可靠性设计,而是资源泄漏。

二、四种常用策略

1. 固定间隔

text 复制代码
失败 → 等 5s → 重试 → 等 5s → 重试 → ...

优点是实现简单、行为可预测,适合:

  • 周期性状态查询;
  • 低频本地恢复;
  • 对恢复速度要求不高的后台任务。

缺点是无法区分轻微抖动和严重故障,也容易让一批设备在同一时间重试。

2. 指数退避

text 复制代码
失败 → 1s → 2s → 4s → 8s → 16s → 达到上限后保持不变

间隔随失败次数增加,用于减少对故障服务的压力。必须设置 max_delay,否则间隔会增长到不可用。

指数退避只解决"压得慢一点",不解决"大家一起压"。

3. 指数退避加抖动

当 1000 台网关同时掉线,固定或纯指数策略会让它们在相近时间重连,形成同步重试。抖动可以把重试时间打散。

常见做法:

text 复制代码
base_delay = min(base * 2 ^ attempt, max_delay)
full_jitter_delay = random(0, base_delay)
equal_jitter_delay = base_delay / 2 + random(0, base_delay / 2)

工业网关大量部署时建议使用带抖动的策略,并为随机值设置最小等待时间,避免出现近似 0 秒的密集重试。

4. 限流与令牌桶

严格说,令牌桶不是重试策略,而是重试的并发和速率控制。它适合放在重试队列外层:

text 复制代码
pending retry queue
    ↓
token bucket / semaphore
    ↓
bounded worker pool
    ↓
target service

作用包括:

  • 限制每秒重试请求数;
  • 限制并发连接数;
  • 控制对现场设备和云端 API 的压力;
  • 防止队列恢复后瞬间放洪。

策略选择

场景 推荐策略
单台设备短周期读 固定间隔,少量重试
云端 API 调用 指数退避 + 抖动 + 超时
大规模网关重连 指数退避 + 大范围抖动
本地写队列恢复 指数退避 + 持久化队列 + 幂等键
服务持续异常 重试预算 + 熔断 + 告警

三、错误分类:先分类,再重试

不要把所有异常都转换成 Exception 后统一重试。至少分成三类:

python 复制代码
class RetryableError(Exception):
    """明确可以安全重试的临时错误。"""


class NonRetryableError(Exception):
    """重试不会改变结果的错误。"""


class AmbiguousError(Exception):
    """请求可能已生效,结果不确定。"""

分类建议:

类型 例子 处理
可重试 连接拒绝前的网络错误、服务暂不可用、限流 退避后重试
不可重试 认证失败、参数错误、权限不足、协议不支持 记录并进入异常流程
结果不确定 写入后超时、连接中断 查询结果或靠幂等键判断

认证失败通常不重试,但可以在 token 过期时执行一次刷新后重试,并限制刷新次数。

四、HTTP 状态码的重试规则

HTTP 状态码只能作为初步判断,还要结合请求方法、幂等性和业务语义。

状态码 常见处理
2xx 不重试,确认业务结果
400 / 401 / 403 / 404 / 409 / 422 默认不重试,进入业务异常处理
408 可重试,但需确认请求是否可安全重复
425 Too Early 可按服务端语义退避后重试
429 优先读取 Retry-After,并全局限速
500 谨慎重试;可能是服务端持续异常
502 / 503 / 504 通常可重试,配合退避和熔断
501 Not Implemented 不重试

几个容易忽略的点:

  • POST 不一定不幂等,关键看服务端语义和幂等键;
  • 429Retry-After 可能是秒数,也可能是 HTTP 日期,需要分别解析;
  • 代理返回的 502 可能只代表代理到上游失败,不能无限重试;
  • 服务端返回 5xx 时,应保留响应体中的错误码,避免丢失真实原因;
  • 客户端超时不是 HTTP 状态码,要按"结果不确定"处理。

五、Python 实现

1. tenacity 示例

python 复制代码
import logging

from tenacity import (
    before_sleep_log,
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_random_exponential,
)

log = logging.getLogger(__name__)


@retry(
    stop=stop_after_attempt(5),
    wait=wait_random_exponential(multiplier=1, min=1, max=30),
    retry=retry_if_exception_type(RetryableError),
    before_sleep=before_sleep_log(log, logging.WARNING),
    reraise=True,
)
async def fetch_device(device_id: str):
    async with asyncio.timeout(5):
        return await client.get(f"/devices/{device_id}")

这个例子假设业务层已经把异常分类好。如果在这里捕获所有异常,认证错误和参数错误也会被重复发送。

2. 自定义退避函数

python 复制代码
import asyncio
import random
import time


async def retry_with_backoff(
    func,
    *,
    max_attempts: int = 5,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
    min_delay: float = 0.2,
    deadline: float = 60.0,
):
    started_at = time.monotonic()

    for attempt in range(1, max_attempts + 1):
        try:
            return await func()
        except RetryableError as exc:
            if attempt == max_attempts:
                raise

            remaining = deadline - (time.monotonic() - started_at)
            if remaining <= 0:
                raise TimeoutError("retry deadline exceeded") from exc

            raw_delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
            delay = max(min_delay, random.uniform(0, raw_delay))
            delay = min(delay, remaining)

            log.warning(
                "retry attempt=%d/%d delay=%.2fs error=%s",
                attempt,
                max_attempts,
                delay,
                exc,
            )
            await asyncio.sleep(delay)

    raise RuntimeError("unreachable retry state")

自定义实现的价值在于可以把业务语义、重试预算、审计日志和指标统一起来。生产代码中还应支持 asyncio.CancelledError 透传,避免取消任务被误当成可重试错误。

六、Go 实现

下面的示例使用请求级 context、完整超时和指数退避:

go 复制代码
func fetchWithRetry(
    ctx context.Context,
    url string,
) ([]byte, error) {
    var body []byte

    operation := func() error {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return backoff.Permanent(err)
        }

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        defer resp.Body.Close()

        switch {
        case resp.StatusCode >= 200 && resp.StatusCode < 300:
            raw, err := io.ReadAll(resp.Body)
            if err != nil {
                return err
            }
            body = raw
            return nil
        case resp.StatusCode == http.StatusTooManyRequests:
            return newRetryAfterError(resp)
        case resp.StatusCode == http.StatusBadGateway,
            resp.StatusCode == http.StatusServiceUnavailable,
            resp.StatusCode == http.StatusGatewayTimeout:
            return fmt.Errorf("temporary server error: %d", resp.StatusCode)
        default:
            return backoff.Permanent(
                fmt.Errorf("non-retryable status: %d", resp.StatusCode),
            )
        }
    }

    policy := backoff.NewExponentialBackOff()
    policy.InitialInterval = time.Second
    policy.MaxInterval = 30 * time.Second
    policy.MaxElapsedTime = 2 * time.Minute

    err := backoff.Retry(
        operation,
        backoff.WithContext(policy, ctx),
    )
    return body, err
}

工程上还要注意:

  • 不要在库层隐式无限重试;
  • 每次请求都要有超时,外层再设总超时;
  • backoff.Permanent 用于明确不可重试的错误;
  • 429 的等待时间应优先进退避策略;
  • 指数重试会拉长尾延迟,交互路径和后台路径要分开配置。

七、重试预算与熔断

当目标服务已经持续故障,继续重试只会消耗 CPU、内存、连接和流量。需要引入预算与熔断。

1. 重试预算

常见做法:

  • 每个目标服务设置最大重试 QPS;
  • 重试请求占总请求比例不超过阈值,例如 10%-20%;
  • 每台网关限制同时重试的任务数;
  • 高优先级任务和低优先级任务使用不同预算;
  • 预算耗尽后直接进入延迟队列或失败流程。

2. 熔断器状态机

text 复制代码
Closed → 失败率超过阈值 → Open
Open   → 冷却时间结束 → Half-Open
Half-Open → 探测成功 → Closed
Half-Open → 探测失败 → Open

熔断不是替代重试,而是限制重试的作用范围:

  • Closed:正常放行;
  • Open:快速失败或走本地缓存;
  • Half-Open:只放少量探测请求;
  • 恢复后逐步放量,避免二次打崩。

3. 降级策略

熔断打开后,按业务选择:

  • 使用本地最近一次缓存值,并标记数据时间;
  • 保留采集数据到本地队列;
  • 暂停非关键上报;
  • 控制指令进入人工确认流程;
  • 告警提示链路不可用。

八、与 DLQ 组合

对消息消费和上行队列,最终失败不能只抛异常,还要让消息可追踪、可审计、可重放。

python 复制代码
async def process_with_retry_and_dlq(message):
    error = None

    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            await process(message)
            await ack(message)
            return

        except NonRetryableError as exc:
            error = exc
            break

        except RetryableError as exc:
            error = exc
            if attempt < MAX_ATTEMPTS:
                await sleep_with_jitter(attempt)

    await dead_letter_queue.put(
        {
            "message": message,
            "error": str(error),
            "attempts": attempt,
            "failed_at": utc_now().isoformat(),
        }
    )
    await ack(message)

DLQ 设计要点:

  • DLQ 本身要持久化,不能只放在进程内存;
  • 记录原始消息、错误、堆栈、尝试次数和失败时间;
  • 区分业务失败、系统失败和毒性消息;
  • 提供人工重放入口和重放次数限制;
  • 设置 DLQ 深度和消息年龄告警;
  • 重放前先修复配置或服务,否则只是重复失败。

注意:AmbiguousError 不应直接进 DLQ 后自动重放。应先通过查询接口或幂等记录确认结果。

九、工业网关的三类重试

1. 南向设备读写

现场总线、Modbus、CAN、MQTT 设备访问要尊重设备能力:

  • 读请求可以有限重试;
  • 写和控制指令必须确认幂等性;
  • 同一设备的并发请求要做串行化或限流;
  • 超时后不能假设设备没有执行;
  • 保留最后错误码和报文上下文,便于排查。

2. 北向云服务调用

云端 API 通常适合指数退避加抖动:

  • 使用请求级超时和总超时;
  • 429 时全局降速;
  • 携带幂等键;
  • 失败后进入持久化队列;
  • 监控上下行链路成功率。

3. 长连接重连

MQTT、WebSocket、TCP 长连接的"重试"主要是重连:

  • 指数退避加大范围抖动;
  • 每次重连都要重新认证和订阅;
  • 断线期间数据落本地队列;
  • 恢复后按序、限速补发;
  • 避免一断开就立刻全量重连。

十、监控指标

只记录"成功 / 失败"不够,至少要观测:

  • 首次成功率;
  • 重试后成功率;
  • 按尝试次数统计任务分布;
  • 重试请求占比;
  • 最终失败数;
  • DLQ 深度与消息年龄;
  • 熔断状态和状态切换次数;
  • 重试预算耗尽次数;
  • 尾延迟 p95 / p99;
  • 本地待补发队列长度;
  • 断网时长和恢复时长;
  • 每类错误码分布。

关键告警示例:

text 复制代码
重试率连续 5 分钟超过 20%
同一接口最终失败率超过 5%
DLQ 消息年龄超过 1 小时
熔断器 10 分钟内打开 3 次
本地待补发队列接近容量上限

重试后成功率高,说明策略有效;重试率和最终失败率同时升高,说明目标服务或网络已有系统性问题,需要告警而不是继续等待。

十一、常见坑与修正

坑 1:无限重试

现象:任务长期占用连接和内存,重启后问题消失。

修正:设置最大次数、最大耗时和退避上限,并支持取消。

坑 2:嵌套重试

现象:SDK 重试一次,业务层重试三次,任务队列再重试五次,实际请求被放大几十倍。

修正:每一层职责清晰;底层负责单次请求,上层负责流程恢复,并统一预算。

坑 3:没有抖动

现象:断电恢复或服务重启后,大量网关同时重连。

修正:使用指数退避加全量抖动,并按设备 ID、站点或租户打散。

坑 4:把不可重试错误硬重试

现象:认证失败、非法地址、参数错误反复请求。

修正:先分类错误;不可重试错误直接记录、告警或进入 DLQ。

坑 5:写操作不幂等

现象:超时后重复上报,云端生成两条记录或重复执行控制。

修正:使用客户端事务 ID、序列号、业务主键或条件更新。

坑 6:重试队列只在内存里

现象:进程重启后待重试任务全部丢失。

修正:关键数据写入 SQLite、本地日志或持久化队列,并做恢复与对账。

坑 7:没有监控

现象:系统"看起来可用",但大量请求靠多次重试撑着。

修正:把重试率、最终失败率、DLQ 和尾延迟纳入核心看板。

十二、落地检查清单

  • 错误已分为可重试、不可重试、结果不确定;
  • 写操作具备幂等键或条件更新机制;
  • 每次请求和整个流程都有超时;
  • 指数退避设置了最大间隔;
  • 大规模设备部署启用了抖动;
  • 最大尝试次数和总耗时已明确;
  • 重试任务支持取消;
  • 关键失败进入持久化队列或 DLQ;
  • DLQ 支持审计和受控重放;
  • 设置了重试预算与并发上限;
  • 持续失败会触发熔断和降级;
  • 重试率、最终失败率和队列积压有监控告警。

TL;DR

工业网关的重试机制要从"固定间隔"进化为"错误分类 + 指数退避 + 抖动 + 预算 + 熔断"的组合。临时错误才重试;不可重试错误快速失败;结果不确定的写请求必须依赖幂等键或结果查询。关键任务要有总超时、重试上限、持久化队列和 DLQ。大规模网关场景下,抖动和全局重试预算比单纯增加重试次数更重要。

相关推荐
嘻哈baby1 小时前
Go 函数中的参数为什么不支持默认值?
java·开发语言·jvm
一晌小贪欢1 小时前
python-第29天:Python面向对象之多态与抽象类
开发语言·python·数据可视化·面向对象·python办公·python多态
Chester_19991 小时前
CSP202312C.树上搜索
开发语言·数据结构·c++·蓝桥杯·stl
Figo_Cheung2 小时前
Figo共振网络宇宙演化论(RNC) :从原初对称破缺到全息大和谐的演化路径研究
开发语言·php·量子计算
重生之我来学Python2 小时前
Git-SVN 混合开发,从入门到精通!
开发语言·git·svn·github
CoderYanger2 小时前
前端基础——JavaScript(基础语法)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展
sugar__salt2 小时前
从跨域到 WebSocket:一篇讲透浏览器的通信边界
网络·websocket·网络协议
京师20万禁军教头2 小时前
39面向对象(高级)-设计模式
java·开发语言·设计模式
2601_966799042 小时前
酷嗨米J300:硬件级多通道分发采集设备,为矩阵直播打造独立信号通道
服务器·网络·负载均衡