代码改变世界

老版本 EasyExcel 一个神出鬼没的异常 - 教程

2025-10-13 15:25  tlnshuju  阅读(38)  评论(0)    收藏  举报

1 背景

数据中台上线以来,总有一个 同步作业 偶尔会抛出异常:com.alibaba.excel.exception.ExcelAnalysisException: Converter not found, convert STRING to java.lang.String

在这里插入图片描述

作业通过 datax 同步用户在 ftp 上传的文件数据到数据中台;datax 会调用使用 EasyExcel 库加载 Excel 文件的数据,并在加载过程中偶尔抛出异常。

异常是由于无法找到,把 Excel 中的 字符串 Cell 转换为 java 字符串 的 转换器,这就很奇怪,这应该是一个最基本的转换器了。

这个 神出鬼没 的异常,有以下几个特点:

  • 只有固定的 同步作业 会抛出异常,其余的同步作业都运行正常;
  • 这个异常是偶发的,每个月会发生 4-5 次;
  • 作业由于异常运行失败后 5 分钟,再重试,就成功了,非常诡异。

虽然,重试机制可以保证作业最终是运行成功的,但,只要抛出这个异常,整条数据链路的完成时间就会延迟。因此,必须找出导致异常的 root cause。

2 根本原因分析

2.1 EasyExcel 的源码分析

首先,数据中台当前使用的 EasyExcel 版本是 2.1.4,已经是 2019 年的老版本了:
在这里插入图片描述

(1) 分析异常栈

在这里插入图片描述
异常由 ConverterUtils 的 130 行抛出,具体代码如下:
在这里插入图片描述
由于无法从 converterMap 中获取转换器,从而抛出 ExcelDataConvertException 异常。

converterMap 里面保存了 Excel Cell 数据类型 - java 类型转换器 的对应关系。

因此需要分析 converterMap 是如何被初始化的,有可能在运行到 ConverterUtils 的 130 行 时,converterMap 仍未完成初始化,导致无法获取 转换器

(2) 分析 converterMap 如何初始化

  • converterMap 来自 AbstractHolder 类:
    在这里插入图片描述
  • converterMap 的初始化时机

在读取 Excel 数据时,会实例化类 ReadSheetHolder,它是 AbstractHolder 的子类(ReadSheetHolder -继承-> AbstractReadHolder -继承-> AbstractHolder

在实例化 ReadSheetHolder 时,会调用 AbstractReadHolder 的构造器:
在这里插入图片描述
AbstractReadHolder 的构造器会调用 DefaultConverterLoader.loadDefaultReadConverter()converterMap 进行赋值。

(3) DefaultConverterLoader 初始化 转换器Map

在这里插入图片描述

  • 首先,loadDefaultReadConverter 是类级别的静态方法;
  • loadDefaultReadConverter 最终会调用 loadAllConverter 方法;
  • loadAllConverter 会判断静态属性 allConverter 是否为 null,如果非 null 则直接返回 allConverter,否则初始化 allConverter 中的转换器。

(4) 异常根本原因分析

  • 首先,作业是要同步文件夹中的 4 个文件到数据中台,并发度为 3,即同时会有 3 个线程同步文件,因此,loadAllConverter 运行在一个多线程的场景下;
  • loadAllConverter 方法没有做线程安全处理;
  • 会出现一种情况,线程 A 执行到
allConverter = new HashMap<String, Converter>(64);

B线程刚好执行到

if (allConverter != null) {
return allConverter;
}

就返回了空的 allConverter 了,由此,导致无法找到 转换器,而抛出 ExcelDataConvertException 异常。

这种由于没有做线程安全处理而导致的异常,也符合它神出鬼没的特性。

2.2 本地复现异常

2.2.1 先在 maven 引入数据中台版本的 EasyExcel 依赖

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>2.1.4</version>
</dependency>

2.2.2 编写测试代码

package com.nutanix.test;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
/**
* 使用EasyExcel读取Excel文件并转换为Map集合的工具类
*/
public class ExcelToMapReader {
/**
* 读取Excel文件并转换为List<Map<String, Object>>
  * @param filePath Excel文件路径
  * @return 包含Excel数据的Map集合列表,每个Map对应一行数据
  */
  public static List<Map<String, Object>> readExcelToMap(String filePath) {
    // 创建一个监听器实例
    ExcelMapListener listener = new ExcelMapListener();
    // 读取Excel文件
    EasyExcel.read(filePath, listener)
    .sheet() // 读取第一个sheet
    .doRead(); // 执行读取操作
    // 返回读取到的数据
    return listener.getDataList();
    }
    /**
    * 自定义监听器,用于处理Excel读取事件
    */
    private static class ExcelMapListener extends AnalysisEventListener<Map<String, Object>> {
      // 存储读取到的数据
      private List<Map<String, Object>> dataList = new ArrayList<>();
        /**
        * 每读取一行数据都会调用此方法
        * @param data 一行数据,键是表头,值是单元格内容
        * @param context 分析上下文
        */
        @Override
        public void invoke(Map<String, Object> data, AnalysisContext context) {
          dataList.add(data);
          }
          /**
          * 读取完成后调用此方法
          * @param context 分析上下文
          */
          @Override
          public void doAfterAllAnalysed(AnalysisContext context) {
          // 可以在这里添加读取完成后的处理逻辑
          System.out.println("Excel文件读取完成,共读取 " + dataList.size() + " 行数据");
          }
          /**
          * 获取读取到的数据列表
          * @return 数据列表
          */
          public List<Map<String, Object>> getDataList() {
            return dataList;
            }
            }
            public static void main(String[] args) throws InterruptedException {
            Thread thread = new Thread(() -> {
            readExcelToMap("/tmp/customer-file1.xlsx");
            });
            Thread thread2 = new Thread(() -> {
            readExcelToMap("/tmp/customer-file2.xlsx");
            });
            Thread thread3 = new Thread(() -> {
            readExcelToMap("/tmp/customer-file3.xlsx");
            });
            Thread thread4 = new Thread(() -> {
            readExcelToMap("/tmp/customer-file4.xlsx");
            });
            thread.start();
            thread2.start();
            thread3.start();
            thread4.start();
            }
            }

程序模拟数据中台的作业,启动 4 个线程,分别读取对应的 excel 文件。

2.2.3 打断点

DefaultConverterLoaderloadAllConverter() 打断点:
在这里插入图片描述

2.2.4 调试

  • 运行程序,4 个线程都会在断点处停下;
  • 选择其中 1 个线程,并 Step Over 到 allConverter = new HashMap<String, Converter>(64); 后,例如下图;
    在这里插入图片描述
  • 再选择另一个线程,并让它恢复运行;
    在这里插入图片描述
  • 异常重现
    在这里插入图片描述

3 新版本的 EasyExcel 处理

在新版本的 EasyExcel,阿里团队已经通过采用静态代码块的方式,在 DefaultConverterLoader 首次被访问时,对 allConverter 进行了初始化,从而解决了在并发环境的线程安全问题:
在这里插入图片描述

4 总结

至此,根因已经找到了,就是因为,老版本的 EasyExcel,在初始化 类型与转换器的 Map 对象时,没有做线程安全处理,导致,在并发环境下,ExcelDataConvertException 异常神出鬼没地出现。

希望本文,对其他遇到这个问题的同学有帮助。