JVisualVM 性能分析与 Mybatis ResultHandler 实战

一、概要

本文将模拟一个生产场景的性能分析:从 Mysql 数据库中返回百万行数据并且导出数据到 Excel 文件,期间使用 JVisualVM 监控工具查看代码性能。

二、IDEA 集成 VisualVM

插件市场搜索关键字,

VisualVM Launcher

如果 IDEA 因为网络问题无法连接,可以直接去官网下载,

https://plugins.jetbrains.com/idea

下载完 jar 包直接拖拽到 IDEA,

插件安装完成后就会在右上角多出两个按钮,接下来配置 JVisualVM 参数,

配置完成后,点击右上角的运行按钮即可启动项目,同时会启动 JVisualVM ,

安装 GC 插件,

如果遇到网络不通,可以把下载地址复制到下载软件加速下载,

重新启动,可以看到新增了 Visual GC 栏位,

三、Mybatis ResultHandler 接口

官方文档中介绍了 Select 方法的其他高级版本,其中,可以重写 ResultHandler 对大数据集进行自定义操作,

Mybatis 官方有一个 ResultHandler 的默认实现,将结果塞到 list 中返回,

接下来我们重新实现一个 ResultHandler,将结果集导到 Excel 文件中。

四、业务代码实现

1、TestVo

package com.example.demo.vo;

import lombok.Data;

/**
 * 测试 Vo
 *
 * @author yushanma
 * @since 2023/7/11 9:30
 */
@Data
public class TestVo {
    private String f1;
    private String f2;
    private String f3;
    private String f4;
    private String f5;
    private String f6;
    private String f7;
    private String f8;
    private String f9;
    private String f10;
    private String f11;
    private String f12;
    private String f13;
    private String f14;
    private String f15;
    private String f16;
    private String f17;
    private String f18;
    private String f19;
    private String f20;
}

2、Dao 与 Mapper XML

    /**
     * 返回百万数据
     * @param resultHandler 结果处理器
     */
    void selectMillionData(ResultHandler<TestVo> resultHandler);
    <select id="selectMillionData" resultType="com.example.demo.vo.TestVo">
        (SELECT * FROM TestXlsDataC20 LIMIT 250000)
        UNION ALL
        (SELECT * FROM TestXlsDataC20 LIMIT 250000)
        UNION ALL
        (SELECT * FROM TestXlsDataC20 LIMIT 250000)
        UNION ALL
        (SELECT * FROM TestXlsDataC20 LIMIT 250000)
    </select>

3、ExportResultHandler

简单起见,这里使用 jackson 序列化代替文件写入,后续再实现 Excel 写入,

package com.example.demo.handler;

import com.example.demo.utils.JacksonUtil;
import com.example.demo.vo.TestVo;
import org.apache.ibatis.session.ResultContext;
import org.apache.ibatis.session.ResultHandler;

/**
 * 结果集处理器
 *
 * @author yushanma
 * @since 2023/7/11 9:36
 */
public class ExportResultHandler implements ResultHandler<TestVo> {

    @Override
    public void handleResult(ResultContext<? extends TestVo> resultContext) {
        System.out.printf("result count %d", resultContext.getResultCount());
        TestVo testVo = resultContext.getResultObject();
        try {
            System.out.println(JacksonUtil.toJsonString(testVo));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

4、ServiceImpl 调用

    @Override
    public void testResultHandler() {
        testDao.selectMillionData(new ExportResultHandler());
    }

5、性能测试 

请求时间 1m35.46 s,而且从输出字符来看,是一行一行处理结果集,接下来观察 GC 情况, 并没有 OOM 问题,

6、真正写入文件 

POM 引用 Easy Excel,

        <!-- xls 数据导入导出 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>easyexcel</artifactId>
            <version>3.2.1</version>
        </dependency>

定义 BaseExportResultHandler,增量把数据写入 Excel 文件, 

package com.example.demo.handler;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.enums.CellDataTypeEnum;
import com.alibaba.excel.metadata.Head;
import com.alibaba.excel.metadata.data.WriteCellData;
import com.alibaba.excel.util.ListUtils;
import com.alibaba.excel.util.MapUtils;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
import com.alibaba.excel.write.style.column.AbstractColumnWidthStyleStrategy;
import com.example.demo.utils.JacksonUtil;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.ibatis.session.ResultContext;
import org.apache.ibatis.session.ResultHandler;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.poi.ss.usermodel.Cell;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletResponse;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

/**
 * 通用导出数据结果集处理器
 *
 * @author yushanma
 * @since 2023/7/11 11:37
 */
public abstract class BaseExportResultHandler<T> implements ResultHandler<T> {

    /**
     * Excel 表头
     */
    private String[] header;

    /**
     * 模板文件名
     */
    private String templateName;

    /**
     * 日志
     */
    private final Logger logger = LogManager.getLogger(BaseExportResultHandler.class.getName());

    /**
     * 模板文件路径
     */
    private final String TEMPLATE_FILE_PATH = "src/main/resources/templates/";

    /**
     * 文件类型
     */
    private final String FILE_TYPE = ".xlsx";

    /**
     * 每隔 100 条数据写入 Excel ,然后清理 list ,方便内存回收
     */
    private final int BATCH_COUNT = 100;

    /**
     * 缓存 list
     */
    private List<T> cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);

    /**
     * excelWriter
     */
    private ExcelWriter excelWriter;

    /**
     * writeSheet
     */
    private WriteSheet writeSheet;

    /**
     * httpServletResponse
     */
    private HttpServletResponse response;

    /**
     * zip io 输出流
     */
    private ZipOutputStream zos = null;

    /**
     * io 输出流
     */
    private OutputStream os = null;

    /**
     * 是否导出 zip
     */
    private boolean isExportZip;

    /**
     * 重写自适应列宽策略
     */
    static class CustomLongestMatchColumnWidthStyleStrategy extends AbstractColumnWidthStyleStrategy {

        private static final int MAX_COLUMN_WIDTH = 255;

        private final Map<Integer, Map<Integer, Integer>> cache = MapUtils.newHashMapWithExpectedSize(8);

        /**
         * 无参构造
         */
        public CustomLongestMatchColumnWidthStyleStrategy() {
        }

        /**
         * 设定列宽
         *
         * @param writeSheetHolder
         * @param cellDataList
         * @param cell
         * @param head
         * @param relativeRowIndex
         * @param isHead
         */
        @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) {
                HashMap<Integer, Integer> maxColumnWidthMap = (HashMap<Integer, Integer>) this.cache.computeIfAbsent(writeSheetHolder.getSheetNo(), (key) -> {
                    return new HashMap<Integer, Integer>(16);
                });
                Integer columnWidth = this.dataLength(cellDataList, cell, isHead);
                if (columnWidth >= 0) {
                    if (columnWidth > MAX_COLUMN_WIDTH) {
                        columnWidth = 255;
                    }

                    Integer maxColumnWidth = (Integer) maxColumnWidthMap.get(cell.getColumnIndex());
                    if (maxColumnWidth == null || columnWidth > maxColumnWidth) {
                        maxColumnWidthMap.put(cell.getColumnIndex(), columnWidth);
                        writeSheetHolder.getSheet().setColumnWidth(cell.getColumnIndex(), columnWidth * 256);
                    }

                }
            }
        }

        private Integer dataLength(List<WriteCellData<?>> cellDataList, Cell cell, Boolean isHead) {
            if (isHead) {
                return cell.getStringCellValue().getBytes().length;
            } else {
                WriteCellData<?> cellData = (WriteCellData) cellDataList.get(0);
                CellDataTypeEnum type = cellData.getType();
                if (type == null) {
                    return -1;
                } else {
                    switch (type) {
                        case STRING:
                            return cellData.getStringValue().getBytes().length;
                        case BOOLEAN:
                            return cellData.getBooleanValue().toString().getBytes().length;
                        case NUMBER:
                            return cellData.getNumberValue().toString().getBytes().length;
                        default:
                            return -1;
                    }
                }
            }
        }
    }

    /**
     * 解析 header 返回
     *
     * @return header 列表
     */
    private List<List<String>> getXlsTemplateHeader(String[] header) {
        List<List<String>> list = new ArrayList<List<String>>();
        for (String h : header) {
            List<String> head = new ArrayList<String>();
            head.add(h);
            list.add(head);
        }
        return list;
    }

    /**
     * 模板内容
     * 1、模板注意用 {} 来表示你要用的变量 如果本来就有"{","}" 特殊字符 用 "\{","\}" 代替
     * 2、填充 list 的时候还要注意 模板中 {.} 多了个点 表示 list
     * 3、如果填充 list 的对象是 map , 必须包涵所有 list 的 key , 哪怕数据为 null,必须使用 map.put(key,null)
     *
     * @param header header
     * @return List<List < Object>>
     */
    private List<List<Object>> getXlsTemplateContent(String[] header) {
        List<List<Object>> list = new ArrayList<>();
        List<Object> templateContent = new ArrayList<>(header.length);
        for (String h : header) {
            templateContent.add(String.format("{.%s}", h));
        }
        list.add(templateContent);
        return list;
    }

    /**
     * 通过 ResultHandler 流式查询数据,由子类自定义实现
     */
    public abstract void fetchDataByStream();

    /**
     * 处理结果,这里可以获取当前行的数据以及行数
     *
     * @param resultContext 上下文
     */
    @Override
    public void handleResult(ResultContext<? extends T> resultContext) {
        T resultObject = resultContext.getResultObject();
        invoke(resultObject);
    }

    /**
     * 处理数据
     *
     * @param obj 泛型对象
     */
    private void invoke(T obj) {
        // 先塞到缓存中
        cachedDataList.add(obj);
        // 如果到达缓存值,则写入 Excel
        if (cachedDataList.size() >= BATCH_COUNT) {
            // 写入 Excel
            writeObjToExcel();
            // 清空缓存数组
            cachedDataList.clear();
        }
    }

    /**
     * 把缓存数据 追加写入 Excel
     */
    private void writeObjToExcel() {
//        logger.info("writeObjToExcel {} ", cachedDataList.size());
        this.excelWriter.fill(cachedDataList, this.writeSheet);
    }

    /**
     * 构造函数
     *
     * @param header Excel 表头
     * @throws IOException io 异常
     */
    public BaseExportResultHandler(String[] header) throws IOException {
        this(header, "DefaultFileName");
    }

    /**
     * 构造函数
     *
     * @param header       Excel 表头
     * @param templateName 模板名
     * @throws IOException io 异常
     */
    public BaseExportResultHandler(String[] header, String templateName) throws IOException {
        this(header, templateName, false);
    }

    /**
     * 构造函数
     *
     * @param header       Excel 表头
     * @param templateName 模板名
     * @param isExportZip  是否导出 zip
     * @throws IOException io 异常
     */
    public BaseExportResultHandler(String[] header, String templateName, boolean isExportZip) throws IOException {
        this.header = header;
        this.templateName = templateName;
        this.isExportZip = isExportZip;
        this.initExcelWriter();
    }

    /**
     * 初始化 Excel 属性
     */
    private void initExcelWriter() throws IOException {
        // http 响应
        //        this.response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
        this.response = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getResponse();
        // io 流
        assert this.response != null;
        this.os = new BufferedOutputStream(this.response.getOutputStream());
        // 如果需要压缩成 zip
        if (this.isExportZip) {
            this.zos = new ZipOutputStream(os);
            ZipEntry zipEntry = new ZipEntry((this.templateName + this.FILE_TYPE).replaceAll(" ", ""));
            this.zos.putNextEntry(zipEntry);
        }
        // 模板路径
        String templateFile = this.TEMPLATE_FILE_PATH + this.templateName + this.FILE_TYPE;
        // 生成模板
        this.generateExcelTemplate(templateFile);
        // 读取模板
        this.excelWriter = EasyExcel
                .write(this.isExportZip ? this.zos : this.os)
                .withTemplate(templateFile)
                .build();
        // 填充模板 sheet
        this.writeSheet = EasyExcel
                .writerSheet()
                .registerWriteHandler(new CustomLongestMatchColumnWidthStyleStrategy())
                .build();
    }

    /**
     * 生成模板
     *
     * @param templateFile 模板名
     */
    private void generateExcelTemplate(String templateFile) {
        EasyExcel
                .write(templateFile)
                //.registerWriteHandler(getCustomHorizontalCellStyleStrategy())
                .registerWriteHandler(new CustomLongestMatchColumnWidthStyleStrategy())
                .head(this.getXlsTemplateHeader(this.header))
                .sheet(this.templateName)
                .doWrite(this.getXlsTemplateContent(this.header));
    }

    /**
     * 开始导出数据
     *
     * @throws IOException io 异常
     */
    public void startExportExcel() throws IOException {

        try {
            this.response.setCharacterEncoding("utf-8");
            // 下载文件名,这里URLEncoder.encode可以防止中文乱码
            String fileName = URLEncoder.encode(this.templateName, "UTF-8").replaceAll("\\+", "%20");
            if (!isExportZip) {
                // 这里注意 使用swagger 会导致各种问题,请直接用浏览器或者用postman
                this.response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
                this.response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
                logger.info("is Export Excel");
            } else {
                this.response.setContentType("application/octet-stream");
                this.response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".zip");
                logger.info("is Export Zip");
            }
            // 子类流式获取数据
            this.fetchDataByStream();
        } catch (Exception e) {
            // 重置response
            response.reset();
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            Map<String, String> map = MapUtils.newHashMap();
            map.put("status", "failure");
            map.put("message", "下载文件失败" + e.getMessage());
            response.getWriter().println(JacksonUtil.toJsonString(map));
        } finally {
            // 关闭 Excel 资源
            this.excelWriter.finish();
            // 关闭 io 资源
            if (this.isExportZip) {
                try {
                    if (zos != null) {
                        zos.close();
                    }
                } catch (IOException e) {
                    logger.error("error", e);
                }
            } else {
                try {
                    if (os != null) {
                        os.close();
                    }
                } catch (IOException e) {
                    logger.error("error", e);
                }
            }
        }
    }
}

封装 MyExcelUtil 用于获取实体类对应的 Excel header,否则可以直接用 HashMap 替代 TestVo 作为实体类,

package com.example.demo.utils;

import com.fasterxml.jackson.core.JsonProcessingException;

import java.util.HashMap;

/**
 * MyExcelUtil
 *
 * @author yushanma
 * @since 2023/7/15 17:10
 */
public class MyExcelUtil {

    /**
     * 获取 header
     *
     * @param obj 通用对象
     * @return 对象属性数组
     */
    public static String[] getHeader(Object obj) throws JsonProcessingException {
        String jsonString = JacksonUtil.toJsonString(obj);
        HashMap<String, Object> objectMap = (HashMap<String, Object>) JacksonUtil.toObject(jsonString, HashMap.class);
        return objectMap.keySet().toArray(new String[objectMap.keySet().size()]);
    }
}

调用 BaseExportResultHandler,

    @Override
    public void testResultHandler() throws IOException {
        // old school
//        testDao.selectMillionData(new ExportResultHandler());

        // new school
        // 每次导出 new 一个 BaseExportResultHandler 对象,参数为 header、 templateName、 isExportZip
        BaseExportResultHandler<TestVo> handler = new BaseExportResultHandler<TestVo>(
                MyExcelUtil.getHeader(new TestVo()), "TestVo", false
        ) {
            /**
             * 子类重写 fetchDataByStream ,自定义获取数据的方式
             */
            @Override
            public void fetchDataByStream() {
                // 这里的this 指的就是 BaseExportResultHandler<TestVo> handler 这个对象,在这里写 mapper 调用获取数据的调用
                testDao.selectMillionData(this);
            }
        };
        // startExportExcel 方法中调用 fetchDataByStream 方法,
        // 而 fetchDataByStream 方法中 selectMillionData 方法会调用 handler 中的 handleResult 方法
        // 最终 handleResult 方法调用 invoke 处理数据
        handler.startExportExcel();
    }
    @GetMapping("/testResultHandler")
    public void testResultHandler() throws IOException {
        // old school
//        testService.testResultHandler();
        // new school
        testService.testResultHandler();
    }

七、Excel 导出测试

报错:Error: Maximum response size reached

解决:修改响应数据报文大小,原 50 MB,现修改为 1024 MB

如果 Postman 还是崩溃闪退,就直接用浏览器,

最终下载的文件大小为 505 MB,

 

八、JVM 分析

应用刚启动时,

导出 Excel 时,

导出完成,

总耗时,cost PT4M20.169S,Excel 文件大小 505 MB,内容总字符 1000000 * 36 * 20 = 7.2 亿,并没有产生 OOM 问题,平均每秒处理 3846 行数据、276.9230 万字符,

尝试压缩后下载,

解压后文件正常打开,但是压缩率并不高。 

注意

sql 查询接口的返回值,类型为 void,所以并没有接受这个返回值,不会产生大对象,只是在查询数据的过程中,处理了每一条数据,并没有保存数据在内存中。

参考

1、通过Mybatis查询并导出超大Excel,防止内存溢出_resulthandler 导出excel_冰之杍的博客-CSDN博客

2、关于Easyexcel | Easy Excel

3、MyBatis中ResultHandler的使用

4、mybatis – MyBatis 3 | Java API

5、Java 使用 Easyexcel 导出大量数据_java easyexcel导出_余衫马的博客-CSDN博客

6、Java 使用 opencsv 库导出大量数据_余衫马的博客-CSDN博客

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

余衫马

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值