Day 35 Hard IoT MQTT Edge Time-Series DB

物联网与边缘 — 千万设备每 10 秒一次心跳的吞吐工程IoT & Edge: Ingestion at Scale, MQTT, Edge Compute, Time-Series Storage

问题场景 + 需求约束

设计一个 1000 万台联网设备的遥测后端(电动车队 / 工业传感器 / 智能电表):每台设备每 10 秒上报一次电池、温度、GPS、电流等 20 个指标。这类系统和请求-响应 Web 后端是两种物种——写远多于读(99% 是写入),连接是长连接而非短请求,网络不可靠(车进隧道、传感器断电),且数据一旦写入几乎不改

高层架构

graph LR
    D["设备 / 传感器
千万级"] EG["边缘网关
过滤 · 聚合 · 本地告警"] BR["MQTT Broker 集群
EMQX · 百万连接/节点"] K["Kafka 缓冲
削峰 · 解耦"] SP["流处理
Flink · 去重/降采样"] TSDB[("时序 DB
热数据 · 压缩")] OBJ[("对象存储
冷数据 · Parquet")] AL["告警 / 仪表盘"] D -->|MQTT| EG -->|MQTT/QoS| BR BR --> K --> SP SP --> TSDB SP --> OBJ TSDB --> AL classDef dev fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef edge fill:#0e2030,stroke:#5eead4,color:#e8eef5 classDef pipe fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef store fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 class D dev class EG,BR edge class K,SP pipe class TSDB,OBJ,AL store

四段职责分明:边缘减量、broker 收连接、Kafka 削峰解耦、TSDB 压缩存储。任何一段直连下游都会被流量打爆。

核心思路是层层减量、层层解耦。边缘网关把原始采样本地聚合成摘要,砍 90% 回传带宽;MQTT broker 专职维持海量长连接、做 QoS 投递;Kafka 是关键的削峰蓄水池——broker 输出抖动剧烈(早高峰 3 倍)而 TSDB 写入恒定,Kafka 吸收差值;流处理做去重、乱序重排、降采样后落库。

关键技术点

1. 设备协议:MQTT vs HTTP vs CoAP

1 句话:长连接 + pub/sub 的 MQTT 用连接复用有状态服务器,是海量低功耗设备的默认选择。

原理:HTTP 是请求-响应 + 每次新建 TCP/TLS,对每 10 秒发一个 2 字节温度的设备,握手开销(几 KB)是 payload 的上千倍,还无法服务端主动推送(要远程关阀门只能让设备轮询)。MQTT 是 OASIS 标准的发布-订阅协议:设备与 broker 建一条长连接,按 topic(如 fleet/car42/battery)发布;header 最小仅 2 字节;支持服务端主动下发、遗嘱消息(Last Will,设备掉线自动通知)、保留消息。代价是 broker 必须有状态地维持千万连接与每设备的会话。

选型 Trade-off:
# MQTT QoS:投递保证分三级,按数据重要性选
# QoS 0 (at-most-once):发了就忘,丢了不重传 —— 高频温度采样
# QoS 1 (at-least-once):收到 PUBACK 才算成功,可能重复 —— 普通事件
# QoS 2 (exactly-once):四次握手 PUBREC/PUBREL/PUBCOMP,不重不漏 —— 计费/告警
client.publish("fleet/car42/temp",  payload, qos=0)   # 丢一个无所谓
client.publish("fleet/car42/alert", payload, qos=1)   # 至少一次 + 下游幂等去重
# ⚠️ QoS 2 最贵(双倍往返 + broker 存状态),千万设备别全用 2
现实案例:

2. 边缘计算:在数据产生地就近处理

1 句话:边缘用本地算力回传带宽 + 响应延迟,但引入运维与一致性难题。

原理:不是所有数据都值得回传云端。一台车每秒产生 MB 级传感器流,但云端真正需要的是摘要(每分钟均值)和异常(温度超阈值)。边缘网关(车载计算单元 / 工厂网关)在本地做三件事:① 过滤降采样——20 个原始采样聚合成 1 条;② 本地决策——过热立即断电,不等云端 200ms 往返;③ 断网缓冲——车进隧道时本地存盘,重连后补传。这把"云端集中处理"变成"边缘-云协同"。

选型 Trade-off:
# 边缘网关:本地聚合 + 异常直传 + 断网缓冲(伪代码)
buf = RingBuffer(maxlen=10000)          # 本地缓冲,断网时不丢
def on_sample(s):
    if s.value > THRESHOLD:             # 异常:本地立即动作 + 高 QoS 上报
        actuator.shutdown()             # 不等云端,<100ms 急停
        publish(s, qos=1)
    window.add(s)
    if window.full():                   # 正常:每分钟聚合一条,砍 90% 带宽
        agg = window.summary()          # min/max/avg/p95
        if online: publish(agg, qos=0)
        else:      buf.append(agg)       # 断网先存,重连补传
现实案例:

3. 摄取管道:削峰、解耦、背压

1 句话:broker 和 TSDB 之间必须有 Kafka 这层缓冲,否则写入抖动会反压击穿整条链路。

原理:摄取的本质矛盾是输入抖动(早高峰车队同时上线,瞬时 3 倍)vs 下游恒定吞吐(TSDB 写入能力固定)。若 broker 直写 TSDB,峰值时 TSDB 写满 → 反压到 broker → broker 连接堆积 → 设备重连风暴 → 雪崩。Kafka 作蓄水池:broker 全速写 Kafka(顺序写,极快),消费者按 TSDB 能承受的速率拉取。Kafka 还提供解耦——同一份数据可被告警、TSDB 落库、ML 训练多个消费组独立消费,互不影响。分区键用 device_id 保证同设备数据有序。

选型 Trade-off:
现实案例:

4. 时序数据库:压缩、降采样、基数爆炸

1 句话:时序场景的特殊性(写多改少、时间递增、数值相邻相似)让专用压缩比通用 DB 省 10 倍存储。

原理:遥测数据有强规律——时间戳近似等差相邻数值变化小(温度不会从 25 跳到 5000)。Facebook 的 Gorilla(VLDB 2015)利用这两点:时间戳用 delta-of-delta(存二阶差,等间隔采样的差几乎全是 0),数值用 XOR 压缩(相邻浮点 XOR 后高位全 0,只存有效位),把每个 point 从 16 字节压到平均 1.37 字节(约 12 倍),从而能全内存存储、查询提速数十倍。TimescaleDB 走另一条路:基于 Postgres,用 hypertable 按时间自动切 chunk,旧 chunk 转列存压缩(约 10 倍),保留完整 SQL 生态。

TimescaleDBInfluxDBPrometheus
模型Postgres 扩展 (SQL)专用 TSDB (列存/Parquet)拉模型 (pull)
写入高,靠 chunk 分区极高吞吐中(监控量级)
压缩列存 ~10x列存 + 编码delta+XOR (类 Gorilla)
适合要 SQL/JOIN/关系数据纯时序、超大规模基础设施监控
-- TimescaleDB:建 hypertable + 自动压缩策略
SELECT create_hypertable('telemetry', 'ts',
       chunk_time_interval => INTERVAL '1 day');
-- 7 天前的 chunk 自动列存压缩(约 10x)
ALTER TABLE telemetry SET (timescaledb.compress,
       timescaledb.compress_segmentby = 'device_id');
SELECT add_compression_policy('telemetry', INTERVAL '7 days');
-- 连续聚合:预降采样,仪表盘查的是分钟级而非原始秒级
SELECT add_retention_policy('telemetry', INTERVAL '90 days'); -- 冷数据归档
基数爆炸(Cardinality Explosion):时序 DB 的头号杀手。每个唯一的 tag 组合(device_id × metric × region)是一条独立 series,索引在内存。1000 万设备 × 20 指标 = 2 亿 series,内存索引直接撑爆。解法:别把高基数字段(如 trip_id、随机 UUID)塞进 tag/label,放 field;用设备分桶聚合。InfluxDB 早期版本就因基数问题臭名昭著。
现实案例:

扩展与优化

常见陷阱 + 面试追问

1. 全用 QoS 2:图省心给所有消息上 exactly-once,broker 要为每条消息存四次握手状态,千万设备直接压垮。按数据价值分级是基本功。
2. 高基数 tag:把 trip_id/时间戳/随机 ID 当 tag,series 数量爆炸,TSDB 内存 OOM。这是面试 TSDB 必问的坑。
3. broker 直写 DB 无缓冲:忘了 Kafka 削峰,早高峰反压雪崩。面试官爱问"流量突增 3 倍怎么办"。
4. 忽略时钟与乱序:设备时钟不准、断网补传导致数据乱序到达。要用 event-time + watermark(见 Day 20),不能按到达顺序写。
5. 重连风暴(Thundering Herd):broker 重启或网络抖动,千万设备同时重连握手把集群打死。重连必须带随机退避 + jitter
6. 没区分"丢得起"与"丢不起":温度采样可丢,告警/计费不可丢。下游消费必须幂等(用 device_id + ts 去重)。

深入资源

深入思考(点击展开答案)

1. 2000 万 points/s、压缩后 3.5TB/天,但客户要求"任意设备任意时刻可秒级回查"。全留热存放不下,全归档查不动,怎么设计?

核心是分层 + 预聚合,按访问模式而非"全都要"设计:热层(7 天,SSD)留原始秒级,命中 95% 查询;温层(7-90 天,压缩 chunk)只留分钟级降采样,体量缩 60 倍;冷层(90 天+,S3 Parquet)原始秒级归档。

"任意设备秒级回查"的关键是分区裁剪:冷层按 device_id 哈希分桶 + 按天分区,查单设备单日只扫一个小 Parquet 文件,秒级返回。代价是跨设备聚合慢(要扫多文件),但那是分析查询,可接受分钟级。本质:原始数据冷归档 + 聚合数据热常驻,把成本和延迟解耦。

2. 一个城市突发大停电,几十万智能电表同时断网又同时恢复供电重连。会发生什么? 怎么防?

会发生重连风暴 + 补传洪峰的双重打击:

  • 重连风暴:几十万设备在供电恢复的同一秒发起 TCP+TLS 握手,broker CPU(TLS 握手极耗 CPU)瞬间打满,握手排队超时 → 设备重试 → 雪上加霜。
  • 补传洪峰:每台设备断网期间本地缓冲了 N 条数据,重连后一次性全推,瞬时写入是平时的几十倍,击穿 Kafka 下游。

防御:客户端随机退避——重连延迟 = base × 2^n + random(0, jitter),把同时重连摊到几分钟窗口(固件层必做);② broker 接纳限速——连接层令牌桶,超速握手先拒绝以保护已建连接;③ 补传限速 + 错峰,靠 Kafka 削峰吸收洪峰;④ 优雅降级——洪峰期丢 QoS 0 历史采样,只保 QoS 1 关键事件。

这就是 Day 23"重试必带 jitter"在 IoT 的放大版——设备数量让任何同步行为都变成 DDoS。

3. 为什么 MQTT broker 难水平扩展,而无状态 HTTP 服务加机器就行? 加一个 broker 节点要解决什么?

根因是 broker 有状态:它要为每个连接维持 TCP 长连接、会话状态、订阅关系、QoS 1/2 的未确认消息。HTTP 无状态,任何请求路由到任何节点都一样,加机器即扩容。broker 加节点要解决:

  • 连接归属:设备 A 连在节点 1,要发消息给订阅了同 topic 的设备 B(连在节点 3),节点间必须路由转发——需要集群内的 topic 订阅表共享(EMQX 用分布式路由表)。
  • 会话迁移:节点 1 挂了,A 重连到节点 2,它的 persistent session(离线消息、订阅)要能恢复——需要会话状态外置或集群复制。
  • 负载均衡的粘性:不能简单轮询,同一设备最好稳定落同一节点(连接是长期的),常用一致性哈希。

这正是 EMQX 选 Erlang/OTP 的原因——其轻量进程(每连接一进程,开销几 KB)+ 内置分布式(节点间透明消息传递)天生为"海量有状态长连接"设计。换成线程模型的语言,百万连接的上下文切换就先把自己拖垮了。

4. 边缘网关本地跑了"温度 > 80 就断电"的规则,现在要把阈值改成 75。这个看似一行的改动,在千万设备规模下为什么是难题?

因为这是分布式配置在数万边缘节点的灰度下发问题,不是改一行代码:

  • 设备常离线:下发不能假设全在线,要用 device shadow——把"期望阈值=75"存云端影子,设备上线时拉取对齐。
  • 不能一次全推:千万设备同时拉新配置 = 自造请求风暴,必须灰度(1% → 观察 → 放量)且可回滚。
  • 安全逻辑要 fail-safe:下发失败或配置损坏时回退到安全默认值(宁可误停不漏停),不能因拿不到配置就不保护。

本质:边缘把"一处部署"换成"N 处部署",运维从中心化的易更新退化成分布式的灰度/一致性/回滚——这是边缘最大的隐性成本。

5. 设备时钟普遍不准(廉价 RTC 漂移、断网补传),数据"事件时间"乱序到达。直接按到达顺序写 TSDB 会错在哪? 怎么办?

按到达顺序(processing-time)写的错误:

  • 聚合窗口算错:一台车断网 1 小时后补传,这批"1 小时前"的数据若算进当前分钟窗口,会让当前均值被历史值污染,趋势图出现假尖峰。
  • 降采样错位:连续聚合按到达时间分桶,同一真实时刻的数据散落在不同桶,回查时间线错乱。
  • 告警误判:迟到的旧"过热"事件被当成实时告警,触发已经无意义的动作。

正解(见 Day 20 流处理): ① 用 event-time(设备打的时间戳)而非到达时间分窗;② Watermark 容忍乱序——定义"允许迟到 N 分钟",窗口等 watermark 越过才闭合,超期迟到数据走侧输出单独修正;③ 设备定期 NTP 对时校正漂移;④ 用 device_id + event_ts 做主键幂等去重,补传重复时覆盖而非追加。

核心认知:IoT 的"时间"有两个——发生时间和到达时间,可能差几小时。聚合/告警/计费都必须以发生时间为准。