一条报文的自述
我是一条属性上报报文。
07:41:12,设备把我写进 TCP 连接,主题是 /sys/pk_airsensor/dk_a01/thing/event/property/post,载荷 96 字节,里面是三个温度值和一个电量。
从我被 Broker 收到,到我变成时序库里的一行、缓存里的一份影子、告警规则里的一个输入、业务系统收到的一条北向消息,中间隔着十几步。每一步都写在代码里,只是平时没人从头到尾走一遍。
这篇就按顺序走一遍。不谈部署规模,只谈这条链路长什么样------因为一条链路的形状,决定了它能不能撑住第二万台设备。
一、第一道判断:这个主题归不归我管
平台的 MQTT 订阅不是一条一条手工挂的,而是一张表。启动时各业务模块把自己关心的主题登记进去,最后由 StartSubscriber 统一订阅:
// network/core/router.go
func StartSubscriber(ctx context.Context) error {
for topic := range subMapInfo.subTopics {
if err := mqtt.Subscribe(ctx, topic, subMapInfo.HandleMessage(ctx, subMapInfo.subTopics[topic])); err != nil {
return err
}
}
return nil
}
表里存的是通配主题,不是具体设备的主题。所以系统订阅的是 /sys/+/+/thing/event/+/post,而不是一万台设备的一万个主题。设备上下线、新增删除,都不需要重订阅。
报文进来后第一件事是拆主题,判断它是不是本平台认的格式:
func isValidTopic(topicInfo []string) bool {
return len(topicInfo) >= 3 && (topicInfo[1] == "sys" || topicInfo[1] == "ota")
}
主题第二段是 sys 或 ota,才继续往下走。这一条判断把平台自己的主题空间和 Broker 上的其他流量彻底隔开了------同一套 MQTT 集群里跑着别的业务,也不会误入。
二、从第几段取身份
主题不是一个字符串,它是一份带位置的路由表。SagooIoT 的约定是固定位置取身份:
func extractKeys(topicInfo []string) (productKey, deviceKey string) {
if topicInfo[1] == "ota" {
return topicInfo[4], topicInfo[5]
}
return topicInfo[2], topicInfo[3]
}
普通业务主题取第 2、3 段,OTA 主题往前走两位取第 4、5 段。两条命名空间各取各的,互不干扰。
日志类型也不是问出来的,是从主题形状推出来的:
func getLogType(topicInfo []string, defaultLogType string) string {
if len(topicInfo) == 8 && topicInfo[6] == "property" {
return consts.MsgTypePropertyReport
}
return defaultLogType
}
八段、第六段是 property,就是属性上报;否则用注册这条订阅时带的默认类型。这样一份订阅表可以同时承载多种报文类型,而处理函数只关心自己那一类。
登记在册的通配订阅一共十条:
| 主题 | 日志类型 | 处理函数 |
|---|---|---|
/sys/+/+/thing/event/+/post |
事件上报(含属性上报分支) | ReportEvent |
/sys/+/+/thing/event/property/pack/post |
网关批量上报 | GatewayBatchReportProperty |
/sys/+/+/thing/service/+ |
方法调用回复 | ServiceCallOutput |
/sys/+/+/thing/service/property/set_reply |
属性设置回复 | PropertySet |
/sys/+/+/thing/config/get |
设置远程请求 | ConfigGet |
/sys/+/+/thing/config/push/reply |
配置推送回复 | ConfigPush |
/sys/+/+/thing/shadow/update |
影子更新 | ReportState |
/sys/+/+/thing/tags |
Tag 更新 | TagUpdate |
/ota/device/inform/+/+ |
版本上报 | Inform |
/ota/device/progress/+/+ |
升级进度 | Progress |
十条订阅覆盖了上行侧的全部入口,属性上报由第一条的 property 分支接管。加一种新报文,是往这张表里加一行,而不是加一条订阅连接。
三、三道门禁,全部不碰数据库
拿到 deviceKey 之后,平台要确认"这台设备配不配被处理"。三个判断,全部走缓存:
deviceInfo, err := dcache.GetDeviceDetailInfo(deviceKey)
if err != nil { ... return err }
if deviceInfo == nil { return nil } // 设备不存在
if deviceInfo.TSL == nil { return nil } // 没有物模型
if deviceInfo.Status == model.DeviceStatusNoEnable { return nil } // 未启用
第一道问"你是谁",第二道问"你的数据长什么样",第三道问"你现在允不允许上报"。三道都过了,报文才有资格进入业务逻辑。
三道判断共用一次缓存读取。设备详情里已经带了产品信息、物模型、启用状态,一次取全,后面十几步都复用这一份。这也是整条链路能以毫秒计的前提------高频路径上不能有数据库往返。
四、并发闸门与对象复用
百万级设备接入时,最怕的不是单条报文慢,而是慢报文把协程堆爆。这里的做法是给每条报文套一层固定的并发栅栏:
var gPool = gpool.NewGPool(200, // 初始容量
gpool.WithAutoScale(true), // 自动扩容
gpool.WithMinCapacity(100), // 最小容量
gpool.WithMaxCapacity(2000), // 最大容量
gpool.WithTaskQueue(5000), // 任务队列
gpool.WithErrorChanSize(2000)) // 错误通道
var messagePool = sync.Pool{
New: func() interface{} { return new(topicModel.TopicHandlerData) },
}
两百个常驻协程起步,压力上来自动扩到两千,中间还有五千个任务的缓冲队列。超过这个量不是无限堆协程,而是排队------排队是可观测的,协程爆炸是不可观测的。
每条报文还带自己的超时:
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
十秒内处理不完,就把上下文取消掉,让后面的步骤及早退出。一条卡住的报文不会长期占着一个协程位置。
传递数据的结构体从 sync.Pool 里取,用完即还。字段是四个:主题、产品、设备、载荷,都是指针或切片,复用成本极低。高频路径上每一条报文省一次堆分配,一天下来就是几千万次。
还有一个容易被忽略的细节------设备状态更新是单独起协程的:
// 更新设备状态 - 使用新的 goroutine 但不使用 gPool 避免潜在死锁
go deviceStatus.UpdateStatus(context.Background(), deviceInfo)
它不占业务协程池的位置。状态更新只是"记一笔这设备刚说过话",和报文处理没有依赖关系,让它独立跑,业务侧就不会被它拖慢,它也不会因为业务侧排队而延迟。
五、字节到 JSON,两级翻译
设备千差万别,报文自然也不统一。SagooIoT 把翻译拆成两级,中间用 JSON 交接。
第一级是协议插件,管字节层:
if deviceInfo.Product.MessageProtocol != consts.DefaultProtocol && deviceInfo.Product.MessageProtocol != "" {
pluginData, err := plugins.GetProtocolPlugin().GetProtocolDecodeData(deviceInfo.Product.MessageProtocol, payload)
if err != nil { return string(payload), err }
pluginDataByte, _ := json.Marshal(pluginData.Data)
jsonData = string(pluginDataByte)
}
产品上配了非默认协议,就交给对应的插件进程去解------Modbus、DL/T 645、私有二进制帧都在这一层变成 JSON。插件是独立进程,跑崩了不影响主进程,升级插件也不用重启平台。
第二级是产品脚本,管语义层:
if deviceInfo.Product.ScriptInfo != "" {
functionName := ""
switch logType {
case consts.MsgTypePropertyReport, consts.MsgTypeGatewayBatch:
functionName = jsinterpreter.FunctionNameByParse
case consts.MsgTypeFunctionReply:
functionName = jsinterpreter.FunctionNameByReply
}
if functionName != "" {
jsonData, err = jsinterpreter.RunScriptWithData(functionName, deviceInfo.Product.ScriptInfo, jsonData)
if err != nil { return string(payload), err }
}
}
插件吐出来的 JSON 字段名未必符合物模型,这一层用产品自带的脚本把它整形成平台标准格式。按日志类型选函数:属性上报走 parse,方法回复走 reply,各管各的。
两级各管一段,边界很清楚:插件不认识物模型,脚本不认识字节。所以在现场最常见的场景------"设备协议已经打通,但字段名对不上"------只需要写脚本,不需要动插件。
出口处还有一行收尾:
return strings.ReplaceAll(jsonData, "\\", ""), nil
把转义反斜杠统一去掉。下游所有解析器都按干净 JSON 处理,不必各自再兜一次。
六、一个通配订阅,接管所有事件
平台上的"事件"是一个开放集合:设备厂商想报什么事件就报什么事件,标识由物模型定义。如果按事件类型逐个订阅,每加一个事件模型就要改一次订阅逻辑。
这里的做法是用一个通配订阅全部接下:
core.RegisterSubTopicHandler(sagooProtocol.EventRegisterSubRequestTopic, consts.MsgTypeEvent, ReportEvent)
// /sys/+/+/thing/event/${tsl.event.identifier}/post
然后在处理函数里分流。属性上报本身也是"事件的一种",走的是同一个通配:
func ReportEvent(ctx context.Context, data topicModel.TopicHandlerData) error {
if strings.HasSuffix(data.Topic, "property/post") {
// 这段特殊的逻辑是为了处理topic通配符的问题
return reporter.ReportProperty(ctx, data)
}
topicInfo := strings.Split(data.Topic, "/")
eventKey := topicInfo[6]
if eventKey == "property" {
return errors.New("ignore")
}
...
}
代码里那行注释写得很实在:通配符把 property 也一起收了进来,所以这里要做一次后缀判断,把它交给属性处理器。
这个设计的好处是新增事件类型零改动 。物模型里加一个事件标识,设备按 thing/event/{标识}/post 上报,平台自动认得------因为订阅的是位置,不是名字。
七、解析一次,分发六路
属性报文走到 ReportProperty,才真正开始产生业务价值。这里的第一件事是按物模型解析:
reportDataInfo, err := service.DevTSLParse().ParseData(ctx, data.DeviceDetail, data.PayLoad)
ParseData 把载荷收敛成"属性标识 → 值"的规范结构,同时完成类型转换、量程夹紧、枚举白名单这些动作。后面所有消费者拿到的都是同一份结果,谁也不用再解析一遍原始报文。
解析结果当场分成六路:
| 去向 | 执行方式 | 作用 |
|---|---|---|
| 设备日志 | go baseLogic.InertTdLog(...) |
原始报文入库,可按时间回溯 |
| 设备影子 | go baseLogic.UpdateShadow(...) |
更新 reported 状态 |
| 北向消息 | north.WriteMessage(...) |
推给上层业务系统 |
| 告警规则 | service.AlarmRule().Check(...) |
触发阈值/状态告警 |
| 场景规则 | queues.ScheduledSceneRule.Push(...) |
进入联动队列 |
| 设备状态 | go deviceStatus.UpdateStatus(...) |
刷新最近活跃时间 |
六路里五路是异步的,只有告警检测是同步等待。这个取舍很明确:告警是"必须当场给结论"的动作,晚一步可能就错过处置窗口;其余五路都是"可以稍后一点"的旁路,异步出去不阻塞主链路。
日志这一路用的是原始报文,不是解析结果:
go baseLogic.InertTdLog(ctx, consts.MsgTypePropertyReport, data.DeviceDetail.Key, reportData.Id, reportData)
存原文的好处是取证。现场出问题时,能看到设备到底发了什么字节,而不是只看到平台理解成了什么。物模型改了、脚本改了,历史日志依然是当时的原貌。
影子这一路带自己的超时:
timeoutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
resultChan := shadowService.UpdateReportedAsync(timeoutCtx, productKey, deviceDetail, data)
三十秒,后台协程收结果,超时就放弃。影子是派生状态,晚一点更新不影响主流程,但不能让一次慢写入拖住整条链路。
八、要回执的报文,给它回执
有些设备要求平台确认收到,否则会重发。这个需求写在报文体里:
if reportData.Sys.Ack == sagooProtocol.NeedAck {
return mqtt.PublishWithInterface(
fmt.Sprintf(strings.ReplaceAll(sagooProtocol.PropertyRegisterPubResponseTopic, "+", "%s"), data.ProductKey, data.DeviceKey),
sagooProtocol.ReportPropertyReply{
Code: 200, Data: struct{}{}, Id: reportData.Id,
Message: "success", Method: "thing.event.property.post", Version: "1.0",
},
)
}
回执发到 post_reply 主题,带上原始 Id------设备靠这个 Id 对上自己发出去的那一条。不需要回执的报文,这一支直接跳过,不多一次发布。
平台侧还有一层过滤,避免把回执自己又收进来处理一遍:
if logType == consts.MsgTypePropertyReport && strings.HasSuffix(topicInfo[6], "reply") {
return true
}
shouldIgnoreMessage 把 _reply 结尾的主题挡在业务处理之外。上下行在同一个 Broker 上跑,靠这条规则保证不形成回环。
九、落库三段:缓冲、聚合、写入
一条属性要进时序库,中间隔着三段。
第一段是内存缓冲,先落到 Redis:
err := redisDb.GetDeviceBatcher().Add(ctx, deviceKey, deviceLog)
if err != nil {
if insertErr := redisDb.DB().InsertData(ctx, deviceKey, deviceLog, false); insertErr != nil { ... }
}
按设备分组攒批,攒够一批再一次写。Redis 这一层同时承担"最近数据"的查询职责------大屏上看到的曲线,很多时候还没进时序库,读的就是这里。
第二段是聚合队列,落库动作本身异步化:
tsdEnable := g.Cfg().MustGet(ctx, "tsd.enable", true).Bool()
if tsdEnable {
data, _ := json.Marshal(deviceLog)
queues.DeviceDataSaveWorker.Push(ctx, consts.QueueDeviceDataSaveTopic, dataId, data, 0)
}
tsd.enable 是开关,关掉就只保留 Redis 这一段。小规模部署不需要时序库,也能正常跑。
第三段是批量写入,队列侧把同一批任务合成一次多设备插入:
// 基于物模型解析数据
deviceDataList[devLog.Device] = append(deviceDataList[devLog.Device], deviceData)
...
_, err = db.BatchInsertMultiDeviceData(deviceDataList)
注意这里又解析了一次。接入侧解析是为了实时判断,队列侧解析是为了写入;两次解析,两个目的,不共用中间结果。这样接入侧可以随意改动、可以失败重试,队列侧只认原始日志------只要日志在,数据就不会丢。
DeviceDataSaveWorker 用的是聚合模式:
func (q *qDeviceDataSave) GetProcessConfig() *worker.ProcessConfig {
return &worker.ProcessConfig{
Topic: consts.QueueDeviceDataSaveTopic,
ProcessType: worker.AggregateProcess,
}
}
同一主题的任务在队列侧合并成批,一次数据库往返写多台设备。设备越多,这个合并的收益越大。
十、五条后台队列各管一摊
除了数据落库,还有几条队列在后台分工:
| 队列主题 | 处理函数 | 职责 |
|---|---|---|
task.device.data.save |
DeviceDataSave |
设备数据批量写时序库 |
task.device.alarm.log |
DeviceAlarmLog |
告警日志落库 |
task.device.status.info.update |
DeviceStatusInfoUpdate |
设备上线/离线/注册状态回写 |
task.scene.rule |
SceneRule |
设备触发的联动规则 |
sagooiot.request.log |
SysOperLog |
系统操作日志 |
它们在启动时一起拉起:
go queues.ScheduledDeviceAlarmLogRun() //设备告警日志
go queues.ScheduledSysOperLogRun() //系统操作日志
go queues.TaskDeviceDataTsdSaveRun() //设备数据保存
go queues.DeviceInfoUpdateRun() //设备信息更新
go queues.ScheduledSceneRuleRun() //场景规则
场景规则这一条值得单独看。它收到的是已经解析好的业务数据,不是原始报文:
switch reportData.DataType {
case consts.SceneTriggerReportAttribute:
var tmp iotModel.ReportPropertyData
json.Unmarshal(tmpData, &tmp)
reportData.Data = tmp
case consts.SceneTriggerReportEvent:
...
case consts.SceneTriggerOnLine, consts.SceneTriggerOffLine:
...
}
属性、事件、上线、离线、方法调用,五种触发源在这里被还原成各自的类型,再统一交给规则链执行。所以"温度超过 30 度就开风扇"和"设备上线就推送通知"用的是同一个执行引擎,区别只在触发源。
设备状态回写也是批量聚合的:
go func() {
err := service.DevDevice().BatchUpdateDeviceStatusInfo(context.Background(), DeviceStatusLogList)
...
}()
状态变化是高频事件,但数据库不需要知道每一次;攒成一批更新,写压力能降一到两个数量级。
十一、北向:十四条主题向上层交付
平台不只是存数据,还要把数据交给上层。北向消息是一套独立的主题空间,十四条:
| 主题 | 方向 | 内容 |
|---|---|---|
/message/device/online |
上行 | 设备上线 |
/message/device/offline |
上行 | 设备离线 |
/message/device/add、update、delete |
上行 | 设备变更 |
/message/tsl/receive/property/report |
上行 | 属性上报 |
/message/tsl/receive/event/report |
上行 | 事件上报 |
/message/tsl/send/property/set |
下行 | 属性设置 |
/message/tsl/send/service/call |
下行 | 服务调用 |
/message/tsl/send/config |
下行 | 配置下发 |
/message/tsl/receive/* |
上行 | 各类回执 |
消息体是统一的信封:
type Message struct {
Meta map[string]string `json:"meta"`
MessageId string `json:"messageId"`
ProductKey string `json:"productKey"`
DeviceKey string `json:"deviceKey"`
Data interface{} `json:"data"`
}
meta 留给调用方塞上下文,messageId 用于链路追踪,data 按主题各自定义。业务系统只要订阅一个主题,就能拿到结构稳定的消息------不需要理解设备协议,也不需要理解物模型内部结构。
receive 和 send 两个词把方向写进了主题名。上层系统看一眼主题就知道这是"我要处理的"还是"我发出去的"。
十二、TCP、HTTP、CoAP 走的是同一个处理器
前面走的都是 MQTT。但现场设备不一定支持 MQTT:有的只会 HTTP POST,有的用 CoAP 省电,有的干脆是串口转 TCP 的透传模块。
平台把这几种接入方式都做成了对等的服务,按类型注册:
switch server.Type {
case "tcp": svr = tcp.NewServerTCP(server)
case "udp": svr = udp.NewServerUDP(server)
case "http": svr = iothttp.NewServerHttp(server)
case "coap": svr = coap.NewServerCoap(server)
case "websocket": svr = websocket.NewServerWebsocket(server)
}
关键在于,它们最后调的是同一个函数。HTTP 侧:
// network/core/server/iothttp/server-http.go
if err := reporter.ReportProperty(context.Background(), getParameter(r, "/sys/%s/%s/thing/event/property/post")); err != nil {
...
}
CoAP 侧完全一样:
// network/core/server/coap/server-coap.go
if err := reporter.ReportProperty(context.Background(), getParameter("/sys/%s/%s/thing/event/property/post", deviceKey, data)); err != nil {
...
}
它们甚至不需要"翻译"成 MQTT 报文,而是直接构造一个 TopicHandlerData 交给处理器。主题只是一个字符串参数,跟传输层无关。
透传 TCP 稍特殊一点,设备发的是裸字节,没有主题。所以它用另一张表做路由------由脚本告诉平台"这条数据是什么":
modelFuncName, ok := dataInfo["model_func_name"].(string)
modelIdentifyName, ok := dataInfo["model_func_identify"].(string)
handleF := tunelBase.GetModelHandle(modelFuncName)
if modelFuncName == tunelBase.UpProperty {
modelIdentifyName = "property"
}
脚本解出 model_func_name(上行类型,如 upProperty)、model_func_identify(物模型标识),平台按名字查表拿到处理器,再拼出一个逻辑主题。之后的路和 MQTT 完全一致:同一个 Handle、同一套物模型解析、同一个落库队列。
四种接入,一套业务:
| 接入方式 | 入口 | 汇聚点 |
|---|---|---|
| MQTT | router.go → HandleMessage |
reporter.ReportProperty |
| HTTP | iothttp/server-http.go |
reporter.ReportProperty |
| CoAP | coap/server-coap.go |
reporter.ReportProperty |
| TCP 透传 | tunnel-base.go → router |
base.GetModelHandle |
这个结构带来的直接好处是:业务逻辑只写一遍。告警、影子、场景规则、北向消息,不管设备怎么接进来,拿到的都是同一份解析结果。
十三、链路的形状
把这条线摊平,就是十二步:
| 步骤 | 动作 | 位置 |
|---|---|---|
| 1 | 通配订阅匹配 | subTopics |
| 2 | 并发闸门 + 超时 | gpool / context.WithTimeout |
| 3 | 主题校验与拆段 | isValidTopic / extractKeys |
| 4 | 回执主题过滤 | shouldIgnoreMessage |
| 5 | 设备三道门禁 | dcache.GetDeviceDetailInfo |
| 6 | 协议插件解码 | plugins.GetProtocolPlugin |
| 7 | 产品脚本整形 | jsinterpreter.RunScriptWithData |
| 8 | 物模型解析 | DevTSLParse().ParseData |
| 9 | 六路分发 | 日志/影子/北向/告警/场景/状态 |
| 10 | 回执发布 | post_reply |
| 11 | Redis 缓冲 + 队列聚合 | DeviceBatcher / asynq |
| 12 | 批量写时序库 | BatchInsertMultiDeviceData |
十二步里,只有第 6、7、8 三步是"必须当场算完"的,其余都可以异步、可以批、可以延后。这个切分决定了整条链路的上限:同步路径越短,吞吐越高;异步路径越清晰,出问题时越好定位。
回头看开头那条报文。它进站时只是一段 96 字节的字节流,出站时已经变成了四种形态:时序库里的历史、缓存里的当前值、规则引擎的输入、业务系统的消息。中间没有一处需要人工干预,也没有一处把"设备是什么协议"泄漏到业务层。
这就是接入链路该有的样子------协议在边上,语义在中间,存储在后面。三者各管一段,谁也不越界。
项目地址
开源社区版:https://github.com/sagoo-cloud
本文涉及的代码均来自 SagooIoT 专业版源码,路径已在文中标注。文中代码为便于阅读做了必要的节选,未改动逻辑。