工业边缘网络天然不稳定:4G 信号抖动、专线短暂中断、云端服务发布、现场设备忙、DNS 解析失败都可能造成一次调用失败。重试可以提升可用性,也可能把小故障放大成重试风暴。
因此,重试机制的核心不是"失败后再调一次",而是回答四个问题:
- 这个错误是临时错误吗?
- 重复执行会不会造成副作用?
- 什么时候必须停止?
- 停止后数据去哪里、谁来处理?
本文按错误分类、退避策略、幂等、预算、熔断、死信队列和监控来拆解工程实践。
一、重试适用的前提
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不一定不幂等,关键看服务端语义和幂等键;429的Retry-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。大规模网关场景下,抖动和全局重试预算比单纯增加重试次数更重要。