首页 / 资讯中心 / 文章详情

JSP+Servlet上传Excel并批量导入数据库:从表单到POI解析与JDBC批处理实战

JSP+Servlet上传Excel并批量导入数据库:从表单到POI解析与JDBC批处理实战 ★ FEATURED ARTICLE
1. 为什么文件上传这件事在JSPServlet里总有人栽跟头做Java Web开发的人几乎都绕不开一个需求让用户上传一份Excel然后把里面的数据批量写进数据库。听起来简单但真动手做的时候很多人会卡在几个地方——文件到底怎么从前端传到后端Servlet里用request.getParameter()为什么拿不到文件Excel的.xls和.xlsx格式处理方式为什么完全不一样几万行数据一次性插入数据库为什么程序直接卡死或者内存溢出我见过不少项目上传功能是能跑通的但代码里藏着各种隐患临时文件没清理、大文件直接把内存撑爆、Excel表头顺序一变就全乱套、日期格式解析报错、空行导致主键冲突……这些问题在测试环境数据量小的时候根本看不出来一上生产就集中爆发。这篇内容就是围绕JSPServlet上传Excel并导入数据库这个具体场景把从表单设计、文件接收、Excel解析、数据校验到批量入库的完整链路拆开讲清楚。适合正在做后台管理系统的开发者、需要实现批量导入功能的同学以及想搞清楚Servlet文件上传底层原理的人。我会把每一步为什么这么做、参数怎么算、坑在哪里都说明白代码可以直接参考改造。核心关键词就几个JSP文件上传、Servlet接收文件、Excel解析、批量导入数据库、Apache POI。把这几个环节串起来整个功能就立住了。2. 文件上传的表单设计一个enctype属性决定成败2.1 为什么普通表单传不了文件很多人第一次写上传功能前端表单是这样写的form actionupload methodpost input typefile nameexcelFile input typesubmit value上传 /form后端Servlet里用request.getParameter(excelFile)去取结果拿到的是文件名不是文件内容。问题就出在表单缺少enctype属性。默认情况下表单的enctype是application/x-www-form-urlencoded这种编码方式会把所有字段拼成keyvaluekeyvalue的字符串格式文件这种二进制数据根本没法用这种方式传输。浏览器只会把文件的路径或者文件名当作一个普通字符串发过去。要让文件内容真正传到服务器必须把编码类型改成multipart/form-dataform actionupload methodpost enctypemultipart/form-data input typefile nameexcelFile accept.xls,.xlsx input typesubmit value上传 /formmultipart/form-data的意思是多部分表单数据它会把表单拆成多个部分每个部分用一段边界字符串boundary隔开。文件部分会带上Content-Type和Content-Disposition头信息服务器端就能根据这些信息把文件内容单独提取出来。注意method必须是postget请求的请求体长度有限制而且文件内容没法放在URL里。2.2 accept属性只是建议不是保险accept.xls,.xlsx这个属性只是给浏览器一个提示让文件选择框默认过滤出Excel文件。但用户完全可以手动切换成所有文件然后选一个.txt或者.exe。所以真正的格式校验必须在服务端做不能依赖前端。我一般会在前端加一层JavaScript校验用文件名的后缀做初步判断同时限制文件大小function checkFile() { var file document.getElementById(excelFile).files[0]; if (!file) { alert(请选择文件); return false; } var name file.name.toLowerCase(); if (!name.endsWith(.xls) !name.endsWith(.xlsx)) { alert(只支持xls和xlsx格式); return false; } if (file.size 10 * 1024 * 1024) { alert(文件不能超过10MB); return false; } return true; }这段JS的作用是提升用户体验避免用户选了文件提交后才被服务端拒绝。但服务端必须再校验一遍因为前端校验可以被绕过。2.3 表单里同时传普通字段的处理实际项目里上传Excel往往还要附带一些参数比如导入类型覆盖策略所属部门等。这些普通字段和文件放在同一个表单里Servlet端怎么取在multipart/form-data编码下request.getParameter()依然可以取到普通文本字段但前提是你使用的文件上传解析库已经正确解析了请求。如果用的是Servlet 3.0自带的Part接口普通字段需要通过request.getParameter()获取文件通过request.getPart()获取。如果用的是Apache Commons FileUpload则需要遍历FileItem列表判断每个item是普通字段还是文件字段。这个区别很关键后面讲Servlet接收时会详细展开。3. Servlet端接收文件Part接口和Commons FileUpload怎么选3.1 Servlet 3.0的Part接口原生但够用从Servlet 3.0开始Java EE规范内置了文件上传支持。你只需要在Servlet上加上MultipartConfig注解就可以用request.getPart()拿到文件WebServlet(/upload) MultipartConfig( fileSizeThreshold 1024 * 1024, // 1MB超过这个大小写入磁盘 maxFileSize 10 * 1024 * 1024, // 单个文件最大10MB maxRequestSize 20 * 1024 * 1024 // 整个请求最大20MB ) public class UploadServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); Part part request.getPart(excelFile); String fileName part.getSubmittedFileName(); InputStream inputStream part.getInputStream(); // 后续解析Excel } }fileSizeThreshold这个参数值得说一下。当上传的文件小于这个阈值时文件会保存在内存里超过阈值就写入临时磁盘文件。设置成1MB是个比较稳妥的选择——小文件走内存速度快大文件走磁盘避免内存溢出。maxFileSize和maxRequestSize是硬性限制超过会抛IllegalStateException。这两个值要根据实际业务定不能拍脑袋写个很大的数否则容易被人恶意上传大文件拖垮服务。Part接口的优点是原生、无需额外依赖、代码简洁。缺点是功能相对基础比如获取文件名在不同浏览器下行为不一致有些老容器对getSubmittedFileName()支持不好需要自己从Content-Disposition头里解析。3.2 Apache Commons FileUpload老牌但灵活在Servlet 3.0之前或者项目需要更细粒度控制时Apache Commons FileUpload是标准选择。它需要配合commons-io使用核心类是ServletFileUpload和DiskFileItemFactoryDiskFileItemFactory factory new DiskFileItemFactory(); factory.setSizeThreshold(1024 * 1024); factory.setRepository(new File(System.getProperty(java.io.tmpdir))); ServletFileUpload upload new ServletFileUpload(factory); upload.setFileSizeMax(10 * 1024 * 1024); upload.setSizeMax(20 * 1024 * 1024); ListFileItem items upload.parseRequest(request); for (FileItem item : items) { if (item.isFormField()) { // 普通字段 String fieldName item.getFieldName(); String value item.getString(UTF-8); } else { // 文件字段 String fileName item.getName(); InputStream in item.getInputStream(); } }Commons FileUpload的优势在于可以精确控制临时文件目录、可以逐个处理每个表单项、对文件名解析做了兼容处理。缺点是代码量稍多而且需要引入两个jar包。3.3 选型建议看项目环境对比维度Part接口Commons FileUpload依赖无Servlet 3.0原生需要commons-fileupload和commons-io代码量少中等文件名兼容性部分容器需手动解析自动处理临时文件控制通过注解参数可精确指定目录适用场景新项目、Tomcat 7老项目、需要精细控制我的建议是如果项目跑在Tomcat 7及以上直接用Part接口就够了少引入依赖少一份维护成本。如果项目环境比较老或者需要把上传的文件先存到指定目录再处理Commons FileUpload更合适。提示无论用哪种方式临时文件都要记得清理。Part接口可以通过part.delete()删除Commons FileUpload的FileItem在解析完成后如果没被持久化会在请求结束时由容器清理但显式调用item.delete()更保险。4. Excel解析POI的两种Workbook和那些容易踩的格式坑4.1 HSSF和XSSF的区别不是名字那么简单Apache POI处理Excel有两个核心类HSSFWorkbook对应.xls格式Excel 97-2003XSSFWorkbook对应.xlsx格式Excel 2007。这两个格式的底层结构完全不同——.xls是二进制格式.xlsx本质是一个zip压缩包里面是一堆XML文件。很多人写代码时只用一个HSSFWorkbook结果用户上传.xlsx直接报错。正确的做法是根据文件后缀或者用WorkbookFactory自动判断InputStream in part.getInputStream(); Workbook workbook; try { workbook WorkbookFactory.create(in); } catch (Exception e) { throw new RuntimeException(文件格式不正确请上传有效的Excel文件); }WorkbookFactory.create()会根据文件头自动选择HSSF还是XSSF省去了手动判断的麻烦。但要注意它需要poi-ooxml依赖才能处理.xlsx只引入poi是不够的。Maven依赖至少要有这两个dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.3/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency4.2 大文件解析的内存陷阱XSSFWorkbook会把整个Excel文件加载到内存里一个几万行的.xlsx文件可能占用几百MB内存。如果同时有多个用户上传服务器很容易OOM。POI提供了流式解析方案SXSSFWorkbook用于写大文件XSSFReader配合SheetContentsHandler用于读大文件。读大文件的模式叫SAX模式它不会把整个文件加载到内存而是逐行读取并触发事件回调。OPCPackage pkg OPCPackage.open(inputStream); XSSFReader reader new XSSFReader(pkg); SharedStringsTable sst reader.getSharedStringsTable(); XSSFReader.SheetIterator iter (XSSFReader.SheetIterator) reader.getSheetsData(); while (iter.hasNext()) { InputStream sheetStream iter.next(); SheetContentsHandler handler new SheetContentsHandler() { public void startRow(int rowNum) { } public void endRow(int rowNum) { } public void cell(String cellReference, String formattedValue, XSSFComment comment) { // 逐单元格处理 } public void headerFooter(String text, boolean isHeader, String tagName) { } }; XSSFSheetXMLHandler xmlHandler new XSSFSheetXMLHandler( sst, null, handler, new DataFormatter(), false); SAXParser parser SAXParserFactory.newInstance().newSAXParser(); parser.parse(sheetStream, xmlHandler); sheetStream.close(); }SAX模式代码复杂不少但内存占用极低适合处理几万行以上的数据。如果数据量在几千行以内直接用WorkbookFactory更省事。4.3 单元格取值DataFormatter是你的朋友Excel单元格有各种类型字符串、数字、日期、布尔、公式。直接用cell.getStringCellValue()遇到数字类型会抛异常用cell.getNumericCellValue()遇到字符串也会挂。最稳妥的方式是用DataFormatter它能把单元格的值按照Excel里显示的格式转成字符串DataFormatter formatter new DataFormatter(); for (Row row : sheet) { for (Cell cell : row) { String value formatter.formatCellValue(cell); // value就是单元格显示的内容 } }但DataFormatter有个坑日期格式。如果单元格是日期类型formatCellValue返回的是根据单元格格式渲染的字符串可能是2024/1/15也可能是2024-01-15取决于用户在Excel里设置的格式。如果数据库字段是DATE类型你需要自己解析这个字符串或者用DateUtil.isCellDateFormatted(cell)判断后取cell.getDateCellValue()。if (DateUtil.isCellDateFormatted(cell)) { Date date cell.getDateCellValue(); // 转成java.sql.Date或Timestamp } else { String value formatter.formatCellValue(cell); }4.4 表头映射别把列顺序写死新手常犯的错误是代码里写死第0列是姓名第1列是年龄第2列是邮箱。用户稍微调整一下Excel列顺序整个导入就乱了。更健壮的做法是先读表头行建立列名→列索引的映射MapString, Integer headerMap new HashMap(); Row headerRow sheet.getRow(0); for (Cell cell : headerRow) { String header formatter.formatCellValue(cell).trim(); headerMap.put(header, cell.getColumnIndex()); } // 后续取值 int nameCol headerMap.get(姓名); int ageCol headerMap.get(年龄);这样即使用户调整了列顺序只要表头名称不变代码就能正确取值。当然表头名称本身也要做校验缺少必要列时给出明确提示。5. 数据校验与批量入库从逐条insert到batch的性能跨越5.1 解析出来的数据先别急着入库Excel是用户手工填的什么情况都可能有空行、合并单元格、数字填成文本、日期格式五花八门、必填项为空、外键关联的记录不存在。如果解析一行就插一行插到一半报错前面已经插入的数据就变成了脏数据。我的做法是分三步走解析收集 → 统一校验 → 批量入库。解析阶段把每一行转成一个Java对象比如UserImportDTO同时记录行号方便报错时定位。校验阶段逐条检查必填项、格式、长度、业务规则把所有错误收集到一个列表里。如果错误列表不为空直接返回给前端一条都不入库。只有全部校验通过才执行批量插入。ListUserImportDTO dataList new ArrayList(); ListString errorList new ArrayList(); for (int i 1; i sheet.getLastRowNum(); i) { Row row sheet.getRow(i); if (row null) continue; UserImportDTO dto new UserImportDTO(); String name getCellValue(row, headerMap.get(姓名)); if (name null || name.trim().isEmpty()) { errorList.add(第 (i 1) 行姓名不能为空); continue; } dto.setName(name.trim()); // 其他字段解析... dataList.add(dto); } if (!errorList.isEmpty()) { // 返回错误列表不入库 return; }这种全有或全无的策略在批量导入场景下最安全。如果业务允许部分成功那就要在返回结果里明确标注哪些行成功、哪些行失败及原因。5.2 JDBC批处理addBatch和executeBatch的正确用法逐条executeUpdate()插入1000条数据可能要几秒钟因为每次都是一次网络往返。用JDBC的批处理可以把多条insert攒在一起发送性能提升非常明显。String sql INSERT INTO user_info (name, age, email, create_time) VALUES (?, ?, ?, ?); Connection conn null; PreparedStatement ps null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); ps conn.prepareStatement(sql); for (UserImportDTO dto : dataList) { ps.setString(1, dto.getName()); ps.setInt(2, dto.getAge()); ps.setString(3, dto.getEmail()); ps.setTimestamp(4, new Timestamp(System.currentTimeMillis())); ps.addBatch(); } ps.executeBatch(); conn.commit(); } catch (SQLException e) { if (conn ! null) conn.rollback(); throw e; } finally { // 关闭资源 }这里有几个关键点关闭自动提交setAutoCommit(false)所有插入在一个事务里要么全成功要么全回滚。批处理大小不要一次性addBatch几万条内存会撑不住。建议每500到1000条执行一次executeBatch()然后clearBatch()。异常回滚任何一条出错都要rollback()避免部分数据写入。分批处理的写法int batchSize 500; int count 0; for (UserImportDTO dto : dataList) { ps.setString(1, dto.getName()); // ... ps.addBatch(); if (count % batchSize 0) { ps.executeBatch(); ps.clearBatch(); } } ps.executeBatch(); // 处理剩余不足一批的5.3 数据库连接池别忘了配上面代码里的dataSource应该来自连接池比如Druid、HikariCP。直接在Servlet里DriverManager.getConnection()每次创建新连接在批量导入场景下性能极差而且连接数一多数据库就扛不住。以HikariCP为例典型配置HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/test_db?useUnicodetruecharacterEncodingutf8); config.setUsername(root); config.setPassword(password); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(30000); DataSource dataSource new HikariDataSource(config);连接池大小不是越大越好。批量导入是IO密集型操作连接数设置为CPU核心数的2到4倍比较合适。设置太大反而会因为上下文切换降低性能。6. 那些只有踩过才知道的坑编码、临时文件与事务边界6.1 中文文件名乱码的根因上传文件后part.getSubmittedFileName()拿到的文件名如果是中文可能显示成乱码。这是因为不同浏览器和容器对文件名的编码处理不一致。Tomcat 8以后默认用UTF-8解析Content-Disposition一般不会有问题。但如果遇到乱码可以手动转码String fileName part.getSubmittedFileName(); if (fileName ! null) { fileName new String(fileName.getBytes(ISO-8859-1), UTF-8); }不过这种转码方式在Tomcat 8上反而会把正常的中文转坏。更稳妥的判断方式是看乱码特征或者直接配置Tomcat的server.xml在Connector上加上URIEncodingUTF-8。实际上如果只是把Excel内容导入数据库文件名乱码影响不大因为你不依赖文件名做业务逻辑。但如果要把上传的文件保存到磁盘文件名就必须处理好否则会出现同名覆盖或者找不到文件的问题。6.2 临时文件清理的时机用Part接口时如果文件大小超过fileSizeThreshold容器会把文件写到临时目录。这个临时文件在请求结束后由容器清理但清理时机不确定。如果解析Excel时抛异常临时文件可能残留。更主动的做法是在finally块里调用part.delete()Part part null; try { part request.getPart(excelFile); // 解析处理 } finally { if (part ! null) { part.delete(); } }用Commons FileUpload时FileItem如果没被写到磁盘会在请求结束时清理如果调用了item.write(file)持久化了那就要自己管理那个文件的生命周期。6.3 事务边界不要跨Servlet有些人为了保险在Servlet的doPost一开始就开启数据库事务直到响应返回才提交。这样做的问题是事务持有时间过长数据库连接被长时间占用并发一高连接池就耗尽。正确的做法是把事务边界控制在Service层的方法内Servlet只负责接收请求、调用Service、返回结果。Service方法内部完成校验批量插入后立即提交事务释放连接。// Servlet层 protected void doPost(HttpServletRequest request, HttpServletResponse response) { Part part request.getPart(excelFile); InputStream in part.getInputStream(); ImportResult result importService.importExcel(in); // 返回结果 } // Service层 Transactional public ImportResult importExcel(InputStream in) { ListUserImportDTO dataList parseExcel(in); validate(dataList); batchInsert(dataList); return new ImportResult(dataList.size()); }这样事务范围小、连接占用时间短系统吞吐量会好很多。6.4 导入进度反馈大数据量时的体验优化如果Excel有上万行用户点上传后可能要等十几秒甚至更久页面一直转圈用户不知道是在处理还是卡死了。这时候可以考虑异步导入上传后立即返回一个任务ID后台线程处理导入前端轮询进度。实现方式可以用一个ConcurrentHashMap存任务状态或者用数据库任务表。前端每隔两秒请求一次进度接口显示已处理3000/10000行。这个优化不是必须的但对用户体验提升很大尤其是后台管理系统。7. 完整代码骨架与部署要点7.1 项目结构一个典型的实现会包含这些文件src/main/java/com/example/ servlet/UploadServlet.java service/ImportService.java dto/UserImportDTO.java util/ExcelUtil.java src/main/webapp/ upload.jsp result.jsp WEB-INF/web.xml如果用注解可省略 pom.xmlUploadServlet负责接收文件ExcelUtil负责解析ImportService负责校验和入库UserImportDTO是数据载体。职责分离方便测试和维护。7.2 web.xml配置要点如果用注解方式Servlet 3.0可以完全不用web.xml。但如果项目需要兼容老容器或者要配置multipart-configweb.xml里这样写servlet servlet-nameUploadServlet/servlet-name servlet-classcom.example.servlet.UploadServlet/servlet-class multipart-config max-file-size10485760/max-file-size max-request-size20971520/max-request-size file-size-threshold1048576/file-size-threshold /multipart-config /servlet servlet-mapping servlet-nameUploadServlet/servlet-name url-pattern/upload/url-pattern /servlet-mapping注意max-file-size的单位是字节10MB就是10485760。7.3 依赖版本匹配POI的版本要和poi-ooxml保持一致否则会出现NoSuchMethodError。另外POI 5.x需要Java 8如果项目还在Java 7只能用POI 3.x。commons-fileupload和commons-io的版本也要匹配一般用commons-fileupload 1.5配commons-io 2.11.0比较稳。7.4 部署后的验证清单功能上线前至少验证这几项上传正常.xls文件数据正确入库上传正常.xlsx文件数据正确入库上传空文件给出友好提示上传非Excel文件改后缀的被服务端拦截上传超过大小限制的文件给出明确提示Excel中有空行、格式错误的行错误提示能定位到具体行号批量插入1万条数据观察内存和耗时并发上传两个文件确认不会互相干扰这些场景覆盖了大部分线上问题提前测过能省很多排查时间。8. 个人实操体会几个让代码更稳的习惯做了这么多批量导入功能我最大的体会是解析和入库一定要解耦。解析阶段只负责把Excel变成Java对象列表入库阶段只负责把对象列表写进数据库。中间用校验层隔开。这样任何一段出问题都容易定位也方便单独写单元测试。另一个习惯是永远不要相信Excel的格式。用户可能把数字存成文本、把日期存成字符串、在数据中间插空行、表头写错别字。代码里对每个字段的取值都要有兜底逻辑取不到就给默认值或者记错误不能直接抛异常中断整个导入。还有一点日志要打够。导入失败时日志里要有文件名、行号、字段名、原始值这样排查问题不用去猜。我一般会在解析每一行时用logger.debug记录关键字段出错时用logger.error带上完整上下文。最后如果数据量真的很大十万行以上考虑用数据库的LOAD DATA或者批量插入存储过程JDBC批处理在极端场景下还是会有性能瓶颈。但大多数后台管理系统的导入需求几千到几万行用上面这套方案完全够用稳定性和性能都能兼顾。
阅读完成 · 觉得有帮助?
咨询建站