1. 从一个真实的数据导入需求说起手上有一批历史数据散落在几十个纯文本文件里格式五花八门——有的是逗号分隔有的是竖线分隔还有的干脆用固定宽度对齐。业务方要求把这些数据统一清洗后灌进数据库后续要做报表和查询。这种活儿听起来简单但真动手写起来坑比想象中多得多编码问题、字段类型转换、批量插入性能、异常数据怎么处理、事务边界怎么划……每一个环节都能让你加班到深夜。这篇文章就是围绕用 Java 从文本文档读取数据并写入数据库这个核心任务把整个链路拆开揉碎讲清楚。适合谁看如果你正在做数据迁移、日志入库、批量导入这类工作或者刚接触 JDBC 和文件 IO 想找个完整的实战参考那这篇内容应该能帮你少走不少弯路。我会从文件读取的编码陷阱讲起到解析策略的选择、JDBC 批量插入的优化、事务与异常处理的设计最后给出一个可以直接改造使用的完整代码骨架。全程用从业者的视角讲清楚每一步为什么这么做而不只是怎么做。先说一个基本判断这类任务的核心难点从来不是读文件或写数据库这两个动作本身而是数据质量的不确定性和性能的可控性。文本文件不像数据库表有严格的 schema 约束它可能缺列、多列、类型不对、编码混乱、行尾符不统一。而数据库插入又涉及连接管理、批处理、事务提交策略。把这两端的不确定性都管住代码才算真正可用。2. 文本读取的编码陷阱与行处理策略2.1 为什么 FileReader 是个坑很多人写文件读取的第一反应是new FileReader(data.txt)然后套个BufferedReader。这行代码在本地测试时往往没问题但一上生产环境就出乱子——中文变乱码、特殊符号丢失。原因在于FileReader使用的是平台默认字符集Windows 中文环境默认是 GBKLinux 服务器默认可能是 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字符导致你按字段名取值时死活匹配不上。解决办法是在读取第一行后手动去掉 BOMif (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 逐条插入为什么慢得离谱新手最容易写出的代码是这样的读一行拼一条 INSERTexecuteUpdate()一次循环。这种写法在数据量小的时候看不出问题一旦上万条耗时会呈指数级上升。原因在于每次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是线程不安全的多线程环境下必须用DateTimeFormatterJava 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 关键参数对照表参数建议值说明批大小 batchSize500~2000太小提升有限太大占内存提交间隔 commitInterval1000~5000控制事务粒度平衡性能与回滚成本连接池大小5~10导入任务瓶颈通常在数据库写入单行长度上限1MB防止异常长行撑爆内存跳过率告警阈值5%超过则中止并排查文件格式这些值不是固定的要根据实际数据量和数据库性能压测后调整。我一般会先用小批量数据跑一遍观察耗时和内存占用再逐步放大参数找到拐点。5.3 性能实测的几点观察在同一台机器上做过对比测试一万条记录逐条插入耗时约 45 秒批量 1000 条提交耗时约 1.2 秒差距接近 40 倍。如果再关掉自动提交、用连接池还能再快一些。这说明批量操作是这类任务性能优化的第一优先级其他优化都是锦上添花。另一个观察是数据库端的索引和约束会显著影响插入速度。如果目标表有很多索引每插一条都要更新索引批量插入的优势会被削弱。对于大批量导入一个常见做法是先禁用索引导入完成后再重建。但这属于数据库运维层面的操作要谨慎评估。6. 踩过的坑与排查思路6.1 中文乱码的排查链路遇到乱码时不要急着改代码按这个顺序排查第一步用十六进制工具看文件头几个字节确认实际编码第二步确认读取时指定的字符集与文件编码一致第三步确认数据库连接串里是否指定了characterEncoding第四步确认数据库表和字段的字符集。这四步里任何一环不匹配都会导致乱码而且现象可能相似必须逐一排除。我踩过最隐蔽的一次是文件是 UTF-8读取也指定了 UTF-8但数据库连接串没加useUnicodetruecharacterEncodingutf8结果存进去还是乱码。这个参数在 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. 一些个人体会这类读文件入库的任务代码写起来不难难的是把各种边界情况都考虑到。我的经验是先花时间把数据摸清楚再动手写代码。拿几个样本文件用文本编辑器打开看看编码、分隔符、字段数、有没有脏数据这些信息比任何文档都可靠。摸清楚之后再设计解析器和清洗规则返工的概率会小很多。另外日志一定要打足。每处理一批记录一下进度遇到异常记录原始行内容任务结束后输出成功数、失败数、跳过数。这些日志在出问题时就是救命稻草。我见过太多人为了代码简洁把日志省掉结果线上出问题两眼一抹黑只能加日志重新跑一遍。最后别迷信一次跑通。批量导入任务第一次跑通常都会暴露各种问题预留出调试和重跑的时间比赶进度硬上要靠谱得多。