资讯动态

10万条设备数据把接口打崩了?时序数据查询的限量、分页与索引实战

发布时间:2026/10/15 15:41:53 来源:尧图企业网站定制
***## 一、问题从哪来上一篇文章写了遥测查询接口的基本设计倒序限量、时间范围、DTO 隔离当时库里只有 6 条测试数据怎么查都是几毫秒的事。但真实世界不是这样的一台设备每 2 秒上报一条一天就是 43,200 条十台设备跑一个月data_point表就奔 1300 万条去了。上的坑——同一个查询接口因为查询方式不同并发能力能差 400 倍。本文所有数据都是实测EMQX 5.3.2 本机启动项目用 H2 profile 跑在 8080用 Python 模拟设备 灌数脚本通过 MQTT 真实写入了 100,000 条遥测数据约 762 条/秒耗时 141 秒然后用 Node 22 的 undici 做并发压测。代码全部来自我的开源项目 iot-device-platform来源文件在文中逐一标注。二、先看接口代码限量是怎么做的查询接口的核心逻辑在DataPointController.java来源src/main/java/com/iothub/data/DataPointController.javaprivatestaticfinalintDEFAULT_LIMIT100;privatestaticfinalintMAX_LIMIT1000;GetMappingpublicApiResponseListDataPointViewquery(PathVariableLongdeviceId,RequestParam(requiredfalse)Stringfrom,RequestParam(requiredfalse)Stringto,RequestParam(requiredfalse)Integerlimit){if(from!nullto!null){returnviews(dataPointRepository.findByDeviceIdAndReceivedAtBetweenOrderByReceivedAtDesc(deviceId,parse(from,from),parse(to,to)));}if(from!null){returnviews(dataPointRepository.findByDeviceIdAndReceivedAtAfterOrderByIdDesc(deviceId,parse(from,from)));}// ... to 分支同理略 ...intsizelimitnull?DEFAULT_LIMIT:Math.min(limit,MAX_LIMIT);PageablepageablePageRequest.of(0,size,Sort.by(Sort.Direction.DESC,id));returnviews(dataPointRepository.findByDeviceId(deviceId,pageable));}看limit那两行不传默认 100传了也用Math.min(limit, MAX_LIMIT)硬截到 1000。这个设计很朴素但实测证明它救了命——10 万条数据下限量分支是整个接口里唯一扛得住并发的查询方式。有个细节值得说倒序返回用的是Sort.by(DESC, id)而不是receivedAt。因为id是自增主键时序数据里 id 的顺序和时间顺序天然一致主键排序能走索引、不需要 filesort。这是上篇文章讲过的点实测数据也再次验证了它。81.5ms】三、实测第一组限量查询怎么传都不慌10 万条数据下分别用不同 limit 请求真实耗时如下浏览器同源 fetch 实发请求截图多次运行数值有 ±15% 浮动请求返回条数耗时不传 limit10075.2 mslimit50050078.4 mslimit1000100088.2 mslimit999991000被截断23.8 ms两个观察下这点开销几乎可以忽略。限量的本质是给返回体封顶数据库扫描的路径并没有变。2. limit99999 被截成 1000调用方拿不到全量。这不是缺陷是保护——如果真的放行 10 万条查询下一节你会看到会发生什么。四、实测第二组时间范围查询直接把吞吐打到 3 req/s接口还支持from/to时间范围。上一篇文章夸过这个功能走联合索引只扫窗口内的行——但当时我忽略了一个致命细节看上面代码的分支顺序只要带了 from就会走findByDeviceIdAndReceivedAtAfter...这类方法而这些方法没有 Pageable 参数没有限量窗口一大返回的就是窗口内的全部数据。我实测窗口开 40 分钟正好覆盖灌进去的 10 万条单次请求返回 100,207 条、响应体 13 MB、耗时约 730ms。单发请求看着还能接受那并发呢32 并发压 10 秒指标?limit100限量?from…范围无 limit吞吐量1,213 req/s3 req/s平均响应26.4 ms9,006 msP99147.5 ms14,457 ms10 秒完成请求12,140 个47 个同样的库、同样的接口只把limit换成from吞吐掉了400 倍。原因链条很清晰每次请求都在数据库捞 10 万行 → 序列化成 13 MB 的 JSON → 占着 Tomcat 工作线程十几秒 → 32 个并发线程很快全部被占满 → 后续请求全部排队。调用方视角就是接口偶尔卡死而服务端日志里一条错误都没有——它没崩只是每一条请求都慢得离谱。这就是典型的**“单发测试发现不了、上量必炸”**的坑。测试环境造 6 条数据from 查询也是 20ms生产环境造 10 万条from 查询就是 14 秒。组 67ms】五、怎么修时间范围查询也必须限量修法只要几行范围查询和限量查询合并limit对所有分支生效。我把DataPointController.java来源src/main/java/com/iothub/data/DataPointController.java改成了这样项目里已经是修复后的代码// ⭐ 所有分支统一限量2026-10-14 实测修复10 万条数据下范围查询不接 limit// 会一次返回 10 万行 / 13 MB32 并发吞吐从 1213 req/s 掉到 3 req/s。// 约定时间范围查询返回窗口内最新的 N 条窗口太大由调用方分页拉取。intsizelimitnull?DEFAULT_LIMIT:Math.min(limit,MAX_LIMIT);PageablepageablePageRequest.of(0,size,Sort.by(Sort.Direction.DESC,id));if(from!nullto!null){returnviews(dataPointRepository.findByDeviceIdAndReceivedAtBetweenOrderByReceivedAtDesc(deviceId,parse(from,from),parse(to,to),pageable));}if(from!null){returnviews(dataPointRepository.findByDeviceIdAndReceivedAtAfterOrderByIdDesc(deviceId,parse(from,from),pageable));}// to 分支同理对应的DataPointRepository.java来源src/main/java/com/iothub/data/DataPointRepository.java方法签名加上 PageableListDataPointfindByDeviceIdAndReceivedAtAfterOrderByIdDesc(LongdeviceId,Instantfrom,Pageablepageable);Spring Data JPA 会把 Pageable 翻译成LIMIT子句拼在时间条件后面联合索引idx_datapoint_device_time (device_id, received_at)见src/main/resources/sql/schema_mysql.sql负责把扫描范围压到这台设备的这个时间窗LIMIT 负责把返回行数压到 1000 以内——索引管扫多少限量管吐多少两层防线各管各的。修完重新灌 10 万条、重跑同样的 32 并发压测效果立竿见影指标修复前范围无 limit修复后统一限量 100吞吐量3 req/s637 req/s平均响应9,006 ms50.2 msP9914,457 ms253.1 ms10 秒完成请求47 个6,376 个一行 Pageable吞吐恢复 200 多倍范围查询和限量查询回到同一量级。单发请求的响应体也从 13 MB 降到 13 KB。修复后的语义约定要写进接口文档时间范围查询返回的是窗口内最新的 N 条而不是窗口内全部。前端画历史曲线时如果窗口内数据超过 1000 条应该缩小窗口分页拉取而不是指望服务端吐全量。另一个加固方向是响应体预算DataPointView已经把原始 payload 解析成 metrics 对象来源src/main/java/com/iothub/data/DataPointView.java单条响应小了同样 1000 条的返回体也小了一大截。数据量再涨就该在入库时抽列、或者直接换 TDengine 这类时序库接口契约不用变。六、没写出来的第二个坑索引进场时机测试时还顺手验证了索引的作用这个点单独说一下。H2 profile 下表结构由 JPA 的ddl-auto: update自动生成DataPoint实体上只声明了idx_datapoint_devicedeviceId 单列索引来源src/main/java/com/iothub/data/DataPoint.javaEntityTable(indexesIndex(nameidx_datapoint_device,columnListdeviceId))publicclassDataPoint{// ...}时间范围查询真正需要的是(device_id, received_at)联合索引但 JPA 不会自动建所以 MySQL 建库脚本里单独补了一句CREATEINDEXidx_datapoint_device_timeONdata_point(device_id,received_at);教训是索引要在建表脚本里管不能指望 ORM。本机 H2 数据量小感觉不出来MySQL 上百万行时缺这个索引的时间范围查询就是全表扫描。这个坑 09-24 写数据保留策略时提过一嘴这次压测算是给它补上了实测证据。七、面试怎么讲这个坑这套实测放进简历或面试可以按这个层次讲现象同一接口limit 查询 1213 req/s范围查询 3 req/s差 400 倍根因范围查询分支没接 Pageable窗口一大返回体无上界13 MB 响应 长事务占满线程池修复所有查询分支统一限量索引管扫描范围、LIMIT 管返回行数沉淀压测必须用生产量级的数据集造数6 条测试数据测不出分位数崩塌——这条比代码本身值钱。面试官追问为什么不用分页深度偏移标准答案是时序数据的深分页OFFSET 90000会让数据库扫描并丢弃前 9 万行正确姿势是游标分页——拿上一页最后一条的 id/receivedAt 当下一页的起点配合WHERE id ?走索引。这个项目当前用倒序 limit已经覆盖看板场景游标分页是数据量再上一个量级后的事。我是软件工程在读专升本正在从零搭一套物联网设备接入平台这个系列是项目的真实开发笔记——代码、踩坑、压测数据全部来自真实仓库没有编造的示例。上篇文章讲了查询接口的基本设计这篇是它在 10 万数据量下的压力测试续集。项目源码和系列文章都放在主页欢迎交流。推荐阅读上一篇《物联网平台遥测数据怎么查》讲查询接口的设计原理《接口 P99 很高怎么办》讲压测方法本身。两篇加起来正好是接口设计 → 数据集造数 → 分位数分析的完整链路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑