一块智能电表挂在配电箱里,其实只会做一件事:把"到此刻为止累计用了多少度"这个数字报上来。它不回答"今天用了多少"------那个数不在表里,它躲在两次读数之间。抄表员月末来一趟,记下月初的 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:它决定了"这道缝从哪一端开始量"。 基准一错,后面所有的加法都是错的,而且错得很安静。
如果你也在处理电表、水表、气表这类"只涨不跌"的数据,可以先从增量计算这一节开始看------那是最容易踩坑、也最容易见效的一段。
项目地址
- 主仓库(开源组织):https://github.com/sagoo-cloud
- 社区版源码:https://github.com/orgs/sagoo-cloud/repositories
- 在线文档:https://iotdoc.sagoo.cn
本文代码与设计细节来自 SagooIoT 专业版本地源码 pkg/computeSrv,以及随源码附带的《计算服务设计文档》《计算服务开发人员手册》。