做数据库迁移评估这些年,最常听到的一句话是"SQL 都能跑,迁过去应该问题不大"。说这话的人往往只看到了表结构和几条简单查询,没看到业务代码里那些天马行空的写法——字符串隐式转数字、DISTINCT ON取最新一条、ON CONFLICT做幂等写入、拿pg_class拼出来的动态 SQL。等真正把 PostgreSQL 上的库往 GaussDB 上搬,第一轮 DDL 跑下来就报错一半,第二轮跑存储过程直接卡住,第三轮做数据比对发现分页结果顺序全乱了。
这篇内容就是把这几年在 PG 到 GaussDB 迁移里反复撞到的语法兼容性问题摊开讲。核心不在于罗列一份"支持/不支持"清单——那种清单每个版本都在变,照抄必踩坑——而在于讲清楚兼容性差异的成因、定位方法、改写路径,以及怎么把这件事工程化地管起来。适合正在做或即将做这类迁移的后端开发、DBA、数据平台同学看,尤其适合手里有一坨跑了七八年、没人敢动的老 SQL 的团队。
1. 迁移前先把语法账算清楚:兼容性差在哪儿
很多团队迁移失败不是因为技术难,而是因为一开始把问题定义错了。他们以为迁移是"导数据 + 建表",实际迁移是"把一个运行了多年的语义系统,重新在一套不同的规则下表达一遍"。语法兼容性只是这个语义系统的表层,但表层下面连着类型系统、函数库、优化器、事务模型四根筋。所以动手前,得先搞清楚差异是哪几类。
1.1 兼容性问题的四种典型形态
我习惯把 PG 到 GaussDB 的兼容性问题分成四类,分类的目的不是学术,而是决定用什么手段去处理。
| 问题形态 | 典型表现 | 处理手段 | 处理成本 |
|---|---|---|---|
| 语法层不支持 | DDL 直接报语法错误,如分区表、CONCURRENTLY建索引 | 改写 DDL,迁移窗口期处理 | 低,但影响面大 |
| 函数与系统表缺失 | 运行时才报错,如DISTINCT ON、array_agg变体 | 逐条替换,需要回归测试 | 中,容易漏 |
| 隐式行为差异 | 不报错但结果不同,如 NULL 排序位置、隐式类型转换 | 靠双跑比对发现 | 高,最隐蔽 |
| 架构性差异 | 分布键、跨节点 JOIN、执行计划剧变 | 需要重新设计表与索引 | 高,需要重构 |
第一类最容易发现,报错就是最好的文档;第三类最要命,SQL 跑得通、结果不对,上线后才发现数据算错了。我在实际项目里见过一个统计报表,迁移后数字对不上,查了三天发现是ORDER BY ... LIMIT 1在 NULL 值存在时取到了不同的行——两边对 NULL 的默认排序位置不一致。这类问题只能靠成体系的比对去兜。
1.2 建库时的兼容模式:最容易被忽略的一个开关
这里必须单独拎出来讲,因为它是所有语法差异的上游变量。GaussDB 在建库时可以指定兼容模式,常见的有面向 Oracle、MySQL、PostgreSQL 的几种。如果建库时不显式声明,拿到的默认模式很可能不是 PG 模式。
后果是什么?举个具体的:在 Oracle 兼容模式下,空字符串''会被当作 NULL 处理。PG 里'' IS NULL是 false,Oracle 语义里是 true。一个WHERE name = ''的查询,两边返回的行数可能天差地别。再比如日期格式、双引号标识符的大小写处理、||拼接遇到 NULL 的行为,都可能随模式变化。
所以迁移前第一件事,是确认目标库的DBCOMPATIBILITY参数,并把它固化到建库脚本里。我会建议创建一个和源库行为最接近的模式,然后在评估报告首页写上这个参数值。很多"莫名其妙的差异"追到最后,都是这个开关没对齐。
-- 建库时显式声明兼容模式(具体取值以目标版本手册为准) CREATE DATABASE appdb WITH ENCODING = 'UTF8' DBCOMPATIBILITY = 'PG';提示:不要在生产库上直接试默认值。搭一套和生产同版本、同模式的评估库,所有语法扫描都在评估库上跑,结论才有意义。
1.3 语法扫描要扫到什么颗粒度
扫描不能只扫.sql文件。真实项目里,SQL 藏在四个地方:DDL 脚本、存储过程/函数体、ORM 生成的动态 SQL、应用代码里手拼的字符串。前两处好办,后两处是黑洞。
我的做法是三步:第一步,从数据库里把pg_proc里的函数定义全导出来,把视图定义从pg_views导出来,这部分是"活的 SQL";第二步,让应用团队提供慢查询日志和全量 SQL 审计日志,取最近三个月出现过的所有去重 SQL 文本;第三步,对 ORM 层的查询构造器做代码检索,重点找那些直接调原生 SQL 的地方。
扫描脚本可以实现简单点,用正则匹配高风险特征:DISTINCT ON、ON CONFLICT、RETURNING、::regclass、pg_catalog.、generate_series、jsonb_、array_、CREATE EXTENSION、CONCURRENTLY、LATERAL、FILTER (、GROUPING SETS。命中的每一条都人工过一遍,标记为"确认支持/需改写/待验证"。这套流程听起来笨,但比事后在生产环境上撞墙便宜得多。
2. 那些会在第一轮就报错的 SQL 写法
评估库建好、脚本扫描跑完,接下来就是逐条改写。这一章挑几个命中率最高的写法,把报错原因和改写方案讲透。这些不是理论推演,是我在不同项目里反复遇到的真实高频项。
2.1 自增主键:serial、identity 与序列的三条路
PG 里建自增主键有几种写法,迁移时表现各不相同。
serial/bigserial是伪类型,本质是"建一个序列 + 设默认值 + 加 NOT NULL",大多数目标库能识别。但GENERATED ALWAYS AS IDENTITY是标准 SQL 写法,在 PG 10 之后才支持,部分目标版本识别不了,直接报语法错误。
-- 源库常见写法(可能不被目标库识别) CREATE TABLE orders ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ... ); -- 迁移改写:显式序列 + 默认值,兼容性最好 CREATE SEQUENCE seq_orders_id START WITH 1 INCREMENT BY 1; CREATE TABLE orders ( id BIGINT NOT NULL DEFAULT nextval('seq_orders_id') PRIMARY KEY, ... );显式序列的写法啰嗦,但胜在确定性。要注意两个细节:一是序列的START WITH必须大于源库当前最大值,否则切换后立刻主键冲突;二是nextval('seq_name')里的序列名如果没带 schema,会走search_path解析,跨 schema 场景下强烈建议写成nextval('app.seq_orders_id')。
还有一个坑:GENERATED ALWAYS的语义是"禁止手工插入",改成普通默认值之后这个约束就丢了。如果有业务依赖这个约束防止误插,得用触发器或者应用层补回来。我在一个项目里就因为这个丢约束,测试环境有人手工插了一批指定 ID 的数据,导致序列水位错乱,上线后第一批写入就主键冲突。教训是:迁移要迁移语义,不只是迁移语法。
2.2 数据类型映射:jsonb、数组、网络类型这些"PG 特产"
PG 的类型系统非常丰富,丰富到迁移时会疼。下面这张表是我常用的映射参考,具体以目标版本文档为准:
| 源库类型 | 迁移建议 | 注意事项 |
|---|---|---|
json/jsonb | 保留jsonb,或降级为text | 操作符@>、?、#>>支持度需逐条验证 |
int[]/text[] | 保留数组,或拆子表/转 JSON | unnest、ANY()一般可用,数组包含操作符常缺 |
inet/cidr | 保留,或转varchar(45) | 转换后失去网段包含判断能力,需应用层实现 |
uuid | 保留,或转char(36) | 生成函数不同,gen_random_uuid()未必可用 |
money | 建议改numeric(19,4) | 显示格式、四舍五入规则可能不一致 |
tsvector | 一般需替换方案 | 全文检索能力差异较大,考虑外部检索组件 |
hstore | 转jsonb或拆列 | 依赖扩展,多数环境不可用 |
jsonb是重灾区。很多人以为"都叫 jsonb 就一样",实际差异集中在操作符层面。->和->>取字段一般没问题,但@>(包含)、?(键存在)、#>(路径取值)这些就未必。我的处理原则是:能下沉到应用层的就下沉。把 JSON 取字段、判断包含这类逻辑挪到代码里,SQL 只负责存取整段 JSON,这样迁移的确定性和后续可维护性都更好。
数组类型也类似。WHERE id = ANY($1)这种写法通常可用,但WHERE tags @> ARRAY['a','b']这种包含判断就得改写。改写的替代方案有几种:拆成关联表(最规范但改动大)、存成逗号分隔字符串用LIKE匹配(快但容易误匹配)、存 JSON 数组用 JSON 函数判断(折中)。选哪个取决于查询频率和精度要求,没有标准答案。
2.3 保留字与大小写:一个字段名引发的连锁反应
这个坑特别有欺骗性,因为报错信息往往指向很莫名的地方。
GaussDB 的保留字集合是 PG、Oracle、MySQL 三家的并集。也就是说,一个在 PG 里当普通标识符完全没问题的词,到了 GaussDB 可能变成保留字。常见的受害者:type、comment、user、order、check、values、level、size、start。
-- 源库可以,目标库可能报错 CREATE TABLE logs ( id BIGINT, type VARCHAR(20), -- type 可能是保留字 comment TEXT -- comment 可能是保留字 ); -- 改写方案一:加双引号 CREATE TABLE logs ( id BIGINT, "type" VARCHAR(20), "comment" TEXT ); -- 改写方案二(更推荐):改名,避开保留字 CREATE TABLE logs ( id BIGINT, log_type VARCHAR(20), remark TEXT );我更推荐改名。加双引号虽然能过,但会带来新问题:加了双引号之后标识符变成大小写敏感,"type"和TYPE、Type就不是一回事了,后续所有引用都必须带引号且大小写完全一致。ORM 层如果不加引号,查询就会报"列不存在"。这种问题排查起来极其费时。
顺带说大小写。PG 的规则是:不加引号的标识符折叠为小写,加引号则原样保留。这个规则目标库一般也遵循,但在某些兼容模式下会变。如果 ORM 生成的 SQL 里带双引号,比如SELECT "user"."name" FROM "user",而建表时没加引号建成了小写user,那正好能对上;但如果建表时用了引号建成了"User",就对不上了。建议全线统一:建表和查询都用小写不加引号,把大小写敏感这条路径彻底堵死。
2.4 隐式类型转换:PG 惯出来的坏习惯
PG 的类型转换相当宽松,WHERE id = '123'(id 是整数)能跑,WHERE create_time > '2024-01-01'也能跑。到了更严格的目标库上,这类写法可能直接报"operator does not exist"。
-- 源库能跑,目标库可能报错 SELECT * FROM orders WHERE id = '123'; SELECT * FROM orders WHERE amount > '100'; -- 显式转换,两边都稳 SELECT * FROM orders WHERE id = 123; SELECT * FROM orders WHERE amount > 100::numeric;这件事的麻烦在于量太大。一个老项目里这种隐式转换可能有几百处,全改工作量惊人。我的策略是:优先改高频和核心链路的 SQL,长尾的先不动,等报错了再改。同时在应用层加一层类型校验,让参数在进 SQL 之前就是正确类型,从源头减少隐式转换。评估阶段可以用正则粗筛:找= '数字'、> '数字'、IN ('数字', ...)这类模式,能覆盖大部分。
3. 函数与系统表:藏在业务 SQL 深处的暗礁
语法错误是明枪,函数和系统表的差异是暗箭。DDL 跑通了、简单查询跑通了,团队以为迁移完成度 80%,实际上一跑复杂报表和后台任务,问题才成片冒出来。
3.1 聚合与字符串函数的替换路径
聚合函数里,array_agg、string_agg一般能用,但带ORDER BY的写法要留意。PG 允许array_agg(x ORDER BY y),某些目标库对聚合内排序的支持不完整。
-- 依赖聚合内排序 SELECT dept_id, string_agg(name, ',' ORDER BY id) FROM emp GROUP BY dept_id; -- 改写:先排序再聚合,兼容性更好 SELECT dept_id, string_agg(name, ',') FROM (SELECT dept_id, name FROM emp ORDER BY dept_id, id) t GROUP BY dept_id;FILTER子句(count(*) FILTER (WHERE status = 1))是 PG 9.4 引入的,写法优雅,但支持度不保证。稳妥的替代是CASE WHEN:
-- FILTER 写法 SELECT count(*) FILTER (WHERE status = 1) AS ok_cnt FROM orders; -- CASE WHEN 写法,通用性好得多 SELECT count(CASE WHEN status = 1 THEN 1 END) AS ok_cnt FROM orders;字符串处理函数差异更碎。regexp_split_to_table、regexp_matches的返回形式、format()函数、to_char的格式化模板(尤其是FM、TH、IYYY这些修饰符)都可能有出入。经验是:金额和日期的格式化,尽量挪到应用层做。数据库负责存取原始值,格式化交给代码,既避免差异,也便于做多语言多区域适配。
3.2 pg_catalog 系统表查询的改写策略
后台管理功能里经常有这类查询:查表大小、查索引使用情况、查正在执行的 SQL、查锁等待。这些直接读pg_class、pg_stat_activity、pg_locks的 SQL,迁移时基本都要重写。
原因是系统表只是名字像,字段未必一致,视图的列数、列名、统计口径都可能不同。比如pg_stat_activity在 PG 里字段很多,某些目标版本的等价视图字段少一半,query_start、state_change这些列可能没有。
-- 典型的 PG 系统表查询,迁移后大概率报列不存在 SELECT relname, pg_size_pretty(pg_total_relation_size(oid)) FROM pg_class WHERE relkind = 'r' ORDER BY pg_total_relation_size(oid) DESC; -- 改写思路:先查目标库的等价视图/函数,再逐列对齐 -- 部分版本提供 pg_total_relation_size 与 pg_size_pretty,需实测 SELECT relname, pg_total_relation_size(oid) AS bytes FROM pg_class WHERE relkind = 'r';我的做法是准备一张"系统表映射表",把项目里用到的每个系统视图、每个函数列出来,在评估库上逐条执行,记录可用/不可用/字段差异,然后决定是改写、是换目标库提供的等价视图,还是干脆把这类功能改成一个独立的小监控服务——最后这个方案听着激进,但对那些重度依赖 PG 系统表的运维平台来说,往往是最省事的。
还有一类更隐蔽的:::regclass、::regtype这种类型转换,以及用quote_ident()、quote_literal()拼动态 SQL 的写法。动态 SQL 在存储过程里很常见,迁移时要特别检查拼接后的标识符会不会撞上保留字。
3.3 存储过程与触发器:PL/pgSQL 的兼容边界
PL/pgSQL 大体上能平移,但边界不少。
第一,包(package)。GaussDB 提供了类 Oracle 的 package 语法,这是新增能力,PG 里没有,所以从 PG 迁过去的过程不会用到,反而要小心别混用两套风格。
第二,游标与异常处理。EXCEPTION WHEN OTHERS THEN这类写法支持度一般可以,但异常码的对应关系可能有差异,靠SQLSTATE精确匹配的逻辑要重测。
第三,动态 SQL。EXECUTE ... USING ...一般可用,但EXECUTE拼接带RETURNING、带INTO的组合要逐条验证。
第四,触发器。行级BEFORE/AFTER触发器通常支持,INSTEAD OF触发器(用于视图)支持度就差很多。另外分布式表上的触发器行为要特别关注——触发器里做跨节点写操作,可能带来意料之外的事务行为和性能问题。
-- 触发器示例:注意 NEW/OLD 在分布式表上的行为需实测 CREATE OR REPLACE FUNCTION trg_orders_audit() RETURNS TRIGGER AS $$ BEGIN INSERT INTO orders_audit(order_id, op, changed_at) VALUES (NEW.id, TG_OP, CURRENT_TIMESTAMP); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER t_orders_audit AFTER INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE PROCEDURE trg_orders_audit();提示:存储过程的迁移不要追求"一次全改完"。先保证业务主链路的过程能跑,审计类、报表类的过程可以第二批处理。分批的关键是给每批定义清晰的验收标准。
4. 从单机思维到分布式思维:语法之外的真问题
前面三章讲的都是"能不能跑"。但 PG 迁 GaussDB,很多时候不只是换了个数据库,而是从单机架构换到了分布式架构。这一层的变化会反过来影响 SQL 怎么写、表怎么建、索引怎么加。很多团队迁移完成后性能不达预期,根子都在这里。
4.1 分布键选错,SQL 写得再对也慢
分布式库的核心概念是分布键。表的每一行按分布键的哈希值落到某个节点上。查询时如果条件里带了分布键,可以直接路由到单个节点;如果不带,就要广播到所有节点再汇总。
这个机制决定了一件事:高频查询的过滤条件、高频 JOIN 的连接键、高频 GROUP BY 的字段,应该是分布键的首选。
-- 按 user_id 分布,用户维度查询能单节点路由 CREATE TABLE orders ( order_id BIGINT, user_id BIGINT, amount NUMERIC(19,4), created TIMESTAMP ) DISTRIBUTE BY HASH(user_id);选错的后果很具体。假设订单表按order_id分布,但 90% 的查询是WHERE user_id = ?,那每次查询都要扫全部节点。数据量一上来,延迟直接翻倍。
还有几个实战要注意的点:
- 小表用复制表。字典表、配置表这类几百上千行的小表,用
DISTRIBUTE BY REPLICATION全节点复制一份,JOIN 时省掉数据重分布,收益非常明显。 - 没有分布键的表会怎样。有些版本默认取第一列做分布键,有些会建复制表,行为差异很大。建表时一定要显式声明,别赌默认值。
- 分布键不要选高倾斜的列。比如用
status(只有几个枚举值)做分布键,数据会全挤在一两个节点上,等于没分布。选基数高、分布均匀的列。
我见过一个案例:迁移后报表慢了五倍,SQL 一条没改,最后定位到是订单表按created(日期)做分布键,而报表按用户维度聚合,每次都要全节点扫描。改成按user_id分布后,恢复到了源库水平。这类问题靠改 SQL 是治不好的,只能改表设计。
4.2 分区表、全局索引与执行计划的连锁变化
分区表的语法差异前面提过,这里说影响。PG 10 之后的声明式分区,语法是PARTITION BY RANGE (col),某些目标库的语法结构不同,或者是用继承方式实现的老式分区。改写时不能只改关键字,要连带把分区边界、默认分区、分区索引一起理清楚。
-- 目标库常见分区语法形态(具体以版本文档为准) CREATE TABLE orders ( order_id BIGINT, created TIMESTAMP ) PARTITION BY RANGE (created); CREATE TABLE orders_2024 PARTITION OF orders FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');索引方面的坑:
CREATE INDEX CONCURRENTLY通常不支持。这意味着大表建索引会锁表,必须在迁移窗口内完成,窗口要留够。- 全局索引与本地索引的选择。分布式环境下,本地索引(每个节点各自一份)查询时要汇总;全局索引能直接定位,但写入代价更高。热点查询列适合全局索引,写入密集的表要谨慎。
- GIN、GiST 这类特殊索引支持度不一。原来靠 GIN 加速的 JSON 查询、全文检索,迁过去可能没索引可用,得换方案。
执行计划的变化更微妙。PG 的代价模型和目标库的代价模型不是一套参数,同一张表同样的索引,两边选出来的计划可能完全不同。所以迁移后必须重新做一轮 SQL 调优,把源库上的执行计划当作参考,而不是当作标准答案。我的习惯是挑出 Top 50 慢 SQL,在两边分别EXPLAIN,对比扫描方式、连接顺序、是否有数据重分布算子,再决定加不加索引、改不改写法。
5. 落地打法:扫描、改写、双跑、灰度四步走
讲完问题,讲怎么把这件事工程化地推下去。纯靠人工一条条看 SQL,项目稍微大点就会失控。我的做法是四个阶段,每个阶段有明确的产出物和验收标准。
5.1 自动化改写脚本的边界
先说结论:自动化改写能覆盖 60%~70% 的机械问题,剩下 30% 必须人工。
适合自动化的:保留字加引号或改名、serial转显式序列、FILTER转CASE WHEN、简单的类型显式转换、CONCURRENTLY关键字删除。这些都是模式清晰、改动确定的。
不适合自动化的:DISTINCT ON转窗口函数(涉及子查询嵌套和语义等价性)、ON CONFLICT转MERGE或WHERE NOT EXISTS(涉及并发语义)、jsonb操作符替换(涉及业务逻辑)、系统表查询(涉及字段映射)。这些改错了不会报错,会静默出错,风险极高。
-- DISTINCT ON:取每个用户最新一笔订单 SELECT DISTINCT ON (user_id) user_id, order_id, created FROM orders ORDER BY user_id, created DESC; -- 改写:窗口函数方案,通用性好 SELECT user_id, order_id, created FROM ( SELECT user_id, order_id, created, row_number() OVER (PARTITION BY user_id ORDER BY created DESC) AS rn FROM orders ) t WHERE rn = 1;改写时要注意created有重复值时两个写法的结果可能不同——DISTINCT ON取的是排序后的第一行,row_number()在有并列时按物理顺序取,需要用额外字段(比如order_id DESC)保证排序唯一。这种细节自动化脚本处理不了。
5.2 双跑比对:怎么设计才有效
双跑是发现隐式差异的唯一可靠手段。基本做法是:同一批 SQL,在源库和目标库各跑一遍,比对结果。
比对的维度设计比工具选型重要。我一般做三层:
| 比对层级 | 比对内容 | 能发现的问题 |
|---|---|---|
| 行数层 | 总行数、分组计数 | 过滤条件语义差异、NULL 处理差异 |
| 内容层 | 排序后逐行 MD5 或字段级 diff | 类型转换、精度截断、函数差异 |
| 聚合层 | sum/avg/min/max 等指标 | 数值精度、四舍五入规则差异 |
这里有个细节必须处理:比对前必须保证排序确定。如果 SQL 里没有唯一键排序,两边返回的行顺序可能不同,逐行比对会全错。解决办法是比对时统一加ORDER BY所有输出列,或者把结果集按主键排序后再哈希。
还要注意 NULL 的排序位置。PG 默认升序时 NULL 排在最后(NULLS LAST),降序时排最前。有些目标库的默认行为不同。这会直接影响ORDER BY ... LIMIT N的结果。解决办法是在所有涉及 NULL 列的排序里显式写NULLS FIRST或NULLS LAST。
-- 显式声明 NULL 位置,消除默认行为差异 SELECT * FROM orders ORDER BY created DESC NULLS LAST, order_id DESC LIMIT 100;时间精度也是高频差异点。timestamp(6)和timestamp(0)存同一时刻,比对时可能差几微秒。比对脚本要么统一date_trunc到秒,要么带容差比较。浮点数同理,别用=比,用差值阈值。
5.3 灰度切换与回滚预案
最后一步是切流量。我的建议是分三档:
第一档,只读灰度。把部分读请求打到目标库,写请求还在源库。这个阶段验证查询正确性和性能。发现不一致就快速切回,影响面小。
第二档,双写。写请求同时写两边,读还在源库。这个阶段验证写入路径、事务行为、自增序列、触发器。双写要处理失败补偿,一般用消息队列做异步补偿。
第三档,读写切换。读切到目标库,写还在双写。观察一段时间没问题,再停源库写入,完成切换。
回滚预案要提前写好,不能临时想。关键点有三个:数据回滚(怎么把目标库的新数据同步回源库)、流量回滚(切流量的开关在哪儿、多快能生效)、业务回滚(应用配置怎么切回老连接串)。三件事都要提前演练一遍,尤其是数据回滚,涉及增量同步的反向配置,第一次做基本都会出问题。
提示:切换窗口尽量选在业务低峰,并且提前和上游下游打好招呼。数据库切换不是单点动作,依赖它的数据同步、报表、数据仓库都可能受影响。
最后一句话分享我自己的体会:迁移项目里最容易低估的不是技术难度,而是"改完之后的验证成本"。一条 SQL 改写只花五分钟,验证它改对了可能要半小时。所以评估工作量的时候,把验证时间按改写时间的三倍算,项目排期才靠谱。另外,那些看起来"没什么用"的老后台功能、没人访问的历史报表,该下线就趁迁移一起下线,别一股脑全搬过去——迁移最大的隐性成本,往往是为没人用的东西付的。