消息队列在物联网数据链路中的应用:从内存队列到 RabbitMQ 的完整改造
消息队列在物联网数据链路中的应用:从内存队列到 RabbitMQ 的完整改造
本文总结了一次真实改造:把"采集 -> 规则处理 -> 内存队列 -> 存储"的数据链路升级为
"采集 -> RabbitMQ -> 规则处理 -> RabbitMQ -> 批量落库"的持久化链路。
目录
- 为什么要引入消息队列
- RabbitMQ 核心概念
- 通用架构模式:采集-处理-队列-存储
- 可靠性设计四件套
- 批量消费模式(攒批 + 定时兜底)
- 生产端代码示例(C#)
- 消费端代码示例(C#)
- Node-RED 集成:自写 AMQP 节点
- 选型对比:内存队列 vs MQTT vs RabbitMQ
- 常见坑与调试技巧
- 落地检查清单
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.raw、telemetry.processed、telemetry.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 链路有两种方式:
- 安装社区包(如
node-red-contrib-amqp、node-red-contrib-amqplib) - 自写本地节点(推荐,可控性最强,几十行即可)
实际踩坑:官方
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 验证链路是否打通的万能三板斧
- 管理界面看队列:消息数是否增长/清零(积压一目了然)
- 向队列手动发一条测试消息:看消费者日志是否打印处理成功
- 杀进程重启测试:进程重启后队列积压消息是否被续传消费(验证持久化)
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 与取舍不同。

浙公网安备 33010602011771号