设计一个 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 句话:长连接 + pub/sub 的 MQTT 用连接复用换有状态服务器,是海量低功耗设备的默认选择。
原理:HTTP 是请求-响应 + 每次新建 TCP/TLS,对每 10 秒发一个 2 字节温度的设备,握手开销(几 KB)是 payload 的上千倍,还无法服务端主动推送(要远程关阀门只能让设备轮询)。MQTT 是 OASIS 标准的发布-订阅协议:设备与 broker 建一条长连接,按 topic(如 fleet/car42/battery)发布;header 最小仅 2 字节;支持服务端主动下发、遗嘱消息(Last Will,设备掉线自动通知)、保留消息。代价是 broker 必须有状态地维持千万连接与每设备的会话。
# 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
1 句话:边缘用本地算力换回传带宽 + 响应延迟,但引入运维与一致性难题。
原理:不是所有数据都值得回传云端。一台车每秒产生 MB 级传感器流,但云端真正需要的是摘要(每分钟均值)和异常(温度超阈值)。边缘网关(车载计算单元 / 工厂网关)在本地做三件事:① 过滤降采样——20 个原始采样聚合成 1 条;② 本地决策——过热立即断电,不等云端 200ms 往返;③ 断网缓冲——车进隧道时本地存盘,重连后补传。这把"云端集中处理"变成"边缘-云协同"。
# 边缘网关:本地聚合 + 异常直传 + 断网缓冲(伪代码)
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) # 断网先存,重连补传
1 句话:broker 和 TSDB 之间必须有 Kafka 这层缓冲,否则写入抖动会反压击穿整条链路。
原理:摄取的本质矛盾是输入抖动(早高峰车队同时上线,瞬时 3 倍)vs 下游恒定吞吐(TSDB 写入能力固定)。若 broker 直写 TSDB,峰值时 TSDB 写满 → 反压到 broker → broker 连接堆积 → 设备重连风暴 → 雪崩。Kafka 作蓄水池:broker 全速写 Kafka(顺序写,极快),消费者按 TSDB 能承受的速率拉取。Kafka 还提供解耦——同一份数据可被告警、TSDB 落库、ML 训练多个消费组独立消费,互不影响。分区键用 device_id 保证同设备数据有序。
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 生态。
| TimescaleDB | InfluxDB | Prometheus | |
|---|---|---|---|
| 模型 | 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'); -- 冷数据归档
device_id × metric × region)是一条独立 series,索引在内存。1000 万设备 × 20 指标 = 2 亿 series,内存索引直接撑爆。解法:别把高基数字段(如 trip_id、随机 UUID)塞进 tag/label,放 field;用设备分桶聚合。InfluxDB 早期版本就因基数问题臭名昭著。
trip_id/时间戳/随机 ID 当 tag,series 数量爆炸,TSDB 内存 OOM。这是面试 TSDB 必问的坑。device_id + ts 去重)。核心是分层 + 预聚合,按访问模式而非"全都要"设计:热层(7 天,SSD)留原始秒级,命中 95% 查询;温层(7-90 天,压缩 chunk)只留分钟级降采样,体量缩 60 倍;冷层(90 天+,S3 Parquet)原始秒级归档。
"任意设备秒级回查"的关键是分区裁剪:冷层按 device_id 哈希分桶 + 按天分区,查单设备单日只扫一个小 Parquet 文件,秒级返回。代价是跨设备聚合慢(要扫多文件),但那是分析查询,可接受分钟级。本质:原始数据冷归档 + 聚合数据热常驻,把成本和延迟解耦。
会发生重连风暴 + 补传洪峰的双重打击:
防御: ① 客户端随机退避——重连延迟 = base × 2^n + random(0, jitter),把同时重连摊到几分钟窗口(固件层必做);② broker 接纳限速——连接层令牌桶,超速握手先拒绝以保护已建连接;③ 补传限速 + 错峰,靠 Kafka 削峰吸收洪峰;④ 优雅降级——洪峰期丢 QoS 0 历史采样,只保 QoS 1 关键事件。
这就是 Day 23"重试必带 jitter"在 IoT 的放大版——设备数量让任何同步行为都变成 DDoS。
根因是 broker 有状态:它要为每个连接维持 TCP 长连接、会话状态、订阅关系、QoS 1/2 的未确认消息。HTTP 无状态,任何请求路由到任何节点都一样,加机器即扩容。broker 加节点要解决:
这正是 EMQX 选 Erlang/OTP 的原因——其轻量进程(每连接一进程,开销几 KB)+ 内置分布式(节点间透明消息传递)天生为"海量有状态长连接"设计。换成线程模型的语言,百万连接的上下文切换就先把自己拖垮了。
因为这是分布式配置在数万边缘节点的灰度下发问题,不是改一行代码:
本质:边缘把"一处部署"换成"N 处部署",运维从中心化的易更新退化成分布式的灰度/一致性/回滚——这是边缘最大的隐性成本。
按到达顺序(processing-time)写的错误:
正解(见 Day 20 流处理): ① 用 event-time(设备打的时间戳)而非到达时间分窗;② Watermark 容忍乱序——定义"允许迟到 N 分钟",窗口等 watermark 越过才闭合,超期迟到数据走侧输出单独修正;③ 设备定期 NTP 对时校正漂移;④ 用 device_id + event_ts 做主键幂等去重,补传重复时覆盖而非追加。
核心认知:IoT 的"时间"有两个——发生时间和到达时间,可能差几小时。聚合/告警/计费都必须以发生时间为准。