news 2026/10/10 12:56:26

MySQL迁移PostgreSQL必踩的语法鸿沟与对照指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL迁移PostgreSQL必踩的语法鸿沟与对照指南

两年前我第一次把一个维护了快六年的 MySQL 业务库迁到 PostgreSQL(下面统称 PG),心里想得很简单:把表结构倒过去、改一下连接串,SQL 应该大差不差。真正开工之后才发现,卡住我的不是数据量,不是服务器配置,而是一堆看起来很小的语法差异。这篇文章就把我当时逐个踩过的“鸿沟”整理出来,重点放在语法迁移上:自增主键、引号与大小写、UPSERT、GROUP BY、NULL 排序、DDL 类型映射、日期函数和正则这些最容易让 MySQL 老手懵掉的地方。如果你也要做类似迁移,或者只是想在两种数据库之间切换不踩坑,可以直接参照这些对照关系来改。

1. 为什么语法迁移不能靠“搜索-替换”解决

1.1 两种数据库的“性格”差很远

MySQL 在很长一段时间里主打“好用、够用”,很多 SQL 写法是宽松的;PG 则更像一个严守 SQL 标准的“考官”,你写得不规范,它真的会报错。举个最简单的例子,下面这条 SQL 在很多老 MySQL 实例上能跑:

SELECT name, age, COUNT(*) FROM users GROUP BY name;

但在 PG 里,它会直接告诉你:users.age必须出现在 GROUP BY 子句中或用于聚合函数。原因很本质:当你按name分组时,age到底取哪一行,SQL 标准没有定义。MySQL 的宽松模式默认给了你一个“任意值”,PG 不允许这种模棱两可。

这类差异才是迁移成本的大头。它们不会在数据拷贝阶段暴露,而是在业务代码跑起来之后一个接一个蹦出来。你很难用“搜索替换”一次解决,因为问题不是某个关键字不同,而是背后逻辑不同。

1.2 从“宽松模式”到“严格模式”

MySQL 的sql_mode可以调节不少行为,比如是否允许GROUP BY选非聚合列、是否允许''转成0、除法精度如何处理。PG 没有一套等价的 session 开关,它的行为更像“标准模式常开”。

这意味着迁移时你要把那些年在宽松模式下写出来的“野 SQL”重新按标准写一遍。这不是坏事,但对排期来说确实是变量。我当时的做法是:把所有待迁移的 SQL 先在 PG 上跑一遍,让报错帮我们列清单,而不是靠肉眼 review。

1.3 迁移前先统一 SQL 规范

如果你跟我一样是从一个老项目开始,建议先别急着改代码,先在团队里把 SQL 规范统一一下:表名、列名统一小写下划线;普通字符串用单引号;查询显式列出列名;GROUP BY里的列和SELECT里的非聚合列严格对应。这个动作能省掉后面一大半问题。否则同样的坑会在不同模块里反复出现,你改完一个还有下一个。

2. 引号、大小写、反斜杠:最容易出“玄学报错”的入口

2.1 反引号换成双引号,但别滥用

MySQL 里最常见的写法是用反引号包裹表名和列名:

SELECT `id`, `name` FROM `user` WHERE `status` = 1;

PG 不支持反引号,它是 SQL 标准那一套,用双引号:

SELECT "id", "name" FROM "user" WHERE "status" = 1;

但这里有个容易误导人的点:在 PG 里,双引号会创建一个“区分大小写、保留原样”的标识符。如果你建表时字段叫Name,那每次查询都得写成"Name",写name会找不到。所以我的建议是:迁移时尽量把标识符整理成小写下划线,能不加引号就不加引号。别把 MySQL 那种“每列都反引号包起来”的习惯带到 PG,否则你会被大小写问题折磨死。

2.2 未加引号的标识符会被折叠成小写

这是 PG 和 MySQL 一个非常隐蔽的区别。在 PG 里,不带引号的标识符会被自动转成小写。也就是说,你建表时写:

CREATE TABLE UserInfo (id int);

PG 实际存的名字是userinfo。之后你写SELECT * FROM UserInfo,PG 会把它翻译成userinfo,所以能查到。但如果你建表时用了双引号:

CREATE TABLE "UserInfo" (id int);

那表名就是确确实实的UserInfo,之后写SELECT * FROM UserInfo会因为被折叠成userinfo而报“表不存在”。从 MySQL 迁过来时,如果老表名里有大写,我建议统一改成小写,或者在第一次建表时就保持不带引号的小写命名。不要在迁移后还依赖大小写混用,PG 会让你很痛苦。

2.3 字符串常量里的反斜杠默认不再转义

MySQL 默认把\n、\'这类反斜杠序列当转义符处理;PG 默认standard_conforming_strings=on,普通字符串里反斜杠就是普通字符。举个例子,SELECT 'a\nb';在 MySQL 里返回带换行的两行文本,在 PG 里返回字面量a\nb。如果你老代码里大量用\n拼换行,迁移后要改成chr(10)或者用 PG 的转义字符串E'\n'。

这个坑特别隐蔽,因为通常不会报错,只是返回值变了。最典型的是路径字符串和正则表达式,原本“看起来对”的结果会悄悄变掉。

3. 自增主键迁移:从 AUTO_INCREMENT 到 SERIAL 与 IDENTITY

3.1 三种写法的对应关系

MySQL 的自增主键写法大家很熟:

CREATE TABLE users ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, PRIMARY KEY (id) );

PG 有两条路可以走。老一点的方式是用SERIAL伪类型:

CREATE TABLE users ( id serial PRIMARY KEY, username varchar(50) NOT NULL );

SERIAL本质上是自动帮你创建了一个序列(sequence),然后默认取下一个值。现代 PG 更推荐标准写法:

CREATE TABLE users ( id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, username varchar(50) NOT NULL );

这里我特意用了BY DEFAULT而不是ALWAYS。原因很实际:MySQL 允许你显式插入自增列的值,老业务代码里很可能有这类操作。GENERATED ALWAYS默认会拦截显式插入,你得额外写OVERRIDING SYSTEM VALUE,迁移阶段没必要给自己加这种负担。等业务完全稳定,再考虑收紧成ALWAYS也不迟。

3.2 插入后拿新 ID 的方式变了

MySQL 老代码经常这样拿自增 ID:

INSERT INTO users (username) VALUES ('neo'); SELECT LAST_INSERT_ID();

PG 更推荐直接用RETURNING:

INSERT INTO users (username) VALUES ('neo') RETURNING id;

这种方式的好处是单条 SQL 就能拿到完整行数据,而且是事务内安全的,不容易出现连接串行执行时取错 ID 的问题。批量插入时也一样:

INSERT INTO users (username) VALUES ('a'), ('b'), ('c') RETURNING id, username;

如果你用的是 ORM 或 JDBC 的getGeneratedKeys(),PG 驱动也支持,但底层实际上也是帮你走RETURNING。所以裸 SQL 场景里,直接用RETURNING最直观。

3.3 手动重置自增起点

老项目经常有“清掉测试数据后,把自增 ID 回到某个值”的操作。MySQL 写法是:

ALTER TABLE users AUTO_INCREMENT = 10000;

PG 里分两种情况。如果是serial:

ALTER SEQUENCE users_id_seq RESTART WITH 10000;

如果是GENERATED AS IDENTITY:

ALTER TABLE users ALTER COLUMN id RESTART WITH 10000;

注意,serial自动创建的序列名一般是“表名_列名_seq”,这个命名容易被忽略,但重置时必须写对。

4. 几个高频 DML 语法差异,逐个对齐

4.1 UPSERT:ON DUPLICATE KEY UPDATE换成ON CONFLICT

MySQL 里很常见的写法:

INSERT INTO users (id, email, name) VALUES (1, 'a@example.com', 'A') ON DUPLICATE KEY UPDATE name = VALUES(name);

PG 的等价写法是:

INSERT INTO users (id, email, name) VALUES (1, 'a@example.com', 'A') ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;

两个容易踩的差异点:

第一,PG 不认VALUES()函数,冲突行要引用EXCLUDED这个虚拟表。第二,ON CONFLICT DO UPDATE必须指定冲突目标。比如表上有两个唯一键,你只说ON CONFLICT DO UPDATE不写具体列名,PG 会报错,因为它在多个唯一索引下不知道该按哪个来判断冲突。MySQL 的ON DUPLICATE KEY UPDATE不需要指定,它是碰到任意唯一键冲突就触发。这个语义差异会导致同一个 SQL 在 PG 上必须补上明确的冲突目标。

如果你原本用的是INSERT IGNORE,PG 里对应的是ON CONFLICT DO NOTHING,这个可以不指定目标:

INSERT INTO users (id, email) VALUES (1, 'a@example.com') ON CONFLICT DO NOTHING;

4.2 UPDATE/DELETE 后面接 LIMIT:PG 不直接支持

在 MySQL 里,分批更新或删除经常这么写:

DELETE FROM logs WHERE status = 0 LIMIT 500; UPDATE tasks SET retry_count = retry_count + 1 WHERE status = 0 LIMIT 100;

PG 不支持UPDATE ... LIMIT,也不支持DELETE ... LIMIT。正确做法是用 CTE 先取出目标 ID,再做操作:

WITH ids AS ( SELECT id FROM logs WHERE status = 0 ORDER BY id LIMIT 500 ) DELETE FROM logs USING ids WHERE logs.id = ids.id;

更新类似:

WITH ids AS ( SELECT id FROM tasks WHERE status = 0 ORDER BY created_at LIMIT 100 ) UPDATE tasks SET retry_count = retry_count + 1 FROM ids WHERE tasks.id = ids.id;

这里多说一句:如果分批处理不关心“取前多少行里的哪些行”,确实可以只写LIMIT不写ORDER BY,但结果就不确定了。MySQL 和 PG 都是如此。而既然要对数据做更新/删除,我建议无论如何都加上ORDER BY,至少保证操作行为和预期一致。

4.3 严格 GROUP BY 与“每组取某一行”的改写

前面说过,老 MySQL 允许GROUP BY时直接选出不在分组里的列。这种 SQL 到了 PG 会直接被拒。正确的处理方式不是到处找ANY_VALUE平替,而是把业务需求问清楚:

如果只是想要分组后的某个聚合值,就写聚合:

SELECT user_id, MAX(age) FROM profile GROUP BY user_id;

如果是要“每个用户的最近一条记录”,那用DISTINCT ON比聚合更合适:

SELECT DISTINCT ON (user_id) user_id, age, created_at FROM profile ORDER BY user_id, created_at DESC;

DISTINCT ON是 PG 的特色语法,非常适合“每组取一行”的场景。迁移时如果遇到这类查询,别硬改成GROUP BY再在外面套子查询,DISTINCT ON会更清爽。

4.4 NULL 排序、整数除法与字符串拼接

这几个点看着小,真遇到时数据对不上最容易查半天。

先说 NULL 排序。MySQL 默认升序时 NULL 排最前,降序时 NULL 排最后;PG 默认升序时 NULL 排最后,降序时 NULL 排最前,因为 PG 把 NULL 默认当成“比任何值都大”。如果你想保持 MySQL 的排序结果,迁移时要显式写:

原 MySQL 写法意图PG 迁移写法
ORDER BY col ASC(NULL 在头部)ORDER BY col ASC NULLS FIRST
ORDER BY col DESC(NULL 在尾部)ORDER BY col DESC NULLS LAST

再说除法。MySQL 里SELECT 5 / 2;返回2.50这样的小数;PG 里整数相除默认得到整数,5 / 2是2。如果你需要保留小数,必须让其中一个操作数是浮点数或 numeric:

SELECT 5::numeric / 2; SELECT 5.0 / 2;

这种差异会影响报表、统计类 SQL 的精度,迁移后要逐条检查。

最后说拼接。MySQL 老代码爱用CONCAT,PG 也有CONCAT,但两者对 NULL 的处理不一样。SELECT CONCAT(NULL, 'abc');在 MySQL 里结果是 NULL,在 PG 里结果是abc,因为 PG 的concat会跳过 NULL。如果你依赖“只要有一项为 NULL 结果就是 NULL”的业务逻辑,需要自己写CASE WHEN处理,或者用NULLIF包裹。用||拼接时也注意,PG 的||对非文本类型不一定会隐式转换,建议把数字先转成text再拼。

5. DDL 建表语句与类型映射,粘贴即报错的高发区

5.1 没有 ENGINE,也没有 DEFAULT CHARSET

MySQL 建表时常见的尾巴:

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

在 PG 里这两个选项都不存在。PG 表的默认存储引擎就是“事务性存储”,你不需要也不能指定ENGINE=InnoDB。字符集也不是表级别设置的,而是数据库集群在初始化时定好。所以迁移建表脚本时,直接删掉这两段即可。

这里要特别提醒一个性能语义变化:如果你的 MySQL 表之前是 MyISAM,SELECT COUNT(*)会非常快,因为它有表级计数。PG 没有这种“非事务表”,COUNT(*)需要扫描数据,大表上会明显变慢。迁移后如果业务对这个查询性能敏感,别硬扛,考虑用统计信息、物化视图或业务侧自增计数来替代。

5.2 常见类型对照表

MySQL 类型PG 推荐映射说明
TINYINT(1)boolean或smallint如果只存 0/1,直接映射成 boolean
INT UNSIGNEDbigint无符号 int 最大值仍然在 bigint 范围内
BIGINT UNSIGNEDnumeric(20,0)超过bigint范围时用 numeric 兜底
DATETIMEtimestamp without time zone原类型不带时区,保持语义
TIMESTAMPtimestamp with time zoneMySQL 的 timestamp 实际受会话时区影响
TEXT/BLOBtext/byteaPG 的 text 可以存大对象,不需要长度限制
JSONjsonb一般建议用 jsonb,性能更好、支持索引
ENUM('a','b')text+ CHECK 约束 或 PG 内置 enumMySQL 的 enum 变更成本低,PG 变更 enum 成本高,需权衡

建表时还常见VARCHAR(255),这个两个数据库都支持,不用太担心。真正要留意的是UNSIGNED:PG 没有UNSIGNED选项。如果只是INT UNSIGNED,映射成bigint通常没问题;但遇到BIGINT UNSIGNED且业务确实会超过bigint上限时,就只能用numeric(20,0)了。

5.3 ALTER TABLE 的语法习惯要改

MySQL 改列常用MODIFY COLUMN:

ALTER TABLE users MODIFY COLUMN age INT NOT NULL;

PG 用的是两步走:

ALTER TABLE users ALTER COLUMN age SET NOT NULL;

改类型则是:

ALTER TABLE users ALTER COLUMN age TYPE bigint USING age::bigint;

USING这一步很重要。当 PG 不能自动完成类型转换时(比如字符串转数字、字符串转日期),你必须告诉它怎么转。如果不加USING,很多TYPE操作会直接报错。

5.4 索引写法差异

MySQL 允许在建表语句里内联定义索引:

CREATE TABLE t ( id INT PRIMARY KEY, name VARCHAR(50), KEY idx_name (name) );

PG 通常把索引独立写在建表语句后面:

CREATE TABLE t ( id int PRIMARY KEY, name varchar(50) ); CREATE INDEX idx_name ON t (name);

大表上加索引,建议用 PG 的CREATE INDEX CONCURRENTLY,避免长时间阻塞读写。这个关键词在 MySQL 里没有,算是一个迁移时需要顺手改掉的习惯。

6. 日期函数、聚合函数与正则表达式的“翻译表”

6.1 日期时间函数对照

MySQLPG说明
NOW()now()或CURRENT_TIMESTAMP差异不大
CURDATE()CURRENT_DATE返回当前日期
DATE_FORMAT(d, '%Y-%m-%d')to_char(d, 'YYYY-MM-DD')格式符写法完全不同
STR_TO_DATE(s, '%Y-%m-%d')to_date(s, 'YYYY-MM-DD')字符串转日期
DATEDIFF(a, b)a::date - b::datePG 日期相减就是整数天数
DATE_ADD(d, INTERVAL 1 DAY)d + interval '1 day'PG 的 interval 写法固定
UNIX_TIMESTAMP()extract(epoch from now())返回浮点秒数
FROM_UNIXTIME(ts)to_timestamp(ts)注意返回 timestamptz

DATE_FORMAT的格式符是最容易出错的。%Y-%m-%d %H:%i:%s在 PG 里对应YYYY-MM-DD HH24:MI:SS,很多报表 SQL 都被这个点卡过。

6.2 聚合与 NULL 处理相关函数

MySQL 的IFNULL(a, b)在 PG 里是COALESCE(a, b);MySQL 的IF(condition, a, b)建议改成标准CASE WHEN condition THEN a ELSE b END。

GROUP_CONCAT是 MySQL 非常高频的函数,PG 里的等价物是string_agg:

-- MySQL SELECT user_id, GROUP_CONCAT(DISTINCT tag SEPARATOR ',') FROM user_tags GROUP BY user_id; -- PG SELECT user_id, string_agg(DISTINCT tag, ',') FROM user_tags GROUP BY user_id;

如果原来的GROUP_CONCAT里有排序需求,比如GROUP_CONCAT(tag ORDER BY tag SEPARATOR ','),对应 PG 的写法是:

SELECT user_id, string_agg(tag, ',' ORDER BY tag) FROM user_tags GROUP BY user_id;

6.3 正则会话要换操作符

MySQL 用的是REGEXP关键字:

SELECT * FROM users WHERE email REGEXP '^[a-z]+@example\\.com$';

PG 用的是一组操作符:

语义MySQL 写法PG 写法
匹配col REGEXP 'pattern'col ~ 'pattern'
不匹配col NOT REGEXP 'pattern'col !~ 'pattern'
忽略大小写匹配col REGEXP 'pattern'(非二进制字符串通常不区分大小写)col ~* 'pattern'
忽略大小写不匹配col NOT REGEXP 'pattern'col !~* 'pattern'

这里有一个来自 MySQL 的隐藏差异:MySQL 的REGEXP在普通字符串场景下大多不区分大小写,而 PG 的~是区分大小写的。迁移时如果你的老查询依赖“不区分大小写”,只把REGEXP换成~还不够,应该换成~*。

7. 迁移实操顺序与自查清单

7.1 先用工具把表结构和数据搬过来

最好的策略是先让“数据底座”跑起来,再逐条修 SQL。我在迁移时用的是pgloader,它可以连接 MySQL,自动完成建表、类型转换和数据导入,基本命令类似这样:

pgloader mysql://user:pass@127.0.0.1:3306/app postgresql://user:pass@127.0.0.1:5432/app

但你要清醒一点:这类工具解决的是“表结构和数据”,不是“业务 SQL”。它不会帮你把ON DUPLICATE KEY UPDATE、GROUP_CONCAT、REGEXP翻译掉。工具只是减少体力活,改 SQL 仍然要人工做。

7.2 在代码仓库里 grep 这些关键词

迁移期我一般会在代码库全文搜索下面这些关键词,基本能定位九成以上的问题点:

  • 反引号,例如`id`这种写法
  • AUTO_INCREMENT
  • ENGINE=建表配置
  • ON DUPLICATE KEY UPDATE
  • INSERT IGNORE
  • GROUP_CONCAT
  • DATE_FORMAT
  • UNIX_TIMESTAMP
  • IFNULL
  • REGEXP
  • UPDATE ... LIMIT、DELETE ... LIMIT

连接串方面也别漏掉:驱动类要从com.mysql.cj.jdbc.Driver换成org.postgresql.Driver,端口从3306变成5432,URL 前缀从jdbc:mysql://变成jdbc:postgresql://。如果原来 URL 里带了一堆 MySQL 专属参数,比如useSSL、characterEncoding,基本都要删掉或换成 PG 对应配置。

7.3 回归验证时重点盯这几个场景

迁移完不是“能连上、能跑通”就完了。我当时的做法是专门列了一张测试 SQL 清单,每个场景都要人工对一遍结果:

  • 插入后返回自增 ID
  • 批量 INSERT 遇到唯一键冲突
  • 包含 NULL 的排序结果
  • 整数除法结果
  • GROUP BY查询在所有模块都能跑
  • 日期格式化输出
  • 分页查询的 total 和当前页数据
  • 正则匹配的命中列表

我建议你哪怕觉得很基础,也要逐条对。尤其是 NULL 排序和无符号类型这两个点,很多“线上数据对不上”的问题都出在这里。

最后再说一点个人体会:迁移过程中如果某条 SQL 在 PG 上改起来很麻烦,不要急着找一个“长得像”的函数糊弄过去。PG 之所以报错,往往是因为原来的 SQL 本身就有语义含糊的地方——它逼你把业务问题想清楚,这其实是件好事。凡是认真改过去的 SQL,后来都成了团队 SQL 规范里最好的反面教材。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 12:56:22

Agentic AI成果导向评估:从调用量到问题解决率的指标重构

1. 从“调用次数”到“问题解决率”:一场被忽视的指标静默革命我第一次在某客户现场听到CTO说“我们上季度AI调用量涨了300%,但业务部门投诉率也涨了45%”时,手里的咖啡杯差点没拿稳。这不是个例——过去两年,我参与过7个不同行业…

作者头像 李华
网站建设 2026/10/10 12:55:21

yolov8头盔检测模型资源使用指南:从权重加载到C++部署

简介:面向摩托车骑行安全监管场景的 YOLOv8 佩戴头盔与驾驶员检测模型包,能够帮助计算机视觉开发者、算法工程师或相关专业学生快速完成模型推理、微调与部署验证。压缩包共含 901 个文件,大小约 101.23MB,文件类型较为丰富&#…

作者头像 李华
网站建设 2026/10/10 12:54:34

去中心化AI决策机制:从信任模型到工程落地的完整拆解

1. 先看地基:去中心化系统给AI准备的决策环境聊AI决策,大家首先想到的往往是中心化场景:数据集中在一台服务器上,模型由一家公司训练和部署,用户提交请求之后,后台跑推理,最后返回结果。这个链路…

作者头像 李华
网站建设 2026/10/10 12:54:25

dxdiag不是修复工具,而是Windows图形诊断的X光机

1. 这不是“一键修复”,而是诊断思维的落地实践很多人看到“使用DirectX诊断工具简单修复”这个标题,第一反应是:又一个教你怎么点几下鼠标就搞定显卡问题的速成教程?我试过太多次了——双击dxdiag.exe,勾选“启用D3D加…

作者头像 李华
网站建设 2026/10/10 12:54:15

蛋白质二级结构预测实战:从特征工程到BiLSTM模型

简介:一份基于Python的蛋白质二级结构预测毕业设计资源,面向计算机、人工智能、自动化等专业的在校学生、老师及企业员工,可用于毕业设计、课程设计、作业或项目初期演示,也适合新手学习进阶。资源共35个文件,包含5个P…

作者头像 李华
网站建设 2026/10/10 12:54:12

电力市场节点出清电价LMP计算原理与Python程序实现

刚接触电力市场的时候,"节点出清电价"这六个字我盯着教材看了很久,始终觉得像雾里看花。做了几轮课设和项目之后才慢慢意识到,节点电价不是一个抽象的经济学名词,它是从一份求解优化模型得到的数值解里,一行…

作者头像 李华