资讯详情

资讯详情

Java实现Excel/CSV文件比对:键值定位与差异分析实战

简介面向 Java 开发者的表格差异比对工具资源用于解决 Excel 或 CSV 文件中按指定键值列匹配行、并对比目标列取值变化的问题。压缩包内为可直接导入的 Eclipse 工程共 24 个文件包含 9 个 Java 源文件、9 个已编译的 class 文件、3 个第三方依赖 jarjxl、javacsv、commons-logging以及 .classpath、.project、.prefs 等工程与环境配置整体仅 666KB。源码与编译产物并存导入 IDE 即可运行或二次改动也可直接引用 class 文件快速实现两表比对。实现思路覆盖文件读取、键值列组合映射、逐行遍历、差异输出等环节支持按需指定键值列和比对列适用于数据核对、报表校验、接口联调等场景。已有 2541 人学习属于轻量实用的 Java 示例代码量不大但结构清晰适合初学者理解 Map 数据结构在数据比对中的应用也方便有经验的开发者在此基础上扩展为带界面或批量处理的完整工具。1. 两文件比对不是 diff先想清楚“键”和“列”再动手java 实现两excel或csv文件的比对以某几列作为键值比对指定列的值差异——这个需求在数据运维、对账、ETL 校验里出现频率极高。我见过太多人拿到两个表就直接双层 for 循环逐行比对文件稍微大点就卡死也见过有人把整行拼成字符串塞进 Set 里做差集结果数字 1.0 和 1 被当成不同值误报一屏。这个标题的关键不在于“读 Excel”而在于两点一是想清楚哪几列联合起来能唯一标识一行二是只比对指定列的差异而不是整行比对。前者决定比对结果准不准后者决定比对效率高不高。这篇文章我会从技术选型、内存模型、比对引擎、结果输出、踩坑记录到大数据量优化完整讲一遍这个工具的落地路径。用 Java 实现Cover 到 Excel 和 CSV 两种格式场景覆盖几千行的中小文件和几十万行的大文件。你会看到一个可以直接抄走的最小实现也会看到我在真实项目里踩过的坑——有些坑不遇到是真的想不到。2. 技术选型内存模型与三套解析方式的取舍2.1 EasyExcel vs POI按数据量选型而不是按名气做 Excel 解析绕不开 POI 和 EasyExcel 这两个库。很多初学者一上来就选 POI因为教程多、资料全但 POI 的 UserModel也就是 HSSFWorkbook / XSSFWorkbook 那一套会把整个工作簿加载进内存一个 50MB 的 xlsx 解析下来堆内存轻松涨到 1GB 以上。EasyExcel 是阿里开源的那套基于 SAX 模式解析读一行回调一行内存占用比 POI 低一个量级。如果是 CSV 文件连 EasyExcel 都可以不用的直接用 OpenCSV 或者 Commons CSV 解析纯文本JDK 自带 java.nio 也能搞定。我的建议是文件在 10MB 以内、行数在五万以下用 POI 也没问题代码直观好调试文件超过这个规模直接上 EasyExcel 的同步读EasyExcel.read().sheet().doReadSync()API 简单内存也稳。真正到了百万行级别同步读也要小心需要换成带invoke回调的流式读法这个我们在第 4 章细说。dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.2/version /dependency dependency groupIdcom.opencsv/groupId artifactIdopencsv/artifactId version5.8/version /dependency这里给的是我常用的版本组合。EasyExcel 3.x 系列 API 稳定OpenCSV 5.x 处理带引号的 CSV 字段不会翻车。选这两个库的理由是一个解决 Excel 解析的内存问题一个解决 CSV 解析的引号和转义问题。POI 不是不能用而是你需要在“省内存”和“省代码”之间做取舍。2.2 统一数据结构Record 与 RowKey 的设计不管读进来的是 Excel 还是 CSV到了内存里应该统一成同一种结构否则后续比对逻辑要写两份。我习惯先把每一行转成一个Record对象字段值统一按字符串存。这样做是故意的——比对场景里数字、日期、金额这些类型在不同文件里格式往往不一致先统一成字符串再归一化处理比在类型层面硬碰硬省心得多。public class Record { /** 行号用于定位原始文件中的位置 */ private int rowNum; /** 列名到值的映射值统一用 String 存储 */ private MapString, String fields; /** 原始行对象排错时备用 */ private Object sourceRow; // 构造器、getter、setter 省略 public String get(String columnName) { return fields.get(columnName); } }这里的核心设计是MapString, String。列名作为 Map 的 key好处是后续“比对指定列”时可以直接按列名取值不用记住列下标。坏处是如果两个文件列名不一致你得先做一层列名映射这个问题在第 5 章避坑里会专门讲。2.3 CSV 用 OpenCSV 解析的边界问题CSV 看似简单坑其实不少字段里包含逗号、引号、换行时普通String.split(,)会直接翻车。OpenCSV 的CSVParser能正确处理带引号的字段但它也有自己的脾气——默认分隔符是逗号如果你的 CSV 是制表符分隔TSV需要显式指定分隔符。另外 CSVParser 默认会跳过空行但不会跳过注释行遇到以#开头的行需要自己在回调里过滤。public static ListRecord readCsv(String filePath, ListString columns) throws Exception { ListRecord records new ArrayList(); try (Reader reader Files.newBufferedReader(Paths.get(filePath), StandardCharsets.UTF_8)) { CSVParser parser new CSVParserBuilder() .withSeparator(,) .withIgnoreQuotations(false) .build(); try (CSVReader csvReader new CSVReaderBuilder(reader) .withCSVParser(parser) .withSkipLines(1) // 跳过表头 .build()) { String[] line; int rowNum 1; while ((line csvReader.readNext()) ! null) { if (line.length 1 line[0].trim().isEmpty()) { continue; // 跳过空行 } Record record new Record(); record.setRowNum(rowNum); MapString, String fieldMap new HashMap(); for (int i 0; i columns.size() i line.length; i) { fieldMap.put(columns.get(i), line[i]); } record.setFields(fieldMap); records.add(record); } } } return records; }这个方法的几个参数是踩过坑才定下来的withIgnoreQuotations(false)表示保留引号语义字段值里的引号会被正确去除withSkipLines(1)跳过表头行。需要注意columns参数必须由调用方传入因为 CSV 本身不包含列的元信息——如果表头行存在你也可以先读第一行再动态生成 columns但那样代码会复杂一些我一般倾向于在配置里写死列名清晰且可控。3. 核心比对引擎从“双层循环”到“HashMap 一次命中”3.1 最小实现加载文件到 Map 的完整代码双层 for 循环比对的时间复杂度是 O(n×m)两个一万行的文件就是上亿次比较肉眼可见的卡顿。业界通用的做法是用 HashMap 以键值列拼接后的字符串作为 key把行 Record 作为 value两次遍历即可完成比对时间复杂度降到 O(nm)。这也是标题里“以某几列作为键值”的核心——先拼键再比较。public static MapString, Record buildIndex(ListRecord records, ListString keyColumns) { MapString, Record index new HashMap(); for (Record record : records) { String key buildKey(record, keyColumns); // 处理键冲突同一键值出现多行时保留第一行并记录冲突 if (index.containsKey(key)) { System.err.println(警告键值重复行号 record.getRowNum() 与行号 index.get(key).getRowNum() 键值相同: key); continue; } index.put(key, record); } return index; } public static String buildKey(Record record, ListString keyColumns) { StringBuilder sb new StringBuilder(); for (int i 0; i keyColumns.size(); i) { if (i 0) { sb.append(\u0001); // 用不可见字符做分隔符降低碰撞概率 } String val record.get(keyColumns.get(i)); sb.append(val null ? : val); } return sb.toString(); }buildKey方法里有个细节值得解释分隔符用了\u0001SOH 控制字符而不是常见的下划线或竖线。因为键值本身可能包含下划线如果键值是a_b和c分隔符用下划线会拼成a_b_c跟a、b_c拼出来的结果一样产生键碰撞。\u0001在实际业务数据里几乎不会出现代价只是调试时打印 key 会看到奇怪字符但正确性优先。如果你确认数据里不会出现|用|也可以但\u0001是最稳妥的选择。3.2 双文件比对主流程新增、删除、差异分类有了两个 Map 索引之后比对流程就清晰了遍历 A 文件索引查 B 文件索引找不到就是“已删除”遍历 B 文件索引查 A 文件索引找不到就是“新增”两边都能找到就逐个比较指定的列有任何一个值不同就是“差异”。这样一趟下来三种结果全部收敛。public static CompareResult compare( MapString, Record baseIndex, MapString, Record targetIndex, ListString compareColumns) { CompareResult result new CompareResult(); // 先遍历基准文件找出缺失的键值 for (Map.EntryString, Record entry : baseIndex.entrySet()) { String key entry.getKey(); Record baseRecord entry.getValue(); Record targetRecord targetIndex.get(key); if (targetRecord null) { result.addDeleted(baseRecord); continue; } // 有对应行比对指定列 ListDiffField diffs new ArrayList(); for (String col : compareColumns) { String baseVal normalize(baseRecord.get(col)); String targetVal normalize(targetRecord.get(col)); if (!Objects.equals(baseVal, targetVal)) { diffs.add(new DiffField(col, baseRecord.get(col), targetRecord.get(col))); } } if (!diffs.isEmpty()) { result.addModified(key, baseRecord, targetRecord, diffs); } } // 再遍历目标文件找出新增的键值 for (Map.EntryString, Record entry : targetIndex.entrySet()) { if (!baseIndex.containsKey(entry.getKey())) { result.addAdded(entry.getValue()); } } return result; }结果对象CompareResult内部维护了三个列表deletedList在基准里有、目标里没了、addedList目标里有、基准里没有、modifiedList两行都存在但指定列的值有变化。需要注意modifiedList里保存的是两边的完整Record和差异字段明细这样后续输出报告时既能看概览也能深挖每一项变化。3.3 键值拼接与归一化比对之前先做这步normalize方法是我在第 2 章留的扣子。为什么要归一化因为两个文件来自不同系统时同一个值经常长不一样一个存了1.0另一个存了1一个是2024-01-01 00:00:00另一个是2024/01/01一个是张三 带尾随空格另一个是张三。不归一化直接比你的工具会天天误报用不了三天就被扔进垃圾堆。public static String normalize(String value) { if (value null) { return ; } String v value.trim(); // 数字归一化去掉尾部的 .0处理科学计数法 if (v.matches(-?\\d\\.0)) { v v.substring(0, v.length() - 2); } // 去除数字中的千分位逗号 v v.replace(,, ); // 日期归一化把 / 替换成 -去掉时间部分 v v.replace(/, -); v v.replaceFirst(\\s\\d{2}:\\d{2}:\\d{2}$, ); return v; }这个normalize覆盖了我遇到过的 80% 格式差异场景但有几个雷建议你不要踩第一不要去掉字符串中间的所有空格A B和AB是不同的东西第二千分位逗号替换要先做因为1,234和1234是同一个数的两种写法第三日期归一化只处理了分隔符和尾随时间时区问题不要在这里解决那是另一个维度的坑。归一化逻辑应该做成可配置的不同业务可以启用或关闭——这个后面讲进阶时再展开。4. 结果输出差异报告要让人一次看懂4.1 输出到控制台 vs 输出到 Excel比对引擎运行完结果要给谁看如果是你自己排查数据问题控制台输出带行号和差异明细就够了如果是要发给业务方让他们核对并反馈那就必须输出一个结构化的报告文件。我见过的失败案例是报表输出了一堆只有程序员看得懂的键值字符串业务方看到直接崩溃。正确的做法是输出文件里保留原始业务键值如订单号、身份证号差异单元格标红两侧新旧值并列展示。public static void printSummary(CompareResult result) { System.out.println( 比对结果汇总 ); System.out.println(新增行数: result.getAddedList().size()); System.out.println(删除行数: result.getDeletedList().size()); System.out.println(差异行数: result.getModifiedList().size()); System.out.println(); for (ModifiedRecord mr : result.getModifiedList()) { System.out.println(键值 [ mr.getKey() ] 行号 [ mr.getBaseRecord().getRowNum() - mr.getTargetRecord().getRowNum() ]); for (DiffField df : mr.getDiffs()) { System.out.println( 列 [ df.getColumnName() ] : df.getOldValue() df.getNewValue()); } } }控制台输出的要点是“一眼定位”键值、行号、变更明细三项信息缺一不可。注意这里打印的是行号不是行的内容——真正大文件里行号是最快的定位方式也是 Excel 表格翻找时最直接的锚点。4.2 生成差异 Excel 的落地代码给业务方的报告用 Excel 最合适可以直接筛选、排序还能做条件格式。我用的方案是 EasyExcel 的写功能生成一个差异汇总工作簿第一个 Sheet 放差异总览第二个 Sheet 放新增行第三个 Sheet 放删除行。差异总览的每一行包含键值列、基准文件行号、目标文件行号、以及每个差异字段的旧值和新值。public static void writeReport(String outputPath, CompareResult result, ListString keyColumns, ListString compareColumns) throws Exception { // 差异总览的数据模型 ListDiffRow diffRows new ArrayList(); for (ModifiedRecord mr : result.getModifiedList()) { DiffRow row new DiffRow(); row.setKey(joinDisplayKey(mr.getKey(), mr.getBaseRecord(), keyColumns)); row.setBaseRowNum(mr.getBaseRecord().getRowNum()); row.setTargetRowNum(mr.getTargetRecord().getRowNum()); StringBuilder sb new StringBuilder(); for (DiffField df : mr.getDiffs()) { sb.append(df.getColumnName()) .append(: [) .append(df.getOldValue()) .append(] - [) .append(df.getNewValue()) .append(]; ); } row.setDiffDetail(sb.toString()); diffRows.add(row); } // 用 EasyExcel 写出 EasyExcel.write(outputPath, DiffRow.class) .sheet(差异总览) .doWrite(diffRows); }DiffRow是一个普通的 POJO字段上用ExcelProperty注解指定列名。这里有个输出细节diffDetail字段把该行所有列的差异拼成一长串。有人说应该把每个差异列拆成独立行让业务方能逐列核对这个想法没错但实际用下来业务方更习惯一行一个业务对象比如一笔订单差异明细里能看到“金额列变了、状态列也变了”。如果你要拆列也是改一个 DTO 的事看业务偏好来定。4.3 大文件下的流式读取与分批输出到了几十万行这个量级doReadSync()一次性读进 List 的方式就要小心了。EasyExcel 的同步读本质上是把流式回调攒到 List 里内存占用会随行数线性增长。我处理百万行文件时的做法是回调模式 分批写入比对。public static void compareLargeFiles(String basePath, String targetPath, ListString keyColumns, ListString compareColumns) { MapString, Record baseIndex new HashMap(); MapString, Record targetIndex new HashMap(); // 流式读取第一个文件建立索引 EasyExcel.read(basePath, new AnalysisEventListenerMapInteger, String() { Override public void invoke(MapInteger, String row, AnalysisContext context) { // 拿到行数据转成 Record放入 baseIndex Record record rowToRecord(row, context.getCurrentRowIndex()); String key buildKey(record, keyColumns); baseIndex.put(key, record); // 索引过大时先做一次部分比对 if (baseIndex.size() % 100000 0) { System.out.println(已加载基础文件 baseIndex.size() 行); } } Override public void doAfterAllAnalysed(AnalysisContext context) { System.out.println(基础文件加载完成共 baseIndex.size() 行); } }).sheet().doRead(); // 流式读取第二个文件边读边比对 // ...同样的回调结构 }这段代码展示了流式读法的骨架实际的边缘处理比较多比如第二个文件边读边比对时遇到新增行直接记录遇到键值存在于 baseIndex 的行立即比对并输出结果这样内存里只需要保留一个完整索引。如果两个文件都巨大连单个索引都放不进内存就需要走数据库或者分桶策略这是第 6 章的内容。5. 避坑指南比对工具最常见的 5 个翻车点5.1 数字精度1.0 和 1 到底算不算相同现象两个文件里的数值列一个显示1一个显示1.0被判定为差异业务方反馈数据没问题误报率爆表。原因Excel 单元格的数字读到 Java 里可能是Double类型1被格式化成1.0CSV 文件里同一列可能存的就是字符串1。两者字符串形态不同直接比对必然不等。解决对数值类型的列统一走normalize的“去尾部 .0”逻辑。另外要小心科学计数法——1.0E7和10000000是不同的字符串表达但数值相等。我一般建议数值列先转BigDecimal再转字符串能避开大部分精度坑。public static String normalizeNumeric(Object obj) { if (obj null) return ; if (obj instanceof Double || obj instanceof Float) { BigDecimal bd BigDecimal.valueOf(((Number) obj).doubleValue()); return bd.stripTrailingZeros().toPlainString(); } return String.valueOf(obj).trim(); }5.2 日期格式2024-01-01 和 2024/01/01 是同一个值吗现象A 文件日期列是2024-01-01B 文件是2024/01/01工具判定为差异但两个日期是同一天。原因日期格式没有统一字符串比对天然不认语义等价。解决第 3 章的normalize里已经做了替换和去时间处理这是一种轻量方案。如果日期有更多形态比如20240101这种纯数字建议写一个专门的日期归一化函数把所有已知格式先转成LocalDate再格式化成yyyy-MM-dd异常格式保持原样并打日志这把格式问题收敛到一个函数里。5.3 空字符串与 null 的区分现象A 文件某列为空B 文件对应位置是null可能来自 Excel 的空单元格工具判定为差异。原因EasyExcel 读空单元格回调时拿到的是null而 CSV 读取空字段时拿到的是。解决normalize方法里把null和空字符串统一映射成同一种形态来处理比对之前先过一层规则不要让后续逻辑去区分null和。这对业务来说通常是符合直觉的——空就是空。但也有的场景两者语义不同比如 Excel 里空格子与真实空字符串可能代表“未填写”和“填写了空值”这种特殊情况需要加配置开关。public static final String EMPTY_MARK \u2400; // 空值统一标记 public static String normalizeWithEmptyMark(String value) { if (value null) return EMPTY_MARK; String v value.trim(); return v.isEmpty() ? EMPTY_MARK : v; }5.4 键值拼接时的分隔符碰撞现象两个文件都有键A、B和A_B、使用_做分隔符拼接键后A_B和A_B_存在冲突风险比对结果错乱。原因键值本身包含分隔符字符导致不同键组合拼出相同字符串。除了换不可见字符外更稳妥的方式是用一个不可见字符加长度前缀避免任何可能的碰撞。解决构建 key 时同时拼接各字段的长度信息例如len(1):A\u0001len(1):B。这样即使值里包含\u0001也能区分。代价是 key 变长内存占用稍高但对正确性的提升是值得的。5.5 乱码问题CSV 的 BOM 与编码猜测现象CSV 文件用 Excel 打开正常用 Java 读出来第一列第一个字符变成\ufeff整行数据比对全部失败。原因Excel 导出的 CSV 默认带 BOM也就是在文件开头写入了一个不可见的 UTF-8 BOMEF BB BFJava 的Files.newBufferedReader不会自动跳过 BOM。解决读取时检测并去掉 BOM 头。public static Reader createUtf8Reader(String filePath) throws IOException { InputStream in Files.newInputStream(Paths.get(filePath)); PushbackInputStream pushback new PushbackInputStream(in, 3); byte[] bom new byte[3]; int len pushback.read(bom); if (len 3 (bom[0] (byte) 0xEF) (bom[1] (byte) 0xBB) (bom[2] (byte) 0xBF)) { // 跳过 BOM继续读取 } else { pushback.unread(bom, 0, len); } return new InputStreamReader(pushback, StandardCharsets.UTF_8); }这个场景我遇到的次数不少每次都是新接一个系统导出的 CSV 就踩一次。顺手也提醒一句BOM 只在文件开头出现中间段落出现\ufeff大概率是数据本身有问题不要统一处理。6. 进阶百万行文件怎么把比对时间从分钟压到秒文件到了一百万行以上HashMap 索引本身占用内存就非常可观估算 1G 以上GC 频繁比对性能断崖式下降。我的做法是把“单 Map 大索引”拆成“分桶小索引”用键值的哈希值取模分桶每个桶独立 Map内存更紧凑也为并行处理留下空间。public class BucketIndex { private static final int BUCKET_COUNT 16; private final MapString, Record[] buckets new HashMap[BUCKET_COUNT]; public BucketIndex() { for (int i 0; i BUCKET_COUNT; i) { buckets[i] new HashMap(); } } public void put(String key, Record record) { int bucketIdx (key.hashCode() 0x7fffffff) % BUCKET_COUNT; buckets[bucketIdx].put(key, record); } public Record get(String key) { int bucketIdx (key.hashCode() 0x7fffffff) % BUCKET_COUNT; return buckets[bucketIdx].get(key); } }分桶索引的查询和插入没变只是把一个大 Map 拆成了 16 个小 Map单次 GC 的时间常数显著下降。比对逻辑也可以按桶并行化每个桶一个线程做比对最后合并结果。要注意HashMap不是线程安全的每个桶单独持有一个 Map、单独线程读写不需要加锁。线程数建议控制在 4~8 之间BUCKET_COUNT和线程数保持倍数关系负载更均衡。并行代码不复杂核心是ExecutorServiceFuture汇总ExecutorService executor Executors.newFixedThreadPool(8); ListFutureCompareResult futures new ArrayList(); for (int i 0; i BUCKET_COUNT; i) { final int bucketIdx i; futures.add(executor.submit(() - compareInBucket( baseBucket.getBucket(bucketIdx), targetBucket.getBucket(bucketIdx), compareColumns))); } // 合并所有分桶的结果 CompareResult total new CompareResult(); for (FutureCompareResult future : futures) { total.merge(future.get()); } executor.shutdown();compareInBucket的逻辑跟第 3 章的完整比对流程一致只处理传入的两个桶 Map。如果你要用这个方案有两个前置条件需要确认键值必须唯一、数据无需全局排序。因为分桶后同一个键必然落在同一个桶里这是哈希一致性的保证所以正确性不受影响。这段优化代码是我在几次真实项目里反复打磨过的产物能落地的原因在于分桶的设计不改变比对逻辑本身只是对 Map 这一层做物理拆分出问题时可以快速回退到单 Map 排错。我自己的工具箱里这套分桶代码比 EasyExcel 的流式读法更常被复用——因为数据量大到一定程度后瓶颈往往不在读文件而在内存里索引的 GC 压力。第一次做百万行文件比对时我还会在主流程里偷偷加一个耗时统计看分桶并行方案把时间从 4 分钟压到 20 秒以内的时候心里确实有一种“值了”的踏实感。希望这篇文章能帮你少走这些弯路。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →