📦 大批量数据导入导出优化:从龟速到光速的蜕变!

95 阅读8分钟

"老板让我导出100万条数据,我点了导出按钮,然后...程序崩了!" 💀

📖 为什么大批量数据操作这么难?

想象你要搬家:

  • 小搬家:3个箱子,自己一趟搬完 ✅
  • 大搬家:300个箱子,自己搬?累死也搬不完!😱

大批量数据操作的挑战

100万条数据导出:

❌ 一次性全加载到内存:
   100万 × 2KB = 2GB 内存 → OutOfMemoryError!💥

❌ 一次性全写入Excel:
   Excel 2007+ 最多 104万行 → 超限报错!

❌ 单线程串行处理:
   100万条 ÷ 1000条/秒 = 1000秒 ≈ 17分钟 → 用户等疯了!

问题:
  - 内存不够 💾
  - 时间太长 ⏱️
  - 容易超时 ⏰
  - 数据库压力大 📊

🎯 优化策略总览

大批量数据处理三板斧:

1️⃣ 分批处理(Batch Processing)
   → 化整为零,逐批操作

2️⃣ 并行处理(Parallel Processing)
   → 多线程/多进程同时处理

3️⃣ 流式处理(Stream Processing)
   → 边读边处理边写,不占内存

🔥 优化技巧一:数据导入(INSERT)

❌ 反面教材:逐条插入

// 单条插入(最慢!)
public void importUsers(List<User> users) {
    for (User user : users) {
        userMapper.insert(user);  // 每次1条SQL
    }
}

// 100万条数据:
//   100万次 SQL
//   100万次网络往返
//   100万次事务提交
//   耗时:约 3000 秒(50分钟)😱

为什么慢?

每次插入都要:
1. 应用 → 数据库(网络往返)    100ms
2. 数据库解析 SQL                1ms
3. 数据库执行 INSERT             1ms
4. 提交事务                      10ms
5. 数据库 → 应用(返回结果)    100ms
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
单条耗时:约 212ms

100万条 × 212ms = 58小时!!!

✅ 优化方案1:批量插入(JDBC Batch)

// 批量插入(快1000倍!)
public void importUsersBatch(List<User> users) {
    int batchSize = 1000;  // 每批1000条
    
    for (int i = 0; i < users.size(); i += batchSize) {
        int end = Math.min(i + batchSize, users.size());
        List<User> batch = users.subList(i, end);
        
        // 批量插入
        userMapper.insertBatch(batch);
    }
}
<!-- MyBatis XML -->
<insert id="insertBatch">
    INSERT INTO user (name, age, email)
    VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.name}, #{user.age}, #{user.email})
    </foreach>
</insert>

<!-- 生成的 SQL:
INSERT INTO user (name, age, email)
VALUES
  ('张三', 25, 'zhang@example.com'),
  ('李四', 30, 'li@example.com'),
  ('王五', 28, 'wang@example.com'),
  ...(1000条)
-->

性能对比

方式100万条耗时提升倍数
单条插入50分钟-
批量插入(1000条/批)3分钟17倍

✅ 优化方案2:JDBC PreparedStatement 批处理

// 原生 JDBC 批处理(最快!)
public void importUsersJdbcBatch(List<User> users) throws SQLException {
    String sql = "INSERT INTO user (name, age, email) VALUES (?, ?, ?)";
    
    try (Connection conn = dataSource.getConnection();
         PreparedStatement pst = conn.prepareStatement(sql)) {
        
        // 关闭自动提交
        conn.setAutoCommit(false);
        
        int batchSize = 1000;
        int count = 0;
        
        for (User user : users) {
            pst.setString(1, user.getName());
            pst.setInt(2, user.getAge());
            pst.setString(3, user.getEmail());
            pst.addBatch();  // 添加到批次
            
            if (++count % batchSize == 0) {
                pst.executeBatch();  // 执行批次
                conn.commit();        // 提交事务
                pst.clearBatch();     // 清空批次
            }
        }
        
        // 处理剩余数据
        pst.executeBatch();
        conn.commit();
    }
}

// 100万条数据:约 2分钟 ⚡⚡

✅ 优化方案3:MyBatis-Plus 批量插入

@Service
public class UserService extends ServiceImpl<UserMapper, User> {
    
    // MyBatis-Plus 提供的批量插入
    public void importUsers(List<User> users) {
        this.saveBatch(users, 1000);  // 每批1000条
    }
}

// 底层使用 JDBC Batch
// 简单易用,性能也不错

✅ 优化方案4:并行批量插入(最快!)

@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private ThreadPoolExecutor executor;
    
    // 并行批量插入
    public void importUsersParallel(List<User> users) throws Exception {
        int batchSize = 1000;
        int threadCount = 10;  // 10个线程并行
        int totalSize = users.size();
        int chunkSize = (totalSize + threadCount - 1) / threadCount;
        
        List<Future<?>> futures = new ArrayList<>();
        
        // 分成10个任务
        for (int i = 0; i < threadCount; i++) {
            int start = i * chunkSize;
            int end = Math.min(start + chunkSize, totalSize);
            
            if (start >= totalSize) break;
            
            List<User> chunk = users.subList(start, end);
            
            // 提交异步任务
            Future<?> future = executor.submit(() -> {
                // 每个线程处理一批数据
                for (int j = 0; j < chunk.size(); j += batchSize) {
                    int batchEnd = Math.min(j + batchSize, chunk.size());
                    List<User> batch = chunk.subList(j, batchEnd);
                    userMapper.insertBatch(batch);
                }
            });
            
            futures.add(future);
        }
        
        // 等待所有任务完成
        for (Future<?> future : futures) {
            future.get();
        }
    }
}

// 100万条数据:约 20秒 ⚡⚡⚡
// 性能提升:150倍!

线程池配置

@Configuration
public class ThreadPoolConfig {
    
    @Bean
    public ThreadPoolExecutor importExecutor() {
        return new ThreadPoolExecutor(
            10,                     // 核心线程数
            20,                     // 最大线程数
            60L,                    // 空闲线程存活时间
            TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(100),  // 队列容量
            new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略
        );
    }
}

🔥 优化技巧二:数据导出(SELECT)

❌ 反面教材:一次性全查询

// 一次性查询所有数据(内存爆炸!)
public void exportUsers(HttpServletResponse response) {
    List<User> users = userMapper.selectAll();  // 查询100万条
    // OutOfMemoryError!💥
    
    // 写入 Excel
    ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build();
    writer.write(users);
}

// 问题:
//   - 100万条 × 2KB = 2GB 内存
//   - ResultSet 也要占内存
//   - Excel 对象也要占内存
//   - 总共可能需要 5GB+ 内存!

✅ 优化方案1:分页查询 + 流式写入

@GetMapping("/export/users")
public void exportUsers(HttpServletResponse response) throws IOException {
    response.setContentType("application/vnd.ms-excel");
    response.setHeader("Content-Disposition", 
        "attachment;filename=users.xlsx");
    
    // 流式写入(不占内存)
    ExcelWriter writer = EasyExcel.write(response.getOutputStream(), User.class)
        .build();
    WriteSheet sheet = EasyExcel.writerSheet("用户").build();
    
    int pageSize = 10000;  // 每页1万条
    int pageNum = 1;
    
    while (true) {
        // 分页查询
        PageHelper.startPage(pageNum, pageSize);
        List<User> users = userMapper.selectAll();
        
        if (users.isEmpty()) {
            break;  // 没有数据了
        }
        
        // 流式写入(立即释放内存)
        writer.write(users, sheet);
        
        pageNum++;
    }
    
    writer.finish();
}

// 内存占用:约 200MB(只保留当前页)
// 时间:约 5分钟(100万条)

✅ 优化方案2:游标查询(MyBatis Cursor)

// Mapper 接口
@Mapper
public interface UserMapper {
    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 1000)
    @Select("SELECT * FROM user")
    Cursor<User> selectCursor();
}

// Service
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    @Transactional(readOnly = true)  // 必须在事务中
    public void exportUsers(HttpServletResponse response) throws IOException {
        response.setContentType("application/vnd.ms-excel");
        response.setHeader("Content-Disposition", 
            "attachment;filename=users.xlsx");
        
        ExcelWriter writer = EasyExcel.write(response.getOutputStream(), User.class)
            .build();
        WriteSheet sheet = EasyExcel.writerSheet("用户").build();
        
        // 游标查询(流式读取)
        try (Cursor<User> cursor = userMapper.selectCursor()) {
            List<User> batch = new ArrayList<>(1000);
            
            for (User user : cursor) {
                batch.add(user);
                
                // 每1000条写入一次
                if (batch.size() >= 1000) {
                    writer.write(batch, sheet);
                    batch.clear();  // 释放内存
                }
            }
            
            // 写入剩余数据
            if (!batch.isEmpty()) {
                writer.write(batch, sheet);
            }
        }
        
        writer.finish();
    }
}

// 优势:
//   - 内存占用极低(约 50MB)
//   - 流式处理,边读边写
//   - 不会OOM

✅ 优化方案3:异步导出 + 消息通知

// 适用场景:数据量特别大(100万+),用户等不起

@RestController
public class ExportController {
    
    @Autowired
    private ExportService exportService;
    
    // 提交导出任务(立即返回)
    @PostMapping("/export/users/async")
    public Result<String> exportAsync() {
        String taskId = UUID.randomUUID().toString();
        
        // 异步执行
        exportService.exportAsync(taskId);
        
        return Result.success(taskId, "导出任务已提交,完成后会发送通知");
    }
    
    // 查询任务进度
    @GetMapping("/export/task/{taskId}")
    public Result<ExportTask> getTask(@PathVariable String taskId) {
        ExportTask task = exportService.getTask(taskId);
        return Result.success(task);
    }
}

@Service
public class ExportService {
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private AsyncTaskExecutor executor;
    
    @Autowired
    private OSSClient ossClient;  // 阿里云OSS
    
    private Map<String, ExportTask> tasks = new ConcurrentHashMap<>();
    
    @Async
    public void exportAsync(String taskId) {
        ExportTask task = new ExportTask(taskId);
        tasks.put(taskId, task);
        
        try {
            // 更新状态:处理中
            task.setStatus("PROCESSING");
            
            // 导出到临时文件
            File tempFile = File.createTempFile("users_", ".xlsx");
            
            try (FileOutputStream fos = new FileOutputStream(tempFile)) {
                ExcelWriter writer = EasyExcel.write(fos, User.class).build();
                WriteSheet sheet = EasyExcel.writerSheet("用户").build();
                
                int pageSize = 10000;
                int pageNum = 1;
                int total = userMapper.count();
                
                while (true) {
                    PageHelper.startPage(pageNum, pageSize);
                    List<User> users = userMapper.selectAll();
                    
                    if (users.isEmpty()) break;
                    
                    writer.write(users, sheet);
                    
                    // 更新进度
                    int processed = Math.min(pageNum * pageSize, total);
                    task.setProgress(processed * 100 / total);
                    
                    pageNum++;
                }
                
                writer.finish();
            }
            
            // 上传到 OSS
            String url = ossClient.upload(tempFile);
            
            // 更新状态:完成
            task.setStatus("COMPLETED");
            task.setDownloadUrl(url);
            
            // 发送通知(短信/邮件/站内信)
            notificationService.send(task.getUserId(), 
                "您的数据导出已完成,下载链接:" + url);
            
        } catch (Exception e) {
            task.setStatus("FAILED");
            task.setErrorMsg(e.getMessage());
        }
    }
    
    public ExportTask getTask(String taskId) {
        return tasks.get(taskId);
    }
}

// 优势:
//   - 用户不需要等待 ✅
//   - 支持超大数据量(1000万+)✅
//   - 不会超时 ✅
//   - 用户体验好 ✅

🔥 优化技巧三:Excel 处理优化

EasyExcel vs POI

// ❌ Apache POI(内存占用高)
Workbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet("用户");

for (int i = 0; i < users.size(); i++) {
    Row row = sheet.createRow(i);
    User user = users.get(i);
    row.createCell(0).setCellValue(user.getName());
    row.createCell(1).setCellValue(user.getAge());
    // ...
}

workbook.write(outputStream);
// 100万行:需要 2GB+ 内存

// ✅ EasyExcel(内存占用低)
EasyExcel.write(outputStream, User.class)
    .sheet("用户")
    .doWrite(users);
// 100万行:只需 100MB 内存(20倍差距!)

EasyExcel 的优势

  • ✅ 内存占用低(10-20倍差距)
  • ✅ API 简单易用
  • ✅ 支持流式写入
  • ✅ 自动样式美化

CSV 导出(更快!)

// CSV 比 Excel 快 5-10 倍!
@GetMapping("/export/users/csv")
public void exportCSV(HttpServletResponse response) throws IOException {
    response.setContentType("text/csv");
    response.setHeader("Content-Disposition", 
        "attachment;filename=users.csv");
    
    try (PrintWriter writer = response.getWriter()) {
        // 写入表头
        writer.println("姓名,年龄,邮箱");
        
        // 流式查询 + 流式写入
        try (Cursor<User> cursor = userMapper.selectCursor()) {
            for (User user : cursor) {
                writer.printf("%s,%d,%s%n", 
                    user.getName(), user.getAge(), user.getEmail());
            }
        }
    }
}

// 性能对比:
//   Excel:100万行 5分钟
//   CSV:  100万行 1分钟 ⚡

📊 性能对比总结

数据导入性能对比

方案100万条耗时内存占用难度推荐指数
单条插入50分钟
MyBatis批量插入3分钟⭐⭐⭐⭐⭐
JDBC Batch2分钟⭐⭐⭐⭐⭐⭐⭐
并行批量插入20秒⭐⭐⭐⭐⭐⭐⭐⭐⭐

数据导出性能对比

方案100万条耗时内存占用难度推荐指数
一次性查询OOM5GB+
分页 + EasyExcel5分钟200MB⭐⭐⭐⭐⭐⭐
游标 + EasyExcel4分钟50MB⭐⭐⭐⭐⭐⭐⭐⭐
游标 + CSV1分钟50MB⭐⭐⭐⭐⭐⭐⭐
异步导出后台执行50MB⭐⭐⭐⭐⭐⭐⭐⭐⭐

💡 面试加分回答模板

面试官:"如何优化大批量数据的导入导出?"

标准回答

"我会从以下几个维度优化:

数据导入优化

  1. 批量插入:使用 JDBC Batch 或 MyBatis foreach,每批 1000-5000 条
  2. 并行处理:多线程并行插入,10线程可提升 10倍
  3. 事务控制:批量提交事务,避免每条都提交

数据导出优化

  1. 流式查询:使用 MyBatis Cursor,避免一次性加载到内存
  2. 流式写入:使用 EasyExcel 流式API,边读边写
  3. 异步导出:大数据量(100万+)用异步,完成后通知用户下载
  4. CSV替代Excel:如果不需要复杂格式,CSV 性能快 5-10 倍

实际案例: 我们有个订单导出功能,原来用 POI 一次性导出,500万订单直接 OOM。 优化后:

  • 改用 MyBatis Cursor + EasyExcel 流式写入
  • 内存从 5GB 降到 100MB
  • 导出时间从无法完成到 10分钟完成
  • 用户体验从频繁超时到稳定可用"

🎉 总结

大批量数据处理的核心原则

1. 分而治之(Divide and Conquer)
   → 大任务拆成小任务

2. 流式处理(Stream Processing)
   → 边读边处理边写,不占内存

3. 并行加速(Parallel Processing)
   → 多线程/多进程同时干活

4. 异步解耦(Async Decoupling)
   → 用户不等待,后台慢慢处理

记住这个公式

性能 = 批量大小 × 并行度 / 事务提交次数

优化方向:
  - 增大批量大小 ↑
  - 增加并行度 ↑
  - 减少事务提交次数 ↓

最后一句话

小数据量(<1万):怎么方便怎么来
中数据量(1-100万):批量 + 分页 + 流式
大数据量(>100万):异步 + 并行 + 通知

根据场景选方案,不要一刀切!🎯

祝你的数据导入导出又快又稳! 🚀📦


📚 扩展阅读