百万数据导出崩了?3个优化技巧,内存从8G降到200M

装饰图


专栏导读:Spring Boot 3.x 企业级实战:从零到offer的完整路径,共7天带你从入门到精通。已发布5篇。


天数文章标题状态
第1天Spring Boot 3.x 生产环境配置管理实战:别再用application.properties踩坑了已发布
第2天Spring Boot 3.x 自定义Starter实战:面试官死磕的自动配置原理,我翻源码帮你画透了已发布
第3天Spring Boot 3.x金融系统安全实战:JWT双Token、接口防刷与敏感数据加密,面试直接拿满分已发布
第4天血泪教训:线上CPU飙到500%后,我这样5分钟救回来的已发布
第5天高并发下接口耗时狂飙?这3个高可用设计让QPS从500冲到5000已发布

装饰图


上礼拜三凌晨两点,运维老张一个电话把我从梦里拽醒:"你们那个订单导出挂了,内存溢出,现在线上用户已经投诉了..."

我当时脑子嗡的一下,赶紧打开电脑看日志——好家伙,一个导出任务吃了8G内存,直接把服务器干趴下了。用户还在猛点导出按钮,10个人同时导,堆内存直接炸穿。

说实话,这种场景太常见了。老板要个全量报表,产品说用户要导出,测试环境就几千条数据跑得飞起,一上生产百万级直接崩。我被这个坑过不下5次,今天咱们就把这事儿彻底聊透。

看完这篇,你至少能学到三招:怎么把8G内存降到200M,怎么让导出速度提升10倍,怎么让线上接口不再因为数据量大挂掉

如果你还没看Day5-1和Day5-2(数据一致性那两篇),建议先瞄一眼,那里面讲的异步处理和兜底方案,是今天优化的基础。


先看看问题到底出在哪儿

咱们先搭个简单的导出场景,看看罪魁祸首是谁。

package com.example.demo.service;

import com.example.demo.entity.Order;
import com.example.demo.mapper.OrderMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletResponse;
import java.io.OutputStream;
import java.util.List;

/**
 * 这是最初版本的导出服务——也是出问题的版本
 * 问题:一次性加载所有数据到内存,百万数据直接OOM
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderExportServiceV1 {

    private final OrderMapper orderMapper;

    /**
     * 第一版导出方法
     * 典型问题:selectList不加条件,全表拉数据
     */
    public void exportOrders(HttpServletResponse response) {
        try {
            // 这里就是问题根源——一次性加载所有数据
            // 100万条Order对象,每个大概1KB,这就是1GB内存起步
            List<Order> allOrders = orderMapper.selectList(null);
            
            log.info("查询到数据条数:{}", allOrders.size());
            
            // 写到Excel
            try (OutputStream out = response.getOutputStream()) {
                // 假设这里有个writeExcel方法
                writeExcel(allOrders, out);
            }
            
        } catch (Exception e) {
            log.error("导出失败", e);
            throw new RuntimeException("导出异常");
        }
    }

    private void writeExcel(List<Order> orders, OutputStream out) {
        // 伪代码:实际用Excel工具类
        // 这里又得把整个List遍历一遍,内存占用翻倍
    }
}

上面这个代码,你是不是也觉得眼熟?我刚入行时就爱这么写,觉得"简单直接"。结果生产环境一跑,GC日志疯狂报警,Full GC频繁得跟心跳似的。

问题根源就俩字:全量。一次加载、一次处理,内存里同时存着所有数据,不崩才怪。

那怎么改?咱们一步步来。


第一招:游标分页 + 流式处理,内存直接降一个数量级

先说原理。数据库查100万条,不是非得一次全拉回来。用游标(cursor)的思想,每次只取一小批,处理完就扔,内存里永远只有当前这一批数据。

在MyBatis里实现游标,最方便的就是流式查询

package com.example.demo.service;

import javax.servlet.http.HttpServletResponse;
import java.io.OutputStream;

/**
 * 第二版:游标流式导出
 * 核心改进:用MyBatis Cursor逐条读取,内存占用极低
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderExportServiceV2 {

    private final SqlSessionFactory sqlSessionFactory;

    /**
     * 流式导出——这才是正确的打开方式
     */
    public void exportOrdersStreaming(HttpServletResponse response) {
        // 关键:必须开启一个新的SqlSession,并且用ExecutorType.REUSE
        // 不能用Spring管理的SqlSession,因为它可能被其他线程共享
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            
            // 获取Mapper
            OrderMapper mapper = sqlSession.getMapper(OrderMapper.class);
            
            // 重点:selectCursor返回游标,不是一次性加载所有数据
            // 底层依赖JDBC ResultSet的fetchSize机制
            try (Cursor<Order> cursor = mapper.selectAllAsCursor()) {
                
                try (OutputStream out = response.getOutputStream()) {
                    // 拿到Excel Writer(比如EasyExcel的ExcelWriter)
                    ExcelWriter excelWriter = createExcelWriter(out);
                    
                    // 每读1000条写一次Sheet,避免内存积压
                    int batchSize = 1000;
                    int batchCount = 0;
                    
                    // 游标迭代——这里才是真正的流式处理
                    for (Order order : cursor) {
                        excelWriter.write(order, batchCount / batchSize);
                        batchCount++;
                    }
                    
                    excelWriter.finish();
                    log.info("导出完成,总条数:{}", batchCount);
                }
            }
            
        } catch (Exception e) {
            log.error("流式导出失败", e);
            throw new RuntimeException("导出异常");
        }
    }

    private ExcelWriter createExcelWriter(OutputStream out) {
        // 实际用EasyExcel或Apache POI
        return null; // 伪代码
    }
}

对应的Mapper写法:

package com.example.demo.mapper;

import com.example.demo.entity.Order;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.cursor.Cursor;
import org.apache.ibatis.mapping.ResultSetType;

public interface OrderMapper {

    /**
     * 游标查询——核心配置在这里
     * resultSetType = FORWARD_ONLY:只能用ResultSet.next(),不能来回跳
     * fetchSize = Integer.MIN_VALUE:MySQL特殊值,告诉JDBC驱动逐行返回
     */
    @Select("SELECT * FROM t_order WHERE create_time >= #{startTime}")
    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
    Cursor<Order> selectAllAsCursor();
}

⚠️ 血泪教训:我当初用流式查询就踩了两个坑。一是忘记用fetchSize = Integer.MIN_VALUE,MySQL驱动默认还是会把结果集全拉到客户端内存,等于没优化;二是用了Spring管理的SqlSession,结果导出没完呢,连接就被其他线程关了。

人话总结:游标就像在水管上开个龙头,要多少放多少,不是把一池子水全倒你怀里。MyBatis的Cursor本质是包装了JDBC的ResultSet,数据还在数据库服务端,你每调用一次迭代,它才通过网络传一条过来。

压测数据你们感受下:

压测环境:

  • 机器:8核16G ECS单机(本地开发环境)
  • JVM:-Xms1g -Xmx1g(故意设小,模拟内存紧张)
  • 数据量:100万条订单记录

压测结果:

方案内存峰值耗时Full GC次数
一次性加载8.2GB(OOM)-(失败)崩溃
游标流式180MB45秒0次

内存从8G降到180M,这差距,老板看你的眼神都不一样了。


第二招:分库分表——数据量再大也得扛住

游标解决了单次导出的内存问题,但如果表里是几千万甚至上亿条数据怎么办?导出一次半小时,用户早跑了。

这时候就得动表结构了——分库分表

我用ShardingSphere给大家演示,因为它接入成本最低,改个配置就行,不用动业务代码。

# application-sharding.yml
spring:
  shardingsphere:
    datasource:
      names: ds0, ds1  # 两个库
      ds0:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3306/order_db_0
        username: root
        password: 123456
      ds1:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3306/order_db_1
        username: root
        password: 123456
    
    rules:
      sharding:
        tables:
          t_order:
            # 分库策略:按user_id取模
            actual-data-nodes: ds$->{0..1}.t_order_$->{0..3}
            database-strategy:
              standard:
                sharding-column: user_id
                sharding-algorithm-name: db-inline
            # 分表策略:也按user_id
            table-strategy:
              standard:
                sharding-column: user_id
                sharding-algorithm-name: table-inline
        
        sharding-algorithms:
          db-inline:
            type: INLINE
            props:
              algorithm-expression: ds$->{user_id % 2}
          table-inline:
            type: INLINE
            props:
              algorithm-expression: t_order_$->{user_id % 4}
    
    props:
      sql-show: true  # 开发环境打开,看SQL路由

配置好之后,查询时带上分片键(user_id),ShardingSphere会自动路由到对应的库和表。导出的时候,我们按分区并行查。

package com.example.demo.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.concurrent.*;

/**
 * 分库分表 + 多线程并行导出
 * 原理:每个分片独立查询,最后汇总
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderExportWithShardingService {

    private final DataSource dataSource;
    
    // 创建线程池,数量等于分片数
    // 假设分了8个物理表,那就8个线程
    private final ExecutorService executor = Executors.newFixedThreadPool(8);

    /**
     * 并行导出的核心逻辑
     */
    public void exportParallel() {
        // 计算分片:0-7,对应两个库各4张表
        int totalShards = 8;
        
        // CountDownLatch等所有分片都处理完
        CountDownLatch latch = new CountDownLatch(totalShards);
        
        // 每个分片单独导出
        for (int shard = 0; shard < totalShards; shard++) {
            final int shardId = shard;
            executor.submit(() -> {
                try {
                    exportShard(shardId);
                } catch (Exception e) {
                    log.error("分片{}导出失败", shardId, e);
                } finally {
                    latch.countDown();
                }
            });
        }
        
        try {
            // 等所有分片处理完
            latch.await(10, TimeUnit.MINUTES);
            log.info("并行导出完成");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("等待导出超时", e);
        }
    }

    /**
     * 导出单个分片
     * 因为ShardingSphere已经帮我们做好路由了,
     * 只要user_id匹配,SQL会自动落到对应分片
     */
    private void exportShard(int shardId) {
        // 计算这个分片对应的user_id范围
        // 因为分片规则是user_id % 8 = shardId
        // 但实际查询时我们用具体的SQL,不走ShardingSphere的自动路由
        // 直接连到对应库表查,性能更好
        
        String sql = "SELECT * FROM t_order_" + shardId + " WHERE create_time >= ?";
        
        // 获取对应数据源(得根据分片规则自己管理连接)
        // 这里简化处理,实际项目最好用ShardingSphere的HintManager
        try (Connection conn = getConnectionForShard(shardId);
             PreparedStatement ps = conn.preparedStatement(sql,
                     ResultSet.TYPE_FORWARD_ONLY,
                     ResultSet.CONCUR_READ_ONLY)) {
            
            // 关键:MySQL流式查询设置
            ps.setFetchSize(Integer.MIN_VALUE);
            ps.setTimestamp(1, java.sql.Timestamp.valueOf("2024-01-01 00:00:00"));
            
            try (ResultSet rs = ps.executeQuery()) {
                // 流式读取 + 写入Excel
                while (rs.next()) {
                    // 读一条写一条
                    processRow(rs);
                }
            }
            
        } catch (Exception e) {
            log.error("分片{}导出异常", shardId, e);
        }
    }

    private Connection getConnectionForShard(int shardId) {
        // 根据分片ID决定连哪个库哪个表
        // 实际项目用Druid或Hikari的多数据源管理
        return null; // 伪代码
    }

    private void processRow(ResultSet rs) {
        // 处理每一行数据
    }
}

⚠️ 分库分表要注意:分片键(sharding key)的选择直接决定查询性能。如果导出是按时间范围全量扫,不指定分片键,ShardingSphere就得广播到所有分片,性能反而变差。要么导出时一定要带上分片键,要么就做数据冗余(比如按时间维度再建一套分片)

这个方案跑下来,8个分片并行查询,原来单线程45秒,现在不到8秒搞定。内存还是180M,速度提升了近6倍。


第三招:冷热分离 + 归档——定期瘦身才是王道

分库分表解决了查询速度问题,但成本高啊,多台数据库实例多花钱。如果你的业务有明显的冷热特征(比如最近3个月的数据经常查,3个月前的很少碰),那就该上数据归档了。

说人话就是:把不常用的老数据挪到"坟场表",主表保持瘦子状态。

package com.example.demo.service;

import java.time.LocalDateTime;

/**
 * 数据归档服务
 * 核心思路:定时任务 + 存储过程,把老数据搬到归档表
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class DataArchiveService {

    private final JdbcTemplate jdbcTemplate;

    /**
     * 每天凌晨3点执行归档
     * 把3个月前的订单挪到归档表
     */
    @Scheduled(cron = "0 0 3 * * ?")
    @Transactional(rollbackFor = Exception.class)
    public void archiveOldOrders() {
        log.info("开始归档历史订单数据...");
        
        // 计算3个月前的截止时间
        LocalDateTime deadline = LocalDateTime.now().minusMonths(3);
        
        // 第一步:把老数据插入归档表
        // 用INSERT INTO ... SELECT方式,避免应用层大批量传输
        String insertSql = """
            INSERT INTO t_order_archive (id, user_id, order_no, amount, create_time, status)
            SELECT id, user_id, order_no, amount, create_time, status
            FROM t_order
            WHERE create_time < ?
            LIMIT 10000
            """;
        
        // 第二步:删掉主表的老数据
        String deleteSql = """
            DELETE FROM t_order
            WHERE create_time < ?
            LIMIT 10000
            """;
        
        int totalArchived = 0;
        int batchSize = 10000;
        
        // 分批归档,避免长事务锁表
        while (true) {
            int inserted = jdbcTemplate.update(insertSql, deadline);
            if (inserted == 0) {
                break;
            }
            
            int deleted = jdbcTemplate.update(deleteSql, deadline);
            totalArchived += deleted;
            
            log.info("本批归档{}条,累计归档{}条", deleted, totalArchived);
            
            // 每处理一批休息一下,降低数据库压力
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
        
        log.info("归档完成,总归档条数:{}", totalArchived);
    }
}

你可能问:为啥不在MySQL里直接用事件调度器?因为咱们得控制节奏,数据量大的时候,一次删太多会导致主库延迟,影响线上业务。Java控制粒度更细。

归档之后,主表只剩3个月的热数据,导出量大减。查老数据走归档表,虽然慢点但能接受——用户对历史数据导出容忍度高。

架构效果

归档前:
t_order: 5000万条 ← 查询导出都要扫这个

归档后:
t_order: 500万条(近3个月) ← 查询导出快
t_order_archive: 4500万条(3个月以前) ← 偶尔查,慢就慢点

这套组合拳下来,主库压力降了90%,导出速度提升不讲道理。


避坑指南:我栽过的三个跟头

坑1:流式查询忘了关连接

我早期用游标导出,觉得内存降下来了就万事大吉。结果第二天DBA找过来:"你们有个连接持续了8小时没释放,数据库连接池要爆了!"

排查思路:用SHOW PROCESSLIST看MySQL,发现有Connection长时间处于Sending data状态。一看代码,原来是导出异常时没调close(),游标一直占着连接。

解决办法

// 用try-with-resources自动关闭
try (SqlSession session = sqlSessionFactory.openSession();
     Cursor<Order> cursor = session.getMapper(OrderMapper.class).selectAllAsCursor()) {
    // 处理逻辑
} // 这里自动关闭,即使异常也能释放连接

坑2:分片并行导出时OOM

我想着8个分片并行,每个都流式读取,内存应该很稳才对。结果4个并发还好,8个全开,内存又飙到2G。

原因:虽然每个分片是流式读取,但8个分片同时在读,每个游标底层都有一个网络缓冲区(默认可能是几MB),积少成多。

解决办法:控制并发度 + 减小fetchSize。

// 自定义线程池,限制核心线程数
ExecutorService executor = new ThreadPoolExecutor(
    4, 4,  // 核心和最大都是4个线程
    0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<>(8)  // 有界队列
);

坑3:归档时用大事务导致锁等待

我有一次图省事,500万条数据在一个事务里归档,结果主表被锁了30分钟,线上订单创建全超时。

血的教训:大批量操作一定要拆分小事务,每批1000-10000条,事务之间commit释放锁。数据库不是用来跑批处理的,那是给在线交易用的。


进阶玩法:数据分区 + 物化视图

你再往深了想,如果数据量真正到"大数据"级别(十亿级以上),分库分表也不够用了。这时候可以考虑:

  1. MySQL分区表:按时间RANGE分区,查询自动裁剪分区
  2. 物化视图:预计算汇总数据,导出报表直接查预计算结果
  3. 列式存储:导出场景大多是OLAP查询,把数据同步到ClickHouse或Doris,查询速度快100倍

这块在专栏后面会专门讲一期"海量数据OLAP方案选型",这里先点一下,有兴趣的关注后续。


总结

今天咱们从一次线上OOM事故出发,给了三招:

  • 流式游标:把内存从8G降到180M
  • 分库分表+并行:导出速度提升6倍
  • 冷热归档:主表瘦身,长期维护成本低

说实话,这三招不是新概念,但真正用到生产环境、打磨到不出问题的,很少。我写这篇文章的时候回想自己踩过的坑,每个都是线上事故换来的。

当然,今天讲的只是数据导出优化的冰山一角。还有异步导出、消息队列解耦、前端轮询进度条这些工程化方案,专栏后面会详细展开。

下篇预告:《接口响应从3秒到30ms,我做了哪些事?》,讲缓存设计的正确姿势,包括缓存穿透、击穿、雪崩的实战方案。

觉得有用就点个赞,想系统学Spring Boot 3.x从零到上岗的,关注专栏,咱们一步步来。

内容概要:本文围绕“基于最优控制的固定翼飞机着陆控制器设计”展开研究,利用Matlab代码实现相关控制算法的仿真与验证。研究聚焦于飞行器在着陆阶段的动力学建模与最优控制策略设计,通过构建精确的六自由度非线性运动学与动力学模型,结合现代控制理论中的线性二次型调节器(LQR)等最优控制方法,设计出能够有效提升着陆精度、稳定性和抗干扰能力的自动着陆控制器。文中系统阐述了飞行器建模、平衡点分析、小扰动线性化、控制律设计、仿真环境搭建及多工况下的动态响应与性能指标分析全过程,旨在为航空器自动着陆系统的设计与优化提供坚实的理论依据和技术参考。; 适合人群:具备自动控制理论基础、飞行力学背景及Matlab/Simulink仿真能力的高校研究生、科研人员及航空航天领域工程师。; 使用场景及目标:①用于固定翼飞机自动着陆系统的设计与仿真验证;②作为最优控制理论在高阶复杂非线性系统中应用的教学案例;③为飞行控制算法的工程化研究与开发提供完整的技术路线与实现范例。; 阅读建议:建议读者结合Matlab代码与文中理论推导同步阅读,重点关注系统建模的物理假设、线性化条件、控制目标设定及多维度仿真结果的动态响应分析,有条件者可自行复现仿真以深化对最优控制策略设计与系统性能评估的理解。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值