☰
Oracle按主键取前1万条数据迁移:ROWNUM陷阱、CSV导出与校验
2026/10/15 11:41:07 网站建设 项目流程

先交代一下背景。前阵子朋友找我,说他在做一个Oracle数据迁移的小任务:一张几千万行的业务流水表,不需要全量,只需要按主键顺序提取前10,000条记录,导成CSV交给下游测试环境使用。听起来很简单,不就是ROWNUM加个ORDER BY吗?真动手才发现,这里面的坑一个接一个——取数顺序不对、CSV中文乱码、导进去之后才发现某些字段丢了、数据校验对不上数。我前后折腾了两轮,才把整条链路理顺。

这篇东西就是把这些经验整理出来,从取数SQL的正确写法、CSV导出的格式细节,到目标库导入的三种方式对比,再到迁移完成后的校验方法,一次性讲透。适合正在做Oracle小批量数据迁移、想把部分数据搬到测试库或交接给其他团队的开发、DBA朋友参考。

1. 为什么“按主键顺序取前10,000条”值得单独开一篇

1.1 这类迁移的真实场景很常见

可能很多人第一反应是:全量迁移用Data Pump不就行了,为什么非得搞个CSV、还只取前一万条?

实际工作中这类“取部分数据”的需求非常普遍。我随手列几个:

  • 测试环境造数:生产库几千万行,不可能全量灌到测试库,只需要一份有代表性的子集;
  • 数据交付:把某段时间或某个范围的数据导出CSV交给数据需求方,对方要能直接用Excel或导入其他系统处理;
  • 分批迁移试点:大表整体切换前,先迁移一小批数据验证流程、校验规则、目标端性能,跑通了再全量做;
  • 归档清理:把历史流水按主键顺序切段,一批一批导出存档,导完一批删一批。

在这些场景里,expdp/impdp其实也能做条件导出,但有两个痛点:一是它生成的.dmp文件是二进制格式,下游如果用的是MySQL、SQL Server或者干脆只想看CSV,完全没法直接用;二是Data Pump的查询条件要写到QUERY参数里,想表达“排序后的前N条”这种语义,SQL写得会很别扭。所以CSV才是这批场景下最通用的载体。

1.2 主键排序带来的三个红利

“按主键顺序”这几个字不是拍脑袋加的,它直接决定了整个迁移方案的质量。

第一个红利是可重复性。按主键排序后的结果集是确定的——同一时刻、同一查询条件,跑出来的永远是同一批数据。如果你不加排序直接取ROWNUM <= 10000,Oracle取的是物理存储顺序里的前一万行,而物理顺序会受数据块变化、并发插入影响,这次取出的是A集合,下次可能就带上新插入的行了。数据子集不稳定,后面所有校验都无从谈起。

第二个红利是断点续传。按主键排序后,第一万条记录的主键值就是天然的书签。第二批迁移可以直接从PK_ID > 10000继续,第三批从PK_ID > 20000继续,不需要维护任何额外的游标状态,SQL本身就把进度记下了。

第三个红利是校验和排查方便。取出记录的主键范围是连续的(或有规律的),迁移完成后核对目标端最大主键、最小主键、行数,几个SQL一跑就知道有没有丢数据。全乱序取数的话,连“少了哪条”都很难定位。

1.3 先明确源表假设

为了让后面的示例不那么抽象,我虚构一张典型的业务流水表,后面所有SQL都以它为基础:

CREATE TABLE t_biz_log ( pk_id NUMBER(12) NOT NULL, -- 主键,自增数字 biz_type VARCHAR2(20) NOT NULL, -- 业务类型 amount NUMBER(12,2), -- 金额 create_time DATE, -- 创建时间 remark VARCHAR2(500), -- 备注 CONSTRAINT pk_t_biz_log PRIMARY KEY (pk_id) );

表里假设有3000万行,主键接近连续自增(中间会有少量删除导致的断号)。接下来所有取数、导出、校验的方法,都围绕这张表展开。

2. 取数SQL的三种写法,以及那个最经典的ROWNUM陷阱

2.1 最容易被坑的写法:ROWNUM和ORDER BY的顺序问题

先把最容易翻车的写法亮出来——很遗憾,这也是很多教程里经常出现的错误写法:

-- 错误写法 SELECT * FROM t_biz_log WHERE ROWNUM <= 10000 ORDER BY pk_id;

这条SQL的执行顺序是:先对全表做扫描,给前一万行按访问顺序分配ROWNUM,然后再对整个结果按pk_id排序。也就是说,ORDER BY发生在ROWNUM之后,最终拿到的是“物理顺序前一万条”补了个排序外壳,根本不是主键最小的那一万条。

打个比方:银行柜台排队取号,没排序之前,谁先挤到机器前面谁先拿号,等到柜台叫号的时候你按号码排了序——号码序列看着整齐,实际上先排队的人可能根本没拿到靠前的号。

为什么这个坑特别容易踩?因为表数据量小的时候,全表扫描出来的物理顺序和主键顺序往往恰好接近,结果碰巧看起来是对的。一旦数据量大、RAC环境下不同节点插入的数据分布错乱,这个SQL立刻翻车,而且翻得很隐蔽——不报错,就是数据不对,上线后才是噩梦。

2.2 11g时代的标准写法:子查询先排序再取数

如果你的Oracle还是11g(现在存量系统里真不少),FETCH FIRST语法用不了,标准做法是子查询嵌套:

SELECT * FROM ( SELECT * FROM t_biz_log ORDER BY pk_id ) WHERE ROWNUM <= 10000;

在这个写法里,内层先按主键排序,产出有序结果集,外层再按ROWNUM截断前一万条。逻辑上完全正确,也是11g上最常见、最稳妥的写法。

不过要留意性能:内层的ORDER BY pk_id会触发一次全表排序操作,3000万行的排序可不是闹着玩的,内存不够还会落盘,速度慢到怀疑人生。幸运的是主键上有索引,如果优化器聪明,会改用索引扫描来避免显式排序。但如果没有走索引,你也可以手动加提示:

SELECT * FROM ( SELECT /*+ INDEX(t_biz_log pk_t_biz_log) */ * FROM t_biz_log ORDER BY pk_id ) WHERE ROWNUM <= 10000;

2.3 12c以后的FETCH FIRST写法

如果你的数据库是12c及以上,直接上标准SQL语法:

SELECT * FROM t_biz_log ORDER BY pk_id FETCH FIRST 10000 ROWS ONLY;

这条SQL的可读性最好,执行计划通常会走主键索引有序扫描然后停止,取完一万行立即终止,而不是傻乎乎算完全部排序。从这个角度说,FETCH FIRST比11g的子查询写法更高效。

对应第二批数据,可以写:

SELECT * FROM t_biz_log ORDER BY pk_id OFFSET 10000 ROWS FETCH NEXT 10000 ROWS ONLY;

OFFSET可以越过头一万条,从第一万零一条继续取。但提醒一句:OFFSET在数据量大时性能并不好,它需要从结果集头部开始数,数够一万行才返回。真要做多批次迁移,我更推荐下面这种主键范围切分的方式。

2.4 进阶:用主键范围直接切,性能最好

如果你是做分批迁移,而不是只取一次,那么次优方案是“排序后取N条”,最优方案其实是“按主键范围直接切段”。

假设源表主键最小值是100000(生产库一般不会从1开始)。第一万条之后的主键大致是100999左右(中间有断号的话会有偏差),但如果你的业务表主键是用序列生成的、基本连续,那么可以直接这样取:

-- 第一段 SELECT * FROM t_biz_log WHERE pk_id BETWEEN 100000 AND 100999; -- 第二段 SELECT * FROM t_biz_log WHERE pk_id BETWEEN 101000 AND 101999;

这个写法没有全表排序,走的是主键索引范围扫描,取多少行就扫多少索引,性能是最优的。但它有一个前提:你得知道主键的大致范围和密度。如果断号严重(比如大量删除、回滚),按固定长度切段会导致每段行数不固定,那就需要结合ROWNUM或FETCH FIRST来动态确定边界值,比如:

SELECT MIN(pk_id), MAX(pk_id) FROM ( SELECT pk_id FROM t_biz_log ORDER BY pk_id FETCH FIRST 10000 ROWS ONLY );

拿到这个范围的MIN/MAX,再按边界值精确导一次,这样既保证了数据是前1万条,SQL又走索引。这个方法我自己很常用,相当于把FETCH FIRST和范围扫描结合了起来。

3. 把查询结果变成可靠CSV:SQL*Plus导出与字符集细节

3.1 最小可用方案:spool加colsep

取数SQL确定了,下一步就是把结果落成CSV文件。最轻量的方式是直接用SQL*Plus的spool:

set pagesize 0 set linesize 200 set trimspool on set heading off set feedback off set echo off set termout off set colsep '|' spool /tmp/biz_log_part1.csv SELECT pk_id || '|' || biz_type || '|' || nvl(amount,'') || '|' || to_char(create_time, 'YYYY-MM-DD HH24:MI:SS') || '|' || nvl(remark,'') FROM ( SELECT * FROM t_biz_log ORDER BY pk_id ) WHERE ROWNUM <= 10000; spool off

这段脚本有几个要点:

  • set linesize 200要足够宽,否则一行会被自动折行,CSV就错位了;
  • set trimspool on去掉行尾空格;
  • set heading off不输出列名,如果你希望带表头,可以自己用SELECT拼接一行;
  • 每一列都手动用||拼接,而不是用colsep自动分隔,原因是colsep在字段内容包含分隔符时不会自动转义,容易出问题。

3.2 为什么我建议用竖线而不是逗号

严格来说CSV是“字符分隔值”(Character-Separated Values),逗号只是最常见的一种分隔符,不是必须。业务数据里逗号出现频率太高了(比如“备注:买了两件,其中一件……”),如果选逗号做分隔符,转义逻辑会非常痛苦——要用双引号把含有逗号的字段包起来,遇到字段内的双引号还要再翻倍转义,SQL*Plus默认的SET MARKUP CSV ON在转义上并不完美,很容易导出不合规的文件。

我的实践是用竖线|做分隔符。它在正常业务文本里出现的概率很低,几乎不需要转义。缺点无非是文件后缀仍然是.csv,但Excel打开时手动选一下分隔符就行,下游程序读取也只需要把分隔符参数改掉,完全不是障碍。

如果某个字段确实可能包含竖线(极端情况),可以在SQL里先做替换:

SELECT pk_id || '|' || replace(biz_type, '|', ' ') || ...

3.3 NULL、日期、CLOB这些“刺头”怎么处理

这是最容易踩坑的细节区,一个个说。

NULL值:直接用||拼接时,NULL会被当成空字符串,看起来是a||b这种连续分隔符,CSV解析时那个字段就是空值。但要注意,如果业务上“空字符串”和“NULL”有区别,你需要在SQL里显式区分,比如用NVL(amount, '')。

日期字段:DATE类型不格式化导出,最终文件里是Oracle默认的NLS格式,比如24-APR-25这种,下游读起来非常痛苦。必须用TO_CHAR指定格式。我习惯统一用YYYY-MM-DD HH24:MI:SS,这是最容易被各种解析器识别的格式之一。

CLOB字段:这种大字段不能简单用||拼接,SQL*Plus下超过4000字节会报错。如果确实需要导出CLOB,一种做法是DBMS_LOB.SUBSTR(remark, 4000, 1)截断,另一种是单独用一个SQL导出带主键的CLOB分片,再在目标端重组。大多数场景下截断就够了,但要在文档里注明。

换行符:字段内容里有换行符的话,导出文件会出现“逻辑一行变成物理多行”的错乱。处理方式同样是在SQL里替换掉:

replace(replace(remark, chr(10), ' '), chr(13), ' ')

3.4 中文乱码:NLS_LANG和文件编码是同一个问题

CSV导出中文乱码,源头几乎都在客户端字符集设置和数据库字符集不一致。

SQLPlus生成文件的编码,取决于你执行SQLPlus时所在客户端的NLS_LANG环境变量,以及数据库的字符集。规则是:客户端从数据库取回数据时,Oracle会按NLS_LANG指定的字符集做转换。如果数据库是AL32UTF8,你的NLS_LANG又正确设成了SIMPLIFIED CHINESE_CHINA.AL32UTF8,那么导出的文件内容就是UTF-8编码;如果客户端是GBK环境而程序没设置NLS_LANG,可能得到乱码或GBK内容。

我的建议是:在导出前先确认目标库或下游程序期望的编码。如果下游也是Oracle或多数现代系统,统一UTF-8;如果下游是遗留Windows Excel环境,可能需要GBK。设置方式很简单:

export NLS_LANG=AMERICAN_AMERICA.AL32UTF8 sqlplus user/pass@service <<EOF ... EOF

注意:设置NLS_LANG要在启动SQL*Plus之前,启动后改不起作用。

我见过不止一次:SQL写的完全没问题,CSV打开却是满屏问号,最后发现是NLS_LANG没设置。这个坑极其隐蔽,但排查起来其实很快——file命令看一眼文件编码就清楚了。

4. 目标库导入:SQL*Loader、外部表和直插,怎么选

4.1 三条路线先对比一下

CSV文件到手,目标库可能是另一个Oracle,也可能是开发临时搭的库。导入方式我常用三套,各有各的适用定位:

方式适用场景性能优点缺点
SQL*Loader标准CSV文件批量入库高,万行级别瞬秒成熟稳定,控制字段映射灵活控制文件语法需要熟悉,坏行要处理
外部表CSV已在数据库服务器上中等偏高能直接用SQL查询CSV,导入天然带条件需要DBA创建目录对象,文件权限有讲究
直插(循环INSERT)一次性、数据量极小低最简单,不需要额外工具上万条逐条执行速度堪忧,不推荐

1万条这个量级,三条路其实都能扛住,真正的选择依据是你手上的权限和控制粒度。

4.2 SQL*Loader的ctl文件写法与关键参数

最通用的示范是SQL*Loader。控制文件长这样:

LOAD DATA INFILE '/tmp/biz_log_part1.csv' APPEND INTO TABLE t_biz_log FIELDS TERMINATED BY '|' OPTIONALLY ENCLOSED BY '"' TRAILING NULLCOLS ( pk_id INTEGER EXTERNAL, biz_type CHAR(20), amount DECIMAL EXTERNAL, create_time DATE "YYYY-MM-DD HH24:MI:SS", remark CHAR(500) )

几个容易疏忽的参数:

  • APPEND表示往表里追加数据;如果重新导入需要清空目标表,则用REPLACE或TRUNCATE,但我个人建议先确认表是空的再追加,避免误删;
  • TRAILING NULLCOLS很关键,允许CSV行尾字段为空时仍能正常映射;
  • OPTIONALLY ENCLOSED BY '"'表示如果字段被双引号包裹,就自动去除引号,这是兼容有引号嵌套的CSV;
  • 日期字段必须指定DATE "YYYY-MM-DD HH24:MI:SS",否则会用会话默认的日期格式解析,十有八九报错。

执行命令:

sqlldr user/pass@service control=load_biz_log.ctl log=load_biz_log.log bad=load_biz_log.bad

注意log和bad文件路径。bad文件里是加载失败的行,这个文件一定要检查——加载完10000行,成功率99.9%和100%是两码事,哪怕只坏了3行,数据也是不完整的。

4.3 外部表:如果你有DBA权限,这是最“优雅”的方案

如果CSV文件就在数据库服务器上,而且你有DBA权限,外部表是体验最好的方式。思路很简单:先把CSV“伪装”成一张表,然后直接SQL插入目标表。

第一步,创建目录对象:

CREATE OR REPLACE DIRECTORY ext_dir AS '/data/csv_files'; GRANT READ, WRITE ON DIRECTORY ext_dir TO your_user;

第二步,建外部表:

CREATE TABLE ext_biz_log ( pk_id NUMBER(12), biz_type VARCHAR2(20), amount NUMBER(12,2), create_time DATE, remark VARCHAR2(500) ) ORGANIZATION EXTERNAL ( TYPE ORACLE_LOADER DEFAULT DIRECTORY ext_dir ACCESS PARAMETERS ( RECORDS DELIMITED BY NEWLINE FIELDS TERMINATED BY '|' MISSING FIELD VALUES ARE NULL ( pk_id INTEGER EXTERNAL, biz_type CHAR(20), amount DECIMAL EXTERNAL, create_time DATE "YYYY-MM-DD HH24:MI:SS", remark CHAR(500) ) ) LOCATION ('biz_log_part1.csv') ) REJECT LIMIT UNLIMITED;

然后一条SQL完成导入:

INSERT INTO t_biz_log SELECT * FROM ext_biz_log WHERE pk_id IS NOT NULL; COMMIT;

外部表的好处是,你可以像普通表一样对它做过滤、排序、关联,甚至先做数据清洗再插入。比如只导入biz_type合法的行,或者对remark做REPLACE处理,都在同一条SQL里解决,完全不用碰控制文件语法。

外部表要注意的坑也有:文件的行如果和定义不匹配,查询外部表会直接报错(除非设置了REJECT LIMIT),所以拿到文件后先用SELECT COUNT(*)探一下底,再正式导入。还有一点,外部表文件建议在导入完成后保留一段时间,方便追溯。

4.4 导入顺序性:一个看起来不重要但其实有用的点

有人会问:我导出的时候按主键排了序,但导入的时候Oracle又不是按这个顺序物理存储的,排序还有意义吗?

答案是:排序的意义不完全在存储,而在于可追踪和断点续传。导入的时候,理想情况下你应该按主键顺序一条条插入,这样如果导入中途失败,从日志里看到最后成功的主键是哪个,就能精确知道还差哪些数据;如果乱序导入,失败了一个批次,你得重新算增量,非常痛苦。

另外,如果目标表是索引组织表(IOT)或按主键做了PARTITION BY RANGE分区,按主键顺序插入能显著减少索引分裂和分区切换的开销。即便对普通堆表,顺序插入对索引维护也更友好。所以导出的排序在导入侧不是浪费,是有实打实收益的。

5. 迁移完成后的校验清单,以及一份真实踩坑记录

5.1 五个必须核对的项目

导入完成,千万别急着宣布“迁移成功”。每次我做完这种小批量数据迁移,下面五件事是一步都不能省的:

第一,行数核对。源端和目标端分别跑COUNT(*),两边必须是同一个数。如果源端是“小于等于10000”而不是恰好10000(主键断号导致最终结果略少),两边数字也得一致。

SELECT COUNT(*) FROM t_biz_log WHERE pk_id <= 100999; -- 源端按范围核对 SELECT COUNT(*) FROM t_biz_log; -- 目标端全表核对

第二,主键边界核对。源端的MIN(pk_id)和MAX(pk_id)、目标端对应值必须完全一致。这个能快速确认没有“多导”“少导”“错导”。

第三,抽样内容核对。按主键随机抽50条左右,把biz_type、amount、create_time、remark逐个比对。抽样其实比全量校验更能发现隐藏问题,比如字段错位、日期格式被悄悄改掉、NULL变成了0这类。

第四,类型转换核对。重点核对NUMBER和DATE类型。CSV是纯文本,导入时如果DECIMAL EXTERNAL定义少了小数位,金额就会丢精度;日期格式串写错,时间可能变成0000-00-00。这些光看行数看不出来,必须抽几行看原始值。

第五,字符集验证。导入后中文没有乱码算是底线,但还要确认不是“看起来正常、实际编码不对”。我一般会在目标端跑一下:

SELECT remark, length(remark), dump(remark) FROM t_biz_log WHERE pk_id = 100001;

dump函数返回字节流,能看出实际存储的是不是UTF-8。如果目标端字符集配置不对,中文可能已经变成了替换字符而显示上还不容易察觉。

5.2 导出期间源表被更新了怎么办:一致性快照

导出数据不是瞬间完成的,尤其在几千万行的大表上跑排序,可能要跑几十秒甚至几分钟。这个窗口内如果有并发事务在更新表,导出结果可能不是一个一致性的快照——前半段读到的是旧数据,后半段读到的是新数据。

保证一致性有两个思路:一是协调业务方冻结写入窗口(适合可以接受的场景);二是用Oracle的闪回查询,把查询锚定到一个时间点:

SELECT * FROM t_biz_log AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '1' MINUTE) ORDER BY pk_id FETCH FIRST 10000 ROWS ONLY;

或者更精确地用SCN:

SELECT * FROM t_biz_log AS OF SCN 123456789 ORDER BY pk_id FETCH FIRST 10000 ROWS ONLY;

AS OF能确保整个查询读到的都是同一时间点的数据,不受中间并发修改影响。注意:闪回查询依赖UNDO保留期,如果查询时间点太老会报ORA-01555。实操中我会在导出前先查一下UNDO保留时间,选个安全的时间点。

5.3 我实际踩过的几个坑

第一个坑就是文章开头说的ROWNUM和ORDER BY顺序。当时我图省事,直接在外层加了ORDER BY,结果导出来的数据主键不是从最小开始,排查了半天才发现SQL逻辑问题。这个教训是:凡是涉及取数,先认真翻译一遍SQL的执行顺序,不要相信“看着像对的”。

第二个坑是CSV文件里的换行符导致导入错位。源表有个备注字段,业务人员填写时喜欢敲回车。导出后肉眼根本发现不了,SQL*Loader却把一行拆成了两行,加载报错。后来我养成了习惯:导出前统一把chr(10)、chr(13)替换成空格,宁可损失一点点文本格式,也要保证文件行结构稳定。

第三个坑是外部表文件权限。外部表对文件的读取权限是Oracle操作系统进程的,不是你自己当前的shell用户。有次我把CSV放在自己家目录下,权限是700,Oracle进程读不了,查询外部表直接报“文件不存在”。实际上文件在,就是权限不对。后来我统一把CSV放在DBA指定的共享目录下,并确保权限是644。

第四个坑是NLS_LANG和文件编码。那次导出的CSV在Linux上用file命令看是UTF-8,但下游同事用Windows Excel打开就是乱码,最后只好重导成GBK版本。说到底,导出的编码必须在动手前就确认清楚,而不是事后补救。

5.4 校验时的一个加分项:抽样对比SQL

抽样对比如果靠人工肉眼一条条看,1万条也能看瞎。我写了一个简单的SQL,能自动对比源端和目标端某些关键字段:

-- 源端:按主键范围汇总校验值 SELECT COUNT(*) AS cnt, SUM(amount) AS sum_amt, MIN(create_time) AS min_time, MAX(create_time) AS max_time FROM t_biz_log WHERE pk_id BETWEEN 100000 AND 100999; -- 目标端同样执行,两个结果集人工比对 SELECT COUNT(*) AS cnt, SUM(amount) AS sum_amt, MIN(create_time) AS min_time, MAX(create_time) AS max_time FROM t_biz_log;

SUM、MIN、MAX这些聚合值的差异,基本能覆盖绝大多数漏导、错导问题。如果连这些都对不上,再往下精查也不迟。

最后分享一个我自己的操作习惯

整个流程跑熟之后,我现在做这类Oracle数据迁移,顺序几乎固定不变:先明确主键范围和字符集要求,再用FETCH FIRST或主键范围切分取数,导出到CSV前先做字段清洗和格式化,然后根据权限选外部表或SQL*Loader导入,最后必须完成五个核对项才宣布收工。

有一个小技巧值得单独说:CSV文件和对应的SQL脚本、控制文件,我都会按批次命名归档,类似biz_log_batch1.sql、biz_log_batch1.csv、load_biz_log_batch1.ctl这种。数据迁移出了问题是常态,有了这几个文件,回溯起来会非常快——查到哪一批、用的什么条件、加载时有没有bad file,一目了然。别小看这个习惯,关键时刻能帮你省下半天排查时间。

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

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

立即咨询