☰
PL/SQL导入CSV实战:外部表、SQL*Loader与UTL_FILE选型及避坑
2026/11/3 12:29:35 网站建设 项目流程

业务群甩过来一个 CSV,附一句“数据不大,直接导进去就行”——这大概是每个在 PL/SQL 环境里摸爬滚打的人都听过的经典开场白。等你真正打开文件,才发现“不大”是三十万行、八十兆,“直接导进去”意味着你得先摆平文件编码、字段分隔、双引号包裹、内嵌逗号、CRLF 行尾、日期格式、空串与 NULL 的区别这一连串问题。在 PL/SQL 里导入 CSV 文件,从来不是敲一条命令就能收工的事,它更接近一套需要提前设计的小型数据管道:文件怎么读、脏数据往哪放、失败怎么重来、导完怎么对账,每一步都得想清楚。

这篇内容就是围绕“PL/SQL 导入 CSV”这个具体场景展开的。我把它定位成一份可以直接照着做的操作手册,同时也是一份踩坑记录,适合三类人看:日常要给业务系统灌数据的开发同学、偶尔被拉去救火的 DBA,以及需要把手工导数据流程固化成脚本的数据工程从业者。如果你只会用图形化工具点几下导入,看完至少能搞明白那些“为什么导进去少了三百行”“为什么金额字段变成了科学计数法”的问题出在哪;如果你已经有外部表或者 SQL*Loader 的使用经验,后面关于编码、坏文件、幂等重导和纯 PL/SQL 解析状态机的部分,应该还能捞到点东西。

1. 先把路选对:PL/SQL 环境里导入 CSV 的五条通道

1.1 五种方案的能力边界对比

很多人一上来就问“用哪个函数能导 CSV”,这个问题本身就问偏了。PL/SQL 里没有所谓的“导入 CSV 函数”,你面对的是若干条从文件到表的通道,每一条的权限要求、性能特征、能处理的脏数据类型完全不同。选错通道,后面八成时间都花在打补丁上。

方案适用数据量级是否需要 DIRECTORY 对象能否处理字段内换行落库后能否直接查询主要限制
外部表(ORACLE_LOADER)万级到千万级需要不能能,建完即查依赖目录对象,坏行只能看日志
SQL*Loader(sqlldr 命令行)十万级到上亿级需要不能需单独加载语句在服务器端执行,控制文件要调好
UTL_FILE 存储过程万级以内需要可以自己做能性能最差,逻辑要自己写全
图形化导入向导万级以内不需要视工具而定能无事务控制,大文件容易崩
DBMS_CLOUD(云上库)十万级以上不需要视格式而定能仅限支持该包的环境

这张表我建议你贴在工位上。它的核心信息是:数据量越大,越应该往“外部表”和“SQL*Loader”这两条路上靠;数据越脏、逻辑越复杂,才越值得动用 UTL_FILE 手写解析。图形化向导属于“一次性救急”的工具,别把它写进任何需要重复执行的流程里。

1.2 一个能落地的选型判断法

我自己的判断顺序是这样的,你可以直接套用。第一步看频次:这个文件是一次性的还是每天/每周都要来?如果是一次性的,量又不大,图形化向导最省事,十分钟搞定,别折腾。第二步看量级:超过十万行,向导基本可以排除,因为它在客户端逐条拼 INSERT,速度会掉到每分钟几千行,而且中途断了没有断点。第三步看数据质量:如果 CSV 是自家系统导出的、格式高度可控,外部表一行CREATE TABLE就能搞定;如果是对外收集的、里面混着各种引号和不规则换行,那就得先做文件级清洗,再进加载环节。第四步看是否需要留痕:审计要求高的场景,SQL*Loader 的 badfile 和 discardfile 机制天生比手写脚本更规范,出问题能直接拿出证据文件。

还有一条容易被忽略的判断依据:这个操作最终是谁来执行。如果执行人是业务方,那你能给的只有工具和步骤文档;如果执行人是自动化调度,那就必须选可脚本化、有明确退出码的方案,SQL*Loader 的返回码和日志文件在这里就很有优势,而图形化向导完全无法纳入调度。

1.3 先明确“导入”的终点在哪

这一条是我踩过坑之后才想明白的。所谓“把 CSV 导入数据库”,终点其实有三个层次:第一层是文件内容变成一张能 select 出来的表;第二层是这张表的数据通过了业务校验(主键不重复、金额能对上、日期在合理区间);第三层是数据已经合并进正式业务表并且可回滚。绝大多数事故发生在“第一层成功就以为完事了”,结果三天后对账发现少了一批。

所以我的习惯是:任何一次 CSV 导入,都必须先落到一张临时表(STG_ 前缀),校验通过后再用 MERGE 进正式表。这一步看起来是多余的,但它把“文件解析”和“业务写入”这两件完全不同的事解耦了,出问题时能立刻定位是哪一段的锅。

2. 动手前必须做的一件事:给 CSV 做体检

2.1 用十六进制看清编码、BOM 与行尾

我见过太多人对着一个“看起来很正常”的 CSV 调半天,最后发现文件是 GBK 编码,而数据库是 AL32UTF8,中文全变问号。所以在写任何加载脚本之前,先在文件所在的服务器上做三个检查,命令都不复杂:

# 看文件类型和编码猜测 file -i /data/csv/sales.csv # 看前 16 个字节,重点确认有没有 BOM hexdump -C -n 16 /data/csv/sales.csv # 看行尾是 LF 还是 CRLF hexdump -C /data/csv/sales.csv | head -5

输出里如果开头是ef bb bf,说明这是带 BOM 的 UTF-8 文件。BOM 本身不可见,但它会粘在第一列的第一个字符上,导致第一列如果是数字,直接报 ORA-01722 无效数字;如果是字符串,你会发现第一行的那条记录怎么都关联不上。处理办法有两个:加载时用SKIP=1连同表头一起跳过(前提是文件确实有表头),或者在服务器上用sed -i '1s/^\xEF\xBB\xBF//'去掉。

行尾的判断同样关键。Windows 导出的是0D 0A(CRLF),有些系统导出的是纯0A(LF),还有极少数老系统用0D。如果加载工具按0A断行而文件是 CRLF,你的每一行末尾都会多一个不可见的\r,字符串字段比较时会莫名其妙不相等,日期字段转换时会报格式错误。在 SQL*Loader 里这个问题的标准解法是在 INFILE 后面显式声明记录分隔串,外部表则相对宽容,但也不是完全免疫。

2.2 双引号、内嵌逗号与内嵌换行:CSV 不是“按逗号切一刀”

这是最容易被低估的一点。CSV 有自己的格式约定,一个字段如果包含逗号、双引号或换行,就会整体被双引号包起来,字段内部的双引号则写成两个连续双引号来转义。看下面这行:

1001,"某商贸公司,华东分公司","备注里说了""特殊""情况",12800.50,2024-03-15

如果你用INSTR找逗号再SUBSTR,第二个字段会被切成“某商贸公司”和“华东分公司”两段,后面所有列全部错位。正确做法是识别引号状态:遇到引号进入引用段,引用段内的逗号不算分隔符,连续两个引号还原成一个引号,退出引用段后才恢复按逗号切分。

正规的加载工具都实现了这套规则,OPTIONALLY ENCLOSED BY '"'这一句就是干这个的。但要注意,外部表和 SQL*Loader 都处理不了“字段内部含换行”的情况,因为它们按行读取,一个引号内的换行会被当成记录结束。这类文件必须在入库前做预处理:要么写个脚本把内嵌换行替换成空格,要么承认这一列不重要直接丢弃,要么改用自己写的解析器整文件读取后再切分。

2.3 日期、金额、空值三处高频翻车点

日期的问题集中在格式上。2024-03-15、2024/3/15、15-MAR-2024、20240315,这四种格式在同一个文件的不同行里出现都不稀奇。加载工具的日期掩码只能写一种,所以要么在文件层面统一,要么把该列先读成字符串,落进临时表后再用TO_DATE配合多格式尝试转换。我更倾向于后者,因为临时表里保留原始字符串,出问题能直接看到原值,比在加载阶段报错要好排查得多。

金额的坑主要在千分位和货币符号。12,800.50这种带千分位的值,如果字段没有用引号包起来,会被当成两列;即使包起来了,直接转 NUMBER 也会失败。带¥、$前缀同理。这类字段建议在加载时按 VARCHAR2 接收,落表后用REPLACE清掉符号再转数字。

空值是最隐蔽的。CSV 里一个字段“什么都没有”,它到底是空字符串还是 NULL?在 Oracle 里,空字符串本身就等于 NULL,所以VARCHAR2列无所谓。但如果是数字列或日期列,一个空字段会导致整行被拒绝,除非你显式声明空值处理规则。SQL*Loader 里靠TRAILING NULLCOLS和NULLIF ... = BLANKS,外部表里靠MISSING FIELD VALUES ARE NULL加上NULLIF。这三个关键字看着不起眼,缺一个就能让你的导入行数少掉一大截。

3. 外部表:把 CSV 变成一张能直接 select 的表

3.1 DIRECTORY 对象与最小权限配置

外部表的前提是数据库能“看到”服务器上的那个目录。这里必须强调一次:PL/SQL 里的文件操作全部发生在数据库服务器上,不是你自己的电脑上。很多人把文件放在本地 C 盘,然后困惑为什么报“文件不存在”,因为服务器根本访问不到你的机器。

配置分两步走。先由一个具备相应权限的账号创建目录对象并授权:

CREATE OR REPLACE DIRECTORY csv_dir AS '/data/csv'; GRANT READ, WRITE ON DIRECTORY csv_dir TO app_user;

这两句里,路径必须是服务器上的绝对路径,并且数据库进程的操作系统用户对该目录要有读权限,否则后面会看到 KUP-04040 之类的报错。授权只给 READ 就够加载用了,WRITE 是为了让坏文件和日志能写进去,如果你的加载过程需要生成 badfile,那就得给。权限给多了不符合最小化原则,给少了调试时会浪费大量时间在“到底是路径问题还是权限问题”上。

一个实用技巧:先做个连通性验证,别急着建表。

DECLARE v_fh UTL_FILE.FILE_TYPE; BEGIN v_fh := UTL_FILE.FOPEN('CSV_DIR', 'sales.csv', 'R'); UTL_FILE.FCLOSE(v_fh); DBMS_OUTPUT.PUT_LINE('目录与文件均可访问'); EXCEPTION WHEN OTHERS THEN DBMS_OUTPUT.PUT_LINE('访问失败: ' || SQLERRM); END; /

这段跑通了,说明目录对象、路径、权限、文件名四件事全对,后面再出问题就只可能是内容格式问题,排查范围一下子缩小很多。

3.2 ACCESS PARAMETERS 逐项拆解

外部表的核心全在ACCESS PARAMETERS那段里,它决定了文件怎么被理解。给一个可以直接改吧改吧就用的完整例子:

CREATE TABLE ext_sales ( order_id NUMBER, customer VARCHAR2(200), amount NUMBER, order_date DATE, remark VARCHAR2(2000) ) ORGANIZATION EXTERNAL ( TYPE ORACLE_LOADER DEFAULT DIRECTORY csv_dir ACCESS PARAMETERS ( RECORDS DELIMITED BY NEWLINE CHARACTERSET AL32UTF8 BADFILE csv_dir:'sales_%p.bad' LOGFILE csv_dir:'sales.log' FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' MISSING FIELD VALUES ARE NULL ( order_id, customer, amount, order_date CHAR(10) DATE_FORMAT DATE MASK 'YYYY-MM-DD', remark ) ) LOCATION ('sales.csv') ) REJECT LIMIT UNLIMITED;

逐项说一下为什么这么写。CHARACTERSET指的是文件本身的字符集,不是数据库字符集,写错了中文就是乱码,常见取值是AL32UTF8和ZHS16GBK。BADFILE和LOGFILE里的%p是并行度占位符,串行加载时它会被替换成 0,加上这个后缀主要是为了方便多文件并行时区分日志。FIELDS TERMINATED BY ','配OPTIONALLY ENCLOSED BY '"'是标准 CSV 组合,注意OPTIONALLY这个词不能省,省了就变成“必须带引号”,不带引号的字段会读失败。

MISSING FIELD VALUES ARE NULL处理的是“行尾字段缺失”的情况,比如某行只有四个字段而表有五列,没有这句会直接报错。REJECT LIMIT UNLIMITED表示坏行不阻断整体查询,坏行会被写进 badfile,同时你能通过日志看到具体是哪一行、什么原因。这个设置在生产环境调试阶段非常有用,但正式跑批时我建议改成具体数字,比如REJECT LIMIT 100,超过一百行坏数据就整体失败,避免“导入成功但少了一半”的假象。

3.3 字段映射、NULLIF 与日期数字转换

外部表的字段定义区里,列名后面的类型声明才是“转换层”。默认情况下所有字段都按字符读入,然后隐式转换成表定义的类型,隐式转换一旦失败就是 ORA-01722 或者 ORA-01861。显式声明能把这个过程变得可控。

日期字段的写法有两种,一种是order_date CHAR(10) DATE_FORMAT DATE MASK 'YYYY-MM-DD',先按十个字符读入,再用掩码转成 DATE;另一种是order_date DATE "YYYY-MM-DD",效果类似但语法更紧凑。两种都可以,我个人用前者多一些,因为CHAR(10)的长度限制能提前拦住那些被截断的脏数据。

数字字段如果存在空值或者带特殊符号,可以这样处理:

amount CHAR(30) NULLIF amount = BLANKS

NULLIF ... = BLANKS的意思是“如果这个字段读进来全是空格,就当 NULL”。这个技巧对“行末尾多了一个逗号导致最后一列是空串”的场景特别有效。反过来,如果某一列在文件里是固定占位的几个空格当 NULL 用,也是同样的写法。

还有个小细节:外部表建完之后,SELECT * FROM ext_sales是即时生效的,不占存储空间,因为它每次查询都在读文件。这意味着如果你在两次查询之间文件被覆盖了,查出来的结果会变。我遇到过一次,两个团队同时在用一个目录,A 团队导入完把文件挪走了,B 团队再查就报错。后来我们约定:外部表目录只读不写、文件用完移到归档目录,才彻底解决。

4. SQL*Loader:大批量、可重复、带证据文件的正式方案

4.1 控制文件的骨架与关键参数

数据量上了百万级,外部表查询加INSERT INTO ... SELECT的方式就开始吃力了,因为中间有一次完整的行迁移。这时候该上 SQL*Loader。它的控制文件是一份纯文本的描述,把所有规则写清楚,命令行一句sqlldr user/pass control=xxx.ctl就开跑。

OPTIONS (SKIP=1, ERRORS=5000, ROWS=10000, BINDSIZE=1048576, DIRECT=TRUE) LOAD DATA CHARACTERSET AL32UTF8 INFILE '/data/csv/sales.csv' "STR X'0D0A'" BADFILE '/data/csv/sales.bad' DISCARDFILE '/data/csv/sales.dsc' APPEND INTO TABLE stg_sales FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' TRAILING NULLCOLS ( order_id, customer, amount, order_date DATE "YYYY-MM-DD", remark )

SKIP=1用来跳过表头,这也是处理 BOM 的一个顺手办法。INFILE后面那个"STR X'0D0A'"是显式声明记录分隔串为 CRLF,专治 Windows 导出的文件;如果是 Unix 的 LF,就改成"STR X'0A'"。这一步很多人不知道,结果每次导入每行末尾都带一个回车符,字符串比较全都不相等,查了两天才发现。

4.2 ROWS、BINDSIZE、DIRECT 三个参数怎么定

这三个参数直接决定加载速度,值得单独说。

ROWS是每次提交的行数,默认 64,这个默认值小得可怜。常规路径下每次提交意味着一次事务落盘加一次索引更新,64 行提交一次等于白白消耗了大量时间。我的经验值是 10000 到 50000 之间,视单行大小调整,行宽大就往小调,行窄可以往大调。

BINDSIZE是绑定数组的字节数,它和ROWS配合工作。如果BINDSIZE不够大,实际生效的ROWS会被压缩,你在日志里看到的“提交点”会比设定的频繁。一万行、每行两百字节的话,BINDSIZE设在 2M 到 4M 比较合适。判断依据是加载日志里的Total logical records skipped/read/rejected和提交次数,提交次数明显偏多就说明绑定量没喂饱。

DIRECT=TRUE是直接路径加载,绕过常规的 SQL 处理路径,直接把数据块写进段里。它的速度提升在百万级以上非常明显,但代价必须说清楚:不触发触发器、加载期间表被排他锁定、索引的维护成本会转移到加载过程。如果表上索引很多,直接路径反而可能变慢,这时候更聪明的做法是先把索引和约束置于不可用状态,加载完再重建。另外,直接路径加载不会因为主键冲突而拒绝整行,它默认是先加载再检查,所以加载前的去重工作一定要做好。

4.3 坏文件、丢弃文件与错误上限

SQL*Loader 最让我满意的一点是它天然留下了“证据”。加载结束后你会得到三份东西:日志文件(.log)、坏文件(.bad)、丢弃文件(.dsc)。坏文件里装的是格式不合规、被数据库拒绝的行,原始内容原样保留;丢弃文件里装的是被WHEN条件过滤掉的行,也就是“语法没问题但业务上不需要”的数据。这两个文件是排查问题的第一现场,比你去翻源文件猜是哪一行要高效得多。

配ERRORS=5000的意思是允许五千行失败,超过就中止。这个值不要设得太随意:设成 0,那怕只有一行脏数据也会中断,几十万行的加载前功尽弃;设成很大,可能导完了你才发现中间断了一大段,还以为成功了。我的做法是分批试跑:先拿一千行小文件跑通,确认零错误后再全量跑,全量时把ERRORS设成一个你能接受的比例,比如总行数的千分之一。

5. 纯 PL/SQL 解析:什么时候值得自己写

5.1 UTL_FILE 逐行读取的骨架

有些场景外部表和 SQL*Loader 都覆盖不了。比如:字段内部有换行必须原样保留;或者导入过程中要同步调用业务逻辑做校验;或者文件路径是动态生成、不能在控制文件里写死。这时候只能自己动手,用 UTL_FILE 一行行读。

DECLARE v_fh UTL_FILE.FILE_TYPE; v_line VARCHAR2(32767); v_cols SYS.ODCIVARCHAR2LIST; v_cnt PLS_INTEGER := 0; BEGIN v_fh := UTL_FILE.FOPEN('CSV_DIR', 'sales.csv', 'R', 32767); LOOP BEGIN UTL_FILE.GET_LINE(v_fh, v_line, 32767); EXCEPTION WHEN NO_DATA_FOUND THEN EXIT; END; v_cnt := v_cnt + 1; IF v_cnt = 1 THEN CONTINUE; -- 跳过表头 END IF; v_cols := split_csv_line(v_line); INSERT INTO stg_sales (order_id, customer, amount, order_date, remark) VALUES ( TO_NUMBER(TRIM(v_cols(1))), TRIM(v_cols(2)), TO_NUMBER(REPLACE(TRIM(v_cols(3)), ',', '')), TO_DATE(TRIM(v_cols(4)), 'YYYY-MM-DD'), TRIM(v_cols(5)) ); IF MOD(v_cnt, 1000) = 0 THEN COMMIT; END IF; END LOOP; COMMIT; UTL_FILE.FCLOSE(v_fh); EXCEPTION WHEN OTHERS THEN IF UTL_FILE.IS_OPEN(v_fh) THEN UTL_FILE.FCLOSE(v_fh); END IF; RAISE; END; /

这段骨架里有几个地方值得特别注意。FOPEN的第四个参数是最大行长度,默认值只有一千多,超过就会被截断,所以务必显式给到 32767。GET_LINE读到文件末尾抛的是NO_DATA_FOUND,不是返回空串,所以异常处理里必须单独捕获它并退出循环,不能被外层的WHEN OTHERS吞掉。COMMIT的位置也有讲究,每一千行提交一次是性能和安全的平衡点,提交太频繁会拖慢速度,全程不提交又会在出错时丢掉全部成果。

5.2 写一个扛得住引号的拆分函数

前面说的“按逗号切”是不够的,必须处理引号状态。下面这个函数我用了很久,逻辑是逐字符扫描,维护一个“是否在引号内”的布尔状态,遇到连续两个引号就还原成一个:

CREATE OR REPLACE FUNCTION split_csv_line ( p_line IN VARCHAR2, p_sep IN VARCHAR2 DEFAULT ',' ) RETURN SYS.ODCIVARCHAR2LIST IS v_out SYS.ODCIVARCHAR2LIST := SYS.ODCIVARCHAR2LIST(); v_buf VARCHAR2(32767); v_inq BOOLEAN := FALSE; v_len PLS_INTEGER := NVL(LENGTH(p_line), 0); v_i PLS_INTEGER := 1; v_ch VARCHAR2(1); BEGIN WHILE v_i <= v_len LOOP v_ch := SUBSTR(p_line, v_i, 1); IF v_ch = '"' THEN IF v_inq AND SUBSTR(p_line, v_i + 1, 1) = '"' THEN v_buf := v_buf || '"'; -- 转义的双引号 v_i := v_i + 2; CONTINUE; END IF; v_inq := NOT v_inq; -- 进出引用段 ELSIF v_ch = p_sep AND NOT v_inq THEN v_out.EXTEND; v_out(v_out.LAST) := v_buf; v_buf := NULL; -- 空字段保留为 NULL ELSE v_buf := v_buf || v_ch; END IF; v_i := v_i + 1; END LOOP; v_out.EXTEND; v_out(v_out.LAST) := v_buf; -- 最后一列 RETURN v_out; END; /

用WHILE而不是FOR的原因是要在转义引号处手动跳两个字符,FOR循环的索引没法这么控制。另外注意p_sep只支持单字符分隔,如果你的文件是分号或竖线分隔,改这个参数就行;如果是多字符分隔,这个函数得再改造一下。函数返回SYS.ODCIVARCHAR2LIST这样一个集合,调用时用v_cols(1)取值,直观而且不需要额外建类型。

5.3 异常隔离、批量提交与断点续跑

手写解析最大的风险是一行脏数据把整个循环打断,前面几千行的成果全丢。所以每一行的处理应该包在自己的BEGIN ... EXCEPTION里,把失败的行连同原始内容写进一张错误表,然后继续处理下一行:

BEGIN INSERT INTO stg_sales (...) VALUES (...); EXCEPTION WHEN OTHERS THEN INSERT INTO stg_sales_err (line_no, raw_line, err_msg) VALUES (v_cnt, SUBSTR(v_line, 1, 4000), SQLERRM); END;

这样一来,主流程不会中断,错误行又完整留存,导完之后扫一眼stg_sales_err就知道该修哪几行。至于断点续跑,最简单的做法是在表里记录“已处理到第几行”,下次从该行之后开始读;或者更稳妥地用一个文件哈希加状态表,判断这个文件是否已经导过、导到哪一步。这一步听起来麻烦,但真遇到两百万行的文件导到一半超时,你会庆幸自己提前做了这件事。

6. 图形化向导与云上方案:顺手但不适合当主力

6.1 导入向导的三个典型坑

各类客户端工具都带导入向导,操作路径大同小异:右键目标表,选导入数据,选文件,预览,映射列,执行。它适合几十行到几千行、一次性、对时延没要求的场景。但它有几个坑必须知道。

第一个坑是编码选择。向导里通常有个编码下拉框,默认值不一定和你的文件一致,选了错的编码,中文全变问号,而且这种错误不会报异常,你看到的是“导入成功”。第二个坑是日期格式,向导会依据前几行样本推断格式,如果文件里前几行恰好都是YYYY-MM-DD而后面混了YYYY/MM/DD,推断出来的格式会把后面的行全部判为错误。第三个坑是事务控制,向导内部对每一行单独提交,中途失败时前面已经提交的部分无法回滚,你会得到一张“一半数据”的表。

我的建议是:用向导做数据探查可以,用它做正式导入不行。如果非要用,务必遵循两条规矩——先建临时表把数据导进去,校验完再合并;导入前先全表清空并把自动提交关掉。

6.2 云上环境的另一种思路

如果数据库是托管在云平台的,很多平台提供了对象存储直读的能力,比如通过DBMS_CLOUD这类包直接把存储桶里的 CSV 建成外部表,或者先复制到库内再加载。它的好处是绕开了“文件必须放在数据库服务器上”这个限制,你只需要把文件传到对象存储,给一个访问凭据就能读。

BEGIN DBMS_CLOUD.CREATE_EXTERNAL_TABLE( table_name => 'EXT_SALES', credential_name => 'CSV_CRED', file_uri_list => 'https://存储地址/sales.csv', format => json_object( 'type' VALUE 'csv', 'delimiter' VALUE ',', 'skipheaders' VALUE 1, 'characterset' VALUE 'AL32UTF8'), column_list => 'order_id NUMBER, customer VARCHAR2(200), amount NUMBER, order_date DATE, remark VARCHAR2(2000)' ); END; /

这种方式的参数含义和本地外部表是一一对应的,只是把“目录 + 文件名”换成了“地址 + 格式描述”。要注意的是,格式描述里的skipheaders解决表头,编码仍然要单独指定,字符集写错的后果和本地版本完全一样。另外跨网络读取的延迟比本地磁盘高不少,做全表扫描时要留意性能,能提前过滤的条件尽量下推。

7. 导完之后:对账、去重与可回滚

7.1 三道对账,缺一不可

导入报“成功”不等于数据对。我固定做三道对账,加起来不超过五分钟,但拦下过好几次事故。

第一道是行数对账。源文件去掉表头和空行后的行数,必须等于临时表的行数加上错误表的行数。这三个数对不上,说明中间有行被静默丢弃了,常见原因是最后一行没有换行符导致读不到,或者某行字段数不足被整行拒了却没进错误表。

第二道是金额对账。把所有金额列求和,和源文件用表格软件或脚本算出来的合计比对,允许的误差就是小数位精度。这一道能抓出“数字被截断”“千分位没清干净”“小数位被四舍五入”这类问题,光看行数是发现不了的。

第三道是关键字段抽样核对。随机抽二十行,逐字段和源文件比对,重点看日期、字符串里的特殊字符、前导零。电话号码、编号这类字段如果在源文件里是001234,加载时按数字处理就变成了1234,前面那个零没了。这类问题在汇总对账里完全隐形。

7.2 重复导入怎么处理

同一个文件被跑了两遍,这是极高频的线上问题。调度重试、人工补跑、上游重发,任何一个环节都可能触发。所以导入流程必须自带幂等。

我的标准做法是:临时表不加唯一约束,允许重复进去;合并到正式表时用MERGE按业务主键匹配,存在则更新、不存在则插入。这样跑一百遍结果都一样。

MERGE INTO sales t USING ( SELECT order_id, customer, amount, order_date, remark FROM stg_sales WHERE order_id IS NOT NULL ) s ON (t.order_id = s.order_id) WHEN MATCHED THEN UPDATE SET t.customer = s.customer, t.amount = s.amount, t.order_date = s.order_date, t.remark = s.remark WHEN NOT MATCHED THEN INSERT (order_id, customer, amount, order_date, remark) VALUES (s.order_id, s.customer, s.amount, s.order_date, s.remark);

如果临时表里本身有重复(同一文件里同一个单号出现两次),MERGE会报“无法获取稳定行集”。这时候要先用ROW_NUMBER()按主键分区、取最新一条,或者在临时表阶段就做一次聚合。这一步别偷懒,直接合并的后果可能是报错,也可能是随机取一条,后者更危险。

至于回滚,最省事的方式是合并前先给正式表打快照,或者记录下本次合并影响的主键范围,出问题时按范围反向处理。真正规矩的做法是导入全程在一个大事务里,校验全部通过再提交,但这对大表的锁竞争不友好,实际项目管理里通常用“临时表 + 分期合并 + 快照”来折中。

8. 报错速查:这些错误码对应什么问题

报错大概率原因处理方向
KUP-04040 / KUP-04108文件找不到或无法打开检查目录对象路径、服务器文件权限、文件名大小写
ORA-29913外部表访问失败通常是目录对象权限没给到位,或日志文件写不进去
ORA-29280无效目录路径UTL_FILE 用的目录对象不存在或未授权
ORA-29283无效文件操作文件不存在、路径越界,或打开模式不对
ORA-01722无效数字某列混入了非数字内容,或列错位导致数字列读到了字符串
ORA-01861文字与格式字符串不匹配日期掩码和实际内容不一致,换成先读字符串再转
ORA-12899值对于列太长目标列定义比源数据短,或字符集导致字节数放大
ORA-01438值大于此列允许的精度金额列精度不够,或千分位没清
ORA-06502字符缓冲区太小UTL_FILE 的 max_linesize 给小了,或变量长度不足
SQL*Loader-524拒绝记录数超限坏行太多,先看 badfile 定位具体行

这张表我建议打印出来放手边。绝大多数“导不进去”的问题,答案都在这十行里。真正需要动脑的是那些不报错但结果不对的情况——少了几行、多了几行、某个字段被静默截断,这类才需要动用前面说的三道对账。

我个人在实际操作中的体会是,CSV 导入这件事的难点从来不在“会不会用某个工具”,而在“有没有在动手前花十分钟看清楚文件长什么样”。编码、行尾、引号、空值这四件事,只要提前用十六进制看一眼、用文本编辑器搜一下双引号的数量,后面能省掉大半的调试时间。另外一条经验是:永远先拿一百行的小文件跑通全流程,再上全量。这个习惯帮我躲过的坑,比任何技巧都多。最后分享一个我常备的小工具函数,把任意一行 CSV 和拆分结果打出来对照,格式不对的时候一眼就能看出是哪一列开始偏的:

DECLARE v_cols SYS.ODCIVARCHAR2LIST; BEGIN v_cols := split_csv_line('1001,"某公司,华东","备注""特殊""",12800.50,2024-03-15'); FOR i IN 1 .. v_cols.COUNT LOOP DBMS_OUTPUT.PUT_LINE(i || ' => [' || v_cols(i) || ']'); END LOOP; END; /

先用单行验证解析逻辑,再放到循环里跑全文件,排查效率会高一个数量级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询