零、一个看似自相矛盾的需求

前面的 Mesh 系列文章反复强调了一件事:LoRa 带宽极低,单信道不到 6 kbps。在这个前提下,如果有人对你说——

"我想在这个 LoRa Mesh 上跑 MQTT,10Hz 频率,大量消息。"

你的第一反应大概率是:不可能。

但先别急。这个需求并非凭空想象——工业传感器、无人机遥测、车辆追踪、机器人状态上报,这些场景天然需要高频数据流。即便在低带宽 Mesh 上,我们也可以通过一套组合策略让"10Hz MQTT over LoRa"在某些约束条件下变得可行。

这篇文章不讲"能不能",讲的是"在什么条件下能,以及怎么做"。

一、先算账:10Hz 到底要吃掉多少带宽

1.1 标准 MQTT 在 LoRa 上的自杀式开销

一个最小化的 MQTT v3.1.1 PUBLISH 报文的结构:

1
2
3
4
5
6
7
8
9
10
11
┌──────────────┬────────┬───────┬─────────┬──────────┐
│ Fixed Header │ Topic │ Msg ID│ Payload │ 总计 │
│ 2~5 字节 │ Len+N │ 2字节 │ P │ │
└──────────────┴────────┴───────┴─────────┴──────────┘

假设 Topic = "sensor/temp/room1" (19 字节), Payload = 4 字节(float)
最小 PUBLISH 报文 ≈ 2 + 2 + 19 + 2 + 4 = 29 字节

加上 QoS 1 的两轮 ACK:
PUBACK ≈ 4 字节
→ 每次可靠传输 ≈ 29 + 4 = 33 字节

33 字节 × 8 bit/字节 = 264 bit。10 条/秒 = 2640 bps

看起来不到 5.5 kbps 的一半?别急,这只是一条消息在空中的大小。在 Mesh 网络中:

1
2
3
4
5
6
Mesh 路由开销(以 Meshtastic 为例):
- 消息头: ~20 字节(源、目标、跳数、消息 ID)
- 实际在空中传输: 33 + 20 = 53 字节

加上洪泛:平均每个消息被转发 15 次(20 节点网络)
空中总流量 = 53 × 8 × 10 × 15 = 63,600 bps ≈ 63.6 kbps

63.6 kbps 的总空中流量需求 vs 5.5 kbps 的单信道容量——差距是 11.5 倍。

这就是为什么标准 MQTT + Meshtastic 洪泛 + 10Hz = 网络瞬间崩溃。

1.2 在 Reticulum 路由模式下的改善

Reticulum 的路由寻址替代洪泛后,同样的场景:

1
2
3
4
平均跳数: 3(而非 15 次洪泛)
空中总流量 = 53 × 8 × 10 × 3 = 12,720 bps ≈ 12.7 kbps

仍然超出 5.5 kbps 约 2.3 倍。

结论:仅靠'用 Reticulum 替代 Meshtastic'还不足以支撑 10Hz。需要在协议栈的每一层做优化。

二、第一刀:MQTT-SN,为低带宽而生

2.1 MQTT-SN 和标准 MQTT 的差异

MQTT-SN(MQTT for Sensor Networks)是专门为低带宽、低功耗网络设计的变体。核心差异:

特性 标准 MQTT MQTT-SN
Topic 表示 完整字符串 sensor/temp/room1 2 字节 Topic ID
传输层 TCP(需要稳定连接) UDP(无连接)
QoS -1 无此模式 预定义 Topic,无需注册
Keep Alive PINGREQ/PINGRESP 心跳 GW 代为管理,终端可休眠
连接建立 三次握手 + CONNECT 单包注册
最小 PUBLISH ~29 字节 ~7 字节

2.2 MQTT-SN 最小报文拆解

1
2
3
4
5
6
7
8
9
10
11
12
13
MQTT-SN PUBLISH (QoS -1, 预定义 Topic):
┌─────────┬──────────┬─────────┐
│ Length │ Flags │ Payload │
│ 1 字节 │ 1 字节 │ P │
└─────────┴──────────┴─────────┘

Flags 字段包含:
- Topic ID Type: 2 bit (预定义 Topic ID = 无需注册)
- Topic ID: 10+ bit (可表示 1024+ 个预定义 Topic)
- QoS: 2 bit (设为 -1 时无 ACK)
- DUP/Retain: 各 1 bit

总开销 = 2 字节(Length + Flags)!

对比标准 MQTT 的 29 字节 → 减少 93% 的协议开销

2.3 回到算账

MQTT-SN 优化后:

1
2
3
4
单条消息在空中: 7 字节(PUBLISH) + 22 字节(Mesh头) = 29 字节
空中总流量(3跳路由) = 29 × 8 × 10 × 3 = 6,960 bps ≈ 7 kbps

仍然超出 5.5 kbps 约 27%。

协议开销已经砍到极致,但 Mesh 路由转发仍然放大了流量。需要继续优化。

三、第二刀:批量打包——用时间换带宽

3.1 从"每秒 10 条"到"每秒 1 批"

10Hz 意味着每 100ms 一个采样点。如果应用能容忍 1 秒的延迟(对于大多数遥测场景完全可以接受),可以把 10 个采样点打包成一个批量消息:

1
2
3
4
5
6
7
8
9
10
单条 PUBLISH(1 个采样点)→ 7 字节
批量 PUBLISH(10 个采样点)→ 1 + 1 + 4×10 = 42 字节

每秒发 1 条批量消息 vs 每秒发 10 条单点消息:

单点方案: 29 × 10 × 3 = 870 字节/秒(空中)
批量方案: (22 + 42) × 1 × 3 = 192 字节/秒(空中)

空中流量减少 78%。
192 × 8 = 1,536 bps — 仅占 5.5 kbps 的 28%

到了这里,10Hz 等效数据率在 3 跳 Mesh 上已经完全可行

3.2 批量格式设计:Protobuf

手工 struct 打包虽然紧凑,但缺乏可扩展性——加一个字段就得改解析逻辑。Protobuf 兼顾紧凑性和向后兼容,且生态成熟。

1
2
3
4
5
6
7
8
9
// sensor_batch.proto
syntax = "proto3";

message SensorBatch {
uint32 topic_id = 1; // 预定义 Topic ID (0x01=温度, 0x02=湿度, ...)
double start_time = 2; // 起始时间戳 (Unix 秒)
uint32 interval_ms = 3; // 采样间隔 (毫秒, 10Hz → 100)
repeated float samples = 4 [packed=true]; // 采样值序列 (packed 避免每个值单独编码)
}

packed=true 是关键:重复的 float 值不会被逐个 Tag+Value 编码,而是共享一个 Tag,后面跟一个紧凑的长度前缀 + 连续值——与 struct 手工打包的紧凑度几乎等同。

编译与使用:

1
protoc --python_out=. sensor_batch.proto
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from sensor_batch_pb2 import SensorBatch

def pack_batch(topic_id: int, samples: list[float],
start_time: float, interval_ms: int = 100) -> bytes:
"""Protobuf 序列化,10 个采样点 ≈ 52 字节(比 struct 多 5 字节 Tag 开销)"""
batch = SensorBatch()
batch.topic_id = topic_id
batch.start_time = start_time
batch.interval_ms = interval_ms
batch.samples.extend(samples) # packed repeated, 紧凑编码
return batch.SerializeToString()

def unpack_batch(data: bytes) -> SensorBatch:
batch = SensorBatch()
batch.ParseFromString(data)
return batch

Protobuf 比手工 struct 多出约 5 字节的 Tag/Varint 开销,换来的是:

  • 模式演化:新增字段(如 battery_voltage)不破坏旧固件
  • 跨语言:C++/Python/Java/Go/Rust 共享同一份 .proto
  • 生态工具protoc 直接生成序列化/反序列化代码,零手写解析器

3.3 进一步压缩:差分 Protobuf

如果连续采样值变化缓慢(温度、湿度),Protobuf 版本也可以与差分编码结合——单独定义一个精简 message:

1
2
3
4
5
6
7
8
9
10
// sensor_diff.proto
syntax = "proto3";

message SensorDiff {
uint32 topic_id = 1;
double start_time = 2;
uint32 interval_ms = 3;
sint32 base = 4; // 首值 × scale, zigzag 编码
bytes deltas = 5; // 后续差值 (每个 1 字节, int8)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import struct
from sensor_diff_pb2 import SensorDiff

def diff_encode_proto(topic_id: int, samples: list[float],
start_time: float, scale: float = 100) -> bytes:
"""差分编码 + Protobuf,10 个采样点 ≈ 18 字节"""
msg = SensorDiff()
msg.topic_id = topic_id
msg.start_time = start_time
msg.interval_ms = 100 # 10Hz
msg.base = int(samples[0] * scale) # sint32 zigzag
diffs = bytearray()
for i in range(1, len(samples)):
diff = int((samples[i] - samples[i-1]) * scale)
diff = max(-128, min(127, diff))
diffs.append(diff & 0xFF) # int8 → byte
msg.deltas = bytes(diffs)
return msg.SerializeToString()

# 批量消息: 22(Mesh头) + 18(Protobuf) = 40 字节
# 空中: 40 × 8 × 1 × 3 = 960 bps — 仅占 5.5 kbps 的 17%

极端优化下,10Hz 温度传感数据在 LoRa 上的空中占用不到 20% 的单信道容量

四、第三刀:多信道并行——用频率换带宽

4.1 CN470 频段的可用信道

中国 LoRa CN470-510 频段有多个可用信道:

1
2
3
4
5
6
7
8
CN470 上行信道(部分):
Channel 0: 470.3 MHz
Channel 1: 470.5 MHz
Channel 2: 470.7 MHz
...
Channel 7: 471.9 MHz

共 8 个 125kHz 信道可用(受限于 LoRaWAN 规范定义,非 LoRaWAN 场景可自行规划)

如果同时使用 4 个信道,总容量就是 5.5 × 4 = 22 kbps。

4.2 按优先级分信道

1
2
3
4
5
6
信道分配策略(4 信道方案):

Ch 0 (470.3 MHz): 路由与网络管理(Reticulum Announce、路径探测)
Ch 1 (470.5 MHz): 高优消息(紧急告警、控制指令,QoS 1)
Ch 2 (470.7 MHz): 批量遥测上行(传感器数据,QoS -1,10Hz 批量)
Ch 3 (470.9 MHz): 大文件/固件升级(低优先级,QoS -1,深夜传输)

在 Reticulum 中,每个接口可以绑定不同的 LoRa 模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Reticulum 多信道配置
interfaces:
# 管理信道 - Heltec V3 #1
- type: RNodeInterface
frequency: 470300000 # 470.3 MHz
bandwidth: 125000
spreading_factor: 7
name: "Ch0-Control"

# 遥测信道 - Heltec V3 #2
- type: RNodeInterface
frequency: 470700000 # 470.7 MHz
bandwidth: 125000
spreading_factor: 7
name: "Ch2-Telemetry"

硬件成本增加:每增加一个信道,多加一个 Heltec V3(35 元)+ 天线(5 元)= 40 元。4 信道方案总硬件增量 120 元——极为廉价。

五、端到端架构:从传感器到云端的完整链路

5.1 整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[传感器节点群] (10Hz 采样)

│ MQTT-SN PUBLISH (批量, QoS -1)
│ 通过 Reticulum Mesh (470.7 MHz 遥测信道)

[MQTT-SN Gateway] (Orange Pi + Heltec V3 × 2)

│ ① 接收 MQTT-SN 批量包
│ ② 解包、校验、时序重建
│ ③ 转换为标准 MQTT (QoS 1)
│ ④ 通过互联网/Wi-Fi 上行到云端 MQTT Broker

[EMQX / Mosquitto Broker] (云端)


[数据消费者] (InfluxDB / Grafana / 控制台)

5.2 Gateway 节点核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
"""
MQTT-SN over Reticulum Gateway
运行在 Orange Pi 上,桥接 LoRa Mesh 和互联网 MQTT Broker
"""
import time
import paho.mqtt.client as mqtt
import RNS
from sensor_batch_pb2 import SensorBatch

# ---- 配置 ----
MESH_TOPIC_MAP = {
0x01: "sensor/temperature/batch",
0x02: "sensor/humidity/batch",
0x03: "sensor/gps/batch",
0x10: "control/relay/command",
}
MQTT_BROKER = "192.168.1.100" # 本地或云端 Broker

# ---- MQTT 上行客户端 ----
mqtt_client = mqtt.Client()
mqtt_client.connect(MQTT_BROKER, 1883)
mqtt_client.loop_start()

# ---- Reticulum 接收回调 ----
def on_mesh_packet(message, packet):
batch = SensorBatch()
try:
batch.ParseFromString(message)
except:
return # Protobuf 解析失败,丢弃

topic_name = MESH_TOPIC_MAP.get(batch.topic_id)
if topic_name is None:
return

# 拆分为标准 MQTT,Protobuf 序列化作为 payload
for i, value in enumerate(batch.samples):
ts = batch.start_time + i * batch.interval_ms / 1000.0
# MQTT payload 仍用 Protobuf,消费者端统一解析
from sensor_batch_pb2 import SensorPoint
point = SensorPoint()
point.timestamp = ts
point.value = value
mqtt_client.publish(topic_name, point.SerializeToString(), qos=1)

# ---- 启动 Reticulum ----
reticulum = RNS.Reticulum()
identity = RNS.Identity()

mesh_iface = reticulum.get_interfaces()[1] # Ch2-Telemetry
mesh_iface.on_packet = on_mesh_packet

print("[Gateway] MQTT-SN → MQTT Bridge running (Protobuf)...")
while True:
time.sleep(1)

Mesh 内和 MQTT 上行全程使用 Protobuf,云端消费者(InfluxDB/Grafana)通过同一份 .proto 生成的代码反序列化,无需任何文本解析。

5.3 传感器节点代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
"""
传感器节点:每 100ms 采样,每秒批量发送一次
"""
import time
import random
import RNS
from sensor_batch_pb2 import SensorBatch

reticulum = RNS.Reticulum()
identity = RNS.Identity()
destination = RNS.Destination(
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
"mqtt_sn_gateway", "telemetry_batch"
)

TOPIC_TEMP = 0x01
BATCH_SIZE = 10
SAMPLE_INTERVAL = 0.1 # 100ms

while True:
# 构造 Protobuf 批量消息
batch = SensorBatch()
batch.topic_id = TOPIC_TEMP
batch.start_time = time.time()
batch.interval_ms = int(SAMPLE_INTERVAL * 1000) # 100ms

for _ in range(BATCH_SIZE):
value = 25.0 + random.uniform(-0.5, 0.5)
batch.samples.append(value)
time.sleep(SAMPLE_INTERVAL)

# 序列化并发送
RNS.Packet(destination, batch.SerializeToString()).send()
# 1 秒循环,无需额外延时

六、总结:10Hz MQTT over LoRa Mesh 的可行性矩阵

优化策略 单条消息大小 空中流量(3跳) 占信道容量 可行性
标准 MQTT + 洪泛 33 字节 63.6 kbps 1156%
标准 MQTT + 路由 33 字节 12.7 kbps 231%
MQTT-SN + 路由 7 字节 7.0 kbps 127% △ 临界
MQTT-SN + 批量 42 字节(批) 1.5 kbps 28%
+ 差分量化和多信道 20 字节(批) 0.7 kbps 4% ✓ 充裕

最终答案:在 Reticulum 路由模式 + MQTT-SN 协议 + 批量打包 + 差分编码的组合策略下,10Hz 等效数据率在 3 跳 LoRa Mesh 上完全可行,且留有充裕的信道余量给其他流量。

关键约束条件(必须满足)

  1. 延迟容忍 ≥ 1 秒:批量打包带来了 1 秒的端到端延迟(采完 10 个点才发)
  2. QoS 0 或 -1:高频遥测不能用 QoS 1(每条消息都需要 ACK),必须接受偶尔的丢包
  3. Protobuf 序列化:放弃 JSON/文本,全程 Protobuf——Mesh 内和 MQTT 上行共用同一份 .proto 定义
  4. 路由模式,非洪泛:必须用 Reticulum 或 MeshCore,不能用 Meshtastic 的默认洪泛
  5. Topic 预定义:MQTT-SN 的 Topic ID 需要在 Gateway 和传感器端预先约定

如果不满足这些条件怎么办?

  • 如果必须 QoS 1 可靠传输,把频率降到 2Hz 以下
  • 如果必须 JSON 格式,把频率降到 1Hz 以下,并启用 GZIP 压缩
  • 如果必须用 Meshtastic(洪泛),把频率降到 0.2Hz 以下(每 5 秒一条)
  • 如果必须 < 100ms 延迟,放弃 LoRa,改用 Wi-Fi Mesh 或私有 LTE

低频 Mesh 通信和传统互联网之间有一条宽阔的工程鸿沟——跨越鸿沟的不是任何单一技术,而是协议选择、数据压缩、批量策略和信道规划的合力。