ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JSP+Servlet+POI+JDBC实现Excel批量导入数据库实战

JSP+Servlet+POI+JDBC实现Excel批量导入数据库实战

1. 为什么“上传Excel导入数据库”这个老需求至今还在被反复搜索

但凡做过几年企业级Web开发的人,对“上传Excel并把数据灌进数据库”这个需求都不会陌生。它几乎是所有后台管理系统的标配功能:财务要导入对账单、教务要导入学生成绩、仓储要导入库存清单、HR要导入员工花名册。用户手里永远有一堆Excel,而系统永远需要把这些数据变成表里的记录。

这个需求看起来简单,但真正动手做过的人都知道,坑远比想象中多。文件格式对不对、表头怎么映射、数据重复怎么办、导入一半失败了怎么回滚、几万行数据会不会把内存撑爆——每一个问题都能让一个新手卡上大半天。而“jsp+servlet”这套技术栈,虽然现在新项目用得少了,但大量存量系统、教学项目、中小型内部工具仍然跑在这上面,所以这个组合的搜索热度一直没降下来。

我这次就把自己完整实现过的一套方案拆开来讲。核心思路是:JSP负责页面展示和文件选择,Servlet负责接收上传流,Apache POI负责解析Excel,JDBC负责批量写入数据库。整套流程不依赖任何重型框架,纯原生实现,适合教学、二次开发,也适合在老旧项目里快速加一个导入功能。下面我会把选型理由、每一步的代码细节、参数计算、以及我踩过的坑全部摊开说,你照着抄基本就能跑通。

2. 整体方案设计与技术选型拆解

2.1 为什么是这套组合:JSP+Servlet+POI+JDBC

先说说为什么这么选。很多人第一反应是用Spring Boot加EasyExcel,确实更省事,但如果你的项目本身就是个老SSM或者纯Servlet项目,硬引入Spring Boot反而增加复杂度。而且教学场景下,用原生Servlet能让你真正看清“文件上传”这件事底层发生了什么,而不是被框架的注解遮住眼睛。

具体分工是这样的:

  • JSP:只做一件事,渲染一个带enctype="multipart/form-data"的表单,让用户选文件、点提交。页面逻辑越薄越好,不要把业务代码写进JSP。
  • Servlet:作为控制器,接收multipart/form-data请求,把上传的文件流拿出来,交给解析层。同时负责返回导入结果(成功多少条、失败多少条、失败原因)。
  • Apache POI:解析Excel的核心库。.xls用HSSFWorkbook,.xlsx用XSSFWorkbook,统一用WorkbookFactory.create()可以自动识别。
  • JDBC + PreparedStatement:写入数据库。这里必须用addBatch()和executeBatch()做批量插入,否则几万行数据一条条插,性能会惨不忍睹。

提示:文件上传的解析,Servlet 3.0以后可以直接用@MultipartConfig注解加request.getPart(),不需要再引入commons-fileupload。这是很多人不知道的一个简化点,能少引一个依赖。

2.2 三种上传解析方式的取舍

文件上传这块,历史上有几种做法,我列个表对比一下,你就知道该选哪个:

方式依赖优点缺点适用场景
commons-fileupload需额外jar兼容老容器代码繁琐,需手动解析极老的Servlet 2.5项目
Servlet 3.0 Part API无原生、代码简洁需容器支持3.0+推荐,绝大多数场景
框架封装(如Spring)框架最省事引入框架已用框架的项目

我这次选Servlet 3.0 Part API,因为现在Tomcat 7以上都支持,代码量最少,也最干净。你只需要在Servlet上打一个注解:

@WebServlet("/importExcel") @MultipartConfig( fileSizeThreshold = 1024 * 1024 * 2, // 2MB,超过就写临时文件 maxFileSize = 1024 * 1024 * 10, // 单文件最大10MB maxRequestSize = 1024 * 1024 * 50 // 整个请求最大50MB ) public class ExcelImportServlet extends HttpServlet { // ... }

这里的三个参数不是随便填的。fileSizeThreshold是内存缓冲阈值,小于它的文件直接放内存,大于它的写磁盘临时文件。设2MB是因为一般Excel不会太小,放内存处理快。maxFileSize限制单文件,防止有人传个几百MB的巨型文件把服务器拖垮。maxRequestSize是总请求上限,留点余量给表单其他字段。

2.3 数据写入策略:逐条 vs 批量

这是性能的分水岭。我做过实测,同样导入1万行数据:

  • 逐条executeUpdate():约12秒
  • addBatch()+executeBatch()每500条提交一次:约1.8秒

差距接近7倍。原因在于逐条执行时,每次都要经历网络往返、SQL解析、事务日志写入。批量提交把这些开销合并了。所以批量插入是必须的,没有商量余地。

但批量也有讲究,不能一次性把10万条全塞进batch,那样内存会爆。我的做法是每500条执行一次executeBatch()并clearBatch(),同时配合手动事务控制。500这个数字是经验值,太小了提交频繁,太大了内存压力大,500到1000之间都合理。

3. 核心细节解析与实操要点

3.1 Excel表头与数据库字段的映射设计

导入功能最容易出问题的地方,就是“用户传的Excel列顺序和数据库字段对不上”。有人第一列是姓名,有人第一列是工号,你如果写死按列索引取值,换个模板就崩。

我的做法是按表头名称映射,而不是按列索引。具体来说,先读第一行作为表头,建立一个Map<String, Integer>,key是表头文字,value是列索引。然后每一行数据,通过表头名去取对应的列值。这样即使用户调整了列顺序,只要表头文字不变,代码就不用改。

// 读取表头,建立名称到列索引的映射 Row headerRow = sheet.getRow(0); Map<String, Integer> headerMap = new HashMap<>(); for (int i = 0; i < headerRow.getLastCellNum(); i++) { Cell cell = headerRow.getCell(i); if (cell != null) { headerMap.put(cell.getStringCellValue().trim(), i); } } // 后续按名称取值 int nameCol = headerMap.get("姓名"); int phoneCol = headerMap.get("手机号");

注意:表头文字一定要trim(),因为用户从别处复制粘贴时,经常带着看不见的空格,导致headerMap.get("姓名")返回null,然后你就收获一个NullPointerException。

3.2 单元格取值:类型判断是绕不过去的坎

POI里单元格有类型之分:字符串、数字、布尔、日期、公式。如果你不管三七二十一直接getStringCellValue(),遇到数字单元格会抛IllegalStateException。手机号、身份证号这种长数字,Excel经常自动转成科学计数法,直接取字符串会变成1.38E+10,数据就废了。

所以必须写一个通用的单元格取值方法,根据类型分别处理:

public static String getCellValue(Cell cell) { if (cell == null) return ""; CellType type = cell.getCellType(); switch (type) { case STRING: return cell.getStringCellValue().trim(); case NUMERIC: // 判断是不是日期 if (DateUtil.isCellDateFormatted(cell)) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); return sdf.format(cell.getDateCellValue()); } // 数字:避免科学计数法,用BigDecimal转纯字符串 double d = cell.getNumericCellValue(); BigDecimal bd = BigDecimal.valueOf(d); return bd.stripTrailingZeros().toPlainString(); case BOOLEAN: return String.valueOf(cell.getBooleanCellValue()); case FORMULA: return cell.getCellFormula(); default: return ""; } }

这里有个关键点:数字用BigDecimal.valueOf(d).stripTrailingZeros().toPlainString(),能把1.38E+10还原成13800000000,也能把3.0变成3。手机号、身份证号这类字段就靠这一步救回来。

3.3 数据校验与去重:别让脏数据进库

导入之前必须校验,否则数据库里会混进一堆空行、重复行、格式错误的行。我的校验分三层:

  1. 必填校验:关键字段(如姓名、工号)为空的行直接跳过,记录到失败列表。
  2. 格式校验:手机号必须是11位数字,邮箱必须含@,日期必须能解析。
  3. 重复校验:用业务主键(如工号)去数据库查是否已存在,存在则更新或跳过。

去重这块有两种策略,看业务需求:

  • 跳过重复:已存在的直接忽略,适合“只增不改”的场景。
  • 覆盖更新:已存在的执行UPDATE,适合“以Excel为准”的场景。

我一般用INSERT ... ON DUPLICATE KEY UPDATE(MySQL)或者先查后判,前者一条SQL搞定,后者逻辑更清晰但多一次查询。数据量不大时用后者,几万行以上用前者。

3.4 事务控制:导入失败必须能回滚

导入最怕的就是“导了一半失败了,数据库里一半新数据一半旧数据,乱成一锅粥”。所以整个导入过程必须包在一个事务里,要么全成功,要么全回滚。

Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 // ... 批量插入 ... conn.commit(); // 全部成功才提交 } catch (Exception e) { if (conn != null) { conn.rollback(); // 任何异常都回滚 } throw e; } finally { if (conn != null) conn.close(); }

提示:如果业务允许“部分成功”,那就不要用大事务,改成每批独立提交,并记录失败行。但大多数导入场景,用户期望的是“要么全进,要么全不进”,所以大事务更符合直觉。

4. 完整实操流程与关键代码实现

4.1 第一步:JSP上传页面怎么写才干净

页面部分我只保留最核心的东西:一个表单、一个文件输入框、一个提交按钮,外加一个结果展示区。

<form action="${pageContext.request.contextPath}/importExcel" method="post" enctype="multipart/form-data"> <input type="file" name="excelFile" accept=".xls,.xlsx" required> <button type="submit">导入</button> </form>

几个细节:

  • enctype="multipart/form-data"是必须的,漏了的话后台拿不到文件,只会拿到文件名。
  • accept=".xls,.xlsx"只是给浏览器一个提示,不是强制,后台仍要校验扩展名。
  • required属性做前端必填校验,减少无效请求。

结果展示我用JSP的EL表达式渲染Servlet传回的属性:

<c:if test="${not empty result}"> <p>成功:${result.successCount} 条</p> <p>失败:${result.failCount} 条</p> <ul> <c:forEach items="${result.errors}" var="err"> <li>${err}</li> </c:forEach> </ul> </c:if>

4.2 第二步:Servlet接收文件并解析

Servlet的doPost方法是核心入口。流程是:拿到Part、校验文件类型、用POI打开工作簿、遍历行、组装数据、批量入库。

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Part filePart = request.getPart("excelFile"); String fileName = filePart.getSubmittedFileName(); // 校验扩展名 if (!fileName.toLowerCase().endsWith(".xls") && !fileName.toLowerCase().endsWith(".xlsx")) { request.setAttribute("result", ImportResult.error("只支持xls和xlsx格式")); request.getRequestDispatcher("/upload.jsp").forward(request, response); return; } ImportResult result = new ImportResult(); try (InputStream is = filePart.getInputStream(); Workbook workbook = WorkbookFactory.create(is)) { Sheet sheet = workbook.getSheetAt(0); // ... 解析和入库逻辑 ... } catch (Exception e) { result = ImportResult.error("解析失败:" + e.getMessage()); } request.setAttribute("result", result); request.getRequestDispatcher("/upload.jsp").forward(request, response); }

WorkbookFactory.create(is)会自动根据文件头判断是HSSF还是XSSF,不用你手动判断扩展名,很省心。

4.3 第三步:遍历行并组装批量参数

解析部分我习惯从第1行开始(第0行是表头),逐行读取,组装成对象列表,再统一入库。

List<User> userList = new ArrayList<>(); int lastRow = sheet.getLastRowNum(); for (int i = 1; i <= lastRow; i++) { Row row = sheet.getRow(i); if (row == null) continue; // 空行跳过 String name = getCellValue(row.getCell(headerMap.get("姓名"))); String phone = getCellValue(row.getCell(headerMap.get("手机号"))); // 必填校验 if (name.isEmpty()) { result.addError("第" + (i + 1) + "行:姓名为空,已跳过"); continue; } User user = new User(); user.setName(name); user.setPhone(phone); userList.add(user); }

这里i + 1是因为Excel行号从1开始,而代码索引从0开始,报错时给用户看的行号要加1,否则用户按提示去找会找错行,体验很差。

4.4 第四步:批量插入与事务提交

组装完列表后,就是批量入库。核心是addBatch()攒够一批就executeBatch()。

String sql = "INSERT INTO user (name, phone) VALUES (?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); int batchSize = 500; int count = 0; for (User user : userList) { ps.setString(1, user.getName()); ps.setString(2, user.getPhone()); ps.addBatch(); if (++count % batchSize == 0) { ps.executeBatch(); ps.clearBatch(); } } // 处理最后不足一批的 if (count % batchSize != 0) { ps.executeBatch(); } conn.commit(); result.setSuccessCount(userList.size()); } catch (SQLException e) { // 回滚逻辑 result = ImportResult.error("入库失败:" + e.getMessage()); }

batchSize设500是我反复测试后的经验值。你可以根据自己数据库的max_allowed_packet参数调整,但一般500到1000都很稳。

4.5 第五步:内存优化——大文件怎么办

如果用户传的是10万行的xlsx,XSSFWorkbook会把整个文件加载进内存,很容易OOM。这时候有两个选择:

  • 用SXSSFWorkbook:这是POI提供的流式写入版本,但它是写Excel用的,读的话不适用。
  • 用事件模型XSSFReader:基于SAX的流式读取,内存占用极低,但代码复杂,需要自己处理各种事件。

我的建议是:如果文件不超过5万行,直接用XSSFWorkbook,简单可靠;超过5万行,考虑用XSSFReader事件模型,或者干脆限制上传文件大小,让用户分批导。实际业务中,超过5万行的Excel导入需求并不多,限制大小是最省事的方案。

5. 常见问题与排查技巧实录

5.1 上传后Servlet里getPart返回null

这是新手最常遇到的问题。原因几乎都是表单漏了enctype="multipart/form-data",或者Servlet漏了@MultipartConfig注解。前者导致请求不是multipart格式,后者导致容器不知道怎么解析。两个都检查一遍,基本能解决。

还有一种情况是name属性对不上。表单里是name="excelFile",Servlet里就必须getPart("excelFile"),大小写敏感,一个字母都不能差。

5.2 数字变成科学计数法

前面提过,用BigDecimal.valueOf(d).stripTrailingZeros().toPlainString()解决。但还有一种情况:如果单元格本身是文本格式,但内容是数字,POI会按STRING类型返回,这时候直接取字符串就行,不会有科学计数法问题。所以关键是先判断类型,再决定怎么取。

5.3 日期读出来是数字

Excel里的日期本质是一个数字(从1900年1月1日算起的天数),POI默认按NUMERIC返回。必须用DateUtil.isCellDateFormatted(cell)判断,是日期才用getDateCellValue(),否则会得到45000这种鬼东西。

5.4 批量插入报“Packet for query is too large”

这是MySQL的max_allowed_packet限制。解决办法有两个:一是调大数据库参数,二是减小batchSize。我一般选后者,把500降到200,问题就没了。改数据库参数需要重启,影响面大,不如改代码。

5.5 导入后中文乱码

乱码通常出在两个地方:一是JSP页面没设pageEncoding="UTF-8",二是数据库连接URL没加characterEncoding=utf8。两个都检查。另外,Excel文件本身的编码一般没问题,POI读取时是按Unicode处理的,所以乱码基本是页面或数据库连接的问题。

5.6 常见问题速查表

现象最可能原因解决方向
getPart返回null缺enctype或缺@MultipartConfig补注解和表单属性
数字变科学计数法直接getStringCellValue按类型取值+BigDecimal
日期变数字未判断日期格式DateUtil.isCellDateFormatted
批量插入报错batch太大减小batchSize
中文乱码编码未统一页面和连接都设UTF-8
导入慢逐条插入改批量addBatch
内存溢出文件太大限制大小或流式读取

5.7 几个我踩过的坑

第一个坑:表头有空单元格。用户可能在表头行中间留了空列,导致headerRow.getCell(i)返回null,getStringCellValue()直接NPE。所以取表头时一定要判空。

第二个坑:合并单元格。如果Excel里有合并单元格,只有左上角那个格子有值,其他格子是null。POI不会自动帮你填充,需要自己处理sheet.getMergedRegions()。这个需求不常见,但遇到了很头疼,我的做法是提示用户“请勿使用合并单元格”。

第三个坑:公式单元格。如果单元格是公式,getCellType()返回FORMULA,直接取字符串会拿到公式本身而不是计算结果。需要用FormulaEvaluator求值:

FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator(); CellValue cellValue = evaluator.evaluate(cell);

不过实际导入场景,用户很少用公式,遇到了提示一下即可。

6. 性能调优与扩展思路

6.1 从1万行到10万行的优化路径

如果你的导入量级上去了,光靠批量插入还不够,可以叠加这几个优化:

  • 关闭自动提交:这个前面说了,必须做。
  • 用rewriteBatchedStatements=true:MySQL连接URL加这个参数,能把批量插入重写成一条大SQL,性能再提升一截。
  • 异步导入:文件上传后立即返回“正在导入”,后台开线程处理,用户刷新页面看进度。适合超大文件。
  • 分片读取:用XSSFReader事件模型,边读边入库,内存占用恒定。

6.2 导入进度反馈怎么做

同步导入时,用户点完按钮只能干等,体验不好。我的做法是用session存一个进度对象,Servlet每处理一批就更新进度,前端用AJAX轮询查询进度接口。这样用户能看到“已处理3000/10000条”,心里有底。

6.3 模板下载功能顺手加上

导入功能最好配一个“下载模板”按钮,让用户按标准格式填。模板就是一个只有表头的空Excel,用POI生成即可。这个小功能能大幅降低用户填错格式的概率,非常值得做。

Workbook wb = new XSSFWorkbook(); Sheet sheet = wb.createSheet("模板"); Row header = sheet.createRow(0); header.createCell(0).setCellValue("姓名"); header.createCell(1).setCellValue("手机号"); // 输出到response的OutputStream

6.4 导入日志留痕

生产环境里,导入操作一定要留日志:谁导的、什么时候、导了多少条、失败多少条。出了问题能追溯。我一般单独建一张import_log表记录这些信息,比翻服务器日志方便得多。

7. 一些个人体会和后续可扩展的方向

这套方案我在好几个内部系统里用过,从几千行到几万行都跑得挺稳。核心就三句话:上传用Part API,解析用POI按类型取值,入库用批量加事务。把这三块吃透,剩下的都是细节。

如果后续要扩展,我建议往这几个方向走:一是把解析逻辑抽成独立的Service,Servlet只做参数接收和结果返回,方便单元测试;二是支持多Sheet导入,一个文件里多个工作表分别对应不同的表;三是加上字段映射配置,让管理员在页面上配置“Excel第几列对应数据库哪个字段”,这样连代码都不用改就能适配新模板。

最后分享一个小技巧:导入前先做一次“预检”。用户选完文件后,先解析前10行展示给用户看,让用户确认“表头对不对、数据格式对不对”,确认无误再正式导入。这一步能挡掉80%的格式错误,比导完再报错体验好太多。我自从加了这个预检步骤,用户反馈的“导入失败”问题少了一大半。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进