原项目栈: Spring Boot + MyBatis-Plus + MySQL 。换 PostgreSQL,最初以为只是改个驱动包、改个 JDBC URL——结果踩了一堆坑。
核心结论先甩在这里 :MySQL 是宽容型方言,PostgreSQL 是严格型方言。从 MySQL 迁过去最大的事不是 SQL 改写,而是 习惯了 MySQL 的"自动隐式转换"在 PG 上全部失效 。
下面把 11 个具体差异 按破坏力分成大/中/小坑 ——按这个顺序看,能让你避开 80% 的踩坑时间:
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/ruoyi-vue-pro 视频教程:https://doc.iocoder.cn/video/ < dependency > < groupId > org.postgresql groupId > < artifactId > postgresql artifactId > dependency > spring: datasource: driver-class-name: org.postgresql.Driver
# PG 驱动只识别 PG 自己的参数——别把 useUnicode / characterEncoding / serverTimezone / useSSL 这些 MySQL 的私货带过来 url: jdbc:postgresql://数据库地址/数据库名?currentSchema=模式名 # 可选参数(按需开): # ApplicationName=myApp ← 在 pg_stat_activity 里能看到,方便定位连接来源 # connectTimeout=10 ← 连接超时(秒) # socketTimeout=30 ← 读取超时(秒) # # 走 SSL 用 PG 自己的 ssl / sslmode: # ssl=true&sslmode=require 这里有个 PG 特有的概念要先记住 :PostgreSQL 比 MySQL 多了一层"模式"(schema) ——一个数据库下可以有多个 schema。URL 里的 currentSchema 等价于以前 MySQL 里的"数据库名",不指定就走默认的 public 。
到这里改造看着完了——但你以为这就结束了?后面才是真正的灾难现场。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/yudao-cloud 视频教程:https://doc.iocoder.cn/video/ 这是迁移踩坑次数最多、修改成本最高的差异 ——单这一个坑就够你改一周。
MySQL 是弱类型——字段是 tinyint ,传 boolean 也能匹配,自动转。 PG 是强类型 ,字段类型和参数类型必须一致,否则直接抛异常。最常见两种触发:
-- ❌ SELECT 时类型不匹配:is_active 是 smallint,传 true SELECT * FROM orders WHERE is_active = true -- 报错:operator does not exist: smallint = boolean -- ❌ UPDATE 时类型不匹配 UPDATE orders SET is_premium = false WHERE is_premium = true -- 报错:column "is_premium" is of type smallint but expression is of type boolean 两种解法 :
方案一 (首选):改 PG 字段类型为 boolean
,或改代码字段类型为 Integer ——保证两边对齐。
方案二 (无源码修改权时):在 PG 里注册隐式类型转换函数:
-- smallint → boolean CREATE OR REPLACE FUNCTION smallint_to_boolean(i int2) RETURNS bool AS $ BODY $ BEGIN RETURN (i::int2):: integer :: bool ; END ; $BODY$ LANGUAGE plpgsql VOLATILE; CREATE CAST ( smallint AS boolean ) WITH FUNCTION smallint_to_boolean AS ASSIGNMENT; -- boolean → smallint CREATE OR REPLACE FUNCTION boolean_to_smallint(b bool ) RETURNS int2 AS $ BODY $ BEGIN RETURN (b:: boolean ):: bool :: int ; END ; $BODY$ LANGUAGE plpgsql VOLATILE; CREATE CAST ( boolean AS smallint ) WITH FUNCTION boolean_to_smallint AS IMPLICIT; ⚠️ 谨慎使用隐式转换 :注册过多 cast 可能让 PG 在解析操作符时找不到唯一最优匹配,抛 Could not choose a best candidate operator 或 operator is not unique —— 能改代码的优先改代码 。
PSQLException: Cannot convert the column of type TIMESTAMPTZ to requested type java.time.LocalDateTime. PG 的 TIMESTAMPTZ (带时区的时间戳) 没法直接映射到 Java 的 LocalDateTime 。两个解法:
PG 表字段改成 timestamp (不带时区)——推荐
或 Java 字段类型改成 OffsetDateTime / Date 最好建表时就避开 :MySQL 迁移工具默认生成 TIMESTAMPTZ , 手动改成 timestamp ——后续改代码会少踩很多坑。
Cause: org.postgresql.util.PSQLException: ERROR: current transaction is aborted, commands ignored until end of transaction block 这是 PG 一个特别让人迷惑的行为 :同一事务里只要某条 SQL 出错, 整个事务就废了 ——之后所有语句都会被忽略,必须 ROLLBACK 才能继续。
MySQL 完全不会有这个问题。错误源头通常是这种代码:
if (assigneeStatistics == null ) { StatisticsEntity entity = new StatisticsEntity(); entity.setProcessDefKey(processDefKey); entity.setTaskDefKey(taskDefKey); entity.setUserId(userId); entity.setChannel(channel); entity.setQuantity( 1 ); try { save(entity); // 这里 INSERT 可能因为唯一键冲突失败 } catch (DuplicateKeyException e) { // MySQL 下可能还能继续执行;PostgreSQL 下事务已经进入 aborted 状态 statisticsMapper.increaseByUnique(processDefKey, taskDefKey, userId, channel); } } else { statisticsMapper.increase(assigneeStatistics.getId()); } 问题点 : save(entity) 抛异常后,代码还在同一个事务里继续执行 increaseByUnique(...) 。
翻译过来就是 :业务代码 try-catch 了一个 SQL 异常,想"接着做下一步"——MySQL 默认放行,PG 直接拒绝。
解决思路 : 别用数据库异常控制业务逻辑 ——改成手动判断(先 SELECT 看记录是否存在再决定 INSERT/UPDATE)。这是 PG 迁移最容易出"线上事故"的地方, 所有 catch 了 SQLException 后还要继续 DB 操作的代码必须排查一遍 。
-- MySQL:JSON Path 语法 + -> WHERE order_meta->'$.coupon_code' LIKE CONCAT('%', ?, '%') -- PostgreSQL:直接 ->>'key' 取值 WHERE order_meta ->>'coupon_code' LIKE CONCAT('%', ?, '%') MySQL 用 -> + $.xxx ,PG 用 ->>'xxx' 直接拿值。 项目里 JSON 字段越多,改的地方越多 。
替换示例(保留代码方便读者直接拷贝):
to_char(create_time, 'YYYY-MM-DD') <=> DATE_FORMAT(create_time, '%Y-%m-%d') to_char(create_time, 'YYYY-MM') <=> DATE_FORMAT(create_time, '%Y-%m') to_char(create_time, 'YYYYMMDDHH24MISS') <=> DATE_FORMAT(create_time, '%Y%m%d%H%i%s') Cause: org.postgresql.util.PSQLException: ERROR: column "u.user_name" must appear in the GROUP BY clause or be used in an aggregate function PG 严格遵循 SQL 标准: SELECT 字段必须在 GROUP BY 里,或者用聚合函数包起来 。MySQL 容忍非聚合列(随机取值),PG 直接报错。
-- ❌ 错(user_name 不在 GROUP BY 里、也没聚合) SELECT user_name, age, COUNT (*) FROM user_profile GROUP BY age, score -- ✅ 改法 1:把 user_name 加进 GROUP BY SELECT user_name, age, COUNT (*) FROM user_profile GROUP BY user_name, age, score -- ✅ 改法 2:用聚合包起来 SELECT MIN (user_name), age, COUNT (*) FROM user_profile GROUP BY age, score 真正坑的是 :MySQL 项目里 GROUP BY 写得越粗放,迁移到 PG 要改的查询越多—— 全文 SQL 搜索 GROUP BY 一一过一遍是必须的 。
这五个是 纯语法差异 ——遇到了改一改就行,不影响业务逻辑:
字符串值 WHERE status = "paid" WHERE status = 'paid' 字段名包裹 WHERE \ WHERE order_status = 'paid' 类型转换 convert(amount, DECIMAL(20, 2)) CAST(amount AS DECIMAL(20, 2)) convert 空值替换 IFNULL(coupon_amount, 0) COALESCE(coupon_amount, 0) IFNULL 强制索引 force index(idx_create_time)
说白了 :这五个用 IDE 全文替换跑一遍就能搞定大半,剩下的让 PG 抛异常时再补刀。
❝
ps:
timestamp without time zone 就是 timestamp timestamp with time zone 就是 timestamptz ❞
DO $$ DECLARE rec RECORD ; BEGIN
FOR rec IN SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '要处理的模式名' AND data_type = 'timestamp with time zone' LOOP EXECUTE 'ALTER TABLE ' || rec.table_name || ' ALTER COLUMN ' || rec.column_name || ' TYPE timestamp' ; END LOOP ; END $$; -- 注意:|| 拼接的字符串前面要有空格 DO $$ DECLARE rec RECORD ; BEGIN FOR rec IN SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '要处理的模式名' AND data_type = 'timestamp without time zone' AND column_name in ( 'create_time' , 'update_time' ) LOOP EXECUTE 'ALTER TABLE ' || rec.table_name || ' ALTER COLUMN ' || rec.column_name || ' SET DEFAULT CURRENT_TIMESTAMP;' ; END LOOP ; END $$; MySQL 的 tinyint 对应 PG 的 smallint ——不要换成 bool 类型,代码侧的 Java 字段对应不上 如果 Java 用 LocalDateTime ,PG 一定别用 TIMESTAMPTZ ——大坑 2 已经踩过这坑 MySQL 的
tinyint ↔ Java Boolean 自动转换在 PG 失效 ——两个选择: 或修改代码所有表对象的字段类型与传参类型——但底层框架自己写 SQL 的部分改不了,只能从数据库侧解决 最后说一句 :这种迁移 不要直接灰度上生产 。先用一个真实业务的子模块在预发跑两周,把所有 SQL 都过一遍—— 3 个大坑你不全踩到,就不算真正完成迁移评估 。