1. 先分清:你说的“PL/SQL”到底指哪一层
做 PL/SQL 导入 CSV 这件事,先要理清一个前提:PL/SQL 本身并不能直接读取 CSV 文件。PL/SQL 是数据库内部的存储过程语言,它运行在数据库服务端,操作对象是表和游标,而不是某个目录下的文件。很多朋友在群里问“PL/SQL 怎么导入 CSV”,实际场景多半是这样:他打开了一个叫 PL/SQL Developer 的客户端工具,想把本地某个 data.csv 灌进数据库某张表,然后卡在分隔符、中文乱码、日期格式上。
这个区分非常关键,因为一旦你要解决的问题是“文件到表”,就存在至少四条完全不同的技术路线,它们分别运行在客户端、命令行、数据库服务端,性能和适用边界差异极大。选错路线,你会把一个五分钟能搞定的小事,耗成半天摸不着头脑的体力活。
我见过最典型的错配是:有人拿十几万行的文件,用客户端工具的文本导入器一行行提交,界面卡住半小时;也有人为了导一个二十行的配置表,去写控制文件、建目录对象、配并行,结果比手工 INSERT 还慢。所以这篇内容不按“工具说明书”的套路写,我按“什么场景选什么路线、每条路线的关键动作和坑在哪”来拆,不管你是刚接触数据库的新手,还是天天跟数据打交道的运维,都能直接照着抄。
1.1 客户端工具、语言、数据库三者不是一回事
把这三个东西掰开讲,后面就不会乱了。客户端工具负责把文件读进来、拼成 INSERT 语句或者走批量接口发到数据库,文件在你自己电脑上,网络传输、字符集转换都发生在这条链路上。SQL*Loader是数据库自带的命令行装载工具,它同时支持客户端模式和“文件放在服务器上”的模式,性能比一行行插入高得多。外部表是把服务器上的文件当成一张只读表来查,导入动作就变成了一条INSERT INTO ... SELECT,完全在数据库内部完成。
理解了这个分层,你就会明白为什么有些方案要求“文件必须在数据库服务器上”,有些方案对本地文件就能干活。这个限制不是刁难人,是数据流向决定的。举个例子,PL/SQL Developer 的文本导入器读的是你本机的路径,它当然只认本地文件;外部表是数据库进程去打开文件,那文件自然得在服务器文件系统上,还必须有目录对象做定位。
提示:动手前先想清楚“文件在哪、数据量多大、要不要重复执行”,这三个答案基本就锁定了方案,能省掉大量试错。
1.2 四条导入路线的能力对照
下面这张表是我自己整理后长期贴在便利贴上的版本,你可以直接对照自己的场景挑一条:
| 路线 | 文件位置 | 适合数据量 | 二次执行(脚本化) | 主要门槛 |
|---|---|---|---|---|
| 文本导入器(图形界面) | 本机 | 几千到几万行 | 差,靠手工操作或最后导出SQL | 分隔符、日期格式手工指定 |
| SQL Developer 导入向导 | 本机 | 几千到十万级 | 中等,可保存为脚本 | 类型推断容易出错 |
| SQL*Loader | 本机或服务器 | 十万到千万级 | 极好,一条命令跑完 | 要写控制文件,参数较多 |
| 外部表 | 服务器 | 十万到千万级 | 好,DDL + INSERT | 要建目录对象,需相应权限 |
| UTL_FILE + 纯PL/SQL | 服务器 | 万行以内 | 好,封装成过程 | 自己解析CSV,转义处理麻烦 |
用一句话概括我的偏好:一次性的小数据,图形化最省事;需要反复跑的批量数据,SQL*Loader 或外部表;要在存储过程里做数据清洗和业务校验的,UTL_FILE 走一遍内存处理再落表。
2. 选型不靠感觉:三个维度定方案
2.1 数据量、文件位置、执行频率
数据量直接决定你要不要在乎“逐行提交”带来的开销。客户端工具默认往往按批提交,几百行一批,几万行数据下来,光是网络往返和 undo 生成就足够让界面假死。过了五十万行,我基本不考虑图形化,直接用 SQL*Loader 的 direct 模式或者外部表并行。
文件位置是个硬约束。如果你的文件在内网某台应用服务器的共享目录上,而数据库在另一台机器,那外部表这条路就得先把文件摆到数据库能访问的路径,或者用目录对象指向一个挂载点。这一步在很多环境里需要额外协调,别等到做了一半才发现没有权限。
执行频率决定了你值不值得把动作脚本化。一次性的临时数据,手工搞定最划算。但如果这个 CSV 每天凌晨都要进来一次,那哪怕只有五千行,也应该固化成控制文件加调度任务,因为手工操作意味着人得在场,而人是最不稳定的环节。
还有一个维度容易被忽略:数据质量。如果文件里日期写法五花八门、数字带千分位、字段里有换行符,那么清洗逻辑写在哪一层就很重要。放数据库里做,SQL 表达力强、可留痕;放 PL/SQL 里逐行做,灵活但代码量大;交给 SQL*Loader 的WHEN条件和NULLIF,则是把过滤前移到装载阶段,效率最高。
2.2 目标表先建好,别指望工具替你设计
不管走哪条路线,我都建议先手工建好目标表,字段类型、长度、精度定清楚,然后再去配置导入。理由有三个:工具自动建表时类型推断经常乐观,比如把2024-01-05推成 DATE 没问题,但把00123推成 NUMBER 就会丢掉前导零;字段长度按样本最大值推断,一旦后面出现超长行,整行数据会被拒;更麻烦的是,自动建的表没有注释、没有约束、没有索引,你还得回头补一遍,等于做了两次。
我吃过一次亏:一个物料编码列,样本前一百行都是纯数字,工具推断成 NUMBER(6),结果正式数据里出现了A1024这种带字母的编码,整批装载直接报错中断。后来老老实实手工建表,把编码定为 VARCHAR2(32),一次就过了。表结构是契约,契约要自己写,不能让别人代笔。
3. 图形化路线:五分钟把文件灌进去
3.1 文本导入器的完整链路
以一个典型场景走一遍:本地有个product.csv,首行是列名,逗号分隔,编码是常见的 GBK,日期写成2024/01/05,要进T_PRODUCT表。
打开工具后进入文本导入器,第一步选文件、选编码。这里编码必须手动确认,默认值经常和实际不符。判断方法很简单,用记事本另存为对话框看一眼当前编码,或者用命令行工具查一下文件头。选错编码的后果是中文列全部变成问号或方块,而英文和数字完全正常,这种“一半好一半坏”的现象最容易让人误判成数据库字符集问题。
第二步选“分隔符为逗号”,同时把“首行是列名”勾上。这里有个细节值得强调:分隔符选项里有全角逗号和制表符,如果文件是从某些办公软件另存出来的,分隔符未必是半角逗号。导入预览里如果发现所有字段挤在第一列,十有八九就是分隔符选错了。
第三步是字段映射。左边是文件列,右边是表列,工具会按位置或按名称自动匹配,但自动匹配只认名字完全一致。文件里叫“产品编号”,表里叫PRODUCT_ID,就得手工拖。这一步别偷懒,我见过拖错一列导致整张表数据错位的情况,事后核对成本极高。
第四步设置日期格式。这一步是新手翻车最集中的地方。工具默认按数据库会话的NLS_DATE_FORMAT解析,如果你的文件写2024/01/05而会话格式是DD-MON-RR,就会报无效月份。正确做法是在导入界面的日期格式框里明确写YYYY/MM/DD,让它覆盖默认值。
3.2 字段映射里两个必须做的动作
第一个动作是预览末尾几行。很多人只看了前几行就点确定,而问题往往藏在文件尾部:多余的汇总行、空行、最后一行缺字段。预览时直接跳到末尾翻两页,能挡掉一大半事故。
第二个动作是先跑一次“生成SQL”而不是直接导入。文本导入器通常支持把整批操作生成 INSERT 语句,这个中间产物非常有用:你可以先在测试库跑一遍,看看有没有报错,也可以人工检查几条语句拼得对不对。确认无误再执行真实导入,比直接对着生产库点“导入”稳妥得多。
注意:图形化工具的直接导入通常带自动提交,出了错已经写进去的数据不会自己回滚。养成先在测试表验证的习惯,比事后清表重新来要省事。
3.3 SQL Developer 导入向导的差异
另一款常用客户端提供的是“导入数据”向导,流程上更现代化:选文件、选分隔符、预览、指定目标表、字段映射,最后一步能生成一个可保存的脚本。它的优势在于能一次性导入多个文件,并且支持把映射配置存下来反复用。
它的短板在于类型推断。向导会扫描样本数据给出建议类型,但建议就是建议,我要强调的还是那句话:目标表自己建,向导只负责搬数据。另外它对超大文件的处理不算友好,几百万行的文件加载预览时会长时间无响应,这种时候果断换路线,别硬扛。
4. SQL*Loader:生产环境的主力
4.1 一份能直接抄的控制文件
控制文件是 SQL*Loader 的核心,写清楚了它就是一份可复用的“装载说明书”。下面这份是我常用的骨架,注释我写在旁边:
LOAD DATA CHARACTERSET UTF8 -- 文件编码,和实际一致 INFILE 'product.csv' -- 相对路径相对于执行目录 BADFILE 'product.bad' DISCARDFILE 'product.dsc' APPEND -- 追加;整表重灌用 TRUNCATE INTO TABLE T_PRODUCT FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' TRAILING NULLCOLS ( PRODUCT_ID, PRODUCT_NAME, CATEGORY NULLIF CATEGORY = 'NULL', PRICE "TO_NUMBER(:PRICE, '999999.99')", CREATE_DATE DATE 'YYYY/MM/DD', REMARK )逐段解释一下关键点。CHARACTERSET UTF8告诉装载器文件的字节编码,注意这里的名字是 Oracle 的字符集名,UTF-8 文件写UTF8,GBK 文件写ZHS16GBK。写错这一行,中文就全是问号,而且日志里未必报错,属于最难查的一类问题。
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'是 CSV 的标配。OPTIONALLY的意思是引号可以出现也可以不出现,包裹的内容里即使有逗号也不会被切断。这一句解决了很多“字段里含逗号导致列错位”的麻烦。
TRAILING NULLCOLS处理短行。文件里某行只有前三个字段,后面缺失的列会被置为 NULL,而不是报错终止。
DATE 'YYYY/MM/DD'是 SQL*Loader 的日期掩码写法,比在 SQL 表达式里套TO_DATE更简洁,也不受会话NLS_DATE_FORMAT影响。这个写法我强烈推荐,会话参数是可变的,写在控制文件里的掩码是固定的。
TO_NUMBER(:PRICE, '999999.99')用来剥掉数字里的格式干扰。如果文件里的价格写成1,234.50,因为逗号已经被当作分隔符,你其实需要把这一列用引号包起来,或者换一种分隔符。这一点很多人绕不过来:当数据本身包含分隔符时,要么靠引号包裹,要么换分隔符,没有第三条路。
4.2 参数与性能开关的取舍
命令行参数的选择,直接决定这条命令是跑三分钟还是跑三小时:
| 参数 | 作用 | 什么时候用 |
|---|---|---|
userid | 连接串 | 必填,建议用凭据文件而非明文 |
control | 指定控制文件 | 必填 |
log | 日志文件 | 必填,排查全靠它 |
bad | 拒绝行文件 | 建议必填 |
errors | 允许的错误行数 | 批量场景设大一些,别用默认值 |
direct=true | 绕过 SQL 引擎直接写块 | 大数据量、目标表无触发器时用 |
parallel=true | 并行装载 | 配合多 INFILE,CPU 核数允许时用 |
rows | 每批提交行数 | 默认 64 偏小,可调到 1000 到 5000 |
bindsize | 绑定数组大小 | 与 rows 配合调大,减少往返 |
errors这个参数值得单独说。默认值是 50,意思是超过 50 行出错就整体终止。批量装载时,我通常把它设成一个绝对数比如 10000,或者干脆设成允许的比例,然后在装载结束后回头分析 bad 文件。这样能保证“绝大部分好数据先进去,坏数据单独拿出来修”,比一有错就全停要实用得多。
direct=true属于典型的“用对场景就是神器”。它跳过 SQL 层,直接格式化数据块写盘,速度快几倍。但代价是不走触发器、不走约束检查(实际上主键、唯一约束在装载后会处于不可用状态,需要手工重建),而且不能用APPEND以外的某些选项。所以它适合往“干净的中间表”灌数据,不适合带一堆业务触发器的正式表。
关于rows和bindsize的配合,经验值是让单批的字节量控制在几兆到十几兆,太小网络往返多,太大内存吃紧。我一般从rows=1000起步,看日志里的耗时再微调,不追求一次到位。
4.3 bad 文件和 log 文件的读法
装载结束,先看 log 最后几行。成功的标志是“逻辑记录数”和“成功载入行数”一致。如果出现“拒绝记录数”,那就去 bad 文件里找对应行。
bad 文件里的内容基本是“原始行 + 错误说明”,错误说明会写清楚是第几列、什么原因,比如“列 PRICE 的值超出范围”“日期格式不匹配”。照着列号回原文件找,比从头逐行肉眼扫快得多。
有个细节:bad 文件只记录被拒的行,而 discards 文件记录的是被WHEN条件过滤掉的行。两者别混。如果你的控制文件里写了WHEN子句做条件装载,那不属于“错误”,行会被丢进 discard,别去 bad 里找。
5. 服务端路线:外部表与 UTL_FILE
5.1 目录对象与外部表 DDL
外部表的思路很讨巧:把文件“注册”成一张只读表,剩下的事全是 SQL。第一步建目录对象,指向服务器上的物理路径:
CREATE OR REPLACE DIRECTORY DIR_IMP AS '/data/import'; GRANT READ, WRITE ON DIRECTORY DIR_IMP TO app_user;第二步建外部表:
CREATE TABLE EXT_PRODUCT ( PRODUCT_ID VARCHAR2(32), PRODUCT_NAME VARCHAR2(200), CATEGORY VARCHAR2(64), PRICE VARCHAR2(32), CREATE_DATE VARCHAR2(32), REMARK VARCHAR2(500) ) ORGANIZATION EXTERNAL ( TYPE ORACLE_LOADER DEFAULT DIRECTORY DIR_IMP ACCESS PARAMETERS ( RECORDS DELIMITED BY NEWLINE CHARACTERSET AL32UTF8 BADFILE DIR_IMP:'ext_product.bad' LOGFILE DIR_IMP:'ext_product.log' FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' MISSING FIELD VALUES ARE NULL ( PRODUCT_ID, PRODUCT_NAME, CATEGORY, PRICE, CREATE_DATE, REMARK ) ) LOCATION ('product.csv') ) REJECT LIMIT UNLIMITED;这里我要特别强调一个设计取舍:外部表的所有列我都定义成 VARCHAR2。为什么不直接定义成 DATE 和 NUMBER?因为文件里的脏数据一旦让某个字段解析失败,整行会被拒绝,且排查起来要翻日志。先全部按字符串读进来,再用一层INSERT ... SELECT做类型转换和校验,脏数据能被 SQL 条件筛出来,控制力强得多。
转换那一步大概长这样:
INSERT INTO T_PRODUCT (PRODUCT_ID, PRODUCT_NAME, CATEGORY, PRICE, CREATE_DATE, REMARK) SELECT TRIM(PRODUCT_ID), TRIM(PRODUCT_NAME), TRIM(CATEGORY), TO_NUMBER(REPLACE(TRIM(PRICE), ',', ''), '999999.99'), TO_DATE(TRIM(CREATE_DATE), 'YYYY/MM/DD'), REMARK FROM EXT_PRODUCT WHERE TRIM(PRODUCT_ID) IS NOT NULL AND REGEXP_LIKE(TRIM(CREATE_DATE), '^\d{4}/\d{2}/\d{2}$');这条 SQL 里藏了几个实用动作:TRIM去掉首尾空格,REPLACE剥掉数字里的千分位,REGEXP_LIKE先筛掉日期格式明显不对的行,让它们进不了转换阶段。把校验前置到 WHERE 里,比让 TO_DATE 抛异常再捕获要干净得多。
5.2 外部表的并行与权限门槛
外部表能并行查询,INSERT INTO ... SELECT /*+ PARALLEL(8) */就够用,性能不比 SQL*Loader 差多少。它的额外好处是可以随时SELECT复读,同一份文件反复查不用重新装载,做数据核对的时候特别顺手。
它的门槛也很明确:文件必须在服务器路径下,必须有CREATE ANY DIRECTORY或同等的建目录权限,且目录指向的路径要有读权限。很多环境里这些权限不在开发手里,所以走这条路之前先确认权限,别写到一半卡住。
5.3 UTL_FILE 逐行解析的适用边界
UTL_FILE是数据库内置的文件读写包,能在存储过程里一行行读文件、做业务判断、再落表。它的适用面其实很窄:万行以内、需要复杂清洗逻辑、或者要在导入过程中调用其他业务过程。
为什么强调万行以内?因为它是逐行处理的,每读一行都是一次上下文切换,几万行以上性能掉得厉害。而且它有个硬限制:文件同样必须在服务器上,且路径要通过目录对象定位,和外部表一样。
DECLARE v_file UTL_FILE.FILE_TYPE; v_line VARCHAR2(4000); v_cnt PLS_INTEGER := 0; BEGIN v_file := UTL_FILE.FOPEN('DIR_IMP', 'product.csv', 'R', 4000); LOOP BEGIN UTL_FILE.GET_LINE(v_file, v_line, 4000); EXCEPTION WHEN NO_DATA_FOUND THEN EXIT; END; IF v_cnt > 0 THEN -- 跳过表头 -- 这里放解析与入库逻辑 NULL; END IF; v_cnt := v_cnt + 1; END LOOP; UTL_FILE.FCLOSE(v_file); DBMS_OUTPUT.PUT_LINE('处理行数: ' || v_cnt); END;上面这段的骨架意义大于逻辑意义,重点看它的两个必备防护:NO_DATA_FOUND作为循环终止条件,以及表头跳过用计数器实现。少了第一个,读完就抛异常;少了第二个,表头会被当成数据行入库。
5.4 带引号字段的解析函数
纯 PL/SQL 解析 CSV 最麻烦的是引号内的逗号。用INSTR找第一个逗号就切分,遇到"某商品,蓝色,大号"这种字段会切成三段,直接错位。我的做法是写一个能识别引号状态的分割函数,核心逻辑用状态机:
FUNCTION SPLIT_CSV(p_line IN VARCHAR2, p_idx IN PLS_INTEGER) RETURN VARCHAR2 IS v_len PLS_INTEGER := NVL(LENGTH(p_line), 0); v_in_q BOOLEAN := FALSE; v_field PLS_INTEGER := 1; v_start PLS_INTEGER := 1; v_ch VARCHAR2(1); BEGIN IF v_len = 0 THEN RETURN NULL; END IF; FOR i IN 1 .. v_len LOOP v_ch := SUBSTR(p_line, i, 1); IF v_ch = '"' THEN v_in_q := NOT v_in_q; -- 双引号翻转状态,连续两个引号视为转义 ELSIF v_ch = ',' AND NOT v_in_q THEN IF v_field = p_idx THEN RETURN TRIM(BOTH '"' FROM SUBSTR(p_line, v_start, i - v_start)); END IF; v_field := v_field + 1; v_start := i + 1; END IF; END LOOP; IF v_field = p_idx THEN RETURN TRIM(BOTH '"' FROM SUBSTR(p_line, v_start)); END IF; RETURN NULL; END;这个函数用一个布尔量记录“当前是否在引号内”,只有不在引号内遇到的逗号才算分隔符。取第 N 列时一路扫到第 N 个分隔符为止。写法简单,但有几个已知短板需要说清楚:一是性能一般,每取一列要把整行扫一遍,六个字段就是六遍;二是连续两个双引号代表一个引号的转义规则没有完整实现,标准 CSV 里""应该还原成一个",上面这段是先翻转再翻转,结果能对上,但语义上不够严谨。做严谨实现的话,遇到引号要先看下一个字符是不是引号,是的话直接跳两步。
提示:如果文件里引号转义很复杂,别在 PL/SQL 里硬啃。先把文件规整成不含内嵌逗号的干净格式,再走 SQL*Loader,整体成本低得多。
6. 六个高频翻车点
6.1 中文乱码的三层排查
乱码是导入区最高频的问题,而且很多人一上来就去改数据库字符集,方向就错了。正确的排查顺序是自外向内三层:
第一层,文件自身编码。用能显示编码的编辑器打开看一眼,是 GBK 还是 UTF-8,有没有 BOM 头。BOM 是 UTF-8 文件开头那三个不可见字节,图形化导入时它会粘在第一个列名前面,导致“列名匹配不上”,看起来像工具抽风,实际是三个隐藏字节在捣乱。处理方式很简单:用编辑器另存为“UTF-8 无 BOM”,或者用命令行去掉前三个字节。
第二层,装载工具的编码参数。SQL*Loader 里的CHARACTERSET、外部表里的CHARACTERSET就是干这个的。文件是 GBK 就写ZHS16GBK,是 UTF-8 就写AL32UTF8或UTF8。这一项写错,中文必然报废。
第三层,数据库字符集与会话设置。如果前两层都对了,查出来的中文还是乱,那才轮到看数据库字符集和客户端NLS_LANG。多数现代环境数据库字符集是AL32UTF8,客户端也设成对应的.AL32UTF8就一致了。
我用这张表做过多次现场排查,按顺序走基本不会绕远路:
| 现象 | 大概率原因 | 处置 |
|---|---|---|
中文全是问号? | 装载时字符集参数不匹配 | 改控制文件或外部表的 CHARACTERSET |
| 中文全是方块或乱码 | 文件编码判断错误 | 用编辑器确认真实编码后重设 |
| 第一列名莫名带奇怪字符 | UTF-8 BOM 头 | 另存为无 BOM,或跳过前 3 字节 |
| 部分行中文正常、部分乱 | 文件本身混了两种编码 | 按行拆分,统一转码后再导 |
| 数据库里正常,客户端显示乱 | 客户端 NLS_LANG 与库不一致 | 调客户端环境变量 |
6.2 逗号、引号与 BOM 三件套
前面提过引号包裹的作用,这里补一个实操细节:OPTIONALLY ENCLOSED BY '"'只管“去掉外层引号并保护内部逗号”,它不会帮你把内部的双引号还原成单个引号。文件里如果写的是"他说""你好"""这种标准转义,装载后可能留下多余引号,需要再用REPLACE清洗一遍。
BOM 的另一条处理路径是在导入前就把它干掉。我常用一个笨但可靠的办法:用文本编辑器打开,另存为时明确选“无 BOM 的 UTF-8”,保存后文件名不变,问题消失。批量文件的话,写个小脚本统一转码更省事。
6.3 日期与数字格式的显式约定
日期的原则是“永远显式指定掩码”。不要依赖会话默认格式,因为会话格式会随客户端环境变化,今天能跑的脚本换台机器就报错。DATE 'YYYY/MM/DD'(控制文件里)和TO_DATE(x, 'YYYY/MM/DD')(SQL 里)都是显式写法。
还有个容易被忽略的问题:文件里的日期如果是2024-1-5,掩码写YYYY/MM/DD反而对不上,因为月份和日期是不是两位数不确定。稳妥做法是把掩码写成能兼容单双位的形式,或者先统一规整文件格式。不要指望数据库替你猜,它猜错的时候不会告诉你。
数字的原则是“先当字符串进来”。千分位逗号、货币符号、全角数字、前后空格,这些在字符串里都好处理,直接用REPLACE、TRIM、TO_NUMBER组合解决。反过来,如果一上来就按 NUMBER 装载,遇到1,234直接整行被拒,你连原始值都看不到。
6.4 空值、空格与 NULL 的边界
这里有个 Oracle 特有的坑值得单独讲:空字符串等价于 NULL。文件里某个字段是空的两段逗号,,,装进去就是 NULL,不是空串。这个行为对绝大多数业务没问题,但如果你的表上有NOT NULL约束,空值行会被拒绝,得靠TRAILING NULLCOLS配合默认值处理。
另一个常见现象是“看起来是空的但实际有内容”。全角空格、制表符、不间断空格,肉眼和半角空格一模一样,TRIM默认也去不掉。这时候要用REGEXP_REPLACE(col, '[[:space:]]', '')或者REPLACE指定具体字符来清。
6.5 提交频率与回滚空间
批量导入时“每行提交一次”是最常见的性能杀手。每提交一次都要写 redo、等 IO,几十万行下来能慢十倍。合理做法是分批提交,SQL*Loader 用rows参数控制,PL/SQL 里用计数器每 N 行COMMIT一次。
但分批提交有个副作用要提前知道:中断后数据是部分进入的。所以要么在导入前清空目标表,要么给数据加个批次号字段,出问题时按批次删除重来。我倾向于后者,可追溯性更好。
6.6 重复导入的幂等设计
同一个 CSV 文件被跑了两遍,这是运维日常里最普通的事故。防它其实不难,三选一:
- 导入前
TRUNCATE TABLE(全量重灌场景,最快) - 加一个批次号或文件名+时间戳字段,导入后按批次校验
- 用业务主键做
MERGE(增量更新场景,最稳)
MERGE的写法稍长,但对“新数据插入、旧数据更新”的增量场景最合适:
MERGE INTO T_PRODUCT t USING (SELECT TRIM(PRODUCT_ID) pid, TRIM(PRODUCT_NAME) pname FROM EXT_PRODUCT) s ON (t.PRODUCT_ID = s.pid) WHEN MATCHED THEN UPDATE SET t.PRODUCT_NAME = s.pname WHEN NOT MATCHED THEN INSERT (PRODUCT_ID, PRODUCT_NAME) VALUES (s.pid, s.pname);7. 常见问题速查与经验收尾
7.1 问题速查表
跑了这么多年,出问题的点其实高度集中在几个地方,整理成表备查:
| 问题现象 | 排查方向 | 处置办法 |
|---|---|---|
| 导入后行数比文件少 | 看 bad 和 discard 文件 | 按错误说明修数据后重跑 |
| 报“无效的月份” | 日期掩码不匹配 | 显式指定DATE 'YYYY/MM/DD' |
| 报“ORA-01722 无效数字” | 数字列混入了非数字字符 | 先按 VARCHAR2 装载再清洗 |
| 列整体错位一列 | 分隔符判断错误或字段内有逗号 | 确认分隔符,启用引号包裹 |
| 装载速度极慢 | 逐行提交或走了 SQL 层 | 调大rows,考虑direct=true |
| 权限不足无法建外部表 | 缺少目录对象权限 | 申请CREATE ANY DIRECTORY或让管理员建 |
| 中文变问号 | 字符集参数不匹配 | 核对文件编码与 CHARACTERSET |
| 文件读不到 | 路径不是服务器路径 | 外部表和 UTL_FILE 只认服务器路径 |
7.2 我在实操中的几点体会
第一点,先跑小样本再跑全量。从 CSV 里切出前五百行单独做一次装载,能暴露 90% 的格式问题,这个动作只花两分钟,却能把返工概率砍掉一大半。很多人嫌麻烦跳过这步,然后在几十万行的失败日志里找原因,那才是真的浪费时间。
第二点,把校验逻辑写进 SQL,别写进注释。WHERE里加个正则筛掉明显不对的日期,比在文档里写一句“日期须为 YYYY/MM/DD 格式”有用一百倍。文档没人看,SQL 会执行。
第三点,留一份可复现的脚本。控制文件、建表 DDL、转换 SQL、调度命令,全部放进版本管理。半年后同样的文件再来一次,你不需要回忆当时怎么点的界面。
第四点,也是最实在的一条:批量导入前先确认能不能回滚。有没有清表权限、有没有备份、出问题影响多少下游。这三件事想清楚了,剩下的都是手艺活,慢一点快一点都无所谓。
如果你手上的文件只是几千行的配置表,真的不用上 SQL*Loader,图形化工具点几下就完事。工具没有高下之分,只有合不合适。上面这些路线我全都在生产环境用过,用得最多的反而是最朴素的那条:先把表建好,把格式规整干净,然后挑一条最省事的路径灌进去,跑完对一遍行数,收工。