从MySQL迁到PostgreSQL,或者反过来,几乎每个做数据库选型的人心里都盘算过“反正都是关系型数据库,SQL差不多,平滑迁移应该不难吧”。这句话我听了无数次,也看过太多团队因为这句“差不多”栽在线上故障里。PostgreSQL和MySQL的兼容性标准,不是一个能用“是”或“否”回答的问题,而是一份长长的、藏在语法和底层行为里的差异清单。
这份清单,我从“纯MySQL栈”迁到“PG主存储”的实战中踩了个遍。项目里有金融级的事务场景,也有复杂的报表查询,踩坑记录写了几十页。这次就结合DeepSeek在数据库兼容性上的总结思路,把PostgreSQL和MySQL在语法、数据类型、索引、存储过程、事务、工具链这些维度的真实兼容边界拆开讲。读完你会发现,真正影响迁移成本的,从来不是“能不能跑起来”,而是“跑起来之后,那些平时碰不到的特殊行为会不会忽然炸给你看”。
这篇文章适合正打算做数据库迁移评估的架构师,也适合已经在迁移路上、被诡异报错折磨的开发同学。我会把能直接抄作业的检查点和判断标准都列出来。
1. 兼容性不是一个指标,是一组纬度的叠加
先把“兼容性”这个词拆干净。很多团队在选型报告里写“PostgreSQL和MySQL高度兼容”,依据仅仅是“都能跑SQL”,这个结论太粗了。我习惯把数据库兼容性分成五个层面,任何一个层面出问题,都会直接影响业务。
- SQL语法兼容:是否能用同一条SQL语句跑出相同结果。这是大家最关注的层面,也是最容易出“看起来兼容、实际不兼容”的层面。
- 数据类型兼容:同样一个字段,存进去的值范围、排序规则、比较行为是否一致。
- 行为兼容:这是最阴险的层面。同样的SQL,两边都能执行,但遇到NULL、空字符串、特殊字符、并发冲突时,结果输出和处理方式完全不同。
- 生态兼容:客户端驱动、ORM、连接池、监控工具、备份工具是否无缝切换。
- 性能形态兼容:同样的表结构和查询语句,在两边生成的执行计划、索引利用方式截然不同,导致性能表现天差地别。
把这五个层面摆出来,你就明白为什么不能简单回答“PostgreSQL兼容MySQL吗”。客观地说,PG在SQL标准遵循度上远高于MySQL,PG对标准SQL的兼容接近教科书级别,而MySQL有自己的方言和历史上遗留下来的怪异行为。所以“从MySQL迁到PG”和“从PG迁到MySQL”的兼容性难度,完全不对称。
提示:做迁移评估前,先定性你的应用属于哪种类型。如果是CRUD密集型业务,兼容性风险主要集中在语法和类型转换;如果是复杂报表和数据分析业务,风险集中在函数、窗口函数、执行计划和类型精度上;如果是高并发写入业务,风险则集中在锁机制、隔离级别和死锁处理上。
很多人在第一层“SQL语法”上做了大量测试,却忽略了行为层的差异,导致上线后出现“MySQL里正常、PG里数据错乱”的线上事故。
2. 语法兼容性的真相:核心语句一致,细节处处是坑
2.1 基础CRUD的兼容程度比想象中高
INSERT、SELECT、UPDATE、DELETE这四类基础语句,在PostgreSQL和MySQL里的兼容性确实非常高。我做过一个测试,把一个包含200多条业务SQL的文件直接在两个数据库里跑,其中基础CRUD语句的通过率接近98%,剩下的不兼容项几乎都集中在特殊的扩展语法上。
比如MySQL的INSERT INTO ... ON DUPLICATE KEY UPDATE,在PostgreSQL里没有对应语法,PG使用INSERT INTO ... ON CONFLICT (column) DO UPDATE SET ...。这不仅仅是写法上的区别,两者的语义也有微妙差异:
- MySQL的
ON DUPLICATE KEY UPDATE在没有任何唯一键冲突时走插入,在有冲突时走更新; - PG的
ON CONFLICT必须明确指定冲突的约束或列,如果不指定列名,遇到任何唯一约束冲突都会报错,这一点比MySQL更严格。
再比如REPLACE INTO,这是MySQL特有语法,PG里完全没有对应实现。REPLACE INTO的语义是先删除冲突行再插入新行,这个行为在PG里只能通过ON CONFLICT DO UPDATE或事务里的DELETE+INSERT来模拟。要注意的是,模拟出来的语义并不完全等价,因为REPLACE INTO会触发DELETE相关的触发器,而ON CONFLICT DO UPDATE触发的是UPDATE触发器,对依赖触发器的业务会有完全不同的影响。
2.2 分页和字符串拼接的坑
分页查询的LIMIT/OFFSET两边都支持,但MySQL需要在LIMIT后面加逗号语法LIMIT offset, count,PG只支持LIMIT count OFFSET offset。如果你的代码里混用了这两种写法,迁移后会在最基础的查询上报错。
字符串拼接是另一个高频踩坑点。MySQL里SELECT 'a' + 'b'返回的是数字0,因为在MySQL中+是算术运算符,字符串会被隐式转换为数字,转换失败就算0。PG里+不适用于字符串,直接报错。MySQL的字符串拼接用CONCAT('a', 'b'),PG同样支持CONCAT函数,但如果你的代码里习惯用||做拼接,两边都支持,可是遇到NULL时行为不同——这个我放到后面的行为兼容性里细说。
MySQL在SQL语法上的自由度还体现在GROUP BY上。MySQL默认允许SELECT子句中出现不在GROUP BY中的非聚合列,而且返回的值不确定。PG严格遵守SQL标准,SELECT中出现的非聚合列必须全部出现在GROUP BY中,否则直接报错。迁移时经常能把MySQL里隐藏的数据逻辑错误暴露出来,这是好事,但对赶进度的团队来说就是额外工作量。
2.3 DDL语句里那些隐蔽的不兼容
建表和改表语句的兼容度没有CRUD那么高。最典型的是自增主键:
-- MySQL CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) ); -- PostgreSQL CREATE TABLE user ( id SERIAL PRIMARY KEY, name VARCHAR(50) );PG的SERIAL本质是INTEGER加一个默认值为nextval('user_id_seq'),它和MySQL的AUTO_INCREMENT在使用层面上等价,但底层原理完全不同。MySQL的自增只能有一个,PG的序列可以被多个表共享,也可以在插入时手动覆盖。如果业务代码里有INSERT INTO user (id, name) VALUES (NULL, 'test')这种写法,MySQL能正确分配自增值,PG会报错,因为SERIAL列不允许插入NULL且没有默认值。
字符集和排序规则的定义方式也不同。MySQL在表级别可以指定DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci,PG使用SET语法定义在数据库创建阶段。这个差异在数据迁移阶段影响不大,但在编码不一致导致乱码或排序结果不同的时候,排查起来特别痛苦。PG的排序规则还直接决定索引的排序行为,一旦建表时没指定对collation,后面想改只能重建索引。
3. 数据类型兼容性:同样的字段名,不一样的行为边界
3.1 整数、小数和布尔值:精度问题能埋雷
整数类型两边都有SMALLINT、INTEGER、BIGINT,名称完全一致,范围也一致。但MySQL的INT还可以附带UNSIGNED属性,PG不支持无符号整数。如果你的表里有用到INT UNSIGNED存储IP地址或大数ID的场景,迁到PG时必须改成BIGINT,否则插入超过20亿的值就会溢出报错。
小数类型是兼容性的重灾区。MySQL的DECIMAL(p, s)是定点数,PG的NUMERIC(p, s)也是定点数,两者可以对应。问题出在MySQL的FLOAT和DOUBLE上,PG对应的是REAL和DOUBLE PRECISION,但MySQL的FLOAT默认保留4字节,PG的REAL同样保留4字节,看起来没问题,实际比较时却会因为浮点精度产生差异。更微妙的是,MySQL在FLOAT列上做等值比较时,因为存储引擎的精度处理方式,偶尔能匹配上;PG的浮点运算严格遵循IEEE标准,等值比较几乎永远不相等。迁移后如果应用层有浮点等值判断,会成批量地失败。
布尔值是最容易忽略的。MySQL的BOOLEAN本质是TINYINT(1),可以存0、1,但严格来说也可以存任何整数,且TRUE和FALSE只是1和0的别名。PG的BOOLEAN是真正的布尔类型,只接受true/false或't'/'f',不接受0和1。代码里如果直接拼SQLWHERE enabled = 1,在PG上会直接报错。这点在ORM框架下问题不大,因为框架会做类型转换,但原生SQL越多的项目,越容易被这个细节卡住。
3.2 日期时间类型:时区语义完全不同
日期时间类型是所有迁移项目里必须重点检查的类别。MySQL的DATETIME不携带时区信息,存进去是什么就返回什么;TIMESTAMP虽然底层以UTC存储,但读写时按会话时区转换。PG的TIMESTAMP(不带时区)和TIMESTAMP WITH TIME ZONE(带时区)是两种明确划分的类型,而且PG内部把所有TIMESTAMPTZ的值都统一转为UTC存储,查询时再按会话时区输出。
我在实际项目中见过一个典型事故:原先在MySQL里用DATETIME存订单创建时间,应用层在插入时传入'2024-06-01 12:00:00',这个字符串不含时区信息,数据库原样保存,应用层读取后按东八区输出。迁到PG后,表结构定义用了TIMESTAMP WITH TIME ZONE,插入时同样传入'2024-06-01 12:00:00',PG把它解析为会话时区的当地时间,存储为UTC的'2024-06-01 04:00:00'。到了读取环节,因为应用服务器时区设置不一致,有的接口显示的时间凭空多了8小时。
注意:在PG里,如果业务逻辑不关心时区,请用
TIMESTAMP WITHOUT TIME ZONE;如果关心时区,明确使用TIMESTAMPTZ。最忌讳的是把MySQL的DATETIME无脑映射成PG的TIMESTAMPTZ,这会引入隐性时区偏移。
另一个差异点是默认值和自动更新。MySQL可以在DATETIME列上定义DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP,PG的DEFAULT now()只覆盖插入场景,没有原生的“更新时自动刷新时间戳”能力,需要靠触发器实现。
3.3 JSON类型:一个追求速度,一个追求功能
MySQL从5.7开始支持JSON类型,但底层实现是文本存储,每次查询都需要解析整个JSON文档。PG的JSON类型同样有两个:JSON和JSONB。JSON是文本格式存储,行为和MySQL的JSON类似;JSONB是二进制存储,内部做了解析和去重,查询性能更好,还支持GIN索引加速。
迁移时最大的坑不在存储层,而在函数层。MySQL的JSON操作函数如JSON_EXTRACT、JSON_UNQUOTE、->和->>运算符,在PG里对应为->、->>、#>、#>>,函数名完全不同。MySQL的JSON_SEARCH、JSON_CONTAINS、JSON_KEYS在PG里没有一一对应,需要用jsonb_path_query或jsonb_exists等函数替代,逻辑上能对等,写法上完全是两套。
如果你的业务大量使用JSON做条件查询,比如WHERE JSON_EXTRACT(data, '$.age') > 18,迁到PG后建议直接改写成WHERE>CREATE INDEX idx_user_name ON user (name) INCLUDE (email);
约束行为上,MySQL和PG的UNIQUE约束都自动创建唯一索引,但唯一性判断的规则不同。MySQL在utf8mb4_unicode_ci排序规则下,默认唯一性判断忽略大小写;PG的默认排序规则对大小写敏感。如果原来MySQL里('abc')和('ABC')不能同时插入,迁到PG后却能插入,这就导致业务层假设“唯一”的字段在PG里出现重复数据。迁移时要把所有字符串列的排序规则调整为C或显式指定collation,才能保持大小写敏感的唯一性。
PG的NULL值在唯一索引里的行为也值得注意。两者都允许唯一索引中存在多个NULL值,但MySQL在旧版本和特定引擎下对NULL的唯一性判断有不同表现。好在这个差异在新版本中已经趋同,不太需要额外处理。
5. 存储过程与函数:最不能偷懒的部分
5.1 语言体系不通用
存储过程是PostgreSQL和MySQL兼容性最差的模块之一,而且两边都不遵循同一套语言标准。MySQL的存储过程基础语法接近SQL标准加一些流程控制,PG的存储过程和函数用的是PL/pgSQL,语法结构上更接近Oracle的PL/SQL。
举个最简单的例子,MySQL里这样定义一个存储过程:
DELIMITER // CREATE PROCEDURE get_user(IN uid INT) BEGIN SELECT * FROM user WHERE id = uid; END // DELIMITER ;PG里等价写法是:
CREATE OR REPLACE FUNCTION get_user(uid INT) RETURNS SETOF user AS $$ BEGIN RETURN QUERY SELECT * FROM user WHERE id = uid; END; $$ LANGUAGE plpgsql;这不仅是语法的差异。MySQL的存储过程可以有IN、OUT、INOUT参数,调用时用CALL get_user(1);PG的函数参数默认是IN,没有单独的OUT关键字,所有返回值都通过RETURNS子句表达。MySQL的存储过程中可以直接执行多条SQL并返回多个结果集,PG的函数用RETURNS SETOF只能返回一个结果集,要返回多个结果集必须用REFCURSOR,这在实际应用中非常不方便。
变量声明和异常处理的差异也很大。MySQL用DECLARE可以在任意位置声明变量,PG的PL/pgSQL要求在函数体的DECLARE段集中声明。MySQL的异常处理用DECLARE CONTINUE HANDLER FOR SQLEXCEPTION,PG用BEGIN ... EXCEPTION WHEN others THEN ... END块。如果你的项目里有上百个存储过程,这个迁移工作量会非常大,基本等于重写。
5.2 内置函数同名不同义
函数层的不兼容是隐蔽性最强的。两边都有DATE_FORMAT、SUBSTRING、IFNULL、NOW这些函数,但细节行为不一样。
NOW()两边都返回当前时间,MySQL返回DATETIME,PG返回TIMESTAMP WITH TIME ZONE,用途相同但类型不同。IFNULL(a, b)在MySQL里是标准函数,PG不支持,PG标准写法是COALESCE(a, b),但COALESCE的参数数量不受限制,且两边都支持。如果你的代码里用了大量IFNULL,迁移后必须批量替换成COALESCE,不然报错会报到你怀疑人生。
字符串截取函数也有差异。MySQL的SUBSTRING(str, pos)和SUBSTRING(str, pos, len),PG的SUBSTRING函数同样存在,但对负数的处理方式不同。MySQL里SUBSTRING('abc', -1)返回'c',PG里返回整个字符串。日期格式化上,MySQL用%Y-%m-%d %H:%i:%s这种格式占位符,PG用YYYY-MM-DD HH24:MI:SS,迁移如果没替换,跑出来的日期时间全是错的。
聚合函数方面,GROUP_CONCAT是MySQL特有的,PG的对应函数是STRING_AGG,性能接近,但参数顺序不同。GROUP_CONCAT(DISTINCT col ORDER BY col SEPARATOR ',')在PG里写成STRING_AGG(DISTINCT col, ',' ORDER BY col)。不经常写SQL的人很容易在这上面卡住。
6. 事务隔离与锁:并发场景下最容易翻车
6.1 可重复读的语义不一样
MySQL InnoDB默认的隔离级别是REPEATABLE READ,PG默认是READ COMMITTED。这个默认差异已经足够让一堆应用在迁移后出现数据不一致的困惑。但更隐蔽的是,同样叫REPEATABLE READ,两边的行为完全不同。
MySQL的REPEATABLE READ实现的是“快照读”,在一个事务里多次执行普通SELECT,看到的数据是一致的快照,不会受到其他事务已提交数据的影响。PG的REPEATABLE READ也是快照隔离,听起来一样,但PG的快照是在事务里第一条语句执行时建立的,而MySQL的快照是在第一条SELECT执行时建立的,严格来说两者的“快照起始点”定义不完全相同。
PG在REPEATABLE READ下做数据修改,如果发现另一条并发事务已经修改了同一行,会直接报“could not serialize access due to concurrent update”错误。MySQL在这类冲突下会等待锁,然后继续执行,不报错。结果是,同一个并发压测脚本,在MySQL里能全部跑完,在PG里会大量报串行化错误。这不是BUG,是设计差异,但应用层必须为PG的串行化失败增加重试机制。
6.2 锁粒度与死锁特征
MySQL InnoDB的锁是索引记录锁,PG的锁也是行级锁,实现机制相似。但PG有一个MySQL没有的特性:SELECT ... FOR UPDATE支持SKIP LOCKED和NOWAIT,这在做任务队列类的业务时非常实用。MySQL 8.0虽然也加入了NOWAIT和SKIP LOCKED支持,但成熟度和稳定性不如PG。
死锁的处理方式差异更大。MySQL检测到死锁后,会主动回滚其中一个事务,返回Deadlock found错误,应用层收到后可以捕获并重试。PG同样会检测死锁并回滚其中一个事务,但PG的死锁检测频率和触发条件与MySQL不同,在高并发场景下PG的lock_timeout默认是0,即无限等待。如果不显式设置lock_timeout和deadlock_timeout,一些在MySQL里马上报错的场景,在PG里会挂起较长时间。
7. 生态工具链的兼容性:真正决定迁移成本的因素
7.1 驱动和ORM的现实处境
PostgreSQL的JDBC驱动和MySQL的JDBC驱动在接口层面都遵循JDBC规范,应用层用java.sql.Connection写的代码基本不用改。但驱动默认参数不一样,MySQL驱动默认useSSL=false、serverTimezone=Asia/Shanghai,PG驱动默认不启用SSL但支持sslmode参数,时区处理默认读取系统时区。这些参数在连接串里如果不显式指定,迁移后会出现连接超时、时区错乱。
ORM的兼容性要分框架看。MyBatis这种直接写SQL的框架,对数据库语法差异几乎没有屏蔽能力,迁移时所有SQL都要过一遍,上面提到的函数和语法问题一个都躲不掉。Hibernate/JPA的方言机制(dialect)能屏蔽一部分语法差异,比如分页方式、自增主键生成策略,但无法屏蔽函数差异。GORM(Go)通过gorm.io/driver/postgres适配PG,AutoMigrate能自动建表,但建出来的表结构和MySQL下的不完全一致,比如TEXT/LONGTEXT映射差异。
连接池方面,HikariCP、Druid等主流连接池都同时支持两种数据库,把驱动类名、连接URL、验证SQL换掉就行。有一个小坑:MySQL的连接池验证SQL常用SELECT 1,PG同样支持,但如果用SELECT 1 FROM DUAL这种MySQL特有能力,PG会直接报错。
7.2 实战可用的迁移工具与对比工具
网上讨论最多的迁移工具是pgloader,它能从MySQL直接迁移到PG,支持表结构、数据、索引、外键的自动转换。实测下来,pgloader对基础表结构迁移的成功率很高,但遇到复杂类型(比如ENUM、SET、JSON)时需要手动调整映射规则。pgloader对存储过程、触发器、视图的迁移能力很弱,基本只搬运数据,代码对象要靠人工重写。
migra是一个用Python写的PostgreSQL数据库结构对比工具,主要做的是两个PG数据库之间的schema对比和同步。它不直接解决MySQL迁移PG的问题,但迁移完成后,可以用migra对比源库和目标库的表结构差异,检查有没有字段遗漏、索引缺失。我在一个真实项目里用它先对比同一份schema在两个PG实例上的差异,效果很好,值得加入迁移工具箱。
如果要在迁移前做兼容性预检,推荐用开源工具ora2pg改一下MySQL的配置来生成评估报告,但它的强项是Oracle到PG。MySQL到PG,目前没有完全自动化的神器,最靠谱的还是写脚本把information_schema的表结构导出,用AST解析SQL的方式扫描应用代码里的不兼容语法。
8. 迁移前必做的兼容性检查清单
8.1 用这8个问题给项目做“体检”
- 代码里有没有
ON DUPLICATE KEY UPDATE、REPLACE INTO、GROUP_CONCAT、IFNULL这类MySQL方言语句?有的话,优先制定改写方案。 - 表结构里有没有
UNSIGNED、AUTO_INCREMENT、DATETIME、TINYINT(1)、ENUM类型?有的话,设计好PG中的替代类型映射,并检查应用层的类型转换代码。 - SQL里有没有依赖隐式类型转换?比如字符串字段和数字直接比较,MySQL会隐式转换,PG要求显式转换,这类SQL在PG上可能报错或走不了索引。
- 有没有用到MySQL的特定排序规则,比如
utf8mb4_unicode_ci?如果有,迁移后必须确认PG的默认排序规则是否满足业务对大小写和重音敏感度的要求。 - 存储过程、触发器、事件调度器的数量是多少?数量超过20个,我建议把代码重写纳入项目计划,而不是硬翻译。边翻译边改逻辑,往往比重写还累。
- 高并发写入场景是否依赖MySQL的
REPEATABLE READ下不报错的特性?是的话,要么接受PG的串行化异常并加重试,要么把隔离级别调整为READ COMMITTED并让应用层自己处理一致性。 - 客户端驱动和ORM版本是否支持PG?这一步最容易被忽略。一些老旧项目的JDBC驱动版本太低,连接PG时报“不支持认证方式”的错误,升级驱动又可能引发其他依赖冲突。
- 运维侧的备份、监控、告警脚本是否兼容PG?MySQL的
mysqldump全量备份逻辑,在PG里对应的是pg_dump和pg_basebackup;监控指标从SHOW STATUS变成pg_stat_activity和pg_stat_database,所有自动化脚本都要重写。
8.2 哪些坑工具能填,哪些必须人肉排
上面这些检查项里面,第2和第4项可以靠迁移工具和schema对比工具自动化处理,第1、3、5、6项基本要靠代码扫描加人工评审。pgloader能在建表时自动完成常见类型映射,但映射不正确时不会智能判断你的业务语义。比如MySQL的VARCHAR(255)在PG里可以原样保留,但如果你把MySQL的TEXT自动映射成PG的TEXT,两者语义也有差异,MySQL的TEXT不能有默认值,PG的TEXT也不能有默认值,这个倒是一致的。真正需要人肉判断的是:DATETIME要不要改成TIMESTAMPTZ?TINYINT(1)要当布尔用还是当整数用?ENUM是扩展成VARCHAR加CHECK约束,还是改成关联表?这些决策带着业务语义,工具做不了。
我的习惯是准备一张“兼容性映射表”,每一行是MySQL的一个类型或语法特性,对应到PG里的替代方案和需要测试的边界条件。这张表由DBA、后端开发和测试共同维护,作为迁移验收的底稿。所有SQL通过巡检脚本扫描后,把疑似不兼容的地方逐条登记,能自动替换的用脚本替换,不能自动替换的留给人肉确认。
提示:迁移之后,不要急着删掉MySQL环境,保留至少一个完整的数据副本,并跑两周的并发影子测试。把线上流量复制一份到PG环境,对比两边返回的结果和耗时,这是找出行为差异最直接的手段。
9. 最后一层经验:别迷信“完全兼容”,要建自己的兼容层
我见过太多项目在迁移文档里写着“PostgreSQL与MySQL完全兼容”,然后把精力全部放在数据搬运上。等到业务上线,第一波流量就把隐藏的兼容性问题全打出来了。真正稳妥的做法,是接受两边的差异,在应用层建一个薄薄的兼容层,把最容易受影响的SQL语法、函数、类型转换统一封装起来。
比如,不要在业务代码里到处写IFNULL和GROUP_CONCAT,而是封装成通用的查询工具函数,底层根据当前数据库类型返回对应的SQL片段。ORM框架里尽量使用数据库无关的查询构造器,少写原生SQL。存储过程这种强绑定数据库的东西,能拆到应用层的就拆到应用层,拆不掉的再做专门的适配。
最后再分享一个我在迁移项目里用得很顺手的技巧:写一个自动化的SQL方言检查脚本,预设好两张表——一张记录MySQL方言特性白名单,一张记录PG已兼容特性黑名单。每次CI跑测试时,扫描代码仓库里的SQL文件,一旦发现白名单里的MySQL方言出现在面向PG的代码路径中,立刻标记为失败。配合这个脚本,我在两个迁移项目里把线上不兼容问题从十几个降到了个位数。数据库的兼容性从来不是一个静态结论,它是你对自己系统掌控力的真实反映。