MQTT实战 - MQTTnet连接可靠性设计:自动重连、心跳、ClientId管理(五)

01、引子:两个通宵——"假在线"与"重连风暴"

1.1、第一个通宵:集体假在线。

一个工业项目,现场光纤半夜割接。监控大屏上 380 台设备同时变成灰色——但更诡异的是,几个小时后又齐刷刷"自己回来了"。值班同事第一反应是"网络恢复了",实际不是:设备其实早就重连上了,只是连上之后没有重新订阅 Topic,所以后端一条消息都收不到;而大屏的在线状态又依赖设备上报,于是全线飘灰,直到下一次重启进程才恢复。

根因:重连代码只写了 ConnectAsync(),没写重订阅。连是连上了,但订阅关系全丢了。

1.2、第二个通宵:重连风暴。

另一个项目,2000 台设备。某次 Broker 抖动了一下,所有设备的客户端同时检测到断线,然后用同一个固定间隔(5 秒)疯狂重试。结果:Broker 刚恢复,就被 2000 台设备同一秒钟的连接请求再一次打挂,如此往复——经典的惊群效应(thundering herd)。

这两件事指向同一个结论:MQTT 的连接可靠性,不是"能不能重连",而是"重连时重订阅了吗、重试会不会自己把服务打挂"。

而这两点,都要在设计阶段就想清楚。

02、先认清:连接会怎么"断"

在写任何代码之前,先搞清楚你的敌人是什么,连接断开不是只有一种样子。

2.1 四类断线类型

同样表现为「连接不可用」,但触发原因、协议特征与客户端可感知程度差异巨大 —— 尤其要警惕「半开连接」。

1

2.2 最阴险的一种:半开连接

半开连接(half-open connection)的本质是:TCP 连接的两端对状态认知不一致。客户端认为连接还在,Broker 已经释放了会话;或者反过来,Broker 认为客户端还在线,但中间链路已经不通了。

这种情况下:

  • IsConnected 返回 true——因为 TCP 层面没有收到断开信号

  • 发送消息不报错——数据写入了本地发送缓冲区,但永远到不了对端

  • DisconnectedAsync 不触发——因为没有断开事件可以捕获

设备"在线"但什么都不干,一假就是十几个小时。这就是引子里那个假在线的来源。

2.3 三句话结论

  • DisconnectedAsync 触发 ≠ 断线一定被感知(半开连接下它根本不触发)

  • IsConnected == true ≠ 连接一定活着(假在线状态下永远返回 true)

  • ConnectAsync 成功 ≠ 可以立刻订阅(重连场景下内部状态可能还没就绪)

基于这三个认知,下面逐根拆开讲。

03、支柱① ClientId:唯一 ≠ 稳定

这是最容易被忽略、却最容易埋雷的一根。很多人对 ClientId 的理解只有一句"要唯一",于是写成了这样:

// ❌ 反面示例1:带时间戳,每次重连都是"新设备"
var clientId = $"device-{deviceId}-{DateTime.Now.Ticks}";
// ❌ 反面示例2:每次启动随机生成
var clientId = $"device-{Guid.NewGuid()}";

3.1、坑一:每次重连都是"全新客户端",会话永远续不上。

MQTT 里,Broker 是靠 ClientId 来识别"这是不是同一个会话"的。你一带时间戳或随机数,每次重连在 Broker 眼里都是一台新设备:旧会话变成无人认领的僵尸会话,新会话又从零开始。网络一抖,几百个僵尸会话堆上来,连接数上限直接被撑爆。

3.2、坑二:多设备共用同一个 ClientId = 互相踢。

反过来,如果两台设备用了同一个 ClientId(比如出厂配置脚本 bug,都写了 device-001),Broker 的规则是:新连接会把旧连接踢掉。于是两台设备无限互踢,谁也连不稳。

真实案例:一个智慧园区项目,300 多台门禁设备全部显示离线。排查到早上 6 点,原因是两台设备烧录了同一个 ClientId,后连的踢先连的,先连的重连又踢后连的——死循环。Broker 日志里只显示"client disconnected by new connection",没有任何告警。

3.3、三条铁律

ClientId 不是随便起的字符串 —— 它决定会话续接、互踢行为与后期可维护性。

2

3.4、推荐命名范式

命名不是"能跑就行",而是连接可靠性、ACL 管控、故障排查的前置条件。一个好名字,让你在 Broker 管理后台一眼就能定位设备身份,而不是面对一堆 device-xxxx 发呆。

格式:{产品码}_{环境}_{位置}_{序列号}
示例:
  gw_prod_parkA_001        # 园区A的1号网关
  sn_SH202610190001        # 上海2026年10月19日第1台设备
  hvac_beta_floor3_001     # 测试环境3楼空调

约束建议:

  • 长度控制在 23 字符以内。MQTT 3.1.1 时代 Broker 对 ClientId 长度有 23 字符的限制,虽然 MQTT 5.0 放宽了,但跨版本、跨 Broker 的兼容性仍然建议保守。

  • 序列号在设备出厂或首次激活时写入本地配置,之后永不改变。

  • 环境字段(prod/staging/dev)让同一套 ACL 规则可以跨环境复用。

3.5、绝对禁止:

这些坑我在生产环境里全踩过,每一条背后都是一次深夜告警。记住:ClientId 一旦写错,问题不会立刻暴露——它会在你最放松的时候,以最诡异的方式炸开。

3

3.6、动态生成 + Broker 端管控

客户端自己生成 ClientId 只是第一道防线,Broker 端必须兜底。再好的命名规范,也挡不住出厂烧录脚本的 bug、测试人员手敲的随意字符串、或者某天某个新同事"临时改一下"。Broker 端的管控策略就是最后一道闸门——拦住一切不合规的连接。

// 设备端:用 MAC + 产品型号生成,烧录阶段固化
public static string GenerateClientId(string productCode, string macAddress)
{
    var macSuffix = macAddress.Replace(":", "").Replace("-", "").TakeLast(6);
    return $"{productCode}-{new string(macSuffix.ToArray())}";
}
// 示例输出:hvac-A1B2C3
# EMQX 服务端管控(防止意外重复)
# 1. 启用 ClientId 白名单
# 2. 设置最大连接数 per ClientId = 1
# 3. 设置 ClientId 正则校验:^[a-z0-9\-]{3,23}$

一句话总结:客户端生成保证"对",Broker 管控保证"不会错"——两层加在一起,ClientId 这条线才算真正焊死。

04、支柱② 心跳:KeepAlive 不是万能保险

4.1 工作原理

KeepAlive 是客户端在 CONNECT 报文里声明的一个秒数。之后只要在这段时间内没有发送任何其他报文,客户端就必须发一个 PINGREQ;Broker 回 PINGRESP。反过来,Broker 如果在 1.5 × KeepAlive 的时间内没收到客户端的任何报文,就判定它掉线。

业务报文会刷新心跳计时——高频上报场景下,业务数据本身就充当心跳,PINGREQ 几乎不会发出。

4.2 分层配置表(按网络 + 供电)

KeepAlive 没有"万能值"。设太短,弱网下 PINGRESP 来不及返回,被误判掉线,反而加重重连负担;设太长,真断了半天才发现,假在线时间被拉长。正确做法是按网络类型和供电方式分层配置——核心权衡就两个变量:延迟波动和功耗预算。

按网络类型与功耗约束取值:Broker 侧超时通常为 KeepAlive 的 1.5 倍,留出一次丢包余量。

4

4.3 三条铁律

心跳参数只有三个坑,但每一个都能让系统"看起来能用、实际上随时会炸"。这三条是我在无数次线上故障后总结的底线规则——不是"最佳实践",而是"不这么做一定会出事"。

  • KeepAlive ≠ 0: 0 代表关闭心跳。NAT 网关长时间无报文直接掐断 TCP,Broker 永远检测不到设备离线。

  • KeepAlive 别太小: 5 秒心跳在弱网下 PINGRESP 来不及返回,被误判为掉线,反而更耗电。

  • 业务高频上报时 KeepAlive 可以大胆设大: 反正业务报文会刷新心跳计时。

4.4 MQTTnet 中的配置

前面讲了 KeepAlive 的原理和分层策略,这里落到代码层面。MQTTnet 的 MqttClientOptionsBuilder 是连接参数的唯一入口——所有三根支柱的配置都集中在这里,一行写错,后面全盘皆输。

var options = new MqttClientOptionsBuilder()
    .WithTcpServer("broker.example.com", 1883)
    .WithClientId($"gw-prod-parkA-001")
    .WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
    .WithCleanSession(false)  // 保留会话,离线消息不丢
.Build();

4.5 ⚠️ 已知缺陷 + 应用层心跳兜底

MQTTnet 有几个已知问题会导致 KeepAlive 机制失效:

  • issue #2248: TrySendKeepAliveMessages 捕获 OperationCanceledException 后直接返回,DisconnectedAsync 永远不会触发,IsConnected 一直返回 true。官方 issue 里有个极端案例:上游 Broker 集群替换节点时没有发送 TCP 断开信号,客户端在"假在线"状态下持续了 17 小时。

  • issue #2180: 重连成功后 IsConnected 仍为 false,导致代码陷入循环重连。

  • issue #2244: 连接中断时 DisconnectedAsync 不触发。

结论:KeepAlive 不能单独信任。生产环境必须加应用层心跳兜底。

#region 应用层心跳
// 心跳循环:周期发送 MQTT PINGREQ;失败时主动断开连接,让重连流程接管。
private async Task HeartbeatLoopAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        // 1) 按配置间隔等待
        await Task.Delay(_heartbeatInterval, ct);
        // 2) 不在线则跳过(重连循环会负责恢复)
        if (!_client.IsConnected)
        {
            continue;
        }
        // 3) 发送应用层心跳(PINGREQ),带超时避免半开连接永久挂起
        bool pong;
        try
        {
            using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            pingCts.CancelAfter(TimeSpan.FromSeconds(PingTimeoutSec));
            pong = await _client.TryPingAsync(pingCts.Token);
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        {
            // 超时(非外部取消):视为心跳失败
            pong = false;
        }
        // 4) 心跳失败:连接可能已半开,强制断开以触发重连
        if (!pong)
        {
            _logger.LogWarning("心跳探测失败,强制重连");
            _ = ForceReconnectAsync();
        }
    }
}
// 主动断开当前连接,触发 DisconnectedAsync 事件进入重连流程。
private async Task ForceReconnectAsync()
{
    try
    {
        // 仅在仍在线时才需要断开
        if (_client.IsConnected)
        {
            await _client.DisconnectAsync();
        }
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "强制断开连接失败");
    }
}
#endregion

4.6 会话策略:CleanSession vs CleanStart + SessionExpiry

连接断了再连回来,之前没收到/没确认的消息还要不要?这个问题决定了你的会话策略。选错了,轻则丢指令,重则计费漏单。MQTT 3.1.1 只有 CleanSession 一个开关,非黑即白;MQTT 5.0 拆成了 CleanStart + SessionExpiryInterval 两个维度,灵活度上了一个台阶——但也更容易配错。

场景决定会话是否复用:遥测无脑清新会话,指令短期续接,计费永久保留 —— 但永久会话最吃 Broker 资源。

5

坑:MQTT 5.0 中 WithCleanSession() 无参 = CleanStart=false(复用旧会话);WithCleanSession(true) = CleanStart=true。务必看源码确认,别靠猜。

05、支柱③ 自动重连:指数退避 + 抖动

5.1、为什么固定间隔 = 自导自演 DDoS

如果所有设备用固定 5 秒重连,Broker 短暂抖动时,成千上万台设备会在同一时刻一起冲上来,把刚恢复的服务再一次打挂。这就是第二个通宵的由来。

5.2、退避策略对比

重连策略本质上是在回答一个问题:"断线了,我该等多久再试?"​ 这个答案决定了你的系统在网络抖动时是优雅恢复,还是雪上加霜。四种策略从"裸奔"到"生产级",差距不是一星半点。

四种策略的核心矛盾:既要尽快恢复,又不能把正在恢复的 Broker 再次压垮。

6

5.3、退避参数表

上一节讲了"为什么要用指数退避 + 抖动",这一节落到具体数字上。参数不是拍脑袋定的——起步太激进会打崩 Broker,封顶太短会浪费电量,封顶太长会让设备"回不来"。下面这组参数是我在 500~2000 台设备规模下反复调优后的推荐值,可以直接抄。

退避上限封顶 30s,叠加 10%~30% 随机抖动,避免大量设备同步重试。

7

注:抖动系数为 [1.1, 1.3] 区间内的均匀随机数;上限从第 6 次起固定为 30s,不再翻倍,防止重连间隔无限增长。

封顶是必须的:不设上限,设备断网一小时后要等几十分钟才回来,不可接受。

5.4、关键坑:重连成功 ≠ 可以订阅了

ConnectAsync 返回成功,不代表所有内部状态已经就绪。尤其在重连场景下,立刻调用 SubscribeAsync 可能报 "client not connected",或者订阅被静默忽略。

正确做法:客户端自己维护一份订阅登记表,重连后逐条恢复。不要依赖 Broker 帮你恢复一切。

// 连接成功回调:重置退避计数、恢复订阅、补发离线缓冲消息。
private async Task OnConnectedAsync(MqttClientConnectedEventArgs e)
{
    // 1) 重置连续失败计数,下次重连从 base 开始
    Interlocked.Exchange(ref _attempt, 0);
    _logger.LogInformation("MQTT 连接成功");
    // 2) 按订阅表逐个恢复订阅
    foreach (var (topic, qos) in _subscriptions)
    {
        await _client.SubscribeAsync(
            new MqttClientSubscribeOptionsBuilder()
                .WithTopicFilter(topic, qos)
                .Build());
    }
    // 3) 补发断线期间缓冲的消息
    while (_outbox.TryDequeue(out var msg))
    {
        try
        {
            await _client.PublishAsync(msg);
        }
        catch (Exception ex)
        {
            // 补发失败:重新入队并中断本次补发,等下次连接成功再试
            _logger.LogWarning(ex, "补发离线消息失败,重新入队");
            _outbox.Enqueue(msg);
            break;
        }
    }
}

5.5、另一个坑:重连循环叠加

DisconnectedAsync 事件可能触发多次。如果没有锁,会出现好几个重连循环同时在跑。

// 重连锁:保证同一时刻只有一个重连循环在跑,避免并发重连。
private readonly SemaphoreSlim _reconnectLock = new(1, 1);
// 断线回调:由曾经在线触发的断线才启动重连循环,避免初始连接失败被重复触发。
private Task OnDisconnectedAsync(MqttClientDisconnectedEventArgs e)
{
	_logger.LogWarning("MQTT 连接断开:{Reason}", e.Reason);
	// 仅当之前确实在线时才触发重连,避免初始连接失败被事件重复触发
	if (e.ClientWasConnected)
	{
		_ = ReconnectLoopAsync();
	}
	return Task.CompletedTask;
}
// 重连循环:通过信号量保证同一时刻只有一个循环;按指数退避 + 随机抖动等待后重试。
private async Task ReconnectLoopAsync()
{
    // 1) 非阻塞抢锁:已有人在重连则直接退出
    if (!await _reconnectLock.WaitAsync(0))
    {
        return;
    }
    try
    {
        // 2) 循环重试,直到取消或连接成功
        while (!_cts.IsCancellationRequested && !_client.IsConnected)
        {
            // 2.1 计算本次等待时长(指数退避 + 抖动)
            var delay = NextDelay();
            _logger.LogInformation(
                "第 {Attempt} 次重连尝试,等待 {Delay:0.00} 秒",
                _attempt,
                delay.TotalSeconds);
            // 2.2 退避等待,期间被取消则退出
            try
            {
                await Task.Delay(delay, _cts.Token);
            }
            catch (OperationCanceledException)
            {
                return;
            }
            // 2.3 带超时地尝试连接
            try
            {
                using var timeout = new CancellationTokenSource(
                    TimeSpan.FromSeconds(ReconnectTimeoutSec));
                await _client.ConnectAsync(_options, timeout.Token);
                // 连接成功,OnConnectedAsync 会重置 _attempt 并做重订阅/补发
                return;
            }
            catch (Exception ex)
            {
                // 2.4 失败:记录日志并自增失败次数,进入下一轮更长的等待
                _logger.LogWarning(ex, "重连失败");
                Interlocked.Increment(ref _attempt);
            }
        }
    }
    finally
    {
        // 3) 释放重连锁
        _reconnectLock.Release();
    }
}

5.6、ManagedMqttClient vs 裸客户端

MQTTnet 提供了两种客户端:MqttClient(裸)和 ManagedMqttClient(托管)。选哪个不是口味问题,而是你的设备规模、可靠性要求和定制深度决定的。很多项目一开始用托管客户端"图省事",到后期发现改不动——因为关键逻辑被封装在内部,你碰不到。

托管版帮你补上重连、离线队列、订阅恢复;但「抖动」与「应用层心跳兜底」两者都要自己写。

8

建议:中小规模(< 500 设备)直接用 ManagedMqttClient,省心;大规模或需要应用层心跳兜底时,自己封装裸客户端。

⚠️ 版本现状:MQTTnet 5.x 已经不再内置 ManagedMqttClient

从 MQTTnet 5.0​ 开始,官方在升级说明里明确写了 Removal of Managed Client (BREAKING CHANGE)。MQTTnet.Extensions.ManagedClient 的托管客户端能力在 5.x 主线中已不可用;最后一个还带 ManagedClient 的版本是 4.3.7.1207。

官方原话是:

The managed client in version 4 lacks several features which require that the client will be built from scratch. It is recommended to use the regular client and doing the reconnect stuff etc. via your own code.

也就是说:

  • 新项目用 MQTTnet 5.x: 没有 CreateManagedMqttClient(),要自己用 MqttClient 包一层重连、离线队列、重订阅——本文的 ReliableMqttClient 就是这个思路。

  • 老项目依赖 ManagedClient: 可以继续锁 MQTTnet 4.3.7.1207,但拿不到 5.x 的性能优化、Memory/Span 改进、MQTT 5.0 新特性。

  • 不要混用: MQTTnet 5.x + MQTTnet.Extensions.ManagedClient 4.x 基本编不过,API 和命名空间都对不上。

所以现在讲“ManagedMqttClient vs 裸客户端”,更准确的说法是:

MQTTnet 5.x 时代,托管客户端不是“选不选”的问题,而是“你已经只能基于裸客户端自己造一个托管层”。

中小项目如果想少写代码,可以用社区封装(如 MQTTnet.Rx 的 IResilientMqttClient),但生产级 IoT 后端更推荐自己掌控重连/抖动/离线缓冲。

06、三件事的协作关系

连接、断线回调、心跳三者并非独立逻辑,而是一条互相兜底的闭环链路。

9

DisconnectedAsync 是第一信号,第一时间把断线事实交给重连循环;心跳是第二保险,用来捕捉「连接看起来还在、实际已经死了」的静默断线。

而 ClientId 的稳定性决定了重连时 Broker 是否保留会话、订阅是否能正确恢复 —— 它是这条闭环里唯一不能变的锚点。

07、完整生产级封装:ReliableMqttClient

以下代码整合了:指数退避 + 随机抖动 + 重连锁 + 重订阅 + 应用层心跳兜底 + 离线缓冲。可直接复制进项目。

using Microsoft.Extensions.Logging;
using MQTTnet;
using MQTTnet.Protocol;
using System.Collections.Concurrent;
namespace MqttClient.Demo;
// 可靠 MQTT 客户端。
// 提供六项可靠性能力:指数退避、随机抖动、重连锁、自动重订阅、应用层心跳兜底、离线消息缓冲。
public sealed class ReliableMqttClient : IAsyncDisposable
{
    #region 常量
    // 默认初始退避秒数。
    private const int DefaultBaseDelaySec = 1;
    // 默认退避上限秒数。
    private const int DefaultMaxDelaySec = 30;
    // 默认离线缓冲最大消息数。
    private const int DefaultMaxOutboxSize = 10000;
    // 默认应用层心跳间隔秒数。
    private const int DefaultHeartbeatIntervalSec = 30;
    // 单次重连尝试的超时秒数。
    private const int ReconnectTimeoutSec = 15;
    // 单次心跳等待 PINGRESP 的超时秒数,超时视为心跳失败。
    private const int PingTimeoutSec = 5;
    #endregion
    #region 字段
    // 底层 MQTTnet 客户端实例,负责实际的协议收发。
    private readonly IMqttClient _client;
    // MQTT 连接选项(服务端地址、客户端 ID、保活、CleanSession 等)。
    private readonly MqttClientOptions _options;
    // 日志记录器,用于输出连接、重连、心跳等运行信息。
    private readonly ILogger _logger;
    // 订阅登记表:重连后按它自动重订阅。
    private readonly ConcurrentDictionary<string, MqttQualityOfServiceLevel> _subscriptions = new();
    // 断线期间的待发消息缓冲队列,重连后按 FIFO 顺序补发。
    private readonly ConcurrentQueue<MqttApplicationMessage> _outbox = new();
    // 重连锁:保证同一时刻只有一个重连循环在跑,避免并发重连。
    private readonly SemaphoreSlim _reconnectLock = new(1, 1);
    // 全局取消令牌:Dispose 时停掉心跳与重连循环。
    private readonly CancellationTokenSource _cts = new();
    // 随机数生成器,用于指数退避的抖动计算。
    private readonly Random _rand = new();
    // 连续失败次数:连接成功时清零,失败时自增,用于计算指数退避。
    private int _attempt;
    // 指数退避初始秒数。
    private readonly int _baseDelaySec;
    // 指数退避上限秒数,超过后不再增长。
    private readonly int _maxDelaySec;
    // 离线缓冲队列最大长度,超出后新消息丢弃。
    private readonly int _maxOutboxSize;
    // 应用层心跳间隔。
    private readonly TimeSpan _heartbeatInterval;
    #endregion
    #region 事件
    // 收到 broker 推送的应用消息时触发。
    public event Func<MqttApplicationMessage, Task>? ApplicationMessageReceived;
    #endregion
    #region 构造函数
    // 创建一个可靠 MQTT 客户端实例。
    public ReliableMqttClient(
        string brokerHost,
        int brokerPort,
        string clientId,
        ILogger logger,
        int baseDelaySec = DefaultBaseDelaySec,
        int maxDelaySec = DefaultMaxDelaySec,
        int maxOutboxSize = DefaultMaxOutboxSize,
        int heartbeatIntervalSec = DefaultHeartbeatIntervalSec)
    {
        // 保存配置
        _logger = logger;
        _baseDelaySec = baseDelaySec;
        _maxDelaySec = maxDelaySec;
        _maxOutboxSize = maxOutboxSize;
        _heartbeatInterval = TimeSpan.FromSeconds(heartbeatIntervalSec);
        // 创建底层客户端
        _client = new MqttClientFactory().CreateMqttClient();
        // 构建连接选项:固定 clientId + 30s 保活 + CleanSession=false(会话持久化,便于重订阅)
        _options = new MqttClientOptionsBuilder()
            .WithTcpServer(brokerHost, brokerPort)
            .WithClientId(clientId)
            .WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
            .WithCleanSession(false)
            .Build();
        // 订阅连接/断线事件
        _client.ConnectedAsync += OnConnectedAsync;
        _client.DisconnectedAsync += OnDisconnectedAsync;
        // 订阅消息接收事件(只注册一次,避免重连后重复挂接导致一条消息被回调多次)
        _client.ApplicationMessageReceivedAsync += args =>
        {
            var msg = args.ApplicationMessage;
            return ApplicationMessageReceived?.Invoke(msg) ?? Task.CompletedTask;
        };
    }
    #endregion
    #region 公共方法
    // 启动客户端:建立连接并启动应用层心跳循环。初始连接失败会自动进入重连流程。
    public async Task StartAsync()
    {
        // 1) 后台启动心跳循环
        _ = Task.Run(() => HeartbeatLoopAsync(_cts.Token));
        // 2) 尝试首次连接
        try
        {
            await _client.ConnectAsync(_options, _cts.Token);
        }
        catch (Exception ex)
        {
            // 3) 初始连接失败:记录日志并触发重连循环
            _logger.LogError(ex, "初始连接失败");
            _ = ReconnectLoopAsync();
        }
    }
    // 订阅主题。登记到订阅表中,连接建立后立即订阅;断线期间登记的主题会在重连后自动恢复。
    public async Task SubscribeAsync(string topic, MqttQualityOfServiceLevel qos)
    {
        // 1) 登记到订阅表,重连后会按此表恢复
        _subscriptions[topic] = qos;
        // 2) 已连接则立即下发 SUBSCRIBE
        if (_client.IsConnected)
        {
            await _client.SubscribeAsync(
                new MqttClientSubscribeOptionsBuilder()
                    .WithTopicFilter(topic, qos)
                    .Build());
        }
    }
    // 发布消息。在线时直接发送;断线或发送失败时进入离线缓冲队列,待重连后自动补发。
    public async Task PublishAsync(MqttApplicationMessage message)
    {
        // 1) 在线则直接发送
        if (_client.IsConnected)
        {
            try
            {
                await _client.PublishAsync(message);
                return;
            }
            catch (Exception ex)
            {
                // 2) 在线但发送失败:降级到离线缓冲
                _logger.LogWarning(ex, "发布失败,转入离线缓冲");
            }
        }
        // 3) 离线缓冲:队列未满则入队,否则丢弃
        if (_outbox.Count < _maxOutboxSize)
        {
            _outbox.Enqueue(message);
        }
        else
        {
            _logger.LogWarning("离线缓冲已满,消息被丢弃");
        }
    }
    // 释放资源:停止心跳与重连循环,断开连接并释放底层客户端。
    public async ValueTask DisposeAsync()
    {
        // 1) 取消所有后台循环
        _cts.Cancel();
        // 2) 若仍在线则主动断开
        if (_client.IsConnected)
        {
            await _client.DisconnectAsync();
        }
        // 3) 释放底层资源
        _client.Dispose();
        _cts.Dispose();
        _reconnectLock.Dispose();
    }
    #endregion
    #region 连接事件处理
    // 连接成功回调:重置退避计数、恢复订阅、补发离线缓冲消息。
    private async Task OnConnectedAsync(MqttClientConnectedEventArgs e)
    {
        // 1) 重置连续失败计数,下次重连从 base 开始
        Interlocked.Exchange(ref _attempt, 0);
        _logger.LogInformation("MQTT 连接成功");
        // 2) 按订阅表逐个恢复订阅
        foreach (var (topic, qos) in _subscriptions)
        {
            await _client.SubscribeAsync(
                new MqttClientSubscribeOptionsBuilder()
                    .WithTopicFilter(topic, qos)
                    .Build());
        }
        // 3) 补发断线期间缓冲的消息
        while (_outbox.TryDequeue(out var msg))
        {
            try
            {
                await _client.PublishAsync(msg);
            }
            catch (Exception ex)
            {
                // 补发失败:重新入队并中断本次补发,等下次连接成功再试
                _logger.LogWarning(ex, "补发离线消息失败,重新入队");
                _outbox.Enqueue(msg);
                break;
            }
        }
    }
    // 断线回调:由曾经在线触发的断线才启动重连循环,避免初始连接失败被重复触发。
    private Task OnDisconnectedAsync(MqttClientDisconnectedEventArgs e)
    {
        _logger.LogWarning("MQTT 连接断开:{Reason}", e.Reason);
        // 仅当之前确实在线时才触发重连,避免初始连接失败被事件重复触发
        if (e.ClientWasConnected)
        {
            _ = ReconnectLoopAsync();
        }
        return Task.CompletedTask;
    }
    #endregion
    #region 重连与退避
    // 重连循环:通过信号量保证同一时刻只有一个循环;按指数退避 + 随机抖动等待后重试。
    private async Task ReconnectLoopAsync()
    {
        // 1) 非阻塞抢锁:已有人在重连则直接退出
        if (!await _reconnectLock.WaitAsync(0))
        {
            return;
        }
        try
        {
            // 2) 循环重试,直到取消或连接成功
            while (!_cts.IsCancellationRequested && !_client.IsConnected)
            {
                // 2.1 计算本次等待时长(指数退避 + 抖动)
                var delay = NextDelay();
                _logger.LogInformation(
                    "第 {Attempt} 次重连尝试,等待 {Delay:0.00} 秒",
                    _attempt,
                    delay.TotalSeconds);
                // 2.2 退避等待,期间被取消则退出
                try
                {
                    await Task.Delay(delay, _cts.Token);
                }
                catch (OperationCanceledException)
                {
                    return;
                }
                // 2.3 带超时地尝试连接
                try
                {
                    using var timeout = new CancellationTokenSource(
                        TimeSpan.FromSeconds(ReconnectTimeoutSec));
                    await _client.ConnectAsync(_options, timeout.Token);
                    // 连接成功,OnConnectedAsync 会重置 _attempt 并做重订阅/补发
                    return;
                }
                catch (Exception ex)
                {
                    // 2.4 失败:记录日志并自增失败次数,进入下一轮更长的等待
                    _logger.LogWarning(ex, "重连失败");
                    Interlocked.Increment(ref _attempt);
                }
            }
        }
        finally
        {
            // 3) 释放重连锁
            _reconnectLock.Release();
        }
    }
    // 计算下一次重连等待时间:base * 2^(n-1),封顶 max,并乘以 [0.5, 1.0] 的随机抖动。
    private TimeSpan NextDelay()
    {
        // 指数部分:base * 2^n
        var n = _attempt + 1;
        var cap = Math.Min(_maxDelaySec, _baseDelaySec * Math.Pow(2, n - 1));
        // 抖动:0.5 ~ 1.0 之间的随机系数,避免大量客户端同时重连造成惊群
        var jitter = 0.5 + _rand.NextDouble() * 0.5;
        return TimeSpan.FromSeconds(cap * jitter);
    }
    #endregion
    #region 应用层心跳
    // 心跳循环:周期发送 MQTT PINGREQ;失败时主动断开连接,让重连流程接管。
    private async Task HeartbeatLoopAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            // 1) 按配置间隔等待
            await Task.Delay(_heartbeatInterval, ct);
            // 2) 不在线则跳过(重连循环会负责恢复)
            if (!_client.IsConnected)
            {
                continue;
            }
            // 3) 发送应用层心跳(PINGREQ),带超时避免半开连接永久挂起
            bool pong;
            try
            {
                using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                pingCts.CancelAfter(TimeSpan.FromSeconds(PingTimeoutSec));
                pong = await _client.TryPingAsync(pingCts.Token);
            }
            catch (OperationCanceledException) when (!ct.IsCancellationRequested)
            {
                // 超时(非外部取消):视为心跳失败
                pong = false;
            }
            // 4) 心跳失败:连接可能已半开,强制断开以触发重连
            if (!pong)
            {
                _logger.LogWarning("心跳探测失败,强制重连");
                _ = ForceReconnectAsync();
            }
        }
    }
    // 主动断开当前连接,触发 DisconnectedAsync 事件进入重连流程。
    private async Task ForceReconnectAsync()
    {
        try
        {
            // 仅在仍在线时才需要断开
            if (_client.IsConnected)
            {
                await _client.DisconnectAsync();
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "强制断开连接失败");
        }
    }
    #endregion
    #region 测试缝(internal,仅用于单元/集成测试)
    // 当前连续重连失败次数(测试用)。
    internal int AttemptCount => Volatile.Read(ref _attempt);
    // 当前离线缓冲队列长度(测试用)。
    internal int OutboxCount => _outbox.Count;
    // 当前是否处于已连接状态(测试用)。
    internal bool IsConnected => _client.IsConnected;
    // 当前订阅表的只读快照(测试用)。
    internal IReadOnlyDictionary<string, MqttQualityOfServiceLevel> Subscriptions =>
        _subscriptions.ToDictionary(kv => kv.Key, kv => kv.Value);
    // 计算下一次重连延迟,不实际启动重连(测试用)。
    internal TimeSpan NextDelayForTest()
    {
        return NextDelay();
    }
    // 手动触发一次重连循环(测试用)。
    internal Task TriggerReconnectForTest()
    {
        return ReconnectLoopAsync();
    }
    #endregion
}

08、踩坑实录:5 个连接故障

前面六节讲的都是"应该怎么做",这一节讲"不这么做会怎样"。这 5 个故障全是我或团队在生产环境里真实踩过的坑——每一个都对应前面某一节的某一个知识点。你可以把它们当成反面教材对照表:看到哪个坑,就回去翻哪一节的解决方案。

五个真实踩坑案例 —— 每一个背后都是一条容易被忽略的配置纪律。

10

09、上线前自检清单(12 条)

前面七节讲了一堆原理、策略和代码,但上线前你真正需要的是一份能打勾的清单。这 12 条是我从本文所有知识点里提炼的"最小完备集"——全过,连接可靠性基本稳了;有一条没过,就是一颗定时炸弹。建议打印出来,逐条对着代码过一遍,别凭记忆。

逐项核对,全部通过再上线 —— 每一条都对应一个真实踩过的坑。

11

10、最后

做物联网 MQTT 的朋友可以一起交流。后面我会在这个号持续更新这个系列,也会穿插一些软考架构师备考、技术管理转型、嵌入式学习的记录。

我是【IT规划师】,8 年 .NET 物联网后端,正在从纯后端往软硬融合架构师的方向走。这条路不容易,但值得。

注: 测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。https://gitee.com/hugogoos/Planner

posted @ 2026-10-21 07:00  IT规划师  阅读(20)  评论(0)    收藏  举报