做Oracle开发和运维的人,对SQLLoader应该都不陌生。Oracle 12c时代,它依然是批量数据导入里最常被想起的工具之一。我第一次在一个12c生产环境里接手大批量数据迁移时,也一度怀疑这个老工具还能不能撑住,实测结论是:核心机制没变,该用还是用,而且在12c的新架构下,它反而跟In-Memory、标识列这些新特性有了不少协同配合。SQLLoader本质就是一个命令行批量加载工具,你写一个控制文件告诉它数据怎么进来,它负责把外部数据文件里的行按规则灌进目标表。它解决的场景,是那些几十万、几百万行甚至更大的外部数据文件,快速进Oracle,同时数据格式可能还不太规整,逗号分隔、固定宽度、引号包裹、日期格式怪异,都能用一套控制文件规则兜住。
适合谁呢?运维DBA、后端开发、数据工程师都会碰到。尤其是经常做历史数据迁移、报表数据清洗、合作方数据交换的人,SQL*Loader是你的常备基本功。这篇文章我不想把官方文档复述一遍,而是把我在12c环境里用这个工具的实操经验拆开讲清楚,包括控制文件怎么写、直接路径怎么用、踩过的坑怎么排。看完你至少能独立写一个像样的加载脚本,并且知道出了问题先看哪里。
1. 12c时代,为什么还要回头认识SQL*Loader
1.1 它到底解决什么问题
先对齐一下SQLLoader的定位。你手头有一份外部数据文件,可能是业务方给的CSV,可能是旧的订单流水文本,也可能是某个老旧系统导出的定长文件。这份数据要导进Oracle的某张表,你用Data Pump?不合适,那是Oracle内部格式的备份与传输,解决的是Oracle到Oracle的搬迁。你写INSERT脚本一条条插入?几十万条数据光网络往返、SQL解析就能让你等到怀疑人生。这时候SQLLoader就是那个“闷头干活”的角色,一条命令,把文件扫进去,逐行解析、转换、写入,整个过程不经过交互式SQL。
它在12c里依然是默认安装的,这一点值得庆幸。Oracle对老工具做过好几次“退休警告”,比如老的imp/exp被数据泵替代,但SQLLoader一直保留着,说明它在数据搬运场景里不可替代。你可能觉得外部表也能读外部文件,确实可以,但外部表本质是让Oracle把外部文件当成一张只读表来查,每次查询都要重新扫描文件,如果你要的是“把文件变成表里实实在在的数据”,那SQLLoader更直接:文件读一遍,行写进表,加载完成即结束。
1.2 别把它和数据泵、外部表混为一谈
我和不少同行聊过,发现大家对这几个工具的边界经常搞混,这里用一句话区分:
- 数据泵(expdp/impdp):Oracle到Oracle,备份、迁移、整库或Schema级搬运,数据文件是Oracle私有格式。
- 外部表(External Table):把外部数据文件映射成一张可查询的表,适合“查”和“ETL中间层”,不落本地表。
- SQL*Loader:外部数据文件到Oracle表,一锤子买卖,灌进去就算完。
实际工作中,这三个工具经常搭配用。比如先用SQLLoader把原始文件灌进一张临时表,再用SQL做清洗转换,最后用数据泵或者普通INSERT进入正式环境。SQLLoader的角色是“入口”,它的强项是解析和写入,不是业务转换。
1.3 12c给SQL*Loader带来了什么
说句实在话,SQL*Loader的语法从10g到19c基本是延续的,12c没有把它重写,也没有颠覆性变化。但如果你正好用到了12c的新功能,有几个点值得注意:
- 表或分区启用了In-Memory列式存储后,通过直接路径加载的数据可以绕过Buffer Cache,直接为列式存储的构建或后台填充做好准备,对实时分析类环境友好。
- 12c引入的标识列(Identity Column),在控制文件里可以留空或配置生成逻辑,直接路径加载不会像老版本那样因为缺少默认值而翻车。
- 目标字段如果是JSON类型,控制文件加载的路径比老版本先塞CLOB再转换省了一步。
- 对带有大分区、多分区的表,直接路径加载的稳定性明显好于10g、11g时代。
这些点不是让你为了新特性而去用,而是告诉你:如果你的数据库已经升级到12c,那么原来那些SQL*Loader脚本不需要大改,照样能跑,而且在新特性的边缘能白捡一点性能红利。
2. 控制文件是灵魂——读懂与手写
2.1 一个最小可运行的控制文件长什么样
几乎所有SQLLoader任务都从一个控制文件开始。这玩意儿不复杂,但它的语法非常“死板”,多一个空格、少一个分号,都可能直接报SQLLoader-350。先看一个最简版:
LOAD DATA INFILE 'users.csv' INTO TABLE t_user FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ( user_id, user_name, sign_time DATE 'YYYY-MM-DD HH24:MI' )对应数据文件可能是这样:
USER001,"某项目成员",2024-01-15 08:30 USER002,"普通用户",2024-01-15 09:10然后命令行执行:
sqlldr your_user/oracle@orcl control=users.ctl log=users.log bad=users.bad命令里your_user/oracle@orcl是数据库连接串,control指定控制文件路径,log是运行日志,bad是坏记录文件。如果一切顺利,日志末尾会显示多少行成功加载、多少行失败。这个流程就是SQL*Loader的最小闭环。
控制文件第一行LOAD DATA是固定头,别动它。INFILE指定要读取的数据文件。INTO TABLE后面跟目标表名。FIELDS TERMINATED BY ','告诉它字段之间用逗号分隔,OPTIONALLY ENCLOSED BY '"'表示每个字段可以被双引号包起来,但不是必须的。这两个配置组合在一起,就是处理标准CSV文件的黄金搭档。
括号里是字段列表。顺序就是数据文件里列的顺序,每行的名字默认对应目标表的列名。日期列必须显式给出格式掩码,否则它只会把字符串原样塞给目标列,等着数据库报ORA-01843。我见过太多人忽略这一句,然后去日志里对着报错发呆。
2.2 空值处理:NULLIF和DEFAULTIF不能省
数据文件里最脏的东西不是乱码,而是“看起来什么都没有,实际有内容”的字段。比如CSV里这么写:
USER003,,2024-01-15 10:00第二列是两个连续逗号之间的空字符串,如果目标列是VARCHAR2,倒还能插进去,如果目标列是NUMBER,直接报ORA-01722无效数字。更隐蔽的是,有的文件里空值变成了一串空格,或者被引号包住的空格串。
处理办法是在字段后面加NULLIF条件:
( user_id, user_name NULLIF (user_name = BLANKS), sign_time DATE 'YYYY-MM-DD HH24:MI' )NULLIF (user_name = BLANKS)意思是:如果这个字段的值是空白(完全由空格组成或为空),就把它当成NULL处理。BLANKS是SQL*Loader里专门表示空白的条件判断,它在处理数字列之前的清洗场景下极其好用。
还有DEFAULTIF,它和NULLIF是兄弟关系。NULLIF是有条件地置NULL,DEFAULTIF是有条件地使用目标列的默认值。比如:
status CHAR(1) DEFAULTIF (status = BLANKS)如果status字段为空,就用表定义时这个列的默认值来填充。这个特性在表结构里已经配了DEFAULT约束时特别省事,不用在控制文件里写死值。
2.3 FILLER字段和POSITION定长格式
CSV之外的另一个常见场景是定长文件。你在银行、运营商、物流行业经常能拿到这种格式,比如每行规定前10位是用户ID,11到20位是手机号,21到50位是备注。这种文件用分隔符是没法处理的,得用POSITION指定起止位置:
LOAD DATA INFILE 'members.dat' INTO TABLE t_member ( user_id POSITION(01:10), mobile POSITION(11:20), remark POSITION(21:50) )POSITION(01:10)表示截取每一行的第1到第10个字符作为user_id的值。这个功能在处理老系统导出文件时几乎是救命稻草,因为定长文件没有逃逸规则,也没有引号干扰,对齐就是一切。
如果源文件里有些列你不需要,用FILLER关键字占位:
( user_id POSITION(01:10), temp_field POSITION(11:20) FILLER, mobile POSITION(21:30) )FILLER字段会被读取但不会写入目标表,相当于“读取并跳过”。这个关键字在你有100列文件但目标表只要其中30列时,能让控制文件保持清晰。
2.4 一个物理行对应多个逻辑记录
这算是控制文件的进阶玩法。有的数据文件不是“一行一条记录”,而是“几行拼成一条记录”,或者“一行里有好几条记录”。前者用CONTINUEIF:
LOAD DATA INFILE 'multi_line.dat' CONTINUEIF NEXT PRESERVE (1 = 1) INTO TABLE t_event ( event_id, event_desc )意思是如果读取下一行时满足了某种条件,就把这行拼到上一条逻辑记录后面。更简单的场景是用CONCATENATE 2,直接把连续的两行合并成一条逻辑记录。不过说实话,这种文件在实际业务里不多,只要知道SQL*Loader接得住,别到时候被吓住就行。
3. 一次完整的导入实操:从CSV到表
3.1 场景设定与表结构准备
我拿一个实际做过的项目来举例。当时某支付清算项目要每天从合作方收到一份用户标签文件,CSV格式,GBK编码,行数在20万到80万之间浮动。目标表结构建在Oracle 12c里:
create table t_user_tag ( user_id varchar2(32), tag_code varchar2(20), tag_value number(10,2), sign_time date, batch_no varchar2(20), remark varchar2(500) );注意我加了一个batch_no字段,用来记录这是哪一批导入的数据,这个字段在控制文件里用CONSTANT生成,源文件里并没有这一列。这类“批次号”“来源标识”是非常实用的设计,以后排查数据归属时一眼就能看出来。
数据文件user_tags.csv的典型行是:
USER001,TAG_DIAMOND,128.50,2024-01-15 08:30:00,备注信息可能包含逗号,"这里的引号会被正确解析" USER002,TAG_SILVER,30.00,2024-01-15 09:10:00,,第5列和第6列可能为空,甚至连续两个逗号之间什么都没有。这种“最后几列空着”的情况,如果控制文件不处理,会出现字段数不够的报错。
3.2 编写并执行控制文件
基于这个场景,控制文件我这样写:
LOAD DATA CHARACTERSET ZHS16GBK INFILE 'user_tags.csv' INTO TABLE t_user_tag APPEND FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' TRAILING NULLCOLS ( user_id, tag_code, tag_value NULLIF (tag_value = BLANKS), sign_time DATE 'YYYY-MM-DD HH24:MI:SS', batch_no CONSTANT 'BATCH_20240115', remark )几个关键点拆开讲:
CHARACTERSET ZHS16GBK显式声明数据文件的字符集。这一步不能省,尤其是你的数据库字符集是AL32UTF8时,如果环境变量NLS_LANG没有配好,SQL*Loader会按照操作系统默认字符集去读文件,中文备注很可能变成乱码。显式声明能避免这个坑。
APPEND表示追加模式。如果表里已经有数据,不会清空,新数据直接追加。对应还有INSERT(要求表为空)、REPLACE(先删除表中所有行再加载)、TRUNCATE(先TRUNCATE表再加载)。全量替换文件的场景,我推荐用TRUNCATE,因为它比REPLACE的逐行DELETE快得多,但要注意TRUNCATE之后如果加载失败,老数据也没了,使用前要权衡。
TRAILING NULLCOLS的作用是:当数据行里的字段数少于控制文件里声明的字段数时,自动把缺失的字段补成NULL。比如上面例子里的最后两列是空的,没有这个语法,SQL*Loader会报“记录中的字段数不足”。加上它,文件里那些“尾巴上缺字段”的行就能被稳稳接住。
batch_no CONSTANT 'BATCH_20240115'是固定值填充。每一条被加载的行,batch_no都会写入这个常量,后面做数据回溯时直接按批次号查就行。
执行命令:
NLS_LANG=AMERICAN_AMERICA.ZHS16GBK sqlldr your_user/oracle@orcl \ control=user_tags.ctl log=user_tags.log bad=user_tags.bad \ discard=user_tags.dsc这里我把NLS_LANG也在命令行显式指定了,双重保险。discard后面是丢弃文件,被WHEN条件过滤掉的行会写到这里,和坏记录文件是两回事。这个文件我没在控制文件里写WHEN过滤条件,但因为加了discard参数,一旦以后加了条件,能直接看到被丢弃的行。
3.3 执行后的验证与增量加载
加载完先别急着高兴,去日志文件看两个数字:
Table t_user_tag: 780000 Rows successfully loaded. 12 Rows not loaded due to data errors.“Rows successfully loaded”必须和源文件的有效行数对上。“Rows not loaded”必须能解释清楚,它们在bad文件里。我当时的习惯是加载完立刻跑一条SQL:
select count(*) from t_user_tag where batch_no = 'BATCH_20240115';用批次号核对,一目了然。
如果业务上不是全量替换,而是每天增量更新,常见做法不是直接往正式表里灌,而是先加载到一张临时表,再用MERGE写进正式表:
merge into t_user_tag_target t using user_tag_stage s on (t.user_id = s.user_id and t.tag_code = s.tag_code) when matched then update set t.tag_value = s.tag_value, t.sign_time = s.sign_time when not matched then insert (user_id, tag_code, tag_value, sign_time, batch_no) values (s.user_id, s.tag_code, s.tag_value, s.sign_time, s.batch_no);SQL*Loader管的是“把文件变成临时表里的数据”,正式表的去重、更新、历史归档,交给SQL来做。这种分工是我在十几个项目里验证下来最稳的组合。
4. 12c下的提速手段与高级玩法
4.1 直接路径到底有多快,代价又是什么
默认的SQL*Loader是传统路径加载,相当于程序帮你生成了很多条INSERT语句,一条条提交。这种方式走SQL引擎、写Buffer Cache、产生UNDO,慢是必然的。数据量大时,唯一的正解是直接路径加载,命令里加参数:
sqlldr your_user/oracle@orcl control=user_tags.ctl \ log=load.log bad=load.bad direct=true直接路径的底层逻辑是:构造好Oracle数据块格式的数据,绕过SQL引擎和Buffer Cache,直接在表的高水位线以上插入新块。它跳过了大量原本开销巨大的环节,所以加载速度能比传统路径快一个数量级。
但是代价得说清楚,不然你会在生产环境被坑:
- 表上如果有活动事务,直接路径加载会报错。需要确保加载窗口内没有会话在改这张表。
- 触发器不触发。如果业务依赖表的DML触发器去做审计或者级联操作,直接路径会直接绕过。
- 引用完整性约束可能不被检查。
- 唯一索引如果遇到重复值,报错的行为和传统路径不同,有时会把整个加载中止。
- 直接路径会推进表的高水位线,换句话说,表占用的物理空间会涨,即使逻辑上有些块是空的。
所以我的经验是:大表有多个普通索引时,加载前先DROP索引,加载完再重建。听着有点反直觉,但全表扫描灌几百万行数据时,卸载索引再重建往往比一边灌一边维护索引快得多,尤其12c的并行索引重建很快。
4.2 真正有用的并行加载配置
很多人以为加一个parallel=true就万事大吉,其实不是。parallel=true只是允许SQL*Loader在直接路径模式下进行某种程度的并行处理,真正的大规模并行来自手动分片:把数据文件分成多份,同时起多个sqlldr进程,每个进程加载一部分。
实操上我是这么做的:
- 把80万行的文件用
split命令按行数切成四份。 - 给每份文件单独写一个控制文件,语法完全相同,只有
INFILE文件名不同。 - 写一个Shell脚本,后台依次启动四个管道中的sqlldr进程。
sqlldr user/pass control=part1.ctl direct=true parallel=true & sqlldr user/pass control=part2.ctl direct=true parallel=true & sqlldr user/pass control=part3.ctl direct=true parallel=true & sqlldr user/pass control=part4.ctl direct=true parallel=true & wait注意,parallel=true需要目标表上没有活动的修改操作,并且几路加载同时往同一张表追加数据,靠数据库的并行机制协调。这个模式出来的速度,我用单机环境实测,比单纯direct=true还能再快两三倍。瓶颈往往在磁盘I/O和归档日志生成速度上。
还有一个参数值得记:rows。在传统路径下,它控制每多少行提交一次,默认是64。如果你因为某些原因必须用传统路径,把rows调大能明显减少提交次数:
sqlldr ... control=xxx.ctl rows=5000不过记住,直接路径下rows失效,它是传统路径专用参数。
4.3 数据过滤:WHEN、表达式和外部表兜底
控制文件里可以在INTO TABLE前加WHEN条件,做行级过滤。比如我只想加载状态为正常的数据:
INTO TABLE t_user_tag WHEN (status = 'NORMAL')满足条件的行进入目标表,不满足的行写到discard文件。这个机制是用来做“加载前过滤”的,比读入后再DELETE一套SQL要高效。
字段级转换也很有用,控制文件支持用SQL表达式处理字段值:
( user_id, mobile "REGEXP_REPLACE(:mobile, '[^0-9]', '')" )这个写法里,字段名后面跟一个双引号包起来的SQL表达式,字段值用:字段名引用。比如上面的表达式就去掉了手机号里的非数字字符。日期清洗、字符串截断、大小写转换都可以这么做。
如果清洗逻辑实在太复杂,超过了几条表达式能表达的范围,我的建议是别硬用SQL*Loader的表达式硬扛,改用外部表方案。把CSV挂成Oracle外部表,然后用SQL的MERGE、多表JOIN、CASE WHEN去做清洗转换,最后INSERT进正式表。12c的外部表并行访问已经很成熟,这个方案在需要大量业务判断的ETL场景里,可读性和维护性远好于揉成一团的控制文件表达式。
5. 常见问题速查与排障手记
5.1 高频报错与解决对照
下面这张表是我在实际运维中整理的高频报错速查,尤其是12c环境里遇到的,基本涵盖了80%的情况:
| 报错或现象 | 常见原因 | 处理方式 |
|---|---|---|
| SQL*Loader-304 记录长度超过最大值 | 数据行因为引号不配对或字段分隔符错乱,被读成超长行 | 检查源文件是否有全角逗号、没闭合的引号;确保行结束符统一为LF或CRLF |
| ORA-01722 无效数字 | 数值字段里混入了空字符串、空格、不可见字符 | 给数值列加NULLIF (字段 = BLANKS);清洗源文件;检查字段是否被错误的逗号切分 |
| ORA-12899 列值过长 | 源字段长度超过目标列定义长度 | 在控制文件里用SUBSTR(:字段, 1, n)截断;或加长目标列 |
| ORA-26002 直接路径加载失败 | 目标表存在活动事务或正在被修改 | 确保加载窗口内表处于静止状态;必要时在维护窗口操作 |
| ORA-1400 无法插入NULL | 非空列没有提供值 | 用CONSTANT固定值、DEFAULTIF调用默认值,或先确认数据源完整性 |
| SQL*Loader-350 控制文件语法错误 | 关键字拼错、字段列表括号不匹配、文件换行异常 | 打开控制文件逐行检查;从网上复制的控制文件要重新确认换行符 |
ORA-01722是我遇到最频繁的。90%的情况不是源文件真的有问题,而是CSV里某个空字段在数值列上变成了空白串。SQL*Loader处理空串和NULL是两个完全不同的行为,空串塞进NUMBER列就会无效数字,这个坑不踩一次不会长记性。
5.2 排障三板斧:LOG、BAD、DISCARD
加载失败后第一件事,永远是先看日志文件,不是先改SQL。
log文件记录了完整的加载过程,加载了多少行、失败了多少行、失败的具体错误码都会出现在末尾。bad文件保留了解析失败的原始记录,一行对应源文件里一个有问题的数据行。你可以直接打开看是第几行、长什么样。discard文件则不同,它记录的是被WHEN条件“合法过滤掉”的行,不是错误行。
很多新手会把discard和bad混在一起,其实区别很清晰:bad是“想处理但处理不了”,discard是“按规则主动不要”。如果你在控制文件里配了WHEN条件,最好显式指定discard文件,不然过滤行都丢了,你想复核都没机会。
排查时我的习惯流程是:
- 拉出log文件末尾的加载统计。
- 如果bad文件有内容,取第一条记录,对照源文件所在行。
- 用编辑器打开该行,开启“显示所有字符”功能,查有没有尾随空格、制表符、回车换行混用。
- 用SQL*Loader单独加载这一行到一张结构相同的测试表,复现错误。
- 确认修复逻辑后再全量加载。
这个流程我用了很多年,能在五分钟内定位绝大多数数据格式问题。
5.3 个人经验和真正的优化建议
最后说点我在多个项目里沉淀下来的经验,你可以直接抄作业。
第一,控制文件必须进版本库。它和SQL脚本一样是工程资产,不是临时工具。哪天数据文件格式发生调整,你还能翻出历史控制文件对比差异。
第二,字符集永远显式指定。我不止一次因为图省事没写CHARACTERSET,结果加载完发现中文全部是乱码。这种数据错误比加载失败还危险,因为日志里看不出任何问题,直到业务方在用数据时才发现。
第三,大批量导入前,先做一个小文件试跑。不用多,几百行就够。重点看log文件里的错误行和bad文件,确认控制文件和源文件格式完全对应后,再放开跑全量。这一步节省的时间远超试跑的几分钟。
第四,直接路径加载完,关注一下表的高水位线。如果一次性灌入百万行级别数据,这个表后续的索引重建、统计信息收集都要跟上。别忘了执行:
exec dbms_stats.gather_table_stats(user, 'T_USER_TAG', cascade => true);统计信息不更新,12c的优化器很容易在后续查询里选错执行计划。
第五,加载是IO密集型任务,别挑业务高峰期。普通传统路径会占用大量Buffer Cache,直接路径也会带来大量归档日志。给DBA留点面子,把这些活安排到维护窗口做,大家都会感谢你。
SQL*Loader这个工具,看着不起眼,关键时刻能顶半边天。掌握控制文件的几个关键语法,理解直接路径的取舍,学会看日志排错,你基本就能从容应对日常90%以上的批量导入需求了。