万级数据优化EasyExcel+mybatis流式查询导出封装

文章目录

    • 前言.万级数据优化
    • 一. 直接上流式查询封装工具代码
    • 二. 传统分页导出查询
    • 三. 流式查询概念
    • 游标查询


前言.万级数据优化

我们不妨先给大家讲一个概念,利用此概念我们正好给大家介绍一个数据库优化的小技巧: 需求如下:将一个地市表的数据导出70万条。

万级数据优化EasyExcel+mybatis流式查询导出封装_第1张图片

如果你不假思索,直接一条sql语句搞上去,直接就会内存溢出,因为mysql会将结果记录统一查询出来然后返还给内存:那内存可能直接OOM!

@Test
public void testQuery1()  {

    // 1、定义资源
    Connection connection = null;
    ResultSet resultSet = null;
    PreparedStatement statement = null;
    String sql = "select * from user";
    try {
        // 获取连接
        connection = DBUtil.getConnection();
        // 获取使用预编译的statement
        statement = connection.prepareStatement(sql);

        long start = System.currentTimeMillis();
        resultSet = statement.executeQuery();
        while (resultSet.next()){
            System.out.println("name---->" + resultSet.getString("nick_name") );
        }
        long end = System.currentTimeMillis();
        System.out.println(end -start);
    } catch (SQLException e){
        e.printStackTrace();
    } finally {
        // 关闭资源
        DBUtil.closeAll(connection,statement,resultSet);
    }
}

所以我们通常有如下几种解决方案:

一. 直接上流式查询封装工具代码

使用2核4G云服务器 下载速度在40-50s之间波动
万级数据优化EasyExcel+mybatis流式查询导出封装_第2张图片
万级数据优化EasyExcel+mybatis流式查询导出封装_第3张图片
万级数据优化EasyExcel+mybatis流式查询导出封装_第4张图片

表格美化 CustomCellWeightStrategy.class
/**
 * @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
/**
 * @author YuanJie
 * @projectName vector-server
 * @package com.vector.common.utils
 * @className com.vector.common.utils.easyexcel.EasyExcelUtil
 * @copyright Copyright 2020 vector, Inc All rights reserved.
 * @date 2023/8/26 1:17
 */
@Slf4j
public class EasyExcelUtil {

    private static final String DATE_FORMAT = "yyyy-MM-dd";


    /**
     * 设置批量存储最大值,也影响sheet页数
     */
    private static final Integer MAX_SHEET_DATA = 50000;
    /**
     * 设置内存最大值
     */
    private static final Integer MAX_MEMORY_DATA = 1000;

    /**
     * 使用EasyExcel生成Excel  xls
     *
     * @param response      响应对象
     * @param fileNameParam 文件名
     * @param sheetName     表格名
     * @param clazz         导出实体类
     * @param t             查库入参
     * @param func          流式查询方法 Cursor listOrders(@Param("userName") String userName);
     */
    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      响应对象
     * @param fileNameParam 文件名
     * @param sheetName     表格名
     * @param clazz         导出实体类
     * @param t             查库入参
     * @param func          流式查询方法 Cursor listOrders(@Param("userName") String userName);
     */
    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      响应对象
     * @param fileNameParam 文件名
     * @param sheetName     表格名
     * @param clazz         导出实体类
     * @param excelType     导出类型
     * @param t             查库入参
     * @param func          流式查询方法 Cursor listOrders(@Param("userName") String userName);
     * @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 {
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATE_FORMAT);
        String fileName = fileNameParam + dateTimeFormatter.format(LocalDateTime.now()) + excelType;
        ExcelWriter excelWriter = EasyExcel
                .write(getOutputStream(fileName, response, excelType), clazz)
                .registerWriteHandler(new CustomCellWeightStrategy())
                .build();

        // 内容样式
        WriteCellStyle contentWriteCellStyle = new WriteCellStyle();
        // 水平居中
        contentWriteCellStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);
        // 垂直居中
        contentWriteCellStyle.setVerticalAlignment(VerticalAlignment.CENTER);
        // 设置自动换行,前提内容中需要加「\n」才有效
        contentWriteCellStyle.setWrapped(true);
        // 这个策略是 头是头的样式 内容是内容的样式 其他的策略可以自己实现
        HorizontalCellStyleStrategy horizontalCellStyleStrategy =
                new HorizontalCellStyleStrategy(null, contentWriteCellStyle);
        Cursor<?> cursor;
        List<Object> list = new ArrayList<>();
        int page = 0;
        WriteSheet writeSheet = EasyExcel
                .writerSheet(++page, sheetName + page)
                .registerWriteHandler(horizontalCellStyleStrategy)
                .build();
        // 流式数据库查询
        cursor = func.apply(t);
        int count = 0;
        try {
            for (Object o : cursor) {
                list.add(o);
                if(list.size() == MAX_MEMORY_DATA){
                    count += list.size();
                    excelWriter.write(list, writeSheet);
                    list.clear();
                    // 每个sheet页最大存储MAX_SHEET_DATA条数据
                    if (count >= MAX_SHEET_DATA) {
                        writeSheet = EasyExcel
                                .writerSheet(++page, sheetName + page)
                                .registerWriteHandler(horizontalCellStyleStrategy)
                                .build();
                    }
                }
            }
            // 处理最后不足MAX_SHEET_DATA的数据
            if (list.size() > 0) {
                writeSheet = EasyExcel
                        .writerSheet(++page, sheetName + page)
                        .registerWriteHandler(horizontalCellStyleStrategy)
                        .build();
                excelWriter.write(list, writeSheet);
                list.clear();
            }
        } catch (Exception e) {
            // 重置response
            response.reset();
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            String json = JacksonInstance.toJson(R.errorResult(EnumHttpCode.SYSTEM_ERROR, "下载文件失败" + e.getMessage()));
            response.getWriter().println(json);
        } finally {
            if (cursor != null) {
                cursor.close();
            }
            if (excelWriter != null) {
                excelWriter.finish();
            }
        }
    }

    /**
     * 导出文件时为Writer生成OutputStream
     *
     * @param finalName 文件名
     * @param response 响应对象
     * @param excelType 导出文件类型
     * @return OutputStream
     */
    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("utf8");
        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();
    }
}
导出实体 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);
}


二. 传统分页导出查询

大表的深度分页性能很差,也受制于表设计的影响
@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流式思想,将直接提供数据替换成提供获取数据的管道,客户端读取数据时直接从管道中遍历获取;整个读取的过程需要客户端保持和服务端的连接,也很好理解,它实际是一个管道,管道得通着才能取数据。

采用传统的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 还提供了三个方法:

  1. isOpen():用于在取数据之前判断 Cursor 对象是否是打开状态。只有当打开时 Cursor 才能取数据;
  2. isConsumed():用于判断查询结果是否全部取完;
  3. 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关闭它之前,你不能使用同一个数据库连接去发起另外一个查询,否者抛出异常(第一次调用的正常,第二次的抛出异常)。

你可能感兴趣的:(mybatis,android)