物联网平台数据表只涨不缩?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_pointdevice 档案永远保留。一句话原则:数据会过期,档案不会。 设备表是业务实体,删了就等于设备"消失了",历史数据全成孤儿。

如果要更进一步,接着做这三步: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 单表上可以用,但有两个现实问题:一是有 LIMITDELETE 在部分场景(多表、有外键、需要主从一致)支持不佳;二是先 SELECT 出主键列表再按主键删,主从复制更安全,而且这个 SELECT 走索引、结果可预测。我选的是后者。关键从来不是选哪种语法,而是"别让一个事务删几百万行"这个原则本身。

Q:分批删除会不会删到一半失败,留下不一致状态?

A:不会。每一批是独立事务,删掉的就是真删掉了,剩下的下一轮继续删——最终一致。清理任务不需要 ACID 跨批事务,因为它本来就是个"幂等的收敛过程"。真出异常,第二天凌晨 3 点还会再来一次。

Q:什么时候该放弃 MySQL,换 TDengine / TimescaleDB?

A:三个信号同时出现就该换了:① 单表超过 1 亿行;② 查询从"某设备最近数据"变成"多设备跨时间聚合"(比如按小时算平均值);③ 你开始手写分区、归档、降采样这些时序库自带的功能。换成时序库之后,接口层几乎不用改——这也是为什么我从一开始就把查询包在 DataPointView 这种 DTO 里,而不是把实体直接抛出去。

下一步

到这里,设备接入平台的"数据侧"闭环了:接入 → 入库 → 限量查询 → 定时清理 → 压测验证。再往后是两件事:一是把数据量堆上去,用分区表或 TDengine 重跑同一份压测脚本,看拐点在哪;二是做规则引擎和告警,这才是物联网平台真正产生业务价值的地方。


我是软件工程在读,正在从零搭一个物联网设备接入平台(Spring Boot + EMQX + MySQL),所有代码和踩坑记录都会发出来。欢迎关注,一起卷物联网后端。

相关文章
|
2天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
5191 6
|
1天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
748 0
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
14天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。
|
15天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
|
13天前
|
IDE 开发工具
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
Qoder国际版上线全新内置大模型Sonus(/ˈsoʊnəs/),全球领先,专精超长任务执行与电脑操作(Computer Use)。配合Qoder桌面端0.2.3版本,可自主完成编程、金融建模、科研及表格制作等复杂工作。现全面支持Qoder全系产品,效率提升3.2倍。
1716 8
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
|
16天前
|
缓存 人工智能 自然语言处理
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
本文是阿里云百炼平台Qwen3.8-Flash大模型的选型接入指南,作为兼顾性能与响应速度的高性价比多模态模型,它支持百万级上下文窗口、全场景多模态输入与完整智能体能力矩阵,适配编程辅助、智能体协作等核心场景。文中同步梳理了最新下调的阶梯定价、夜间4折等优惠活动,搭配OpenAI兼容流式调用示例,帮助开发者低成本快速落地高并发AI应用。
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
|
9天前
|
缓存 IDE Java
【保姆级】Android Studio下载、安装和汉化教程(2026最新)
Android Studio 是 Google 官方推出的免费 Android 应用开发集成环境,基于 IntelliJ IDEA,内置模拟器、调试器、性能分析及 Compose 界面工具,功能全面,文档丰富,是安卓开发首选工具。(239字)
1051 1
|
15天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
2010 15

热门文章

最新文章