ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java实现Excel/CSV数据键值比对与差异核对实战

Java实现Excel/CSV数据键值比对与差异核对实战

简介:在日常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[]存单元格值,外加一个List<String>存表头。列顺序两边一致时,直接按下标取单元格,读写都最直观;读取器不需要引入复杂模型,比对器只需要把列下标传来传去。缺点是列一多,下标含义要靠表头来回查,代码可读性下降。

另一种方案是每行存成Map<String, String>,列名做 key。优点是列顺序不一致甚至列数不同都不怕,比对的列名直接当作键来取;缺点是每个单元格都要构造一个 Map.Entry,内存开销比数组大不少,几万行时尚可接受,几十万行就明显肉疼。我在一次数据核对中对比过两种写法,同等数据量下,后者的内存占用大概是前者的三倍。

判断项String[] + List表头Map<String, 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,它们的格式完全不同。常见做法是各写一个读取方法,返回统一的数据结构Map<String, String[]>,调用方只关心我拿到了键值到整行单元格的映射,不关心背后是哪种文件。

CSV 我一般直接手写解析,比引入 OpenCSV 更轻,也更容易控制细节。读取时有三个注意点:一是编码必须在打开流时指定;二是每行调用split(",", -1)必须带第二个参数 -1;三是表头行要单独跳过。第三个问题虽然看起来简单,但表头行也可能是空行,只判断行号 0 就会漏掉真正的内容行。

public Map<String, String[]> readCsv(Path file, Charset charset, List<Integer> keyCols) throws IOException { Map<String, 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 Map<String, String[]> readExcel(Path file, List<Integer> keyCols) throws IOException { Map<String, 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 ""; } }

这个方法解决了两个经典翻车现场:一是数值单元格存的是 100,POI 却返回 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(Map<String, String[]> oldData, Map<String, String[]> newData, List<Integer> valueCols) { CompareReport report = new CompareReport(); for (Map.Entry<String, 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; } List<String> 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.Entry<String, 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 List<RowDiff> added = new ArrayList<>(); private final List<RowDiff> deleted = new ArrayList<>(); private final List<RowDiff> modified = new ArrayList<>(); public static class RowDiff { final String key; final String[] oldRow; final String[] newRow; final List<String> fieldsChanged; RowDiff(String key, String[] oldRow, String[] newRow, List<String> 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, List<String> 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, List<String> 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, List<String> 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表示变化字段,开发时没问题,把报告交给业务看就说不过去了。翻译很简单:读文件时把表头保存到List<String>,打印时按下标查表头。

private static String resolveColName(List<String> 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里存List<Integer> changedCols,输出时再按需查表头转列名,适合长期维护的产品代码;resolveColName这个轻量方案适合一次性核对工具。

5. 避坑指南:键值比对最常见的五个翻车点

5.1 空值和空字符串被误判成修改

现象:两边文件同一位置一个单元格没填、一个填了空字符串,比对结果显示一大票"空 -> 空"的修改,看得人心烦。

原因:读取 Excel 时空单元格getCellValueAsString返回的是 null 或空串,而 CSV 空字段解析出来是空串;两边只要有一边取到的形态不同,Objects.equals就直接判不等。

解决:比对前统一做一次 normalize,让 null 和空串变成同一个值。如果两边都做了这一步还在报差异,就要检查是不是一边有普通空格一边有全角空格,String.trim()也要进 normalize。空值语义还是要由业务确认,比如"单价为空"和"单价为 0"是否一致,不要程序自己拍板。

5.2 数字单元格变身科学计数法

现象:Excel 里一个大整数,比对报告显示1.2345678901E10,CSV 那边却是普通写法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(Map<String, 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 方案在行数超过十几万时会明显吃力。两个Map<String, 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 硬扛吗?多数时候答案是不必。把读取和比对下推给引擎,程序里的逻辑少一半,内存问题也自然消失。

希望这套做大文件的关键做法能帮到你。下次拿到两张要对齐的表,可以先花十分钟想清楚键值、空值和数据量,再动手写代码,能少走很多弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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