
前两天下午生产环境突然有人喊导入失败报错信息贴在群里就这么一行导入失败报错“too many filtered rows xxx, “ErrorURL“:“说实话我第一眼看到这个报错整个人是懵的。导入失败我见过不少日期格式不对、必填项缺失、数据库唯一键冲突、Excel 单元格里带公式……但too many filtered rows是什么鬼这个异常名跟后端 Java 代码里常见的NullPointerException、IllegalArgumentException完全对不上。更奇怪的是消息末尾还挂着一个半截的ErrorURL字段看起来像是若依AjaxResult返回结构里的东西。这篇博文就按我当时的完整排查链路来写从日志、复现、源码定位到最终修复都会讲到。如果你做的系统里有 Excel 导入导出功能或者你正在用若依系框架做二开那这个坑建议提前踩一遍不然真上线了再碰到会很被动。1. 先搞清楚报错的两段信息分别是谁抛的排错的第一步不是改代码而是先拆解报错文本。这一大段消息混在一起很唬人但只要拆开看就能发现它其实来自两个完全不同的层面。1.1 “ErrorURL”是若依导入功能的业务返回字段在若依框架里做导入正常代码路径大概是这样的前端把文件传上来后端Controller调用ExcelUtil完成解析随后进入业务校验和入库逻辑。若依的ExcelUtil会把解析出来的每一行做类型转换和规则校验凡是校验失败的行都会被收集起来最后把失败原因写回到一个错误明细 Excel 里再把下载地址塞进AjaxResult的errorUrl字段返回给前端。所以当你看到报错信息里有ErrorURL的时候说明抛出这条消息的地方已经不是底层 POI 解析而是业务层或者Controller层在拼装返回结果时把这个字段带了出来。也就是说这条报错链路上至少有两层底层解析层抛了一个很奇怪的异常上层业务层把异常消息原封不动塞进了返回体同时拼了个错误明细文件的下载地址。我在项目里见过很多类似的消息拼接写法比如AjaxResult ajax AjaxResult.success(); ajax.put(msg, 导入失败 e.getMessage()); ajax.put(errorUrl, errorFileUrl);用字符串拼接把异常信息和errorUrl揉在一起。如果异常消息里恰巧带双引号前端展示出来就是标题里那种半 JSON 不像 JSON 的奇怪形态。1.2 “too many filtered rows”来自 Excel 解析层的异常那too many filtered rows这段英文又是哪来的答案在 Apache POI 源码里。Excel 里有一项很常用的功能叫“筛选”。当你在表头点开下拉箭头选择条件后整个区域会被设置成一个自动筛选对象文件里对应的是 sheet 上的autoFilter refA1:D50000/这样的 XML 节点。POI 在加载这个文件的时候默认不会对筛选条件做实际求值但某些操作——比如访问筛选结果、自适应列宽——会触发它对筛选区域逐行套用条件算出哪些行被过滤掉。如果计算结果超出了 POI 内部设定的上限它就会直接抛出一个IllegalStateException消息大约就是too many filtered rows 100000, ...后面那串 xxx 在真实日志里多半是一个行数或者触发行号不同版本格式略有差异。我当时翻了 POI 源码才确认这个异常不是业务校验逻辑触发的而是底层 API 在读取自动筛选时主动抛出来的保护性异常。前面业务层看到的“导入失败”真正的导火索就是这一句。2. 复现故障同一张表别人导入成功我这批就失败2.1 复现的完整操作过程先说一下环境。我们这套系统是基于若依 3.8.x 二次开发的后端用的 POI 版本是 4.1.2导入入口是标准的 Excel 导入接口前端组件封装了上传、进度条和结果弹窗。用户报障时说得很简单在管理后台的“订单导入”页面选中一个 Excel 文件点上传后台直接返回“导入失败”连成功条数都没有。我拿到这个文件以后先在本地环境跑了一遍导入完美复现。当时 Tomcat 日志里的异常堆栈大概是这样的java.lang.IllegalStateException: too many filtered rows ... at org.apache.poi.xssf.usermodel.XSSFSheet.getFilteredRows(XSSFSheet.java:...) at org.apache.poi.xssf.usermodel.XSSFSheet.autoSizeColumn(XSSFSheet.java:...) at com.ruoyi.common.utils.poi.ExcelUtil.importExcel(ExcelUtil.java:...) at com.xxx.modules.order.service.OrderImportServiceImpl.importData(...)这个堆栈信息量很大异常在下游 POI 的getFilteredRows方法里上游调用者是若依ExcelUtil中的某一行。顺着调用链往前看几乎可以断定问题出在autoSizeColumn和自动筛选的组合上。2.2 用户文件与模板文件的差异到这里我已经明白问题不在代码逻辑而在文件本身。我让用户把那份 Excel 文件发过来打开以后一眼就看见了表头那一排下拉箭头——是筛选状态。进一步对比用户这份文件是在我们官方模板基础上手工插入了几列又用 WPS 的“筛选”功能对整列做了筛选没有取消筛选就直接另存为发出来了。筛选项本身很简单但关键在于 WPS 在另存为 xlsx 时会把筛选区域写进文件而且有时候会把区域范围扩得很大。用户这份文件筛选区域是 A1:Z50000也就是说接近五万行都被划进了筛选范围。这个区域本身不算夸张但 POI 要动态计算这个范围内的过滤行集合时候选行和结果集超过了它的上限瞬间就炸了。更隐蔽的是之前我们的测试同事也导入过带筛选的文件但数据只有几千行筛选区域很小POI 算得完所以这个问题一直没暴露出来。它就像一个隐性雷文件数量少、数据量小时完全没感觉一旦遇到大范围筛选就点爆了。2.3 直接猜错方向以为代码 bug其实数据文件的问题坦白说前期排查我走了不少弯路。刚开始我以为是ExcelUtil处理时遇到了什么特殊单元格格式比如日期列里有非法字符串、数字列里有空格。我花了将近半天在清洗数据上面写各种正则去检测单元格内容完全没往“筛选”这个方向想。真正让我回过神来的是一次对照实验我用一个纯手工新建、没有任何筛选的 Excel 文件尝试导入一切正常再把用户原文件复制一份清掉表头筛选后导入也正常——这才彻底确认根因。这次排查最大的教训就一句话导入类报错先看文件本身再看代码逻辑。很多看似离奇的导入错误其实都藏在文件的隐形属性里而不是业务代码里。文件有没有加密、有没有宏、有没有合并单元格、有没有筛选区域每一项都可能成为压垮 POI 的最后一根稻草。3. 定位根因从异常栈一路追进 POI 源码3.1 完整错误堆栈与关键代码定位到XSSFSheet.getFilteredRows以后我打开本机 Maven 仓库里对应版本的 POI 源码顺着这个方法往下看。这个方法的逻辑不复杂拿到自动筛选的引用范围循环遍历区域内每一行对每行执行筛选条件判断如果行被过滤掉就收集进一个 List。代码里有一个上限常量当收集数量超过上限时直接抛IllegalStateExceptionprivate static final int MAX_FILTERED_ROWS ...; if (filteredRows.size() MAX_FILTERED_ROWS) { throw new IllegalStateException(too many filtered rows filteredRows.size() ...); }具体数字我不在这里写死因为不同 POI 小版本会有差异但限制确实存在。而且这个方法是在autoSizeColumn中触发的类名就是这个意思。更值得关注的是调用入口。我们在继承若依ExcelUtil的项目代码里导出一段逻辑for (int i 0; i fields.length; i) { sheet.autoSizeColumn(i); }这段代码本意是在导入成功后调整列宽让错误明细文件更易读。但autoSizeColumn内部会调用getFilteredRows而我们的导入文件恰好带自动筛选。平时没有筛选的文件走这里完全无感一旦筛选区域太大就直接炸掉。3.2 控制变量法锁定元凶为了确认是不是autoSizeColumn这一条路径导致的我又做了几组对照实验文件情况操作结果无筛选2000 行直接导入成功有筛选筛选区域 50000 行直接导入失败too many filtered rows有筛选但移除筛选后 50000 行直接导入成功有筛选50000 行跳过 autoSizeColumn 逻辑直接导入成功有筛选50000 行跳过 autoSizeColumn 但保留筛选直接导入成功这一组结果非常干净问题只跟筛选状态有关跟行数无关。因为同样五万行数据去掉筛选就没事不调用autoSizeColumn也没事。所以结论可以锁定Excel 文件里的 AutoFilter 导入后的 autoSizeColumn 调用 必炸。3.3 为什么 POI 要限制“过滤行数”有人可能会问POI 为什么不直接算出所有过滤行非要搞个上限我个人的理解是POI 在内存模型下做这个操作需要先把整个 sheet 的 XML 解析成一大批 Java 对象本身已经非常吃内存。如果允许一次性对十几万行做逐行筛选求值在高并发场景下会把堆内存直接打爆。设置上限是一种保护机制宁可抛异常也不过度消耗内存。但这同时也暴露了 POI 的一个设计问题autoSizeColumn和筛选求值本来是两件不太相干的事前者只是需要拿到列的实际宽度却被迫走了一遍“计算被过滤行集合”的逻辑。这种隐式耦合导致一个本来只用于美化导出结果的调用反而成了导入失败的风险点。3.4 结论模板带自动筛选且筛选区域行数过大最终根因归纳下来就是两句话用户上传的 Excel 文件里存在 AutoFilter筛选区域行数较多触发 POI 的过滤行数上限。二次开发后的导入工具在导入完成后执行autoSizeColumn主动走进getFilteredRows分支把异常抛了出来。这两个条件缺一不可文件没问题、代码没问题但两者组合起来就是有问题。排错时如果只盯一个变量很容易陷进去出不来。4. 修复方案入口校验 代码兜底 两手抓4.1 让用户改 Excel去掉自动筛选最快的应急办法是让用户重新处理文件把 Excel 里的筛选关掉再上传。具体操作很简单选中表头区域点“数据”选项卡里的筛选按钮让下拉箭头消失然后另存为 xlsx 重新上传。但这个方案只能救急。真实业务里用户不会管你这些能上传成功才是硬道理而且很多人根本不知道自己的文件头上带着筛选状态。有人用 WPS 打开模板后点了一下筛选保存时连自己都没注意多了一个下拉箭头。所以必须从代码层面做兜底。4.2 后端代码兜底导入前清理 AutoFilter最直接的修复是在读取文件后、执行解析前把 sheet 上的自动筛选拿掉。在 POI 里可以用下面的方式判断并清除import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.xssf.usermodel.XSSFSheet; for (int i 0; i workbook.getNumberOfSheets(); i) { Sheet sheet workbook.getSheetAt(i); if (sheet.getAutoFilter() ! null) { ((XSSFSheet) sheet).setAutoFilter(null); } }关键点是setAutoFilter(null)而不是去遍历单元格删除数据。它只是移除文件里的筛选配置不会影响任何业务数据。把这个清理逻辑放在WorkbookFactory.create之后、new ExcelUtil(clazz).importExcel之前就能把绝大多数带筛选文件的问题从源头抹平。但是我也要提醒一句不要盲目对生产环境所有导入都做这一步因为确实存在极少数业务场景需要根据筛选条件来限定导入范围。更稳妥的做法是在清理前打日志记录文件的筛选状态方便后来排查。我实际落地的时候改的是我们项目里继承出来的公共导入方法而不是直接改若依 jar 包里的代码。这样既保留了原始逻辑又在我们自己的工程里做了统一处理。4.3 更彻底的方案替换成 SAX 模式的 EasyExcel 导入如果你的项目还没上线、改动成本可控还有一个更彻底的做法把导入解析从 POI 的 UserModel 换成 EasyExcel 的 SAX 模式。EasyExcel 底层虽然也依赖 POI但它是基于 SAX 事件解析的一次只读一行数据不吃内存而且对自动筛选没有同样的求值逻辑。它处理大 Excel 有天然优势大概率能直接绕开这个问题。我简单对比过两套实现的差异对比项RuoYi 自带 ExcelUtilPOIEasyExcel解析模型UserModel 全量加载SAX 事件逐行读取内存占用文件越大越吃力相对稳定自动筛选文件可能触发 filtered rows 上限不涉及该问题模板校验/错误回写已有内置能力需要重新封装改造量无中等偏大对于已经上线、导入频率不高的系统我建议先做第二种方案清理 AutoFilter应急等有新迭代窗口再评估是否迁移 EasyExcel。没必要为了一个文件问题去重写整个导入模块。5. 改完之后的实测表现5.1 修复前后对比按 4.2 节的方法改完代码我在测试环境做了五轮验证用的正是用户当初那份出问题的文件约 4.8 万行数据、带自动筛选。验证项修复前修复后相同文件导入直接抛异常正常导入错误明细回写无正常生成 errorUrl10000 行无筛选文件正常正常10000 行有筛选文件异常正常重复导入同一文件异常正常补充一点清理 AutoFilter 只影响 Excel 文件里的筛选状态不改变数据内容所以修复后的导入结果跟去掉筛选后手动操作完全一致业务数据没有任何差异。5.2 边界情况什么样的文件还会继续踩这个坑虽然问题修了但有几种文件还是要小心宏文件.xlsm如果一个 sheet 同时有宏定义和自动筛选直接setAutoFilter(null)后另存时可能影响宏信息处理前最好先判断文件类型。老版本.xlsHSSF 的实现跟 XSSF 不同getFilteredRows这个方法只在 xlsx 的 XSSFSheet 上有.xls 格式一般不受影响。包含多个 sheet 的文件清理时要遍历所有 sheet而不是只处理第一个。WPS 生成的 xlsx有些 WPS 版本会在 autoFilter 之外再写一个自定义扩展属性POI 对它的兼容性不太好同样建议在导入前做一次“另存为纯 Office 格式”的测试。5.3 上线观察与后续监控修复后上线观察了一个月导入模块没有再出现too many filtered rows的报错。但我建议大家在监控里加一条规则如果再次出现类似异常报警关键词除了too many filtered rows把ErrorURL、autoFilter、XSSFSheet也一起加上。因为这类异常往往不是单一代码问题文件来源不可控今天修了自动筛选明天可能又会冒出来别的 POI 兼容性问题。提前把异常关键词埋进告警能减少“用户发现了问题、我们还没发现”的被动局面。6. 这类导入报错的通用排查清单6.1 先分清是文件问题还是代码问题遇到任何导入类报错我现在的第一反应永远是先下载原始报错文件本地尝试导入一次同时分别做两组对照同一文件换代码版本、同一代码换另一个文件。如果只有某一个文件必现那 90% 可以断定是文件本身的问题如果所有文件都报再回头看代码和依赖。这套判断规则听起来很简单但我见过很多同事一上来就翻后端代码翻了半天最后发现是用户拿了个加密 Excel、带宏的 Excel、或者旧版 .xls 后缀但内容其实是 .xlsx 的文件。先分离变量效率最高。6.2 让报错信息“带全”把 errorUrl 真正利用起来前面提到ErrorURL是若依返回错误明细下载地址的字段。实际排查时我发现很多导入失败场景下前端只是简单弹了个“导入失败”根本没有把errorUrl里的链接展示给用户也没有把底层异常完整传给前端。我强烈建议在导入页面的失败提示里把“下载错误明细”做成一个可点击的按钮这样用户自己也能拿到失败原因不用每次都把文件转来转去。如果担心底层英文异常看着吓人可以做一个映射已知 POI 异常统一翻译成“文件格式不支持或包含特殊筛选条件请另存为 xlsx 后重试”未知异常保留原始堆栈到日志。6.3 临时方案和长期方案要分清楚最后再说一个经验修这种问题临时方案和长期方案不要混为一谈。临时方案是帮用户立刻恢复导入能力比如清掉自动筛选、手动另存文件长期方案是代码层面做兜底比如导入前清理 AutoFilter、异常告警、错误详情下载。如果只做前者问题过了两天又会以另一种文件属性冒出来如果只做后者用户当下还是卡在那里干着急。两条线一起推才是完整闭环。如果让我总结这次排查最大的收获不是记住了 POI 某个内部限制而是重新建立了一个习惯遇到导入报错先把报错信息里的上下文字段当线索——ErrorURL指向业务层、底层异常指向解析层逐层剥开再动手改代码。最后再分享一个小技巧凡是处理 Excel 的公共服务我都建议在单元测试里放一个带自动筛选、带合并单元格、带图片的精简测试文件跑一遍导入冒烟用例比任何代码 review 都能更早发现这类“组合型”问题。