从 PostgreSQL 迁到 GaussDB,第一轮回归最容易暴露的并不是"性能慢",而是"SQL 直接跑不通"。我手上这个业务系统,PG 侧整理了 2100 多条回归用例,切到 GaussDB 之后第一轮挂了 340 多条,报错五花八门:列不存在、函数不存在、插入报非空约束、明明有数据的查询返回空集、金额字段小数位莫名其妙对不上。数据库迁移里最容易被低估的一块,就是语法兼容性——它不像性能问题那样有曲线可看,它是一刀切的硬伤,跑不通就是跑不通。这篇文章我想把 PG 到 GaussDB 的语法兼容性挑战拆开讲透:哪些差异是"能解析但语义变了"这种最危险的,哪些只是"报个错改一下就完事"的,以及我在实际项目里总结出来的一套可复用的扫描、改写、校验流程。适合正在做或准备做这条迁移路线的 DBA、后端开发和数据平台同学,哪怕你只写过 CRUD 也能看懂,因为大部分坑最终都会以"应用报错"的形式落到业务侧。
1. 迁移方案的顶层设计与兼容模式选型
1.1 把"语法兼容"拆成四层来看,才不会乱
很多人一上来就打开 SQL 文件开始改,改到第三天发现改不完,因为没搞清楚问题分几类。我的经验是先分四层:第一层是解析层,语句能不能被目标库的词法语法分析器吃下去,比如CREATE INDEX CONCURRENTLY、LATERAL子查询、ON CONFLICT这些可能直接语法报错;第二层是语义层,语句能执行,但结果和 PG 不一样,比如空串与 NULL 的等价判断、整数除法返回值、字符串拼接遇到 NULL 的行为,这一类是最致命的,因为不报错,只是数据悄悄错了;第三层是对象层,DDL 建不出来或者建出来结构变了,比如布尔列、带时区时间戳、序列自增、分区表语法;第四层是运维层,备份恢复工具、监控视图、权限模型对不上。四层里,第一层和第二层要在迁移评估阶段扫干净,第三层在结构迁移阶段解决,第四层放在上线前演练。
为什么要这样分层?因为每一层的处理成本差一个数量级。解析层报错,改一条 SQL 可能五分钟;语义层出错,你可能要追一周的数据差异,还要写数据修复脚本,甚至要停机回滚。所以我的排序原则是:先保语义正确,再保语法通过,最后谈性能。见过太多团队反过来做,先想办法让 SQL 跑通(比如到处加显式类型转换),跑通之后语义还是错的,上线三天才发现账单金额少了。
1.2 目标库兼容模式怎么选:A 模式还是 PG 模式
GaussDB 建库时可以指定兼容模式,常见的有面向 Oracle 习惯的 A 模式、面向 MySQL 习惯的 B 模式,以及面向 PostgreSQL 习惯的 PG 模式。这个选择基本上决定了你后续 70% 的改造工作量,必须先定下来再动手。如果你迁过来的这套系统本来就是 PG 原生写法,应用层 SQL 大量依赖 PG 语义,那优先选 PG 模式,能省掉大量空串、大小写、类型隐式转换的坑;如果这套系统未来还要和其他 Oracle 风格的应用共库,或者团队本身更习惯 A 模式的行为,那就选 A 模式,但你必须提前在应用层把空串和标识符大小写这两件事处理干净。
建库语句大致是这样,具体参数以你手上的版本文档为准:
CREATE DATABASE appdb WITH ENCODING = 'UTF8' DBCOMPATIBILITY = 'PG' TEMPLATE = template0;这里有个容易被忽略的点:兼容模式是库级属性,建完基本不能改。所以我的做法是先建两个测试库,一个 A 模式一个 PG 模式,把同一批 DDL 和核心 SQL 各跑一遍,统计报错条数和结果差异条数,用数据说话再决定。这个动作两个小时就能做完,但能避免后面两周的返工。另外提醒一句,即使是 PG 模式,也不等于"和 PG 一模一样",它只是把行为往 PG 靠,函数集、系统表字段、DDL 语法仍然有差异,别把兼容模式当成免死金牌。
1.3 迁移节奏:评估、改造、双跑,一步都不能省
我把整个迁移切成三段。第一段是评估,目标是产出一份"对象清单 + 风险分级",把所有表、视图、函数、触发器、序列、索引、分区、定时任务全部拉出来,按改造难度标红黄绿。第二段是改造,按分层顺序推进:先 DDL 后 DML,先结构后逻辑,先函数后应用 SQL。第三段是双跑,新旧两套库同时接流量(读走新库、写双写,或者按业务模块灰度切),用同样的输入比对输出。双跑阶段最重要的不是"有没有报错",而是"结果对不对",所以必须有一套自动化的结果比对机制,不能靠人工点页面。
为什么双跑不能省?因为语义层的差异只有真实数据才能暴露。举个我自己踩过的例子:某个统计报表在测试库跑出来数字完全正确,因为测试数据里没有空串;上线后生产数据里有大量历史遗留的空串,A 模式下这些空串被当成 NULL,WHERE status = ''一条都查不到,报表直接归零。这种问题纯靠 code review 是看不出来的,只能靠真实数据比对。
2. 高频语法差异逐项拆解与改写方法
2.1 标识符大小写:最容易被忽视的第一杀手
PG 的规则是:不加双引号的标识符统一折叠成小写,加双引号则原样保留。所以你在 PG 里写CREATE TABLE T_Order (ID int),实际建出来的表名是t_order,列名是id。到了 A 模式下,未加引号的标识符往往折叠成大写,于是t_order和T_ORDER变成两个东西,应用里写select id from t_order直接报"表或视图不存在"。这个问题的恶心之处在于:它不是全部报错,而是一部分报错、一部分恰好能跑,让你误以为只是零星问题。
我的处理原则有三条。第一,所有业务对象统一用小写加下划线命名,DDL 脚本里一律不加双引号,从源头消掉大小写歧义。第二,如果历史代码里大量出现带引号的混合大小写对象名,不要试图在数据库侧兼容,直接在建表阶段把名字规范化,然后在应用层做一次全局搜索替换。第三,迁移后跑一遍元数据比对,把源库和目标库的表名、列名全部拉出来做 diff,一条不漏。
-- 源库导出对象清单,落到临时表后与目标库做 diff SELECT n.nspname AS schema_name, c.relname AS object_name, c.relkind AS object_type FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND c.relkind IN ('r', 'v', 'm', 'S', 'i') ORDER BY 1, 2;注意:
relkind在不同版本里含义略有差别,r是普通表、v是视图、m是物化视图、S是序列、i是索引。导出后建议人工过一遍,别完全相信脚本。
2.2 空串、NULL 与字符串拼接:语义层最大的雷区
这是我认为整个迁移里最危险的一块。在 PG 里,空串''和NULL是两个完全不同的东西,'' IS NULL返回 false,'a' || NULL返回 NULL,length('')返回 0。而在 A 模式下,空串和 NULL 在很多场景被视为等价,'a' || NULL可能返回'a',WHERE col = ''可能一条都匹配不到。这带来的连锁反应非常恐怖:非空约束突然报错(因为写入的''被转成了 NULL)、COALESCE的兜底逻辑失效、字符串拼接出来的内容缺字段、唯一索引行为变化。
我遇到过一个典型案例:某个配置表有个remark字段允许为空,应用写入了'',PG 里存的是空串。迁到 GaussDB 后,这批数据被识别成 NULL,然后某个下游服务读取时用remark || '-suffix'拼接,PG 时代结果是-suffix(因为 NULL 传播),GaussDB 下变成-suffix也一样,看起来没差;但另一个地方用WHERE remark = ''做判断的代码就彻底失效了,因为 NULL 不等于空串。这类问题最麻烦的地方在于它不报错,只是逻辑走错分支。
我的应对策略分三步。第一步,在评估阶段统计全库空串分布,把含有空串的列全部列出来,评估哪些列有业务含义、哪些只是历史脏数据。第二步,做数据清洗,把"业务上代表空"的空串统一刷成 NULL,把"业务上代表有效值"的空串保留但改成语义明确的占位值,比如'NONE'。第三步,应用层改造,所有判断空值的代码统一改成IS NULL或COALESCE,禁止用= ''做判断。
-- 评估:找出含空串的列(以 varchar 类为例,需按表逐个执行) SELECT count(*) FILTER (WHERE col_a = '') AS empty_col_a, count(*) FILTER (WHERE col_a IS NULL) AS null_col_a FROM target_table;提示:
FILTER子句在部分版本上支持情况不一,如果目标库不支持,改成SUM(CASE WHEN ... THEN 1 ELSE 0 END),兼容性更稳。另外这个扫描要按表、按列逐个来,全库一次性扫会锁表,生产库务必放到低峰期或者从只读备库上扫。
2.3 数据类型映射:逐条对照,别凭感觉
数据类型是 DDL 改造的主战场,我整理了一份实际项目里用得最多的对照关系,注意这只是常见映射,具体以你的目标库版本文档为准,落地前一定要建表验证。
| PG 侧类型 | 目标库建议类型 | 风险等级 | 说明 |
|---|---|---|---|
boolean | char(1)或smallint | 高 | 部分兼容模式没有布尔列类型,需应用层约定'Y'/'N'或1/0 |
timestamptz | timestamp+ 独立时区列 | 高 | 带时区类型支持有限,时区语义要下沉到应用层 |
bytea | blob | 中 | 大字段读写接口和 JDBC 方法要同步改 |
serial/bigserial | 显式序列 +DEFAULT nextval | 中 | 自增语义可用,但序列归属和权限要重新确认 |
json/jsonb | 同名或text | 中 | 操作符->、->>支持不一,复杂查询建议应用层解析 |
uuid | varchar(36)或原生 uuid | 中 | 若无原生类型支持,改用字符串并统一小写格式 |
inet/cidr/macaddr | varchar | 低 | 网络类型一般不支持,改字符串存储 |
point/polygon/line | text或拆列 | 低 | 几何类型基本不支持,改结构化存储 |
tsvector/tsquery | text+ 外部检索 | 高 | 全文检索能力不对等,需要重新设计 |
money | numeric(18,2) | 低 | 建议全库统一用 numeric,避免精度歧义 |
text | text或varchar(n) | 低 | 注意varchar不带长度在某些模式下等价于长度 1 |
表里我特意把boolean和timestamptz标成高风险,因为这两个在实际项目里翻车率最高。布尔类型的问题不只是建表报错,更深的是应用层 ORM 生成的 SQL 里到处是= true、isTrue这种判断,改类型之后 ORM 映射要同步调整,否则读出来全是 null。带时区时间戳的问题在于语义丢失:PG 存的是 UTC 时间点,目标库如果只支持不带时区的 timestamp,你存进去的到底是服务器本地时间还是 UTC 时间,必须在迁移前定死规则,否则跨时区业务会出现几小时的偏差。
2.4 函数与操作符:能跑不代表跑对
函数差异是改造量最大的一块,因为业务 SQL 里到处是函数调用。我把常见的差异归成三类。第一类是完全不支持,需要换成等价写法,比如某些正则函数、数组展开函数、全文检索函数。第二类是同名但参数顺序或返回值不同,比如聚合函数的分隔符位置、日期格式化模板的占位符(PG 的to_char用YYYY-MM-DD,其他体系可能用yyyy-mm-dd,大小写含义不同)。第三类是隐式转换行为不同,比如WHERE varchar_col = 123,PG 会直接报类型不匹配,某些兼容模式会自动把字符串转成数字,看起来更方便,但一旦遇到非数字字符串就抛异常,反而更难排查。
几个高频改写示例,都是我在项目里真实改过的:
-- 改写前(PG 原生写法) SELECT * FROM orders WHERE remark ILIKE '%urgent%'; -- 改写后(不依赖 ILIKE) SELECT * FROM orders WHERE LOWER(remark) LIKE '%urgent%';-- 改写前:依赖 generate_series 造数 SELECT generate_series(1, 100) AS n; -- 改写后:用递归 CTE 兜底(目标库支持递归 CTE 时) WITH RECURSIVE seq(n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 100 ) SELECT n FROM seq;-- 改写前:ON CONFLICT 做幂等写入 INSERT INTO user_points (user_id, points) VALUES (1001, 10) ON CONFLICT (user_id) DO UPDATE SET points = user_points.points + EXCLUDED.points; -- 改写后:先 MERGE 或先查后写(视目标库支持情况) MERGE INTO user_points t USING (SELECT 1001 AS user_id, 10 AS points FROM dual) s ON (t.user_id = s.user_id) WHEN MATCHED THEN UPDATE SET t.points = t.points + s.points WHEN NOT MATCHED THEN INSERT (user_id, points) VALUES (s.user_id, s.points);注意:
MERGE的语法在不同体系里差异不小,dual这种伪表也不一定存在,改写前先在小环境验证。我的习惯是每个改写都准备一份"输入-期望输出"的对照用例,改完立刻跑,别攒着一起测。
2.5 DDL 语法:分区表、索引、约束的差异点
分区表是最典型的语法不对等。PG 声明式分区写法是PARTITION BY RANGE (col)加FOR VALUES FROM ... TO ...,而 GaussDB 更接近传统写法,用PARTITION BY RANGE (col)加PARTITION p1 VALUES LESS THAN (...),边界语义一个是左闭右开、一个是小于,差一个等号就可能把数据分错区。索引方面,部分索引、表达式索引一般能用,但CREATE INDEX CONCURRENTLY的在线建索引能力要按版本确认,大表建索引如果在业务高峰期做,锁表时间可能直接打满。
我的做法是:所有 DDL 不用工具自动转,手工重写一遍。听起来笨,但这是最稳的。工具转出来的 DDL 经常在细节上埋雷,比如默认值丢失、约束名冲突、字符集不对。手工重写的时候顺手把命名规范、注释、表空间一起梳理了,反而省事。DDL 重写完之后一定要做一次结构比对,把源库和目标库的列名、类型、可空性、默认值、约束全部拉出来对照。
-- 结构比对:导出列定义清单 SELECT c.relname AS table_name, a.attname AS column_name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnotnull AS not_null, pg_get_expr(d.adbin, d.adrelid) AS default_expr FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum WHERE a.attnum > 0 AND NOT a.attisdropped AND c.relkind = 'r' AND n.nspname = 'public' ORDER BY 1, a.attnum;2.6 存储过程与匿名块:PL/pgSQL 的兼容边界
如果业务里有大量 PL/pgSQL 存储过程,这块的工作量要单独评估。基础语法兼容度还不错:变量声明、IF/ELSIF、LOOP、异常处理EXCEPTION WHEN OTHERS、游标、%TYPE、%ROWTYPE基本能用。但有几点要注意:GET DIAGNOSTICS取影响行数的写法在不同版本支持不一;PERFORM关键字在某些实现里需要用SELECT ... INTO或直接执行替代;RAISE NOTICE的日志输出位置和级别映射要确认;SECURITY DEFINER加SET search_path的组合要验证权限是否符合预期。
匿名块DO $$ ... $$是最容易出问题的,部分版本支持有限,我的建议是一律改写成具名存储过程,既能被调用,也方便版本管理和权限控制。另外提醒一句,存储过程迁移完之后一定要做并发测试,因为有些实现在函数内的锁行为和 PG 不完全一致,单线程跑通不代表并发没问题。
-- 改写示例:把匿名块改成具名过程 CREATE OR REPLACE PROCEDURE refresh_daily_stat(p_date DATE) LANGUAGE plpgsql AS $$ BEGIN DELETE FROM daily_stat WHERE stat_date = p_date; INSERT INTO daily_stat (stat_date, order_cnt, amount) SELECT p_date, COUNT(*), COALESCE(SUM(amount), 0) FROM orders WHERE created_at >= p_date AND created_at < p_date + 1; RAISE NOTICE 'refresh done: %', p_date; EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'refresh failed: %', SQLERRM; RAISE; END; $$;3. 实操过程:从扫描到上线的完整链路
3.1 第一步:用系统表做一次全量体检
评估阶段我不太依赖第三方工具的第一版报告,而是自己写 SQL 从系统表拉数据,因为系统表的信息最原始、最可信。体检的核心目标是产出四张清单:表清单(含行数、大小)、列清单(含类型、可空性、默认值)、对象清单(视图、函数、序列、索引、触发器)、依赖清单(视图依赖哪些表、函数调用哪些函数)。这四张清单一出来,改造工作量和排期基本就能估准了。
-- 表清单:行数与容量粗估 SELECT n.nspname AS schema_name, c.relname AS table_name, c.reltuples::bigint AS est_rows, pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema') ORDER BY pg_total_relation_size(c.oid) DESC;-- 函数清单:把所有自定义函数定义导出来,便于逐个评审 SELECT n.nspname AS schema_name, p.proname AS func_name, l.lanname AS lang, pg_get_functiondef(p.oid) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace JOIN pg_language l ON l.oid = p.prolang WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') ORDER BY 1, 2;提示:
c.reltuples是统计信息估算值,不是精确行数,用来排优先级够用。要精确行数就得count(*),大表上别在生产高峰做。另外函数清单导出来之后,建议先按调用频次排个序,优先改高频的那几个,低频的可以放到二期。
3.2 第二步:DDL 批量改造的落地方式
DDL 改造我一般不用全自动脚本,而是"脚本生成初稿 + 人工复审 + 建表验证"三步走。脚本生成初稿的办法是:从系统表导出所有表的列定义,按映射表做一次类型替换,生成目标 DDL;人工复审重点看三样东西——默认值、约束、索引;建表验证是在目标库实际执行一遍,把所有报错收集起来逐个解决。这个过程看着慢,但比"边跑边改"效率高得多,因为报错是集中的,你能一次性看到全部问题类型。
批量处理的时候有个小技巧:先改表结构,再迁数据,最后建索引和约束。原因是先建索引再灌数据,插入性能会掉一大截,尤其是唯一索引在大批量导入时反复校验,慢得离谱;而先灌数据再建索引,一次扫描就能建完。唯一要注意的是非空约束和唯一约束要在灌数据之前先建好,否则脏数据进去了后面清理更麻烦。这个顺序上的取舍,本质上是在"约束校验的时机"和"写入吞吐"之间做平衡。
3.3 第三步:数据搬迁与序列校准
数据搬迁用的是逻辑导出导入工具,源库侧对应pg_dump,目标库侧对应gs_dump、gs_restore、gsql这一套,命令形态接近但不完全一样,参数要逐个确认。我习惯用纯文本格式导出,因为可读、可改、出问题好排查;自定义格式导出快,但跨版本兼容性不如文本格式稳。
# 源库导出:只导结构和数据,不含属主与权限 pg_dump -h source-host -p 5432 -U app_user -d appdb \ --no-owner --no-privileges \ -F p -f /data/dump/appdb.sql # 目标库导入:先建库建用户,再执行导入 gsql -h target-host -p 8000 -U app_user -d appdb -f /data/dump/appdb.sql序列校准是最容易漏的一步,也是上线后最爱出问题的一步。逻辑导出只搬数据,序列的当前值往往不会跟着走,或者走了但顺序错位。结果就是:应用插入第一条数据,主键冲突。我的做法是数据灌完之后,统一跑一遍序列校准脚本,把每个序列的当前值设置成对应表的最大 ID 加一个安全步长。
-- 序列校准:以 orders 表为例 SELECT setval('orders_id_seq', COALESCE((SELECT MAX(id) FROM orders), 0) + 1, false);注意:第三个参数
false表示"下一个 nextval 返回给定值",true表示"给定值已经被用过"。这两个参数弄反了就会跳号或者撞号,我第一次写的时候就写反过,结果新插入第一条就冲突。另外校准脚本建议做成通用的,从系统表里把所有序列和对应表、对应列自动关联出来,别手工一个个写。
3.4 第四步:数据一致性校验怎么做才靠谱
数据校验不能只比行数,行数对上但内容错位的情况太常见了。我的校验方案是三层:第一层比行数,快速发现"漏表、漏分区、导入中断"这类粗粒度问题;第二层比聚合摘要,对每个表按主键排序做一次字符串聚合再取哈希,比单个字段的 sum/count 更能发现内容差异;第三层比业务指标,把核心报表的数字跑出来和源库对照,这一层最贴近业务感知。
-- 第二层:按分桶做摘要比对(大表建议按主键区间分桶执行) SELECT COUNT(*) AS row_cnt, MD5(string_agg(id::text || '|' || COALESCE(amount::text, 'N'), ',' ORDER BY id)) AS content_digest FROM orders WHERE id BETWEEN 1 AND 100000;-- 第三层:核心业务指标对照 SELECT stat_date, COUNT(*) AS order_cnt, SUM(amount) AS total_amount, COUNT(DISTINCT user_id) AS user_cnt FROM orders WHERE stat_date BETWEEN DATE '2024-01-01' AND DATE '2024-03-31' GROUP BY stat_date ORDER BY stat_date;提示:
string_agg的分隔符和排序写法在部分版本上支持不一,如果目标库不认ORDER BY写在聚合函数里,就套一层子查询先排序再聚合。另外摘要比对一定要用同样的 SQL 在源库和目标库各跑一次,同一条 SQL 能同时跑通,本身就是兼容性的一个验证。
3.5 第五步:应用层 SQL 与 ORM 的隐藏地雷
数据库侧改完只算完成了一半,应用层还有大量隐藏 SQL。ORM 框架生成的 SQL 是最难排查的一类,因为你看不到源码,只能通过慢日志或者 SQL 审计去抓。我的做法是:迁移前在源库打开全量 SQL 审计,跑一周完整业务周期(包含日终批处理、月度结算这类低频任务),把抓到的 SQL 全部落库,然后逐条在目标库执行一遍,统计失败率。这一步能抓出 80% 以上的应用层兼容问题,比看代码高效得多。
常见的问题类型有这么几种:ORM 自动生成的LIMIT ? OFFSET ?分页在深分页时性能崩掉;批量插入用了INSERT ... VALUES (...), (...)多值语法,某些实现对单条语句的 value 数量有限制;RETURNING子句取自增主键的写法要确认支持;连接池的健康检查 SQL(比如SELECT 1)一般没问题,但有些框架会用SELECT pg_backend_pid()这类系统函数,需要替换。
4. 常见问题排查实录与避坑清单
4.1 问题速查表
| 现象 | 常见根因 | 处理方向 |
|---|---|---|
| 报"表或视图不存在",但表明明建了 | 标识符大小写折叠规则不同 | 统一小写命名,去掉双引号,检查搜索路径 |
| 插入报非空约束冲突,应用明明传了值 | 空串被当作 NULL | 清洗空串数据,代码改用IS NULL判断 |
| 查询返回空集,但表里确实有数据 | WHERE col = ''匹配不到 NULL | 改判断条件,或把空串刷成占位值 |
| 金额小数位不对、四舍五入结果不一致 | numeric 精度或隐式转换规则不同 | 显式转型,统一CAST(... AS numeric(18,2)) |
| 自增主键冲突 | 序列当前值未校准 | 灌数据后跑序列校准脚本 |
| 日期少 8 小时 / 多 8 小时 | 时区语义丢失 | 统一存 UTC,时区下沉到应用层 |
| 布尔字段读出来全是 null | 类型映射不匹配 | 改char(1)或smallint,同步改 ORM 映射 |
| 存储过程编译报错 | PL/pgSQL 语法细节差异 | 逐段改写,重点看异常处理和游标 |
| 大表建索引锁表严重 | 在线建索引能力受限 | 低峰期操作,或建影子表切换 |
| 深分页查询超时 | LIMIT/OFFSET实现与索引利用差异 | 改游标分页(按主键区间翻页) |
4.2 两个印象最深的排障过程
第一个案例是"报表数字对不上"。现象是月度汇总金额比源库少了几百块。排查过程走了弯路:先怀疑精度问题,把 numeric 改成更高精度,没解决;再怀疑浮点误差,把所有计算链路查了一遍,也没问题。最后是靠逐条比对明细发现的——有几笔订单的discount字段存的是空串,A 模式下被当成 NULL,SUM(discount)直接忽略了这几条,而源库把空串转成 0 参与计算。这个问题教会我一件事:聚合函数遇到 NULL 是静默跳过的,不会报错,所以空串清洗必须在迁移前做完,不能指望上线后补。
第二个案例是"偶发的主键冲突"。现象是白天正常,凌晨批处理跑完之后开始报冲突。查了半天发现是批处理里有段逻辑用了setval重置序列,重置的值算错了,导致序列回退。这类问题的排查思路是:先确认冲突的时间规律(是不是和某个定时任务重合),再抓那个时间窗口的 SQL 审计,看有没有对序列的操作。教训是:序列是全局状态,任何手工干预都要有记录,最好直接禁止业务代码里出现setval。
4.3 我总结的避坑清单
- 迁移前一定要先做空串统计,把所有含空串的列拉出来逐个确认业务含义,这一步能消掉一半以上的语义类问题。
- 兼容模式先定死再动手,建库之后基本改不了,别想着"先随便选一个,后面再换"。
- 所有 DDL 手工重写一遍,工具转出来的初稿只当参考,别直接执行。
- 数据灌完必须校准序列,并且把校准脚本纳入上线检查项。
- 校验要三层:行数、摘要、业务指标,缺一层都可能漏。
- 应用层 SQL 靠审计抓,别靠人肉找,跑满一个完整业务周期再评估。
- 上线前至少做一次全链路压测,语法改对了但执行计划变了,性能可能掉一个数量级。
- 保留回滚通道,双跑阶段新库出问题能随时切回源库,别做"一刀切"的切换。
4.4 性能回归这件事,别等上线才想起来
语法兼容性解决完,很多人就直接上线了,结果发现查询慢了三倍。原因是改写后的 SQL 虽然能跑,但执行计划变了。最典型的是索引失效:改写了函数调用之后,原本能走索引的WHERE date_col > ...变成了WHERE to_char(date_col, ...) > ...,索引直接废掉,全表扫描。还有一种情况是LIKE '%xxx%'这种前后模糊匹配,PG 上本来就慢,迁过去之后如果优化器统计信息不准,可能慢得更离谱。
我的做法是:每条改写后的核心 SQL,都要在目标库跑一次EXPLAIN,和源库的执行计划做对照,重点看三件事——有没有走索引、估算行数和实际行数差多少、有没有出现意外的排序或哈希。统计信息一定要在数据灌完之后重新收集一遍,否则优化器基于空表的统计做决策,执行计划能离谱到你想不到。压测阶段如果发现某条 SQL 慢了,先看执行计划再看 SQL 本身,很多时候问题不在写法,在统计信息。
-- 收集统计信息(表名替换为实际表) ANALYZE orders; ANALYZE user_points; -- 看执行计划,重点对比索引使用情况 EXPLAIN (ANALYZE, BUFFERS) SELECT id, amount FROM orders WHERE user_id = 1001 AND created_at >= DATE '2024-01-01';注意:
EXPLAIN ANALYZE会真实执行语句,写操作或者重查询别在生产库直接跑。BUFFERS参数在某些版本上可能不支持,去掉也能看计划。
我在实际项目里的体会是,PG 到 GaussDB 这条迁移路线,真正难的不是"改语法",而是"识别语义差异"。语法报错会自己跳出来找你,语义差异藏在数据里,得你主动去找。所以我给自己定的规矩是:任何一条改写,都必须配套一条对照用例,输入相同、输出必须相同,用例跑通才算改完。这套规矩执行下来,第一轮 340 多条失败用例,第二轮就压到了 20 条以内,剩下的基本都是低频边缘场景。如果你也在做类似的迁移,建议先把评估那一步做扎实,宁可多花三天把清单理清楚,也别急着上手改 SQL——清单不清,后面全是返工。