前阵子做一个Oracle老库向新库迁移的活,单表上千万行,停机窗口只有四个小时。客户不追求一步到位,要求按主键顺序把每一批数据先导成CSV,每批固定1万条,验完了再往目标库里灌。平时自己写导出脚本也就一条SQL的事,但真按这个规则落地的时候,才发现“按主键顺序提取前10000条”这句话里面藏了不少细节:分页写法有坑、CSV特殊字符要处理、字符集乱了全是问号、批次断了怎么续……这篇文章就把我实际踩过的这些点完整梳理一遍,从设计思路到可直接抄的脚本都有,适合正在折腾Oracle数据迁移、需要分批导出CSV的DBA和开发。
1. 先想清楚:为什么前1万条一定要按主键顺序来
1.1 “前10000条”这个批次量是怎么算出来的
很多数据迁移任务里,批次大小不是拍脑袋定的。我见过有人直接写个ROWNUM <= 10000就跑,也不管结果集多大、文件多大、导入端能不能扛住。实际业务场景中,批次大小至少要从四个维度来定:停机窗口、数据库事务压力、CSV文件可传输大小、导入端提交频率。
先说事务压力。如果一批只导1万条记录,即使导入端每条INSERT都触发索引和约束检查,常规路径下事务也相对可控。万一导入到一半报错回滚,1万行的回滚代价也远小于10万行。很多SQL*Loader的惯例做法就是每1万行提交一次(ROWS=10000),正好和这个批次量匹配。
再说文件大小。1万条普通业务表记录,CSV文件通常在几十MB量级,无论是网络传输还是临时存储都很友好。如果表里有CLOB大字段,1万条可能变成几百MB,这时就该把批次调小到2000甚至1000。所以“10000”不是金科玉律,它是一个默认起点,需要根据“单行平均字节数 × 行数”估算后调整。我习惯先在测试环境造一批数据量,实测一下单批导出耗时和文件大小,再倒推生产环境该用多少条一批。
最后说迁移窗口。停机窗口四小时和四十小时,策略肯定不一样。窗口紧的时候,我会把批次改大(比如5万行一批),配合并行通道往目标库灌;窗口宽裕时,1万行一批慢慢来,安全第一。批次大小本身就是“速度”和“安全”之间的权衡值。
1.2 主键顺序不是矫情,是断点续传的命根子
很多人会觉得“按主键顺序提取”是多此一举,反正导出去的兜底数据都一样。其实这条规则是整个分批迁移方案的地基,没有它后面每一步都会崩。
第一,稳定排序让批次边界清晰可记录。每次导完一批,记录下这一批的最大主键值(比如MAX(order_id))。下一批从哪里开始?很简单,从last_max_order_id + 1开始。如果迁移过程中断、服务器重启、网络抖动,只要查一下批次日志表,马上知道该从哪个主键接着跑。但如果批次顺序是乱的,断点就没法定义,只能从头再来或靠目标库去重,风险大得多。
第二,主键顺序天然配合唯一约束,能有效防止重复。目标库表如果已经建了主键,导入时用INSERT APPEND或普通INSERT,遇到重复主键要么报错要么跳过,你立刻能发现问题。如果乱序导入,同一批里前后主键毫无规律,排查重复数据只能全表扫,非常被动。
第三,有序导入对目标库的索引更友好。虽然现代数据库插入时B树都能自适应,但按增序插入主键索引时,叶子节点是顺序扩展的,页分裂少,索引碎片少,导入完查询性能更好。这个优化在单batch下不明显,但百万级、千万级数据量差距就出来了。批量导完再做对账查询,两边都按主键有序扫描,效率也会高很多。
顺带提一句:主键顺序不要求主键是连续数字。业务主键可能是UUID、字符串、复合主键,只要排序规则稳定,能定义“上一批的最后一个值”就行。但为了简单,下面所有例子都用单列数字主键来讲解。
1.3 导出CSV的技术选型:SPOOL、UTL_FILE还是Python
确定“按主键分批导CSV”的思路后,接下来是选工具。这一步被很多人忽略,直接打开Toad或者SQL Developer点导出,结果文件要么带一堆格式噪音,要么导一半卡死。以我这些年做迁移的经验,主流就三条路:SQL*Plus的SPOOL、Oracle存储过程+UTL_FILE、Python连接Oracle。
| 方案 | 适用场景 | 依赖 | 服务器目录 | 自动化能力 | 推荐指数 |
|---|---|---|---|---|---|
| SQL*Plus SPOOL | 临时导出、人工干预多 | 只要有sqlplus客户端 | 不需要,输出到客户端 | 弱,适合一次性 | 最常用 |
| 存储过程 + UTL_FILE | 定时任务、大批量循环 | Oracle内置包 | 需要,输出到服务器本地 | 强,可配合DBMS_SCHEDULER | 自动化首选 |
| Python + python-oracledb/cx_Oracle | 复杂文件逻辑、数据清洗 | 需要Python环境 | 不需要 | 强,且文件处理灵活 | 灵活首选 |
为什么不用exp/expdp?因为这里的目标不是做物理备份恢复,而是要拿到一个“能被业务工具、Excel、SQL*Loader直接消费”的CSV文本文件,可能还需要过滤列、转换格式、拼接字段。expdp的dmp文件是内部二进制格式,做不到这点;exp导出的固定格式文本也不是纯CSV,处理起来更别扭。
另外,Toad和SQL Developer虽然也能导CSV,但在“按主键分批、循环导出、断点续传”这种场景下,它们的图形化操作很难脚本化,点几百次按钮不现实。我的习惯是:临时摸数据用Toad,正式迁移只用命令行和存储过程。
2. 动手前必须搞懂的分页与CSV格式细节
2.1 Oracle分页的ROWNUM陷阱,以及FETCH FIRST的版本差异
“取前1万条”说起来简单,但Oracle的ROWNUM是个经典陷阱。ROWNUM是在排序之前分配的,如果你写成:
SELECT * FROM orders WHERE ROWNUM <= 10000 ORDER BY order_id;结果是先随机取1万行,然后再按order_id排序。这根本不是“前10000条按主键顺序的记录”,你拿到的是数据库随便抓的1万行,只是碰巧排好了序。正确姿势是让排序发生在子查询里,ROWNUM在外面截断:
SELECT * FROM ( SELECT * FROM orders ORDER BY order_id ) WHERE ROWNUM <= 10000;子查询先把所有记录按主键排好,外层再截断前1万行。这个写法在Oracle 11g及更老版本里是标准答案,也是我这些年用得最多的。
如果你用的是12c及以上版本,可以写得直观些:
SELECT * FROM orders ORDER BY order_id FETCH FIRST 10000 ROWS ONLY;FETCH FIRST是12c引入的ANSI SQL标准语法,内部其实还是类似ROWNUM的处理逻辑。注意:19c里用它做OFFSET分页(比如OFFSET 10000 ROWS FETCH NEXT 10000 ROWS ONLY)也可以,但迁移场景千万不要依赖OFFSET,因为每往后翻一页,数据库都要重新扫描前面所有行,批次多了性能会越来越差。基于主键边界续传才是正解。
此外还有一个ROW_NUMBER()方案:
SELECT * FROM ( SELECT t.*, ROW_NUMBER() OVER (ORDER BY order_id) rn FROM orders t ) WHERE rn BETWEEN 1 AND 10000;这个写法通用性强,但性能上通常不如ROWNUM截断方案,因为它要先对全量结果计算行号再做过滤。数据量几百万还好,上千万行就会明显慢。能用ROWNUM或FETCH FIRST就尽量不用ROW_NUMBER,这是我的如实建议。
2.2 CSV列内容处理:逗号、引号、换行、NULL一个都不能漏
CSV格式看着简单,真正处理起来全是坑。标题里既然定了“基于CSV”,就必须把字段值里可能出现的脏数据先想清楚。
最核心的规则是:字段内容如果包含逗号、双引号、换行,就用双引号把这个字段包起来;字段内部如果出现双引号,就把这个双引号再转义成两个双引号。举例,客户名称是张三, 李四,不带引号导出去就导致CSV列错位;带引号必须是"张三, 李四"。如果客户名称本身就是他说"你好",CSV里要写成"他说""你好"""。
在SQL里拼接CSV行时,我常用的表达式是:
'"' || REPLACE(REPLACE(NVL(customer_name,''), '"', '""'), CHR(10), ' ') || '"'这行拆开看:NVL把NULL变成空字符串;第一层REPLACE(..., '"', '""')处理双引号转义;第二层REPLACE(..., CHR(10), ' ')把字段内的换行符替换成空格,避免CSV一行记录被拆成两行。有些场景下回车符CHR(13)也要处理,可以再包一层或直接用TRIM。
这里强调一点:如果字段里的换行不替换,CSV规范其实是允许的(用双引号包起来且包含换行的字段完全合法),SQL*Loader也能识别,但Excel打开后展示会很混乱,很多运维工具的简单行数校验也会失效。所以在迁移CSV这种场景下,我强烈建议把换行先替掉。
NULL字段的处理也要提前约定。导出端把数字NULL输出成空字符串,导入端用SQL*Loader的TRAILING NULLCOLS自动把空串当NULL处理。字符型NULL导成空串没问题,但注意空串和NULL在Oracle里语义上本来就不完全一样,导入到目标库后如果想保留NULL语义,必须在控制文件字段定义里明确处理。数字字段我用NVL2(amount, TO_CHAR(amount,'FM999999990.00'), ''),有值时输出格式化数字,没值时输出空。
2.3 日期、数字、字符集:导出之前先把格式焊死
CSV是文本文件,所有数据都以字符串形式存在,所以日期和数字的格式必须在SQL层就固定好,否则会受会话NLS参数影响,不同环境导出来格式五花八门。
日期字段我统一用TO_CHAR(order_date,'YYYY-MM-DD HH24:MI:SS'),只有日期部分用YYYY-MM-DD。不要在SQL里直接SELECT DATE类型然后交给SPOOL输出,否则输出格式随数据库或会话的NLS_TIMESTAMP_FORMAT变化,导到目标库解析时全乱套。
数字字段用TO_CHAR(amount,'FM999999990.00')。这里FM前缀很重要,它会去掉数字转换结果里的前导和尾随空格。没有FM的话,Oracle的数字转字符串默认会补空格对齐,CSV里出现空格很恶心,导入端做数值比较或字符串拼接都可能踩坑。如果字段本身有小数位不确定性,也可以直接TO_CHAR(amount),但一定要先测试输出样式。
字符集是另一个高频翻车点。SQL*Plus导出CSV时,客户端字符集由NLS_LANG环境变量决定。如果数据库是AL32UTF8,而你的客户端NLS_LANG设置成中文字符集,最终CSV文件里的中文可能就是乱码或问号。稳妥做法是导出前确认:
SELECT userenv('language') FROM dual;比如输出AMERICAN_AMERICA.AL32UTF8,那么在Linux下导出时设置:
export NLS_LANG=AMERICAN_AMERICA.AL32UTF8Windows下则是:
set NLS_LANG=AMERICAN_AMERICA.AL32UTF8 chcp 65001SQL*Loader导入端同样要在控制文件里指定字符集,比如CHARACTERSET UTF8,否则CSV文件是UTF-8而数据库客户端默认字符集不是UTF-8,又会乱一遍。
2.4 大字段(CLOB/BLOB)怎么进CSV
如果表里有CLOB或者BLOB,CSV方案就要特别设计。CLOB还能勉强往里塞,BLOB二进制数据根本不适合进CSV文本文件。
CLOB的处理,我一般分三种情况。第一,CLOB只存了短文本,比如备注几百字,那就直接在SQL里转成VARCHAR2输出,表达式和普通字段一样处理引号转义和换行替换。第二,CLOB内容很长,比如几万字的合同文本,放进CSV会让单行文件巨大,而且SQL*Loader加载时对单字段长度有限制,我通常只导出前4000个字符,或者把CLOB字段单独导成文本文件,CSV里只留主键关联ID。第三,CLOB内容必须完整迁移,那就不要走CSV路线,直接换数据泵EXPDP,或者用程序逐行读取再写入目标库。
BLOB就一句话:CSV是文本世界,二进制数据进去了就是灾难。如果一定要用CSV中转,可以把BLOB转成HEX字符串再进CSV,文件体积翻倍,导入端再用UTL_RAW.CAST_TO_RAW/HEXTORAW还原。但说实话,这种方案调试成本高、性能差,我只在极特殊场景用过一次。绝大多数BLOB迁移场景,规规矩矩用EXPDP或者目标库DBLink同步,才是正常人干的事。
2.5 批次边界与断点续传:先建一张批次日志表
做迁移和写普通导出脚本最大的区别,就是迁移必须可断、可查、可回退。每次导完一批,除了CSV文件本身,还要记录批次的边界信息。我通常会在源库或一个独立的迁移库上建这张日志表:
CREATE TABLE mig_batch_log ( batch_no NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, csv_file_name VARCHAR2(200), min_order_id NUMBER(12), max_order_id NUMBER(12), row_count NUMBER, created_time DATE DEFAULT SYSDATE );批次日志表的逻辑很简单:导完一批,INSERT一行,记录这一批CSV文件名、最小主键、最大主键、行数。下一次任务启动时,先查这个表拿到最后一个max_order_id,作为新一批的起始边界。这个表就是整个迁移过程的“账本”,人眼审计和脚本续传都靠它。
注意一个细节:不要用“这批最小主键+10000”推出下一批的起始主键。因为主键列可能有删除空洞,也可能是非连续序列,正确做法永远是查上一批的实际最大主键值,再加1作为下批起点。主键空洞不影响这个方案,因为每批都是基于真实主键排序取前N条,空洞只是让主键范围看起来比10000大一点而已。
3. 按主键顺序导前1万条:三种可落地脚本
3.1 准备一张示例表
为了演示,我建一张典型的订单表。实际生产环境换成你的业务表就行,逻辑一致。
CREATE TABLE orders ( order_id NUMBER(12) PRIMARY KEY, customer_name VARCHAR2(120), order_date DATE, amount NUMBER(12,2), status VARCHAR2(20), remark CLOB );测试数据可以这样快速灌一批:
INSERT INTO orders (order_id, customer_name, order_date, amount, status) SELECT LEVEL, '客户' || LEVEL, SYSDATE - LEVEL, ROUND(DBMS_RANDOM.VALUE(100, 10000), 2), CASE WHEN MOD(LEVEL, 3) = 0 THEN '已完成' ELSE '待支付' END FROM dual CONNECT BY LEVEL <= 1000000; COMMIT;如果你要把百万行都导出来,分批逻辑就非常明显了。第一次跑WHERE order_id > 0,拿到前1万条后记录最大order_id;第二次跑WHERE order_id > 上次的最大值,再取接下来1万条。下面三种脚本都是这个思路。
3.2 方案A:SQL*Plus SPOOL导CSV(推荐给绝大多数场景)
这是最通用、最好理解、环境依赖最低的方案。把下面脚本存成export_orders.sql:
SET ECHO OFF SET FEEDBACK OFF SET HEADING OFF SET PAGESIZE 0 SET LINESIZE 32767 SET TRIMSPOOL ON SET TRIMOUT ON SET TERMOUT OFF SET VERIFY OFF SPOOL /tmp/batch_0001.csv SELECT order_id || ',' || '"' || REPLACE(REPLACE(NVL(customer_name,''),'"','""'),CHR(10),' ') || '"' || ',' || TO_CHAR(order_date,'YYYY-MM-DD HH24:MI:SS') || ',' || NVL2(amount, TO_CHAR(amount,'FM999999990.00'), '') || ',' || '"' || REPLACE(REPLACE(NVL(status,''),'"','""'),CHR(10),' ') || '"' FROM ( SELECT * FROM orders WHERE order_id > &last_order_id ORDER BY order_id ) WHERE ROWNUM <= 10000; SPOOL OFF EXIT执行时:
sqlplus mig_user/mig_pass@source_db @export_orders.sql&last_order_id是SQL*Plus替换变量,第一次执行时输入0,之后每批传入上一批记录的max_order_id(可以从mig_batch_log表查,也可以手工记录)。
上面脚本里的每个SET参数都值得说清楚。HEADING OFF去掉列头,否则CSV第一行是字段名,导入时还得SKIP;PAGESIZE 0去掉分页符,避免每个页面之间出现空行;LINESIZE 32767把行宽拉满,防止长字段被截断换行;TRIMSPOOL ON去掉行尾空格,否则每行后面会跟一串空格,CSV文件看起来正常,但SQL*Loader解析时会多出空白字符;TERMOUT OFF不往屏幕打印,纯写文件,大幅降低导出时间;FEEDBACK OFF去掉“10000 rows selected”之类的输出,这些杂讯混进SPOOL文件就废了。
这个方案的缺点是每导出一批要手动改一次&last_order_id,人工操作量大。所以它最适合临时导出两三批的情况,比如验证第一批数据格式、干跑测试导入流程。要长期跑几十批,还是得上方案B。
3.3 方案B:Oracle存储过程 + UTL_FILE(自动化首选)
UTL_FILE可以把文件直接写到数据库服务器本地目录,比如/u01/export。首先要建目录并授权:
CREATE OR REPLACE DIRECTORY EXPORT_DIR AS '/u01/export'; GRANT READ, WRITE ON DIRECTORY EXPORT_DIR TO MIG_USER;然后写一个批量导出过程,参数化批次大小和起始主键:
CREATE OR REPLACE PROCEDURE export_orders_batch ( p_batch_size IN NUMBER DEFAULT 10000, p_last_order_id IN NUMBER DEFAULT 0, p_csv_file_name IN VARCHAR2 ) AS f UTL_FILE.FILE_TYPE; v_batch_max NUMBER := p_last_order_id; v_row_count NUMBER := 0; v_customer VARCHAR2(4000); v_status VARCHAR2(4000); BEGIN f := UTL_FILE.FOPEN('EXPORT_DIR', p_csv_file_name, 'W', 32767); FOR rec IN ( SELECT order_id, customer_name, order_date, amount, status FROM ( SELECT * FROM orders WHERE order_id > p_last_order_id ORDER BY order_id ) WHERE ROWNUM <= p_batch_size ) LOOP v_customer := REPLACE(REPLACE(NVL(rec.customer_name,''), '"', '""'), CHR(10), ' '); v_status := REPLACE(REPLACE(NVL(rec.status,''), '"', '""'), CHR(10), ' '); UTL_FILE.PUT_LINE(f, rec.order_id || ',' || '"' || v_customer || '",' || TO_CHAR(rec.order_date, 'YYYY-MM-DD HH24:MI:SS') || ',' || NVL2(rec.amount, TO_CHAR(rec.amount, 'FM999999990.00'), '') || ',' || '"' || v_status || '"' ); v_batch_max := rec.order_id; v_row_count := v_row_count + 1; END LOOP; UTL_FILE.FCLOSE(f); INSERT INTO mig_batch_log(csv_file_name, min_order_id, max_order_id, row_count) VALUES (p_csv_file_name, p_last_order_id + 1, v_batch_max, v_row_count); COMMIT; EXCEPTION WHEN OTHERS THEN IF UTL_FILE.IS_OPEN(f) THEN UTL_FILE.FCLOSE(f); END IF; RAISE; END;这个存储过程的细节我解释一下。游标里依然是“先按主键排序、再ROWNUM截断”的两层结构,保证每次取出来的一定是按order_id升序的前N条。循环里用变量存v_batch_max,循环结束时它就是本批实际最大主键;v_row_count是本批实际导出的行数。最后INSERT到批次日志表,为下一次续传做边界记录。
调用方式:
BEGIN export_orders_batch( p_batch_size => 10000, p_last_order_id => 0, p_csv_file_name => 'batch_0001.csv' ); END;如果要连续跑多批,可以用一个外层循环,每次从mig_batch_log表查最后一个max_order_id,然后调用存储过程。这样整批迁移就能全自动跑完,到凌晨四点断了也不怕,第二天查日志表就知道从哪个订单号继续。
有几个存储过程相关的点要提醒。UTL_FILE.FOPEN的第四参数是单行最大字节数,这里设置32767,如果某行拼接后超过这个长度,直接报错。CLOB内容很大的表不要硬塞进这行,正如前面说的,要么截断要么单独处理。另外UTL_FILE默认写的是服务器本地文件,最终要把CSV从服务器取回本地传输给目标库,要么在同一个内网,要么用SCP拉取,这步别漏规划。
3.4 方案C:Python连接Oracle查询并写CSV(灵活首选)
Python方案适合那种“SQL不好拼、文件需要切分、编码需要特殊处理”的场景。它的最大优势是Python标准库里的csv模块帮你处理了引号、逗号、换行转义,不用自己在SQL里手工拼一串REPLACE。
Python连接Oracle,老项目一般用cx_Oracle,新项目推荐python-oracledb,它是cx_Oracle的继任者,默认Thin模式不需要额外装Oracle Client,直接连就行:
import csv import oracledb conn = oracledb.connect(user="mig_user", password="mig_pass", dsn="db_host:1521/orclpdb1") cur = conn.cursor() cur.arraysize = 5000 sql = """ SELECT order_id, customer_name, order_date, amount, status FROM ( SELECT * FROM orders WHERE order_id > :last_id ORDER BY order_id ) WHERE ROWNUM <= :batch_size """ last_id = 0 batch_size = 10000 cur.execute(sql, {"last_id": last_id, "batch_size": batch_size}) with open("batch_0001.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL) for order_id, customer_name, order_date, amount, status in cur: writer.writerow([ order_id, customer_name, order_date.strftime("%Y-%m-%d %H:%M:%S") if order_date else "", amount, status ])Python方案里,csv.writer会自动判断字段里是否需要加引号,比手拼SQL省心太多。日期字段因为Oracle返回的是datetime.datetime对象,需要自己格式化成字符串;如果直接写进CSV,默认格式可能不是你要的。
cur.arraysize = 5000是Python连接数据库的一个性能参数,它控制每次从数据库Fetch的行数。在这个1万行的小批次里影响不大,但如果你以后扩展到十万、百万行,这个参数忘了调性能会差很多。
Python方案还可以顺手做文件切分。比如导出一个大CSV后,按1万行一个小文件切分,可以直接在内存里控制写完一批就flush。这其实比“导完再切文件”更干净,因为不会出现把一个多行字段从中间切断的问题。热搜词里那个“CSV文件分割神器2.0”也是这个思路,但在Python脚本里自己控制分片,可控性更强,也不会把被引号包裹的换行字段切坏。
3.5 导入目标库:SQL*Loader控制文件写法和校验
CSV导出来,最终要灌进目标库。Oracle最标准的CSV导入工具就是SQL*Loader。控制文件batch_0001.ctl这样写:
OPTIONS (SKIP=0, ROWS=10000) LOAD DATA INFILE 'batch_0001.csv' APPEND INTO TABLE target_orders FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' TRAILING NULLCOLS ( order_id INTEGER EXTERNAL, customer_name CHAR(200), order_date DATE "YYYY-MM-DD HH24:MI:SS", amount DECIMAL EXTERNAL, status CHAR(20) )执行命令:
sqlldr userid=target_user/target_pass@target_db control=batch_0001.ctl log=batch_0001.log bad=batch_0001.bad几个关键点说明一下。FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'对应了我们导出的双引号包裹逻辑;TRAILING NULLCOLS保证CSV每行末尾空字段被解析为NULL而不是报“列数不够”;ROWS=10000是每1万行提交一次,让事务控制在一个可接受的范围。
导入完成后必须做三层校验。第一批:查询目标库SELECT COUNT(*) FROM target_orders,对比CSV实际行数和批次日志表的row_count。第二批:查询目标库MIN(order_id)和MAX(order_id),和源库这一批的边界对比,防止导错区间。第三批:抽查CSV首尾几行数据,对比目标库对应的主键记录,确认关键业务字段一致。
这里特别提醒:ROWS=10000只对常规路径导入生效。如果用了DIRECT=TRUE,那是直接路径加载,绕过了Oracle的undo和redo,性能快但事务语义不同,一旦出错恢复麻烦。迁移初期或数据量不大时,我建议先用常规路径跑一遍稳定流程,确认无误后再考虑是否用direct模式提速。
4. 常见问题排查与避坑实录
4.1 中文乱码:先查NLS_LANG,再看SQL*Loader字符集
这个坑我至少踩过三次。症状就是CSV里中文全部变成问号或乱码,但数字、英文正常。排查时先不要怀疑数据本身,而是看导出环境。
在SQL*Plus里执行:
SELECT userenv('language') FROM dual;执行结果通常是AMERICAN_AMERICA.AL32UTF8或SIMPLIFIED CHINESE_CHINA.ZHS16GBK之类。SQL*Plus拿到了这个环境变量,用对应的编码去解释客户端传来的所有内容,包括SQL和SPOOL输出的数据。如果数据库字符集是AL32UTF8,而你的NLS_LANG设成了ZHS16GBK,导出的CSV文件就会以GBK编码写入,内容本身可能也是对的,但后续工具如果按UTF-8打开,自然全是问号。
解决方法就是让导出端NLS_LANG和目标读取端一致。Linux下:
export NLS_LANG=AMERICAN_AMERICA.AL32UTF8Windows下还要注意命令行的活动代码页,最好先chcp 65001,再启动SQL*Plus。
SQL*Loader导入端也要配套。控制文件里可以加一行:
CHARACTERSET UTF8否则SQL*Loader会按数据库默认客户端字符集去解析CSV文件,CSV是UTF-8它却按GBK读,结果还是乱。导出、传输、导入这三段编码必须统一,这个原则在任何数据迁移中都适用。
4.2 CSV字段里有逗号、引号、换行导致导入错列
最典型的问题就是:字段值里带着英文逗号,不包引号直接导出。比如客户名称是“北京,有限公司”,导入SQL*Loader时被拆成了两列,后面的字段全部错位。
解决方式我前面已经给了:字符型字段一律走双引号包裹 + 双引号转义 + 换行替换。但如果你的数据是从历史系统里导出来的,或者别人的脚本没有做转义,就得在导入前做一次检查。可以用Python的csv标准库读一遍CSV,看每行列数是否一致:
import csv with open("batch_0001.csv", "r", encoding="utf-8") as f: reader = csv.reader(f) for line_no, row in enumerate(reader, 1): if len(row) != 5: print(f"第{line_no}行列数异常: {len(row)}") break这段脚本不用额外装任何包,干活非常实用。注意别用简单的awk -F',' 'NF != 5'去筛,因为字段里如果带着逗号且被双引号包裹,awk按物理逗号切分得到的NF会比实际列数多。
还有一点:如果你不得不导入一个字段内带换行的CSV,SQL*Loader本身支持,但Excel打开会显示多行。迁移项目如果要交付给业务人员确认数据,我建议还是老实把换行替成空格。你可以在SQL里REPLACE(col, CHR(10), ' '),也可以在Python里col.replace("\n", " ")。这看起来是丢了一点点原始格式,但换来的是后续所有环节的省心。
4.3 导出慢:索引、arraysize和排序策略一个都别落
“按主键顺序提取前10000条”如果执行计划不对,慢到你怀疑人生。最常见的问题是把ORDER BY order_id放到了外层,Oracle被迫做全表排序,而不是走主键索引。
我的排查顺序是:先执行计划。用:
EXPLAIN PLAN FOR SELECT * FROM ( SELECT * FROM orders WHERE order_id > 0 ORDER BY order_id ) WHERE ROWNUM <= 10000; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);看Plan里有没有INDEX FULL SCAN或INDEX RANGE SCAN,有没有SORT ORDER BY。如果出现SORT ORDER BY,说明Oracle不是靠索引天然顺序取数据,而是先全量捞出来再排序,这就慢了。正确情况下应该是INDEX FULL SCAN,因为主键索引本身有序,Oracle可以直接从头扫索引拿前1万行。
SQLPlus导出时还有一个隐藏参数:arraysize。SQLPlus默认一次从数据库Fetch 15行,1万行要Fetch 667次,网络来回开销很高。在脚本开头加:
SET ARRAYSIZE 5000Fetch次数降到2次,导出速度会明显提升。这个对UTL_FILE和Python不太明显,因为PL/SQL引擎和Python驱动有自己的Fetch策略,但SQL*Plus场景值得设置。
如果源库表数据量特别大,批次很多,还可以考虑用并行通道导不同批次区间。但注意:多个并行任务同时导“前1万条”这种ROWNUM截断查询,边界特别容易乱,必须严格用主键区间切分(比如任务A导order_id 1-100万,任务B导1000001-200万),导完在不同CSV文件里,最后合并。千万不要并行写同一个CSV,那产生的结果文件行序会完全失控。
4.4 批次中断后怎么续传:日志表 + 半成品清理
迁移最怕什么?导到一半断了,然后没人知道断在哪。如果没有批次日志表,你只能打开CSV看最后一行,甚至要重新数一遍主键范围。所以我一直在强调mig_batch_log表必须建,它解决的就是“这个烂摊子从哪继续”的问题。
中断后的操作顺序我总结成三步。
第一步,查日志表拿到最后一个成功批次的max_order_id:
SELECT MAX(max_order_id) FROM mig_batch_log;第二步,把半成品CSV文件改名或删掉。半成品CSV的最后一行可能只有半条记录,直接导入会让SQL*Loader报错,就算不报错,数据也是残缺的。
第三步,把上一批成功导入的数据和目标库核对一遍,确认目标库里没有留下半批数据。如果目标库导入也中断过,要清理掉那一批次已插入的行,再重新导这个批次。这个清理动作非常关键,否则续传时源库从正确的主键位置开始导,目标库却已经有部分相同主键的行,要么重复要么冲突。
一个小技巧:导完一批后,在CSV文件名同目录放一个空标记文件,比如batch_0001.csv.ok。启动导入前先检查.ok文件是否存在,存在才允许导入。这个标记文件配合批次日志表,能过滤掉绝大多数“文件没导完但任务还在跑”的事故。
4.5 老版本Oracle分页写法的兼容性
如果你还在维护Oracle 11g,FETCH FIRST语法不能用,这个要注意。标题里的需求场景经常出现在老系统迁移项目里,我见过不少人在11g上写:
SELECT * FROM orders ORDER BY order_id FETCH FIRST 10000 ROWS ONLY;结果直接报ORA-00933。所以导数据前先确认源库版本。11g及以下用ROWNUM套子查询的写法,12c及以上可以直接FETCH FIRST。还有一种更保险的写法是ROW_NUMBER(),虽然性能略差,但兼容性最好,从9i到19c都能跑。
另外,如果源库是11g,目标库是19c,你完全可以在导出端用老语法,导入端用新版SQL*Loader,互不干扰。只要CSV文件格式统一,数据库版本差异不会在这里制造麻烦。
4.6 延伸到Oracle EBS WIP工单和存储过程批处理
热搜词里很多人在搜Oracle EBS WIP工单核心表、非标工单相关的数据迁移,这里顺带说几句。像WIP_DISCRETE_JOBS、WIP_OPERATIONS这类表,主键往往是WIP_ENTITY_ID或者工单号,而且表之间有大量外键关联。如果只是按主键顺序导CSV,能导出来,但导入目标库时大概率因为外键引用、接口表状态不一致导致业务对不上。
做EBS WIP数据迁移时,我一般不建议只导核心业务表,而是把相关的接口表、关联表、子表整套一起按同一批次边界导出,保持数据整体性。比如以WIP_ENTITY_ID作为批次键,把工单头、工单工序、物料事务处理表都过滤同一个工单区间,导出的CSV文件按批次打包。这个思路和标题里的“按主键顺序分批”完全一致,只是主键从订单ID换成了工单ID。
存储过程批处理在这类场景里价值更大。一次跑完几十个批次,每批调用一次export_orders_batch这种过程,批次日志表记录每个WIP工单区间的边界。配合SQL*Loader的导入端脚本,整个WIP工单迁移可以实现全自动化,出问题只看日志表就能定位到具体工单区间,这对EBS这种复杂业务系统尤其重要。
最后分享一个我个人的实操习惯
这批迁移做完之后,我给自己定了一个规矩:任何“按主键分批导CSV”的任务,第一次跑一定先抽样干跑。流程是取一小批数据(比如100条),完整走一遍“导出CSV → 校验行数 → SQL*Loader导入 → 目标库复核”,确认格式、字符集、边界逻辑全都没问题,再把批次循环打开跑全量。干跑看起来很浪费时间,但它能提前暴露至少80%的格式和编码问题,省下的返工调试时间远大于这十分钟。
另外一个小技巧:所有导出的CSV文件,无论批次大小,都保留归档,别导完就删。迁移上线后如果两边数据对不上,CSV原始文件是最后的追溯依据。我在日志表里还会加一列存CSV的MD5值,虽然很少用到,但真到扯皮的时候,它能证明“这个文件从导出到导入期间没有被改过”。数据迁移说到底就是个细心活,把边界记清楚、把格式焊死、把断点续传做扎实,再大的表也能稳稳搬过去。