一台设备每 5 秒上报一次,一天就是 17280 条。100 台设备一天 172 万条,一个月 5000 万条。能收到数据只是第一天,第二天开始真正的难题是:这张表怎么查得动、怎么清得掉。本文用我正在搭的设备接入平台里的真实代码,把"时序数据治理"这条线讲透。
先算一笔账:你为什么一定会遇到这个问题
设备接入层跑通之后,很多人会松一口气——数据能进来、能展示,故事结束了。
但把上报频率代进去就不那么乐观了:
| 设备数 | 上报频率 | 单日条数 | 单月条数 |
|---|---|---|---|
| 1 台 | 5 秒一次 | 17,280 | 约 52 万 |
| 100 台 | 5 秒一次 | 172.8 万 | 约 5184 万 |
| 1000 台 | 5 秒一次 | 1728 万 | 约 5.2 亿 |
遥测数据是典型的时序数据:只涨不缩。 它没有"删除"这个业务动作,设备只要活着就一直上报。所以等你发现 /api/devices/{id}/data 从 7ms 变成 2 秒,通常表已经几千万行了。
这就是设备接入平台从 M2 走到 M3 时必须补上的一课。下面三块,对应"怎么清、怎么查、怎么证明"。
一、怎么清:分批删除,别一条 DELETE 干到底
最直觉的写法是一句 SQL 清掉 30 天前的数据:
-- 千万别这么写
DELETE FROM data_point WHERE received_at < NOW() - INTERVAL 30 DAY;
这句话在开发机上跑得飞快,在有几千万行业务表上就是事故:一个大事务会长时间持锁、undo log 暴涨、binlog 写满、主从延迟飙到几分钟,期间线上查询全部排队。
正确做法是分批删。这是我项目里的真实实现:
@Slf4j
@Component
@RequiredArgsConstructor
public class DataRetentionJob {
public static final int BATCH_SIZE = 1000;
private final DataPointRepository dataPointRepository;
/** 保留天数,application.yml 里 iot.data-retention.days 可覆盖 */
@Value("${iot.data-retention.days:30}")
private int retentionDays;
/** 真正的清理逻辑,返回删除的总行数。public 是为了单元测试直接调用 */
public int purgeExpired(int days) {
Instant cutoff = Instant.now().minus(days, ChronoUnit.DAYS);
int total = 0;
int batch;
do {
List<DataPoint> oldest = dataPointRepository
.findTop1000ByReceivedAtBeforeOrderByIdAsc(cutoff);
batch = oldest.size();
if (batch > 0) {
dataPointRepository.deleteAllInBatch(oldest);
total += batch;
}
} while (batch == BATCH_SIZE); // 删满一批说明可能还有,继续;没删满说明清完了
if (total > 0) {
log.info("数据保留任务:删除 {} 天前的遥测数据 {} 条(截止 {})", days, total, cutoff);
}
return total;
}
/** 生产节奏:每天凌晨 3 点执行。单实例部署暂不需要分布式锁 */
@Scheduled(cron = "${iot.data-retention.cron:0 0 3 * * ?}")
public void run() {
purgeExpired(retentionDays);
}
}
这段代码里有 5 个点是面试真能聊的:
- 分批而不是全删。每批 1000 条,一个短事务一把梭完就提交,锁持有时间毫秒级,不会打扰线上流量。
while (batch == BATCH_SIZE)这个循环条件是关键。删满一批说明"可能还有",继续;没删满说明已经清空,退出。比"先 count 再算循环次数"省一次全表扫描。- 保留天数走配置。
iot.data-retention.days默认 30,不同环境可以不一样(测试环境保留 3 天就够)。硬编码天数是很多人的第一版写法,改起来要重新发版。 - cron 也走配置,默认凌晨 3 点。清理是低优先级任务,要避开业务高峰。
- 只删
data_point,device档案永远保留。一句话原则:数据会过期,档案不会。 设备表是业务实体,删了就等于设备"消失了",历史数据全成孤儿。
如果要更进一步,接着做这三步:received_at 上加索引(否则这个查询本身就在全表扫)、按时间分区表(PARTITION BY RANGE,删数据直接 DROP PARTITION,秒级完成)、上量后换成 TDengine 这类时序库(自带 retention 策略,连定时任务都省了)。
二、怎么查:索引 + 限量 + 不许深分页
清理解决"存得下",查询决定"用起来"。时序表的查询有三条铁律。
第一条:deviceId + receivedAt 必须有索引。 时序表是全库最大的表,没有索引的时间范围查询就是灾难。我的实体上先建了设备维度索引:
@Entity
@Table(indexes = @Index(name = "idx_datapoint_device", columnList = "deviceId"))
public class DataPoint {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long deviceId;
private String productKey;
/** 原始 JSON,如 {
"temp":25.5,"humi":60} */
@JsonIgnore
private String payload;
/** 服务端收到的时间 */
private Instant receivedAt;
}
注意 payload 上是 @JsonIgnore——别把原始 JSON 大字段顺手塞进列表接口的响应里。这是个很容易犯的错:一个设备 100 条数据,每条 payload 200 字节,响应就凭空多出 20KB,网络和前端渲染全被拖慢。
第二条:接口必须限量,且要有硬上限。 设备上报是无界的,全量查询一定会把内存打爆:
private static final int DEFAULT_LIMIT = 100;
private static final int MAX_LIMIT = 1000;
int size = limit == null ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
Pageable pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "id"));
return views(dataPointRepository.findByDeviceId(deviceId, pageable));
Math.min(limit, MAX_LIMIT) 这一句是整个接口最重要的防线。如果有人传 limit=999999,你不拦住,数据库就得把整张表捞出来。开放给外部调用的分页参数,一定要有服务端上限。
第三条:返回倒序,且不给深分页。 我的接口默认返回最新 100 条而非最旧 100 条——看板要画的是"最近的曲线",limit=100 就该拿到最新的 100 条。同时接口只做"最新 N 条"和"时间范围查询",故意不做 offset 深分页:LIMIT 1000000, 100 这种查询 MySQL 要扫前面 100 万行,越翻越慢。历史数据要靠时间范围定位,不是靠翻页。
时间范围参数还要做格式校验,别让非法输入变成 500:
private Instant parse(String value, String param) {
try {
return Instant.parse(value);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException(
param + " 需要 ISO-8601 格式,例如 2026-09-22T00:00:00Z");
}
}
配合全局异常处理,这个 IllegalArgumentException 会变成 400 + 明确提示,用户拿到的是"参数格式不对",而不是"服务器内部错误"。
三、怎么证明:压测要看分位数,不是平均数
代码写完了,"性能好"不能靠感觉,得压。我对最热的查询接口 GET /api/devices/{id}/data?limit=100 做了基准压测(JMeter 5.6,100 并发线程、20 秒 ramp-up、持续 60 秒)。先说清环境:压测端、应用、MySQL 全在同一台开发笔记本上,这是个诚实但很受限的条件。
实测结果(我记录在 loadtest/README.md 里):
| 指标 | 数值 |
|---|---|
| 总请求数 | 136,892 |
| 实际时长 | 59.9 s |
| 吞吐量 | 2,285 req/s |
| 平均响应 | 36.5 ms |
| P50 | 7 ms |
| P90 | 104 ms |
| P95 | 191 ms |
| P99 | 422 ms |
| 最大 | 1,712 ms |
| 错误率 | 0.00% |
比数字更重要的是怎么读这组数字,这里有两个反直觉的点:
第一,瓶颈不在服务端。 100 线程 × 平均 36.5ms ≈ 理论上限 2700 req/s,实测 2285 req/s,逼近这个天花板了。这说明瓶颈是"压测端的并发模型",不是我的接口——服务端还远没打满。想压出服务端真实极限,得把 JMeter 放到另一台机器上。
第二,P50 和 P99 差了 60 倍。 P50 只有 7ms,P99 却是 422ms。长尾从哪来?GC 停顿 + JMeter 和被测应用抢同一台机器的 CPU。如果只报"平均响应 36.5ms",这条长尾就被平均数完全掩盖了。 面试被问"你的接口性能如何",回答"平均 36ms"基本等于没回答;回答"P50 7ms、P99 422ms,长尾来自 GC 和压测端同机抢核,所以我下一步要分离压测机"——这才是做过压测的人。
读者常见三问
Q:为什么不用 DELETE ... LIMIT 1000 这样一句 SQL 批量删,非要先查出来再删?
A:DELETE ... LIMIT 在 MySQL 单表上可以用,但有两个现实问题:一是有 LIMIT 的 DELETE 在部分场景(多表、有外键、需要主从一致)支持不佳;二是先 SELECT 出主键列表再按主键删,主从复制更安全,而且这个 SELECT 走索引、结果可预测。我选的是后者。关键从来不是选哪种语法,而是"别让一个事务删几百万行"这个原则本身。
Q:分批删除会不会删到一半失败,留下不一致状态?
A:不会。每一批是独立事务,删掉的就是真删掉了,剩下的下一轮继续删——最终一致。清理任务不需要 ACID 跨批事务,因为它本来就是个"幂等的收敛过程"。真出异常,第二天凌晨 3 点还会再来一次。
Q:什么时候该放弃 MySQL,换 TDengine / TimescaleDB?
A:三个信号同时出现就该换了:① 单表超过 1 亿行;② 查询从"某设备最近数据"变成"多设备跨时间聚合"(比如按小时算平均值);③ 你开始手写分区、归档、降采样这些时序库自带的功能。换成时序库之后,接口层几乎不用改——这也是为什么我从一开始就把查询包在 DataPointView 这种 DTO 里,而不是把实体直接抛出去。
下一步
到这里,设备接入平台的"数据侧"闭环了:接入 → 入库 → 限量查询 → 定时清理 → 压测验证。再往后是两件事:一是把数据量堆上去,用分区表或 TDengine 重跑同一份压测脚本,看拐点在哪;二是做规则引擎和告警,这才是物联网平台真正产生业务价值的地方。
我是软件工程在读,正在从零搭一个物联网设备接入平台(Spring Boot + EMQX + MySQL),所有代码和踩坑记录都会发出来。欢迎关注,一起卷物联网后端。