⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/xiangmucong-MySQL-qiehuan-PostgreSQLcailetaiduodekeng/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

前言:换数据库不是改个驱动那么简单

原项目栈:Spring Boot + MyBatis-Plus + MySQL。换 PostgreSQL,最初以为只是改个驱动包、改个 JDBC URL——结果踩了一堆坑。

核心结论先甩在这里:MySQL 是宽容型方言,PostgreSQL 是严格型方言。从 MySQL 迁过去最大的事不是 SQL 改写,而是习惯了 MySQL 的”自动隐式转换”在 PG 上全部失效。

下面把 11 个具体差异按破坏力分成大/中/小坑——按这个顺序看,能让你避开 80% 的踩坑时间:

等级 数量 特点 修改成本
🔴 大坑 3 个 迁移必踩、可能让你重写部分代码 改一周
🟡 中坑 3 个 用到了对应特性才会触发 改一两天
🟢 小坑 5 个 语法差异、一眼能改 改半小时

切换流程:依赖、JDBC、模式概念

引入 PostgreSQL 驱动

<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>

修改 JDBC 连接信息

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。

到这里改造看着完了——但你以为这就结束了?后面才是真正的灾难现场。

3 个大坑:必踩 + 修改成本最高

🔴 大坑 1:类型转换异常——强类型对弱类型的清算

这是迁移踩坑次数最多、修改成本最高的差异——单这一个坑就够你改一周。

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——能改代码的优先改代码。

🔴 大坑 2:TIMESTAMPTZ 与 LocalDateTime 不兼容

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——后续改代码会少踩很多坑。

🔴 大坑 3:事务异常会污染整个事务

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 操作的代码必须排查一遍。

3 个中坑:业务用到就要改

🟡 中坑 1:JSON 字段语法不同

-- MySQL:JSON Path 语法 + ->
WHERE order_meta->'$.coupon_code' LIKE CONCAT('%', ?, '%')

-- PostgreSQL:直接 ->>'key' 取值
WHERE order_meta ->>'coupon_code' LIKE CONCAT('%', ?, '%')

MySQL 用 -> + $.xxx,PG 用 ->>'xxx' 直接拿值。项目里 JSON 字段越多,改的地方越多。

🟡 中坑 2:date_format 不存在 → 用 to_char + 占位符对照

替换示例(保留代码方便读者直接拷贝):

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')

🟡 中坑 3:GROUP BY 严格性

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 一一过一遍是必须的。

小坑速查:5 个一眼能改的语法差异

这五个是纯语法差异——遇到了改一改就行,不影响业务逻辑:

差异 MySQL PostgreSQL 修改方式
字符串值 WHERE status = "paid" WHERE status = 'paid' 双引号改单引号
字段名包裹 WHERE \order_status` = ‘paid’` WHERE order_status = 'paid' 反引号改成无 / 双引号
类型转换 convert(amount, DECIMAL(20, 2)) CAST(amount AS DECIMAL(20, 2)) convert 换成 CAST
空值替换 IFNULL(coupon_amount, 0) COALESCE(coupon_amount, 0) IFNULL 换成 COALESCE
强制索引 force index(idx_create_time) (不支持) 直接删除——交给 PG 优化器

说白了:这五个用 IDE 全文替换跑一遍就能搞定大半,剩下的让 PG 抛异常时再补刀。

迁移辅助脚本:批量改字段类型与默认值

批量把 timestamptz 改成 timestamp

❝

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 $$;

批量给 create_time / update_time 设默认值

-- 注意:|| 拼接的字符串前面要有空格
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 $$;

迁移注意事项:四条不踩坑原则

  1. 数据表迁移时字段类型严格对应——别想当然地变更
  2. MySQL 的 tinyint 对应 PG 的 smallint——不要换成 bool 类型,代码侧的 Java 字段对应不上
  3. 如果 Java 用 LocalDateTime,PG 一定别用 TIMESTAMPTZ——大坑 2 已经踩过这坑
  4. MySQL 的 tinyint ↔ Java Boolean 自动转换在 PG 失效——两个选择:
    • 在 PG 里注册隐式转换函数(每次部署后跑一遍)
    • 或修改代码所有表对象的字段类型与传参类型——但底层框架自己写 SQL 的部分改不了,只能从数据库侧解决

最后说一句:这种迁移不要直接灰度上生产。先用一个真实业务的子模块在预发跑两周,把所有 SQL 都过一遍——3 个大坑你不全踩到,就不算真正完成迁移评估。

文章目录
  1. 1. 前言:换数据库不是改个驱动那么简单
  2. 2. 切换流程:依赖、JDBC、模式概念
    1. 2.0.1. 引入 PostgreSQL 驱动
    2. 2.0.2. 修改 JDBC 连接信息
  • 3. 3 个大坑:必踩 + 修改成本最高
    1. 3.0.1. 🔴 大坑 1:类型转换异常——强类型对弱类型的清算
    2. 3.0.2. 🔴 大坑 2:TIMESTAMPTZ 与 LocalDateTime 不兼容
    3. 3.0.3. 🔴 大坑 3:事务异常会污染整个事务
  • 4. 3 个中坑:业务用到就要改
    1. 4.0.1. 🟡 中坑 1:JSON 字段语法不同
    2. 4.0.2. 🟡 中坑 2:date_format 不存在 → 用 to_char + 占位符对照
    3. 4.0.3. 🟡 中坑 3:GROUP BY 严格性
  • 5. 小坑速查:5 个一眼能改的语法差异
  • 6. 迁移辅助脚本:批量改字段类型与默认值
    1. 6.0.1. 批量把 timestamptz 改成 timestamp
    2. 6.0.2. 批量给 create_time / update_time 设默认值
  • 7. 迁移注意事项:四条不踩坑原则