Oracle 迁 PostgreSQL 的第一个坑:'' 在 Oracle 是 NULL,迁移当天数据一行不差,切换后同一条 INSERT 整批失败
在 Oracle 23ai Free(23.26.3)和 PostgreSQL 17.11 测试库(自造 100 万行)上对照实测空字符串:Oracle 把 '' 当 NULL,迁移导出后 PG 里 20000 个 NULL、0 个 '',核对完全一致;切换后同一条批量 INSERT 在 Oracle 写入 10000 行,在 PG 因唯一列 email='' 报 23505、0 行写入;修掉 email 后 phone 出现 200 个 '',IS NULL 统计少了 200 行;'tel:'||phone 在 PG 变成 NULL(20000 行)。附执行计划对比(Oracle 单列索引不存全 NULL 键走全表 7521 块,PG Index Only Scan 22 块)、CHECK 与触发器两种防线的写入代价(+5–10% 对 +50%)、只读扫描 SQL 和一键复跑脚本。
Oracle 23.26.3 与 PostgreSQL 17.11 实测(测试库、自造数据,2026-10-30):Oracle 里 '' IS NULL 为真,所以从 Oracle 迁过来的数据全是 NULL,迁移校验看不出任何问题。问题出在切换之后:应用还按 Oracle 习惯写 '',PG 照单全收。本次同一条 1 万行 INSERT,Oracle 成功,PG 因唯一索引上的两个 '' 冲突整批失败;改好后 phone 列 NULL 和 '' 并存,报表 IS NULL 少算 200 行,'tel:'||phone 拼接结果从 'tel:' 变成 NULL。我的判断:迁移评估里 '' 应该排在语法转换前面先扫;PG 侧用 CHECK (col <> '') 让错误当场暴露,不要用触发器悄悄转成 NULL。
适用对象
- Oracle 23.26.3 里 '' IS NULL 为 Y,length('') 是 NULL,'A'||NULL||'B' = 'AB';PostgreSQL 17.11 里 '' 不是 NULL,length('')=0,'A'||NULL||'B' 是 NULL。Oracle 官方文档写明「目前」把长度为 0 的字符值当 NULL,并建议不要依赖这一点。
- 迁移当天看不出问题:Oracle 100 万行里每 50 行写一次 '',存下来就是 20000 个 NULL;导出 CSV、\copy 进 PG(2.79–3.25 s)后,phone IS NULL = 20000、phone = '' 为 0,行数和 NULL 数全对得上。
- 切换后同一条 INSERT 结果不同:插 1 万行(其中 200 行 email 和 phone 为 ''),Oracle 0.04–0.18 s 写入 10000 行;PG 第二个 email='' 撞唯一索引报 23505,整批 0 行。Oracle 唯一列可以放多个 '',因为它们都是 NULL。
- 修掉 email 后 phone 仍写 '':PG 里 IS NULL = 20000、= '' = 200,按 IS NULL 统计的报表少了 200 行;WHERE ('tel:'||phone) IS NULL 在 Oracle 是 0 行,在 PG 是 20000 行。
- 两种防线的代价:每批 20 万行(2% 为 ''),不设防稳态 259–272 ms;CHECK (phone <> '') 写干净数据 275–346 ms(约 +5–10%),遇到 '' 直接报 23514;BEFORE INSERT 触发器 NULLIF 404–492 ms(约 +50%),而且把问题藏了起来。
同一个 '',两边的 7 种行为
下面每一行都是在两边各执行一次的结果。Oracle 一侧统一用 SQL*Plus,PostgreSQL 一侧用 psql。
本节判断
- NOT NULL 那一行是反方向的坑:Oracle 里靠 NOT NULL 挡住的空输入,到 PG 会以 '' 的形式进库。
- PostgreSQL 文档写明 concat 会忽略 NULL 参数,所以拼接场景改用 concat 能保持和 Oracle 一样的结果,|| 不行。
| 写法 | Oracle | PostgreSQL | 会影响什么 |
|---|---|---|---|
| '' IS NULL | Y(真) | false | 所有 IS NULL / IS NOT NULL 判断 |
| length('') | NULL | 0 | 按长度判断空值的校验 |
| 'A' || NULL || 'B' | 'AB' | NULL | 拼接地址、电话、显示名 |
| NVL('', 'fallback') / coalesce('', 'fallback') | 'fallback' | '' | 缺省值兜底 |
| 唯一列连续插两行 '' | 成功(两行都是 NULL) | 第二行报 23505 duplicate key,Key (email)=() | 唯一约束、批量写入 |
| NOT NULL 列插 '' | ORA-01400 无法插入 NULL | 成功写入 '' | 必填校验 |
| concat('A', NULL, 'B') | 'AB' | 'AB'(concat 忽略 NULL) | 迁移改写时可用来替代 || |
迁移当天:100 万行导出导入,NULL 数一个不差
Oracle 一侧用 INSERT /*+ APPEND */ 造 100 万行(4.87–5.78 s),其中 20000 行的 phone 和 email 写的是 ''。查询结果:phone IS NULL = 20000,phone = '' 为 0——'' 在写入时就变成了 NULL。段大小:表 60 MB,phone 索引 26 MB,email 唯一索引 27 MB。
导出成 CSV,用 psql 的 \copy 导进 PostgreSQL(2.79–3.25 s,heap 81 MB,phone 索引 30 MB)。核对:总行数 1000000,phone IS NULL = 20000,phone = '' 为 0。行数、NULL 数、抽样都对得上,这一步任何校验脚本都会给通过。
所以「数据迁移校验通过」和「'' 问题不存在」是两回事:Oracle 里根本存不下 '',迁移过来的数据当然没有 ''。风险全部在切换之后的写入路径上。
同一条 1 万行 INSERT:Oracle 成功,PostgreSQL 整批 0 行
模拟切换后应用继续按老习惯写数据:一条 INSERT … SELECT 写 1 万行,其中 200 行 phone 和 email 是 ''。
Oracle:0.04–0.18 s 写入 10000 行,phone IS NULL 变成 20200,phone = '' 仍为 0。唯一索引上多出的 200 个 '' 都是 NULL,不冲突。
PostgreSQL:第二个 email = '' 撞上唯一索引,报 ERROR 23505 duplicate key value violates unique constraint,Key (email)=()。这是单条语句,整批回滚,0 行写入。如果这是一个订单同步任务,表现就是「切换后某天开始整批同步失败」,日志里只有一个看不出原因的空键。
把 email 改成写 NULL、phone 仍写 '' 再跑一次,PG 写入成功,但 phone 列变成 IS NULL = 20000、= '' = 200。原来按 phone IS NULL 统计「未留电话」的报表,从切换这天起少算这 200 行,两边加起来才等于 Oracle 的 20200。
| 检查项 | Oracle 23.26.3 | PostgreSQL 17.11 |
|---|---|---|
| 同一条 INSERT(email、phone 都写 '') | 成功,10000 行,0.04–0.18 s | 23505,0 行 |
| email 改写 NULL 后 | — | 成功,10000 行 |
| phone IS NULL | 20200 | 20000 |
| phone = '' | 0 | 200 |
| WHERE ('tel:' || phone) IS NULL | 0 行 | 20000 行 |
| concat('tel:', phone),phone 为 NULL 时 | — | 'tel:' |
WHERE phone IS NULL:Oracle 单列索引用不上,PG 改写成 IS NULL OR = '' 后慢 5 倍
Oracle 的 B-tree 索引不存所有键列都为 NULL 的行(官方 Concepts 文档原话:does not index table rows in which all key columns are null,位图索引除外)。所以在 Oracle 上 WHERE phone IS NULL 不走单列 phone 索引:TABLE ACCESS FULL,7521–7522 个缓冲块,0.01–0.04 s。Oracle 上常见的做法是建 (phone, 0) 复合索引,让 NULL 行也进索引:建索引 0.36–0.45 s、28 MB,之后 INDEX RANGE SCAN,42 个缓冲块,约 0.00–0.01 s。
PostgreSQL 的 B-tree 会存 NULL,同样的 WHERE phone IS NULL 是 Index Only Scan,22 个缓冲块,1.75–2.05 ms。迁移时 (phone, 0) 这种为 Oracle 准备的索引可以不迁。
但如果为了兼容 '' 把查询改成 WHERE phone IS NULL OR phone = '',计划变成 BitmapOr + Bitmap Heap Scan,10335 个缓冲块,8.6–13.0 ms,比只查 IS NULL 慢 5 倍左右。改查询是在给数据问题打补丁,数据统一之后这个代价就不用付。
PostgreSQL 查找「没有电话」的行(ms,越低越好)
IS NULL
1.75–2.05
IS NULL OR = ''
8.6–13.0
PostgreSQL 17.11,101 万行自造数据,热缓存;柱高取区间内代表值。
CHECK 还是触发器:写入代价 +5–10% 对 +50%,我选 CHECK
PG 侧有两种常见防线:在列上加 CHECK (phone <> '') 拒收 '';或者加 BEFORE INSERT 触发器,把 '' 用 NULLIF 转成 NULL,模拟 Oracle 的行为。我在空表上各写 20 万行(其中 2% 为 ''),每种 3 次 × 3 轮。
我的判断:切换前后用 CHECK,不用触发器。理由有两个。第一,CHECK 写入干净数据只多 5–10%,触发器多约 50%;第二,也是更重要的,CHECK 会让每一个还在写 '' 的代码路径当场报 23514,测试环境跑一遍回归就能把它们全部找出来改掉;触发器会把 '' 悄悄改成 NULL,代码路径永远不会暴露,等哪天有人绕过触发器(比如 COPY 到不带触发器的分区、换了 ETL 工具),问题又回来了。
触发器适合的场景只有一种:代码一时改不完、又必须先切换。那就把它当临时兜底,同时记日志找出写 '' 的来源,改完代码再删掉。
| 方案 | 耗时 | 写入结果 | 代价 |
|---|---|---|---|
| 不设防 | 稳态 259–272 ms(第 1 轮 273–414 ms) | 4000 个 '' | — |
| CHECK (phone <> ''),应用照写 '' | 报 23514 | 0 行 | 整批失败,代码路径暴露 |
| CHECK (phone <> ''),写入干净数据 | 275–346 ms | 0 个 '' | 约 +5–10% |
| BEFORE INSERT 触发器 NULLIF(phone, '') | 404–492 ms(典型 405–418 ms) | 4000 个 NULL | 约 +50%,问题被隐藏 |
已经混进 '' 的库:先改数据,再用 NOT VALID 上约束
切换后已经写进了 '' 的表,按下面的顺序处理。本次 101 万行的表上 200 行 '',每一步都是毫秒级;大表请在低峰执行,UPDATE 量大时分批。
PG 侧清洗顺序
- 01
1
只读扫描
用下载区的 PostgreSQL 扫描 SQL 找出所有同时有 '' 和 NULL 的字符列,以及 NOT NULL 列里的 ''。本次扫描 97 ms,标出 phone 列「'' 200 / NULL 20000」、nick 列有 ''。
- 02
2
统一成 NULL
UPDATE cust SET phone = NULL WHERE phone = '';本次 200 行,2.7–3.9 ms。唯一列、外键列先确认业务上 NULL 的含义。
- 03
3
加约束不扫表
ALTER TABLE … ADD CONSTRAINT … CHECK (phone <> '') NOT VALID,0.6–1.4 ms,只检查之后的新写入,不扫描存量数据,所以几乎瞬间完成。
- 04
4
再验证存量
ALTER TABLE … VALIDATE CONSTRAINT,本次 36–38.6 ms;VALIDATE 期间允许并发读写。之后再写 '' 会报 23514。
NOT NULL 列里的 '' 不能改成 NULL,要和业务确认填什么默认值,或者加 CHECK (nick <> '') 先挡住新数据。
在 Oracle 侧先跑一遍只读 SQL:哪些列会出事
迁移前在 Oracle 上无法直接找出「应用写过 ''」——它们已经是 NULL 了。能做的是列出风险最高的三类字符列:有 NULL 的可空列(num_nulls > 0)、唯一索引里的可空字符列(切换后最可能出现 23505)、NOT NULL 字符列(切换后可能进 '')。下载区的 Oracle SQL 只查 ALL_TAB_COLUMNS / ALL_IND_COLUMNS 等字典视图,在本次测试库上准确列出了 CUST.PHONE、CUST.EMAIL(num_nulls 20000,占 2%)、唯一索引 UQ_CUST_EMAIL 和 NOT NULL 列 NICK。
num_nulls 来自统计信息,统计过旧时会不准;清单出来之后,再去代码里搜这些列的写入点,重点看表单空输入、接口默认值和 trim() 之后的结果。
觉得有用?转给正在做这件事的同事或朋友
参考依据
以下来源用于确认市场趋势、政策背景和术语边界;具体落地方案仍以客户的数据范围、权限和交付目标为准。
常见问题
Oracle 里空字符串 '' 为什么等于 NULL?
Oracle 官方文档写明:数据库目前把长度为 0 的字符值当作 NULL,并提示将来版本可能改变、建议不要把空串和 NULL 混为一谈。本次 Oracle 23.26.3 实测 '' IS NULL 为真,length('') 返回 NULL。
迁移到 PostgreSQL 后为什么会报 duplicate key value violates unique constraint,Key (email)=()?
应用还在往唯一列写 ''。在 Oracle 里多个 '' 都是 NULL,不冲突;在 PostgreSQL 里 '' 是一个普通值,第二个就冲突。本次同一条 1 万行 INSERT 在 PG 因此整批 0 行写入。
Oracle 的 || 拼接迁到 PostgreSQL 要改吗?
要看。Oracle 里 'A'||NULL||'B' 是 'AB',PostgreSQL 里是 NULL。字段可能为 NULL 的拼接,改用 concat()(忽略 NULL 参数)或 coalesce(col, '') 能保持原来的结果。
PostgreSQL 里要不要用触发器把 '' 自动转成 NULL?
只建议当临时兜底。本次 20 万行写入,触发器比不设防慢约 50%,而且会把写 '' 的代码路径藏起来。更推荐 CHECK (col <> ''),写入只多 5–10%,并让问题当场报错。
迁移数据校验全部通过,还会有 '' 问题吗?
会。Oracle 里存不下 '',迁移过来的数据只有 NULL,所以校验一定通过。问题出在切换后应用继续写 '',要在测试环境用 CHECK 约束跑一遍回归来发现。
下一步
推荐动作
你提供 Oracle 侧只读 SQL 的结果(可脱敏)和准备迁移的表清单,盈尺(Surinch)按空字符串、唯一约束、拼接与 NVL 写法、索引差异列出切换前必须改的代码点和清洗顺序;需要整体评估时可选体检页里的 Oracle / 国产化迁移评估。生产变更由你方授权执行。