一、时序数据聚合查询踩坑:内存溢出的真实场景

你可能在ThingsBoard平台上碰到过这种情况:运营导出设备当天的小时级温度统计,点完查询后整个服务直接崩了,后台日志跳的全是内存溢出的报错。这不是偶然,而是时序数据聚合时没做优化的典型问题。

举个真实例子:某用户的平台有1000台温感设备,每10秒上报一次数据,一天下来单台设备有8640条记录,总数据量超800万。运营要导出当天每小时的平均温度,开发用了常规写法——直接拉取所有符合时间范围的原始数据到Java内存里,再手动算每小时的平均值,结果刚跑3分钟,服务就因为内存不够直接退出了。

为什么会这样?我们拆开来聊。

1.1 为什么时序数据聚合容易爆内存

时序数据的特点是“量多且有序”,比如设备上报的时间戳是严格递增的,数据是一条接一条攒起来的。常规聚合的坑在于:很多开发者会把计算任务交给应用层(比如Java),而数据库只是“搬运工”——把800万条原始数据全拉到应用里,再遍历计算平均值。这些数据在Java里会变成一堆Double对象、时间对象,再加上对象头,800万条数据能吃掉几百MB的内存,要是同时有几个这样的请求,直接就把JVM的内存吃垮了。

1.2 ThingsBoard里的默认查询逻辑

ThingsBoard自带的时序数据查询接口,默认是返回所有匹配条件的结果,没做分页限制。比如你查当天某设备的温度数据,它会把所有符合的记录塞给你,不管是10条还是1000万条,要是没主动加分页,就会出现刚才的内存溢出问题。

二、问题根源拆解:为什么聚合会占满内存

2.1 全量数据拉取的隐形开销

再举个具体的数字:一条温度记录在数据库里占约20字节(包括时间戳、设备ID、温度值),但在Java里会变成一个Double对象(占8字节)加上对象头(约16字节),总共24字节左右。800万条数据就是800万*24字节=192MB,还不算其他线程、JVM自身的开销,要是应用还有其他逻辑,很容易就达到JVM的内存上限(比如默认给的128MB堆内存)。

2.2 ThingsBoard查询的默认逻辑坑

很多人用ThingsBoard的聚合功能时,没注意到它的“聚合”其实是应用层聚合,而不是数据库端聚合。比如你在界面上选“按小时平均”,它会先把所有原始数据拉回来,再在后台算平均值,这和我们刚才说的坏写法是一样的,没从数据库层面做优化。

三、优化方案:分页策略拯救内存危机

要解决内存溢出,核心思路就是不让应用层装全量数据,把计算和分页都交给数据库,这样应用层只需要处理少量数据。针对时序数据,我们有两种常用的分页策略,结合ThingsBoard的时序数据库(通常是TimescaleDB,PostgreSQL的时序扩展)效果最好。

3.1 基础分页(LIMIT + OFFSET)

最容易理解的分页方式,就是给查询加“每次取N条”和“跳过前M条”的参数。比如要每页取100条,第1页OFFSET 0,第2页OFFSET 100,第3页OFFSET 200,以此类推。这种方式的优点是实现简单,不用改复杂的逻辑,缺点是当OFFSET很大的时候,数据库要先扫描前M条数据再跳过,效率会越来越低,比如第1000页的OFFSET是99900,数据库要扫99900条记录才能返回100条,非常慢。

3.2 高效分页(游标分页)

时序数据是按时间戳严格递增的,我们可以直接用时间戳作为“游标”,不用OFFSET,而是用WHERE条件限制:WHERE ts > 上次返回的最大时间戳 LIMIT 100。这种方式的优点是数据库不用扫描前面的记录,直接按索引找后续的数据,不管翻到第几页,性能都是一样的,特别适合时序数据。

3.3 结合ThingsBoard的配置调整

除了代码层面的优化,还要注意ThingsBoard自身的配置:比如调整application.yml里的查询超时时间,避免慢查询卡住服务;调整JVM的内存参数,给堆内存分配足够的空间,同时设置内存溢出的自动重启规则。

四、实际落地示例(单一技术栈:Java)

我们用Java连接TimescaleDB,分别展示“导致内存溢出的坏代码”和“优化后的代码”,对比两者的差异。

4.1 问题代码示例(内存溢出的根源)

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;

// 坏写法:应用层拉全量数据再聚合,直接导致内存溢出
public class BadAggQuery {
    // TimescaleDB连接信息(ThingsBoard默认使用的时序数据库)
    private static final String DB_URL = "jdbc:postgresql://localhost:5432/thingsboard";
    private static final String DB_USER = "postgres";
    private static final String DB_PWD = "postgres";

    // 查询某设备当天的小时平均温度(坏写法)
    public List<Double> getHourlyAvgTemp(long deviceId, long startTs, long endTs) throws Exception {
        List<Double> allTemperatures = new ArrayList<>();
        // 1. 连接数据库
        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PWD)) {
            // 2. 直接查询所有符合时间范围的原始温度数据
            String sql = "SELECT temperature FROM ts_kv WHERE entity_id = ? AND ts BETWEEN ? AND ?";
            try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
                pstmt.setLong(1, deviceId);
                pstmt.setLong(2, startTs);
                pstmt.setLong(3, endTs);
                ResultSet rs = pstmt.executeQuery();
                // 3. 把所有数据塞进内存(这里就会爆内存!)
                while (rs.next()) {
                    allTemperatures.add(rs.getDouble("temperature"));
                }
                rs.close();
            }
        }
        // 4. 应用层手动聚合(这里数据太多,可能卡住)
        List<Double> hourlyAvgs = new ArrayList<>();
        for (int i = 0; i < 24; i++) {
            hourlyAvgs.add(allTemperatures.stream().skip(i*360).limit(360).mapToDouble(Double::doubleValue).average().orElse(0));
        }
        return hourlyAvgs;
    }
}

4.2 优化后的代码示例(内存稳定,性能提升)

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;

// 优化写法:数据库端聚合+游标分页,不会爆内存
public class OptimizedAggQuery {
    private static final String DB_URL = "jdbc:postgresql://localhost:5432/thingsboard";
    private static final String DB_USER = "postgres";
    private static final String DB_PWD = "postgres";

    // 查询某设备当天的小时平均温度(优化写法)
    public List<Double> getHourlyAvgTemp(long deviceId, long startTs, long endTs) throws Exception {
        List<Double> hourlyAvgs = new ArrayList<>();
        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PWD)) {
            // 1. 用TimescaleDB的time_bucket函数按1小时分组,数据库端直接聚合
            // 2. 用时间戳作为游标分页,每次只取100条聚合结果(这里是小时级,最多24条,不用分页)
            String sql = "SELECT time_bucket('1 hour', ts) AS hour, AVG(temperature) AS avg_temp " +
                         "FROM ts_kv WHERE entity_id = ? AND ts BETWEEN ? AND ? " +
                         "GROUP BY hour ORDER BY hour";
            try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
                pstmt.setLong(1, deviceId);
                pstmt.setLong(2, startTs);
                pstmt.setLong(3, endTs);
                ResultSet rs = pstmt.executeQuery();
                // 3. 只取数据库返回的聚合结果(最多24条,内存占用可以忽略)
                while (rs.next()) {
                    hourlyAvgs.add(rs.getDouble("avg_temp"));
                }
                rs.close();
            }
        }
        return hourlyAvgs;
    }
}

五、优化前后的效果对比

5.1 内存占用变化

优化前:单请求内存占用超200MB,多请求叠加后直接超过JVM内存上限,导致OOM;优化后:每个请求的内存占用仅几MB,即使有十几个并行请求,内存也稳定在安全范围内。

5.2 查询效率提升

优化前:800万条数据的聚合时间超2秒,还占用大量CPU;优化后:数据库端聚合时间仅0.3秒,应用层只需要返回结果,总时间缩短到0.5秒,效率提升4倍以上。

六、应用场景、优缺点、注意事项总结

6.1 适用场景

这个优化方案特别适合物联网场景下的时序数据统计:比如设备的温度/电压/流量的小时/日/周级聚合查询、历史数据导出、ThingsBoard仪表盘的聚合数据展示等。

6.2 技术优缺点

基础分页(LIMIT OFFSET):优点是实现简单,不需要改复杂逻辑;缺点是大数据量下性能差,不适合翻到后面的页。 游标分页(基于时间戳):优点是性能稳定,不管翻到哪一页,数据库都能快速返回;缺点是只能按时间戳排序,不能支持随机跳转(比如跳转到第5页),但时序场景下基本不需要随机跳转。

6.3 注意事项

  1. 永远不要在应用层做聚合:把计算任务交给数据库,数据库比应用更擅长处理大量数据的聚合;
  2. 时序数据尽量用游标分页:避免用OFFSET,减少数据库的扫描开销;
  3. 调整数据库配置:给TimescaleDB加索引(比如按entity_id和ts联合索引),提升聚合和分页的速度;
  4. 监控内存和查询:用Prometheus监控ThingsBoard的JVM内存,设置告警,及时调整参数。