1. 从一个真实的数据导入需求说起
手上有一批历史数据,散落在几十个纯文本文件里,格式五花八门——有的是逗号分隔,有的是竖线分隔,还有的干脆用固定宽度对齐。业务方要求把这些数据统一清洗后灌进数据库,后续要做报表和查询。这种活儿听起来简单,但真动手写起来,坑比想象中多得多:编码问题、字段类型转换、批量插入性能、异常数据怎么处理、事务边界怎么划……每一个环节都能让你加班到深夜。
这篇文章就是围绕"用 Java 从文本文档读取数据并写入数据库"这个核心任务,把整个链路拆开揉碎讲清楚。适合谁看?如果你正在做数据迁移、日志入库、批量导入这类工作,或者刚接触 JDBC 和文件 IO 想找个完整的实战参考,那这篇内容应该能帮你少走不少弯路。我会从文件读取的编码陷阱讲起,到解析策略的选择、JDBC 批量插入的优化、事务与异常处理的设计,最后给出一个可以直接改造使用的完整代码骨架。全程用从业者的视角,讲清楚每一步"为什么这么做",而不只是"怎么做"。
先说一个基本判断:这类任务的核心难点从来不是"读文件"或"写数据库"这两个动作本身,而是数据质量的不确定性和性能的可控性。文本文件不像数据库表有严格的 schema 约束,它可能缺列、多列、类型不对、编码混乱、行尾符不统一。而数据库插入又涉及连接管理、批处理、事务提交策略。把这两端的不确定性都管住,代码才算真正可用。
2. 文本读取的编码陷阱与行处理策略
2.1 为什么 FileReader 是个坑
很多人写文件读取的第一反应是new FileReader("data.txt"),然后套个BufferedReader。这行代码在本地测试时往往没问题,但一上生产环境就出乱子——中文变乱码、特殊符号丢失。原因在于FileReader使用的是平台默认字符集,Windows 中文环境默认是 GBK,Linux 服务器默认可能是 UTF-8,同一份文件在不同机器上读出来的结果完全不同。
正确的做法是显式指定字符集:
BufferedReader reader = new BufferedReader( new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8) );这里有个实操经验:永远不要相信"文件就是 UTF-8"这个假设。我遇到过一批从老旧系统导出的文件,表面看是 UTF-8,实际上夹杂了 GBK 编码的字段,读出来就是一堆问号。处理办法是先做编码探测,或者干脆在读取时用CharsetDecoder配置CodingErrorAction.REPORT,遇到无法解码的字节直接抛异常,而不是静默替换成乱码字符。静默替换是最危险的,因为脏数据会悄无声息地进库。
另外,如果文件带 BOM 头(常见于 Windows 记事本另存的 UTF-8 文件),第一个字段名前面会多出\uFEFF字符,导致你按字段名取值时死活匹配不上。解决办法是在读取第一行后手动去掉 BOM:
if (firstLine != null && firstLine.startsWith("\uFEFF")) { firstLine = firstLine.substring(1); }2.2 大文件不能一次性读进内存
Files.readAllLines()用起来很爽,一行代码拿到所有行。但如果文件有几百 MB 甚至上 G,这一下就把内存撑爆了。文本导入场景里,文件大小往往不可控,所以稳妥的做法是流式逐行读取,边读边处理边入库。
BufferedReader.readLine()是流式的,内存占用只跟单行长度有关。但要注意,如果某一行特别长(比如某个字段里塞了一整段 JSON),单行就可能占几十 MB。这种情况建议设置一个行长度上限,超过就记录日志并跳过,避免被异常数据拖垮整个任务。
还有一个细节:readLine()会把行尾的\n、\r、\r\n都吃掉,所以拿到的字符串是干净的。但如果文件最后一行没有换行符,readLine()依然能正确返回最后一行内容,这点不用担心。
2.3 分隔符解析:split 的局限与 CSV 的复杂性
最简单的解析方式是line.split(","),但这个方法有几个致命问题。第一,如果字段内容本身包含逗号(比如地址"北京市,朝阳区"),split 会把它切成两段,导致列数对不上。第二,split 的参数是正则表达式,遇到特殊字符(如竖线|)需要转义。第三,连续分隔符会产生空字符串,而split默认会丢弃末尾的空字段。
对于真正的 CSV 文件(字段可能带引号、引号内可能含分隔符和换行),手写解析几乎必然出错。这种场景建议直接用成熟的开源库,比如 OpenCSV 或 Apache Commons CSV。它们处理了引号转义、跨行字段、空值等边界情况,省心得多。
但如果文件格式是自己可控的简单分隔符(比如制表符或竖线,且约定字段内不含分隔符),那用split配合-1参数就够了:
String[] fields = line.split("\\|", -1);-1的作用是保留末尾的空字段,否则"a|b|"会被解析成两个字段而不是三个,导致列数错位。这个细节很多人不知道,等到数据对不上才回头排查。
2.4 固定宽度文件的截取技巧
有些老系统导出的文本是固定宽度的,比如前 10 位是编号,接着 20 位是名称,再 8 位是日期。这种文件不能用分隔符解析,得按位置截取。用substring时要注意两点:一是要判断行长是否足够,不足时补空格或跳过;二是中文字符在不同编码下占的字节数不同,如果按字节截取会截断汉字。所以固定宽度解析必须基于字符位置而非字节位置,且要确认文件编码与字段宽度定义一致。
3. 数据库写入的核心:JDBC 批量操作与事务控制
3.1 逐条插入为什么慢得离谱
新手最容易写出的代码是这样的:读一行,拼一条 INSERT,executeUpdate()一次,循环。这种写法在数据量小的时候看不出问题,一旦上万条,耗时会呈指数级上升。原因在于每次executeUpdate都是一次完整的网络往返:应用发 SQL 给数据库,数据库解析、执行、返回结果。一万条就是一万次往返,网络延迟累加起来非常可观。
优化的核心思路是批量提交。JDBC 提供了addBatch()和executeBatch(),可以把多条 SQL 攒在一起一次性发给数据库。数据库收到后可以复用解析计划、减少网络交互,性能提升通常是数量级的。
String sql = "INSERT INTO user_info (id, name, age, create_time) VALUES (?, ?, ?, ?)"; PreparedStatement ps = conn.prepareStatement(sql); for (Record r : records) { ps.setLong(1, r.getId()); ps.setString(2, r.getName()); ps.setInt(3, r.getAge()); ps.setTimestamp(4, r.getCreateTime()); ps.addBatch(); if (++count % 1000 == 0) { ps.executeBatch(); ps.clearBatch(); } } ps.executeBatch();这里有个关键参数:批大小。设太小(比如 100)提升有限,设太大(比如 10 万)可能撑爆数据库的内存或触发包大小限制。经验值在 500 到 2000 之间比较稳妥,具体要看单行数据量和数据库配置。我一般用 1000 作为默认值,然后根据实测调整。
3.2 PreparedStatement 不只是防注入
用PreparedStatement而不是Statement拼接字符串,最常被提到的理由是防止 SQL 注入。但在批量导入场景,它还有两个更实际的好处:一是预编译的 SQL 可以被数据库缓存执行计划,批量执行时效率更高;二是参数设置由 JDBC 驱动处理类型转换和转义,避免了手工拼接时日期格式、引号转义等一堆麻烦。
特别提醒:不要用字符串拼接构造 SQL,哪怕数据来源"看起来"很干净。文本文件里的内容完全不可控,一个带单引号的姓名就能让你的 INSERT 语句语法错误,甚至造成更严重的后果。
3.3 事务边界:多大一批提交一次
批量插入和事务是两回事,但经常要配合使用。executeBatch()只是把 SQL 打包发送,是否提交还取决于连接的自动提交设置。默认情况下 JDBC 连接是自动提交的,每条语句执行完就落库。批量场景下建议关掉自动提交,手动控制事务:
conn.setAutoCommit(false); // ... 批量插入 ... conn.commit();那事务应该多大?如果整个文件几万条放在一个事务里,一旦中途出错全部回滚,前功尽弃;而且长事务会占用数据库资源,可能锁表。合理的做法是分批提交,比如每 1000 条提交一次。这样即使后面出错,前面已提交的数据不会丢,重跑时可以从断点继续。
但分批提交也带来一个问题:如果业务要求"要么全成功要么全失败",那分批提交就破坏了原子性。这时候需要在应用层做补偿,比如记录已处理的行号,失败后清理已插入的数据再重跑。这个取舍要根据业务容忍度来定,没有标准答案。
3.4 连接池:别每次操作都新建连接
DriverManager.getConnection()每次调用都会建立一个新的物理连接,这个过程涉及 TCP 握手、认证等开销,非常耗时。批量导入任务如果每条都新建连接,性能会惨不忍睹。正确做法是使用连接池,比如 HikariCP 或 Druid,复用连接。
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/test_db"); config.setUsername("user"); config.setPassword("pass"); config.setMaximumPoolSize(10); HikariDataSource ds = new HikariDataSource(config);连接池的大小不是越大越好。对于导入任务,通常 5 到 10 个连接就足够了,因为瓶颈往往在数据库的写入能力而非连接数。连接太多反而会增加数据库的锁竞争和上下文切换开销。
4. 数据清洗与类型转换的实战细节
4.1 空值与空字符串的区别
文本文件里的"空"有好几种形态:字段完全不存在(列数不够)、字段是空字符串、字段是空格、字段是 "null" 字符串。这几种在入库时的处理方式完全不同。空字符串可以存进 varchar 字段,但转成 int 就会抛异常;"null" 字符串如果不处理,会原样存进数据库,后续查询时容易和真正的 NULL 混淆。
我的处理原则是:先归一化,再转换。写一个工具方法,把 null、空串、纯空格、字面量 "null"、"NULL"、"N/A" 统一转成 Java 的 null,然后再根据目标字段类型做转换。这样逻辑清晰,也方便统一调整规则。
4.2 日期和数字的解析容错
日期格式是最容易出问题的地方。同一批文件里可能混着 "2023-01-01"、"2023/01/01"、"01/01/2023" 多种格式。硬编码一个SimpleDateFormat必然翻车。稳妥的做法是准备一个格式列表,逐个尝试解析:
private static final String[] DATE_PATTERNS = { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd", "dd/MM/yyyy" }; public static LocalDate parseDate(String raw) { if (raw == null || raw.trim().isEmpty()) return null; String s = raw.trim(); for (String pattern : DATE_PATTERNS) { try { return LocalDate.parse(s, DateTimeFormatter.ofPattern(pattern)); } catch (DateTimeParseException ignored) { } } throw new IllegalArgumentException("无法解析日期: " + raw); }注意SimpleDateFormat是线程不安全的,多线程环境下必须用DateTimeFormatter(Java 8+)或者给每个线程独立实例。这个坑在单线程测试时发现不了,一上并发就出诡异结果。
数字解析同理,要处理千分位逗号、货币符号、前后空格。Integer.parseInt遇到这些直接抛异常,建议先清洗再解析,解析失败时记录原始值和行号,方便事后排查。
4.3 字段长度与精度校验
数据库字段有长度限制,文本文件里的数据可能超长。比如 varchar(50) 的字段,文件里来了个 80 字符的值,直接插入会报错(严格模式下)或被截断(非严格模式下)。截断是更危险的,因为数据静默丢失了。
建议在入库前做一次长度校验,超长的记录到日志并决定是截断、跳过还是报错。数值字段要注意精度,比如 decimal(10,2) 最多存 8 位整数和 2 位小数,超范围的值要提前拦截。
4.4 脏数据的处理策略
面对脏数据,有三种策略:跳过、报错中止、修正后继续。选择哪种取决于业务场景。如果是财务数据,一条都不能错,那就报错中止,人工介入;如果是日志类数据,个别脏数据无所谓,跳过并记录即可;如果是可修正的(比如日期格式不对但能推断),那就修正后继续。
我的建议是默认跳过并记录,同时统计跳过率。如果跳过率超过某个阈值(比如 5%),说明文件格式可能整体有问题,这时候应该中止任务并告警,而不是继续灌入大量残缺数据。
5. 完整代码骨架与性能调优参数
5.1 一个可复用的导入器结构
把前面的要点串起来,一个典型的导入器结构大致如下:配置读取、文件遍历、逐行解析、批量攒批、分批提交、异常记录、结果统计。核心类可以设计成TextToDbImporter,接收文件路径、表名、字段映射等参数。
public class TextToDbImporter { private final DataSource dataSource; private final int batchSize; private final int commitInterval; public ImportResult importFile(Path file, RowParser parser, String insertSql) { ImportResult result = new ImportResult(); try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8); Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement(insertSql)) { String line; int lineNo = 0; int batchCount = 0; while ((line = reader.readLine()) != null) { lineNo++; try { Object[] params = parser.parse(line); bindParams(ps, params); ps.addBatch(); batchCount++; if (batchCount >= batchSize) { ps.executeBatch(); ps.clearBatch(); batchCount = 0; } if (lineNo % commitInterval == 0) { conn.commit(); } result.incrementSuccess(); } catch (Exception e) { result.addError(lineNo, line, e.getMessage()); } } if (batchCount > 0) { ps.executeBatch(); } conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } } catch (IOException | SQLException e) { throw new RuntimeException("导入失败: " + file, e); } return result; } }这个骨架把解析逻辑抽象成RowParser接口,不同格式的文件实现不同的解析器,复用同一套入库流程。异常记录到ImportResult里,任务结束后统一输出报告。
5.2 关键参数对照表
| 参数 | 建议值 | 说明 |
|---|---|---|
| 批大小 batchSize | 500~2000 | 太小提升有限,太大占内存 |
| 提交间隔 commitInterval | 1000~5000 | 控制事务粒度,平衡性能与回滚成本 |
| 连接池大小 | 5~10 | 导入任务瓶颈通常在数据库写入 |
| 单行长度上限 | 1MB | 防止异常长行撑爆内存 |
| 跳过率告警阈值 | 5% | 超过则中止并排查文件格式 |
这些值不是固定的,要根据实际数据量和数据库性能压测后调整。我一般会先用小批量数据跑一遍,观察耗时和内存占用,再逐步放大参数找到拐点。
5.3 性能实测的几点观察
在同一台机器上做过对比测试:一万条记录,逐条插入耗时约 45 秒,批量 1000 条提交耗时约 1.2 秒,差距接近 40 倍。如果再关掉自动提交、用连接池,还能再快一些。这说明批量操作是这类任务性能优化的第一优先级,其他优化都是锦上添花。
另一个观察是:数据库端的索引和约束会显著影响插入速度。如果目标表有很多索引,每插一条都要更新索引,批量插入的优势会被削弱。对于大批量导入,一个常见做法是先禁用索引,导入完成后再重建。但这属于数据库运维层面的操作,要谨慎评估。
6. 踩过的坑与排查思路
6.1 中文乱码的排查链路
遇到乱码时,不要急着改代码,按这个顺序排查:第一步,用十六进制工具看文件头几个字节,确认实际编码;第二步,确认读取时指定的字符集与文件编码一致;第三步,确认数据库连接串里是否指定了characterEncoding;第四步,确认数据库表和字段的字符集。这四步里任何一环不匹配都会导致乱码,而且现象可能相似,必须逐一排除。
我踩过最隐蔽的一次是:文件是 UTF-8,读取也指定了 UTF-8,但数据库连接串没加useUnicode=true&characterEncoding=utf8,结果存进去还是乱码。这个参数在 MySQL 的 JDBC 连接串里经常被忽略,但很关键。
6.2 批量插入时的主键冲突
如果目标表有唯一约束,批量插入时遇到重复主键会整批失败。executeBatch抛出的BatchUpdateException里包含每条语句的执行结果,但不同数据库驱动的行为不一致,有的会继续执行后面的,有的直接中断。稳妥的做法是提前去重,或者用INSERT IGNORE/ON DUPLICATE KEY UPDATE这类数据库特有的语法来处理冲突。但要注意这些语法不是标准 SQL,换数据库时得改。
6.3 内存泄漏的隐蔽来源
流式读取本身不会泄漏,但如果把每行解析后的对象都塞进一个 List 里攒着最后统一处理,那就等于把整个文件加载进内存了。另外,PreparedStatement和Connection忘记关闭也会导致资源泄漏。用 try-with-resources 是基本要求,但要注意ResultSet、Statement、Connection的关闭顺序,以及连接池场景下close()实际是归还连接而非物理关闭。
6.4 任务中断后的断点续传
大批量导入可能跑几个小时,中途因为网络抖动或数据库重启中断。如果没有断点续传机制,只能从头再来。实现思路是记录已成功处理的行号或主键,重启时跳过这些记录。简单做法是在导入前把文件按行号分片,每片独立提交,记录已完成的分片。复杂一点的做法是维护一张进度表,实时更新处理位置。
7. 一些个人体会
这类"读文件入库"的任务,代码写起来不难,难的是把各种边界情况都考虑到。我的经验是:先花时间把数据摸清楚,再动手写代码。拿几个样本文件,用文本编辑器打开看看编码、分隔符、字段数、有没有脏数据,这些信息比任何文档都可靠。摸清楚之后再设计解析器和清洗规则,返工的概率会小很多。
另外,日志一定要打足。每处理一批记录一下进度,遇到异常记录原始行内容,任务结束后输出成功数、失败数、跳过数。这些日志在出问题时就是救命稻草。我见过太多人为了"代码简洁"把日志省掉,结果线上出问题两眼一抹黑,只能加日志重新跑一遍。
最后,别迷信"一次跑通"。批量导入任务第一次跑通常都会暴露各种问题,预留出调试和重跑的时间,比赶进度硬上要靠谱得多。