跟着一条属性报文走一遍 SagooIoT 的接入链路

一条报文的自述

我是一条属性上报报文。

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

在线文档:https://iotdoc.sagoo.cn

本文涉及的代码均来自 SagooIoT 专业版源码,路径已在文中标注。文中代码为便于阅读做了必要的节选,未改动逻辑。

相关推荐
CodeGL1 小时前
微服务框架——go-zero
微服务·架构·golang
ccbw161 小时前
CH9444X多芯片组合的通道扩展架构与损耗叠加分析
驱动开发·架构·带宽·采集
Decay__1 小时前
01_MySQL 体系架构全解 —— 连接层、SQL 层、存储引擎层与 InnoDB 深入
运维·数据库·sql·mysql·架构
ai_coder_ai2 小时前
六边形架构(端口适配器架构)的设计与实践*
架构
涛声依旧-底层原理研究所2 小时前
可控执行系统:企业 Agent 从演示到生产的架构重构
人工智能·架构
笨笨饿3 小时前
#60_# 一、Photoshop 2025 如何批量导出 PSD 文件中的所有图层为独立 PNG 图片
stm32·单片机·嵌入式硬件·物联网·ui·51单片机·photoshop
Shulex11 小时前
跨境电商 AI Agent 核心架构解析:从只读问答到自主执行的工程落地
人工智能·架构·智能客服·跨境电商
钱工的门窗技术笔记12 小时前
室内极窄全景移门的支承路径与预留接口:轨道形式、型材壁厚口径与验收边界
物联网·架构·数据分析
码农小旋风12 小时前
Ray分布式计算引擎介绍
人工智能·架构