消息队列在物联网数据链路中的应用:从内存队列到 RabbitMQ 的完整改造

消息队列在物联网数据链路中的应用:从内存队列到 RabbitMQ 的完整改造

本文总结了一次真实改造:把"采集 -> 规则处理 -> 内存队列 -> 存储"的数据链路升级为
"采集 -> RabbitMQ -> 规则处理 -> RabbitMQ -> 批量落库"的持久化链路。


目录

  1. 为什么要引入消息队列
  2. RabbitMQ 核心概念
  3. 通用架构模式:采集-处理-队列-存储
  4. 可靠性设计四件套
  5. 批量消费模式(攒批 + 定时兜底)
  6. 生产端代码示例(C#)
  7. 消费端代码示例(C#)
  8. Node-RED 集成:自写 AMQP 节点
  9. 选型对比:内存队列 vs MQTT vs RabbitMQ
  10. 常见坑与调试技巧
  11. 落地检查清单

1. 为什么要引入消息队列

1.1 内存队列的痛点

几乎所有实时数据系统最初都会用"进程内队列"承接高频数据:

// 典型的内存队列:ConcurrentQueue 或 Channel
var queue = new ConcurrentQueue<TelemetryData>();

// 生产者(采集线程):数据进队
queue.Enqueue(new TelemetryData { DeviceId = 1, Value = 25.5 });

// 消费者:攒够一批再入库
if (queue.Count >= 50)
{
    while (queue.TryDequeue(out var item)) batch.Add(item);
    SaveToDb(batch);   // 同步刷库, 阻塞采集线程
}

内存队列存在的问题:

问题 说明 后果
进程生命周期 = 数据生命周期 数据只存在于进程内存 进程崩溃/重启,队列中全部数据丢失
无跨进程能力 另一个进程(规则引擎、采集器)无法直接消费 只能靠网络协议转发
无重试机制 落库失败只能丢弃 静默丢数据
无积压控制 无限增长或直接阻塞 OOM 或采集链路阻塞
单机扩展上限 受单进程内存/CPU 限制 无法水平扩展消费者

1.2 消息队列解决什么

消息队列(MQ)把"待处理的暂存数据"从进程内存搬到了独立的中间件进程/磁盘上:

  • 持久化:消息写入磁盘,进程崩溃、重启、断电都不丢
  • 解耦:生产者不关心消费者在哪、有几个、是否在线
  • 削峰:瞬时高流量先堆积在队列,消费者按自己的节奏消化
  • 可扩展:多个消费者并行消费,水平扩展
  • 可靠投递:确认机制(ack)保证消息要么被处理,要么可重投
  • 故障恢复:消费者挂掉期间消息积压,重启后续传

一句话:内存队列解决"缓冲",消息队列解决"缓冲 + 不丢 + 解耦 + 扩展"。


2. RabbitMQ 核心概念

RabbitMQ 基于 AMQP 0-9-1 协议,核心模型:

生产者 --> [Exchange 交换机] --binding--> [Queue 队列] --> 消费者

2.1 关键名词

概念 说明 类比
Producer 生产者 发送消息的程序 寄件人
Consumer 消费者 接收处理消息的程序 收件人
Message 消息 一条数据(header + body) 信件
Exchange 交换机 消息的"路由中枢",不存消息 邮局分拣台
Queue 队列 消息的存储容器,真正存消息的地方 收件人信箱
Binding 绑定 交换机与队列的路由规则 分拣规则
Routing Key 路由键 消息携带的路由标识 收件地址
Virtual Host 虚拟主机 逻辑隔离空间 邮局分区

2.2 交换机类型

类型 路由规则 适用场景
direct routing key 完全相等才投递 精确路由,按设备/主题
fanout 忽略 routing key,广播到所有绑定队列 广播通知
topic routing key 通配符匹配(* 匹配一段,# 匹配多段) 主题订阅,如 device.123.temp
headers 按消息头匹配(几乎不用) 复杂路由

2.3 最简使用模式:默认交换机直达队列

绝大多数场景(包括本改造)根本不需要交换机:使用默认交换机(exchange=""),
routing key 设为队列名即可直达队列。这是最简单可靠的模式:

// exchange 传空字符串 = 默认交换机, routingKey 传队列名 = 投递到该队列
await channel.BasicPublishAsync("", "telemetry.store", false, props, body, default);

设计建议:单队列直连(默认交换机)作为起步;需要多消费者分流/多主题时才引入
topic/direct 交换机。不要为了"规范"而过早引入交换机的复杂度。

2.4 持久化三件套(消息不丢的底线)

RabbitMQ 的持久化由三层构成,缺一层都可能在重启后丢消息:

// 1. 队列持久化: 声明队列时 durable=true (队列本身在 broker 重启后存活)
await channel.QueueDeclareAsync("telemetry.store",
    durable: true,        // <-- 队列持久化
    exclusive: false,
    autoDelete: false,
    arguments: null,
    passive: false, noWait: false, cancellationToken: default);

// 2. 消息持久化: 发布时标记消息 durable
var props = new BasicProperties { Persistent = true };  // <-- 消息持久化
await channel.BasicPublishAsync("", "telemetry.store", false, props, body, default);

// 3. 发布确认: 确认 broker 真正落盘后才认为发布成功
//    (RabbitMQ.Client 7.x 中 channel 默认开启 publisher confirm)
await channel.WaitForConfirmsOrDieAsync(...); // 视客户端版本而定, 见下文
作用 缺失后果
队列 durable=true 队列定义重启后还在 队列消失,消息无处可存
消息 Persistent=true 消息写入磁盘 broker 重启后消息丢失
Publisher Confirm 确认已持久化才继续 网络抖动时消息悄悄丢失

3. 通用架构模式:采集-处理-队列-存储

物联网/遥测数据链路最常见的形态:

[采集端] → [规则处理] → [队列] → [存储]
   │            │           │        │
   │        (可插拔、可热更) │    (批量写时序库)
   │            │           │
[设备/传感器] [计算/映射]  [持久化缓冲]

3.1 两种改造方案

方案 A:存储前置 MQ(最小改动,推荐起步)

采集 → 规则处理 → 队列(内存) → 批量入库
                ↓ 改造
采集 → 规则处理 → RabbitMQ → 批量入库
  • 只把"入库前的暂存"替换为 MQ
  • 改动最小,立即获得"进程重启不丢"的核心收益

方案 B:全链路 MQ(彻底解耦,演进目标)

采集 → RabbitMQ(raw) → 规则处理(独立消费者) → RabbitMQ(processed) → 批量入库
  • 每个环节之间都是 MQ,任意环节独立部署、独立扩展、独立重启
  • 采集端不依赖规则引擎在线;规则引擎不依赖存储在线
  • 代价:多一跳延迟、中间件成为新故障点、维护成本上升

3.2 队列设计要点

设计点 建议
队列命名 业务.阶段.用途,如 telemetry.rawtelemetry.processedtelemetry.store.dlq
消息粒度 批量消息(一条消息携带 N 条数据),消息数减少 ~100 倍,吞吐大幅提升
顺序性 时序数据带时间戳,容忍乱序;若需严格顺序,用同一 routing key 绑同一队列
消息格式 JSON(可读、跨语言),Schema 统一(字段名/类型固定)
幂等键 消息中携带自然唯一键(如 设备ID+变量名+时间戳),供消费端去重

4. 可靠性设计四件套

生产级 MQ 链路必须同时具备以下四点,否则"不丢"是空话:

4.1 发布确认(Publisher Confirm)

保证"消息确实进了 broker"。

// RabbitMQ.Client 7.x: channel 创建时开启 confirm
var options = new CreateChannelOptions
{
    PublisherConfirmationsEnabled = true,
    PublisherConfirmationTrackingEnabled = true
};
var channel = await connection.CreateChannelAsync(options);

await channel.BasicPublishAsync("", queueName, false, props, body, default);

// 等待确认: 未确认会抛异常, 走重连/重发逻辑
// 注意: 7.x 中确认等待 API 因版本而异
//   6.x (IModel): channel.WaitForConfirmsOrDieAsync()
//   7.x (IChannel): 可依赖 GetPublisherConfirmations() 或干脆
//                   "发布失败即重连 + 幂等消费" 兜底

工程折中:如果单条数据量小、发布频率低,可以用"发布 + 失败重连"代替严格的
confirm 等待;代价是极端情况下可能重复或丢失,需靠消费端幂等兜底。
判断标准:数据价值 vs 实现复杂度。

4.2 消费确认(Manual Ack)

必须手动 ack——默认自动 ack 会在消息发给消费者瞬间就删除消息,消费者处理一半
崩溃则消息丢失。

// 关键: autoAck 必须为 false
await channel.BasicConsumeAsync(queueName,
    autoAck: false,                       // <-- 手动确认
    consumerTag: "", noLocal: false, exclusive: false,
    arguments: null, consumer: consumer, cancellationToken: default);

三种确认语义:

方法 语义 适用
BasicAckAsync(tag, false) 成功,删除消息 落库成功
BasicNackAsync(tag, false, requeue: true) 失败,重新入队重试 瞬时故障
BasicRejectAsync(tag, requeue: false) 失败,丢弃(或进死信) 永久失败

4.3 预取(Prefetch)

限制消费者未确认消息数,避免消费者把队列一次性拉爆,也保证负载均衡:

// prefetchSize=0(不限大小), prefetchCount=5000, global=false
await channel.BasicQosAsync(0, 5000, false, default);

4.4 幂等消费(Idempotency)

重投是 MQ 的常态(ack 超时、连接断开、消费者重启都会触发重投),消费端必须幂等:

// 示例: 利用数据库唯一约束 + 忽略冲突实现幂等 (PostgreSQL)
INSERT INTO telemetry (device_id, mac_no, key_name, collection_time, value)
VALUES (@device, @mac, @key, @ts, @val)
ON CONFLICT (device_id, mac_no, key_name, collection_time) DO NOTHING;

对应 C#(SqlSugar):

// 依赖表上的唯一约束; 重复插入会抛异常或空操作
// 更稳妥: 先查后插 / 唯一键冲突时忽略
await db.Insertable(rows)
    .PageSize(500)              // 分批插入, 避免单条 SQL 过长
    .ExecuteCommandAsync();

幂等键选择:业务天然唯一键(不是 MQ 的 deliveryTag,因为重投时 tag 会变)。


5. 批量消费模式(攒批 + 定时兜底)

高频数据逐条落库是大忌(数据库连接、SQL 解析、网络往返)。标准做法:

消费者收到消息 → 暂存内存 List
   ├─ 攒满 N 条(如 5000) → 立即批量落库 → 批量 ack
   └─ 未满 N 条但超过 T 毫秒(如 1000ms) → 兜底批量落库 → 批量 ack

要点:

说明
双触发 "满量立即刷"保证吞吐,"定时兜底"保证低流量时延迟可控(≤1s)
批量 ack 一批成功后循环 BasicAckAsync(tag, false);若用 multiple=true 需保证处理顺序,否则可能误确认未处理消息
失败处理 整批失败 → 整批 reject(记录日志/进死信),避免单条重试风暴
落库分页 5000 条数据可能拆成多条 SQL(PageSize=500),防止 SQL 超长
生命周期 进程退出前必须 flush 残留数据 + 优雅关闭连接

完整批量消费循环(C# 骨架)

private readonly Channel<(ulong Tag, T Data)> _pending =
    Channel.CreateUnbounded<(ulong, T)>();   // 生产者=消费者回调, 消费者=攒批线程

private async Task ConsumeLoopAsync()
{
    while (!_cts.IsCancellationRequested)
    {
        var batch = new List<(ulong, T)>();
        while (batch.Count < _batchSize && _pending.Reader.TryRead(out var item))
            batch.Add(item);

        if (batch.Count > 0)
        {
            await ProcessBatchAsync(batch);        // 批量落库 + 批量 ack/reject
        }
        else
        {
            // 重要: 用不带取消的 Delay 或用 try/catch 吞掉 OCE,
            // 否则 WaitForReadAsync/超时取消会在日志里刷满 OperationCanceledException
            await Task.Delay(_flushIntervalMs, _cts.Token).ConfigureAwait(false);
            // 或者: try { await Task.Delay(...); } catch (OperationCanceledException) { }
        }
    }
    await FlushRemainderAsync();   // 退出兜底
}

血的教训:WaitToReadAsync(超时) + CancellationTokenSource.CancelAfter()
每轮都会抛出 OperationCanceledException(首次未处理异常会在 IDE 输出窗口刷屏,
误以为系统故障)。用定时轮询 + 无取消的 Task.Delay 更干净:
既无异常噪音,行为也完全一致。


6. 生产端代码示例(C#)

使用 RabbitMQ.Client 7.x(NuGet: RabbitMQ.Client >= 7.0)。

6.1 配置类

public class RabbitMqOptions
{
    public bool Enable { get; set; } = true;
    public string HostName { get; set; } = "127.0.0.1";
    public int Port { get; set; } = 5672;
    public string UserName { get; set; } = "guest";
    public string Password { get; set; } = "guest";
    public string VirtualHost { get; set; } = "/";
    public string QueueName { get; set; } = "telemetry.store";
    public int BatchSize { get; set; } = 5000;
    public int FlushIntervalMs { get; set; } = 1000;
    public int PageSize { get; set; } = 500;
    public ushort PrefetchCount { get; set; } = 5000;
}

6.2 发布器(单例,懒连接 + 自动重连)

using RabbitMQ.Client;
using System.Text;
using System.Text.Json;

public class RabbitMqPublisher : IDisposable
{
    private readonly RabbitMqOptions _options;
    private readonly SemaphoreSlim _gate = new(1, 1);   // 串行化发布
    private IConnection? _connection;
    private IChannel? _channel;

    public RabbitMqPublisher(RabbitMqOptions options)
    {
        _options = options;
        // 启动时可预连接(失败也不阻塞, 首次发布时再重试)
        try { EnsureChannelAsync().GetAwaiter().GetResult(); }
        catch { /* 首次发布时自动重连 */ }
    }

    /// <summary>发布一条消息(失败记录日志丢弃, 连接自动重连)</summary>
    public async Task PublishAsync<T>(T message)
    {
        if (!_options.Enable) return;

        await _gate.WaitAsync();
        try
        {
            var channel = await EnsureChannelAsync();
            var body = JsonSerializer.SerializeToUtf8Bytes(message);
            var props = new BasicProperties { Persistent = true };
            await channel.BasicPublishAsync(
                exchange: "",            // 默认交换机
                routingKey: _options.QueueName,   // = 队列名
                mandatory: false,
                basicProperties: props,
                body: body,
                cancellationToken: default);
        }
        catch (Exception ex)
        {
            ResetConnection();   // 断开连接, 下次自动重建
            Console.Error.WriteLine($"发布失败: {ex.Message}");
        }
        finally { _gate.Release(); }
    }

    private async Task<IChannel> EnsureChannelAsync()
    {
        if (_channel is { IsOpen: true } && _connection?.IsOpen == true)
            return _channel;

        ResetConnection();

        var factory = new ConnectionFactory
        {
            HostName = _options.HostName,
            Port = _options.Port,
            UserName = _options.UserName,
            Password = _options.Password,
            VirtualHost = string.IsNullOrWhiteSpace(_options.VirtualHost) ? "/" : _options.VirtualHost,
            AutomaticRecoveryEnabled = true     // 开启自动恢复
        };

        _connection = await factory.CreateConnectionAsync(default);
        _channel = await _connection.CreateChannelAsync(default);

        // 声明持久队列(幂等: 已存在则无操作)
        await _channel.QueueDeclareAsync(_options.QueueName,
            durable: true, exclusive: false, autoDelete: false,
            arguments: null, passive: false, noWait: false, cancellationToken: default);
        return _channel;
    }

    private void ResetConnection()
    {
        try { _channel?.Dispose(); } catch { }
        try { _connection?.Dispose(); } catch { }
        _channel = null;
        _connection = null;
    }

    public void Dispose()
    {
        _gate.Dispose();
        ResetConnection();
    }
}

7.x 与 6.x 差异提醒(升级代码时必看):

  • 6.x 用 IModel(CreateModel()),7.x 用 IChannel(CreateChannelAsync())
  • 6.x 有 WaitForConfirmsOrDieAsync(),7.x 的确认机制改为
    CreateChannelOptions.PublisherConfirmationsEnabled
  • 7.x 全异步 API,几乎全部方法都有 Async 后缀

7. 消费端代码示例(C#)

批量消费者:手动 ack + 攒批 + 批量落库 + 幂等

using RabbitMQ.Client;
using RabbitMQ.Client.Events;

public class RabbitMqBatchConsumer : IDisposable
{
    private readonly RabbitMqOptions _options;
    private readonly CancellationTokenSource _cts = new();
    private readonly List<(ulong Tag, byte[] Body)> _pending = new();
    private readonly object _lock = new();
    private IConnection? _connection;
    private IChannel? _channel;
    private Task? _consumeTask;

    public void Start()
    {
        _consumeTask = Task.Run(ConsumeLoopAsync);
    }

    // ---------- 攒批循环 ----------
    private async Task ConsumeLoopAsync()
    {
        while (!_cts.IsCancellationRequested)
        {
            try
            {
                await EnsureChannelAsync();                       // 连接 + 声明队列 + 启动消费者回调
                await Task.Delay(_options.FlushIntervalMs, _cts.Token);   // 定时兜底
                await FlushBatchAsync();
            }
            catch (OperationCanceledException) { break; }
            catch (Exception ex)
            {
                ResetConnection();
                Console.Error.WriteLine($"消费异常: {ex.Message}");
                try { await Task.Delay(TimeSpan.FromSeconds(5), _cts.Token); }
                catch (OperationCanceledException) { break; }
            }
        }
        await FlushBatchAsync();   // 退出兜底
    }

    // ---------- 连接 + 订阅 ----------
    private async Task EnsureChannelAsync()
    {
        if (_channel is { IsOpen: true } && _connection?.IsOpen == true) return;

        ResetConnection();

        var factory = new ConnectionFactory { /* 同生产端 */ };
        _connection = await factory.CreateConnectionAsync(default);
        _channel = await _connection.CreateChannelAsync(default);

        await _channel.QueueDeclareAsync(_options.QueueName, durable: true,
            exclusive: false, autoDelete: false,
            arguments: null, passive: false, noWait: false, cancellationToken: default);

        await _channel.BasicQosAsync(0, _options.PrefetchCount, false, default);

        var consumer = new AsyncEventingBasicConsumer(_channel);
        consumer.ReceivedAsync += async (_, args) =>
        {
            var ch = _channel;
            if (ch == null) return;

            bool needFlush;
            lock (_lock)
            {
                _pending.Add((args.DeliveryTag, args.Body.ToArray()));
                needFlush = _pending.Count >= _options.BatchSize;   // 满批立即刷
            }
            if (needFlush)
            {
                try { await FlushBatchAsync(); } catch { }
            }
        };

        await _channel.BasicConsumeAsync(_options.QueueName,
            autoAck: false, consumerTag: "", noLocal: false, exclusive: false,
            arguments: null, consumer: consumer, cancellationToken: default);
    }

    // ---------- 批量落库 + 确认 ----------
    private async Task FlushBatchAsync()
    {
        List<(ulong Tag, byte[] Body)> batch;
        lock (_lock)
        {
            if (_pending.Count == 0) return;
            batch = new List<(ulong, byte[])>(_pending);
            _pending.Clear();
        }

        bool success = false;
        try
        {
            // 1. 解析、转换、批量写入数据库(这里省略具体 DB 代码)
            await SaveBatchToDbAsync(batch.Select(b => b.Body));
            Console.WriteLine($"批量落库成功: {batch.Count} 条");
            success = true;
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"批量落库失败: {ex.Message}");
        }

        var ch = _channel;
        if (ch == null) return;

        foreach (var (tag, _) in batch)
        {
            try
            {
                if (success)
                    await ch.BasicAckAsync(tag, false, default);      // 成功才确认
                else
                    await ch.BasicRejectAsync(tag, false, default);   // 失败丢弃(或进死信)
            }
            catch { /* 通道可能已断, 消息会由 broker 重新投递 */ }
        }
    }

    // ---------- 解析注意: 兼容两种时间字段 ----------
    // 上游(如 Node-RED 或第三方)可能输出不同字段名/格式, 消费端要做归一化:
    //   ts:  "2026-08-18T10:30:00"  (ISO)
    //   TS:  "2026-08-18 10:30:00"  (字符串)
    // 处理: 优先 ts, 否则读 TS 并 DateTime.TryParse
    // JsonElement 的值(数字/字符串/bool)要转成原生类型, 否则直接塞进
    // 强类型模型会类型不匹配。

    private void ResetConnection() { /* 同生产端 */ }

    public void Dispose()
    {
        _cts.Cancel();
        try { _consumeTask?.Wait(TimeSpan.FromSeconds(5)); } catch { }
        _cts.Dispose();
        ResetConnection();
    }
}

7.1 如何测试(本地快速验证)

# 1. 用 Node.js amqplib 发一条测试消息
node -e "
const amqp = require('amqplib');
(async () => {
  const c = await amqp.connect('amqp://guest:guest@127.0.0.1:5672');
  const ch = await c.createChannel();
  await ch.assertQueue('telemetry.store', { durable: true });
  await ch.sendToQueue('telemetry.store',
    Buffer.from(JSON.stringify({ deviceId: 'dev-1', ts: new Date().toISOString(),
                                 msg: { temp: 25.5, flag: true } })),
    { persistent: true });
  console.log('sent');
  await c.close();
})();
"

# 2. 或直接登录管理界面 ( http://127.0.0.1:15672 , guest/guest)
#    Queues -> telemetry.store -> Publish message 填 JSON 发送

8. Node-RED 集成:自写 AMQP 节点

Node-RED 作为规则引擎时,把它接进 MQ 链路有两种方式:

  1. 安装社区包(如 node-red-contrib-amqpnode-red-contrib-amqplib)
  2. 自写本地节点(推荐,可控性最强,几十行即可)

实际踩坑:官方 node-red-contrib-amqp 的 npm tarball 曾缺 lib/ 文件,
安装后只剩 package.json 和 README,节点无法加载——这也是选择自写的现实原因。

8.1 节点包结构

把包放到 userDir(存放 flows.json 的目录)下的 node_modules,
Node-RED 启动时会自动扫描并加载 node-red-contrib-* 包:

<userDir>/
├── flows.json
├── package.json              # 记录依赖
└── node_modules/
    ├── amqplib/              # 依赖: 先 npm install amqplib
    └── node-red-contrib-hmes-amqp/   # 自写节点包
        ├── package.json
        ├── amqp-hmes.js      # 节点实现(服务端逻辑)
        └── amqp-hmes.html    # 节点定义(编辑器 UI + 帮助)

8.2 package.json(节点注册)

{
    "name": "node-red-contrib-hmes-amqp",
    "version": "1.0.0",
    "main": "amqp-hmes.js",
    "node-red": {
        "nodes": {
            "hmes-amqp-in": "amqp-hmes.js",
            "hmes-amqp-out": "amqp-hmes.js"
        }
    },
    "license": "MIT"
}

8.3 节点实现(amqp-hmes.js)

module.exports = function (RED) {
    'use strict';
    const amqp = require('amqplib');

    function buildUrl(config) {
        let vhost = config.vhost || '/';
        if (vhost !== '/' && !vhost.startsWith('/')) vhost = '/' + vhost;
        return 'amqp://' + encodeURIComponent(config.username) + ':' +
            encodeURIComponent(config.password) + '@' + config.hostname +
            ':' + config.port + vhost;
    }

    // ============ 输入节点: 订阅队列 ============
    function HmesAmqpIn(config) {
        RED.nodes.createNode(this, config);
        const node = this;
        let conn = null, ch = null;

        node.status({ fill: 'grey', shape: 'dot', text: 'connecting' });

        const connect = async () => {
            conn = await amqp.connect(buildUrl(config));
            ch = await conn.createChannel();
            await ch.assertQueue(config.queue, { durable: true });
            await ch.prefetch(parseInt(config.prefetch) || 5000);

            await ch.consume(config.queue, (msg) => {
                if (!msg) return;
                try {
                    // payload 输出 Buffer, 由下游 function 节点 JSON.parse
                    node.send({ payload: msg.content, topic: config.queue });
                } catch (e) { node.warn('send error: ' + e.message); }
                ch.ack(msg);   // 立即 ack: 数据已交给 Node-RED
            });

            node.status({ fill: 'green', shape: 'dot', text: config.queue });
            conn.on('close', () => {
                node.status({ fill: 'red', shape: 'dot', text: 'disconnected' });
                setTimeout(connect, 3000);   // 自动重连
            });
        };

        connect().catch((e) => {
            node.status({ fill: 'red', shape: 'dot', text: e.message });
            setTimeout(connect, 5000);
        });

        node.on('close', (done) => {
            try { if (ch) ch.close(); } catch (e) {}
            try { if (conn) conn.close(); } catch (e) {}
            done();
        });
    }

    // ============ 输出节点: 发布到队列 ============
    function HmesAmqpOut(config) {
        RED.nodes.createNode(this, config);
        const node = this;
        let conn = null, ch = null;

        const ensure = async () => {
            if (ch && conn) return ch;
            conn = await amqp.connect(buildUrl(config));
            ch = await conn.createChannel();
            await ch.assertQueue(config.queue, { durable: true });
            node.status({ fill: 'green', shape: 'dot', text: config.queue });
            conn.on('close', () => { ch = null; conn = null;
                node.status({ fill: 'red', shape: 'dot', text: 'disconnected' }); });
            return ch;
        };

        node.on('input', async (msg, send, done) => {
            try {
                const c = await ensure();
                let body = msg.payload;
                if (Buffer.isBuffer(body)) { /* ok */ }
                else if (typeof body === 'string') body = Buffer.from(body);
                else body = Buffer.from(JSON.stringify(body));
                await c.sendToQueue(config.queue, body, { persistent: true }); // 持久化
                done();
            } catch (e) {
                ch = null; conn = null;
                node.status({ fill: 'red', shape: 'dot', text: e.message });
                done(e);
            }
        });

        node.on('close', (done) => {
            try { if (ch) ch.close(); } catch (e) {}
            try { if (conn) conn.close(); } catch (e) {}
            done();
        });
    }

    RED.nodes.registerType('hmes-amqp-in', HmesAmqpIn);
    RED.nodes.registerType('hmes-amqp-out', HmesAmqpOut);
};

8.4 节点 UI 定义(amqp-hmes.html,关键片段)

<script type="text/javascript">
    RED.nodes.registerType('hmes-amqp-in', {
        category: 'hmes',
        color: '#7f8c8d',
        defaults: {
            name: { value: '' },
            hostname: { value: '127.0.0.1', required: true },
            port: { value: '5672', required: true, validate: RED.validators.number() },
            username: { value: 'guest', required: true },
            password: { value: 'guest', required: true },
            vhost: { value: '/' },
            queue: { value: '', required: true },
            prefetch: { value: '5000' }
        },
        inputs: 0, outputs: 1, icon: 'arrow-in',
        label: function () { return this.name || 'AMQP in: ' + this.queue; }
    });
    // hmes-amqp-out 类似, inputs:1, outputs:0
</script>

<!-- 配置表单模板 -->
<script type="text/html" data-template-name="hmes-amqp-in">
    <div class="form-row">
        <label for="node-input-queue">队列</label>
        <input type="text" id="node-input-queue">
    </div>
    <div class="form-row">
        <label for="node-input-hostname">RabbitMQ 地址</label>
        <input type="text" id="node-input-hostname">
    </div>
    <!-- 端口/账号/密码/虚拟主机/预取 同理 -->
</script>

8.5 流程改造要点(规则处理函数)

MQ 输入节点输出的是 Buffer,且不再有 MQTT 的 topic 结构——
原来从 topic 解析的字段(设备 ID、批次号)现在要从消息体里取:

// 改造前(依赖 MQTT topic):
// const deviceId = msg.topic.split('/')[4];

// 改造后(消息体自带字段):
let data = msg.payload;
if (Buffer.isBuffer(data)) {
    try { data = JSON.parse(data.toString()); } catch (e) { return null; }
} else if (typeof data === 'string') {
    try { data = JSON.parse(data); } catch (e) { return null; }
}
const deviceId = data.DeviceId;   // 从业务字段取值, 不再依赖传输层
const batchNo  = data.BatchNo;

通用经验:让消息体自包含业务上下文(设备 ID、时间、批次),不要依赖
传输层的 topic/routing key 传递业务字段——换协议(HTTP↔MQTT↔AMQP)时零改动。


9. 选型对比:内存队列 vs MQTT vs RabbitMQ

维度 内存队列(Channel/ConcurrentQueue) MQTT(带 broker) RabbitMQ
持久化 无,进程亡即丢 弱(会话级,broker 重启丢) (队列+消息双持久化)
跨进程
数据存储语义 传输协议,非存储 存储 + 投递保证
重启恢复 可能丢 不丢,续传
消费者扩展 单进程内 多订阅者 多消费者并行(竞争消费)
重试/死信 有(nack/requeue/DLQ)
顺序性 天然有序 单队列有序,可扩展
运维成本 中(需部署维护 broker)
延迟 微秒级 毫秒级 毫秒级
适用 同进程内缓冲 设备 <-> 平台传输 平台内部持久化数据管道

选型结论:

  • 设备到平台的轻量传输 → MQTT(低功耗、低带宽、Pub/Sub 语义)
  • 平台内部的可靠数据管道、需要不丢/重试/扩展 → RabbitMQ
  • 两者不是竞争关系,而是分层配合:MQTT 做接入传输,数据进入平台后转入 RabbitMQ 做持久化管道,是最常见的组合

什么时候不要用 MQ:

  • 数据量极小且可容忍丢失(如日志级别)
  • 单机单进程、重启可接受丢数据
  • 需要强一致性的跨服务事务(应选可靠消息最终一致性方案)

10. 常见坑与调试技巧

10.1 客户端 API 版本差异(6.x vs 7.x)

  • IModel/CreateModel()IChannel/CreateChannelAsync()
  • WaitForConfirmsOrDieAsync() → confirm 机制改为 CreateChannelOptions 配置
  • 方法名基本全部带 Async 后缀
  • 排查方法:反编译/反射读 XML 文档,或直接翻 nuget 包里的 .xml 文档文件核对签名

10.2 消费者回调与攒批线程的并发

  • 回调可能并发触发 → 用 lock 保护 pending 列表
  • 批量 ack 用 multiple=false 逐条确认,避免误确认未处理消息
  • 通道断开后 ack/reject 会抛异常 → 必须 try/catch,让 broker 自动重投

10.3 消息字段兼容

  • 上游可能输出 ts(ISO)或 TS(字符串),消费端要归一化
  • JsonElement 的值要先转原生类型再绑定强类型模型

10.4 编码问题(Windows + .NET 混合项目)

  • 旧项目 .cs 可能是 GBK/ANSI 编码,新文件是 UTF-8 —— 用错编码读写会乱码
  • PowerShell 5.1 读 UTF-8 无 BOM 脚本文件按 ANSI 解释 → 脚本里的中文字符串会错乱
  • 解决:PowerShell 脚本保存为 UTF-8 带 BOM;读 GBK 文件用 Encoding.GetEncoding(936)
  • Node-RED 的 flows.json 是 JSON,中文注释/文案保持 UTF-8

10.5 Node-RED 节点不显示

  • 包必须放在 userDir(flows.json 所在目录)下的 node_modules
  • 包名必须以 node-red-contrib- 开头才会被自动扫描
  • 修改节点代码后必须重启 Node-RED
  • 检查:.config.nodes.json 中是否登记了节点类型

10.6 RabbitMQ 账号

  • 默认 guest 账号只能 localhost 访问
  • 服务端账号要在管理界面(15672)或命令行创建,光改配置文件不生效

10.7 验证链路是否打通的万能三板斧

  1. 管理界面看队列:消息数是否增长/清零(积压一目了然)
  2. 向队列手动发一条测试消息:看消费者日志是否打印处理成功
  3. 杀进程重启测试:进程重启后队列积压消息是否被续传消费(验证持久化)

10.8 日志噪音

  • WaitToReadAsync(超时) + CancelAfter 每轮抛 OperationCanceledException,
    IDE 输出窗口刷屏 → 用 Task.Delay 轮询替代(见第 5 节)

11. 落地检查清单

设计阶段

实现阶段

验收阶段


附:本文涉及的关键 API 速查表

RabbitMQ.Client 7.x:
  ConnectionFactory { HostName, Port, UserName, Password, VirtualHost, AutomaticRecoveryEnabled }
  await factory.CreateConnectionAsync(ct)                       // -> IConnection
  await conn.CreateChannelAsync(ct)                             // -> IChannel
  await ch.QueueDeclareAsync(queue, durable, exclusive, autoDelete, args, ct)
  await ch.BasicPublishAsync("", queueName, mandatory, props, body, ct)
  new BasicProperties { Persistent = true }
  await ch.BasicQosAsync(0, prefetchCount, global, ct)
  await ch.BasicConsumeAsync(queue, autoAck:false, ..., consumer, ct)
  await ch.BasicAckAsync(tag, multiple, ct)
  await ch.BasicRejectAsync(tag, requeue, ct)
  new AsyncEventingBasicConsumer(ch) { ReceivedAsync += handler }

amqplib(Node.js):
  amqp.connect('amqp://user:pass@host:port/vhost')
  conn.createChannel()
  ch.assertQueue(name, { durable: true })
  ch.prefetch(n)
  ch.consume(queue, cb) / ch.sendToQueue(queue, buf, { persistent: true })
  ch.ack(msg) / ch.reject(msg, requeue)

附注:本文的架构模式(采集-处理-队列-存储)、可靠性四件套、批量消费模式,
同样适用于 Kafka、Pulsar、Redis Stream 等其他消息中间件——概念相通,
只是 API 与取舍不同。

posted @ 2026-08-19 14:39  古月秋筠  阅读(7)  评论(0)    收藏  举报