时间 | 更新内容 |
---|---|
2023/09/23 | fix: 每个sheet大小和存储内存条数一致的bug update: 增大一个sheet的默认容量 |
2024/08/1 | feat: 新增传统分页对比 |
前言.万级数据优化
我们不妨先给大家讲一个概念,利用此概念我们正好给大家介绍一个数据库优化的小技巧: 需求如下:将一个地市表的数据导出70万条。如果你不假思索,直接一条sql语句搞上去,直接就会内存溢出,因为mysql会将结果记录统一查询出来然后返还给内存:那内存可能直接OOM!
所以我们通常有如下几种解决方案:
一. 直接上流式查询封装工具代码
使用2核4G云服务器 下载速度在40-50s之间波动. 本机或大水管.我只能说更快了
流式查询
分页查询
/**
* @author YuanJie
* @projectName vector-server
* @package com.vector.common.utils.easyexcel
* @className com.vector.common.utils.easyexcel.CustomCellWeightStrategy
* @copyright Copyright 2020 vector, Inc All rights reserved.
* @date 2023/8/28 17:58
*/
public class CustomCellWeightStrategy extends AbstractColumnWidthStyleStrategy {
private final Map<Integer, Map<Integer, Integer>> CACHE = new HashMap<>();
@Override
protected void setColumnWidth(WriteSheetHolder writeSheetHolder, List<WriteCellData<?>> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) {
boolean needSetWidth = isHead || !CollectionUtils.isEmpty(cellDataList);
if (needSetWidth) {
Map<Integer, Integer> maxColumnWidthMap = CACHE.computeIfAbsent(writeSheetHolder.getSheetNo(), k -> new HashMap<>());
int columnWidth = this.dataLength(cellDataList, cell, isHead)+8;
if (columnWidth >= 0) {
if (columnWidth > 254) {
columnWidth = 254;
}
Integer maxColumnWidth = maxColumnWidthMap.get(cell.getColumnIndex());
if (maxColumnWidth == null || columnWidth > maxColumnWidth) {
maxColumnWidthMap.put(cell.getColumnIndex(), columnWidth);
Sheet sheet = writeSheetHolder.getSheet();
sheet.setColumnWidth(cell.getColumnIndex(), columnWidth * 200);
}
//设置单元格类型
cell.setCellType(CellType.STRING);
// 数据总长度
int length = cell.getStringCellValue().length();
// 换行数
int rows = cell.getStringCellValue().split("\n").length;
// 默认一行高为20
cell.getRow().setHeightInPoints(rows * 20);
}
}
}
/**
* 计算长度
*
* @param cellDataList
* @param cell
* @param isHead
* @return
*/
private Integer dataLength(List<WriteCellData<?>> cellDataList, Cell cell, Boolean isHead) {
if (isHead) {
return cell.getStringCellValue().getBytes().length;
} else {
CellData<?> cellData = cellDataList.get(0);
CellDataTypeEnum type = cellData.getType();
if (type == null) {
return -1;
} else {
switch (type) {
case STRING:
// 换行符(数据需要提前解析好)
int index = cellData.getStringValue().indexOf("\n");
return index != -1 ?
cellData.getStringValue().substring(0, index).getBytes().length + 1 : cellData.getStringValue().getBytes().length + 1;
case BOOLEAN:
return cellData.getBooleanValue().toString().getBytes().length;
case NUMBER:
return cellData.getNumberValue().toString().getBytes().length;
default:
return -1;
}
}
}
}
}
封装工具类EasyExcelUtil.class
/**
* EasyExcel工具类,用于简化Excel导出操作
*/
@Slf4j
public class EasyExcelUtil {
// 日期格式化器,用于格式化日期到yyyyMMdd
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
// 批量存储最大值,也影响sheet页数
private static final int MAX_SHEET_DATA = 50000;
// 内存最大值
private static final int MAX_MEMORY_DATA = 1000;
/**
* 使用EasyExcel生成Excel xls
*
* @param response 响应对象,用于输出Excel文件
* @param fileNameParam 文件名参数
* @param sheetName 工作表名称
* @param clazz 数据模型类
* @param t 泛型对象,用于获取游标
* @param func 函数式接口,用于获取数据游标
* @param <T> 泛型类型
* @throws Exception 可能抛出的异常
*/
public static <T> void writeExcelXls(HttpServletResponse response, String fileNameParam,
String sheetName, Class<?> clazz, T t,
Function<T, Cursor<?>> func) throws Exception {
streamExportExcel(response, fileNameParam, sheetName, clazz, ExcelTypeEnum.XLS.getValue(), t, func);
}
/**
* 使用EasyExcel生成Excel xlsx
*
* @param response 响应对象,用于输出Excel文件
* @param fileNameParam 文件名参数
* @param sheetName 工作表名称
* @param clazz 数据模型类
* @param t 泛型对象,用于获取游标
* @param func 函数式接口,用于获取数据游标
* @param <T> 泛型类型
* @throws Exception 可能抛出的异常
*/
public static <T> void writeExcelXlsx(HttpServletResponse response, String fileNameParam,
String sheetName, Class<?> clazz, T t,
Function<T, Cursor<?>> func) throws Exception {
streamExportExcel(response, fileNameParam, sheetName, clazz, ExcelTypeEnum.XLSX.getValue(), t, func);
}
/**
* 流式导出 Excel
*
* @param response 响应对象,用于输出Excel文件
* @param fileNameParam 文件名参数
* @param sheetName 工作表名称
* @param clazz 数据模型类
* @param excelType Excel文件类型
* @param t 泛型对象,用于获取游标
* @param func 函数式接口,用于获取数据游标
* @param <T> 泛型类型
* @throws Exception 可能抛出的异常
*/
private static <T> void streamExportExcel(HttpServletResponse response, String fileNameParam,
String sheetName, Class<?> clazz, String excelType,
T t, Function<T, Cursor<?>> func) throws Exception {
String fileName = fileNameParam + DATE_TIME_FORMATTER.format(LocalDateTime.now()) + excelType;
try (OutputStream outputStream = getOutputStream(fileName, response, excelType);
ExcelWriter excelWriter = EasyExcel.write(outputStream, clazz)
.registerWriteHandler(new CustomCellWeightStrategy())
.build();
Cursor<?> cursor = func.apply(t)) {
if (cursor == null || !cursor.iterator().hasNext()) {
log.warn("No data to export for file: {}", fileName);
return;
}
WriteCellStyle contentWriteCellStyle = new WriteCellStyle();
contentWriteCellStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);
contentWriteCellStyle.setVerticalAlignment(VerticalAlignment.CENTER);
contentWriteCellStyle.setWrapped(true);
HorizontalCellStyleStrategy horizontalCellStyleStrategy =
new HorizontalCellStyleStrategy(null, contentWriteCellStyle);
List<Object> list = new ArrayList<>();
int page = 0;
int count = 0;
WriteSheet writeSheet = createWriteSheet(++page, sheetName, horizontalCellStyleStrategy);
for (Object o : cursor) {
list.add(o);
if (list.size() >= MAX_MEMORY_DATA) {
count += list.size();
excelWriter.write(list, writeSheet);
list.clear();
if (count >= MAX_SHEET_DATA) {
writeSheet = createWriteSheet(++page, sheetName, horizontalCellStyleStrategy);
count = 0;
}
}
}
if (!list.isEmpty()) {
excelWriter.write(list, writeSheet);
}
} catch (Exception e) {
log.error("Error occurred while exporting Excel: {}", e.getMessage(), e);
response.reset();
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.displayName());
String json = JacksonInstance.toJson(R.errorResult("下载文件失败:" + e.getMessage()));
response.getWriter().println(json);
}
}
/**
* 创建WriteSheet对象
*
* @param page 页码
* @param sheetName 工作表名称
* @param strategy 样式策略
* @return WriteSheet对象
*/
private static WriteSheet createWriteSheet(int page, String sheetName, HorizontalCellStyleStrategy strategy) {
return EasyExcel.writerSheet(page, sheetName + page)
.registerWriteHandler(strategy)
.build();
}
/**
* 导出文件时为Writer生成OutputStream
*
* @param finalName 最终文件名
* @param response 响应对象
* @param excelType Excel文件类型
* @return OutputStream对象
* @throws Exception 可能抛出的异常
*/
private static OutputStream getOutputStream(String finalName, HttpServletResponse response, String excelType) throws Exception {
response.reset();
finalName = URLEncoder.encode(finalName, StandardCharsets.UTF_8);
if (ExcelTypeEnum.XLS.getValue().equals(excelType)) {
response.setContentType("application/vnd.ms-excel");
} else if (ExcelTypeEnum.XLSX.getValue().equals(excelType)) {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
}
response.setCharacterEncoding(StandardCharsets.UTF_8.displayName());
response.setHeader("Content-Disposition", "attachment; filename=" + finalName);
response.setHeader("Pragma", "public");
response.setHeader("Cache-Control", "no-store");
response.addHeader("Cache-Control", "max-age=0");
return response.getOutputStream();
}
/**
* 导出List类型数据到Excel
*
* @param response 响应对象
* @param fileName 文件名
* @param list 数据列表
*/
public static void exportList(HttpServletResponse response, String fileName, List<List<String>> list) {
exportData(response, fileName, list, ExcelTypeEnum.XLS);
}
/**
* 导出Object List类型数据到Excel
*
* @param response 响应对象
* @param fileName 文件名
* @param list 数据列表
*/
public static void exportObjectList(HttpServletResponse response, String fileName, List<List<Object>> list) {
exportData(response, fileName, list, ExcelTypeEnum.XLS);
}
/**
* 导出包含表头和数据的Excel
*
* @param response 响应对象
* @param fileName 文件名
* @param listhead 表头列表
* @param list 数据列表
*/
public static void exportHeadAndData(HttpServletResponse response, String fileName, List<List<String>> listhead, List<List<String>> list) {
try {
EasyExcel.write(getOutputStream(fileName, response, ExcelTypeEnum.XLS.getValue()))
.excelType(ExcelTypeEnum.XLS)
.registerWriteHandler(new SimpleColumnWidthStyleStrategy(25))
.sheet(fileName)
.head(listhead)
.doWrite(list);
} catch (Exception e) {
throw new BadCommonException("导出异常", e);
}
}
/**
* 公共导出方法
*
* @param response 响应对象
* @param fileName 文件名
* @param list 数据列表
* @param type Excel文件类型枚举
* @param <T> 泛型类型
*/
private static <T> void exportData(HttpServletResponse response, String fileName, List<T> list, ExcelTypeEnum type) {
try {
log.debug("导出的数据行数为:{}", list.size());
EasyExcel.write(getOutputStream(fileName, response, type.getValue()))
.excelType(type)
.registerWriteHandler(new SimpleColumnWidthStyleStrategy(25))
.sheet(fileName)
.doWrite(list);
} catch (Exception e) {
throw new BadCommonException("导出异常", e);
}
}
}
导出实体 Dto.class
@Data
public class Dto {
@NumberFormat("#")
@ExcelProperty(value = "地市编码", index = 0)
Long code;
@ExcelProperty(value = "地市名称", index = 1)
String name;
@NumberFormat("#")
@ExcelProperty(value = "地市级别", index = 2)
Integer level;
@NumberFormat("#")
@ExcelProperty(value = "地市父编码", index = 3)
Long pcode;
@NumberFormat("#")
@ExcelProperty(value = "地市父名称", index = 4)
Integer category;
}
测试用例 MeTestController.class
@Resource
private ExportMapper exportMapper;
@Resource
private HttpServletRequest request;
@Resource
private HttpServletResponse response;
@GetMapping("/export")
@Transactional
public void export() throws Exception {
Long params = 110101001000L;
EasyExcelUtil.writeExcelXlsx(
response,
"地市信息",
"地市区域",
Dto.class,
params,
param -> exportMapper.export(null));
}
测试Dao ExportMapper.class
public interface ExportMapper {
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
@ResultType(Dto.class)
@Select("select * from area_code_2023")
Cursor<Dto> export(@Param("params") Long params);
}
二. 传统分页导出查询
大表的深度分页性能很差,也受制于表设计的影响方式一:
@GetMapping("/export2")
public void export2() throws Exception {
// 这里注意 有同学反应使用swagger 会导致各种问题,请直接用浏览器或者用postman
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
// 这里URLEncoder.encode可以防止中文乱码 当然和easyexcel没有关系
String fileName = URLEncoder.encode("测试", StandardCharsets.UTF_8);
response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx");
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), AreaCodeDto.class).build();
WriteSheet writeSheet = EasyExcel.writerSheet("模板").build();
int count = testMapper.count();
for(int i = 0; i < count; i += 2000){
int offset = (i / 2000 + 1) * 2000;
int pageSize = 2000;
List<AreaCodeDto> list = testMapper.getList(offset, pageSize);
excelWriter.write(list, writeSheet);
}
excelWriter.finish();
}
方式二:
@Test
public void testQuery2() {
// 1、定义资源
Connection connection = null;
ResultSet resultSet = null;
PreparedStatement statement = null;
String sql = "select * from user limit ?,?";
try {
// 获取连接
connection = DBUtil.getConnection();
// 获取使用预编译的statement
statement = connection.prepareStatement(sql);
// 获取结果集
long start = System.currentTimeMillis();
long begin = 0L, offset = 10000L;
while (true){
statement.setLong(1,begin);
statement.setLong(2,offset);
begin += offset;
resultSet = statement.executeQuery();
boolean flag = resultSet.next();
if(!flag) break;
while (flag){
System.out.println("name---->" + resultSet.getString("nick_name") );
flag = resultSet.next();
}
}
long end = System.currentTimeMillis();
System.out.println(end -start);
} catch (SQLException e){
e.printStackTrace();
} finally {
// 关闭资源
DBUtil.closeAll(connection,statement,resultSet);
}
}
三. 流式查询概念
采用传统的Stream流式思想,将直接提供数据替换成提供获取数据的管道,客户端读取数据时直接从管道中遍历获取;整个读取的过程需要客户端保持和服务端的连接,也很好理解,它实际是一个管道,管道得通着才能取数据。 流式查询有两种使用方式,一种是用Cursor作为返回值,对数据进行遍历操作;一种是不设置返回值,在入参中传入一个ResultHandler作为回调处理数据。本文将基于Mybatis具体介绍使用方法。 这两种返回值的使用方式是相似的,唯一区别就是返回值不同。Mybatis查询有两种方式,一种是基于注解加在Mapper接口上方,一种是写在xml文件中,主要需要设置以下几个属性:ResultSetType | 结果集读取方式 |
---|---|
FetchSize | MySQL服务端单次发送至客户端的数据条数 |
ResultType | 这个眼熟吧,设置返回实体类映射 |
ResultSetType有4种可选项
DEFAULT(-1),
FORWARD_ONLY(1003),
SCROLL_INSENSITIVE(1004),
SCROLL_SENSITIVE(1005);
FORWARD_ONLY,顾名思义只能向前,即数据只能向前读取,是不是就类似一个流水的管道,读一条就相当于水流过去一些。也是我们需要选用的。
SCROLL_INSENSITIVE,不敏感滚动,和下面那个差不多,都是可以向后读或向前读;这意味着已读取过的数据不能丢掉,要继续保存在内存中,因为有可能会回去再次读取他们。
SCROLL_SENSITIVE,敏感滚动,和上面那个差不多。
这么一比较就看得出来,当选的一定是FORWARD_ONLY,我们亟需解决的就是大数据量对内存的影响,再用后面两个还是会放在内存中。
FetchSize,这个概念在许多服务中都有提及,例如RabbitMQ中是消费者取过来预处理的消息数量,但在MySQL中完全不是一个概念。MySQL的数据传输是基于C/S的阻塞机制,即Client设置FetchSize = 1000,而Server查出来10000条数据,按照常理应该是Server智能地使用分页策略1000条1000条取;实际不是,Server查出来多少就是多少,他会放在自己特定的内存空间内,只是会根据FetchSize的大小一点一点传送给Client——利用C/S的通讯阻塞,发1000条、堵一下、发1000条、堵一下……。
JDBC官方给出的答案是设置为“Integer.MIN_VALUE”,具体原因不清楚,但我猜是为了和游标查询区分开,因为一会你会发现流式查询和游标查询唯一的区别就是FetchSize的大小。注解式
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
@ResultType(ResultVo.class)
@Select("SELECT *, 0 orderType FROM `table`\n" +
" WHERE username = #{userName}")
Cursor<ResultVo> listOrders(@Param("userName") String userName);
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
@ResultType(ResultVo.class)
@Select("SELECT *, 0 orderType FROM `table`\n" +
" WHERE username = #{userName}")
void listOrders2(@Param("userName") String userName, ResultHandler<ResultVo> handler);
使用Mybatis的注解,在 @Options 中指定查询配置参数,在@ResultType中指定返回值类型 ,在 @Select中指定查询语句。最后用Cursor接收返回值,Cursor是可遍历的,所以直接Foreach遍历即可;或者返回void 用ResultHandler处理数据回调,在调用方式时传入new ResultHandler并写明处理逻辑。
xml式
<select id="listOrders" resultType="com.vo.ResultVo" resultSetType="FORWARD_ONLY" fetchSize="Integer.MIN_VALUE">
SELECT *, 1 stuffCount, 1 orderType FROM `table`
WHERE username = #{userName}
</select>
需要注意的是,不可以注解 + xml混合使用,比如注解指定fetchSize,xml只写查询语句,这种只有xml语句会生效!!!要不全用注解,要不全用xml!!!
流式查询由于需要保持客户端与服务端的连接,而一般查询提交完连接就会关闭;因此我们需要保持事务开启,否则会报“A Cursor is already closed.”,即Cursor已经关闭,没法再读取了。最简单的方法就是在方法上加@Transactional,在查询完毕以前事务会一直持有这个数据库连接,但我们在使用完毕后也要自行关闭连接,显式调用Cursor.close(),或者用try with resource语句。
游标查询和流式查询的区分是fetchSize = Integer.MIN_VALUE
Cursor 还提供了三个方法:
isOpen()
:用于在取数据之前判断 Cursor 对象是否是打开状态。只有当打开时 Cursor 才能取数据;isConsumed()
:用于判断查询结果是否全部取完;getCurrentIndex()
:返回已经获取了多少条数据。
try(Cursor cursor = OrderMapper.listOrders()) {
cursor.forEach(rowObject -> {
// ...
});
}
OrderMapper.listOrders2(queryWrapper,resultContext -> {
ResultVo result = resultContext.getResultObject();
//这边循环调用就可以实现业务了
}
游标查询
和流式查询类似fetchSize不设置为MIN_VALUE即可
JDBC查询默认是不支持FetchSize属性的,需要在JDBC连接URL后面加上**“useCursorFetch=true”。**
useCursorFetch=true 是针对 MySQL 数据库的 JDBC 连接参数,用于启用服务器端游标获取数据。在 MyBatis 中,当使用流式查询(例如:分页查询、结果集处理和使用游标等)时,这个配置可以帮助逐行从服务器检索数据,而不是一次性将所有数据加载到内存中,从而降低内存占用。
当使用 MySQL 数据库时,在 JDBC 连接字符串中加入 useCursorFetch=true,并结合设置合适的 fetchSize,可以避免因一次性加载过多数据导致的内存溢出问题。注意,此配置仅对 MySQL 数据库有效。 如果不设置 useCursorFetch=true 这个配置,仅使用之前提到的那些配置(如设置 defaultFetchSize、分页查询、结果集处理和使用游标等),在大多数情况下,这些配置仍然可以有效地避免查询导致的内存溢出。
但需要注意的是,对于 MySQL 数据库,如果不启用服务器端游标获取数据,这可能会影响到流式查询的效果。因为在默认情况下,MySQL JDBC 驱动会一次性将所有数据加载到内存中。此时,即使使用了其他配置,也可能无法达到预期的内存优化效果。
总的来说,在使用 MySQL 数据库时,推荐在 JDBC 连接字符串中加入 useCursorFetch=true 配置,以更好地支持流式查询和降低内存占用。在其他数据库中,可以根据实际需求和场景选择合适的配置和策略来避免查询导致的内存溢出。
还要知道如何判断自己是否使用了流式查询或游标查询,下面是几个数据集的对应关系
普通分页 | ResultsetRowsStatic | RowDataStatic |
---|---|---|
查询方式 | 结果集类型 | 行数据类型 |
流式查询 | ResultsetRowsStreaming | RowDataDynamic |
游标查询 | ResultsetRowsCursor | RowDataCursor |
这3种查询方式,常规非大数据模式下普通查询最快,其次是流式查询,最次是游标查询.
主要是由于游标查询需要和数据库进行多次网络交互,Client处理完这部分后再拉取下一部分数据,因此会比较慢。但是流式查询又会长时间占用同一个数据库连接,因此要取舍一下是能接受连接一直持有但是可能会堵住导致响应慢,还是可能占用较多连接数但单次响应快。当通过流式查询获取一个ResultSet后,在你通过next迭代出所有元素之前或者调用close关闭它之前,你不能使用同一个数据库连接去发起另外一个查询,否者抛出异常(第一次调用的正常,第二次的抛出异常)。