ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Oracle PL/SQL中CSV导入导出实战:乱码、坏数据与最佳方案

2026/9/15 19:06:47 拓冰建站 浏览量
Oracle PL/SQL中CSV导入导出实战:乱码、坏数据与最佳方案 我做了这么多年Oracle开发每天跟PL/SQL打交道最多的场景之一就是把数据从库里倒出来、再倒进去。CSV这个格式看着简单真用起来才发现坑不少。就拿热搜里那几条来说——“csv手机打开正常,电脑打开不正常”、“导入csv文件 csv log unsuccessful”、“sql表数据导出最简单三个步骤”——每一个背后都是有人在导入导出时被折腾过。这篇就把我在实际项目和运维支持里攒下来的经验摊开讲从CSV本身的格式细节到PL/SQL环境下的导出、导入方案再到中文乱码、坏数据拦截这些高频问题一次讲清楚。适合用Oracle数据库做开发、做运维或者经常跟CSV打交道的同学参考。1. 先理清CSV的几个“反直觉”细节很多人觉得CSV太简单了不就是用逗号把字段分开吗真到写解析逻辑的时候才发现CSV格式的坑全藏在边界情况里。1.1 分隔符不是只有逗号标准CSV用的是逗号但实际项目里我见过用竖线、制表符Tab、分号的都有。为什么因为业务数据里本身就带逗号比如地址“北京市,朝阳区,某某大厦”如果直接用逗号做分隔符这一列就会被拆成三列。所以很多系统导出时干脆改用竖线或Tab。在PL/SQL里处理时你要先搞清楚源头文件到底用的什么分隔符。外部表里叫FIELDS TERMINATED BYSQL*Loader里也叫这个UTL_FILE逐行读取时就是INSTR找分隔符的位置。这里有个经验碰到数据里含逗号的文件优先用双引号包裹整个字段而不是换分隔符。因为换分隔符是“牵一发动全身”CSV文件的下游使用方比如Excel、业务系统可能认死理只认逗号。1.2 引号、换行和编码三个最容易翻车的点先说引号。CSV规范里字段如果包含逗号、换行或双引号整个字段要用双引号包起来内部的双引号再用两个双引号转义。比如北京市,朝阳区,他说你好解析出来就是两列第一列是北京市,朝阳区第二列是他说你好。这个规则在导出时必须注意——如果你的数据里有逗号或引号导出去却不加引号包裹Excel打开就是乱的数据库导入回来也会错位。再说换行。很多CSV文件在Excel里看着一行是一条记录但某个字段内部可能嵌了换行符CHR(10)。这时候行数和记录数就对不上。所以判断记录边界不能只看换行符要看“不在双引号内的换行符”才算一条记录结束。外部表对这种处理得比较聪明FIELDS TERMINATED BY遇到引号内的逗号和换行会自动跳过但UTL_FILE逐行读的时候必须自己写状态判断。最后是编码。这大概是中文环境下最坑的环节。Oracle数据库字符集常见是AL32UTF8UTF-8但Windows下的Excel和很多CSV工具默认用GBK即ZHS16GBK或本机ANSI编码。这就解释了热搜里那条“csv手机打开正常,电脑打开不正常”——手机上的APP一般按UTF-8解析电脑Excel按本地ANSI解析两边对不上自然一个正常一个乱码。做导入导出时文件编码和数据库字符集必须搭配好。1.3 CSV里日期和数字该怎么处理CSV没有类型概念全是字符串。但导入到数据库时日期和数字必须转换成正确的类型。导出时如果你不做格式控制Oracle默认的NLS_DATE_FORMAT可能输出11-4月-24这种带中文的格式导出的文件别人根本没法直接用。我的习惯是导出前先设置会话的NLS参数ALTER SESSION SET NLS_DATE_FORMAT YYYY-MM-DD HH24:MI:SS; ALTER SESSION SET NLS_NUMERIC_CHARACTERS .,;NLS_NUMERIC_CHARACTERS这个是控制小数点和千分位的设成.,表示小数点用.千分位用,。不然有些环境默认是.,反过来的数字导出出来全乱套。而导入的时候日期字符串转换成DATE我推荐用TO_DATE(?, YYYY-MM-DD HH24:MI:SS)显式指定格式千万别用隐式转换。你永远不会知道下一个文件里的日期是什么格式。2. 导出数据到CSV三种方案对号入座PL/SQL里导CSV分工具层和代码层两说。工具层指的是PL/SQL Developer这类客户端自带的Export功能代码层指的是用UTL_FILE或SQL*Plus spool自己写。实际开发中我三种都用过适用场景完全不同。2.1 SQL*Plus spool最快上手的轻量方案如果你只是临时导一个查询结果给同事或者把数据带到别的环境看一眼spool是最快的。脚本大概长这样SET ECHO OFF SET FEEDBACK OFF SET HEADING OFF SET PAGESIZE 0 SET LINESIZE 32767 SET TRIMSPOOL ON SET TERMOUT OFF SPOOL /tmp/employee.csv SELECT employee_id || , || last_name || , || TO_CHAR(hire_date, YYYY-MM-DD) || , || salary FROM employees WHERE department_id 50; SPOOL OFF注意几个细节SET HEADING OFF把列标题去掉如果不关第一行就是列名。有些场景要列头那就在SQL里用UNION ALL拼一行或者最后手动补。SET PAGESIZE 0避免每页出现空行和标题重复。SET LINESIZE设大一些否则超过行宽会换行CSV就多出一截。SET TRIMSPOOL ON去掉行尾空格不然每一行后面都拖着长长一串空格。字段拼接时如果字段内容本身可能包含逗号就要用双引号包起来SELECT || employee_id || , || REPLACE(last_name, , ) || , || TO_CHAR(hire_date, YYYY-MM-DD) || FROM employees;REPLACE把字段内部的引号替换成双引号转义这是CSV的标准做法。spool的缺点是只能导出查询结果没法做复杂的并行、日志控制而且CLOB超过32767字节的内容会被截断处理大字段就力不从心了。2.2 UTL_FILE可编程、适合自动化批处理UTL_FILE是Oracle提供的文件读写包适合在存储过程里把CSV写成文件。比如每天晚上定时导出前一天的数据这种场景spool就很难自动化UTL_FILE就顺理成章了。前提是数据库要建目录对象CREATE OR REPLACE DIRECTORY EXP_DIR AS /oracle/export; GRANT READ, WRITE ON DIRECTORY EXP_DIR TO APP_USER;存储过程核心逻辑CREATE OR REPLACE PROCEDURE export_employee_csv IS v_file UTL_FILE.FILE_TYPE; v_line VARCHAR2(4000); BEGIN v_file : UTL_FILE.FOPEN(EXP_DIR, employee.csv, W, 32767); -- 写入表头 UTL_FILE.PUT_LINE(v_file, employee_id,last_name,hire_date,salary); FOR rec IN ( SELECT employee_id, last_name, TO_CHAR(hire_date, YYYY-MM-DD) AS hire_date, salary FROM employees WHERE department_id 50 ) LOOP v_line : rec.employee_id || , || || REPLACE(rec.last_name, , ) || || , || rec.hire_date || , || rec.salary; UTL_FILE.PUT_LINE(v_file, v_line); END LOOP; UTL_FILE.FCLOSE(v_file); EXCEPTION WHEN OTHERS THEN IF UTL_FILE.IS_OPEN(v_file) THEN UTL_FILE.FCLOSE(v_file); END IF; RAISE; END export_employee_csv;这里有个细节FOPEN的第四个参数是每次写入的最大行宽设成32767可以避免长字段被截断。但UTL_FILE每一行的实际字节数也受限于这个参数超过就会报错。如果你的数据里有很长的备注字段要单独做换行拼接或者在导出前用SUBSTR截断。UTL_FILE的优势是灵活、可控制、支持异常处理缺点是写代码的工作量比spool大而且对CLOB的支持仍然有限需要分块读再拼接麻烦但可控。2.3 PL/SQL Developer内置导出图形化兜底很多日常操作比如导个小表给不懂SQL的同事我直接用PL/SQL Developer的Export Tables/Query Results功能。右键点击查询结果选“Export Results”保存为CSV格式点几下就搞定。但这个方式有个坑客户端工具导出的CSV通常用客户端本地的字符集编码如果服务器数据库字符集是AL32UTF8客户端是ZHS16GBK导出的文件在中国区Excel里打开正常但扔回Linux服务器上处理就可能乱码。所以在导出时注意看一下工具的字符集设置PL/SQL Developer在保存时一般会让你选编码格式选UTF-8更通用。图形化工具适合不写代码的临时操作但没法自动化、不好控制行为一旦数据量大就卡。所以我始终认为工具是兜底spool和UTL_FILE才是长期可依赖的方案。3. 从CSV导入数据从“能用”到“好用”导入比导出更讲究因为数据源头不可控文件可能是别人手工整的可能带各种“脏数据”。导入方案我按需选小文件用外部表大数据量用SQL*Loader需要逐行复杂处理时才用UTL_FILE。3.1 外部表写SQL查询CSV最优雅的导入方式Oracle的外部表External Table可以把CSV文件当数据库表来查。最大的好处是不需要把数据先load到数据库直接SQL查询、JOIN、过滤、insert进目标表。这也是我最推荐的导入方式尤其是每天有固定文件要灌进库里的场景。创建外部表CREATE OR REPLACE DIRECTORY EXT_DIR AS /oracle/import; CREATE TABLE employees_ext ( employee_id NUMBER, last_name VARCHAR2(100), hire_date DATE, salary NUMBER ) ORGANIZATION EXTERNAL ( TYPE ORACLE_LOADER DEFAULT DIRECTORY EXT_DIR ACCESS PARAMETERS ( RECORDS DELIMITED BY NEWLINE CHARACTERSET AL32UTF8 FIELDS TERMINATED BY , OPTIONALLY ENCLOSED BY MISSING FIELD VALUES ARE NULL ( employee_id CHAR(20), last_name CHAR(100), hire_date CHAR(20) DATE_FORMAT DATE YYYY-MM-DD, salary CHAR(30) ) ) LOCATION (employee.csv) ) REJECT LIMIT UNLIMITED;重点讲几个参数CHARACTERSET AL32UTF8指定CSV文件的编码。如果你的文件是GBK/ANSI这里要改成ZHS16GBK否则乱码。FIELDS TERMINATED BY , OPTIONALLY ENCLOSED BY 逗号分隔字段可选地用双引号包裹。这个写法是CSV标准解析的精髓能处理“字段里有逗号但被引号保护”的情况。MISSING FIELD VALUES ARE NULL某行的字段数不够时缺少的列填NULL不报错。DATE_FORMAT把字符串转成DATE格式显式指定避免会话NLS设置影响结果。REJECT LIMIT UNLIMITED允许失败记录无限条否则默认遇到第一条坏数据整个外部表就报错。使用外部表的好处是它的错误机制很清晰。默认情况下坏数据会写到同目录下的.bad文件中。每次查询完外部表如果数据有问题去看.bad文件就知道哪些行挂了。导入目标表就是标准SQLINSERT INTO employees (employee_id, last_name, hire_date, salary) SELECT employee_id, last_name, hire_date, salary FROM employees_ext;外部表的劣势在于文件一旦缺列、列顺序变化定义就要跟着改另外这个方案要求文件在数据库服务器本地目录如果文件在客户端机器上你得先传到服务器外部表才读得到。3.2 SQL*Loader大数据量场景下的首选数据量几百MB甚至几GB时外部表查询和大批量INSERT会非常慢这时候SQL*Loader才是正解。它是Oracle自带的命令行工具性能快、日志全平时做初始化数据迁移、批处理导入我一直用它。控制文件load.ctl写法LOAD DATA INFILE employee.csv INTO TABLE employees APPEND FIELDS TERMINATED BY , OPTIONALLY ENCLOSED BY TRAILING NULLCOLS ( employee_id CHAR(20), last_name CHAR(100), hire_date CHAR(20) TO_DATE(:hire_date, YYYY-MM-DD), salary CHAR(30) )命令行执行sqlldr useridscott/tigerorcl controlload.ctl logload.log badload.bad这里有个关键区别SQL*Loader的INFILE路径相对于你执行sqlldr命令的机器路径外部表则是数据库服务器的本地路径。如果是客户端执行sqlldrINFILE路径是客户端机器路径。APPEND表示追加到表中备选还有INSERT要求表为空、REPLACE先清空再插入、TRUNCATETRUNCATE后插入。初次导入用INSERT最安全能防止误清空已有数据。日志文件load.log会记录导入了多少行、失败了多少行、失败原因是什么。热搜里那条“csv log unsuccessful”就是SQLLoader的日志文件里有报错信息。我遇到最多的情况第一是日期格式不匹配第二是数字字段里有空格或千分位符号第三是字段长度超了目标表列长度。SQLLoader的LOAD WHEN可以加条件过滤行比如LOAD WHEN (employee_id ! 0)跳过某些行但大多数时候我们直接用TRAILING NULLCOLS让缺的字段补NULL再把坏行丢到bad文件里做人工排查。3.3 UTL_FILE逐行读取什么时候才需要用这种“笨办法”外部表和SQLLoader适合“规规矩矩”的CSV但现实中总有一些“不规矩”的文件表头有多行、需要根据文件内容决定插到哪张表、需要读一个文件同时更新另一张表、需要对每条记录做复杂的业务校验。这时候外部表和SQLLoader都搞不定只能UTL_FILE逐行读。UTL_FILE读CSV的核心是逐行读取手动解析CREATE OR REPLACE PROCEDURE import_employee_csv IS v_file UTL_FILE.FILE_TYPE; v_line VARCHAR2(4000); v_array SYS.DBMS_SQL.VARCHAR2S; v_cnt PLS_INTEGER; BEGIN v_file : UTL_FILE.FOPEN(IMP_DIR, employee.csv, R, 32767); LOOP BEGIN UTL_FILE.GET_LINE(v_file, v_line); EXCEPTION WHEN NO_DATA_FOUND THEN EXIT; END; -- 跳过表头 IF v_line LIKE employee_id% THEN CONTINUE; END IF; -- 解析CSV行 parse_csv_line(v_line, ,, v_array, v_cnt); -- 校验数据 IF v_array(1) IS NOT NULL THEN INSERT INTO employees (employee_id, last_name, hire_date, salary) VALUES ( TO_NUMBER(TRIM(v_array(1))), v_array(2), TO_DATE(TRIM(v_array(3)), YYYY-MM-DD), TO_NUMBER(TRIM(v_array(4))) ); END IF; END LOOP; UTL_FILE.FCLOSE(v_file); COMMIT; EXCEPTION WHEN OTHERS THEN IF UTL_FILE.IS_OPEN(v_file) THEN UTL_FILE.FCLOSE(v_file); END IF; ROLLBACK; RAISE; END import_employee_csv;parse_csv_line这个解析函数如果不自己写可以用REGEXP_SUBSTR实现一个简化版v_array(1) : REGEXP_SUBSTR(v_line, [^,], 1, 1); v_array(2) : REGEXP_SUBSTR(v_line, [^,], 1, 2);但这个写法没法正确处理字段里有逗号和引号的情况。如果CSV规范严格逗号分隔、引号包裹、内部引号双写解析逻辑会复杂很多我一般推荐用外部表代替UTL_FILE反正外部表已经帮你解析好了。UTL_FILE适合的场景是虽然有CSV但你还得顺手做点“文件里没有”的逻辑判断比如按某列的值决定插入A表还是B表。为了这种灵活性才值得自己写解析。4. 避坑实录中文乱码、报错和坏数据4.1 中文乱码手机能打开但Excel乱码这就是经典的UTF-8编码问题。手机上的文本阅读器一般按UTF-8猜测解码能正常显示Excel在Windows上默认按本地ANSI比如GBK解码UTF-8文件没有BOM头时Excel会按ANSI硬解中文就变成乱码了。解决方式有几种导出时在文件开头加BOM头0xEF 0xBB 0xBF。Excel看到BOM就知道这是UTF-8自动正确解码。外部表导入时不认识BOM需要跳过。导出时直接用GBK编码写文件。用UTL_FILE时在FOPEN前把字节流转换一下Windows下Excel打开就正常了。用UTL_FILE导出时先UTL_FILE.PUT(v_file, UNISTR(\FEFF))这会在文件开头写入BOMExcel再打开就好。但注意BOM也可能引起Linux下其他程序解析异常所以要根据下游工具做选择。从数据库导入CSV时如果文件是GBK外部表定义里要写CHARACTERSET ZHS16GBKSQL*Loader在控制文件里也可以指定字符集CHARACTERSET ZHS16GBK。很多时候乱码不是字符集本身错了而是定义和文件实际编码不一致。建议拿到文件先确认编码再说别急着写导入脚本。4.2 ORA-12514和数据库连接问题热搜里有条“ora-12514:tns监听器当前不知道连接描述符”——这个报错看着吓人其实就是数据库连接串问题跟CSV导入导出本身无关但很常见。它表示你给监听器发的服务名监听器不认。排查思路很固定用tnsping确认网络连通和监听端口。确认连接字符串里的SERVICE_NAME在数据库里存在。用lsnrctl services在服务器上查一下实际注册的服务名。如果是本地平台服务比如ORCL和orcl.world不一致也会报这个错。实际处理时我发现很多人是连接串里SERVICE_NAME写错了或者数据库服务名有多个实例、监听器只注册了其中一个。解决办法就是确认数据库服务名再用正确的连接串重连。这类连接问题跟CSV不直接相关但导入导出时也会被它卡住所以排查顺序上要先解决连接。4.3 坏数据的拦截加载前必须做三件事每次导入CSV我都会先做三轮检查这套流程帮我避开了无数次返工。第一轮空文件检查。文件大小为0或只有表头就跳过不然导入一堆空行。第二轮字段数检查。用外部表或SQL*Loader的bad文件都能看出来。字段数不对大概率是数据里带了换行或逗号但没正确处理。这轮能拦住九成以上的“脏数据”。第三轮数据格式校验。外部表定义里指定DATE_FORMAT就是格式校验的一种方式。对于数字字段一定要用TRIM去掉两边的空格再转类型否则TO_NUMBER会报ORA-01722。SQL*Loader的bad文件、外部表的.bad文件是发现坏数据最直接的路径。文件里的每一行都清楚记录了哪条记录为什么失败根据错误信息回去改CSV源文件比在数据库里做数据清洗高效得多。UTL_FILE方案则要靠自己写异常处理在每条INSERT前后做日志记录我通常写一个import_log表把失败的行原样存下来方便回头排查。另外说一个容易忽略的点CSV文件里的NULL值处理。源文件里空字符串到底表示NULL还是空串两个语义在某些场景下完全不同。外部表用MISSING FIELD VALUES ARE NULL处理缺失列SQL*Loader用TRAILING NULLCOLS处理末尾缺失但空字符串并不会自动转成NULL需要你在字段定义里写NULLIF(字段名, )。这个细节能写出“能用”和“好用”的差别。5. 几个实操中总结的“意外”经验写到最后再分享几个我在实际项目里积累的小经验这些不是标准文档里能查到的完全是踩坑踩出来的。关于CSV导出后文件过大Excel打开特别慢的问题。如果只是给业务人员看导出时可以考虑只导必要字段或者按日期分文件。数据量上百万行时CSV文件动辄几十MB上百MBExcel根本打不开。这种场景我一般用SQL*Loader导出zip压缩后给下游。文件大不是数据库导出慢而是客户端工具处理慢所以纯导数据用spool或外部表客户端工具只负责小文件。关于列顺序。外部表和SQL*Loader的字段顺序必须严格按照CSV文件的列顺序来但这里有个容易踩的坑如果CSV文件多了一列、少了一列导入脚本不会报错只会静默错位或者因为类型转换失败而报错。我的习惯是先导出一行样例数据人工确认列顺序再写导入定义。关于日期格式。每个项目的日期格式都有微妙的差异2024-01-01、01/02/2024、20240101。导入前最好和源数据提供方确认清楚否则TO_DATE解析错位数据就全错了。宁可慢一点先验证三条数据再批量插入。关于性能。外部表和SQL*Loader导入大数据量时目标表如果有索引插入会变慢。一个常见的实操技巧是大批量导入前先ALTER TABLE xxx DISABLE CONSTRAINT或者先drop索引导入完成后重建。这能快好几倍。业务高峰期别这么干DDL会锁表。最后说个心态问题。CSV导入导出看着简单真正做项目时70%的时间都耗在数据质量处理和格式兼容上而不是“能不能导”。如果从一开始就把字符集、分隔符、字段顺序、引号转义这些基础搞对后面会顺很多。希望这篇能帮你少踩几个坑省下一些排查时间。如果你在实操中遇到其他怪问题欢迎在实际项目里多试试上面说的方法很多问题本质上都是那几个基础细节没处理好。