物联网平台数据表只涨不缩?Spring Boot 遥测数据保留策略:分批删除 + 索引优化 + 真实压测数据

简介: 本文以真实设备接入平台为例,详解时序数据治理三大核心:分批删除防锁表、复合索引+限量查询保响应、压测聚焦P99长尾。从100台设备日增172万条数据的现实压力出发,手把手拆解MySQL下高效存查清的落地实践与避坑指南。

一台设备每 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 个点是面试真能聊的:

  1. 分批而不是全删。每批 1000 条,一个短事务一把梭完就提交,锁持有时间毫秒级,不会打扰线上流量。
  2. while (batch == BATCH_SIZE) 这个循环条件是关键。删满一批说明"可能还有",继续;没删满说明已经清空,退出。比"先 count 再算循环次数"省一次全表扫描。
  3. 保留天数走配置。iot.data-retention.days 默认 30,不同环境可以不一样(测试环境保留 3 天就够)。硬编码天数是很多人的第一版写法,改起来要重新发版。
  4. cron 也走配置,默认凌晨 3 点。清理是低优先级任务,要避开业务高峰。
  5. 只删 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),所有代码和踩坑记录都会发出来。欢迎关注,一起卷物联网后端。

相关文章
|
存储 SpringCloudAlibaba Cloud Native
【微服务33】分布式事务Seata源码解析一:在IDEA中启动Seata Server
【微服务33】分布式事务Seata源码解析一:在IDEA中启动Seata Server
2070 0
【微服务33】分布式事务Seata源码解析一:在IDEA中启动Seata Server
|
3天前
|
Web App开发 人工智能 安全
Personal Agent 是什么?为什么大厂都抢着做?连 DeepSeek Bot 都来了。。
Personal Agent 是什么?为什么大厂都在抢着做?跟 OpenClaw、WorkBuddy 等 AI 有什么区别?一文带你搞懂 Muse、Dots 等 Bot,实际体验 Grok Bot,真的好用么?
117 0
Personal Agent 是什么?为什么大厂都抢着做?连 DeepSeek Bot 都来了。。
|
9天前
|
人工智能
阿里云域名注册入口链接:先查询、注册、实名认证及新手指南
本文详解阿里云域名注册全流程:从万网入口查询域名、选择后缀(推荐com/cn)、确认可注册,到创建实名信息模板(个人/企业)、使用优惠口令,最后提交支付。含最新入口链接、价格说明与实名认证指南。(239字)
146 0
|
9天前
|
人工智能 自然语言处理 IDE
阿里云Qoder CN和Qoder有什么区别?一篇文章看懂
Qoder CN是阿里云面向国内市场的AI智能体系列(原通义灵码),支持GLM/DeepSeek等国产模型,全链路境内部署,含编码、办公、CLI、数字员工等子产品;Qoder为其国际版,账号、Credits、模型及部署均独立,不互通。
201 0
|
2月前
|
编解码 人工智能 算法
视频超分省钱攻略:用「低分辨率生成 + 超分交付」把 AI 视频成本压掉一半
AI 视频直接出 1080p / 4K 往往是最贵的路径。这篇攻略以 Seedance 2.0 Mini 的 480p 为底片档位,拆解献丑 AI 视频节点两档超分(超分增强与 Topaz)的计费规则,给出低分辨率生成再超分的成本算法、分辨率与补帧的取舍原则,以及六条可直接照做的省钱操作。
470 1
|
2月前
|
SQL 人工智能 自动驾驶
一文看懂爆火的 Graph Engineering,以及它和 Loop Engineering、Agent 循环到底啥关系
阿星详解AI工程三层次:Agent循环是AI“想-做-看”的基本心跳;Loop工程为其装上“自动巡航”,实现任务自主迭代;Graph工程则构建多节点协同的执行图,如公司般分工、质检、恢复。三者层层递进,非替代而是融合。(239字)
|
3月前
|
自然语言处理 监控 Java
阿里云地址标准化服务完全对接指南:从开通到生产级API调用
本文提供了一份完整的阿里云地址标准化(Address Purification)服务对接指南。地址标准化是依托阿里云海量地址语料库与NLP算法沉淀的高性能标准地址算法服务,能解决一地多名、地址解析、地址真伪辨别等问题。文章详细介绍了服务的开通流程、控制台项目创建、RAM权限配置、API请求结构与签名机制,并提供了Python和Java两种语言的SDK调用示例,涵盖地址结构化、地址纠错、地址补全、门址标准化、行政区划识别等核心接口。同时深入讲解了地址归一化、多地址一致性判断、高精度经纬度等高级能力,以及定制干预管理、数据监控与QPS统计等运维功能。在成本优化方面,分析了按量后付费与资源包两种计费
|
4月前
|
SQL 人工智能 自然语言处理
2026企业有哪些agent应用场景?五大应用场景实战避坑指南
本文系统梳理企业智能问数、报告、解读、搭建及归因五大Agent应用场景,剖析落地挑战与选型要点,结合瓴羊“小Q”等实践案例,提供可落地的AI数据消费升级路径,助力企业从“人人都是分析师”迈向“人人都是数据消费者”。
|
4月前
|
人工智能 运维 监控
AI 时代,满分的程序员简历是怎么样的?附简历模板
大家好,我是程序员鱼皮。 每年求职季,我都会分享一些求职攻略。这些年我帮大家修改过几千份简历,之前写的 《保姆级写简历指南》 也帮了不少同学拿到面试机会。所以在写简历这件事上,我还是比较有发言权的。 不过随着 AI 的发展,程序员的简历写法也必须要更新了! 如今的招聘市场,会 AI 已经不是加分项,而是 筛选条件。随便翻翻现在大厂的 JD,很多都明确写着:熟悉 AI 大模型开发优先、有 AI 编程
199 0
|
5月前
|
存储 缓存 安全
避开“玩具陷阱”:办公Agent在权限管理、审计日志上的工程实践
本文以真实事故切入,详解办公AI Agent安全落地的“刹车”(最小权限、角色隔离、临时提权)与“黑匣子”(全链路、不可篡改、可追溯审计日志)两大核心机制,直击权限滥用与日志缺失痛点,提供可复用的工程实践方案。(239字)
562 1

热门文章

最新文章