资讯动态

Java读取Word并入库:doc/docx分流与POI解析避坑

发布时间:2026/10/24 3:27:53 来源:尧图企业网站定制
简介围绕 Java 读取 Word 文档并将内容写入数据库这一常见需求这套工程示例面向具备一定 Java 基础、需要处理办公文档数据录入的后端开发者重点演示用 Apache POI 解析 .docx 文件和用 JDBC 入库的完整链路。压缩包共 41 个文件、约 13.24MB包含 11 个第三方 jar 依赖、9 个 java 源码与对应编译后的 class、2 个 xml 配置及 properties 属性文件并保留 Eclipse 工程结构与 WebContent 目录导入即可调试。代码涵盖用 XWPFDocument 打开文档、遍历表格行列提取单元格数据、日期与数字类型转换、用 PreparedStatement 批量插入及异常捕获等环节可作为文档解析与数据库批量写入的参考模板。已有 5138 人学习适合想打通从文件解析到持久化流程的开发者对照实践。1. 一份 30 页的合同附件Java 读取 Word 再上传数据库难点从来不在读某公司做供应商资料归档业务方丢过来的需求一句话就能说完——用 Java 读取 Word 文件把里面的段落、表格和图片上传到数据库。真正开工才发现附件是 doc 和 docx 混着来的报价明细全在合并单元格里签字页还贴了扫描图。技术门槛不高坑的密度却极高格式分流判错、样式 ID 玄学、表头错位、批量插入把堆内存打爆任何一个都能让导入任务在凌晨三点挂掉。这篇把我自己落地的这套拆解流程写出来适合要做文档导入、简历解析、合同归档的后端同学照着抄也适合已经在用 POI 但总在细节上翻车的人对一遍参数。2. 选型与依赖doc 和 docx 是两套二进制版本怎么锁.doc是 OLE2 复合文档格式正文、样式表、图片全塞在一个类似 FAT 文件系统的容器里正文数据在 WordDocument 这个 stream 中.docx是 OOXML本质是一个 zip 包正文在word/document.xml样式在word/styles.xml图片在word/media/目录下。两者的解析器完全不是一套东西Apache POI 里分别对应HWPFDocument和XWPFDocument。所以第一步不是着急写解析逻辑而是先老老实实判断手上这个流到底是哪一种——这一步判错后面所有代码都是白写。2.1 三条解析路线为什么最后都收敛到 POI做文档解析能选的方案其实不多我把常见的几种摆在一起对比过方案doc 支持docx 支持依赖体积我的判断Apache POIHWPF 完整支持XWPF 完整支持中等约 10MB 级解析场景首选API 偏底层但可控docx4j不支持支持基于 JAXB偏大更适合文档生成、PDF 转换纯解析属于杀鸡用牛刀各类 EasyWord 封装视封装而定支持小底层还是 POI多一层间接出问题难定位外部进程转 PDF/HTML 再解析间接支持间接支持需要部署额外组件排版还原好但引入进程依赖不适合服务端高并发导入docx4j 我用过一段时间它的强项是从模板生成带复杂排版的文档解析方向要拿到段落级样式和单元格合并信息反而要绕更多弯。至于那些叫 EasyWord、WordParser 的封装库翻开源码看一眼就知道底层还是XWPFDocument多一层包装只会在出错时多一层黑匣子。真正做导入落库直接用 POI 最省心。还有一个容易忽略的点POI 处理 doc 的 HWPF 模块从 4.x 开始被拆到了poi-scratchpad里很多人只引了poi和poi-ooxml一遇到 doc 就抛ClassNotFoundException: org.apache.poi.hwpf.HWPFDocument回头找半天。这个坑下面避坑章会再提一次。2.2 Maven 依赖组合与版本对齐!-- pom.xml 片段三个模块版本必须完全一致 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.5/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version exclusions !-- POI 5.x 用 log4j-api 做日志门面项目里是 logback 时必须排掉 -- exclusion groupIdorg.apache.logging.log4j/groupId artifactIdlog4j-api/artifactId /exclusion /exclusions /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-scratchpad/artifactId version5.2.5/version /dependency三行版本号必须是同一个值这是硬约束。POI 的poi、poi-ooxml、poi-scratchpad共享大量内部工具类版本错开半级就可能出现NoSuchMethodError或者NoClassDefFoundError报错栈还指向 XMLBeans 内部排查起来很费劲。如果项目里同时有别的库传递进来了poi-ooxml的旧版本用mvn dependency:tree -Dincludesorg.apache.poi查一遍冲突就exclusion掉。poi-ooxml会带进来xmlbeans、commons-compress、commons-io三件传递依赖一般不用手动声明除非项目里已经锁了更高版本的commons-io并且和 POI 要求的不兼容。日志那块要特别注意POI 5.x 打印日志走的是log4j-api如果你的项目用 logback不排掉就会出现「日志静默丢失」——POI 内部解析告警一条都看不到排查时完全瞎。稳妥的做法是排掉log4j-api再补一个桥接dependency groupIdorg.apache.logging.log4j/groupId artifactIdlog4j-to-slf4j/artifactId version2.20.0/version /dependency参数说明log4j-to-slf4j的版本要跟项目里 Spring Boot 管理的 log4j 版本对齐Spring Boot 3.x 一般已经统一管理可以不写版本号。这一步做完POI 内部关于「文件格式不标准」「样式缺失」的 WARN 才真的会输出到你的日志文件里否则你永远不知道解析器在抱怨什么。2.3 魔数校验与 doc/docx 分流实现扩展名判断是最不可靠的方式业务方把 doc 另存为 docx 再改个后缀的情况我遇到过不止一次。可靠做法是读文件头字节做魔数匹配再用 zip 目录二次确认public enum WordType { DOC, DOCX, UNKNOWN } public final class WordTypeDetector { // docx 是 zip 包头四字节固定 50 4B 03 04 private static final byte[] ZIP_MAGIC {0x50, 0x4B, 0x03, 0x04}; // doc 是 OLE2 复合文档头八字节固定 D0 CF 11 E0 A1 B1 1A E1 private static final byte[] OLE2_MAGIC {(byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, (byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1}; public static WordType detect(InputStream raw) throws IOException { // 用 BufferedInputStream 开 mark读完头还要 reset 回去给解析器用 BufferedInputStream in raw instanceof BufferedInputStream ? (BufferedInputStream) raw : new BufferedInputStream(raw, 8192); in.mark(16); byte[] head new byte[8]; int read in.read(head); in.reset(); if (read 8 match(head, OLE2_MAGIC)) { return WordType.DOC; } if (read 4 match(head, ZIP_MAGIC)) { // zip 魔数可能是 xlsx、pptx、jar必须再确认 word/document.xml 存在 return hasWordDocumentXml(in) ? WordType.DOCX : WordType.UNKNOWN; } return WordType.UNKNOWN; } private static boolean hasWordDocumentXml(InputStream in) throws IOException { in.mark(1024 * 1024); // 目录项在 zip 尾部但 word/document.xml 条目头通常靠前 try (ZipInputStream zis new ZipInputStream(in)) { ZipEntry entry; while ((entry zis.getNextEntry()) ! null) { if (word/document.xml.equals(entry.getName())) { return true; } } } finally { in.reset(); } return false; } private static boolean match(byte[] src, byte[] magic) { for (int i 0; i magic.length; i) { if (src[i] ! magic[i]) return false; } return true; } }逻辑说明mark(16)只标记了 16 字节但hasWordDocumentXml里要把流交给ZipInputStream读目录所以真实实现里 mark 的 readlimit 要给够或者干脆先把整个流落到临时文件再打开两次。我一般图省事上传后先落盘成临时文件检测和解析各开一次FileInputStream内存占用最可控。参数说明mark的 readlimit 是「最多回退多少字节」设小了会抛IOException: Resetting to invalid mark这个异常信息很误导人容易以为是文件坏了。OLE2 那条分支不用二次确认A1 B1 1A E1 这个尾缀撞车的概率极低。分流写完工厂方法就很直白了public static WordContent parse(File file) throws IOException { WordType type WordTypeDetector.detect(new FileInputStream(file)); try (InputStream in new FileInputStream(file)) { if (type WordType.DOCX) { return XwpfParser.parse(in); // 内部 new XWPFDocument(in) } if (type WordType.DOC) { return HwpfParser.parse(in); // 内部 new HWPFDocument(in) } throw new IllegalArgumentException(不支持的文件类型: file.getName()); } }UNKNOWN抛业务异常别默默返回空列表否则上游会以为「文件解析成功但没有内容」问题被吞掉。3. 拆解三段数据段落、表格、图片的解析与四张表落库Word 里能落库的内容无非三类段落文本、表格单元格、内嵌图片。三类数据的解析路径和存储模型都不一样我一般拆成三个 Reader各自返回一个 DTO 列表最后由 Service 统一在一个事务里写四张表。这样做的好处是每类数据的坑互相隔离段落解析出问题不影响表格入库也方便单测。3.1 段落与样式XWPFParagraph 的取值顺序public ListParagraphData read(XWPFDocument doc) { ListParagraphData result new ArrayList(); ListXWPFParagraph paragraphs doc.getParagraphs(); for (int i 0; i paragraphs.size(); i) { XWPFParagraph p paragraphs.get(i); // getText() 会漏掉部分超链接、域代码、文本框内容用 run 逐个拼更稳 StringBuilder sb new StringBuilder(); for (XWPFRun run : p.getRuns()) { String t run.text(); if (t ! null !t.isEmpty()) { sb.append(t); } } ParagraphData data new ParagraphData(); data.setParaIndex(i); data.setText(sb.toString()); data.setStyleId(p.getStyle()); // 这是 styleId不是显示名 data.setHeading(resolveHeading(doc, p)); result.add(data); } return result; } private boolean resolveHeading(XWPFDocument doc, XWPFParagraph p) { XWPFStyles styles doc.getStyles(); String styleId p.getStyle(); if (styles ! null styleId ! null) { XWPFStyle style styles.getStyle(styleId); if (style ! null style.getName() ! null) { String name style.getName().toLowerCase(); // 英文模板叫 heading 1中文模板叫「标题 1」 return name.contains(heading) || name.contains(标题); } } // 兜底看大纲级别0 表示一级标题 return p.getCTP().isSetPPr() p.getCTP().getPPr().isSetOutlineLvl() p.getCTP().getPPr().getOutlineLvl().getVal().intValue() 0; }逻辑说明getText()在多数情况下够用但它会跳过XWPFHyperlinkRun里的文本、也拿不到文本框内容拼 run 是最省事的补丁。样式这块是最玄学的地方p.getStyle()返回的是 styleId可能是1、Heading1、a3取决于文档模板是谁用什么工具生成的直接拿去和Heading1做字符串比较换个模板就失效。稳妥做法是拿 styleId 去doc.getStyles().getStyle()里查出显示名再判断或者直接用大纲级别。参数说明getRuns()返回的是直接挂在段落下的 run嵌套在XWPFSDT结构化文档标签比如目录、内容控件里的文本不在其中。如果业务方要求把自动生成的目录也入库那就得额外遍历doc.getBodyElements()判断元素类型成本会翻倍一般我在需求阶段就把「目录不入库」写清楚。3.2 表格与合并单元格gridSpan 和 vMerge 怎么处理表格是翻车重灾区。Word 的合并单元格在 OOXML 里用两个属性表达w:gridSpan表示横向跨几列w:vMerge表示纵向合并起始行是restart后续行是continue。XWPFTableRow.getTableCells()会把合并后的单元格按「逻辑单元格」返回但有些模板里 POI 会把被合并掉的占位单元格也返回导致列数对不上。public ListCellData read(XWPFDocument doc) { ListCellData result new ArrayList(); ListXWPFTable tables doc.getTables(); for (int t 0; t tables.size(); t) { ListXWPFTableRow rows tables.get(t).getRows(); for (int r 0; r rows.size(); r) { ListXWPFTableCell cells rows.get(r).getTableCells(); int colIndex 0; for (XWPFTableCell cell : cells) { CTTcPr pr cell.getCTTc().getTcPr(); int gridSpan 1; if (pr ! null pr.isSetGridSpan() pr.getGridSpan().getVal() ! null) { gridSpan pr.getGridSpan().getVal().intValue(); } boolean vMergeContinue pr ! null pr.isSetVMerge() continue.equalsIgnoreCase(String.valueOf(pr.getVMerge().getVal())); CellData data new CellData(); data.setTableIndex(t); data.setRowIndex(r); data.setColIndex(colIndex); data.setGridSpan(gridSpan); data.setVMergeContinue(vMergeContinue); data.setText(cell.getText() null ? : cell.getText().trim()); result.add(data); colIndex gridSpan; // 列号按 gridSpan 推进不是简单 1 } } } return result; }逻辑说明colIndex gridSpan这一行是关键。如果按cells的下标当列号遇到横向合并就会整体错位后面的列全部对不上表头。纵向合并的continue单元格内容通常是空的落库时可以标记出来前端渲染时向上查找最近一个restart的值做填充显示别在解析层就把空值补上那样会丢失原始结构。参数说明pr.getGridSpan().getVal()返回的是BigInteger别直接和 int 比较。getVMerge().getVal()在 POI 5.x 里是枚举类型用String.valueOf转成字符串比较最兼容不同小版本。表格里如果还嵌了表格cell.getTables()这一层递归我没展开一般业务数据两层足够再深就该考虑让业务方拆文件了。3.3 图片抽取getAllPictures 与精确定位public ListImageData read(XWPFDocument doc) { ListImageData result new ArrayList(); ListXWPFPictureData pictures doc.getAllPictures(); for (int i 0; i pictures.size(); i) { XWPFPictureData pic pictures.get(i); ImageData data new ImageData(); data.setImageIndex(i); // 只存扩展名和字节实际文件路径由存储层决定 data.setExtension(pic.suggestFileExtension()); // png / jpeg / emf data.setBytes(pic.getData()); data.setFileName(pic.getFileName()); // 形如 image1.png result.add(data); } return result; }getAllPictures()拿到的是文档里所有内嵌图片的去重集合包括页眉页脚里的。如果业务需要知道「这张图在第几段」得反过来遍历run.getEmbeddedPictures()通过pic.getPictureData()和全局列表比对。多数归档场景只要图存在且能下载回来就够不必定位到段落我一般就用全局方案。这里有个体积陷阱图片字节直接塞进数据库的LONGBLOB会让单行数据变得巨大max_allowed_packet默认 4MB一张手机拍的照片就超了。常见做法是图片落对象存储或者本地目录库里只存路径和 md5这条我在第 4 章的流水线里会具体说。3.4 建表四张表的最小结构-- 主表一份文件一行用 md5 做幂等 CREATE TABLE word_doc ( id BIGINT NOT NULL AUTO_INCREMENT, file_name VARCHAR(255) NOT NULL, file_md5 CHAR(32) NOT NULL, doc_type VARCHAR(8) NOT NULL COMMENT DOC / DOCX, file_size BIGINT NOT NULL, paragraph_cnt INT NOT NULL DEFAULT 0, table_cnt INT NOT NULL DEFAULT 0, image_cnt INT NOT NULL DEFAULT 0, upload_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_md5 (file_md5) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci; -- 段落表doc_id para_index 做联合唯一 CREATE TABLE word_paragraph ( id BIGINT NOT NULL AUTO_INCREMENT, doc_id BIGINT NOT NULL, para_index INT NOT NULL, style_id VARCHAR(64) DEFAULT NULL, is_heading TINYINT NOT NULL DEFAULT 0, content TEXT, PRIMARY KEY (id), UNIQUE KEY uk_doc_para (doc_id, para_index) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci; -- 单元格表列号存 gridSpan 推进后的逻辑列号 CREATE TABLE word_table_cell ( id BIGINT NOT NULL AUTO_INCREMENT, doc_id BIGINT NOT NULL, table_index INT NOT NULL, row_index INT NOT NULL, col_index INT NOT NULL, grid_span INT NOT NULL DEFAULT 1, v_merge TINYINT NOT NULL DEFAULT 0, content TEXT, PRIMARY KEY (id), KEY idx_doc_table (doc_id, table_index) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci;参数说明file_md5上的唯一索引是幂等的基础同一份文件重复上传时直接走INSERT ... ON DUPLICATE KEY UPDATE或者先查后跳避免业务方手抖点两次产生两套数据。content用TEXT最长 64KB而不VARCHAR(65535)是因为 utf8mb4 下VARCHAR的实际行长度会翻四倍容易撞 MySQL 的行大小限制。图片表我没展开写结构就四列doc_id、image_index、storage_path、image_md5。4. 上传链路实现从 MultipartFile 到批量入库的事务边界解析层写完剩下的工作是把它接进 HTTP 链路。这一段的重点不在功能在边界文件多大、事务开多久、批多大、失败了怎么回滚。这三件事没定好导入功能在测试环境跑得飞起一上生产就出问题。4.1 上传入口的参数与白名单# application.yml spring: servlet: multipart: max-file-size: 50MB # 单文件上限按业务最大附件定 max-request-size: 60MB # 整个请求上限多文件时要比 max-file-size 大 file-size-threshold: 2MB # 超过 2MB 落临时目录不占堆内存 location: /data/tmp/upload # 临时目录必须存在且可写 server: tomcat: max-swallow-size: 60MB # 防止超限文件被 Tomcat 直接断连导致前端拿不到报错参数说明file-size-threshold是最容易被忽略的一项。默认值很小看似安全但当它被设成很大比如有人为了「提速」改成 100MB每个请求的MultipartFile会完整驻留堆内存二十个并发就能把 2G 堆打满。2MB 这个值是我常用的经验线超过就落盘POI 反正也是从流里读落盘反而更快。白名单校验放在 Controller 第一行别指望前端private static final SetString ALLOWED_EXT Set.of(doc, docx); PostMapping(/upload) public ResultLong upload(RequestParam(file) MultipartFile file) throws IOException { String name file.getOriginalFilename(); if (name null || name.isBlank()) { return Result.fail(文件名为空); } String ext name.substring(name.lastIndexOf(.) 1).toLowerCase(); if (!ALLOWED_EXT.contains(ext)) { return Result.fail(只接受 doc / docx); } // 真正的类型判断在 Service 里做魔数校验这里只做粗筛 return Result.ok(importService.importWord(file)); }注意扩展名白名单只是第一道粗筛不能替代魔数校验。用户把.exe改名成.docx传上来扩展名校验会放行只有魔数判断才拦得住。4.2 解析到落库的流水线事务边界要卡在解析之后我见过不少实现是把Transactional直接标在整个导入方法上包括解析。这么做的问题很直接一份 20MB 的 docx 解析要 38 秒这段时间数据库连接一直被这个事务占着连接池稍微小一点就全被堵死表现是「导入慢的时候其他接口全部超时」。正确的切法是解析和落库分成两段解析不进事务。public Long importWord(MultipartFile file) throws IOException { // 第一段落临时文件 类型检测 解析全程不碰数据库 File tmp File.createTempFile(word-import-, .tmp); try { file.transferTo(tmp); WordType type WordTypeDetector.detect(new FileInputStream(tmp)); WordContent content WordParserFactory.parse(tmp, type); // 第二段落库短事务 return saveService.save(content, file.getOriginalFilename(), tmp.length()); } finally { // 临时文件必须删/tmp 被灌满的后果比 OOM 还难查 Files.deleteIfExists(tmp.toPath()); } }落库那一段单独开事务Transactional(rollbackFor Exception.class) public Long save(WordContent content, String fileName, long size) { WordDoc doc new WordDoc(); doc.setFileName(fileName); doc.setFileMd5(content.getMd5()); doc.setDocType(content.getType().name()); doc.setFileSize(size); doc.setParagraphCnt(content.getParagraphs().size()); doc.setTableCnt(content.getTables()); doc.setImageCnt(content.getImages().size()); // 幂等md5 已存在则直接返回旧 id不重复写 Long existId docMapper.selectIdByMd5(content.getMd5()); if (existId ! null) { return existId; } docMapper.insert(doc); paragraphMapper.batchInsert(doc.getId(), content.getParagraphs()); cellMapper.batchInsert(doc.getId(), content.getCells()); imageMapper.batchInsert(doc.getId(), content.getImages()); return doc.getId(); }逻辑说明把Transactional放在save这个独立 Bean 方法上而不是importWord上是为了让事务只覆盖四条 INSERT 语句。这里有个 Spring 的经典坑如果save和importWord写在同一个类里this.save(...)这种自调用不会走代理事务注解直接失效而且不报错。要么拆成两个 Bean要么注入自身代理我一般直接拆 Service。参数说明rollbackFor Exception.class必须写。Spring 默认只在RuntimeException和Error上回滚解析层抛的是受检的IOException不写这一行异常发生后主表已经插进去了从表没插留下一条脏数据。4.3 批量插入的批大小与失败重试private static final String INSERT_PARA INSERT INTO word_paragraph(doc_id, para_index, style_id, is_heading, content) VALUES (?, ?, ?, ?, ?); private static final int BATCH_SIZE 500; public void batchInsert(Long docId, ListParagraphData list) { for (int from 0; from list.size(); from BATCH_SIZE) { int to Math.min(from BATCH_SIZE, list.size()); ListParagraphData slice list.subList(from, to); jdbcTemplate.batchUpdate(INSERT_PARA, new BatchPreparedStatementSetter() { Override public void setValues(PreparedStatement ps, int i) throws SQLException { ParagraphData p slice.get(i); ps.setLong(1, docId); ps.setInt(2, p.getParaIndex()); ps.setString(3, p.getStyleId()); ps.setInt(4, p.isHeading() ? 1 : 0); ps.setString(5, p.getText()); } Override public int getBatchSize() { return slice.size(); } }); } }逻辑说明subList切片的目的是控制单次网络往返的语句数。500 这个值是我在 MySQL 上反复试出来的一条经验线再往上单据包体积变大max_allowed_packet容易告警往下调到 100 又会让 3 万段落多出两百多次往返整体耗时翻倍。参数说明JDBC URL 上必须加rewriteBatchedStatementstrue否则 MySQL 驱动会一条一条发batchUpdate形同虚设性能差十倍以上。这个参数是血泪经验很多人只写了batchUpdate就以为完事回头压测发现入库比解析还慢。jdbc:mysql://127.0.0.1:3306/docdb?useUnicodetruecharacterEncodingutf8mb4rewriteBatchedStatementstrueallowPublicKeyRetrievaltrueuseSSLfalse另外characterEncodingutf8mb4这一项别省省了以后 emoji 和部分生僻字会变成问号且不报错属于最难发现的一类问题。5. 避坑排查五个 Word 解析入库的翻车现场5.1 解析层踩过的三个坑坑一改名文件导致OfficeXmlFileException现象某些附件解析时抛org.apache.poi.openxml4j.exceptions.NotOfficeXmlFileException: The supplied data appears to be in the OLE2 Format栈里指向XWPFDocument构造器。原因文件其实是 docOLE2但扩展名被改成了.docx代码按扩展名走了 XWPF 分支。这类文件多半是业务方用「重命名」的方式手动改的或者从某些老系统导出的。解决在进入解析器之前用第 2 章那套魔数检测做分流扩展名只用于展示和粗筛。检测结果和扩展名不一致时按检测结果解析同时打一条 WARN 日志记录原始文件名方便后续找业务方对账。坑二getText()拿到空串文档里明明有字现象段落表入库后大量content为空但用 Word 打开能看到文字。原因文字在超链接里XWPFHyperlinkRun、在文本框里w:txbxContent、在域代码结果里XWPFParagraph.getText()对这几种情况覆盖不全。还有一种情况是文档用了「内容控件」w:sdt整个内容不在doc.getParagraphs()里。解决文本用遍历 run 拼接对内容控件doc.getBodyElements()会返回XWPFSDT单独取sdt.getContent().getText()。我在解析完成后加了一条校验如果段落总数 0 但文本总长度为 0直接把这次导入标记为「疑似解析失败」进人工队列不让脏数据静默入库。坑三表格列数对不上表头和数据错位现象报价单表头 5 列入库后某些行的第 3 列存的是第 4 列的值。原因表格里有横向合并单元格代码用List下标当列号gridSpan2的单元格只占了 1 个下标位置后面整体左移。解决按gridSpan累加推进列号见 3.2 的代码。另外纵向合并的continue单元格内容为空落库时保留空值并标记v_merge1由展示层向上找restart行填充不要在解析层做填充否则原始结构信息就丢了。5.2 内存与入库踩过的两个坑坑四大文件导入时堆内存被逐步占满最后 OOM现象导入 30MB 的 docx 时服务 OOMdump出来发现byte[]占了绝大部分但代码里没有明显的集合膨胀。原因无外乎两处。一处是file.getBytes()把文件整个读进堆另一处是new XWPFDocument(inputStream)之后没有closePOI 底层的OpcPackage持有大量PackagePart图片多的文档尤其明显。还有人用try-with-resources包了InputStream却没包XWPFDocument流的关闭不等于文档关闭。解决传递InputStream或File给构造器不要getBytesXWPFDocument一律用 try-with-resources 包住给 JVM 加-Dorg.apache.poi.util.POIXMLTypeLoader...之外的临时目录配置把poi.tmpdir指到磁盘空间大的分区。这一项对于图片多的文档效果立竿见影。try (InputStream in new FileInputStream(tmp); XWPFDocument doc new XWPFDocument(in)) { // 解析逻辑放这里出 try 块自动 close }坑五中文入库变问号或者内容被截断现象段落文本入库后变成???或者长段落只剩前一段。原因一是 JDBC URL 没指定characterEncoding二是表字段的长度定义不够。VARCHAR(255)在 utf8mb4 下能存 255 个字符但VARCHAR(20000)会直接建表失败行大小限制于是有人图省事改成VARCHAR(2000)长段落被 MySQL 在非严格模式下静默截断。解决连接串统一characterEncodingutf8mb4库和表统一utf8mb4_general_ci长文本字段用TEXT或MEDIUMTEXT别用大号VARCHAR硬撑。另外把 MySQL 的sql_mode保持严格模式宁可插入报错也不要静默截断。6. 入库校验与进阶字符数守恒这条后悔药导入类功能最难受的一点是「看起来成功了」。日志没有异常、接口返回 200、库里也有数据但业务方一比对发现少了半页表格。这种问题靠人工抽查根本发现不了尤其当每天有几百份文件进来的时候。我后来固定加了一道「字符数守恒」校验成本很低效果非常好。思路很简单解析阶段统计源文件里的可读字符总数段落文本长度 所有单元格文本长度落库完成后再从库里按doc_id统计一遍两个数做比对。允许一定误差因为空白符和段落分隔符的处理方式两边可能不一致但差值超过 2% 就该报警。public void verify(Long docId, int sourceCharCount) { Integer stored jdbcTemplate.queryForObject( SELECT COALESCE(SUM(CHAR_LENGTH(content)), 0) FROM ( SELECT content FROM word_paragraph WHERE doc_id ? UNION ALL SELECT content FROM word_table_cell WHERE doc_id ? ) t, Integer.class, docId, docId); int actual stored null ? 0 : stored; // 差值超过 2%标记为待人工复核 if (sourceCharCount 0 Math.abs(actual - sourceCharCount) sourceCharCount * 0.02) { log.warn(字符数校验未通过 docId{} 源{} 库{}, docId, sourceCharCount, actual); docMapper.markSuspect(docId); // 加一列 status0 正常 1 待复核 } }参数说明用CHAR_LENGTH而不是LENGTH前者按字符数算后者按字节数算中文下一个字符三到四个字节用LENGTH会把误差放大到离谱。UNION ALL而不是UNION避免去重影响计数。2% 这个阈值不是拍脑袋是我统计了上百份文件后定的正常文件误差在 0.3% 以内超过 2% 的基本都是表格合并单元格处理出错或者文本框内容漏读。还有一层交叉校验可以加上代价稍高但很值对同一份文件用XWPFWordExtractor再抽一遍纯文本和自建解析器统计出来的字符数比。两个解析路径的实现不同同时出错的概率很低一旦两者差异明显基本可以确定是我们的代码有问题而不是文件有问题。表格这块也可以做结构化校验比字符数更严格解析出的表格行数和 Word 里doc.getTables().get(i).getRows().size()应该一一对应入库后按table_index分组计数对不上就说明批量插入漏了。我曾经因为一次批处理分片边界算错静默丢了每张表的最后一行就是这条校验抓出来的。提示校验逻辑放在落库之后、返回之前失败不要抛异常回滚数据而是打标记进人工队列。数据先留着人能看能修直接回滚的话业务方只会看到「导入失败」还是不知道为什么失败。进阶方向有两个。一个是把解析结果缓存成中间 JSON段落、表格、图片各一个数组这样重新入库时不需要再解析一遍 Word改数据结构或者补字段时可以离线重放。另一个是把图片的md5也放进主表做去重同一份合同的不同版本往往共用同一批签名图去重后存储能省不少空间。从那以后我每次做文档导入都会先把「字符数守恒」这条校验和临时文件清理写进第一版代码里而不是等出了问题再补。这类功能的上线标准从来不是「能跑通」而是「跑不通的时候我能第一时间知道」。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑