
简介在日常Java开发中经常需要比对两份表格类数据文件可能是Excel工作簿也可能是CSV文本。核心思路通常是指定若干关键列作为键值来进行行匹配先找到两个文件中的对应行再比较指定列的值是否变化从而发现修改、新增或缺失的数据。这份资源正是围绕该需求整理的示例代码完整演示了如何使用Apache POI读取Excel、OpenCSV解析CSV、以键值组合构造哈希表存储行数据以及在遍历过程中输出各类差异信息的处理过程思路清晰适合有基础Java语法、需要批量核对表格数据的开发者参考。压缩包共24个文件包含9个Java源码、9个编译后的class文件、3个依赖jar包及工程配置文件整体仅666KB下载后可直接导入IDE查看或按需修改。已有2541人学习下载。资源小巧但结构完整既提供了演示主类调用骨架也附带了lib目录下的依赖库省去手动查找外部包的麻烦对于学习POI/OpenCSV基础用法或实现轻量级表格比对工具都具有不错的实用价值。1. 键值比对让两张表的差异核对从人工变成代码现实中经常遇到这类场景某个系统导出一份 Excel 报价单另一个系统导出一份 CSV 清单业务要核对同一批数据在两边有哪些值不一样或者上个月的全量数据文件跟这个月的做差异检查。逐行翻表是纯体力活行一多就眼花行顺序稍微变一变就很容易看错。这个标题要解决的就是把某几列拼成唯一键把两边的行按键对齐再只对指定列做值比较输出哪些行是新增、哪些是删除、哪些值有变化。适合天天和数据文件打交道的开发、运维和数据专员核心收益是让核对变成一条命令能跑完的事。2. 先定键再选结构比对方案的四项设计决策2.1 为什么选键值而不是逐行比对先说透一个道理两张表行顺序一致的情况在真实业务里非常少见。某个系统导出的清单行序可能按创建时间排另一边导出可能按部门分组如果中间还经过一次 Excel 手工调整行序就更不可信了。逐行比对在这种场景下完全不靠谱第一行和第一行根本不是同一个业务对象。所以必须找一个不依赖行序的对齐方式这就是键值比对的核心动机把业务上的唯一标识当成行的地址再按地址把两边的行拉齐。这个思路跟数据库里的 FULL OUTER JOIN 几乎一模一样表 A 按主键、表 B 按主键一次关联就能区分出三种情况只在 A 里、只在 B 里、两边都有但某些字段不一致。只不过这里我们不想为一次性检查建两张表直接在内存里用 Map 手工实现。选键有三条原则建议写代码前先确认。第一是唯一性键如果在表里重复后读进来的行会覆盖先读进来的行部分数据被静默丢弃。第二是稳定性两边的拼接规则必须完全一致比如键是单号加行号那就两边都拼这两列不能一边拼三列一边拼两列。第三是注意空值键列里某格为空时拼接出来的键可能变成空字符串多个空行会撞到同一个键上。这三条如果业务上满足不了正确做法不是硬写代码而是先去跟上游确认数据含义。键值比对该不该做、能不能做答案往往不在代码里而在对业务主键的理解有多深。我在模拟项目X里接过一个需求两侧导出的订单编号一边有前缀一遍没前缀花了半小时想清楚键的归一化问题比直接开写快得多。2.2 数据结构选型String数组还是列名Map读进来的每一行怎么存我一般看列数多少、列顺序是否固定来决定。最稳妥的起步方案是String[]存单元格值外加一个ListString存表头。列顺序两边一致时直接按下标取单元格读写都最直观读取器不需要引入复杂模型比对器只需要把列下标传来传去。缺点是列一多下标含义要靠表头来回查代码可读性下降。另一种方案是每行存成MapString, String列名做 key。优点是列顺序不一致甚至列数不同都不怕比对的列名直接当作键来取缺点是每个单元格都要构造一个 Map.Entry内存开销比数组大不少几万行时尚可接受几十万行就明显肉疼。我在一次数据核对中对比过两种写法同等数据量下后者的内存占用大概是前者的三倍。判断项String[] List表头MapString, String内存占用低高约 3 倍列顺序不一致需要列名转下标天然支持读取代码量少稍多比对代码按下标直观按列名语义清晰适用行数五万行以上更友好几万行内很舒服我的个人倾向是列数在 20 列以内、两边列顺序一致直接用数组列顺序乱、列数十个以上宁可用 Map。如果你只打算写一个一次性的核对脚本数组方案够用且快改动成本也最低。2.3 键值拼接策略分隔符、空值和重复键键值的生成是整个程序的地基地基错了后面全盘错。单列键简单直接取该列单元格值多列键就需要一个拼接方法。常见做法是用分隔符把多列值串起来例如orderNo | lineNo。这个分隔符有个隐蔽的坑如果业务数据本身含有这个字符两个不同组合可能拼出同一个键。比如订单号AB|C和行号1拼出来是AB|C|1订单号AB和行号C|1拼出来也是AB|C|1。规避办法有两种一是选一个数据里几乎不可能出现的字符比如\u0001二是拼完后再加一个长度前缀比如3:AB|1:C|1这种写法能彻底杜绝碰撞。还有人图省事用Objects.hash(code, lineNo)生成整数键内存省一点运行也快。但哈希碰撞虽然概率低撞上一次足够你怀疑人生。我的建议是能用字符串拼键就别用哈希除非你明确知道两个键列允许存在重复且想用哈希把重复行聚到一起做统计那就另说。2.4 保持输出顺序稳定的理由构建映射表时用HashMap还是LinkedHashMap会影响差异报告里的行顺序。HashMap的遍历顺序由哈希桶决定看着像随机顺序LinkedHashMap按插入顺序遍历先读到的行先出现在报告里。这个细节平时不起眼真到了排查的时候差别很大。差异报告输出顺序如果和主文件行顺序一一对应你就能顺着原始行一路查下来如果是乱序查一行要找半天。所以我在所有构建映射的地方统一用LinkedHashMap代价几乎为零收益是报告可直接和原文件对照。还有一个容易被忽略的决策主文件选哪一边。我的做法是把增量基准作为主表也就是旧表新表里多出来的行算新增旧表里消失的行算删除。如果业务定义反过来只需要在调用时调换两个参数输出报告里新增删除的语义就互换了程序内部不必为这个逻辑做分支。提示如果你用 Java 9 以上的Map.of()构造小常量集合要注意它们是不可变且不允许 null 键值。在键值拼接的单元测试里这个点很容易被忽略。3. 最小可运行实现读取、对齐、比对三步走3.1 统一读取入口CSV 和 Excel 各写一个方法先明确一个前提我不会用同一个库去解析 CSV 和 Excel它们的格式完全不同。常见做法是各写一个读取方法返回统一的数据结构MapString, String[]调用方只关心我拿到了键值到整行单元格的映射不关心背后是哪种文件。CSV 我一般直接手写解析比引入 OpenCSV 更轻也更容易控制细节。读取时有三个注意点一是编码必须在打开流时指定二是每行调用split(,, -1)必须带第二个参数 -1三是表头行要单独跳过。第三个问题虽然看起来简单但表头行也可能是空行只判断行号 0 就会漏掉真正的内容行。public MapString, String[] readCsv(Path file, Charset charset, ListInteger keyCols) throws IOException { MapString, String[] data new LinkedHashMap(); try (BufferedReader reader Files.newBufferedReader(file, charset)) { String line; boolean hasHeader false; while ((line reader.readLine()) ! null) { if (line.isBlank()) { continue; } if (!hasHeader) { hasHeader true; continue; } String[] cells line.split(,, -1); data.put(buildKey(cells, keyCols), cells); } } return data; }这段代码里hasHeader用布尔值跳过第一行空行直接跳过split(,, -1)保留末尾空字段LinkedHashMap保证输出顺序。如果遇到中文乱码把charset换成Charset.forName(GBK)大多能解决不过编码问题不是这一句就能完全说清的后面避坑章会专门展开。Excel 的读取我使用 Apache POI 的WorkbookFactory.create方法它自动识别xls和xlsx省去自己判断扩展名。POI 读取有几个特点空单元格row.getCell(i)返回 null数字单元格默认按 double 返回日期单元格在 POI 底层也是数字。所以读取时必须做单元格类型转换不能直接toString。public MapString, String[] readExcel(Path file, ListInteger keyCols) throws IOException { MapString, String[] data new LinkedHashMap(); try (Workbook workbook WorkbookFactory.create(file.toFile())) { Sheet sheet workbook.getSheetAt(0); boolean hasHeader false; for (Row row : sheet) { if (!hasHeader) { hasHeader true; continue; } int lastCell row.getLastCellNum(); String[] cells new String[lastCell]; for (int i 0; i lastCell; i) { cells[i] getCellValueAsString(row.getCell(i)); } data.put(buildKey(cells, keyCols), cells); } } return data; }需要留意row.getLastCellNum()它返回的是最后一个有内容的列序号加一不是列数。某些行如果右半边完全为空这个值比表头列数小得到的String[]长度就不齐后面按下标比对会数组越界。我的处理是在读取表头后先记录表头列数所有数据行统一按表头列数创建数组长度永远一致。上面这段代码为了保持简短没有写出这个细节实际使用记得改掉。3.2 单元格值转字符串数字、日期与空值的统一口径POI 的Cell.getCellType()返回值类型有字符串、数字、布尔、公式、空等多种一一转成字符串并不复杂麻烦的是格式细节。下面这个方法就是统一口径的关键private static String getCellValueAsString(Cell cell) { if (cell null) { return ; } switch (cell.getCellType()) { case STRING: return cell.getStringCellValue().trim(); case NUMERIC: if (DateUtil.isCellDateFormatted(cell)) { return new SimpleDateFormat(yyyy-MM-dd).format(cell.getDateCellValue()); } double num cell.getNumericCellValue(); if (num Math.floor(num) !Double.isInfinite(num)) { return String.valueOf((long) num); } return String.valueOf(num); case BOOLEAN: return String.valueOf(cell.getBooleanCellValue()); case FORMULA: CellType cachedType cell.getCachedFormulaResultType(); if (cachedType CellType.NUMERIC) { return String.valueOf(cell.getNumericCellValue()); } if (cachedType CellType.STRING) { return cell.getStringCellValue(); } return ; default: return ; } }这个方法解决了两个经典翻车现场一是数值单元格存的是 100POI 却返回 100.0直接转字符串会让两边对不上二是日期单元格不处理就是一串 45000 左右的浮点数。DateUtil.isCellDateFormatted判断日期格式后再格式化比对的日期值就统一了。注意格式化粒度是天级别如果两边都是带时间戳的单元格只差几分钟都会判为不同此时请把yyyy-MM-dd换成yyyy-MM-dd HH:mm:ss。公式分支我直接读取缓存结果类型避免拿到VLOOKUP(...)这种公式文本和另一边的300去比对。POI 解析文件时默认会缓存公式结果但这里要注意如果文件里的公式依赖外部工作簿或 POI 无法计算缓存结果可能拿不到走到默认分支返回空串比对的准确性就会下降。对一次性核对来说遇到这类文件建议先在上游把公式转成静态值再导出。数字格式化的口径也要统一考虑。Excel 单元格显示为100.00时getNumericCellValue()返回的仍是 double我上面的写法会把它转成100如果 CSV 那边存的是100.00两边数值一样但字符串对不上。此时不要在读取代码里到处打补丁而是在normalize方法里集中做数字格式归一化比如用BigDecimal比较数值大小或者统一保留两位小数。3.3 比对逻辑新增、删除、修改三路遍历读完文件、构建好两边映射之后剩下的就是遍历。我习惯分成三路先遍历旧表键在新表中不存在就是删除键存在就比较指定列的值有变化就是修改最后遍历新表键在旧表中不存在就是新增。public CompareReport compare(MapString, String[] oldData, MapString, String[] newData, ListInteger valueCols) { CompareReport report new CompareReport(); for (Map.EntryString, String[] entry : oldData.entrySet()) { String key entry.getKey(); String[] oldRow entry.getValue(); String[] newRow newData.get(key); if (newRow null) { report.addDeleted(key, oldRow); continue; } ListString diffs new ArrayList(); for (int col : valueCols) { if (!Objects.equals(normalize(oldRow[col]), normalize(newRow[col]))) { diffs.add(col : normalize(oldRow[col]) - normalize(newRow[col])); } } if (!diffs.isEmpty()) { report.addModified(key, oldRow, newRow, diffs); } } for (Map.EntryString, String[] entry : newData.entrySet()) { if (!oldData.containsKey(entry.getKey())) { report.addAdded(entry.getKey(), entry.getValue()); } } return report; } private static String normalize(String value) { if (value null) { return ; } return value.trim(); }两个参数值得在调用前想清楚valueCols是要比对的列下标列表比如[3, 4, 5]如果希望所有非键列都比可以在 main 方法里从表头长度生成一个连续下标列表。normalize这里只做了空转空串和去首尾空格实际业务里可能还要处理全角空格、大小写、数字格式化这些都要依据业务自己在 normalize 里补。正常化这一步直接影响差异数量的真实性多花五分钟调它比调任何其他环节都值。4. 输出差异报告让结果直接可读4.1 差异数据模型新增、删除、修改三类存储比对完的结果不能只放一个总共有多少不同的计数后续要么打印到控制台、要么写文件、要么接自动化测试断言所以先定义一个可复用的模型。public class CompareReport { private final ListRowDiff added new ArrayList(); private final ListRowDiff deleted new ArrayList(); private final ListRowDiff modified new ArrayList(); public static class RowDiff { final String key; final String[] oldRow; final String[] newRow; final ListString fieldsChanged; RowDiff(String key, String[] oldRow, String[] newRow, ListString fieldsChanged) { this.key key; this.oldRow oldRow; this.newRow newRow; this.fieldsChanged fieldsChanged; } } public void addAdded(String key, String[] row) { added.add(new RowDiff(key, null, row, List.of())); } public void addDeleted(String key, String[] row) { deleted.add(new RowDiff(key, row, null, List.of())); } public void addModified(String key, String[] oldRow, String[] newRow, ListString fieldsChanged) { modified.add(new RowDiff(key, oldRow, newRow, fieldsChanged)); } public boolean hasDiff() { return !added.isEmpty() || !deleted.isEmpty() || !modified.isEmpty(); } // getter 按需补齐 }设计上有个细节容易被忽略fieldsChanged存的是列下标加旧值加新值的字符串但oldRow和newRow也会完整保留。这样做是为了输出时可以整行打印也可以只打印变化字段两种格式都覆盖到。如果你只把变化字段拼成一句描述后面想换个输出格式又得重新比对一遍等于写死了一条路。模型类还应该提供一个hasDiff()方法让调用方一行判断有没有差异省得每次检查三个列表是否为空。这个判断在自动化测试里反复用到比如 CI 里跑完比对后直接断言report.hasDiff() false干净利落。提示List.of()返回的是不可变列表后续如果要在fieldsChanged上做动态过滤建议在构造函数里复制成new ArrayList(fieldsChanged)避免不可变集合上抛异常。4.2 控制台输出与文件输出双通道输出通道我分两种差异量小走控制台差异量大走文件。控制台输出的目标是扫一眼知道问题多大所以先打一个汇总统计再逐条打印明细。修改类应该单独把键、旧值、新值按块打印方便肉眼对比新增和删除直接整行打出来就行。public void printToConsole(CompareReport report, ListString headers) { System.out.printf(新增 %d 行删除 %d 行修改 %d 行%n, report.getAdded().size(), report.getDeleted().size(), report.getModified().size()); for (CompareReport.RowDiff diff : report.getDeleted()) { System.out.println([删除] 键 diff.key 数据 Arrays.toString(diff.oldRow)); } for (CompareReport.RowDiff diff : report.getAdded()) { System.out.println([新增] 键 diff.key 数据 Arrays.toString(diff.newRow)); } for (CompareReport.RowDiff diff : report.getModified()) { System.out.println([修改] 键 diff.key); for (String field : diff.fieldsChanged) { System.out.println( resolveColName(headers, field)); } } }当差异量到几千行时控制台滚动就不太适合了这时另一条通道是落 CSV 文件。落文件时把修改行展开成一行一条键、状态、列名、旧值、新值。写文件时记得每条记录结束后写\r\n而不是\n否则用 Windows 版 Excel 打开会看到所有内容挤在同一行。public void writeToCsv(CompareReport report, ListString headers, Path output) throws IOException { try (BufferedWriter writer Files.newBufferedWriter(output, StandardCharsets.UTF_8)) { writer.write(键,状态,列名,旧值,新值\r\n); for (CompareReport.RowDiff diff : report.getAdded()) { writer.write(escapeCsv(diff.key) ,新增,,,\r\n); } for (CompareReport.RowDiff diff : report.getDeleted()) { writer.write(escapeCsv(diff.key) ,删除,,,\r\n); } for (CompareReport.RowDiff diff : report.getModified()) { for (String field : diff.fieldsChanged) { String[] parts splitField(field); writer.write(escapeCsv(diff.key) ,修改, escapeCsv(parts[0]) , escapeCsv(parts[1]) , escapeCsv(parts[2]) \r\n); } } } }这里splitField需要从3:100 - 200这样的文本里拆出列名、旧值、新值配合escapeCsv对值里的逗号和双引号做转义双引号变成两个双引号整体再用双引号包起来这是最简且通用的 CSV 转义规则。不加转义的话地址、备注这类字段里一个逗号就能把整列撑错位。什么时候走文件输出我会在 main 方法里加一个--output参数传了它就写文件不传就打印控制台。这样同一个 jar 包既能用于人工核对也能在定时任务里把报告落盘。这个参数设计改动不大但让工具的使用场景宽了一倍。4.3 把列下标翻译成列名报告才给业务看得懂到现在为止代码里一直用列3列4表示变化字段开发时没问题把报告交给业务看就说不过去了。翻译很简单读文件时把表头保存到ListString打印时按下标查表头。private static String resolveColName(ListString headers, String diffText) { int colon diffText.indexOf(:); if (colon 0) { return diffText; } int colIndex Integer.parseInt(diffText.substring(0, colon)); String colName (colIndex 0 colIndex headers.size()) ? headers.get(colIndex) : 列 colIndex; return colName diffText.substring(colon); }这个函数依赖fieldsChanged里的字符串格式固定因此构造这个列表时一定要统一格式比如3:100 - 200这种带冒号和箭头的约定不要混入两种格式否则解析时会出现各种怪异情况。如果你觉得字符串解析太脆弱更稳妥的做法是在RowDiff里存ListInteger changedCols输出时再按需查表头转列名适合长期维护的产品代码resolveColName这个轻量方案适合一次性核对工具。5. 避坑指南键值比对最常见的五个翻车点5.1 空值和空字符串被误判成修改现象两边文件同一位置一个单元格没填、一个填了空字符串比对结果显示一大票空 - 空的修改看得人心烦。原因读取 Excel 时空单元格getCellValueAsString返回的是 null 或空串而 CSV 空字段解析出来是空串两边只要有一边取到的形态不同Objects.equals就直接判不等。解决比对前统一做一次 normalize让 null 和空串变成同一个值。如果两边都做了这一步还在报差异就要检查是不是一边有普通空格一边有全角空格String.trim()也要进 normalize。空值语义还是要由业务确认比如单价为空和单价为 0是否一致不要程序自己拍板。5.2 数字单元格变身科学计数法现象Excel 里一个大整数比对报告显示1.2345678901E10CSV 那边却是普通写法12345678901两边对不上。原因POI 的数字单元格按 double 读入超过一定位数时 Java 默认用科学计数法转字符串CSV 读进来的是原始字符串自然长不一样。解决数字格式化工具方法里多加一道判断整数部分直接转 long 去掉小数尾巴。超过 15 位的超长数字比如业务单号被 Excel 存成数字double 本身就有精度损失这个只能从源头解决让上游把该列存成文本。排错时可以用BigDecimal.valueOf(num).toPlainString()做临时查看不要直接信Double.toString的显示。5.3 CSV 中文乱码编码问题比想象中更常见现象比对结果里中文变成一串乱码或问号数字和字母正常。原因CSV 文件本身的编码和读取时指定的Charset不一致。Windows 上用 Excel 另存的 CSV 多半是 GBK从服务端导出的通常是 UTF-8两个交叉就容易乱。解决在 main 方法里暴露--charset参数并传给Charset.forName默认写成 UTF-8。遇到乱码先别猜用十六进制方式看文件开头的几个字节有EF BB BF是带 BOM 的 UTF-8没有就是 GBK 或者无 BOM UTF-8。如果同事用 Excel 打开正常但程序读乱码大概率文件是 GBK传--charset GBK即可。这个坑很小但在真实环境里出现频率极高尤其当文件经手多个人时。5.4 键值重复导致行被静默覆盖现象比对结果显示大量新增和删除修改数为 0而且新增删除行数接近看起来像整表重发。原因键值列不唯一Map.put时新行覆盖旧行两边的重复行各自只保留下最后一条于是旧表有 A 键、新表有 A 键但指向不同的行就变成了删一行增一行的假象。解决读取时统计每个键的出现次数超过一次就向标准错误打警告。加一行重复检查代码成本很低但能救回很多排查时间public void putWithDuplicateCheck(MapString, String[] data, String key, String[] row) { String[] existed data.put(key, row); if (existed ! null) { System.err.println(警告: 键值重复, key 被覆盖); } }如果业务上键必须唯一说明数据本身已经脏了要先清洗再比对如果键只是主键的一部分需要重新确认键列组合。无论如何都不要在看不警告的情况下直接信任比对结果。5.5 行尾逗号被吞整列数据悄悄错位现象CSV 中某一行末尾有空列读进来看不见报错但比对时那一列的值和其他行对不齐单独看每行又好像没问题。原因line.split(,)不加第二个参数会丢弃末尾空字符串。例如一行是A,B,split 得到 2 个元素而不是 3 个后续按下标比对时少一列整行的末位列都会错位。解决坚持用split(,, -1)保留空尾部。这个符号看着不起眼但它是 CSV 解析里最阴的坑之一错过一次印象就深了。另外如果 CSV 字段本身带引号包裹的逗号手写split就不够用了需要引入一个支持转义的状态机解析器或者直接用现成的 CSV 解析库不要硬手撸。如果你手头的场景跟上面五条都无关但还是翻车优先怀疑数据本身先写个最小复现用例用五六行假数据跑一遍比对确认程序逻辑没问题再去处理真实文件。把问题定位到数据还是代码这一步是排查所有 Excel/CSV 类工具的关键。6. 进阶超过十万行时怎么不把内存打爆内存 Map 方案在行数超过十几万时会明显吃力。两个MapString, String[]加上 POI 读 Excel 时的对象开销堆内存占用很容易突破 1GB。如果只是临时核对调大-Xmx是最快的路但要做成定时任务就得换思路。一个常用做法是流式读取按键排序后落临时文件再用双指针归并比较。流程是读左边每一行拼键按键排序写入临时文件右边同样处理然后两个有序文件各取一个游标键相等就比对指定列键不等时小的那个键所在的行就是只在一边存在的差异。排序可以用 JDK 自带的Files.lines()配合外部归并排序实现量会大一些但内存占用可以控制在几十 MB 以内。还有一个中间量级非常好用的方案把两个文件分别导入嵌入式数据库表然后用一条 SQL 做 FULL OUTER JOIN比手写 Java 稳妥得多。Java 程序里只需要负责参数解析和把最终差异格式化输出核心比对交给引擎SELECT a.key, a.val_old, b.val_new FROM old_file a FULL OUTER JOIN new_file b ON a.key b.key WHERE a.val_old IS DISTINCT FROM b.val_new到这一步键值比对的工具就从一次性脚本长成了能扛住生产数据量的核对程序。我的个人习惯是文件超过十万行先问自己一句——真的要在 Java 里用 Map 硬扛吗多数时候答案是不必。把读取和比对下推给引擎程序里的逻辑少一半内存问题也自然消失。希望这套做大文件的关键做法能帮到你。下次拿到两张要对齐的表可以先花十分钟想清楚键值、空值和数据量再动手写代码能少走很多弯路。本文还有配套的精品资源点击获取