☰
PostgreSQL迁移GaussDB:兼容性分层评估与SQL改写实战
2026/11/2 17:09:06 网站建设 项目流程

上个月接手一个迁移评估,对方递过来的材料是三样东西:一份两千多行的建表脚本、一个导出的pg_dump全量 SQL 文件,以及一句“应该问题不大吧,都是关系型数据库”。我看完第一眼就回了句:能跑通和跑得对是两码事。这两年从 PostgreSQL 迁到 GaussDB 的项目越来越多,很多团队在前期评估阶段把工作量估得偏乐观,真正动手才发现坑不在“建表”这一步,而在函数、隐式转换、UPSERT、存储过程和执行计划这些地方。这篇就把我从几次 PG 到 GaussDB 迁移中攒下来的东西摊开讲——语法兼容性到底卡在哪几层、每一层怎么定位、怎么改写、怎么验证,以及割接当天怎么保住退路。适合正在做迁移评估的 DBA、后端开发和架构同学,也适合只是想把手里那套 PG 生态的 SQL 写得“更可移植”一点的人。

1. 迁移前先把“兼容性”这三个字拆开看

1.1 语法能跑通只是第一层,语义一致才是真兼容

很多人对“兼容”的理解停留在“这条 SQL 不报错”。这是最浅的一层。我把兼容性拆成四层,从上往下,成本依次翻倍:

层级判断标准典型表现排查手段
语法层语句能被解析器接受关键字、函数名、子句顺序批量执行 DDL 脚本看报错
类型层类型映射与隐式转换行为一致空串变 NULL、精度截断、比较结果不同逐表核对类型、抽样比对
语义层同样输入得到同样输出排序规则、聚合空值处理、时区双跑比对结果集
性能层执行计划与资源消耗可接受索引失效、算子退化、并行度差异EXPLAIN 对比、压测

绝大多数迁移事故出在第二层和第三层。语法层反而是最好办的,错一条改一条,编译期就能暴露。类型层和语义层是“静默错误”——不报错,但数据悄悄错了,等业务方发现对账不平,往往已经过了割接窗口。所以我在评估阶段的原则是:宁可多花两天做双跑比对,也不要在割接后半夜对着监控猜哪张表错了。

提示:凡是涉及金额、库存、积分、账单的记录,迁移后必须做逐行或分片校验,不能只信“行数对上了”。

1.2 迁移成本到底花在哪:一次真实的工时分布

我复盘过三个中等规模项目的工时,大致是这个分布:DDL 改写和建库建表占 10% 到 15%,SQL 与函数改写占 30%,存储过程与触发器改造占 25%,数据搬迁与校验占 20%,剩下 10% 到 15% 是割接演练和回滚预案。注意,存储过程那 25% 是波动最大的——如果原系统里有大量 PL/pgSQL 写的业务逻辑,这块能直接翻倍。

还有个容易被忽略的成本:测试环境的等价性。PG 那边可能是 9.6 或 12,目标库是某个具体版本的 GaussDB,两者的优化器行为、可用的 hint、并行策略都不一样。如果测试环境的数据量只有生产的百分之一,那你测出来的执行计划基本没有参考价值。我在一次项目里就吃过这个亏:测试库上一切正常,生产上一上量,某个关联查询从走 Hash Join 变成了 Nested Loop,响应时间从 80ms 涨到 4 秒。

1.3 我用的兼容性盘点四步法

第一步,把源库的对象全量导出成清单。表、视图、物化视图、序列、索引、约束、函数、存储过程、触发器、自定义类型、扩展,一个不漏。可以直接查系统表:

SELECT relname, relkind FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relkind IN ('r','v','m','S','i','c') ORDER BY relkind, relname;

函数和过程单独拉一份:

SELECT n.nspname, p.proname, l.lanname, p.prokind 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 l.lanname, p.proname;

第二步,按“改造量”给对象打标签。纯 DDL 的表打绿,带默认值表达式、生成列、复杂约束的打黄,带触发器、规则、自定义函数的打红。红的先做,因为它的不确定性最高。

第三步,整理一份“特性使用清单”。重点盯这几个:ON CONFLICT、RETURNING、WITH RECURSIVE、窗口函数、LATERAL、FILTER子句、GROUPING SETS、数组操作符、JSON 操作符、正则函数、generate_series、自定义聚合、扩展(尤其是做模糊匹配和地理计算的)。这份清单基本决定了你要改写多少条 SQL。

第四步,挑三到五条最核心的业务 SQL 做“双跑”。同一份数据,源库跑一遍,目标库跑一遍,把结果集导出成文本做 diff。别小看这一步,它能提前把 80% 的语义差异暴露出来。

2. 类型系统与隐式转换:九成的诡异报错都出在这

2.1 基础类型的对应关系与几个反直觉的点

大部分基础类型是对得上的:int4、int8、numeric、varchar、text、timestamp、date、boolean都能直接映射。但有几个地方必须人工确认,我列成表:

PG 侧写法目标库建议写法需要注意的点
serial/bigserialserial或显式序列 + 默认值隐式创建的序列归属关系可能不同,建议显式建序列
texttext或varchar(n)目标库对超长字段的处理和存储方式有差异
json/jsonbjson或目标库的 JSON 类型jsonb的操作符支持度要逐条验证
uuid目标库 uuid 类型或varchar(36)部分版本对 uuid 的索引支持有限
int[]数组谨慎使用数组类型的操作符和索引支持不完整,能拆表就拆表
moneynumeric(19,4)强烈建议不要用 money,两边行为都不直观
enum枚举类型或varchar+ 检查约束枚举值的增删改在两边成本不同

serial这块我要多说一句。PG 里serial会自动建一个序列并设置OWNED BY,删表时序列跟着删。目标库虽然也支持serial,但我习惯显式写:

CREATE SEQUENCE seq_order_id START WITH 1 INCREMENT BY 1 NO CYCLE; CREATE TABLE t_order ( order_id BIGINT NOT NULL DEFAULT nextval('seq_order_id'), ... ); ALTER SEQUENCE seq_order_id OWNED BY t_order.order_id;

理由很简单:显式写出来,迁移工具解析的时候不会漏,出问题也好查。隐式依赖越少,搬迁越省心。

2.2 空串与 NULL 的等价性差异

这是最阴的一类坑,能让人排查一整天。在标准 PG 行为里,空字符串''和NULL是两个不同的值,'' IS NULL返回 false。但在某些兼容 Oracle 语义的模式下,空串会被当成 NULL 处理,'' IS NULL返回 true。这个差异会直接改变查询结果,尤其是那些用WHERE col = ''做判断的老代码。

我遇到过的一个具体场景:订单备注字段允许空串,业务代码里判断“有没有备注”用的是remark = ''。迁移之后这条判断永远查不到数据,因为空串入库变成了 NULL,NULL = ''的结果是 NULL 而不是 true。修复方式说简单也简单,把判断改成remark IS NULL OR remark = '',但前提是你得先发现它。

处理策略我一般这么定:

  • 建库阶段确认目标实例的兼容模式,把空串语义写进评估报告的“已知差异”里。
  • 应用层统一约定:字符串字段不用空串表示“无值”,一律用 NULL。
  • 数据搬迁时做一次清洗,把历史空串统一转成 NULL,并在应用侧同步调整判断逻辑。
  • 所有涉及字符串相等判断的索引,检查是否受影响。

注意:如果目标库开启了某种兼容模式,''的行为可能和新建库时的模式设置绑定,改模式不一定能改回来。这件事必须在建库之前定,不要等到数据搬完了再纠结。

2.3 隐式转换收严之后,索引为什么突然不走了

PG 在类型转换上相对宽松,比如varchar和text比较、int和numeric比较,通常能自动处理。目标库在某些场景下会收严,或者转换规则不同,结果就是:同一条 SQL 不报错,但索引失效了。

举个典型的例子。假设user_id是bigint,应用传参传成了字符串:

-- 源库上可能依然走索引 SELECT * FROM t_order WHERE user_id = '2001';

在某些实现里,这种比较会先把列做隐式转换再比,索引就用不上了。正确写法是显式转换参数:

SELECT * FROM t_order WHERE user_id = CAST('2001' AS BIGINT);

或者干脆在应用层保证参数类型和列类型一致。我个人的做法是:迁移期间把所有核心 SQL 的EXPLAIN输出抓下来存档,割接后逐条对比,看有没有Seq Scan替换掉Index Scan的情况。这个对比工作量不小,但比事后救火便宜得多。

另外一个高频点是LIKE和||拼接后的类型推导。WHERE name LIKE '%' || ? || '%'这种写法在参数类型不明确时,两边可能推出不同的结果类型。稳妥的写法是显式CAST(? AS TEXT)。

3. DDL 层的高频不兼容点逐个拆

3.1 建表语句:自增列、默认值、注释、存储属性

建表语句看着最人畜无害,其实细节最多。我按踩坑频率排了个序:

自增列。除了前面说的serial显式化,还要注意GENERATED ALWAYS AS IDENTITY的支持情况。老一点的版本可能不认这个语法,那就退回序列方案。另外注意OVERRIDING SYSTEM VALUE在数据搬迁时的作用——如果你要往 identity 列里灌历史数据,不加这个子句会报错。

默认值表达式。now()、current_timestamp、CURRENT_DATE一般都支持。但gen_random_uuid()这类函数依赖扩展,目标库未必有。替代方案是在应用层生成 UUID,或者用目标库自带的函数。还有一些用nextval之外的自定义函数做默认值的,需要逐个确认函数已迁移。

注释。COMMENT ON TABLE、COMMENT ON COLUMN一般没问题,但中文注释要注意字符集。建库时如果字符集选的不是 UTF8,中文注释会变成乱码。这一点在建库脚本的第一行就要定死。

存储属性。PG 的WITH (fillfactor=70)、TABLESPACE、UNLOGGED这些子句,目标库的支持度各不相同。fillfactor通常支持,表空间要看部署方式,UNLOGGED在很多实现里不支持,需要删掉。这类子句不影响逻辑正确性,但会影响脚本能否顺利执行,建议在批量执行前先做一次“语法预检”——把所有 DDL 丢进一个空库跑一遍,看报错清单。

# 预检思路:逐条执行,记录失败语句,不中断 while IFS= read -r line; do echo "$line" | psql -h target -d testdb -v ON_ERROR_STOP=0 2>> ddl_errors.log done < schema.sql

实际用的时候建议按分号切分语句,别按行切,否则多行语句会被切碎。

3.2 分区表的语法迁移

分区是 DDL 里改造量最大的一块。PG 10 之后用的是声明式分区,PARTITION BY RANGE (col),子分区用FOR VALUES FROM ... TO ...;目标库的分区语法更接近早期的写法,子分区用VALUES LESS THAN (...)。这两套语法不通用,必须重写。

-- 源库写法 CREATE TABLE t_log ( id BIGINT, log_date DATE, content TEXT ) PARTITION BY RANGE (log_date); CREATE TABLE t_log_202401 PARTITION OF t_log FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
-- 目标库写法 CREATE TABLE t_log ( id BIGINT, log_date DATE, content TEXT ) PARTITION BY RANGE (log_date); CREATE TABLE t_log_202401 PARTITION OF t_log FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

上面这个只是理想情况,实际差异往往在:是否支持DEFAULT分区、是否支持哈希分区、分区键能否是多列、子分区能否再分区、分区索引是全局还是本地。我建议的做法是:先确认目标版本的分区能力清单,再决定分区策略。如果原系统用的是哈希分区而目标库不支持,就得改成按时间或按取模列做范围分区,这会牵动应用层的路由逻辑。

还有一个隐形成本:分区表的数量。有的系统按月分区,三年就是 36 个分区,加上索引就是上百个对象。手工改写不现实,一定要写脚本批量生成。

# 批量生成分区 DDL 的简化示例 from datetime import date, timedelta def month_range(start: date, months: int): cur = start for _ in range(months): nxt = date(cur.year + (cur.month // 12), cur.month % 12 + 1, 1) yield cur, nxt cur = nxt for s, e in month_range(date(2024, 1, 1), 12): name = f"t_log_{s.strftime('%Y%m')}" print(f"CREATE TABLE {name} PARTITION OF t_log " f"FOR VALUES FROM ('{s}') TO ('{e}');")

3.3 索引与约束的落地差异

索引这块,B-Tree、唯一索引、复合索引基本都能平移。需要重点验证的是:表达式索引、部分索引(带WHERE的索引)、GIN/GiST索引、BRIN索引。

部分索引在两边都支持,但谓词表达式的写法可能要调整。表达式索引要求函数是 immutable 的,如果原索引用了自定义函数,得确认目标库上这个函数存在且被标记为 immutable。GIN索引常用于数组和 JSON 字段,迁移前要确认目标库的 opclass 是否齐全。BRIN索引是 PG 的特色,很多目标库不提供,遇到大表的 BRIN 索引,替代方案是改成分区表加本地索引。

约束方面,外键和唯一约束一般没问题,麻烦的是CHECK约束里的表达式。比如CHECK (amount > 0 AND status IN ('a','b')),字符串列表和布尔运算通常没问题,但如果里面用了正则或者自定义函数,就要单独验证。还有一个细节:DEFERRABLE约束的支持度。PG 允许把唯一约束和外键声明为可延迟,在某些批量导入场景下很有用,目标库不一定支持,不支持就得改导入顺序。

4. DML 与查询层的改写实战

4.1 UPSERT:从 ON CONFLICT 到 MERGE INTO

这是改写量最大的一类语句。PG 的INSERT ... ON CONFLICT ... DO UPDATE写法简洁,但目标库大多不直接支持这个语法,标准替代是MERGE INTO。

-- 源库:ON CONFLICT INSERT INTO t_stock (sku_id, qty, updated_at) VALUES (1001, 20, now()) ON CONFLICT (sku_id) DO UPDATE SET qty = t_stock.qty + EXCLUDED.qty, updated_at = EXCLUDED.updated_at;
-- 目标库:MERGE INTO MERGE INTO t_stock t USING (SELECT 1001 AS sku_id, 20 AS qty, now() AS updated_at) s ON (t.sku_id = s.sku_id) WHEN MATCHED THEN UPDATE SET qty = t.qty + s.qty, updated_at = s.updated_at WHEN NOT MATCHED THEN INSERT (sku_id, qty, updated_at) VALUES (s.sku_id, s.qty, s.updated_at);

改写的时候有几个坑要盯住:

第一,EXCLUDED是 PG 的特有引用方式,在MERGE里要换成USING子句的别名。别直接照抄,否则语义会错。

第二,MERGE的并发行为需要实测。两个会话同时MERGE同一条不存在的记录,可能出现唯一键冲突或者双写。PG 的ON CONFLICT在这一点上语义更明确。我的处理办法是在应用层加分布式锁,或者把MERGE包在重试逻辑里,捕获唯一键冲突后重试一次。

第三,批量MERGE的性能。源库可能是一次INSERT带几百行VALUES,换成MERGE后每行一个USING子查询,性能会掉。改进方式是用UNNEST或者临时表:

MERGE INTO t_stock t USING (SELECT * FROM tmp_stock_batch) s ON (t.sku_id = s.sku_id) WHEN MATCHED THEN UPDATE SET qty = t.qty + s.qty WHEN NOT MATCHED THEN INSERT (sku_id, qty) VALUES (s.sku_id, s.qty);

先把批量数据灌进临时表,再一次性MERGE,这个模式在两边都能跑,性能也稳定。我现在的习惯是:凡是批量写入,一律走临时表 +MERGE,不再用多值VALUES。

4.2 RETURNING、LIMIT/OFFSET 与递归 CTE

RETURNING在 PG 里用得极多,插入后直接拿回自增 ID。目标库的支持情况分版本,有的支持INSERT ... RETURNING,有的只支持部分场景。如果目标库不支持,替代方案有三个:

  1. 插入前先SELECT nextval('seq')拿 ID,再显式插入。缺点是序列会被消耗但可能没插入成功,出现空洞。空洞本身不是问题,但如果业务对 ID 连续性有要求就麻烦了。
  2. 插入后用唯一业务键回查。多一次查询,但在唯一索引下成本可接受。
  3. 用MERGE加上RETURNING(如果支持),或者用存储过程封装。

我一般选方案 2,因为不依赖序列行为,并发下也安全。

LIMIT/OFFSET两边都支持,深分页的性能问题也一样。OFFSET 100000这种写法在哪都慢,迁移是改造它的好时机,换成基于游标的分页:

-- 改造前 SELECT * FROM t_order ORDER BY order_id LIMIT 20 OFFSET 100000; -- 改造后:记住上一页最后一个 order_id SELECT * FROM t_order WHERE order_id > 100000 ORDER BY order_id LIMIT 20;

递归 CTE(WITH RECURSIVE)一般都能用,但要注意递归终止条件的写法差异。有的实现对递归深度的默认限制不同,遇到树形结构查询时要显式设置上限,否则可能直接报错或者查询超时。

4.3 函数、正则与 JSON 的替代写法

字符串函数大部分通用:substr、trim、upper、lower、replace、position、concat、string_agg都能找到对应。需要留意的是split_part、regexp_split_to_table、string_to_array这类,支持度不一。

正则是最容易出问题的。PG 用~、~*、!~做匹配,用regexp_match、regexp_matches、regexp_replace做提取替换。目标库通常支持~系列和regexp_replace,但regexp_matches返回多行的行为可能不同。稳妥的做法是把正则逻辑挪到应用层,或者改用substr+position的组合。

JSON 这块要格外注意。PG 的jsonb有丰富的操作符:->、->>、#>、#>>、@>、?、?|、?&。目标库如果只有json类型,@>这类包含操作符可能不支持,?系列也可能缺失。改写的思路是:把常用的 JSON 路径提取改成->>逐层取,把包含查询改成把关键字段抽出来建独立列。

-- 源库:jsonb 包含查询 SELECT * FROM t_event WHERE payload @> '{"type":"pay"}'; -- 目标库:改成路径提取 + 普通比较 SELECT * FROM t_event WHERE payload ->> 'type' = 'pay';

后一种写法在数据量大时性能差,因为没有索引。真要在 JSON 上做高频查询,正确做法是加一个生成列或者冗余列,在上面建索引。这个改造要在迁移评估阶段就提出来,别等上线后才发现慢。

5. 存储过程与触发器:改造量最大的一块

5.1 过程语言与语法糖的差异

两边都支持 PL/pgSQL 风格的存储过程,基础语法大体一致:DECLARE、BEGIN、EXCEPTION、END、IF、LOOP、FOR、RAISE。但细节差异不少:

特性PG 侧目标库侧处理建议
参数默认值支持支持度不一不支持的改成重载多个过程
RETURNS TABLE支持一般支持注意列名冲突
SETOF返回支持支持度不一改用RETURNS TABLE
包(PACKAGE)不支持部分版本支持不要依赖,用命名前缀替代
自治事务不支持部分版本支持不要在核心链路依赖
动态 SQLEXECUTE ... USING支持,语法略有差异逐条验证
游标FOR rec IN cursor支持注意NO SCROLL选项

最麻烦的是参数默认值和重载。PG 允许func(a int, b int default 0),调用时只传a。如果目标库不支持默认值,就得拆成两个过程:func(a)和func(a, b)。这个改造量取决于你原来写了多少带默认参数的过程,我见过一个系统里有两百多个,纯手改不现实,只能写脚本辅助。

5.2 三个高频改写模板

模板一:分页查询过程。原来用LIMIT/OFFSET加动态拼接,改写成显式参数加游标。

CREATE OR REPLACE PROCEDURE sp_query_order( p_user_id BIGINT, p_offset INT, p_limit INT ) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT order_id, amount, created_at FROM t_order WHERE user_id = p_user_id ORDER BY created_at DESC LIMIT p_limit OFFSET p_offset; END; $$;

如果目标库对RETURN QUERY支持不好,改成RETURNS TABLE加RETURN NEXT循环。

模板二:批量更新带异常捕获。这类过程在两边都要注意事务边界。

CREATE OR REPLACE PROCEDURE sp_batch_update() LANGUAGE plpgsql AS $$ DECLARE v_id BIGINT; BEGIN FOR v_id IN SELECT order_id FROM t_order WHERE status = 'pending' LOOP BEGIN UPDATE t_order SET status = 'done' WHERE order_id = v_id; EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'order % failed: %', v_id, SQLERRM; END; END LOOP; END; $$;

注意内层的BEGIN ... EXCEPTION会形成一个子事务,在某些实现里开销不小。批量场景下建议改成集合操作,实在要逐行处理,把异常处理挪到外层。

模板三:返回结果集的过程。用RETURNS TABLE比SETOF record更稳:

CREATE OR REPLACE FUNCTION fn_order_stat(p_date DATE) RETURNS TABLE (status TEXT, cnt BIGINT, total NUMERIC) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT o.status, COUNT(*), SUM(o.amount) FROM t_order o WHERE o.created_at::date = p_date GROUP BY o.status; END; $$;

::date这种转换在两边的行为一致性要验证,某些实现里对timestamp转date的截断规则和时区处理不同。

5.3 异常处理与事务控制的注意点

PL/pgSQL 的EXCEPTION块在两边语义基本一致,但有几个地方要留心。

第一,SQLSTATE码的覆盖范围。PG 的错误码定义比较完整,目标库可能只实现了常用的一部分。如果你在过程里靠WHEN unique_violation判断冲突,这个能覆盖到;但如果是靠具体数字码判断,就要逐个核对。

第二,EXCEPTION块内部回滚的范围。PG 里子事务回滚会撤销块内的所有操作,包括序列的nextval(序列不回滚,这是常识)。目标库行为要实测。

第三,过程内不能直接COMMIT或ROLLBACK(除非支持自治事务)。原来在过程里做分段提交的逻辑,必须挪到应用层用多个事务调用来实现。这个改造会影响失败重试的语义,要在设计阶段想清楚。

6. 数据迁移与割接的完整实操流程

6.1 全量 + 增量的搬数策略

数据量在几十 GB 以内、停机窗口一小时以上的,直接逻辑导出加导入最省事。用pg_dump导出结构,再按表导出数据:

pg_dump -h source -U app -d proddb --schema-only -f schema_pg.sql pg_dump -h source -U app -d proddb --data-only --table=t_order \ -f t_order_data.sql

结构文件不能直接灌进目标库,要先过一遍改写脚本。数据文件如果只是COPY ... FROM stdin格式,通常可以直接用,但要注意转义字符和 NULL 标记的处理差异。

数据量大或者窗口短的,必须走增量同步。思路是:先做一次全量快照,记录快照位点;然后持续订阅增量变更,追平后短暂停写,做最后一次增量同步和校验,再切换。这里的关键是增量变更的捕获方式——逻辑复制、触发器记录变更表、或者解析 WAL。不同方式的侵入性和可靠性差别很大,触发器方式会影响源库写入性能,逻辑复制方式对源库版本有要求。

我的经验是:变更频率高的表走增量,几乎不改的历史表走全量,日志类表可以只搬最近几个月。整体搬迁前先按表分级,能省掉大量时间。

6.2 一致性校验怎么做才靠谱

行数校验只是第一道关,必须做二级校验。我一般用三层:

层级方法成本能发现的问题
第一层COUNT(*)比对低漏表、漏行
第二层按主键范围分组做SUM/MD5聚合比对中字段值错误、类型截断
第三层抽样逐行 diff高编码、排序、精度问题

第二层的实现方式:按主键区间切分,比如每 10 万条算一次聚合值,两边对比。

SELECT (order_id / 100000) AS bucket, COUNT(*) AS cnt, SUM(amount) AS sum_amount, SUM(LENGTH(COALESCE(remark, ''))) AS remark_len FROM t_order GROUP BY bucket ORDER BY bucket;

把这份结果导出成 CSV,两边做 diff。对不上的桶,再进去做逐行比对。这个办法比全表MD5实用得多,因为MD5对全表拼接一次内存扛不住,而分桶后可以并行。

字符串字段的比对要特别注意:编码不同会导致看起来一样的字符算出不同的哈希。校验前先统一字符集,或者把字符串转成bytea再算。

6.3 割接窗口与回滚预案

割接我按时间轴排,每一步都有明确的完成标志和失败动作:

  1. T-7 天:完成全量迁移和首轮校验,差异清零。
  2. T-1 天:停写演练一次,测出真实的最后一次增量同步耗时和校验耗时。
  3. T0:停止应用写入,等待在途事务完成(通常 30 秒到 2 分钟)。
  4. T0+2min:做最后一次增量同步,直到无新变更。
  5. T0+5min:跑第二层校验,差异为 0 则继续。
  6. T0+8min:切换应用连接串,放开灰度流量。
  7. T0+15min:观察核心指标(错误率、慢查询数、连接数)。
  8. 失败动作:任一环节超时或校验不过,立即回切源库,源库保留可写状态至少 24 小时。

这里有两个容易忽略的点。一是源库在割接后不要立刻停掉,至少保持只读或者可写一到两天,给回滚留余地。二是应用连接串的切换要能秒级生效,配置中心或者 DNS 都行,别让运维手工改配置文件重启服务。我见过一次割接,因为配置没推送下去,一半实例连新库一半连旧库,数据瞬间双写,后果很难收拾。

7. 常见问题速查表与几条压箱底的经验

7.1 问题速查表

现象可能原因排查动作
语句报语法错误,位置在ON CONFLICTUPSERT 语法不支持改MERGE INTO或ON DUPLICATE KEY UPDATE
查询结果少了记录空串被当成 NULL检查= ''判断,改成IS NULL OR = ''
明明有索引却走全表扫描隐式类型转换、统计信息缺失EXPLAIN对比,显式CAST,收集统计信息
插入后拿不到自增 IDRETURNING不支持改nextval预取或唯一键回查
中文显示为问号字符集不是 UTF8建库时指定,导入前转码
存储过程编译不过默认参数、SETOF返回拆重载、改RETURNS TABLE
分区表建不上分区语法或分区类型不支持核对版本能力,改范围分区
批量导入特别慢索引和约束在导入时维护先删索引,导入后重建
迁移后慢查询暴涨优化器差异、统计信息未收集收集统计信息,必要时加 hint
权限不足无法建表权限模型(三权分立)开启用初始用户或调整角色配置

7.2 我自己踩过的几个坑

第一个坑:统计信息没收集就压测。数据刚灌进去,优化器手里没有统计信息,执行计划全是猜的。这时候压测出来的性能数据毫无意义,甚至可能让你误判某个索引没用。正确顺序是:灌数据、ANALYZE、再压测。这个大坑我在第一个项目里踩得很结实。

第二个坑:ORDER BY结果不一致。两边排序规则或者默认排序稳定性不同,同一条带ORDER BY created_at的查询,在时间戳相同时返回的顺序可能不同,导致分页丢数据或者重复数据。解决办法是排序键后面追加主键,保证唯一性:ORDER BY created_at DESC, order_id DESC。这是个小改动,但能省掉很多“偶发丢数据”的排查。

第三个坑:时区。源库用timestamp without time zone存本地时间,应用层自己做时区转换;目标库所在机器时区设置不同,now()返回的值就不一样。迁移前必须确认:列类型是带时区还是不带时区、服务器的timezone参数、应用连接串里的时区设置。三者要统一。

第四个坑:扩展缺失。原系统用某个扩展做模糊匹配或者相似度计算,目标库没有对应扩展。这类问题没有优雅的替代方案,要么把计算挪到应用层,要么用LIKE加前缀索引凑合。评估阶段一定要把扩展清单单独列出来,逐个确认。

第五个坑:迁移工具“自作聪明”。有的工具在转换类型时会自动把text改成varchar(255),理由是“更规范”。结果超长内容被截断,而且不报错。凡是自动转换的产物,都要人工过一遍类型定义的 diff。

最后分享一个我觉得最值钱的做法:建一个兼容性测试用例库。把你踩过的每个坑写成一条最小可复现的 SQL,加上期望结果。迁移前跑一遍,割接后回归一遍,后面再有新项目直接复用。这个库前三次维护成本高,之后就变成资产了。我现在的用例库大概两百多条,覆盖了类型转换、聚合、窗口函数、正则、JSON、过程语法几大类,新项目评估基本半天就能出结论。

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

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

立即咨询