MySQL 迁移 PostgreSQL 常见问题记录

作者:old wang 发布时间: 2025-01-22 阅读量:2 评论数:0

MySQL 切 PostgreSQL,不只是改个驱动

原项目技术栈是:

Spring Boot + MyBatis-Plus + MySQL

最开始切 PostgreSQL 时,以为只是:

  1. 换一个数据库驱动;

  2. 改一下 JDBC URL;

  3. 调整一下数据库连接配置。

实际迁移过程中才发现,真正麻烦的不是连接数据库,而是 SQL 方言、字段类型、事务行为和隐式转换规则的差异。

核心结论是:

MySQL 是相对宽容的数据库方言,PostgreSQL 更严格。
从 MySQL 迁移到 PostgreSQL,最大的坑不是 SQL 写法,而是 MySQL 中大量“自动隐式转换”的习惯在 PostgreSQL 中失效。

整理几个迁移过程中常见的问题。

一、基础切换流程

1. 引入 PostgreSQL 驱动

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

2. 修改 JDBC 连接信息

spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://数据库地址/数据库名?currentSchema=模式名

PostgreSQL 驱动只识别 PostgreSQL 自己的参数。

MySQL 中常见的这些参数不要直接带过来:

useUnicode
characterEncoding
serverTimezone
useSSL

PostgreSQL 可以按需配置自己的参数,例如:

ApplicationName=myApp
connectTimeout=10
socketTimeout=30
ssl=true
sslmode=require

如果要开启 SSL,也需要使用 PostgreSQL 自己的参数:

ssl=true&sslmode=require

3. 注意 schema 概念

PostgreSQL 比 MySQL 多了一层 schema 概念。

一个 PostgreSQL 数据库下可以有多个 schema。

JDBC URL 中的:

currentSchema=模式名

可以理解为指定当前连接默认访问的 schema。

如果不指定,默认使用:

public

二、大坑一:类型转换异常

这是迁移过程中最容易遇到的问题之一。

MySQL 对类型转换比较宽松,比如字段是 tinyint,传入 boolean,很多情况下也能自动处理。

PostgreSQL 则更严格,字段类型和参数类型不匹配时,会直接报错。

1. 查询时类型不匹配

例如字段 is_activesmallint,但查询时传入了 true

SELECT *
FROM orders
WHERE is_active = true;

PostgreSQL 可能会报错:

operator does not exist: smallint = boolean

2. 更新时类型不匹配

例如字段 is_premiumsmallint,但更新时传入了 false

UPDATE orders
SET is_premium = false
WHERE is_premium = true;

可能会报错:

column "is_premium" is of type smallint but expression is of type boolean

3. 解决方式

有两类处理方式。

第一种是从字段类型或代码类型上对齐。

例如:

  • PostgreSQL 字段改成 boolean

  • 或者 Java 代码中的字段类型改成 Integer

  • 总之保证数据库字段类型和代码传参类型一致。

这是优先方案。

第二种是在 PostgreSQL 中注册隐式类型转换函数。

例如 smallintboolean

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;

再注册 booleansmallint

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;

但这种方式需要谨慎使用。

隐式转换注册过多,可能导致 PostgreSQL 在解析操作符时找不到唯一的最优匹配,从而出现类似错误:

Could not choose a best candidate operator
operator is not unique

所以能改代码和字段类型时,优先改代码和字段类型。

三、大坑二:TIMESTAMPTZ 与 LocalDateTime 不兼容

迁移后可能遇到类似错误:

PSQLException: Cannot convert the column of type TIMESTAMPTZ to requested type java.time.LocalDateTime.

这是因为 PostgreSQL 的 TIMESTAMPTZ 是带时区的时间戳,而 Java 中的 LocalDateTime 不带时区。

两者不能直接对应。

解决方式

可以选择以下方案:

  1. PostgreSQL 表字段改成 timestamp,也就是不带时区的时间戳;

  2. 或者 Java 字段类型改成 OffsetDateTime / Date

如果项目中 Java 时间字段大量使用 LocalDateTime,建表时建议直接使用:

timestamp

避免迁移工具默认生成:

timestamptz

否则后续需要修改大量字段或 Java 类型。

四、大坑三:事务异常会污染整个事务

PostgreSQL 中,如果同一个事务里某条 SQL 执行失败,整个事务会进入异常状态。

之后即使代码捕获了异常,继续执行其他 SQL,也可能报错:

ERROR: current transaction is aborted, commands ignored until end of transaction block

这个行为和很多 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);
    } catch (DuplicateKeyException e) {
        statisticsMapper.increaseByUnique(
                processDefKey,
                taskDefKey,
                userId,
                channel
        );
    }
} else {
    statisticsMapper.increase(assigneeStatistics.getId());
}

代码意图是:

  1. 先尝试插入;

  2. 如果唯一键冲突;

  3. 捕获异常;

  4. 再执行更新。

在 MySQL 中,这类写法可能还能继续执行。

但在 PostgreSQL 中,save(entity) 一旦抛出数据库异常,当前事务就已经进入异常状态。

此时再执行:

statisticsMapper.increaseByUnique(...);

就可能继续失败。

解决思路

不要用数据库异常控制业务流程。

更稳妥的做法是先判断记录是否存在,再决定执行 INSERT 还是 UPDATE

也就是说,把:

先 INSERT,失败后 catch,再 UPDATE

改成:

先 SELECT,判断存在后再 INSERT 或 UPDATE

迁移 PostgreSQL 时,项目中所有类似下面的代码都需要重点排查:

catch SQLException / DuplicateKeyException 后继续执行数据库操作

这类代码在 PostgreSQL 中很容易触发事务污染问题。

五、中坑一:JSON 字段语法不同

MySQL 和 PostgreSQL 的 JSON 查询语法不一样。

MySQL 中常见写法:

WHERE order_meta->'$.coupon_code' LIKE CONCAT('%', ?, '%')

PostgreSQL 中一般写成:

WHERE order_meta ->> 'coupon_code' LIKE CONCAT('%', ?, '%')

差异点是:

数据库

写法

MySQL

-> '$.coupon_code'

PostgreSQL

->> 'coupon_code'

如果项目里 JSON 字段使用较多,迁移时需要集中排查这类 SQL。

六、中坑二:date_format 不存在

MySQL 中常用:

DATE_FORMAT(create_time, '%Y-%m-%d')

PostgreSQL 中没有 DATE_FORMAT,需要改成:

to_char(create_time, 'YYYY-MM-DD')

常见替换关系如下:

to_char(create_time, 'YYYY-MM-DD')

对应 MySQL:

DATE_FORMAT(create_time, '%Y-%m-%d')

to_char(create_time, 'YYYY-MM')

对应 MySQL:

DATE_FORMAT(create_time, '%Y-%m')

to_char(create_time, 'YYYYMMDDHH24MISS')

对应 MySQL:

DATE_FORMAT(create_time, '%Y%m%d%H%i%s')

七、中坑三:GROUP BY 更严格

PostgreSQL 对 GROUP BY 的要求更严格。

如果 SELECT 中出现的字段既不在 GROUP BY 中,也没有被聚合函数包裹,就会报错。

典型错误:

ERROR: column "u.user_name" must appear in the GROUP BY clause or be used in an aggregate function

错误示例

SELECT user_name, age, COUNT(*)
FROM user_profile
GROUP BY age, score;

这里 user_name 出现在 SELECT 中,但没有出现在 GROUP BY 中,也没有被聚合函数包裹。

PostgreSQL 会直接报错。

修改方式一:加入 GROUP BY

SELECT user_name, age, COUNT(*)
FROM user_profile
GROUP BY user_name, age, score;

修改方式二:使用聚合函数

SELECT MIN(user_name), age, COUNT(*)
FROM user_profile
GROUP BY age, score;

MySQL 项目中如果 GROUP BY 写得比较宽松,迁移到 PostgreSQL 时,需要全文检索 GROUP BY 并逐一检查。

八、小坑速查

下面这些属于常见语法差异,遇到后按规则修改即可。

差异

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)

不支持

删除,交给 PostgreSQL 优化器

这些问题修改成本通常不高,适合通过 IDE 全文搜索逐步处理。

九、迁移辅助脚本

1. 批量把 timestamptz 改成 timestamp

说明:

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

2. 批量给 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 $$;

注意字符串拼接时要保留必要的空格,否则拼出来的 SQL 可能不合法。

十、迁移注意事项

1. 字段类型要严格对应

迁移数据表时,不要想当然修改字段类型。

尤其是 MySQL 中的:

tinyint

迁移到 PostgreSQL 后,需要确认它在代码中到底对应的是:

Integer / Short

还是:

Boolean

字段类型和 Java 类型不一致时,很容易触发 PostgreSQL 类型转换异常。

2. LocalDateTime 不要直接对应 TIMESTAMPTZ

如果 Java 中使用:

LocalDateTime

PostgreSQL 字段建议使用:

timestamp

不要直接使用:

timestamptz

否则可能出现类型转换问题。

3. 不要依赖 MySQL 的隐式转换

MySQL 中很多可以自动转换的写法,到了 PostgreSQL 中会直接失败。

例如:

tinyint ↔ boolean

在 MySQL 中可能可以自动处理。

但 PostgreSQL 中需要保证字段类型和参数类型严格一致。

4. 排查 catch 后继续操作数据库的代码

PostgreSQL 中,一条 SQL 在事务中出错后,整个事务可能进入异常状态。

因此需要重点排查:

catch 数据库异常后,继续执行数据库操作

这类代码在 MySQL 中可能能跑,在 PostgreSQL 中可能会失败。

结论

从 MySQL 切 PostgreSQL,不只是改一个驱动包,也不只是改 JDBC URL。

真正需要关注的是这些差异:

  1. PostgreSQL 类型检查更严格;

  2. MySQL 中常见的隐式转换,在 PostgreSQL 中可能失效;

  3. TIMESTAMPTZ 和 Java LocalDateTime 不能直接对应;

  4. PostgreSQL 中事务一旦异常,后续 SQL 可能无法继续执行;

  5. JSON、日期格式化、GROUP BY、字符串和字段引用方式都有语法差异;

  6. 迁移前需要逐一排查 SQL、字段类型和事务异常处理逻辑。

评论