设备端每天产生海量指标:一台电表每 10 秒上报一次电流电压,一万台设备一天就是 8640 万条记录 ,而运维看板往往还要回查三个月内的历史曲线。写入扛不住、查询跑不动、磁盘还越涨越快,是这类系统最常见的三个坑。
很多人第一反应是拿 MySQL 硬扛:单表加索引、按月分表、再上读写分离。可分表之后跨表聚合就崩了,冷数据归档脚本天天跑,写入高峰期依然锁表,运维成本远超收益。
本文分享一套可直接落地的方案:TimescaleDB 超表建模 + Spring Boot 批量异步写入 + 连续聚合降采样 + 保留与压缩策略,把存储成本降一个量级,把看板查询从秒级压到百毫秒级。
一、写入洪峰与查询长尾:设备监控的三个真实矛盾
设备监控的存储难题,基本都能拆成三件事:
- 写入是洪峰:设备按固定周期上报,整点对齐导致几十万台设备同时推送,瞬时写入量可达均值的 5 倍以上。
- 查询是长尾:实时看板只要最近 5 分钟,排障却要翻三个月前某台设备的原始点,两类查询挤在同一张表里互相拖累。
- 存储是无底洞:原始点位按天增长,没有降采样和压缩,两年就能把磁盘写满。
治理思路:写入按批打散、查询按时间裁剪、冷数据自动压缩。
核心结论: 把「时间」当成一等公民来建模,是时序存储一切优化的前提。

二、时序存储选型:MySQL、InfluxDB 与 TimescaleDB 对比
三种方案我都上过生产,差别集中在四个层面:
| 维度 | MySQL 分表 | InfluxDB | TimescaleDB |
|---|---|---|---|
| 写入模型 | 行存储,自增主键 | LSM,行协议写入 | 超表 + chunk 列存压缩 |
| 跨设备聚合 | 跨表 UNION,越查越慢 | 需学 Flux,生态偏窄 | 标准 SQL + 连续聚合 |
| 技术栈复用 | 最熟,生态全 | 独立集群,双语法 | PostgreSQL 全生态复用 |
| 运维成本 | 分表脚本 + 归档脚本 | 学习曲线陡 | 装扩展即可,与 PG 共存 |
- MySQL 的天花板:日增百万级尚可支撑,再往上分表与归档的运维代码量会失控。
- InfluxDB 的取舍:写入很快,但业务同学要现学 Flux,和已有 PG 数仓打通成本高。
- TimescaleDB 的甜点 :它是 PostgreSQL 的扩展,SQL、事务、备份工具全部复用,时序能力靠超表补上。
核心结论: 已有 PostgreSQL 技术栈的团队,TimescaleDB 是迁移成本最低的选择。
三、读懂 TimescaleDB:超表、压缩与连续聚合分工
TimescaleDB 的核心只有三个概念:
- 超表(Hypertable) :逻辑一张表,物理按时间自动切成多个 chunk,每个 chunk 独立建索引,查询只扫相关分片。
- 压缩(Compression) :过期 chunk 自动转列存,同类指标相邻排列,实测可压到原体积的 1/15 ~ 1/20。
- 连续聚合(Continuous Aggregate):物化视图 + 增量刷新,写入后自动补算新窗口,不必每次查询现场聚合。
三层分工:超表管「切」、压缩管「存」、连续聚合管「查」。
核心结论: 三者配齐才是一套完整时序方案,只建超表不配聚合,看板照样慢。

四、建表与超表转换:一条 SQL 建出按时间分区的表
落地第一步是建表,注意几个字段与约束细节:
sql
CREATE TABLE device_metric (
device_id BIGINT NOT NULL,
metric_name TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
value DOUBLE PRECISION NOT NULL,
quality SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (device_id, metric_name, ts)
);
SELECT create_hypertable('device_metric', 'ts',
chunk_time_interval => INTERVAL '1 day');
CREATE INDEX idx_metric_dev_ts
ON device_metric (device_id, metric_name DESC, ts DESC);
ALTER TABLE device_metric SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'device_id, metric_name',
timescaledb.compress_orderby = 'ts DESC'
);
- 时间列必须用
timestamptz:带时区,避免服务器时区变更导致数据整体错位 8 小时。 - 主键带上时间列:重复上报会被自动丢弃,天然幂等,省掉上游去重逻辑。
- chunk 间隔按查询粒度定:看板只查 5 分钟就设 1 天,排障常查 30 天就设 7 天。
核心结论: chunk 间隔决定分区裁剪效果,宁可先设小再调大。
五、Spring Boot 写入层:JdbcTemplate 批量插入设备指标
写入层直接用 JdbcTemplate 批量插入,绕开 JPA 逐条落库的开销:
java
@Repository
public class MetricWriteRepo {
private static final String SQL = """
INSERT INTO device_metric (device_id, metric_name, ts, value)
VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING
""";
private final JdbcTemplate jdbc;
public MetricWriteRepo(JdbcTemplate jdbc) { this.jdbc = jdbc; }
public int batchInsert(List<MetricPoint> points) {
int[] rows = jdbc.batchUpdate(SQL, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
MetricPoint p = points.get(i);
ps.setLong(1, p.deviceId());
ps.setString(2, p.metricName());
ps.setObject(3, p.ts());
ps.setDouble(4, p.value());
}
@Override
public int getBatchSize() { return points.size(); }
});
return rows.length;
}
}
- 裸 JDBC 优于
saveAll:JPA 每条都要走脏检查与 flush 语义,时序写入完全用不上。 - 按设备 + 指标排序后再插:同一批数据落进同一个 chunk,减少索引随机写。
- 写入失败按批重试:整批重试成本可控,比逐条补偿简单得多。
核心结论: 批量插入 + 预排序,实测写入吞吐可翻 3~5 倍。

六、写入吞吐调优:批次大小与连接池的取舍
写入调优集中在配置参数与执行节奏上:
yaml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
| 批量条数 | 每万条耗时 | 结论 |
|---|---|---|
| 100 | 约 3.8 s | 网络往返开销过大 |
| 500 | 约 1.4 s | 可接受 |
| 1000 | 约 1.1 s | 推荐区间 |
| 5000 | 约 1.3 s | 事务过长,拖慢压缩任务 |
- 连接池别开太大:单实例 20~30 连接足够,再多反而互相抢 CPU 与 IO。
- 写入走异步线程池:网关只负责攒批落库,业务线程不直接等数据库 IO。
核心结论: 先调批大小,再调连接池,最后才考虑分库分实例。
七、查询加速:时间裁剪与连续聚合双保险
看板查询不要扫原始表,交给连续聚合视图:
sql
CREATE MATERIALIZED VIEW metric_1m
WITH (timescaledb.continuous) AS
SELECT device_id,
metric_name,
time_bucket('1 minute', ts) AS bucket,
avg(value) AS avg_v,
max(value) AS max_v,
min(value) AS min_v
FROM device_metric
GROUP BY device_id, metric_name, bucket
WITH NO DATA;
SELECT add_continuous_aggregate_policy('metric_1m',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
- 查询条件写在 bucket 上 :
WHERE bucket > now() - INTERVAL '7 days',直接命中聚合表。 - 原始表只在排障时碰:先定位到具体设备再查明细,避免全量扫描。
- 实时性要求高就缩短刷新间隔 :
schedule_interval调到 1 分钟,延迟几乎无感。
核心结论: 降采样视图把「每次查询现算」变成「写入时算好」。
八、生命周期治理:保留策略与自动压缩自动跑
数据生命周期靠策略执行,不要靠人写脚本:
sql
SELECT add_retention_policy('device_metric', INTERVAL '180 days');
SELECT add_compression_policy('device_metric', INTERVAL '7 days');
SELECT compress_chunk(c) FROM show_chunks('device_metric') c LIMIT 1;
- 保留策略按指标分级:振动等高频指标留 90 天,温度等低频指标留 1 年,别一刀切。
- 压缩要早于保留触发:先压后删,磁盘占用曲线才会持续单调下降。
- 压缩后仍然可查:查询引擎自动解压,业务代码无感知,无需改 SQL。
核心结论: 保留 + 压缩两条策略配齐,存储成本通常下降 90% 以上。

九、接口设计:按时间跨度自动切换数据粒度
接口层按时间跨度自动选择数据源,避免前端传参出错:
java
@RestController
@RequestMapping("/api/metric")
public class MetricQueryController {
private final JdbcTemplate jdbc;
public MetricQueryController(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@GetMapping("/trend")
public List<Map<String, Object>> trend(@RequestParam long deviceId,
@RequestParam String metric,
@RequestParam String from,
@RequestParam String to) {
long days = Duration.between(LocalDate.parse(from).atStartOfDay(),
LocalDate.parse(to).atStartOfDay()).toDays();
String table = days > 3 ? "metric_1h" : "metric_1m";
String sql = """
SELECT bucket, avg_v, max_v, min_v FROM %s
WHERE device_id = ? AND metric_name = ?
AND bucket BETWEEN ? AND ?
ORDER BY bucket
""".formatted(table);
return jdbc.queryForList(sql, deviceId, metric, from, to);
}
}
- 超过 3 天自动走小时级视图:一次请求只返回几百个点,前端渲染不卡顿。
- 禁止接口暴露全量原始点导出:大数据量导出一律走异步任务 + 分页下载。
- 响应里带上
granularity字段:前端据此决定 tooltip 是否显示小数精度。
核心结论: 把降采样规则收敛在服务端,前端只管画图。
十、排错清单:上线前必须验证的五个坑
按这份清单过一遍,能挡掉 80% 的线上问题:
| 现象 | 常见原因 | 处理动作 |
|---|---|---|
| 查询走 Seq Scan | 时间列上套了函数 | 改成 ts >= ? 裸比较 |
| 写入延迟突增 | 单批过大、vacuum 跟不上 | 缩小批量,调高 autovacuum 频率 |
| 时间差 8 小时 | 客户端用了 LocalDateTime | 统一改用 OffsetDateTime |
| 压缩任务不跑 | 没设 segmentby,收益过低 | 按 device_id 分段重设策略 |
| 聚合数据缺几分钟 | 刷新策略 end_offset 过大 | 缩小到 1 分钟再观察 |
- 上线必做 EXPLAIN:确认 chunk exclusion 生效,结果里应只扫少数 chunk 而非全表。
- 监控三层指标:写入 QPS、chunk 总数、压缩后表体积,任一异常都能提前预警。
核心结论: 时序库的问题九成出在「时间条件写法」上,先查 SQL 再查硬件。

结语
这套方案的价值不在于用了哪个新组件,而在于把设备监控数据的三个矛盾拆开治理:写入靠批量与异步打散洪峰,查询靠连续聚合把计算提前,存储靠压缩与保留策略自动收缩。整条链路跑通后,看板查询稳定在百毫秒级,磁盘占用从线性暴涨变成趋于平坦,运维也不再需要维护一堆归档脚本。
时间建模 + 批量写入 + 降采样查询 + 自动压缩,四件事配齐,设备监控数据的存与查就不再是难题。