从 Oracle/MySQL 迁到 PostgreSQL,到底分几步?这问题我被问了一整年。问的人有 DBA,有后端开发,也有刚被甲方要求“数据库替换”的项目经理。大家想要的通常是一个能写进立项 PPT 的答案。但现实是,迁移这事三分靠工具、七分靠人对 SQL 方言的理解。工具负责把表和大部分数据搬过去,剩下的 PL/SQL、递归查询、分页语法、锁语义,都得靠人一处一处地改和验证。所以这篇文章我不打算给你一个停留在理论层面的步骤清单,而是按我真正做过几个迁移项目后的流程来写,覆盖盘点、转换、搬运、改造、验证、切换六件事,每件事里有哪些坑,都有具体例子。
1. 迁移前先做“体检报告”:家底都盘不出来就别谈平滑
很多人接到迁移需求后,第一反应是下载 PostgreSQL、装好环境、然后对着 Oracle 或 MySQL 的库开始导数据。这个顺序是错的。你连这个库里到底有什么都不知道,导到一半发现某张表是外部表、某个存储过程里有DBLINK、某个应用每天都在跑一个用CONNECT BY写的递归查询,这时候再停下来重新评估,返工成本远比一开始做盘点高。
1.1 先回答“迁过去到底能不能跑”:盘点四类资产
我习惯把迁移前的盘点分成四类,不是简单数一下表数量就结束。
第一类是 Schema 对象:表、索引、视图、物化视图、序列、触发器、分区表、同义词、包、存储过程、函数。每一类都要单独列出来,并且标注“有没有外部依赖”。
第二类是数据特征:每张表的行数、数据量、是否有大字段、是否有特殊字符、字符集是否统一。行数和数据量决定了你用哪种搬运方式,大字段和特殊字符决定了你用文本格式导出时会不会踩坑。
第三类是代码资产:所有存储过程、函数、触发器、视图定义、定时任务里的 SQL。这些是最容易被低估的部分。很多人以为迁移就是搬表,实际上业务逻辑有相当一部分写在这类代码里,它们不搬过去,应用就只是“表面跑通”。
第四类是应用连接方式:应用用的什么驱动、连接串怎么配、ORM 是什么框架、SQL 是手写多还是框架生成多、有没有直接用数据库特有函数的地方。这决定了后面应用改造的规模。
1.2 盘点清单怎么落库:两张 SQL 把表统计掏出来
如果你面对的是一个不小的存量库,别靠人去数。直接用 SQL 把统计信息跑出来,存成 CSV 或临时表,后面三轮评审都用这份清单。
Oracle 侧我一般查ALL_TABLES和DBA_TAB_COLUMNS,拿表名、行数估算、段大小、有无分区:
SELECT t.table_name, t.num_rows, ROUND(s.bytes / 1024 / 1024, 2) AS size_mb, t.partitioned FROM all_tables t LEFT JOIN user_segments s ON s.segment_name = t.table_name WHERE t.owner = 'YOUR_SCHEMA' ORDER BY size_mb DESC;MySQL 侧更简单,直接查information_schema:
SELECT table_name, table_rows, ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb FROM information_schema.tables WHERE table_schema = 'your_db' ORDER BY size_mb DESC;注意 Oracle 的num_rows是上次统计信息收集的值,不一定准;MySQL 的table_rows对 InnoDB 也只是估算。真要核对行数,迁移到新库后做数据校验时再精确数。
1.3 迁移难度分级:哪些对象一定会变成硬骨头
盘完之后,我给每个对象打个难度分,这样排计划和估工时才有依据。这里有个参考表,是我根据几个项目总结的通用分级:
| 对象类型 | 难度 | 原因 |
|---|---|---|
| 普通表、普通索引 | 低 | 工具直接转换,基本不涉及人工 |
| 分区表 | 中 | Oracle 分区语法和 PG 差异大,PG 10+ 才支持声明式分区 |
| 视图 | 中 | 嵌套视图、递归视图、带 DML 的视图都得手工改 |
| 物化视图 | 中 | 刷新机制不一样,PG 用REFRESH MATERIALIZED VIEW |
| 序列 | 低 | 对应关系简单,但要注意迁移后水位对齐 |
| PL/SQL 包 | 高 | 需要拆包、改异常、改动态 SQL、改游标 |
| 触发器 | 中高 | 语法差异大,语义也略有不同 |
CONNECT BY递归查询 | 高 | 要重写成WITH RECURSIVE,逻辑复杂时容易错 |
| DBLINK / 外部表 | 高 | PG 用postgres_fdw等替代,但权限和网络配置都要重新做 |
| 定时任务 | 中 | Oracle DBMS_SCHEDULER、MySQL EVENT 到 PG 的 pg_cron 或外部调度 |
难度分出来后你会发现,真正的迁移工程量往往不在表数据,而在代码和应用 SQL。这个结论越早得出越好,否则项目排期全都是错的。
2. 三种数据库的“脾气”差在哪:类型、函数、语法与并发
盘点做完,紧接着要做的不是开搬,而是让团队里每个人都清楚三种数据库的差异。不是让大家把文档背下来,而是建立一张“迁移对照表”,随查随用。我见过太多迁移事故,根源都是“以为差不多”:
- 以为
NVL和COALESCE完全等价,结果遇到了参数类型不一致; - 以为 MySQL 的双引号字符串到 PG 还能用,结果直接语法报错;
- 以为
REPEATABLE READ的语义在两边一样,结果线上出现串行化失败。
2.1 数据类型映射:从 NUMBER 到 NUMERIC,从 AUTO_INCREMENT 到 IDENTITY
数据类型的映射是最机械但也是最容易埋雷的环节。机械是因为规则固定,埋雷是因为“差不多”的映射会导致精度、性能、排序规则全都不对。
Oracle 到 PG 的常用映射我总结如下:
| Oracle | PostgreSQL | 说明 |
|---|---|---|
| VARCHAR2(n) | VARCHAR(n) 或 TEXT | VARCHAR2(4000) 以上建议 TEXT,PG 没有 4000 字节上限 |
| NUMBER | NUMERIC / INTEGER / BIGINT | 无精度时用 NUMERIC;纯整数考虑 INTEGER 或 BIGINT |
| NUMBER(10,2) | NUMERIC(10,2) | 精度映射保持一致 |
| DATE | TIMESTAMP | Oracle 的 DATE 自带时分秒,别映射成 PG 的 DATE |
| TIMESTAMP | TIMESTAMP | 无时区概念,直接映射 |
| TIMESTAMP WITH TIME ZONE | TIMESTAMPTZ | 注意时区处理方式不同 |
| CLOB | TEXT | 大文本,PG 的 TEXT 不限长度 |
| BLOB | BYTEA | 二进制,注意传输方式 |
| RAW(n) | BYTEA | 字节流,处理方式类似 |
| ROWID | 无 | 用主键替代,应用里不要依赖 ROWID |
MySQL 到 PG 的更常见一些:
| MySQL | PostgreSQL | 说明 |
|---|---|---|
| INT / INTEGER | INTEGER | 对应 int4 |
| BIGINT | BIGINT | 对应 int8 |
| TINYINT | SMALLINT | TINYINT(1) 建议直接转 BOOLEAN |
| DECIMAL(p,s) | NUMERIC(p,s) | 完全等价 |
| DATETIME | TIMESTAMP | 迁过去后注意timestamp不带时区 |
| TIMESTAMP | TIMESTAMPTZ | MySQL 的 TIMESTAMP 有时区转换逻辑,PG 更纯粹 |
| TEXT / LONGTEXT | TEXT | 直接对应 |
| BLOB / LONGBLOB | BYTEA | 直接对应 |
| ENUM / SET | TEXT + CHECK 约束或数组 | 不建议直接迁成 PG 的自定义 ENUM,扩展性差 |
| UNSIGNED | NUMERIC / BIGINT | PG 没有无符号整数,超出范围就要用 NUMERIC |
有一个坑特别值得提醒:Oracle 的NUMBER如果不带精度,映射成 PG 的NUMERIC是可以的,但如果你把NUMBER全映射成无限精度NUMERIC,索引性能和排序行为都可能和原来不一样。建议在迁移设计阶段就敲定:哪些字段是整数、哪些是金额,然后分别用BIGINT和NUMERIC(18,2)之类的明确类型,别全用 NUMERIC 一把梭。
2.2 高频函数和写法对照:NVL、ROWNUM、CONNECT BY、GROUP_CONCAT 怎么改
函数差异是迁移时改动量最大的一块。我整理了一份高频对照表,基本覆盖日常业务里 80% 的写法:
| Oracle / MySQL 写法 | PostgreSQL 写法 | 说明 |
|---|---|---|
NVL(a, b) | COALESCE(a, b) | 也常见COALESCE原生支持 |
DECODE(a, x, y, z) | CASE WHEN a = x THEN y ELSE z END | Oracle 专用,必改 |
SYSDATE | now()或CURRENT_TIMESTAMP | 注意 PG 的now()是事务开始时间 |
TO_DATE(str, 'YYYY-MM-DD') | 相同函数,但格式串不同 | PG 格式串大小写敏感 |
ROWNUM <= 10 | LIMIT 10或FETCH FIRST 10 ROWS ONLY | 分页逻辑要重写 |
CONNECT BY PRIOR | WITH RECURSIVE | 递归查询重写,逻辑复杂时最容易漏数据 |
LISTAGG(col, ',') | STRING_AGG(col, ',') | 函数名不同,行为接近 |
WM_CONCAT(col) | STRING_AGG(col, ',') | Oracle 老函数,必改 |
GROUP_CONCAT(col) | STRING_AGG(col, ',') | MySQL 专用,必改 |
TRUNC(date) | DATE_TRUNC('day', date) | Oracle 的 TRUNC 和 PG 的 DATE_TRUNC 略有差异 |
regexp_substr | substring(str from pattern) | 正则写法差异大,逐个验证 |
IFNULL(a, b) | COALESCE(a, b) | MySQL 专用 |
IF(cond, a, b) | CASE WHEN cond THEN a ELSE b END | MySQL 专用 |
DATE_FORMAT(d, '%Y-%m-%d') | TO_CHAR(d, 'YYYY-MM-DD') | 格式符不同,别直接复制 |
LAST_INSERT_ID() | INSERT ... RETURNING id | 获取自增主键的姿势不同 |
这个表看起来简单,但实际执行时最怕的是“函数同名不同义”。比如 Oracle 的TO_CHAR(日期, 格式)和 PG 的TO_CHAR(日期, 格式)虽然函数名一样,格式串的日期要素表示方法有差别,Oracle 用YYYY-MM-DD,PG 也支持,但 Oracle 的RR、MM等细微语义不完全一致。我遇到过一个报表项目,迁移后日期显示的年份差了几十年,就是格式串里用了RR导致的。
2.3 容易被忽略的小坑:空串、双引号、反斜杠、布尔、分页
这些不起眼的细节,往往是应用上线后才炸出来的问题。
第一个是空字符串和 NULL。Oracle 把空字符串当 NULL,PG 也是这么做的,两边一致。但 MySQL 不一样,MySQL 的''就是空字符串,不是 NULL。如果你的应用之前在 MySQL 里依赖“空串”去过滤某类数据,迁到 PG 后语义就变了。反过来,如果你从 Oracle 迁过来,应用里大量WHERE col = ''的写法其实从来没匹配到任何数据,迁到 PG 后依然匹配不到,业务逻辑可能一直有隐患,正好借迁移机会一起改掉。
第二个是双引号。MySQL 在默认sql_mode下,双引号可以被当成字符串字面量;PG 里双引号永远是标识符。以前我在一个项目里就见过 MySQL 代码写WHERE status = "active",迁到 PG 后直接报错,提示column "active" does not exist。这种 SQL 藏得深,光靠工具扫描不一定全查得出来,最好在应用日志里抓异常 SQL 来补齐。
第三个是反斜杠转义。MySQL 默认字符串里\是转义符,比如'a\nb'会被转义;PG 默认standard_conforming_strings=on,反斜杠就是普通字符,要转义得写成E'a\nb'。所以从 MySQL 迁过来的字符串数据里,如果带有反斜杠,导入时要么用COPY并明确转义规则,要么在应用层统一处理。
第四个是布尔类型。MySQL 里的TINYINT(1)经常被当作布尔,应用代码里会有WHERE is_deleted = 1这种写法。迁到 PG 后如果字段类型改成BOOLEAN,这个写法就得变成WHERE is_deleted = true。如果不想大面积改应用 SQL,也可以先把TINYINT(1)映射成SMALLINT,让应用层先跑通,后续再逐步优化。
第五个是分页。MySQL 的LIMIT offset, count写法到 PG 要改成LIMIT count OFFSET offset,顺序反了容易让粗心的开发直接复制报错。Oracle 的ROWNUM分页更是要整段改写,PG 里直接LIMIT OFFSET或者FETCH FIRST就行。另外提醒一句,PG 的OFFSET深分页性能会越来越差,迁移过程中如果发现原系统有大量深分页查询,建议趁这个时机换成 keyset pagination,也就是用WHERE id > ? ORDER BY id LIMIT ?代替大 OFFSET。
2.4 锁与隔离级别:MVCC 看着差不多,实际语义并不一样
从 Oracle 或 MySQL InnoDB 迁到 PG 的人,第一反应都是“都是 MVCC,应该差不多”。实际差得不少。
PG 的默认隔离级别是READ COMMITTED,Oracle 默认也是READ COMMITTED,这个迁移路径上问题不大。但 MySQL 默认是REPEATABLE READ,InnoDB 在 RR 下用了 next-key lock 来防止幻读。同样是REPEATABLE READ,PG 用的是快照隔离,它不锁区间,所以两个并发事务在 PG RR 下可能各自看到不同快照,等到更新同一行数据时,后提交的一方会收到serialization error或更新冲突。这类错误如果不做重试,应用里就会冒出一堆“莫名其妙”的异常日志。
还有一个典型差异是行锁冲突表现。PG 在某个事务更新了一行但未提交时,另一个事务更新同一行会阻塞等待;如果是在 RR 或 Serializable 下,PG 可能在等待后直接报could not serialize access due to concurrent update。MySQL InnoDB 在类似场景下通常是等待锁超时。这个差异直接影响了业务代码要不要加重试逻辑。
迁移前最好把应用里的事务隔离级别设置梳理一遍。如果原来是 MySQL 的REPEATABLE READ,迁到 PG 后建议不要直接沿用,要么降到READ COMMITTED并把业务代码调好,要么改成 PG 的REPEATABLE READ并给关键写事务加重试机制。这个决定要趁早,别等压测时才去查。
3. 工具选型:ora2pg、pgloader 和手工兜底怎么搭配
工具这件事,网上说法很多,但实际用起来每个人都有各自的顺手组合。我的原则是:能做结构转换用工具,数据搬运能走 COPY 就走 COPY,代码改造必须留出充足人工时间。没有任何一个工具能做到“一键迁移后直接上线”。
3.1 ora2pg 是 Oracle 场景的主力
Oracle 到 PG,我用得最多的是ora2pg。它是个 Perl 工具,能把 Oracle 的表结构、视图、函数、存储过程、包、触发器等转成 PG 的 DDL 和 PL/pgSQL 代码,并且支持直接导出数据。
基本用法是写一个配置文件,告诉它两端连接信息、目标模式、导出类型:
ORACLE_DSN dbi:Oracle:host=192.168.1.10;sid=ORCL;port=1521 ORACLE_USER orauser ORACLE_PWD yourpassword SCHEMA YOUR_SCHEMA POSTGRESQL_DSN dbi:Pg:dbname=target_db;host=192.168.1.20;port=5432 POSTGRESQL_USER pguser POSTGRESQL_PWD yourpassword TYPE TABLE OUTPUT schema.sql然后执行:
ora2pg -c ora2pg.conf生成的schema.sql是 PG 风格的建表语句。想导出存储过程,就把TYPE改成FUNCTION、PROCEDURE、PACKAGE、TRIGGER、VIEW等。它也确实会尝试把 PL/SQL 转换成 PL/pgSQL,但转化结果你就当是“草稿”用,必须人工 review。特别是包里的全局变量、重载函数、异常处理部分,ora2pg 往往只是把语法外壳换掉,逻辑还原度不高。
数据导出可以用--type COPY配合--data_only,它会生成 PG 能直接吃的 COPY 格式文件,比逐行 INSERT 快得多。大表建议按表单独导出,避免单个文件占用太大空间、断了不好续传。
3.2 pgloader 是 MySQL 场景的快速通道
MySQL 到 PG,社区里最顺手的工具是pgloader。它最大的特点是直接用COPY协议流式搬数据,不用先落成中间文件,速度和稳定性都不错。而且它在迁移时能顺便把 MySQL 的类型映射成 PG 类型,比如TINYINT(1)默认会映射成BOOLEAN,DATETIME映射成TIMESTAMP。
最简单的用法是命令行直接指定两端连接:
pgloader \ mysql://mysqluser:pass@192.168.1.10/source_db \ postgresql://pguser:pass@192.168.1.20/target_db更推荐的是写一个 load 文件,把迁移规则显式管理起来:
LOAD DATABASE FROM mysql://mysqluser:pass@192.168.1.10/source_db INTO postgresql://pguser:pass@192.168.1.20/target_db WITH include drop, create tables, create indexes, reset sequences, workers = 8, concurrency = 1 SET maintenance_work_mem = '1GB', work_mem = '512MB' CAST type tinyint with extra auto_increment to serial, type tinyint to smallint, type datetime to timestamp, type timestamp to timestamptz;WITH include drop这句要小心,它会在目标库先 DROP 再重建同名表。如果你目标库里已有重要数据,千万别开这个选项。
pgloader 跑完后,自增序列的reset sequences选项会自动把序列拉到当前最大值,这点比手工导数省心很多。
3.3 手工搬迁什么时候才是唯一答案
工具再强,总有它玩不转的场景。我遇到过几类情况,最终都是靠手工脚本 + 中间表解决的。
第一类是特殊类型字段。比如 MySQL 的SET/ENUM,pgloader 虽然能映射,但映射成什么取决于你的业务怎么用。如果你的应用会往SET字段里塞多个枚举值,迁到 PG 后更合适的是TEXT[]数组或者一个关联表。这种决策工具做不了,得人来定。
第二类是含有大量历史归档表的场景。某些表单表数据量几十亿,且几乎不变化,直接用工具全表读一遍效率太低。这种我一般建议在源库按时间范围导出分区数据,用并行任务导成多个 COPY 格式文件,再到 PG 里按分区并行导入。这个流程工具编排不了。
第三类是源库里有特殊字符或编码混乱的数据。MySQL 的utf8其实是utf8mb3,只能存基本多语言平面,遇到四字节 emoji 会丢字符甚至报错。从这种库迁数据前,得先清理数据或者统一转成utf8mb4。这种“脏活”工具不会替你处理。
3.4 增量同步和双写:工具能帮你,但别指望它兜底
如果系统不能接受长时间停机,就需要考虑增量同步。Oracle 侧的选择余地一直不大,商业场景里常见的是用Debezium的 Oracle CDC 模块,把归档日志里的变更流出来,再同步到 PG;MySQL 侧也是类似思路,Debezium读 binlog 发到消息队列,下游消费写入 PG。也有项目直接买云厂商的 DMS/DTS 服务,图形化配置简单一些,但 Oracle 到 PG 的持续同步,说实话没有哪条路是省心的。
我自己的经验是:增量同步只是“迁窗口期”的辅助工具,不是长期双写方案。如果你打算让两套数据库并行跑好几个月,就得设计好两边的冲突处理、数据对账、补偿机制,这个复杂度高于大多数团队的预期。所以除非业务绝对不允许停机,否则我都会建议把迁移窗口拉长,做一次“短停、全量、校验、切换”,比长期双写省事太多。
4. Schema 与数据搬移的实操流程
工具选好了,接下来就是真正动手搬。这个阶段最容易出的问题不是“搬不动”,而是“搬得太快”,结果后患一堆。我的建议是用一个比较克制、可验证的顺序推进。
4.1 目标库环境准备:字符集、排序规则、时区一次配齐
很多人建 PG 库时根本不注意字符集,默认值一用到底。如果源库是 UTF-8 业务数据,PG 默认 UTF8 一般没问题。但如果是从 MySQL 的utf8mb4迁过来,创建数据库时一定要显式指定:
CREATE DATABASE target_db WITH ENCODING 'UTF8' LC_COLLATE 'zh_CN.UTF-8' LC_CTYPE 'zh_CN.UTF-8' TEMPLATE template0;注意排序规则对查询结果的影响。MySQL 的排序规则是按字段或表级别配置的,很多业务查询依赖大小写不敏感排序。PG 里排序规则更严格,如果原来的表用的是utf8mb4_general_ci这类不区分大小写的排序,迁到 PG 后字段级别的COLLATE规则要单独设置,否则ORDER BY出来的顺序可能不一样。
时区也要一次配齐。PG 的timestamptz类型存的是 UTC 时间,展示时按会话时区转换。源库如果是 MySQL 的TIMESTAMP,它内部也会按会话时区转存;但 Oracle 的TIMESTAMP不带时区,转过来如果业务有跨时区需求,得提前确认到底用TIMESTAMP还是TIMESTAMPTZ。
4.2 DDL 转换与逐条 review:别拿到 schema.sql 就直接 psql
我一直强调,工具生成的schema.sql只是初稿。拿到之后我一般按这个顺序过:
- 先看表结构:字段类型、默认值、自增定义、注释。特别是注释,PG 里要用
COMMENT ON语句,工具不一定全部转换。 - 再看约束:主键、唯一约束、非空约束、检查约束。Oracle 的某些约束写法(如
INCLUDE索引等)PG 不支持,需要改。 - 然后看索引:PG 的索引类型比 Oracle 少,Oracle 的位图索引、函数索引、部分索引要评估是否值得重建。有些 Oracle 数据库里堆了一堆冗余索引,正好趁迁移清理掉。
- 最后看视图:视图定义里如果用了 Oracle 专用函数,要逐个改写。视图嵌套层数深的,建议用脚本拆解出来,按依赖顺序重建。
我有次迁移一个 ERP 库,工具生成的 schema 里有一个视图嵌套了 10 层,Oracle 跑得动,PG 解析时外层引用别名和内层字段名冲突,改了一整天才跑通。所以一定要留出 DDL review 的时间,别压缩。
4.3 大规模数据怎么搬快:先拆约束、再并行 COPY
数据搬到 PG,最忌讳的做法是一条一条 INSERT。正确姿势是优先用COPY协议。不管是pgloader还是ora2pg,它们数据类型转换完以后最终都会走COPY,因为 PG 的 COPY 比多行 INSERT 快一个数量级。
手工迁移时,我习惯按这个顺序:
- 先建好所有表结构,但先不建外键、不建非唯一索引,只保留主键和唯一约束用于排重。
- 按表大小倒序导入数据。先小表、后大表,避免大表占用资源时小表排队。
- 大表分成多个分片并行导入。比如按主键范围分成 8 个任务,每个任务用独立的
COPY或INSERT ... SELECT推进。 - 数据全部导入后,再统一创建外键和索引。
- 最后运行
ANALYZE,让统计信息生效。
为什么先拆掉外键和索引?因为 PG 在插入数据时维护索引有成本,外键检查更是逐行触发。导入阶段不建索引,总耗时能下降很多;导完再建索引,建索引本身是批量操作,效率比逐行维护高得多。
如果遇到超大表,还要额外调整几个 PG 参数。我在导入任务里一般会临时调大maintenance_work_mem、max_wal_size、checkpoint_timeout,同时在导入期间把归档和备份任务暂停,避免 WAL 增长拖慢整体速度。
4.4 数据搬完后把序列水位拉到正确位置
这是最容易忘的步骤。Oracle 的序列是独立的,MySQL 的自增 ID 跟着表走,而 PG 的序列也是独立对象。如果你在目标库用的serial或identity,往表里手动插入数据时序列并不会自动跟着最大值走。结果就是:数据搬完了,序列还停在 1,应用一插入新数据就主键冲突。
用 pgloader 时,reset sequences选项会帮你处理。手工导入的话,记得执行:
SELECT setval( pg_get_serial_sequence('public.orders', 'id'), (SELECT COALESCE(MAX(id), 1) FROM public.orders) );如果有几十张表,可以用脚本批量生成这个 SQL,然后统一跑一遍。跑完验证一下,再插入一条测试数据,确认新 ID 不会撞上历史数据。
5. 存储过程与应用 SQL 改造:真正的硬骨头在这
数据搬过去只代表“能查了”,业务真正跑起来还得靠应用和数据库里的代码。这一段是整个迁移项目的重头戏,花的时间经常是搬数据的几倍。
5.1 PL/SQL 包怎么拆:包、异常、动态 SQL 的对应关系
Oracle 里大量业务逻辑放在包里,一个包里有几十个函数和存储过程很常见。PG 没有包这个概念,迁过去要么全部拆成独立的 function/procedure,按 schema 分组管理,要么用一个自定义复合类型临时模拟。实际项目中我都是拆平的:pkg_order.create_order(...)变成pkg_order_create_order(...)或者create_order(...)。
PL/SQL 到 PL/pgSQL 的常见转换点,我列几个最容易被卡住的:
- 包级变量:Oracle 包里的全局变量在函数间共享,PG 拆开后没有这个状态。如果应用依赖这种“会话级状态”,要么改成参数传递,要么改成临时表。
dbms_output.put_line:PG 里换成RAISE NOTICE,功能和查看方式类似。- 异常处理:
EXCEPTION WHEN NO_DATA_FOUND THEN在 PL/pgSQL 里也能写,但系统异常名和触发器异常名的覆盖范围不同,比如WHEN OTHERS THEN捕获到的异常类型可能和 Oracle 不一致。 - 动态 SQL:Oracle 的
EXECUTE IMMEDIATE ... USING ...对应 PG 的EXECUTE ... USING ...,但返回结果集的方式不同,PL/pgSQL 里要用RETURN QUERY EXECUTE或者FOR ... IN EXECUTE循环。 - 游标:Oracle 隐式游标
FOR rec IN (SELECT ...) LOOP在 PL/pgSQL 里也能用,语法几乎一致,但%ROWCOUNT、%FOUND要改成GET DIAGNOSTICS rows = ROW_COUNT;或IF FOUND THEN。
给你一个最简示例。Oracle 写一个返回金额合计的函数:
CREATE OR REPLACE FUNCTION get_total(p_cust_id NUMBER) RETURN NUMBER IS v_total NUMBER := 0; BEGIN SELECT SUM(amount) INTO v_total FROM orders WHERE cust_id = p_cust_id; RETURN NVL(v_total, 0); END;PG 里改成 PL/pgSQL:
CREATE OR REPLACE FUNCTION get_total(p_cust_id BIGINT) RETURNS NUMERIC AS $$ DECLARE v_total NUMERIC := 0; BEGIN SELECT COALESCE(SUM(amount), 0) INTO v_total FROM orders WHERE cust_id = p_cust_id; RETURN v_total; END; $$ LANGUAGE plpgsql;看起来不难,但如果一个包里有几十个函数、互相调用、还有重载,就是纯手工活,没有捷径。排期时别低估这个环节。
5.2 递归查询改写:从 CONNECT BY 到 WITH RECURSIVE
Oracle 的START WITH ... CONNECT BY PRIOR是递归查询最经典的写法。PG 不支持这个语法,必须改成WITH RECURSIVE。语法上差异不算大,但逻辑表达方式完全不同,特别容易在“兄弟节点”“层级字段排序”“根节点过滤”上写错。
举个员工组织架构的例子。Oracle:
SELECT emp_id, manager_id, level FROM employees START WITH manager_id IS NULL CONNECT BY PRIOR emp_id = manager_id;PG:
WITH RECURSIVE emp_tree AS ( SELECT emp_id, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.emp_id, e.manager_id, t.level + 1 FROM employees e JOIN emp_tree t ON e.manager_id = t.emp_id ) SELECT emp_id, manager_id, level FROM emp_tree;逻辑上等价,但要注意 PG 的递归默认不会自动去重。Oracle 的CONNECT BY里如果有环,会报CONNECT BY loop in user data;PG 的WITH RECURSIVE遇到环会无限递归。所以改写时一定要想清楚有没有环,有环就要在递归里加访迹数组去重。PG 14+ 提供了SEARCH/CYCLE子句可以辅助检测环,但老版本只能自己处理。
5.3 应用里的 SQL 方言:MyBatis、JDBC 和 ORM 的常见问题
应用代码的改造往往比存储过程还琐碎。如果项目用了 JPA/Hibernate,大部分 SQL 是框架自动生成的,类型映射调整一下可能就能跑。但如果用了 MyBatis、或者团队里有人直接写 JDBC SQL,问题就多了。
最常见的三个点:
MyBatis 里写分页,MySQL 版通常是:
LIMIT #{offset}, #{count}PG 要改成:
LIMIT #{count} OFFSET #{offset}MyBatis 里返回自增主键,MySQL 用useGeneratedKeys="true"或者专门查LAST_INSERT_ID();PG 更推荐在 INSERT 语句末尾加RETURNING id,配合 MyBatis 的<selectKey>标签把值回填到实体上。示例:
<insert id="insertOrder" parameterType="Order" useGeneratedKeys="true" keyProperty="id"> INSERT INTO orders(customer_id, amount) VALUES (#{customerId}, #{amount}) RETURNING id </insert>还有布尔字段。如果应用原来读 MySQL 的TINYINT(1),映射到 Java 的Integer或Boolean都能工作;迁到 PG 后如果字段类型是boolean,MyBatis 映射到Integer会报转换异常。这个需要把实体字段类型同步改成Boolean。
最后,应用连接串也要改,驱动从mysql-connector-java换成postgresql驱动,URL 从jdbc:mysql://ip:3306/db变成jdbc:postgresql://ip:5432/db。如果你的应用用到了连接池,PG 的连接参数(如socketTimeout、tcpKeepAlive)和 MySQL 的connectTimeout等也不一样,上线前最好按 PG 的推荐配置重新调一遍。
5.4 触发器、视图与物化视图的移植
触发器的差异主要在语法和触发时机。Oracle 触发器里用:NEW.col、:OLD.col;PG 里直接写NEW.col、OLD.col。MySQL 的触发器里也是NEW.col、OLD.col,这点和 PG 一致。
PG 创建触发器的姿势和 MySQL/Oracle 不太一样,它要求你先建一个返回trigger类型的函数,再把这个函数绑定到表上。示例:
CREATE OR REPLACE FUNCTION trg_set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER orders_set_updated BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION trg_set_updated_at();注意 PG 的CREATE TRIGGER默认是FOR EACH ROW,如果你原来用的是语句级触发器,要显式写FOR EACH STATEMENT,并且语句级触发器里不能直接访问NEW/OLD,逻辑通常要改写。
物化视图方面,Oracle 的物化视图有增量刷新、快速刷新,PG 的物化视图只能全量刷新,或者用REFRESH MATERIALIZED VIEW CONCURRENTLY并发刷新(前提是物化视图上有唯一索引)。如果原系统的物化视图数据量很大、刷新频率又高,迁到 PG 前要重新设计刷新策略,甚至考虑改用普通表 + 定时任务维护。
视图迁移相对简单,但有一个隐藏问题:PG 的视图默认是实时解析,如果底层表结构变了,视图可能失效。Oracle 的视图在某些情况下会缓存元数据,PG 的行为更“动态”,这个差异导致偶尔出现“视图没改却报列不存在”的错觉,其实只要刷新一下视图定义就行。
6. 数据校验、性能回归与灰度切换:把“平滑”落到实处
迁移项目最怕的是什么?是切完之后第二天发现有一张表的数据对不上,或者某个核心查询从毫秒级变成秒级。“平滑”二字靠的不是运气,而是切换前一套严格的验证流程。
6.1 数据校验三件套:行数、checksum、业务对账
数据搬完先做第一轮校验:逐表比对行数。这个最直观,但只靠行数远远不够,因为行数相同不代表内容一致。
第二轮我习惯做抽样校验,取每张表的关键字段拼接后做聚合。PG 里可以用hashtextextended对整行做 hash:
SELECT COUNT(*), SUM(hashtextextended(t.*::text, 0)) AS row_hash FROM target_schema.orders t;源库用同样的逻辑算出一个结果,两边对不上,就按主键范围分段对比,二分定位差异数据。这个方案对明细表很有效,但对超大表整表 hash 成本比较高,可以只在抽样分区上做。
第三轮是业务对账,这个需要业务人员参与。比如订单系统,把近三个月的订单数量、金额总额、退款数量在两边各跑一遍,直接看核心业务数字是否一致。业务对账比纯技术校验更能发现问题,因为它验证的是“业务上能不能接受”。
6.2 性能回归:跑一遍慢查询清单,再看执行计划差异
迁移前我建议把源库的慢查询日志或者 DBA 提供的 TOP SQL 清单收集一份,这就是迁移后的性能验收集。切到 PG 后,把同一批 SQL 跑一遍,对比响应时间。
如果发现某个查询明显变慢,第一件事不是调库参数,而是看执行计划。PG 的EXPLAIN (ANALYZE, BUFFERS)输出和 Oracle/MySQL 完全不同,你得重新习惯。常见问题有三个:
一是统计信息没更新。数据导入后没跑ANALYZE或者统计信息过旧,优化器选了很差的执行计划。解决办法就是跑完数据后立刻全库ANALYZE。
二是缺少合适的索引。源库的索引设计基于 Oracle/MySQL 优化器特性,不一定适合 PG。PG 对多列索引的使用更保守,联合索引的顺序、函数索引的写法都要逐个验证。特别是LIKE '%xxx%'这种模糊查询,MySQL 可能走不到索引,PG 也一样,要按 PG 的方式去设计。
三是work_mem设置不合理。PG 的排序和哈希操作都在work_mem内进行,超出就会落到磁盘临时文件,性能骤降。默认值通常偏保守,要根据内存和查询复杂度适当调大,但别一次给太大,否则高并发时内存容易被打爆。
6.3 切换时序:先只读后读写,预留回退通道
切换方案没有万金油,跟业务窗口强相关。我一般分两种场景。
场景一:允许停机的小系统。晚上停机,停应用,停写入,做最后一次增量同步,校验,改应用连接串,启动应用,观察。整个过程控制在 2-4 小时以内。
场景二:不允许长时间停机的大系统。我的做法是“双跑观察 + 灰度切换”。流程大致是:
- 全量迁移完成,开启增量同步,让 PG 持续追平源库。
- 应用层保持写 Oracle/MySQL,读流量逐步切到 PG。先切 5% 读流量,观察报错率和性能指标。
- 读流量全部切到 PG,写流量保持源库,PG 继续通过增量同步追数据。
- 写流量灰度切换:先切一小部分业务模块,比如非核心的订单状态更新,验证写入链路。
- 写流量全部切到 PG,源库停写、保留只读,持续对账几天。
- 确认稳定后,源库归档,保留只读副本作为回退后备。
这套流程里,回退通道永远是最后关掉的。切换前一天,源库做一份全量快照;切换后一周内,每天对比源备份库和 PG 的数据一致性。一旦发现严重问题,应用连接串可以秒切回源库,数据基本不丢。
必须承认,双跑窗口对应用架构的要求很高。它要求应用里数据库操作尽量走统一的数据访问层,切换时只改配置不改代码。如果你的应用 SQL 写得到处都是、连接串散落在几十个服务里,灰度切换会变成一场灾难。这种情况我建议先花时间把数据访问收敛,再谈平滑切换。
6.4 迁完后第一周要盯的指标
切换完后不代表项目结束了,第一周是问题高发期。我会重点盯几个指标:
锁等待。PG 的锁等待日志和 pg_stat_activity 里能看到状态为idle in transaction或长事务的会话。如果应用原来依赖 MySQL 的锁行为,迁到 PG 后锁等待时长可能明显变长。
慢查询。虽然是同一套 SQL,PG 规划器的选择可能随着数据量变化而漂移。把log_min_duration_statement调到 500ms 或更低,每天扫一遍慢查询日志。
序列水位。切换后业务一旦大量写入,序列跳号或撞主键的问题可能不是立刻出现,而是某张表增长到接近历史数据最大值时才爆发。第一周每天跑一次setval水位检查脚本,顺手就做。
VACUUM 情况。PG 的 MVCC 机制下,频繁更新会导致表膨胀。迁完后如果业务更新量很大,需要尽快开启autovacuum的正常调度,必要时手动 VACUUM 大表。Oracle 没有这个问题,DBA 从 Oracle 转过来容易忽视。
我自己的体会是,迁移项目最耗精力的从来不是工具操作,而是把“差不多就行”的侥幸心理从团队里清除掉。数据类型映射差一个精度、递归查询少一个去重、应用 SQL 少改一个方言,这些问题在验证阶段都会靠数据校验和回归测试暴露出来。所以每一步都留出 check 的时间,比追求“一步到位”靠谱得多。真要说一个最值得分享的经验,那就是:永远把数据和代码当成两套资产分别管理,数据可以靠工具,代码必须靠人审。