ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL迁移PostgreSQL实战指南:从评估到上线全流程解析

MySQL迁移PostgreSQL实战指南:从评估到上线全流程解析

最近半年我连续做了两个从MySQL迁移到PostgreSQL的项目。一个是偏静态的报表库全量迁移,一个是核心交易系统的部分拆表同步。两个项目做完,最大的感受是:迁移本身不难,难的是提前把方言差异和业务SQL的老毛病全部筛出来。这篇指南就按我实际操作顺序来写,从评估值不值、盘点存量、选择搬运工具,到Schema差异、SQL改造、上线验证和调优,每一步都给出可以直接照抄的做法和参数。

先声明一下适用范围:文章针对的是常规OLTP业务库,数据仓库级的迁移路线略有不同,但底下的检查清单也能覆盖七八成。适合谁看?正在评估要不要迁的架构师,以及已经接到任务、需要快速落地的开发或DBA。MySQL和PostgreSQL我都不站队,但后面的内容会真实反映我踩过的坑,以及后来沉淀下来的稳定方案。

1. 先说结论:MySQL迁移PostgreSQL到底值不值

很多人做技术选型容易陷入一个误区:因为PostgreSQL功能更强,所以就应该迁移。但实际上,迁移是有成本的,而且这个成本往往被低估。我给两个项目做评估时,是把风险和收益分开列出来的,这里直接分享这套判断框架。

1.1 什么情况下迁移能带来明确收益

我整理了一个简单的加分项清单,命中越多,迁移越值得:

  • 现有SQL里大量使用复杂分析函数、多层子查询,PostgreSQL的CTE和窗口函数支持明显更顺手,优化器在处理复杂关联时也更稳定。
  • 需要把非结构化数据(比如JSON)和业务结构化数据放在同一个库里,PostgreSQL的JSONB支持相当成熟,甚至能做GIN索引。
  • 对数据完整性要求高,需要复杂CHECK约束、排他约束、或者跨库的断言逻辑,PG这方面的完整度比MySQL高。
  • 索引模型需要更丰富的手段,比如部分索引、表达式索引、BRIN这类面向大表的索引,MySQL在这块选择面窄很多。
  • 想要开源许可更自由、社区治理更开放,避免商业授权的不确定性。

我做的第一个项目就是这么回事:原业务在MySQL里跑了一堆复杂报表,每个季度都在超时边缘,迁到PG后最深的那个嵌套查询从秒级降到了几十毫秒级别。这种提升不是靠调MySQL参数能追回来的。

1.2 什么情况下千万别冲动迁移

反过来也有几类明确的反向信号:

  • 业务正在大版本迭代,或者代码里MySQL专属写法特别多(比如大量REPLACE INTO、IGNORE、GROUP_CONCAT嵌套),迁移会变成一场SQL重构马拉松。
  • 系统可用性要求极高,而团队里没人熟悉PostgreSQL的运维模型。PG的连接进程模型、vacuum机制跟MySQL完全是两套玩法,上线后出问题没人能接手,比不迁移的风险大得多。
  • 现有MySQL规模很小,查询根本没什么压力,迁移收益几乎为零。这种场景纯粹属于折腾。

我当时给某交易系统的核心库做评估时,就是因为双写风险太大,改成了先把分析查询切到PG,OLTP主库暂时不动。这个折中方案既拿到了PG的查询能力,又没有把核心交易链路推到重来的境地。

2. 迁移前的保留项目:方言审计与兼容性盘点

很多人拿到迁移任务就急着用工具导数据,这是最容易翻车的做法。数据导过去只需要几小时,但应用起来报错、报表跑不通、字段查出来对不上,这些事可能折磨你几周。所以我习惯把迁移前的工作拆成三件事:摸清驱动和连接方式、审计SQL方言、盘点数据类型的边界情况。

2.1 驱动、连接池和账号权限的差异清单

这一层不解决,应用连上数据库之后寸步难行。我先列一个最常被忽视的清单:

项目MySQL习惯PostgreSQL习惯迁移时要注意的坑
JDBC驱动com.mysql.cj.jdbc.Driverorg.postgresql.Driver驱动包、类名、URL全部要换
JDBC URLjdbc:mysql://host:3306/dbjdbc:postgresql://host:5432/db端口默认值不同,连接参数含义也不同
连接池HikariCP通用HikariCP通用需要调connectionInitSql等参数
Python生态pymysql / mysqlclientpsycopg2 / psycopgSQLAlchemy的dialect名称不同
账号体系CREATE USER + GRANTCREATE ROLE + GRANTPG的角色即用户,权限层级更细

账号权限这块很容易踩坑。MySQL建账号一般是一句话搞定:GRANT ALL ON db.* TO 'user'@'host'。PG不一样,你要先CREATE ROLE,再GRANT CONNECT ON DATABASE,还要给SCHEMA的USAGE权限,再给表的SELECT/INSERT权限。很多应用连上PG后报permission denied for schema,就是因为只建了角色没有给schema权限。我后来养成的习惯是写一个权限初始化脚本,统一给应用账号分配某个schema下的全部表权限,避免漏配。

2.2 方言审计:找出所有依赖MySQL的写法

这一步的核心是让"看不见的坑"提前现形。我的做法分两条线:

一条是静态扫描。把应用代码里所有SQL文件拉出来,用脚本搜MySQL独有关键字,包括但不限于:

  • IFNULL、GROUP_CONCAT、DATE_FORMAT、REPLACE INTO、ON DUPLICATE KEY UPDATE、LIMIT offset, count
  • 反引号包裹的表名和列名
  • ENGINE=InnoDB这类建表语句特有的语法
  • TINYINT(1)当布尔用的旧写法

另一条是动态抓取。正式迁移前,我在MySQL上开通用日志或慢日志,把业务高峰期实际执行过的SQL全部收集起来,跑一个静态规则检测工具。这个动作特别重要,因为很多老系统里有大量历史遗留SQL,平时藏在存储过程、定时任务和报表引擎里,光靠翻代码根本翻不完。

动态抓取帮我抓出过一个很典型的例子:报表模块里有个LIMIT 100000, 20这种深度分页写法,MySQL和PG的LIMIT语义虽然整体一致,但配合ORDER BY的时候排序稳定性不同,迁移后翻页偶尔会出现重复数据。这种问题只有靠真实SQL才能发现。

2.3 数据类型边界盘点:特别是字符串和布尔

数据类型映射是迁移里最容易产生垃圾数据的环节。先给一个我用过的标准映射表,注意不是所有MySQL类型都能无脑一一对应:

MySQL类型PostgreSQL类型注意事项
INT / BIGINTINTEGER / BIGINT一般直接对应
VARCHAR(n)VARCHAR(n)PG按字符数,但要注意与排序规则配合
CHAR(n)CHAR(n)PG的CHAR会补空格,建议业务上尽量避免
TINYINT(1)BOOLEAN应用层取值方式要改
DATETIMETIMESTAMP WITHOUT TIME ZONE注意时区语义差异
TIMESTAMPTIMESTAMP WITH TIME ZONEMySQL的TIMESTAMP有时区转换,PG要明确用哪种
ENUM自定义类型或TEXT + CHECK直接导入可能会变成自定义类型,后续维护麻烦
TEXTTEXT基本一致
BLOBBYTEA驱动读写方式不同
JSONJSONBPG的JSONB功能更强
GEOMETRYPostGIS需要额外安装不是内置能力,必须提前装扩展

字符串这块有个常见的认知盲区:MySQL的utf8mb4和PG的UTF8虽然都能存中文,但排序规则(collation)的机制完全不同。MySQL按配置的排序规则比较,PG默认依赖系统locale,两者的排序结果可能不一样,尤其是在中文环境下。如果业务里有依赖特定排序的列表,迁移前要提前确认PG的lc_collate设置,否则list页面的顺序会变得很奇怪。

布尔类型是另一个高频坑点:MySQL的TINYINT(1)在驱动层通常表现得像数字0和1,但PG的BOOLEAN在JDBC、psycopg里读出来的是true/false。如果你的代码有if (row.getInt("is_active") == 1)这种写法,迁移后要么报类型转换错误,要么永远判断不相等。这种坑我在SQL改造时见过太多次了。

3. 三套数据迁移路线实测对比:pgloader、mysqldump改造与双写同步

数据怎么搬过去,是很多人最关心的部分。市面上方案很多,但真正到了生产环境,我觉得只有三条路值得认真考虑:pgloader、mysqldump加脚本手工改造、双写加实时同步。我把三者的实测感受放在一起对比。

3.1 pgloader:大部分场景的首选方案

pgloader是我默认推荐的开源工具,它做了一步非常关键的事:自动映射数据列,把MySQL的表结构读取后转换成PG的DDL,同时搬数据。不需要自己先去PG里建表,它会自动生成并执行CREATE TABLE,虽然你最好还是提前检查生成的DDL是否符合预期。

最简单的用法是直接写命令行参数:

pgloader mysql://app_user:pass@127.0.0.1:3306/old_db postgresql://app_user:pass@127.0.0.1:5432/new_db

但如果表数量多、数据量大,我建议用命令行文件方式,方便重复执行和调整参数。下面是一个实际在生产用过的配置:

LOAD DATABASE FROM mysql://app_user:pass@127.0.0.1:3306/old_db INTO postgresql://app_user:pass@127.0.0.1:5432/new_db WITH create tables, create indexes, reset sequences, on error stop SET PostgreSQL PARAMETERS maintenance_work_mem = '512MB', work_mem = '64MB' ;

关键在于这几个参数:create indexes让索引在数据导入后再建,速度比边导边建快得多;reset sequences确保导入后自增主键不会撞上重复值;maintenance_work_mem调大一些能显著加快索引创建。pgloader默认会为每个表开启多个并发加载器,小表基本是秒导。

pgloader不是没有缺点。它生成的DDL默认会把MySQL的TINYINT(1)转成SMALLINT而不是BOOLEAN,把DATETIME转成TIMESTAMP但未必带上时区语义,也就是说类型映射还是要人工过一遍。另外,遇到没有主键的表,pgloader会直接拒绝导入或者生成无主键的表,这是它比较严格的地方。我的习惯是:先小规模试跑一个schema,查看生成的报告和日志,确认映射没有大问题后,再放全量。

3.2 手工路线:mysqldump加脚本改造

pgloader没法用的场景我遇到过几次:比如目标机器上装不了额外的软件,或者公司网络环境限制对外下载。这时候手工路线就是保底方案,流程大致是:

  1. mysqldump --skip-comments --skip-add-drop-table --no-data导出纯结构。
  2. 用脚本把建表语句里MySQL特有的语法替换成PG风格。
  3. 在PG里执行清洗后的DDL,先建好所有表。
  4. 用mysqldump --no-create-info --complete-insert --skip-extended-insert --hex-blob导出纯数据。
  5. 把INSERT语句里的反引号去掉,把字符串里的\转义规则改掉,再灌进PG。

--skip-extended-insert是我强烈建议加上的一项,它让每条INSERT只包含一行数据,方便出错时定位具体行。代价是文件巨大,导入慢。如果数据量大,可以拆成多份并行导入,或者导入前临时把目标表上的索引全部删掉,导完再重建。

手工路线的最大问题是体力活多,尤其是类型映射和转义规则。PG的字符串里反斜杠默认不转义(标准SQL行为),而MySQL默认把反斜杠当转义符。导数据时最怕出现含特殊字符的文本字段,一个处理不当就截断或报错。所以这个方案我只建议在数据量不大、表结构简单的前提下使用。

3.3 双写加实时同步:停机窗口很短时的硬核方案

有些系统允许的停机时间不是几小时,而是几分钟。这时候全量导入再用追加数据的方式做增量,就非常吃力。我第二次做迁移用的就是这个思路:

  1. 先在PG建好完整schema,评估好新旧两边的类型映射。
  2. 用同步工具把MySQL的binlog事件流实时转发到PG,让两边数据持续保持一致。可以基于开源CDC组件构建,也可以自己解析binlog事件。关键在于要处理MySQL和PG在数据类型上的差异,比如把MySQL的INSERT事件翻译成PG可接受的SQL参数。
  3. 应用层改造为双写模式:同一个SQL同时发往MySQL和PG,但PG失败不影响主流程,只记日志。
  4. 观察一段时间,确认PG侧数据一致且应用日志没有异常,再把读流量逐步切到PG。
  5. 最后把写流量整体切到PG,MySQL降级为只读保留,用于快速回退。

双写方案的风险在于两个库的事务语义不一样。MySQL默认的事务隔离级别是REPEATABLE READ,而PG默认是READ COMMITTED,如果应用依赖了可重复读特性,切换后可能看到不同的数据快照。我在这个项目里是把PG的事务隔离级别显式调成了REPEATABLE READ,才真正做到行为一致。

下面这个表是我做技术选型时整理的对比,后来写进项目文档里了:

方案实施难度停机要求数据一致性保障适用场景
pgloader低小时级迁移时有停写大部分OLTP迁移
mysqldump + 脚本中高小时级全量后需手工校验受限环境、小数据量
双写 + 实时同步高分钟级高,但需自建监控核心系统、停机敏感

从我个人经验看,80%的迁移用pgloader就能解决,没必要一上来就上双写。双写的成本不只是开发,还有后续的binlog理解成本和运维负担,除非业务真的无法接受长时间停写,否则我一般劝退。

4. Schema迁移的核心差异:建表、约束、序列与索引

数据倒腾过去只是第一步,真正的细节在DDL和约束层面。同一个业务表,在MySQL和PG里建出来,语义可能差一截。这一章我把最容易出问题的几个点单独拉出来展开。

4.1 建表语法和自增主键:从AUTO_INCREMENT到IDENTITY

MySQL时代最典型的写法是:

CREATE TABLE `orders` ( `id` INT NOT NULL AUTO_INCREMENT, `amount` DECIMAL(10,2) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

PG对应的写法是:

CREATE TABLE orders ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, amount NUMERIC(10,2) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

先把表名和列名上的反引号去掉,再把AUTO_INCREMENT换成GENERATED BY DEFAULT AS IDENTITY。这里我特别强调用BY DEFAULT而不是BY ALWAYS:BY ALWAYS拒绝应用显式指定主键值,如果有历史数据要按原ID插入,或者业务有指定ID的场景,会直接报错。BY DEFAULT则允许两者共存,应用给值时用给定值,不给时走自增,迁移期最省心。

另外,serial是一种更老的写法,ID BIGSERIAL PRIMARY KEY也能用,但它本质是INT配一个独立序列,和标准IDENTITY相比,在权限管理、TRUNCATE重置行为上有细微差别。新项目我推荐直接用IDENTITY。

4.2 约束和默认值:PG更严格,也更接近标准

MySQL的DATETIME DEFAULT CURRENT_TIMESTAMP可以配合ON UPDATE CURRENT_TIMESTAMP,让行更新时自动改时间戳。这个语法非常常见,我在迁移时发现PG没有直接等价的关键字,DEFAULT CURRENT_TIMESTAMP只保证插入时生效,行更新不会自动改。要模拟这个行为,需要写一个触发器:

CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_orders_updated_at BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION set_updated_at();

触发器逻辑不复杂,但需要为每一张要自动更新时间戳的表都建一个,批量处理时容易遗漏。我的建议是写个脚本从information_schema查出所有包含updated_at列的表,自动生成触发器语句,避免漏表。

PG的约束体系比MySQL完整,比如CHECK约束可以直接引用多列,甚至可以用函数作为条件;EXCLUDE约束可以做时间段重叠校验,这在MySQL里没有等价物。迁移时如果原MySQL表里有CHECK约束,PG会忠实地保留并强制执行,这一点比MySQL更好,但也意味着如果有历史脏数据,迁移后写入可能开始报错。上线前记得跑一遍全表数据完整性校验。

4.3 索引定义方式完全不同

MySQL习惯在建表语句里带索引定义,比如KEY idx_status (status)。PG的索引是用独立的CREATE INDEX语句创建的,所以pgloader导入后,索引是单独建出来的。这不只是写法问题,而是优化器行为、维护方式都不一样。

有几个MySQL老索引在PG里需要重新思考:

  • MySQL的前缀索引,比如KEY idx_name (name(10)),PG没有直接等价物,但可以用表达式索引模拟:CREATE INDEX idx_name_prefix ON users (left(name, 10));效果接近,但要确认查询写法能匹配上。
  • 如果原表有大量联合索引,PG里要考虑索引列的顺序差别,因为PG的索引扫描在部分场景下无法像MySQL那样灵活利用索引前面的列。
  • PG的BRIN索引非常适合时间序列数据,如果有一张巨大的流水表,按时间范围查询,可以试试CREATE INDEX idx_orders_created_at ON orders USING BRIN(created_at);,体积小很多,扫描大范围数据时效果明显。

还需要注意:创建索引的并发问题。MySQL在早期版本里ADD INDEX会锁表,PG的CREATE INDEX默认不阻塞DML,但大表上长时间建索引会拉高I/O。迁移建索引用CONCURRENTLY更稳妥:

CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders (created_at);

用CONCURRENTLY的时候不能放在事务块里,而且建索引过程中如果遇到错误,会留下无效索引,需要手动清理。生产环境建索引我习惯加这个关键字,虽然慢一点,但不怕阻塞业务。

5. SQL方言适配:从函数替换到ORM改造

数据搬完,约束建好,下一步就是让应用层真正跑起来。这一层的适配琐碎且量很大,如果不系统化处理,会陷入改一个SQL发现另一个报错的循环里。

5.1 高频函数替换对照

我整理了一张高频函数替换表,基本覆盖了我在实际项目里遇到过的90%情况:

MySQL写法PostgreSQL写法说明
IFNULL(a, b)COALESCE(a, b)语义几乎一样
IF(cond, a, b)CASE WHEN cond THEN a ELSE b END没有直接等价函数
GROUP_CONCAT(col)STRING_AGG(col, ',')要指定分隔符,默认不带
DATE_FORMAT(d, '%Y-%m-%d')TO_CHAR(d, 'YYYY-MM-DD')格式模板不同
NOW()NOW() / CURRENT_TIMESTAMP都能用
CURDATE()CURRENT_DATE直接对应
REPLACE INTO t VALUES (...)INSERT ... ON CONFLICT (pk) DO UPDATE ...REPLACE INTO语义是删旧插新,PG用ON CONFLICT显式表达
INSERT IGNOREINSERT ... ON CONFLICT DO NOTHING效果类似,但要先有唯一约束
LIMIT offset, countLIMIT count OFFSET offset顺序反过来了,容易写错

REPLACE INTO值得单独说一句:它不是简单的"存在就更新",而是先删除旧行再插入新行,副作用是会把没参与插入的列重置成默认值,还会触发外键和级联操作。迁移到PG没有这个语法,必须改写成ON CONFLICT,而且这个改写会影响业务逻辑,不能机械替换,要逐条确认语义。

5.2 分组、排序和布尔表达式的行为差异

GROUP BY是另一个容易出问题的地方。MySQL在非严格模式下允许SELECT出不在GROUP BY里的列,结果随机取一行;PG严格要求GROUP BY后面的列必须和查询列一致,否则直接报错。迁移前我的建议是把MySQL的sql_mode临时调成ONLY_FULL_GROUP_BY,让应用在迁移前就把所有不规范的GROUP BY暴露出来。这个动作能省去上线后一大半的SQL报错。

布尔表达式同样要小心。旧代码里如果写WHERE is_active = 1,PG会报错,因为BOOLEAN不能直接和整数比较。要改成:

WHERE is_active = TRUE

或者更简洁的:

WHERE is_active

反过来,如果应用代码里用占位符传0或1,要统一改成传true或false,或者把列类型定成SMALLINT并从应用层做映射。这个必须在前期类型盘点时定下来,不然会出现一大堆"列类型不匹配"的错误。

5.3 ORM和框架层的改造要点

现在业务系统很少会裸写JDBC,基本都是走ORM。这块的改造虽然不用逐条改SQL,但有个从底层就开始的迁移动作。

MyBatis体系下,配置里要改driver和jdbcUrl,同时建议启用databaseIdProvider,这样可以在Mapper里区分MySQL和PG的方言SQL。早期版本如果不配置databaseId,出现兼容性差异时会很痛苦。Hibernate的做法则是切换dialect为PostgreSQL方言,JPA会把分页、序列、布尔类型自动转成PG风格,省很多事。

Python技术栈用SQLAlchemy,连接串从:

mysql+pymysql://user:pass@host/db

改成:

postgresql+psycopg2://user:pass@host/db

然后逐个核对TypeDecorator。最典型的是BOOLEAN类型,ORM模型里的Boolean在MySQL下映射到TINYINT(1),切到PG后是真正的BOOLEAN,如果模型定义用的是SmallInteger,也要改。字段校验和序列化逻辑都可能跟着变。

ORM改造最大的坑不在代码本身,而在测试覆盖不全。很多团队只做了主流程的冒烟测试,结果漏掉了一些只有特定入口才会触发的SQL,上线后才会炸。我现在的习惯是:切库前把接口层的自动化用例完整跑一遍,同时拿一版生产环境的脱敏数据在PG上做并发回归。

6. 割接当天:数据校验、回滚预案与上线后的首要调优

迁移项目的成败,除了功能跑通,还看能不能保证数据一致。这一章是我每次割接前必须检查的内容,也是容易被临时赶工漏掉的环节。

6.1 数据校验脚本:不能只对数

先对表数量:在源库和目标库分别执行information_schema.tables统计,核对表名清单。这一步通常能发现pgloader漏导的表。再对行数:每张表分别SELECT count(*),但大表全表count在MySQL里并不慢,在PG里因为MVCC机制需要扫描全表,反而可能很慢。所以大表我建议用抽样校验,取主键最大值、最小值和几组关键列的和值做对比。

我实际用的校验SQL大概是这样的:

-- 源库 MySQL 侧 SELECT COUNT(*) AS row_count, COALESCE(SUM(amount), 0) AS amount_sum, COALESCE(MIN(id), 0) AS min_id, COALESCE(MAX(id), 0) AS max_id FROM orders; -- 目标库 PG 侧 SELECT COUNT(*) AS row_count, COALESCE(SUM(amount), 0) AS amount_sum, COALESCE(MIN(id), 0) AS min_id, COALESCE(MAX(id), 0) AS max_id FROM orders;

两边结果完全一致,才算通过第一关。之后再随机抽几条样本行,全字段比较一次值是否一致,防止某列因类型映射产生隐式转换差错。

自增序列状态也要检查。pgloader的reset sequences参数会自动重置,但如果你是手工导入的,必须手动执行:

SELECT setval(pg_get_serial_sequence('orders', 'id'), (SELECT MAX(id) FROM orders));

不做这一步,后续插入的主键会从1开始,立刻撞主键。

6.2 回滚预案:关键不是怎么写,而是什么时候触发

回滚方案不能等到割接失败才想。我习惯把回滚条件提前写清楚,比如:

  • 核心交易接口在PG上错误率超过0.5%,持续5分钟
  • 数据校验差值超过某个阈值
  • 应用日志出现批量未处理异常

只要满足任何一个条件,就立即把流量切回MySQL。因为MySQL在迁移期间一直保留为只读或被双写写入,数据并没有停机,回滚不用回补数据。这也是我为什么一直建议迁移后的MySQL实例至少保留两周只读状态的原因。很多人割接成功后立刻把老库删了,一旦发现问题就只能从备份恢复,代价天差地别。

6.3 上线后第一件事:调PG参数和抓慢查询

很多MySQL DBA直接拿默认配置跑PG,然后抱怨性能差。其实PG默认配置是针对低配机器的,生产环境必须调。我上线后第一组要改的参数是:

shared_buffers = 4GB effective_cache_size = 12GB work_mem = 16MB maintenance_work_mem = 512MB max_connections = 200 wal_level = replica

参数值要根据机器内存和连接数来定,不能照抄。shared_buffers通常设为内存的25%,effective_cache_size是操作系统缓存加shared_buffers的估算值,会影响优化器的索引选择倾向;work_mem不能开太大,否则大量并发排序直接吃光内存,我见过有人把work_mem调到1GB然后数据库OOM的。

自动清理(autovacuum)千万不要关。PG的MVCC机制依赖vacuum回收死元组,关闭后会无限膨胀,性能直线下降。迁移初期数据量变动大,更需要保证autovacuum正常触发。

慢查询分析方面,我建议把pg_stat_statements扩展打开:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

然后在配置里加上:

shared_preload_libraries = 'pg_stat_statements'

改这个要重启数据库,所以最好在割接前就配好。之后通过pg_stat_statements查平均耗时最高的SQL,按照调用次数加权排序,逐个排查。迁移后出现性能问题的SQL,一部分是因为统计信息没更新,跑一次ANALYZE就能好很多;另一部分是真的SQL写法不适配PG优化器,需要改写。

7. 迁移后最容易翻车的三个坑:大小写、时间戳与旧查询

最后把这些年在项目里反复遇到的三个"经典坑"单独拎出来说。它们都不是什么高深问题,但几乎每个迁移项目都会碰到。

7.1 大小写敏感:一个引号引发的空表问题

MySQL在Linux默认的表名大小写敏感,但列名不敏感;而PG把所有未加引号的标识符折叠成小写。这就产生了一个很尴尬的场景:原表叫OrderInfo,用pgloader导到PG后,如果表名没加引号,建出来的表实际叫orderinfo,而应用层SQL查询写的是"OrderInfo",PG会按照带引号的精确大小写去找,结果找不到表。

更麻烦的是,应用代码里的表名全是驼峰,迁移过来后你可能会看到一张名为OrderInfo(带引号创建)和一张orderinfo(不带引号创建)同时存在的诡异局面。我的建议是:迁移前在MySQL侧就把表名统一改成小写,或者在PG侧建表时统一用CREATE TABLE "order_info"这种全小写带下划线的风格,应用SQL也统一不加引号。这样最省心。

7.2 时间戳时区:看似一样,实则差八小时

MySQL的DATETIME不存时区信息,你存进去什么就取出什么。PG的TIMESTAMP WITHOUT TIME ZONE(简称timestamp)也不存时区,行为和MySQL一致;但很多迁移工具会默认把MySQL的DATETIME转成PG的TIMESTAMP WITH TIME ZONE(timestamptz)。一旦转成timestamptz,PG会把客户端会话的时区考虑进去再做转换,如果你的应用服务器时区是UTC,入库后查出来会比东八区少8小时。

这个问题特别隐蔽,因为小数据量测试时可能用例太少看不出异常,一上生产看凌晨的数据就全部对不上。我的建议是:如果原业务完全不关心时区转换,目标列类型直接统一用TIMESTAMP WITHOUT TIME ZONE;如果确实需要全球多时区,那就要在应用层和驱动层把时区语义理清楚,不能指望数据库自动转换。

7.3 历史遗留SQL:改完应用代码,别忘了定时任务和后台脚本

很多迁移项目把精力花在主业务接口上,忽略了一大批隐藏的SQL使用者:报表定时任务、消息队列消费者、深夜跑批脚本、管理后台的数据导出功能。这些代码往往不在主干上,测试时也很少被覆盖,但一到凌晨就跑出大量报错日志。

我经历过一次典型事故:迁移后的第三天凌晨,批处理任务报错,排查后发现脚本里用了一个MySQL特有的REPLACE INTO,迁移时漏改了。这类问题没有捷径,只能在迁移前把定时任务清单和后台脚本全部拉出来,按计划任务逐个过一遍SQL,而不是等项目验收时才想起来。

迁移这件事,做到最后往往发现工作量的70%都在前期的SQL梳理和类型映射上,真正执行迁移的时间反而不多。如果你手里也有这样的任务,先把本文第2章的审计清单做扎实,后面会顺很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进