工业边缘会持续产生电压、电流、温度、频率、运行状态和告警事件。如果把这些原始数据全部上传,再在云端做批量统计,常见结果是:链路流量高、告警滞后、云端存储压力大,而且网络抖动时容易丢失观察窗口。
流式聚合的价值是在边缘侧把原始点流转换成可决策的指标,例如每分钟平均电压、每 5 秒最大电流、每小时的设备运行时长、一次连续越限的持续时间。它不是“少传几条数据”,而是把时序数据的口径、边界、状态和异常处理工程化。
本文按工业边缘场景拆解 Tumbling、Sliding、Session、Global 四类窗口,以及聚合算法、时间语义、状态管理、故障恢复和监控的落地做法。
一、先定义业务指标口径
写代码前,先把每个聚合指标的业务口径写清楚。否则窗口看起来能运行,报表和告警却会互相矛盾。
| 问题 | 需要明确的口径 |
|---|---|
| 时间字段用什么? | 设备采样时间、网关接收时间,还是平台入库时间 |
| 窗口边界如何定义? | 左闭右开,还是左闭右闭 |
| 空点如何处理? | 不补点、线性插值、前值填充,还是标记质量位 |
| 乱序数据如何处理? | 允许迟到多久,迟到后是否修正结果 |
| 聚合对象是什么? | 单测点、单设备、单站点,还是跨设备 |
| 输出语义是什么? | 窗口关闭输出、每次更新输出,还是定时输出 |
| 缺数据如何表达? | 不输出、输出空值,还是输出数据完整度 |
工业遥测里尤其要区分三类时间:
- 设备时间:传感器或逆变器产生数据的时间;
- 采集时间:边缘网关收到数据的时间;
- 处理时间:流式系统处理这条数据的时间。
统计设备运行指标时,通常应以设备时间为主;统计链路延迟或采集质量时,才使用采集时间或处理时间。三者混用,是窗口结果“差一分钟”的常见原因。
二、Tumbling Window:固定、不重叠
Tumbling Window 把时间轴切成连续、等长、不重叠的区间,例如 00:00:00 到 00:01:00、00:01:00 到 00:02:00。
00:00 ───────── 00:01 ───────── 00:02
│ window 1 │ window 2 │
它适合周期报表和固定粒度指标:
- 每分钟平均电压;
- 每 5 分钟有功功率总和;
- 每小时告警次数;
- 每天发电量;
- 每个班次的运行时长。
边界规则
推荐统一使用左闭右开:
[00:00:00, 00:01:00)
[00:01:00, 00:02:00)
这样 00:01:00 的数据只会属于第二个窗口。如果使用左闭右闭,边界点会被两个窗口同时计入,导致总和翻倍。
一个简化实现
from collections import defaultdict
from datetime import datetime, timezone
def window_start(ts: datetime, window_seconds: int) -> int:
epoch = int(ts.timestamp())
return epoch - epoch % window_seconds
class TumblingAverage:
def __init__(self, window_seconds: int):
self.window_seconds = window_seconds
self.state = defaultdict(lambda: {"sum": 0.0, "count": 0})
def add(self, device_id: str, value: float, ts: datetime):
start = window_start(ts, self.window_seconds)
bucket = self.state[(device_id, start)]
bucket["sum"] += value
bucket["count"] += 1
def result(self, device_id: str, start: int):
bucket = self.state.get((device_id, start))
if not bucket or bucket["count"] == 0:
return None
return {
"device_id": device_id,
"window_start": start,
"avg": bucket["sum"] / bucket["count"],
"count": bucket["count"],
}
这个实现只解释状态累积。生产系统还需要处理窗口触发、迟到数据、状态 TTL、异常值和质量位,不能只依赖一个字典。
三、Sliding Window:固定长度、可重叠
Sliding Window 的窗口长度固定,但相邻窗口可以重叠。窗口由两个参数决定:
- length:窗口覆盖多久;
- slide:窗口每隔多久向前移动一次。
例如窗口长度 10 秒,滑动步长 2 秒:
00:00 ─────────────── 00:10
│ 10s window │
00:02 ─────────────── 00:12
│ 10s window │
00:04 ─────────────── 00:14
│ 10s window │
它适合平滑趋势和短时越限判断:
- 最近 10 秒平均温度;
- 最近 30 秒电流波动率;
- 最近 1 分钟功率变化率;
- 最近 5 秒通信错误率。
与 Tumbling 的区别
| 项目 | Tumbling Window | Sliding Window |
|---|---|---|
| 窗口是否重叠 | 不重叠 | 可重叠 |
| 每条数据归属 | 一个窗口 | 可能属于多个窗口 |
| 输出频率 | 每个窗口关闭一次 | 每个 slide 都可能输出 |
| 计算和状态成本 | 较低 | 较高 |
| 典型用途 | 报表、账务、固定周期统计 | 趋势、平滑、短时异常检测 |
如果 length = slide,Sliding Window 会退化为 Tumbling Window。如果 slide < length,同一条数据会参与多个窗口,聚合结果不能简单相加成总量。
参数选择
不要只凭“曲线好看”调参数。应结合采样周期、告警响应时间和抖动特性:
| 目标 | 常见做法 |
|---|---|
| 滤掉单点毛刺 | 窗口长度覆盖多个采样点 |
| 告警不能太慢 | slide 小于最大可接受响应延迟 |
| 避免告警抖动 | 加持续时间条件或迟滞阈值 |
| 控制资源 | 限制 slide 与 length 的比值 |
例如采样周期 1 秒,告警要求 5 秒内响应,可以先用“长度 10 秒、步长 2 秒”观察连续越限,而不是对每个单点直接告警。
四、Session Window:按活动间隔切分
Session Window 不是按固定时间切开,而是把间隔小于 gap 的数据归入同一会话;一旦间隔超过 gap,当前窗口关闭。
event: A A A B B C C C C
time: ──┴─┴─┴──────────┴─┴────────────┴─┴─┴─┴──
gap: 3s 3s 3s
window: session 1 session 2 session 3
它适合事件驱动的行为统计:
- 一次连续越限的持续时长;
- 一次通信中断后的恢复过程;
- 一次设备启停循环;
- 一次维护作业中的操作序列;
- 一批短时告警的聚簇分析。
gap 不是采样周期
Session gap 应该来自业务间隔,而不是采集协议周期。设备可能 1 秒采样一次,但允许 5 秒无数据;告警可能连续出现 100 毫秒一次,但 2 秒无新告警就算一次事件结束。
建议分别定义:
device_sample_interval = 1s
network_allowance = 5s
session_gap = 3s
如果设备本身有质量位或运行状态位,优先用状态变化识别会话边界,再用 session gap 作为兜底。
会话窗口会合并
这是它与固定窗口的关键差异:一个新事件落在前一个窗口 gap 范围内时,两个窗口可能合并成更大的窗口。
例如 gap 为 10 秒:
事件 A:00:00
事件 B:00:08
事件 C:00:16
严格来说,A 与 B 的间隔为 8 秒,B 与 C 的间隔为 8 秒,因此 A、B、C 最终都属于同一个 session。若只按“A 和 C 间隔 16 秒”判断,就会错误拆分。
适合和不适合的场景
| 适合 | 不适合 |
|---|---|
| 连续越限时长 | 固定班时报表 |
| 单次故障过程 | 月度发电量 |
| 点击、操作、告警聚簇 | 必须对账的电量累计 |
| 事件之间有明确业务间隔 | 需要稳定时间片输出的指标 |
五、Global Window:全局状态加触发器
Global Window 把所有数据放在同一个逻辑窗口中,通常配合触发器、 TTL 或淘汰规则使用,例如:
- 设备从启动到当前的累计运行时长;
- 当天累计告警次数;
- 最近 N 条数据的滚动指标;
- 一个工单生命周期内的累计处理量。
它不是“无限累积而不清理”的理由。长期 Global Window 必须明确:
- 什么时候输出;
- 什么时候重置;
- 什么时候淘汰;
- 内存上限是多少;
- 状态如何恢复。
例如“当天累计发电量”本质上是按自然日重置的 Global Window,而不是没有边界的全局状态。
from collections import deque
class RollingWindow:
def __init__(self, max_points: int):
self.values = deque(maxlen=max_points)
def add(self, value: float):
self.values.append(value)
@property
def avg(self):
return sum(self.values) / len(self.values) if self.values else None
这是按条数滚动,不是按事件时间滚动。若业务要求“最近 5 分钟”,必须用时间戳淘汰旧数据,否则设备停发数据后窗口会长期保留旧值。
六、精确聚合:从 SUM 到分位数
可增量聚合
COUNT、SUM、MIN、MAX、AVG 可以用有限状态增量计算:
class AverageAggregator:
def __init__(self):
self.sum = 0.0
self.count = 0
def add(self, value: float):
self.sum += value
self.count += 1
def merge(self, other: "AverageAggregator"):
self.sum += other.sum
self.count += other.count
def result(self):
return self.sum / self.count if self.count else None
merge 很重要。多线程、多分区或多边缘节点预聚合后,只有可合并状态才能正确汇总。
方差和标准差
方差不要先保存所有点再计算,可以使用 Welford 在线算法:
class OnlineVariance:
def __init__(self):
self.count = 0
self.mean = 0.0
self.m2 = 0.0
def add(self, value: float):
self.count += 1
delta = value - self.mean
self.mean += delta / self.count
self.m2 += delta * (value - self.mean)
@property
def variance(self):
if self.count < 2:
return None
return self.m2 / (self.count - 1)
分位数
p50、p95、p99 通常不能只靠 sum 和 count 得到。可选方案有:
| 方案 | 精度 | 内存 | 适用 |
|---|---|---|---|
| 排序全量数据 | 精确 | 高 | 小窗口、离线核对 |
| P²、t-digest、KLL sketch | 近似 | 低 | 大流量、边缘侧实时分位数 |
| 直方图桶 | 桶内近似 | 可控 | 已知取值范围和告警阈值 |
工业现场常用直方图桶,因为电压、温度、电流的范围和阈值通常已知。先按业务定义桶边界,再统计每桶数量,比盲目追求精确分位数更实用。
七、近似算法:用可控误差换内存
近似算法适合高基数据流,但必须先接受误差和可解释性问题。
HyperLogLog:估计基数
HyperLogLog 用于估计“有多少个不同元素”,例如:
- 多少个不同设备上报过告警;
- 多少个不同测点出现质量异常;
- 多少个不同来源 IP 访问接口。
from datasketch import HyperLogLog
hll = HyperLogLog(p=12)
for device_id in device_ids:
hll.update(device_id.encode("utf-8"))
estimated_distinct_count = hll.count()
它不适合统计 TopK,也不能给出精确去重列表。若需要列表,应使用 Set、Roaring Bitmap 或外部状态存储。
Count-Min Sketch:估计频次
Count-Min Sketch 用于近似估计某个元素出现次数,内存远小于保存完整计数表:
from probables import CountMinSketch
cms = CountMinSketch(width=1000, depth=5)
for device_id in device_ids:
cms.add(device_id)
estimated_count = cms.check("inverter-01")
它只会高估,不会低估。适合找高频元素,不适合作为对账和计费的精确依据。
TopK 与去重的边界
原文中“TopK 使用 HyperLogLog”容易误导,应修正为:
| 需求 | 可选算法 |
|---|---|
| 精确 TopK | 堆、排序或窗口内完整状态 |
| 近似 TopK | Count-Min Sketch + 堆、SpaceSaving |
| 精确去重计数 | Set、数据库 distinct |
| 近似去重计数 | HyperLogLog |
| 大量位图交集 | Roaring Bitmap |
在告警聚合场景中,可以先用量化误差可接受的近似算法筛出候选,再对候选设备回查精确数据。
八、事件时间、水位线与迟到数据
处理时间与事件时间
处理时间实现简单,但受网络延迟、断线缓存、重启和队列积压影响。设备 23:59:58 产生的数据,可能在边缘重启后于 00:00:20 才被处理。如果按处理时间聚合,这条数据会进入第二天窗口。
事件时间按数据自身时间戳归属窗口,更适合业务统计,但必须处理乱序和迟到。
Watermark
Watermark 表示系统认为某个时间点之前的事件已经基本到齐。例如当前见到的最大事件时间为 12:00:30,允许乱序 5 秒,则 watermark 可以推进到 12:00:25。
当 watermark 超过窗口结束时间,窗口可以关闭并输出结果。
window: [12:00:00, 12:01:00)
watermark >= 12:01:00 -> close window
迟到数据处理策略
迟到数据不是异常,工业现场很常见。可选策略有:
| 策略 | 做法 | 适用 |
|---|---|---|
| 丢弃 | 记录计数和原因 | 对完整性要求不高的趋势指标 |
| 更新原窗口 | 重新计算并发送修正值 | 报表可变、下游支持 upsert |
| 写入侧流 | 单独落盘或转发补偿 | 对账、电量、计费 |
| 延长等待 | 增大乱序容忍 | 网络延迟稳定可控 |
| 边缘缓存 | 断线后按事件时间回放 | 采集链路不稳定 |
不要简单把 watermark 调得无限大。等待越久,告警越滞后,状态越重。趋势指标可以容忍少量误差,电量和结算类指标必须走补偿与对账。
九、Python 流式处理示例
1. Bytewax 的事件时间窗口
下面是 Bytewax 风格的伪代码,用来说明 input、key、event clock 和 tumbling window 的组织方式。实际项目应按所使用的 Bytewax 版本调整 API。
import json
from datetime import datetime, timedelta, timezone
from bytewax.dataflow import Dataflow
from bytewax import operators as op
from bytewax.connectors.kafka import KafkaSource
from bytewax.operators.windowing import EventClock, TumblingWindower
flow = Dataflow("voltage-average")
raw = op.input(
"kafka-input",
flow,
KafkaSource(
brokers=["kafka:9092"],
topics=["device-metrics"],
),
)
def parse(raw_item):
data = json.loads(raw_item.value)
return data["device_id"], {
"timestamp": datetime.fromtimestamp(
data["timestamp"],
tz=timezone.utc,
),
"voltage": float(data["voltage"]),
}
parsed = op.map("parse-json", raw, parse)
clock = EventClock(
ts_getter=lambda item: item["timestamp"],
wait_for_system_duration=timedelta(seconds=5),
)
windower = TumblingWindower(
length=timedelta(minutes=1),
align_to=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
def create_acc():
return {"sum": 0.0, "count": 0}
def update_acc(acc, item):
return {
"sum": acc["sum"] + item["voltage"],
"count": acc["count"] + 1,
}
averaged = op.windowing.fold_window(
"one-minute-average",
parsed,
clock,
windower,
create_acc,
update_acc,
)
2. 不引入框架的最小窗口器
网关侧如果只需要少量指标,可以先实现一个小的事件时间窗口器:
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta
@dataclass
class MetricState:
sum: float = 0.0
count: int = 0
last_event_time: datetime | None = None
class EventTimeTumblingAggregator:
def __init__(self, window_size: timedelta):
self.window_size = window_size
self.states = defaultdict(MetricState)
@staticmethod
def align(timestamp: datetime, window_size: timedelta) -> datetime:
epoch = int(timestamp.timestamp())
size = int(window_size.total_seconds())
return datetime.fromtimestamp(
epoch - epoch % size,
tz=timestamp.tzinfo,
)
def add(self, device_id: str, value: float, event_time: datetime):
start = self.align(event_time, self.window_size)
key = (device_id, start)
state = self.states[key]
state.sum += value
state.count += 1
state.last_event_time = event_time
return key
def result(self, device_id: str, start: datetime):
state = self.states.get((device_id, start))
if state is None or state.count == 0:
return None
return {
"device_id": device_id,
"window_start": start.isoformat(),
"avg": state.sum / state.count,
"sample_count": state.count,
}
这个实现便于验证口径,但生产使用还需要补齐触发器、水位线、迟到侧流、状态淘汰和 checkpoint。
十、状态管理与故障恢复
流式聚合的难点不在公式,而在状态。
状态从哪里来
一个窗口状态至少包含:
- 分区 key:站点、设备、测点;
- 窗口开始和结束时间;
- 聚合中间量;
- 已处理的事件数和字节数;
- 输出序列号或 watermark;
- 数据质量统计;
- 创建时间和最后更新时间。
Checkpoint 的基本原则
import json
import os
import tempfile
class FileCheckpoint:
def __init__(self, path: str):
self.path = path
def save(self, state: dict):
directory = os.path.dirname(self.path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".checkpoint-", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, separators=(",", ":"))
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, self.path)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
def load(self):
if not os.path.exists(self.path):
return {}
with open(self.path, encoding="utf-8") as f:
return json.load(f)
除了原子替换,还应确认:
- checkpoint 与输入 offset 是否一致;
- 恢复时是否重放已处理数据;
- 输出是否幂等;
- 状态 schema 是否有版本号;
- 磁盘写入失败时是否触发降级;
- checkpoint 文件是否定期校验和清理。
输出语义
| 语义 | 条件 | 常见问题 |
|---|---|---|
| At-most-once | 可能丢数据 | 只适合低价值趋势 |
| At-least-once | 输入重放、输出可能重复 | 需要下游幂等 |
| Effectively-once | 状态 checkpoint、输出事务或幂等写入 | 实现复杂 |
工业边缘常见做法是:本地聚合结果使用 (device_id, metric, window_start) 作为幂等 key,上游重放或程序重启后重复写入同一条窗口记录。
状态清理
必须为每个状态定义 TTL:
tumbling window: window_end + allowed_lateness
session window: session_end + allowed_lateness
global window: business_reset_time + retention
rolling window: last_event_time + max_idle
清理策略要可观测。状态只增不减时,应能通过监控发现,而不是等 OOM 后才知道。
十一、工业边缘的部署形态
边缘预聚合
边缘侧适合做:
- 秒级 / 分钟级窗口统计;
- 短时越限判断;
- 本地告警聚簇;
- 断线数据缓存;
- 原始数据降采样;
- 上行流量压缩。
不建议在资源受限的网关上做:
- 大范围跨站点全局排序;
- 长周期全量精确分位数;
- 无 TTL 的全局去重;
- 复杂跨设备关联分析。
这些能力应下沉到厂站服务器、集团平台或专用流处理集群。
分层聚合
设备 / 采集器
↓ 秒级原始指标
边缘网关
↓ 分钟级窗口结果 + 异常事件
厂站 / 区域平台
↓ 站点聚合 + 跨设备关联
云端 / 集团平台
↓ 报表、分析、对账
分层时必须统一时间戳、时区、窗口边界和指标版本。否则每层都能算出结果,但同一分钟的报表无法对上。
十二、监控与验收
流式聚合上线后,至少监控以下指标:
| 类别 | 指标 |
|---|---|
| 输入 | 输入速率、字节数、分区积压、解析失败率 |
| 时间 | 事件时间延迟、watermark 延迟、迟到事件数 |
| 窗口 | 窗口触发数、空窗口数、修正输出数 |
| 状态 | 状态大小、key 数量、TTL 命中率、checkpoint 耗时 |
| 输出 | 输出速率、失败数、重复数、下游确认延迟 |
| 质量 | 样本数、缺失率、质量位异常率 |
| 资源 | CPU、内存、磁盘、网络、GC 或运行时停顿 |
验收时不能只看平均值,还应包含:
- 乱序回放测试;
- 断网重连测试;
- 程序重启恢复测试;
- 时钟跳变测试;
- 窗口边界用例;
- 状态膨胀压测;
- 与离线批处理结果对账。
十三、常见坑与修正
坑 1:用处理时间统计业务指标
现象:网络恢复后,旧数据被算入新窗口。
修正:业务统计使用事件时间,并显式配置乱序容忍。
坑 2:窗口边界重复计算
现象:00:01:00 同时进入两个窗口,总量翻倍。
修正:统一左闭右开边界,并在测试中覆盖整点数据。
坑 3:把 session gap 当采样周期
现象:正常网络抖动把一次故障拆成多次事件。
修正:按业务允许的间隔定义 gap,或使用状态位识别边界。
坑 4:Global Window 无限增长
现象:运行数周后内存持续上涨。
修正:加触发器、重置规则、TTL 和状态上限。
坑 5:状态无法恢复
现象:进程重启后当前窗口结果归零。
修正:checkpoint 输入 offset 与聚合状态,输出使用幂等 key。
坑 6:近似算法当精确结果用
现象:HyperLogLog 或 Count-Min Sketch 的结果用于对账。
修正:近似算法用于筛选和观测,对账走精确数据或补偿流程。
十四、工程能力的选择建议
如果工业边缘运行时已经内置窗口聚合、事件时间水位、本地缓存、状态恢复和幂等输出,项目实施时就能把注意力放在指标口径和告警规则上,而不是每个站点重复造一套流处理组件。对多协议、多设备、长周期运行的场景,这类基础能力会直接影响上行流量、告警时效和运维成本。
TL;DR
工业边缘流式聚合的核心不是选择一个窗口函数,而是定义完整的指标口径。Tumbling Window 适合固定周期统计,Sliding Window 适合趋势和平滑,Session Window 适合事件过程,Global Window 必须配合触发器和 TTL。事件时间、watermark、迟到侧流、状态 checkpoint、幂等输出和监控,共同决定系统能否在现场长期稳定运行。近似算法适合高基数观测和候选筛选,对账和计费仍应使用精确或可补偿流程。

644

被折叠的 条评论
为什么被折叠?



