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

文章目录
上礼拜三凌晨两点,运维老张一个电话把我从梦里拽醒:"你们那个订单导出挂了,内存溢出,现在线上用户已经投诉了..."
我当时脑子嗡的一下,赶紧打开电脑看日志——好家伙,一个导出任务吃了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) | -(失败) | 崩溃 |
| 游标流式 | 180MB | 45秒 | 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释放锁。数据库不是用来跑批处理的,那是给在线交易用的。
进阶玩法:数据分区 + 物化视图
你再往深了想,如果数据量真正到"大数据"级别(十亿级以上),分库分表也不够用了。这时候可以考虑:
- MySQL分区表:按时间RANGE分区,查询自动裁剪分区
- 物化视图:预计算汇总数据,导出报表直接查预计算结果
- 列式存储:导出场景大多是OLAP查询,把数据同步到ClickHouse或Doris,查询速度快100倍
这块在专栏后面会专门讲一期"海量数据OLAP方案选型",这里先点一下,有兴趣的关注后续。
总结
今天咱们从一次线上OOM事故出发,给了三招:
- 流式游标:把内存从8G降到180M
- 分库分表+并行:导出速度提升6倍
- 冷热归档:主表瘦身,长期维护成本低
说实话,这三招不是新概念,但真正用到生产环境、打磨到不出问题的,很少。我写这篇文章的时候回想自己踩过的坑,每个都是线上事故换来的。
当然,今天讲的只是数据导出优化的冰山一角。还有异步导出、消息队列解耦、前端轮询进度条这些工程化方案,专栏后面会详细展开。
下篇预告:《接口响应从3秒到30ms,我做了哪些事?》,讲缓存设计的正确姿势,包括缓存穿透、击穿、雪崩的实战方案。
觉得有用就点个赞,想系统学Spring Boot 3.x从零到上岗的,关注专栏,咱们一步步来。
3161

被折叠的 条评论
为什么被折叠?



