一、查询接口的三个参数来源文件src/main/java/com/iothub/data/DataPointController.javaGET /api/devices/{id}/data 最新 100 条默认 GET /api/devices/{id}/data?limit20 最新 20 条 GET /api/devices/{id}/data?from...to... 时间范围ISO-8601如 2026-10-13T00:45:00Z设计要点三条必须限量。设备上报是无界的——一台设备一秒一条一天就是 8.6 万条一周 60 万。查询接口不加限制前端一个手抖就能把几百万行拉进 JVM 内存OOM 比慢查询更可怕。我的做法是DEFAULT_LIMIT 100、MAX_LIMIT 1000传大了也给你截到 1000。必须倒序最新在前。因为限量和倒序是一对如果正序返回最旧的 100 条看板拉到的永远是一周前的数据倒序返回最新的 100 条看板只拿到自己需要的那一段。看板要画曲线时自己 reverse 一下就行这个代价比拿不到新数据小得多。时间范围用 ISO-8601。?from2026-10-13T00:45:00Zto...字符串进、Instant出时区问题交给标准格式别自己发明20261013这种格式。二、索引时序表的查询命门来源src/main/java/com/iothub/data/DataPoint.javaEntityTable(indexesIndex(nameidx_datapoint_device,columnListdeviceId))publicclassDataPoint{IdGeneratedValue(strategyGenerationType.IDENTITY)privateLongid;privateLongdeviceId;privateStringproductKey;JsonIgnoreprivateStringpayload;// 原始 JSON如 {temp:25.5,humi:60}privateInstantreceivedAt;// 服务端收到的时间}遥测表是整个库里长得最快的表它的查询模式又高度固定永远是某台设备 某个时间段。所以索引就照着这个模式建单设备查最新一条 → 命中deviceId索引后按主键倒序取一行也就是findTopByDeviceIdOrderByIdDesc时间范围查询 →deviceId先过滤receivedAt上再筛对应findByDeviceIdAndReceivedAtBetweenOrderByReceivedAtDesc。面试被问时序数据表怎么建索引答按查询模式建不要按字段重要性建就对了。这个项目数据量小deviceId单列索引够用真上量要升成(deviceId, receivedAt)联合索引再大就该换时序库了。三、DTO 隔离实体绝不出接口来源src/main/java/com/iothub/data/DataPointView.javapublicrecordDataPointView(Longid,LongdeviceId,StringproductKey,InstantreceivedAt,MapString,Objectmetrics){publicstaticDataPointViewfrom(DataPointpoint,ObjectMapperobjectMapper){returnnewDataPointView(point.getId(),point.getDeviceId(),point.getProductKey(),point.getReceivedAt(),parseMetrics(point.getPayload(),objectMapper));}/** 解析失败返回空 map 而不是抛异常——一条历史脏数据不应该让整个查询接口 500 */privatestaticMapString,ObjectparseMetrics(Stringpayload,ObjectMapperobjectMapper){if(payloadnull||payload.isBlank()){returnMap.of();}try{JsonNodenodeobjectMapper.readTree(payload);if(!node.isObject()){returnMap.of(value,objectMapper.convertValue(node,Object.class));}returnobjectMapper.convertValue(node,MAP_TYPE);}catch(Exceptione){returnMap.of();}}}两个细节值得说实体里的payload是原始 JSON 字符串直接出参前端会收到一层转义的字符串、还得自己JSON.parse一次。转成metrics对象前端拿来就能用。脏数据不能拖垮接口。只要有一条历史payload不是合法 JSON按抛异常的思路整个查询就 500 了。这里选择 catch 后返回空 map——查询接口的健壮性优先于数据的严格性。顺带一提看板聚合用的OverviewService也复用同一个DataPointView.from数据口径只有一份HTTP 和 WebSocket 只是两种传输方式。四、实测两批数据一次时间旅行环境EMQX 5.3.2 本机 Spring BootH2 profile Python 模拟设备全部真实运行。先注册一台设备一机一密再用scripts/demo_device.py分两批上报第一批 3 条隔 70 秒再发第二批 3 条。这样库里就有两个时间簇方便验证时间范围查询到底有没有真的过滤。第一批查默认接口返回最新 100 条、倒序限量对比limit2只给最新 2 条——注意这两条是最新的不是最旧的时间范围查询from2026-10-13T00:45:29Z正好卡在两批之间只返回第二批 3 条换成from to掐第一批的时间窗返回第一批 3 条给一个未来时间返回空数组这里有个容易被忽视的设计点空结果不是异常。这个时间段没数据是正常业务语义返回200 空数组而不是 404 或报错。对应服务端真实日志节选08:45:26 INFO 收到设备[1]上报: {temp: 23.9, humi: 53.5} ← 第一批 08:45:28 INFO 收到设备[1]上报: {temp: 24.3, humi: 54.5} 08:46:36 INFO 收到设备[1]上报: {temp: 24.5, humi: 56.4} ← 第二批间隔 70 秒 08:46:40 INFO 收到设备[1]上报: {temp: 25.1, humi: 57.3}五、踩坑同一个接口两种命运上面都是顺利的部分。真正值得写的是这个我随手测边界参数发现同一个接口对两种非法参数的反应完全不同。?from2026-10-13不是 ISO-8601→400 中文提示「from 需要 ISO-8601 格式」?limitabcInteger 转换失败→500 服务器内部错误← 问题在这?limit-1→ 400但提示是 Spring Data 抛的英文原文Page size must not be less than one。为什么from是 400 而limit是 500因为from是我在方法体里自己 parse抛的是IllegalArgumentException全局异常处理器里恰好有这个 handler而limit是 Spring 在参数绑定阶段就转换类型抛的是MethodArgumentTypeMismatchException——我的GlobalExceptionHandler里没有它于是落到了最后那个Exception兜底变成 500。这是参数错误和服务故障的混淆后果不只是不好看客户端拿到 500 会认为服务端出问题了可能进重试队列监控上 5xx 是要告警的参数写错也告警等于把噪声当故障。修复很简单来源src/main/java/com/iothub/common/GlobalExceptionHandler.javaExceptionHandler(MethodArgumentTypeMismatchException.class)publicResponseEntityApiResponseVoidhandleTypeMismatch(MethodArgumentTypeMismatchExceptione){Class?typee.getRequiredType();Stringmsge.getName() 参数格式不正确期望(typenull?合法值:type.getSimpleName());returnResponseEntity.badRequest().body(ApiResponse.error(400,msg));}重启后重测limitabc变成 400 「limit 参数格式不正确期望Integer」顺带把limit-1的英文提示也包了一层自己校验limit 1时抛中文的IllegalArgumentException。参数校验这件事的终点不是参数不对能拦住而是拦住之后告诉调用方怎么改。六、再往后什么时候该换掉 MySQLController 的注释里我留了一句 TODO数据量大后换 TDengine / 按月分表接口不用变。这不是随口写的而是这套设计留的退路——查询接口只暴露(deviceId, from, to, limit)四个语义参数不暴露任何 SQL 细节DataPointView是 DTO不绑定任何存储引擎的结构所以哪天把DataPointRepository换成 TDengine 客户端Controller、DTO、前端全都不用动。这也是我把查询逻辑收在DataPointRepository一个接口里的原因变的是存储不变的是某台设备某段时间的数据这个业务语义。七、面试怎么讲被问物联网平台的数据查询接口怎么设计我按这个顺序答①限量 倒序上报无界接口必须有界且要拿最新的②时间范围 ISO-8601空结果不是异常③按查询模式建索引时序表永远是 deviceId 时间④DTO 隔离 容错解析脏数据不能拖垮接口⑤参数校验收口400 和 500 不能混MethodArgumentTypeMismatchException别漏⑥存储可替换接口只暴露业务语义将来换时序库不用改契约。六个点都有项目里的真实代码和实测截图。关于作者软件工程在读专升本正在从零搭一个物联网设备接入平台Spring Boot EMQX MySQL把踩过的每个坑都写成文章。上一篇讲了下行命令与回执状态机下一篇计划聊 EMQX 共享订阅——平台要横向扩容第一件事就是让多个服务实例别重复消费同一条消息。欢迎关注一起把这个项目做扎实。