数字孪生巡检平台实战(二):一条 MQTT 遥测的诞生

数字孪生巡检平台实战(二):一条 MQTT 遥测的诞生
上一章我们说好了:这条数据链路的第一步,是让设备数据进入平台。本章交付三样东西——一个 docker-compose.yml、一个设备开通脚本、一个 30 行的 MQTT 模拟器。跑完本章,你在浏览器面板上能看到自己的曲线在动。
一、先解决镜像:为什么不是 tb-postgres:4.3
按惯例先查官方最新版——结果一查,事情有意思了。
Docker Hub 上 thingsboard/tb-postgres(单容器全家桶镜像:TB + PostgreSQL 打包)的标签最新只到 4.2.1.1(2025-12-23 构建);thingsboard/tb(不带 DB 的单体镜像)更是 2022 年就停更了。v4.3 的镜像在哪?在官方源码仓库的 docker/ 目录里——v4.3 起官方废弃了全家桶镜像,只提供混合多容器编排:zookeeper + kafka + postgres + tb-core1 + 三个协议传输容器 + haproxy,.env 默认队列类型是 Kafka。

也就是说官方对"单体"的定义变了:从"一个容器装下一切",变成了"一台机器上一组轻量协作容器"。这本身就是一道架构演进题:
为什么官方要拆? 单容器里 Web、规则引擎、MQTT 接入抢同一个 JVM 的线程池和内存,无法独立伸缩、无法独立重启;拆开后 MQTT 接入层可以按设备连接数扩容,规则引擎按消息吞吐扩容,故障域也隔离开了。代价是运维复杂度——所以小规模起步时,单容器依旧是最顺手的选择。
本系列的取舍:02~17 章用 thingsboard/tb-postgres:4.2.1.1 单容器(钉死版本保证读者复现一致,功能上我们用到的设备管理、遥测、RPC、规则引擎与 4.3 完全一致);第 03 章读 v4.3 源码(概念一致);第 18 章讲"单容器 → 混合编排 → 微服务"的完整升级路径——把版本差异变成教学素材,而不是绕开它。
国内镜像源坑:daocloud、xuanyuan、1panel 等国内源对
thingsboard/*命名空间返回 403,1ms.run 则未缓存该命名空间。解决办法:从 Docker Hub 直接拉(需要代理),或用docker load导入离线包。这也解释了为什么本系列所有镜像都强调钉版本 + 可离线交付。
二、docker-compose.yml:每一行都有理由
services:
tb:
image: thingsboard/tb-postgres:4.2.1.1
container_name: dt-tb
restart: unless-stopped
ports:
- "9090:9090"
- "1883:1883"
environment:
TB_QUEUE_TYPE: in-memory
volumes:
- tb-data:/data
- tb-logs:/var/log/thingsboard
volumes:
tb-data:
tb-logs:
四个值得展开的点:
image钉死小版本,不用latest。latest意味着"某天读者跟着文章操作,拉到的行为和你文章截图不一样"。教程可复现性 > 图省事。9090是 Web 控制台 + REST API,1883是 MQTT 接入端口。设备不连 9090,平台管理流量也不走 1883——两个端口、两类流量、两种认证(后面细讲)。TB_QUEUE_TYPE: in-memory:队列放 JVM 内存里,单机自洽。生产形态换 Kafka(第 18 章)——注意这不是"降级",在单实例场景下内存队列反而是正确的:少一个 Kafka 组件,就少一个故障点。- 具名卷而不是匿名卷:
docker compose down不会删具名卷,数据安全;匿名卷会随着容器一起被遗忘。设备遥测数据是资产,资产要有名字。
启动:
docker compose up -d
docker compose logs -f tb
首次启动日志值得多看两眼,它在做三件事:建 PostgreSQL 表结构(实体表、时序表、索引、视图)、装系统函数、然后灌入演示数据:
Installing DataBase schema for entities...
Installing SQL DataBase schema part: schema-entities.sql
Installing SQL DataBase schema indexes part: schema-entities-idx.sql
Installing SQL DataBase schema views: schema-views.sql
Successfully executed query: CREATE OR REPLACE VIEW device_info_view AS ...
看到 Started Thingsboard 就绪(本机实测全流程约 1 分钟),浏览器打开 http://localhost:9090,用演示租户 tenant@thingsboard.org / tenant 登录。
三、开通第一台设备:三步 REST
设备不是手点出来的——本系列所有操作尽量脚本化,读者才能跟着复现。三步:租户登录拿 JWT → 建设备 → 读设备凭证。
# deploy/provision_demo_device.py(节选)
BASE = "http://127.0.0.1:9090"
# 1) demo 租户登录
r = requests.post(f"{BASE}/api/auth/login",
json={"username": "tenant@thingsboard.org", "password": "tenant"})
# 2) 创建设备
r = requests.post(f"{BASE}/api/device", headers=H,
json={"name": "环境传感器-01", "type": "thermometer"})
# 3) 读取 MQTT 凭证
r = requests.get(f"{BASE}/api/device/{device_id}/credentials", headers=H)
token = r.json()["credentialsId"]
实测输出:
[1] tenant login OK, jwt len = 572
[2] device: 环境传感器-01 id = d04f65d0-b425-11f1-beba-810a21a5b263
[3] access token = QPg51ehFlzqQTioJIf4Y
Access Token 是设备的"用户名"。ThingsBoard 的设备认证体系有三档:Access Token(最简,本系列起步用)、MQTT 基础认证(username/password 组合)、X.509 证书(生产推荐)。为什么起步用 Token?因为网关模拟场景里,设备↔网关之间是内网可信链路,认证强度放到网关↔平台这一跳才更有价值(第 07 章接真实机器狗时会重提这个取舍)。

四、30 行模拟器:它就是未来连接器的雏形
tools/mqtt_sim.py,核心逻辑五段:
import paho.mqtt.client as mqtt
# 1) MQTTv311 + 回调 API v2(paho 2.x 必须显式指定,否则报错)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
client_id="sim-xxxx", protocol=mqtt.MQTTv311)
# 2) 设备认证:username = Access Token,密码留空
client.username_pw_set(args.token)
# 3) 上报主题固定:v1/devices/me/telemetry
client.publish("v1/devices/me/telemetry",
json.dumps({"temperature": 25.17, "humidity": 56.3}),
qos=1)
# 4) 订阅下行 RPC:v1/devices/me/rpc/request/+
client.subscribe("v1/devices/me/rpc/request/+")
# 5) 收到 RPC 先回执再处理(模拟设备确认指令)
c.publish(msg.topic.replace("request", "response"),
json.dumps({"status": "ok"}))
实测输出:
[connected] 127.0.0.1:1883 rc=Success
[subscribed] v1/devices/me/rpc/request/+
[telemetry] {"temperature": 25.17, "humidity": 56.3}
[telemetry] {"temperature": 24.71, "humidity": 45.6}
[telemetry] {"temperature": 24.9, "humidity": 57.5}
[telemetry] {"temperature": 24.76, "humidity": 64.6}
[telemetry] {"temperature": 25.21, "humidity": 62.9}
[telemetry] {"temperature": 24.42, "humidity": 70.0}
温度加了正弦漂移 + 噪声——直线曲线不像传感器,面试演示时会被追问"这是假数据吧"。
为什么说这 30 行是连接器的雏形? 第 06/07 章的 MAVLink/ROS 连接器,本质就是"设备协议解析(那部分是新写的)+ 这段上报逻辑(你已经会了)"。边缘网关的全部秘密,不过如此。
五、闭环验证:让服务端自己开口
上报完成,从 REST API 读回最新遥测:
GET /api/plugins/telemetry/DEVICE/{deviceId}/values/timeseries?keys=temperature,humidity
temperature -> 1 points, latest=24.42 at ts=1789821165063
humidity -> 1 points, latest=70.0 at ts=1789821165063
两个值和模拟器最后一条上报完全一致——链路闭环。此时打开平台 UI,设备详情页已经能看到实时曲线。
顺带一个 API 语义坑:values/timeseries 不传时间范围时,每个 key 只返回最新一个值,不是历史曲线。要历史数据必须带 startTs/endTs。这个"默认最新值"的设计是刻意优化:设备面板场景 90% 的请求只需要最新值。
六、本章踩坑清单(都是真踩的)
| 坑 | 现象 | 解法 |
|---|------|------|
| 国内镜像源 403 | docker pull thingsboard/* 报 unexpected status 403 | Docker Hub 直连(代理)或离线 docker load |
| 端口冲突 | tb-server 等旧容器占着 1883 | docker stop 旧容器;复现时先查 docker ps |
| Python 块缓冲 | 重定向/捕获输出时日志一行不显示 | python -u 或 print(..., flush=True)——设备侧日志丢失会误判成"没连上" |
| paho 2.x 报错 | 构造 Client 缺参数直接抛异常 | 显式传 CallbackAPIVersion.VERSION2 |
七、面试考点复盘
- MQTT QoS 0/1/2:0 至多一次(可能丢),1 至少一次(可能重复),2 恰好一次(四步握手,开销大)。遥测选 QoS 1:丢一条温度数据不可接受,但四步握手的开销在百万消息/秒下不可承受;重复消费由服务端幂等消化(同一时间戳覆盖写)。指令下发才考虑 QoS 2——工单指令重复执行的代价高于握手开销。
- 遗嘱消息(Last Will):客户端异常断线时代理替它发布的"遗言",典型用途是设备离线广播。本章模拟器没用遗嘱——因为离线判定交给第 12 章的服务端定时检查 + 冷却去重,两条路线的取舍留在那一章展开。
- 设备认证三档:Token / Basic / X.509 的适用边界(见第三节)。
- 时序数据 API 语义:
values/timeseries默认返回 latest 而非历史——接口设计要为最高频场景做默认值优化。 - 进程输出缓冲:
stdout重定向后从行缓冲变块缓冲,IoT 脚本的日志必须显式 flush——排查"设备失联"时先怀疑日志,再怀疑网络。
八、下一章预告
现在一条遥测已经躺在 PostgreSQL 的时序表里了。但它是怎么进去的?v1/devices/me/telemetry 主题的消息被谁接住、谁校验、谁写库?下一章打开 ThingsBoard 源码(v4.3),沿着 Transport → Actor 系统 → 时序存储的路径,把这条消息的旅程走一遍——这也是整个系列里"读源码浓度"最高的一章。
本章交付物:
deploy/docker-compose.yml、deploy/provision_demo_device.py、deploy/verify_telemetry.py、tools/mqtt_sim.py,均随系列源码仓库提供。
