最近半年我连续做了两个从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.Driver | org.postgresql.Driver | 驱动包、类名、URL全部要换 |
| JDBC URL | jdbc:mysql://host:3306/db | jdbc:postgresql://host:5432/db | 端口默认值不同,连接参数含义也不同 |
| 连接池 | HikariCP通用 | HikariCP通用 | 需要调connectionInitSql等参数 |
| Python生态 | pymysql / mysqlclient | psycopg2 / psycopg | SQLAlchemy的dialect名称不同 |
| 账号体系 | CREATE USER + GRANT | CREATE ROLE + GRANT | PG的角色即用户,权限层级更细 |
账号权限这块很容易踩坑。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 / BIGINT | INTEGER / BIGINT | 一般直接对应 |
| VARCHAR(n) | VARCHAR(n) | PG按字符数,但要注意与排序规则配合 |
| CHAR(n) | CHAR(n) | PG的CHAR会补空格,建议业务上尽量避免 |
| TINYINT(1) | BOOLEAN | 应用层取值方式要改 |
| DATETIME | TIMESTAMP WITHOUT TIME ZONE | 注意时区语义差异 |
| TIMESTAMP | TIMESTAMP WITH TIME ZONE | MySQL的TIMESTAMP有时区转换,PG要明确用哪种 |
| ENUM | 自定义类型或TEXT + CHECK | 直接导入可能会变成自定义类型,后续维护麻烦 |
| TEXT | TEXT | 基本一致 |
| BLOB | BYTEA | 驱动读写方式不同 |
| JSON | JSONB | PG的JSONB功能更强 |
| GEOMETRY | PostGIS需要额外安装 | 不是内置能力,必须提前装扩展 |
字符串这块有个常见的认知盲区: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没法用的场景我遇到过几次:比如目标机器上装不了额外的软件,或者公司网络环境限制对外下载。这时候手工路线就是保底方案,流程大致是:
mysqldump --skip-comments --skip-add-drop-table --no-data导出纯结构。- 用脚本把建表语句里MySQL特有的语法替换成PG风格。
- 在PG里执行清洗后的DDL,先建好所有表。
- 用
mysqldump --no-create-info --complete-insert --skip-extended-insert --hex-blob导出纯数据。 - 把INSERT语句里的反引号去掉,把字符串里的
\转义规则改掉,再灌进PG。
--skip-extended-insert是我强烈建议加上的一项,它让每条INSERT只包含一行数据,方便出错时定位具体行。代价是文件巨大,导入慢。如果数据量大,可以拆成多份并行导入,或者导入前临时把目标表上的索引全部删掉,导完再重建。
手工路线的最大问题是体力活多,尤其是类型映射和转义规则。PG的字符串里反斜杠默认不转义(标准SQL行为),而MySQL默认把反斜杠当转义符。导数据时最怕出现含特殊字符的文本字段,一个处理不当就截断或报错。所以这个方案我只建议在数据量不大、表结构简单的前提下使用。
3.3 双写加实时同步:停机窗口很短时的硬核方案
有些系统允许的停机时间不是几小时,而是几分钟。这时候全量导入再用追加数据的方式做增量,就非常吃力。我第二次做迁移用的就是这个思路:
- 先在PG建好完整schema,评估好新旧两边的类型映射。
- 用同步工具把MySQL的binlog事件流实时转发到PG,让两边数据持续保持一致。可以基于开源CDC组件构建,也可以自己解析binlog事件。关键在于要处理MySQL和PG在数据类型上的差异,比如把MySQL的
INSERT事件翻译成PG可接受的SQL参数。 - 应用层改造为双写模式:同一个SQL同时发往MySQL和PG,但PG失败不影响主流程,只记日志。
- 观察一段时间,确认PG侧数据一致且应用日志没有异常,再把读流量逐步切到PG。
- 最后把写流量整体切到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 IGNORE | INSERT ... ON CONFLICT DO NOTHING | 效果类似,但要先有唯一约束 |
| LIMIT offset, count | LIMIT 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章的审计清单做扎实,后面会顺很多。