同一个值,四个名字,四套口径:SagooIoT 属性上报链路的 Canonical 收敛

给属性上报链路做基线是一件很枯燥的事:固定 10k 设备在线、10k msg/s、每条报文 500 字节上下、平均一条报文带 5 个属性,然后让 benchmark 跑三轮。跑完拿到的第一个数字有点刺眼------一次属性上报,在热路径上要做 67 次堆分配。

67 次里面真正属于业务的没几次。剩下的大部分花在同一个值被反复改名、反复转换上:进来时叫报文字节,解完叫 params,归一后叫 ReportPropertyData,发给下游时又要变成各自投影过的形状。

这篇文章讲的是这条链路上做过的一次收敛:把"同一个值"在平台内部的表述固定下来,让影子、告警、时序库、审计日志四个消费者吃同一份数据;顺带把分配次数从 67 降到 37。为了让说法有据可查,文中凡是"代码里是这么做的",指的都是本地发行版(Professional)的源码------开源主干里不包含 Poller 与驱动插件这套东西。

一、一条属性上报有三条来路

SagooIoT 的设备接入有三条主路径,属性上报从任意一条都能进来。

路径 入口 协议解码 JS 脚本
MQTT 直连设备 mqtt.Subscribe → HandleMessage 走协议插件 走
主动采集设备(Poller) Poller Task → 驱动插件 Read 或 DTU 隧道 Ask 跳过(采集侧已是 JSON) 走
通道接入设备(Tunnel) Server Receive → TunnelBase.ReadData 必须走(要靠解码结果路由) 走

三条路最后都汇到同一个函数:

go 复制代码
func HandleDirectMessage(ctx context.Context, topic string, payload []byte,
    handler func(context.Context, topicModel.TopicHandlerData) error,
    skipProtocolDecode bool, logType string) error {
    ...
    res, err := processMessage(ctx, processDevice, payload, logType, skipProtocolDecode)
    ...
}

Poller 和 Tunnel 调它时 skipProtocolDecode 传 true,因为它们已经各自完成了协议解码------一个在采集侧、一个在 Tunnel 层。

这套"多源汇聚、统一管线"不是一开始就这样。Tunnel 路径原来有一份独立的 router() 实现,后来被删除合流,原因是它带着三个具体问题:不支持 JS 的 reply 函数、不受全局协程池(gPool)背压控制、不被 before/after Filter 拦截。三条都不是理论缺陷,是能在现场撞到的行为差异。

二、同一个值,四个名字

收敛的前提是先把"值"在链路上的形态命名清楚。整条链路里同一个业务值最多有四种形态:

代号 类型 含义
Raw owned []byte / string 协议解码 + JS 脚本处理之后的报文原文
A map[string]any 反序列化后的协议树(params)
B / Canonical ReportPropertyData 过物模型过滤、做过类型与时间归一之后的"平台认定值"
Derived envelope 平台重编的 JSON 不是原文,只用于没有独立原文的场景

Raw 由 processMessage 产出。它返回的不是裸 []byte,而是一个包装类型:

go 复制代码
// network/core/owned_payload.go
// OwnedPayload 持有独立分配的 payload 字节,调用方可安全跨异步持有。
type OwnedPayload struct {
    data []byte
}

Canonical 由 effects.ParseProperties 产出------它做三件事:按产品物模型挑出有定义的属性、按 TSL 声明把值转成平台认定的类型、补时间戳。Derived envelope 则出现在网关批量里:子设备的属性嵌在网关的报文里,没有自己的报文原文,平台只能自己拼一份 JSON 出来。

四种形态不是冗余。Raw 服务审计与排障,A 是协议层的自然产物,Canonical 是四个下游消费者唯一该认的东西,Derived 是"没有原文时不许假装有原文"的产物。第三章的分配问题,本质上就是 A 和 Canonical 没有及时分开造成的。

三、67 次分配到底花在哪

先看三处已经复核过的事实。

第一处:入口重复拷贝。 processMessage 在插件路径上先做了一次 make + copy,紧接着 json.Marshal(pluginData.Data) 又把整块覆盖掉。前半段是纯浪费。

go 复制代码
// 修复前的形态(现已重排为先判插件、再决定是否拷贝)
jsonData := make([]byte, len(payload))
copy(jsonData, payload)
if !skipProtocolDecode && preprocessor.NeedsProtocolPlugin(protocolName) {
    pluginData, err := plugins.ProtocolDecodeOnce(...)
    ...
    jsonData, err = json.Marshal(pluginData.Data)   // 上面那次拷贝白做了
}

第二处:设备日志重包一遍。 直连路径原本把 A 交给 BuildPropertyLogPayload 重新构造成标准信封,再 Encode 成 JSON 存日志------一份内容、两次序列化。

第三处:影子写入时逐属性建节点。 UpdateReportedAsync 内部对每个属性调 CreatePropertyNode,而 Canonical 本来是批量的,逐属性建节点的开销与属性数线性相关。

把这三处叠起来,再加上 map[string]any 从入口贯通到扇出点(每次取值都要类型断言、每层都可能再拷一次),就是 malloc/s 量级到百万那类现象的直接来源。根因不是某一处写错了,而是"同一个值在热路径上被当成不同东西处理了好几遍"。

四、扇出点只有一个

收敛的目标架构里,属性路径的扇出边界只有三个地方:

  • DeviceActor.flushBatch(正常异步路径)
  • fallbackToSyncProcessing / ApplyPropertyEffects(Actor 投递失败后的同步降级)
  • 网关批量拆包之后进入上述边界(外层审计日志除外)

扇出之后,以下动作一律禁止:再跑一次 ParseProperties、再对属性调 CreatePropertyNode、把 A 喂给影子或告警、业务侧从日志 Content 里反解析 Canonical。

这条边界不是风格问题。扇出前做一次归一,得到的是"四个消费者语义一致";扇出后各转各的,得到的是"四个消费者各自看起来都对,但值不一样"。

五、口径分裂是什么样子

收敛之前的扇出是这样的:

消费者 吃什么 结果
影子 A(原始 params) 未过类型转换
属性告警(Direct) A 解包出的毛值 未过 ConvertValue
属性告警(影子旁路) 从影子 Reported 里再解一次 同一次上报被评第二遍
TSD Parsed != nil 判断 无法区分"没解析"和"解析失败"
审计日志 Actor 路径重包 JSON / 同步降级存原文 同一设备存在两种 Content 形态

三件事值得单独说。

第一,属性告警是双路,而且没有去重 。事件告警有 tryMarkEventProcessed 之类的时间戳去重,属性没有。同一条上报会走 CheckPropertyAlarmDirect 评一次,再经 UpdateShadow → OnDeviceUpdate → checkPropertyAlarm 评一遍。

第二,阈值判断吃的是毛值 。影子路径只从 Reported 里提取 {value, time},不做类型转换。这意味着温度属性在物模型里声明成浮点、设备上报字符串 "30.5" 时,影子看到的和告警比的是两个东西。

第三,日志 Content 两种形态并存 。Actor 路径存的是重包后的合法 JSON,同步降级路径存的是 string(data.PayLoad) 原文。这不是未来的风险,是现网已经存在的状态------前端设备详情弹窗里 JSON.parse 没有 try/catch,遇到非 JSON 的 Content 会抛异常,弹窗打不开或显示旧值。

六、收敛一:把"解析状态"从约定变成字段

扇出要按"解析成没成"来决定给谁发数据,而原来的表达方式是靠约定:

  • Parsed = nil 同时表示"还没解析"和"解析失败"
  • Parsed = 空 map 表示"解析成功但没有匹配到物模型属性"

一个字段承担三重含义,任何新写的消费者都得先读懂这个约定。所以第一步是把状态显式化:

go 复制代码
// pkg/iotModel/device.go
type PropertyParseState struct {
    Attempted bool // 是否已执行物模型解析
    Succeeded bool // 解析过程是否成功(无 error)
}

三种组合的含义就清楚多了:

Attempted Succeeded Canonical 含义
false --- nil 尚未解析,不应扇出业务
true true 非 nil(可为空 map) 成功;空 map = 没有匹配到物模型属性
true false nil 解析失败

有了状态,扇出策略就能写成一张确定的表:

状态 审计日志 影子 告警 北向 TSD
成功(有匹配) 按 LogKind 写 更新 评估 发 写
成功(无匹配) 按 LogKind 写 跳过 跳过 跳过 跳过
失败 写(便于排障) 跳过 跳过 跳过 跳过

时序数据库读侧的兼容是这条改动里最容易踩的地方。改之前 TSD 遇到 Parsed = nil 会拿 Content 兜底再解析一次------当时能救回数据,是因为 Actor 路径的 Content 恰好是重包出来的合法信封 JSON。Content 一旦换成真实原文(可能是纯文本、也可能是二进制协议原文),这个兜底要么静默返回空、要么直接抛异常。所以顺序是死的:先有显式状态,再切日志形态;反过来做,切换期就会丢数据。

七、收敛二:属性告警从双路到单路

收敛后的合同写成一句话:每条属性上报,对告警只评估一次 ,入口只有 CheckPropertyAlarmDirect,入参必须是 Canonical 加合并快照。

现在 DeviceActor.flushBatch 里是这样:

go 复制代码
if len(mergedCanonical) > 0 {
    mergedSnapshot := alarmSrv.MergeCanonical(a.lastProps, mergedCanonical)
    a.lastProps = mergedSnapshot

    if handler := alarmSrv.GetAlarmShadowHandler(); handler != nil {
        handler.CheckPropertyAlarmDirect(alarmCtx, a.productKey, a.deviceKey,
            mergedCanonical, mergedSnapshot)
    }
    // ... 场景联动 ...
    updateShadowCanonicalFn(shadowCtx, a.productKey, deviceDetail, mergedCanonical)
}

注意最后的顺序:先评估告警,再更新影子 。影子更新不再触发属性告警,OnDeviceUpdate 被清空成 no-op:

go 复制代码
// OnDeviceUpdate 设备影子更新通知。
// Phase 3:属性/事件告警已收敛为 Actor Direct-only,此处不再二次评估。
// 持续条件到点补评仍由 recheckSustainedConditionAlarms → checkPropertyAlarm 负责。
func (h *AlarmShadowHandler) OnDeviceUpdate(deviceShadow *shadow.DeviceShadow) error {
    if h.ctx.Err() != nil { return h.ctx.Err() }
    _ = deviceShadow
    return nil
}

它没有变成"什么都不做"------持续条件的到点补评还在,只是改由兜底扫描 recheckSustainedConditionAlarms 负责,而不是靠每次影子更新顺带评一遍。这个区分很重要:影子更新触发评估是"数据驱动",兜底扫描是"时间驱动",后者才是持续条件(比如"连续 5 次超阈值""5 小时未恢复")真正需要的触发源------设备一直不上报时,数据驱动的评估根本不会被触发。

事件告警走同样的路:只留 CheckEventAlarmDirect,时间戳去重从"必需"降级成"防止误双调的防御"。

八、收敛三:一个写了很久却没人读的字段

DeviceActor 里有个 lastProps,注释写着"跨批累积",实际状态是只写不读。跨批持续条件("温度连续 5 次超过 30 度")单靠当批 Canonical 根本没法评估,因为一次批量里只有一个时刻。

修法是把合并动作提到评估之前,并且明确归属:

go 复制代码
// 1. 当批 Canonical 逐条解析出来,合并成 mergedCanonical
// 2. 与历史合并:parsed 覆盖 lastProps 的同名键
mergedSnapshot := alarmSrv.MergeCanonical(a.lastProps, mergedCanonical)
a.lastProps = mergedSnapshot
// 3. 两个都交给唯一入口:当批新值用于单次阈值,合并快照用于持续条件与恢复判断
handler.CheckPropertyAlarmDirect(ctx, productKey, deviceKey, mergedCanonical, mergedSnapshot)

还有一个容易被忽略的细节:Actor 是按设备 创建的,进程重启后内存里的 lastProps 就没了。所以 Actor 启动时要从影子里把历史灌回来:

go 复制代码
func (a *DeviceActor) onStarted(ctx actor.Context) {
    a.seedLastPropsFromShadow()
}

灌回来的数据必须走与 Canonical 一致的投影------影子存的是 {time, value},读出来要组装成属性节点,不能在灌种子的路径上又引入一套"只取 value 不看类型"的老逻辑。否则只是把口径分裂从热路径搬到了启动路径。

九、收敛四:Raw 的所有权说不清楚

这一条是并发安全的账。

processMessage 成功时返回的 res 是独立堆缓冲,可以安全跨异步持有;但错误分支返回的是入站原始 []byte。调用方目前不会误用错误分支的返回值,所以没有实际故障------可问题在于纯 []byte 类型在编译期无法区分"owned"和"借用"。将来某个调用方拿着错误分支的返回值去异步用,编译器不会拦。

解法是让类型自己说话:

go 复制代码
// Only err==nil 时返回非空 OwnedPayload(独立堆缓冲,可跨异步持有);
// err!=nil 时返回空包装,调用方无法拿到入站借用缓冲。
func processMessage(...) (OwnedPayload, error) {
    // ...
    return OwnedPayload{}, fmt.Errorf("plugin decode failed: ...")
    // ...
    return OwnedPayload{data: jsonData}, nil
}

同时,这条合同明确绑定了两件"不许做"的事:投递前不许对 PayLoad 做无谓的 Clone(既然已经是 owned,Clone 就是纯浪费),以及不许用 unsafe 做零拷贝字符串再跨异步持有。

十、收敛五:日志 Content 有三种语义

审计日志的 Content 到底是什么,原来靠"哪条路径写的"来推。现在写进枚举:

LogKind 场景 Content 是什么
Raw 直连属性上报 入口 owned 的报文原文
GatewayBatchAudit 网关批量外层审计 网关报文的原文(只写一次)
DerivedEnvelope 网关子设备业务日志 平台重编的 JSON,不是子设备原文

第三种是这张表里最关键的一行。网关子设备的属性是嵌在网关报文里的,子设备自己没有独立原文。原来这里会重包一份 JSON 当 Content 存下来,形式上和一手的原文没法区分------排障时看到一份"像原文的东西",其实是谁也说不清算谁的派生品。现在它老老实实标记成 DerivedEnvelope。

日志写入侧还顺手清掉了一处死分配:

go 复制代码
if str, ok := obj.(string); ok {
    // string 热路径直接用,禁止再 []byte(str) 死分配
    content = str
} else {
    buf := jsonBufferPool.Get().(*bytes.Buffer)
    // ...
}

日志内容切成 Raw 之后,string 分支就是热路径,而它的分配次数比原来"构造 map → Encode → 拷贝 → 转 string"那条路少一次。

十一、Raw 驻留不是免费的

把 Raw 一路带在内存里有个直接的代价:它活得比以前久。以前 PayLoad 在入口反序列化完就能释放,现在要挂在 Actor 的邮箱和批量缓冲上直到 flush。

这笔账可以估出来。固定条件下:批量大小 20,每设备入站上限 100,flush 间隔 100ms,单设备上报速率 10 msg/s,报文约 500 字节,活跃 Actor 1000 个:

复制代码
(20 + 100 + 0.1 × 10) × 500B × 1000 ≈ 60.5 MB

公式里的 100 是每个设备的入站闸门 MailboxCapacity。它不是一个软限制------Send* 占不到槽位就直接返回 ErrActorMailboxFull,调用方必须走同步降级,不许假装投递成功:

go 复制代码
var ErrActorMailboxFull = errors.New("device actor mailbox full")

这个闸门同时兼任内存保护 :单设备的 in-flight 消息数被钉在 100 以内,Raw 驻留就有了上界。所以验收的时候,heap_inuse 的增量要去和公式估的 60.5MB 对账,实测超过估算值 1.2 倍就算没达标。只看分配次数、不看驻留,等于把"少分配了但留久了"这种情况当成优化。

十二、验收为什么必须两个指标一起看

基线数据是这样:

项 before after
allocs/op 67 37
B/op 5115 4811
ns/op ≈5140 3475--3923

分配次数降了约 45%,落在目标区间 −30% ~ −50% 之内。但列出来的验收项有五个:allocs/op、heap_inuse 峰值、malloc 次数、GC 频次,以及日志分支的分配明细。

为什么不能只看一个。只报 allocs/op,会漏掉"分配少了但对象留得久了"这类退化;只报 heap_inuse,会漏掉高频小对象造成的分配密度问题。而且对比的两端必须是同一边界 ------基线端如果取的是池化 buffer 分支,目标端取的是非池化 string(),两者不等价,算出来的改善就是假的。所以日志这一项要拆下来单独比:切换后的日志分支分配次数,必须不大于切换前 Encode 分支的次数,否则视为口径没对齐。

还有一条验收表述被明确禁止:不许把"四个下游最终状态一致"当成成功标准 。影子队列可能熔断,TSD batcher 可能满而丢弃,告警异步提交可能失败,北向发布可能失败------四个 sink 各自独立。能承诺的只是"输入语义一致":四个消费者拿到或序列化出去的业务值,都来自同一份 Canonical。可靠性体现在另一处,即每个 sink 的入队成功、丢弃、超时、失败原因都要有计数。

告警 sink 原来是没有计数的。往细里数有四个失败点:提交池满、评估超时、评估异常、写库失败。池满这一处的处理方式是刻意的------立即返回错误而不是阻塞等待:

go 复制代码
// 池满时立即返回错误而不是阻塞等待:调用方位于设备 Actor 消息循环内,
// 阻塞会把背压传导到整条上报链路(邮箱积压 → inFlight 打满 → 降级同步处理)。
func (h *AlarmShadowHandler) submitTask(task func()) error {
    select {
    case h.workerPool <- struct{}{}:
        // ...
    default:
        return fmt.Errorf("告警 worker pool 已满,丢弃本次评估,当前容量: %d", h.maxWorkers)
    }
}

丢了就丢了,由后续上报或持续条件兜底扫描补评。这是"宁可少评一次,不让背压打穿上报链路"的选择------评估失败的直接后果是漏一次告警,而阻塞的后果是整条链路降级。

十三、刻意留着没动的一处

收敛做完,链路里还剩一处明确没有覆盖 的旧逻辑:pkg/dcache/deviceCacheData 的最新值缓存,和 internal/logic/analysis 的分析页,仍然会对 Redis 日志列表里的 Content 做一次反解析。

现在它还能用,纯属巧合------直连路径的 Raw 恰好就是标准信封 JSON,能被解析器认出来。一旦 Raw 换成非信封形态(比如某类协议插件的原文),这两处会静默丢数据:解析返回空,被当成"已解析成功但没匹配",既不报错也不跳过。

这一处没有在这次收敛里解决,是因为它属于"读侧的历史包袱",改动面比写侧大。把它列出来而不是假装不存在,是因为它正好说明了这次收敛的边界:能保证的是四个已知消费者语义一致,不能保证的是所有下游都不再自己反解一遍。

十四、这次收敛不做什么

  • 不改变 MQTT / Poller / Tunnel 三路径汇聚的模型,只是把汇聚之后的处理收紧。
  • 不强制影子在 Redis 里的文档改成全新 schema,落盘仍然兼容 {time, value}。
  • 不改前端日志列表去展示 Canonical------认定值看影子、时序库和曲线,日志列表保持它原本的用途。
  • 不用 GOGC / GOMEMLIMIT 来替代这套改造。调 GC 参数能让堆看起来平稳,但热路径上该转四遍还是四遍。

这条链路的改造没有引入新概念,做的是把已有的概念钉死:一个值在扇出前只归一一次;解析状态用字段表达而不是用 nil 表达;告警只认认定值;日志内容属于哪一类写进枚举;谁拥有那块内存由类型负责声明。剩下的都是这些约束自然带来的结果------包括从 67 降到 37 的那部分分配。

项目地址:

主仓库:https://github.com/sagoo-cloud

文档站:https://iotdoc.sagoo.cn

相关推荐
miofly1 小时前
GitHub 周榜趋势速报 | 2026-10-11
开源·github
miofly1 小时前
GitHub 日榜趋势速报 | 2026-10-11
开源·github
乐橙开放平台1 小时前
通道国标 ID 贴反会串店:海康大华宇视摄像头怎么用 GB28181 统一接入?
网络·笔记·物联网·自动化·音视频·智能家居
魔众2 小时前
ModStartBlog v11.3.0 安全加固、体验优化,自动化测试更强大
开源·php·laravel
DevUp2 小时前
Directus 换掉 GPL 之后,Node.js CMS 到底怎么选?(三)
开源·node.js·cms
tachibana22 小时前
Golang 中数组和切片的区别?
开发语言·后端·golang·数组·切片
辛迪聊物业数字化2 小时前
物业管理系统新手部署与实操指南
数据库·物联网·架构·系统架构
沐欣工作室_lvyiyi3 小时前
基于物联网的汽车智能座舱控制系统的设计和实现(论文+源码)
单片机·物联网·汽车·智能座舱·哈尔滨信息工程学院·黄河科技学院
枫叶丹43 小时前
语音 AI 怎样边听边答:实时对话系统的工作原理
开发语言·人工智能·chatgpt·开源·php·agent·codex