读数是 1008,用电量是 8:SagooIoT 计算服务的增量口径与七种计算策略

一块智能电表挂在配电箱里,其实只会做一件事:把"到此刻为止累计用了多少度"这个数字报上来。它不回答"今天用了多少"------那个数不在表里,它躲在两次读数之间。抄表员月末来一趟,记下月初的 1000.0 和月末的 1008.0,做个减法,得到 8.0 度。整条链路里最关键的一步不是采集,而是"拿哪个数当基准"。

把这个动作放大到几万台设备、每天几亿条报文,它就会变形:读数只涨不跌,用量却是差值;差值依赖一个基准点,而基准点会过期、会被补报的数据越过、会在设备换表或清零那一刻突然翻脸。这一堆全是"算"的问题,可没人愿意把同一套算法在采集、存储、报表三个地方各写一遍。

SagooIoT 把它单独抠了出来,做成一个组件:计算服务(pkg/computeSrv)。对外只有一个入口,里面是七种策略。这篇文章就讲它长什么样,以及那个最容易被忽略的东西------基准时间。

一、先把三个词分开:读数、增量、累计

聊计算服务之前,得先把三个经常被混着用的词摆平:

  • 读数(reading):表盘上那个只增不减的绝对值,设备上报的原始数据。
  • 增量(delta):一段时间内新增的部分,也就是真正要的"用量"。
  • 累计(cumulative):基准加上增量之后的总读数,对账、抄表要的是它。

用同一块表说清楚:

概念 电表上的含义 示例 谁在用
读数 表盘当前位置 1008.0 kWh 设备上报,绝对值
增量 本周期实际用电 8.0 kWh 报表、计费
累计 到此刻的总读数 1008.0 kWh 抄表、对账

一句话:读数不是用量,用量是两个读数之间的那道缝。 而"缝有多宽",取决于你把哪一端当基准。基准选错,后面全错。

二、一个入口、七种策略:计算服务长什么样

ComputeService 这个接口只做六件事------执行计算、注册策略、列出策略,再加上流式计算的三件套(注册、注销、枚举):

复制代码
type ComputeService interface {
    Compute(ctx context.Context, params map[string]interface{}, strategy string, options ...ComputeOption) (interface{}, error)
    RegisterStrategy(name string, strategy ComputeStrategy) error
    GetAvailableStrategies() []string
    RegisterStreamComputation(name string, params map[string]interface{}) error
    UnregisterStreamComputation(name string) error
    GetStreamComputations() []string
}

每种策略都实现同一个接口 ComputeStrategy:Execute 干活,Validate 在干活之前先拦一道,GetName / GetDescription 自报家门。

复制代码
type ComputeStrategy interface {
    Execute(ctx context.Context, params map[string]interface{}, options map[string]interface{}) (interface{}, error)
    Validate(params map[string]interface{}) error
    GetName() string
    GetDescription() string
}

服务初始化时,默认注册了七种:

复制代码
func (s *computeService) registerDefaultStrategies() {
    s.RegisterStrategy("aggregation", NewAggregationStrategy())
    s.RegisterStrategy("accumulation", NewAccumulationStrategy())
    s.RegisterStrategy("statistical", NewStatisticalStrategy())
    s.RegisterStrategy("mathematical", NewMathematicalStrategy())
    s.RegisterStrategy("conditional", NewConditionalStrategy())
    s.RegisterStrategy("stream_computation", NewStreamComputationStrategy())
    s.RegisterStrategy("incremental_computation", NewIncrementalComputationStrategy())
}

七种策略各管一段,分工大致是这样:

策略名 干什么 关键参数 返回
aggregation 求和/均值/极值/计数 data、method、field、groupBy 单值或分组 map
accumulation 按时间范围/间隔累计 data、field、timeRange、interval 总量或分区间 map
statistical 方差/标准差/分布 data、method、field 指标或分布表
mathematical 公式计算 formula、variables 数值
conditional 条件分支取值 conditions、value 命中的 result
incremental_computation 增量与累计换算 baseValue、newData、operation 数值
stream_computation 窗口/触发/连续 dataStream、operation 窗口结果或注册态

Compute 的执行路径是固定的五步,值得单独看一眼:

复制代码
// 1. 找策略,找不到直接 ErrStrategyNotFound
strategyImpl, exists := s.strategies[strategy]
if !exists {
    return nil, fmt.Errorf("%w: %s", ErrStrategyNotFound, strategy)
}

// 2. 先验证参数,不合法就 ErrInvalidParams
if err := strategyImpl.Validate(params); err != nil {
    return nil, fmt.Errorf("%w: %v", ErrInvalidParams, err)
}

// 3. 合并默认选项与本次选项,取超时时间(默认 30s)
timeout, ok := computeOptions["timeout"].(time.Duration)
if !ok {
    timeout = 30 * time.Second
}
computeCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

// 4. 执行
result, err := strategyImpl.Execute(computeCtx, params, computeOptions)

// 5. 超时归一成 ErrTimeout,其余归一成 ErrComputeFailed
if err == context.DeadlineExceeded {
    return nil, ErrTimeout
}

这个顺序带来两个好处。校验前置 :坏参数在真正开始算之前就被挡回去,不用等跑完一轮才发现少传了字段。超时统一 :不管哪个策略跑飞了,最后都收敛成同一个 ErrTimeout,调用方不用为每种策略写一套兜底逻辑。

服务内部用一把 sync.RWMutex 保护策略表,读多写少,所以 Compute 本身是并发安全的,可以从多个 goroutine 同时调。

三、聚合与累计:把一堆点收成一个数

先说最直观的两个。

聚合(aggregation) 处理的是"一堆同质的点,要一个数"。方法只有五个:sum、avg、max、min、count。它支持 filter 先过滤、groupBy 再分组:

复制代码
params := map[string]interface{}{
    "data":    deviceStatusLogs, // 设备状态日志
    "method":  "avg",
    "field":   "online",         // 1 在线、0 离线
    "groupBy": "deviceId",
}
result, _ := service.Compute(ctx, params, "aggregation")
// 结果形如 {"device001": 0.99, "device002": 0.95}

这里有个容易被忽略的细节:method: "avg" 加上 online 这个 0/1 字段,算出来的就是在线率。不用单独写一个"在线率算法",因为它本来就是"对 0/1 序列求均值"。这也是这套设计的思路------把业务指标翻译成通用算子,而不是为每个指标写一份代码。

累计(accumulation) 处理的是"一堆带时间戳的点,按时间范围收口"。它必须拿到 timeRange.start 和 timeRange.end,interval 可选:

复制代码
params := map[string]interface{}{
    "data":  gasReadings,
    "field": "gas_consumption",
    "timeRange": map[string]interface{}{
        "start": "2025-05-01T00:00:00Z",
        "end":   "2025-05-31T23:59:59Z",
    },
    "interval": "24h", // 按天汇总
    "filter":   map[string]interface{}{"meterId": "gas_meter_001"},
}
result, _ := service.Compute(ctx, params, "accumulation")

内部顺序是固定的:先按时间排序,再按 timeRange 裁剪,最后要么汇总成一个总量,要么按 interval 切成若干个区间。排序这一步不能省 ------设备补报的数据常常是乱序到达的,不排序就按 interval 切,会把同一个时间桶算碎。

四、统计与数学:方差、评分,和一个手写的表达式计算器

统计(statistical) 只有三个方法:variance(方差)、stddev(标准差)、distribution(分布)。分布又分两种:percentile 和 histogram,默认走百分位,分位点默认取 [0, 25, 50, 75, 100],也就是最小值、四分位、中位数、上四分位、最大值。

值得一提的是"标准差怎么来"------它没有直接公式,而是先算方差,再开根号:

复制代码
variance, err := s.calculateVariance(values)
if err != nil {
    return nil, err
}
return math.Sqrt(variance), nil

这种"最小公倍数"式的复用,在这个模块里出现得很多。好处是口径只有一个来源,改方差,标准差自动跟着变。

数学(mathematical) 是七种里最"朴素"的一个。给它一个公式和一组变量:

复制代码
params := map[string]interface{}{
    "formula": "(1 - packetLoss) * (1 - 0.5 * latency / maxLatency) * 100",
    "variables": map[string]interface{}{
        "packetLoss": 0.02,
        "latency":    150,
        "maxLatency": 1000,
    },
}
result, _ := service.Compute(ctx, params, "mathematical")
// 结果:92.5,一个通信质量评分

它内部的做法是字符串替换 + 手写表达式求值 :先把 variables 里的每个变量名在公式里替换成字面量,再用一个递归下降的解析器从内层括号往外算。这个实现很轻,代价是它只认四则运算和括号,不认函数 。设计文档里那个 max(pm25Score, pm10Score, ...) 的 AQI 例子,按当前实现是算不出来的------真要用,得先在外面把各个分指数算好,再传进来比大小。这一点后面还会再提一次。

五、条件计算:把 if-else 也收进策略

条件(conditional) 把常见的"分段取标签"逻辑收进来了:

复制代码
params := map[string]interface{}{
    "conditions": []interface{}{
        map[string]interface{}{"condition": "temperature >= 22 && temperature <= 26", "result": "comfortable"},
        map[string]interface{}{"condition": "temperature > 26 && temperature <= 30", "result": "slightly_uncomfortable"},
        map[string]interface{}{"condition": "temperature > 30 || temperature < 16", "result": "very_uncomfortable"},
        map[string]interface{}{"condition": "default", "result": "acceptable"},
    },
    "value": 25,
}
result, _ := service.Compute(ctx, params, "conditional")
// 结果:"comfortable"

条件是自上而下 逐条判断的,命中即返回,所以顺序就是优先级------这跟写一串 if-else if 是同一种心智。"default" 是个特殊标记,谁走到它谁兜底,通常放在最后。

这里有一个必须知道的边界:如果没有命中任何条件,也没有 default,返回的是 (nil, nil),不是错误。 也就是说调用方拿到 nil 时,分不清是"没匹配上"还是"算挂了"。用的时候要么保证条件表里带 default,要么在结果侧多包一层判断。

六、增量计算:基准值、基准时间,和那道"早于基准就丢"的截断

现在回到开头那块电表。incremental_computation 就是专门干这件事的,它把"基准 + 新增读数 → 用量"这条链固化成一个策略。参数是这样:

参数 必填 说明
baseValue 是 基准值,比如昨日总读数 1000.0
baseTimestamp 否 基准时间(RFC3339),早于它的数据会被忽略
newData 是 新增数据点,元素含时间戳与值
operation 是 add / subtract / replace
outputFormat 否 cumulative(默认)或 delta

它的执行分两步,第一步是解析并排序 ,第二步才是算。解析函数 parseNewData 对输入格式相当宽容,[]map[string]interface{}、[]interface{}、单个 map、单个 float64 都接得住:

复制代码
func (s *IncrementalComputationStrategy) parseNewData(newData interface{}) ([]TimeValuePair, error) {
    switch data := newData.(type) {
    case []map[string]interface{}: // 标准形态
    case []interface{}:            // JSON 反序列化后的常见形态
    case map[string]interface{}:   // 单点
    case float64:                  // 裸数值,用当前时间补时间戳
    default:                       // 兜底:gconv 试一把
    }
}

这一步顺带解释了为什么它要"宽容":策略多半是被 HTTP 接口或规则引擎调用的,参数从 JSON 进来,[]interface{} 和 []map[string]interface{} 长得几乎一样,但 Go 里的类型断言只认后者。把入口收宽一点,能省掉上游一堆转类型的胶水代码。

第二步是三种操作,语义差别不小,用一张表说清:

operation 对新增数据做的事 cumulative 输出 delta 输出
add 把晚于基准时间的值求和 基准值 + 求和 求和
subtract 把晚于基准时间的值求和 基准值 − 求和 求和
replace 只取最新的那个值 最新值 最新值 − 基准值

add 和 replace 的关键差别在这里:add 假设 newData 里装的是增量 (每一条都是"这一小时用了多少"),所以它累加;replace 假设装的是绝对读数(每一条都是"此刻表上是多少"),所以它只认最新那一条,用差值反推用量。

至于 baseTimestamp 那道截断,是整套逻辑里最需要留神的一处:

复制代码
deltaValue := 0.0
for _, pair := range timeValuePairs {
    if pair.Timestamp.After(baseTime) { // 只有严格晚于基准时间的数据才参与
        deltaValue += pair.Value
    }
}

注意是 After,也就是等于基准时间的那条也会被丢 。如果基准从昨天的日报表取,时间是 00:00:00,而这一批补报的数据刚好也有一条落在 00:00:00,它就会被无声地剔除。设备本身没问题,表也没问题,用量就是少了一点------而且是每次都对不上一点点,最难查的那种。

基准时间没给的时候,默认取"当前时间往前 24 小时"。这是有意的:不给基准,就等同于"算最近一天",比"从零开始"安全得多。

七、流式计算:窗口、触发、连续

前面六种都是"你给数据、它算一次"的批处理。stream_computation 是唯一一个有状态的------它注册一次,之后持续吃数据。

它支持三种操作:

  • window :开窗口做聚合。窗口类型有 sliding(滑动)、tumbling(滚动)、session(会话),窗口大小用 Go 的 ParseDuration 语法(如 5m、1h),必须指定 aggregation 和 field。
  • trigger :按条件触发。触发条件有 value_change(值变化)、threshold(阈值)、periodic(周期)。选 threshold 要带 thresholds 阈值表,选 periodic 要带 interval。
  • continuous:持续计算,最简单的形态。

一个电表负荷监测的长这样:

复制代码
params := map[string]interface{}{
    "dataStream":      "electric_meter_001",
    "operation":       "window",
    "windowType":      "sliding",
    "windowSize":      "5m",
    "aggregation":     "avg",
    "field":           "power",
    "triggerCondition": map[string]interface{}{
        "operator": ">",
        "value":    5000,   // 功率阈值 W
        "duration": "3m",   // 持续 3 分钟才报
        "level":    "overload",
        "message":  "电力负荷过高",
    },
}
service.Compute(ctx, params, "stream_computation")

这个例子里有个舒服的地方:"平均功率连续 3 分钟超过 5000W"是一条完整的业务规则 ,但它没有被写成一个专门的"过载告警"策略,而是由「滑动窗口 + 均值聚合 + 阈值触发」三块拼出来。想改成"5 分钟"只改 windowSize,想改成"求和"只改 aggregation。这就是把窗口、聚合、触发拆成三个正交参数的好处。

注册这类流计算时,服务层面还有一层自动登记:当策略名是 stream_computation、且执行结果里 status == "registered" 时,Compute 会顺手把这条流记进服务自己的 streamComputes 表,后面可以用 GetStreamComputations 枚举、用 UnregisterStreamComputation 注销。这样就有一个统一的"当前挂了多少条流"的视角,而不是散在各个策略实例里。

八、几处我认为还没收口的地方

前面讲的都是设计意图,但读代码不能只读意图。挑三处我觉得还留着"手工活"的说说,用的人心里有数。

第一,文档承诺的字段名定制,增量策略没接住。 开发手册里写 incremental_computation 支持 timestampField 和 valueField,用来指定数据点里时间戳和值的键名。但 parseNewData 里硬编码的是 itemMap["timestamp"] 和 itemMap["value"],两个参数传进去也不起作用。对比之下,累计策略 accumulation 是接住了的------它会把时间戳字段在 timestamp 和 time 之间兜底。所以如果上游给的是 ts / reading 这种字段名,走增量策略时得先自己映射成 timestamp / value,否则会卡在"数据项缺少时间戳字段"上。

第二,结果缓存目前只有壳。 WithCache(true) 这个选项确实会把 use_cache 塞进 options,ComputeEngine 接口也定义了 Precalculate 和 GetCachedResult。但默认实现里,Precalculate 是个只 return nil 的存根,服务层也不会自动去查缓存、写缓存。也就是说"缓存"这条路径的接口已经留好了,真正把它接上还得自己动手。手册里也承认了这一点,没有藏着。

第三,数学策略的能力边界比设计文档窄。 前面提过,它只做四则运算加括号,没有函数支持。设计文档里那几个 max(...) 之类的公式示例,是按"有函数"的假设写的,落地时要拆成多步、或者在外面先算好再传进来。这不影响它当"公式计算器"用,但看文档的人容易高估它。

把这三处摆出来,不是挑刺。恰恰相反------一个模块敢把自己的默认实现边界写清楚,比一个看起来什么都支持的模块更可信。 因为你知道哪条路是铺好的,哪条路要自己走。

结语

回到最开始那块电表。它到今天还是只会报一个绝对读数,不回答"今天用了多少"。中间的差值,得由平台替它算------而且得算得可复现、可解释、能被翻出来对账。

SagooIoT 计算服务做的事,就是把这类"算"收进一个统一的入口:七种策略,一个 Compute 接口,参数不合适就挡在 Validate,跑太久就收敛成 ErrTimeout。它不追求大而全,而是把"读数→增量→累计"这条最常走的路走得干净,把窗口、聚合、触发拆成正交的参数,让一条业务规则能靠组合拼出来,而不是靠新加一个策略。

真正的难点其实不在这些策略本身,而在那个不起眼的 baseTimestamp:它决定了"这道缝从哪一端开始量"。 基准一错,后面所有的加法都是错的,而且错得很安静。

如果你也在处理电表、水表、气表这类"只涨不跌"的数据,可以先从增量计算这一节开始看------那是最容易踩坑、也最容易见效的一段。

项目地址

本文代码与设计细节来自 SagooIoT 专业版本地源码 pkg/computeSrv,以及随源码附带的《计算服务设计文档》《计算服务开发人员手册》。

相关推荐
看浪的路人3 小时前
第7讲:大模型后端服务化——Go 实现高并发 LLM Proxy / Router / Fallback
开发语言·后端·golang
星恒讯工业路由器3 小时前
从5G-A到6G:工业通信网络的技术演进与产业落地
网络·物联网·5g·智能路由器·工业路由器·工业物联网·5g-a
灯澜忆梦6 小时前
【Go Zap 日志库】#1 |快速上手 + 配置详解
开发语言·后端·golang
武汉唯众智创8 小时前
物联网安装调试员实训基地建设方案(2026版)设备清单 + 预算拆解 + 技能等级认定避坑指南
物联网·物联网安装调试员·物联网实训基地·实训室建设方案·职业技能等级认定·智慧物联网 / aiot
不是万能的豆学长8 小时前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go
LiuYanG8 小时前
物联网平台怎么给设备下发指令-SpringBoot下行命令与回执状态机实战
物联网
LiuYanG8 小时前
Spring Boot 单元测试怎么写?物联网平台 MockMvc 接口测试与 Mockito 消息链路测试实战(附 JaCoCo 覆盖率报告)
物联网
tachibana29 小时前
Go 语言 Map 的底层实现原理
开发语言·后端·golang·make
权球物联分享物联网连接服务9 小时前
AGV 机器人商业化落地验证,物联网卡保障产线交付稳定性!
物联网·机器人