news 2026/9/18 12:07:08

RuoYi 从 MySQL 迁移 PostgreSQL:SQL 适配与避坑实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RuoYi 从 MySQL 迁移 PostgreSQL:SQL 适配与避坑实战

1. 迁移前先想清楚:为什么要动数据库

把 Ruoyi 从 MySQL 迁到 PostgreSQL,这件事本身不难,难的是迁完之后系统还能原样跑起来。我前前后后在三套 Ruoyi 项目上做过这种切换,有 RuoYi-Vue 单体版的,也有 RuoYi-Cloud 拆成多模块的,还碰过 RuoYi-Vue-Plus 那种默认就带 MyBatis-Plus 的版本。每一次都会在不同的地方被绊一下,而且每次绊倒的位置还不一样——第一次死在自增主键,第二次死在find_in_set,第三次死在他妈代码生成器。

RuoYi、MySQL、PostgreSQL 这三个词组合在一起,本质上是一个「由约定俗成的 MySQL 方言惯用法,撞上更严格标准的 PostgreSQL 语义」的适配问题。MySQL 对语法很宽容,date_formatfind_in_set、反引号、insert ignorelimit 0,10这些它都认;PostgreSQL 对标准的坚持更强,很多 MySQL 里随手就写的东西它直接给你报错。而 Ruoyi 恰恰是一个在 MySQL 上长了七八年、沉淀了大量 MySQL 味道 SQL 的脚手架,所以换库等于把所有历史包袱翻出来重新过一遍。

这篇东西适合谁看?如果你正在用 Ruoyi 做政企项目、信创项目,或者客户明确要求 PostgreSQL,那基本就是你要面对的活。也适合那些用 RuoYi-Vue-Plus 想换库但不知道从哪下手的人。我会把每一处坑的原理、改法、验证方式都摆出来,尽量让你在真机上少走我走过的弯路。下面按「环境打通 → 建表脚本 → 代码适配 → 排查」的顺序铺开,顺序可以按你的项目现状跳着看。

1.1 换库的真实动因

先说动因,因为这直接决定你迁移时的取舍策略。常见的三种情况:一是项目进了信创环境,数据库只能选 PostgreSQL 或某些兼容 PG 的国产库;二是数据规模上来了,需要 PG 更强的窗口函数、JSONB、并行查询能力;三是团队本来就以 PG 为技术栈,不想为了一个脚手架再养一套 MySQL。

三种动因对应三种完全不同的改法。如果只是「跑起来就行」,那很多地方可以用兼容层糊过去,比如保留char(1)字段、不重构 SQL,只做最小化替换。如果是为了长期在 PG 上维护,那就要趁这次把类型、函数、索引都改干净,别留半吊子。我踩过的最大的坑就是一开始想着「先跑通再说」,结果糊了一堆兼容写法,半年后想加新功能时发现到处是地雷,最后返工比一次改到位还累。

还有一点,不同版本的 Ruoyi 结构差别不小。RuoYi 单体版和 Cloud 版的核心表基本一致,但 RuoYi-Vue-Plus 用的是 MyBatis-Plus,很多查询不写在 XML 里,而是靠条件构造器拼出来的,这就意味着换库时你要改的位置完全不同——XML 里那些手写 SQL 变成构造器里的字段名和类型映射。所以动工之前,先花半小时把项目里所有*.xmlapplication-*.ymlsql/目录、pom.xml里的数据库依赖列个清单,后面按清单推进,比拍脑袋改靠谱得多。

1.2 换库前必须盘点的四类东西

第一类是建表脚本。RuoYi 自带的ry_20xxx.sql是纯 MySQL 语法,里面有AUTO_INCREMENTENGINE=InnoDBCOMMENT、反引号、tinyint(1)datetime这些东西,全部要改写。

第二类是框架里写死的 SQL。最典型的是权限相关的树形查询、日志统计、定时任务,以及代码生成器的元数据查询。这些散落在各模块的 Mapper XML 里,不翻一遍根本不知道埋了多少。

第三类是配置。数据源 URL、驱动类名、Druid 校验语句、分页插件方言、MyBatis-Plus 的dbType、Quartz 的驱动器代理类,一个都不能漏。

第四类是应用层代码。有些地方 Java 里直接拼 SQL 或者依赖自增返回,还有的地方用到了 MySQL 特有的时间格式。这部分藏得最深,往往是运行到某个功能才暴露。

盘完这四类,心里就有底了。我一般会做一个表格,标出「必须改」「可以不改」「改不改都行」三档,避免过度修改引入新问题。

2. 环境与依赖:第一步先把连接打通

连接打通是前提,但很多人卡在这里就开始怀疑人生了。PG 的连接串、驱动、校验语句跟 MySQL 都不一样,尤其 RuoYi 的application-druid.yml里有些默认值就是 MySQL 专属的。这一章先把这层打通,后面的表结构和 SQL 才有意义。

2.1 驱动和连接串

先改pom.xml。MySQL 的依赖是mysql-connector-java(新版本叫mysql-connector-j),PG 要换成:

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

版本别选太老的。42.2.x 在某些 JDK 版本上会有getGeneratedKeys行为和bytea单元格读写的兼容问题,42.7.x 稳定得多。如果你用的是 RuoYi-Cloud 这种多模块项目,别忘了ruoyi-common或者各业务模块里可能也单独声明了驱动,搜一遍mysql关键字确认清干净。

连接串改法:

# MySQL url: jdbc:mysql://localhost:3306/ry?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 driverClassName: com.mysql.cj.jdbc.Driver # PostgreSQL url: jdbc:postgresql://localhost:5432/ry?currentSchema=public&stringtype=unspecified&reWriteBatchedInserts=true driverClassName: org.postgresql.Driver

这里有个参数值得单独说:stringtype=unspecified。它让 PG 驱动在传字符串参数时不做强制类型推断,避免column "xxx" is of type integer but expression is of type character varying这类报错。RuoYi 里大量 Mapper 传的参数是字符串,但数据库字段是数值或时间,PG 的类型检查比 MySQL 严,不加这个参数会经常翻车。reWriteBatchedInserts=true是批量插入的优化,配合 RuoYi 的批量新增能快不少,建议一起加上。

2.2 数据源配置里几个必改项

RuoYi 默认用 Druid,application-druid.yml里有几个字段是 MySQL 专属的。

validationQuery在 RuoYi 里默认写的是:

validationQuery: SELECT 1 FROM DUAL

PostgreSQL 没有DUAL这个虚拟表,直接会报relation "dual" does not exist。改成SELECT 1就行。这就是个特别经典、特别容易漏、一启动就报错的坑。

另一个是 Druid 的filters。如果配置里带了wall,那它会对 SQL 做防火墙检查,而 Druid 的 wall 过滤器主要是为 MySQL 设计的,PG 的很多语法它识别不了会误拦。RuoYi 默认一般只有stat,slf4j,但有些二次开发的版本加了wall,这时候要么去掉wall,要么换别的方案。

还有连接池的健康检查、initialSizeminIdle这些不用动。dbType如果配置了,记得从mysql改成postgresql

2.3 初始化脚本的取舍

RuoYi 的sql/目录里有好几个脚本:主业务表、Quartz 表、初始化数据,可能还有流程引擎的表。MySQL 版和 PG 版不完全对应。

Quartz 这块基本都是坑。RuoYi 默认给的是 MySQL 的quartz.sql,直接扔到 PG 里执行会报一堆语法错。标准做法是用 Quartz 官方发行包里docs/dbTables/tables_postgres.sql,同时把配置里的驱动器代理类改成:

org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

RuoYi 里这个配置通常在application.ymlspring.quartz.properties下,或者单独一个quartz.properties。这个不改,定时任务要么不执行,要么执行报Couldn't retrieve job because a required class was not found。热词里有人问「ruoyi 任务不执行什么原因」,如果你换了 PG 又没改这个代理类,八成就是这个原因。

初始化数据脚本里那些INSERT语句也要注意:字符串里的单引号转义、日期字面量格式,MySQL 和 PG 都能认大部分,但\'这种转义在 PG 的标准模式里可能出问题。稳妥一点是把\'换成'',也就是用两个单引号表示一个单引号。

3. 建表脚本:类型映射踩坑最密集的地方

建表脚本这一关是最费时的,也是最值得认真做的。RuoYi 的表结构里藏着很多 MySQL 惯用法,一次性改干净,后面能省无数事。这一章把最常出问题的几类逐个说清楚。

3.1 自增主键与序列

MySQL 里bigint(20) NOT NULL AUTO_INCREMENT就搞定了自增。PG 现在推荐用标准 SQL 的GENERATED BY DEFAULT AS IDENTITY,或者老式的bigserial。两种写法:

-- 方式一:identity(推荐,PG 10+) user_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY -- 方式二:bigserial user_id bigserial PRIMARY KEY

建议用 identity,因为它更接近标准,后续维护清晰。但这里有个必须注意的细节:RuoYi 很多表在初始化数据时是显式指定主键 ID插入的,比如insert into sys_user(user_id, ...) values(1, ...)。用 identity 加BY DEFAULT时,显式插入不会自动推进序列,等你后面用框架自增插入新数据时,序列还停在 1,直接报主键冲突duplicate key value violates unique constraint

解决办法有两步。第一步,建表时用GENERATED BY DEFAULT AS IDENTITY而不是ALWAYSALWAYS会禁止显式插入,初始化脚本直接失败。第二步,初始化数据跑完之后,给每个有自增的表重置序列:

SELECT setval(pg_get_serial_sequence('sys_user', 'user_id'), (SELECT COALESCE(MAX(user_id), 1) FROM sys_user));

pg_get_serial_sequence对 identity 和 serial 都有效。这个操作一定要做,我见过太多人迁完之后「查询都正常,一新增就炸」,排查半天才发现是序列没同步。而且这个问题特别隐蔽,因为开发阶段你可能只点查询,上线之后用户一注册就爆。

另外,RuoYi 的gen_tablegen_table_columnsys_oper_logsys_logininfor这些表都有自增主键,全部要处理。表多的话可以写个DO块批量处理,但初期手动列个清单挨个setval更保险,避免漏。

3.2 tinyint、char(1) 与布尔

RuoYi 里最典型的字段就是status char(1)del_flag char(1)visible char(1),值就是'0''1'。迁到 PG 之后,char(1)本身语法是合法的,但 PG 的char是定长类型,存储时会用空格补齐到指定长度。char(1)'0'没问题,但如果某些表里是char(2)存了'1',实际存进去是'1 ',比较时会出幺蛾子。

我的建议是统一改成varchar(1),语义一样,还避免补齐问题,索引比较也不会被空格坑。RuoYi 的查询里status = '0'这种比较非常多,定长类型的隐式转换虽然大多数时候能对上,但涉及表达式索引、联合索引的时候容易失效。

如果表里有tinyint(1),PG 里根本没有tinyint这个类型,最近的是smallint。但tinyint(1)在 MySQL 里通常被当成布尔用,PG 有原生boolean类型。这时候要看代码怎么处理的:如果 Java 里映射的是Boolean,那 PG 用boolean最自然;如果 Java 里映射的仍然是IntegerString,那就用smallintvarchar(1)更省事。别想当然地改成boolean,MyBatis 的类型处理器没配好会报Cannot convert value of type ... to boolean

注意:PG 的boolean字面量是true/false,跟 MySQL 的1/0不通用。如果你的 SQL 里写了where enabled = 1,字段是 boolean 就会报类型错。这一点在写条件查询时最容易忽略。

3.3 datetime、text、blob

时间类型的对应关系不复杂,但有几个细节。

MySQL 的datetime对应 PG 的timestamp(不带时区),或者timestamptz(带时区)。RuoYi 里create_timeupdate_time这种字段,用timestamp就够了,行为跟datetime最接近。如果你想统一时区处理,用timestamptz也行,但那样 Java 端读取时要保证时区配置一致,否则容易出现差 8 小时的问题。

datedatetimetime,这些没争议。

text类型两边都有,直接对应。但要注意:MySQL 里varchar(2000)text的区别经常被忽略,PG 里也类似,超过一定长度就得用text。RuoYi 的sys_oper_log.json_result用的是varchar(2000)sys_user.remarkvarchar(500),这些长度只要够用就不用动。

blob在 PG 里要改成bytea。RuoYi 本身用 blob 的地方不多,但如果是自己扩展的表,比如存附件二进制,就要注意。MyBatis 映射byteabyte[]没问题,但 Druid 或某些连接池在读取大字段时可能有性能问题,大文件建议还是走对象存储,别塞数据库。

还有个细节:MySQL 的doublefloatdecimal在 PG 里分别对应double precisionrealnumeric。RuoYi 里金额字段一般用decimal(10,2),PG 用numeric(10,2),语义完全一致。

3.4 关键字、大小写与引号

MySQL 用反引号包裹标识符,PG 用双引号。建表脚本里的反引号全部要去掉,或者换成双引号。但更重要的一个问题是大小写。

PG 有个默认行为:不加引号的标识符全部转小写。userName会被理解成username。而 MySQL 在 Linux 下默认表名区分大小写,Windows 下不区分。如果 RuoYi 的表名和字段名都是小写(它基本都是),就没什么问题。但如果你的自定义表和字段用了驼峰,迁到 PG 后就会找不到列。

所以原则是:不要用双引号去强行保留大小写,那样以后每一条 SQL 都必须带引号,极其痛苦。统一改成小写下划线命名是最省心的。

还有一个高频坑是保留字。PG 里userordergroupdescasclimitoffsetcheckdefaulttable这些都是保留字或者半保留字。表名sys_user带前缀所以安全,但如果你的业务表里有个字段叫order或者user,查询就会报语法错。解决办法只有一个:要么改名加前缀,要么每次用双引号包起来。我一般直接改名,比如order改成order_no,一劳永逸。

3.5 注释与索引

MySQL 的建表语句里注释是内嵌的:

`user_name` varchar(30) NOT NULL COMMENT '用户账号'

PG 不支持这种行内注释,要改成独立的语句:

COMMENT ON COLUMN sys_user.user_name IS '用户账号';

表注释同理,COMMENT ON TABLE sys_user IS '用户信息表';。RuoYi 的表注释挺全的,如果在意文档完整性就都补上,如果是内部项目不追求这个,注释可以先略过,但代码生成器依赖表注释来生成字段描述,所以你要是还用代码生成功能,注释就最好补上。

索引方面,MySQL 的KEY idx_x (col)UNIQUE KEYPRIMARY KEY写法要改成 PG 的:

CREATE INDEX idx_user_name ON sys_user(user_name); ALTER TABLE sys_user ADD CONSTRAINT uk_xxx UNIQUE(user_name);

外键约束 RuoYi 默认都不加,所以这里不用太操心。但如果你自己加了外键,注意 PG 的外键检查比 MySQL 严,删除数据时的顺序会影响执行结果。

还有个容易忽略的点:MySQL 的ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci这些尾部子句在 PG 里必须删掉。PG 的字符集和排序规则在建库时指定,不在建表时。建库推荐:

CREATE DATABASE ry WITH ENCODING = 'UTF8' LC_COLLATE = 'zh_CN.UTF-8' LC_CTYPE = 'zh_CN.UTF-8' TEMPLATE = template0;

如果服务器上没有中文排序规则,用C或者en_US.UTF-8也行,但中文排序可能不理想。

4. 代码与 SQL 层适配

表结构改完、数据导完,接下来是真正考验耐心的地方——框架里那些 MySQL 专属 SQL。这一章按类别列,都是我在实际项目里一条条改过的。

4.1 分页插件与 MyBatis 配置

RuoYi 老版本用 PageHelper 做分页。它的方言配置在application.yml或者pagehelper相关配置里:

pagehelper: helperDialect: postgresql reasonable: true supportMethodsArguments: true params: count=countSql

helperDialectmysql改成postgresql,这个不改分页 SQL 会生成limit 0, 10这种 MySQL 语法,PG 直接报错。实际上 PageHelper 的 PG 方言生成的是limit 10 offset 0,是对的。

如果你用的是 RuoYi-Vue-Plus 或者是新版的 MyBatis-Plus 分页,配置在MybatisPlusConfig里:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL)); return interceptor; }

DbTypeMYSQL改成POSTGRE_SQL。不改的话分页查询一样会生成 MySQL 版的limit

还有application.yml里的mybatis-plus.global-config.db-config.db-type,有的话改成postgresql

4.2 日期时间函数改写

这是重灾区。RuoYi 的 Mapper XML 里到处都是 MySQL 的日期函数,最典型的是date_format

比如按时间段查询用户,很多版本里写的是:

<if test="params.beginTime != null and params.beginTime != ''"> and date_format(u.create_time,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d') </if>

PG 里没有date_format,要改成to_char

and to_char(u.create_time, 'YYYYMMDD') >= to_char(#{params.beginTime}::timestamp, 'YYYYMMDD')

注意#{params.beginTime}传进来是字符串,PG 需要显式转成时间类型再做to_char,否则会报类型不匹配。或者更简单的写法是直接比较时间范围,避免字符串转换:

and u.create_time >= #{params.beginTime}::timestamp

我一般倾向于后者,因为to_char会把索引用不上,大表上性能差很多。原来 MySQL 那种写法本身就是个性能隐患,正好借这次迁移改掉。

其他常见函数对应关系:

MySQLPostgreSQL说明
date_format(d, '%Y%m%d')to_char(d, 'YYYYMMDD')格式串语法不同
sysdate()/now()now()PG 的now()返回事务开始时间
date_sub(now(), interval 1 day)now() - interval '1 day'写法差异
date_add(d, interval 7 day)d + interval '7 day'直接加减
unix_timestamp()extract(epoch from now())::bigint返回秒级时间戳
curdate()current_date当前日期
from_unixtime(x)to_timestamp(x)时间戳转时间

RuoYi 的登录日志、操作日志里,时间格式化用 Java 的DateUtils比较多,这部分不用动。纯 SQL 里遇到的按上面表换就行。

4.3 字符串与聚合函数

字符串函数这块,PG 大部分都有对应,但行为细节有差异。

concat(a, b)两边都有,但 PG 的concat会忽略 NULL,MySQL 的concat遇到 NULL 返回 NULL。这个差异会导致结果不一致。比如concat(remark, '-', user_name),如果remark是 NULL,MySQL 返回 NULL,PG 返回-张三。如果业务逻辑依赖这个行为,就要小心。建议用||拼接,||在两边遇到 NULL 都是返回 NULL,行为一致。

ifnull(a, b)在 PG 里没有,要用coalesce(a, b)。这个替换很直接,全局搜索替换即可。

group_concat在 PG 里是string_agg,而且必须指定分隔符:

-- MySQL select group_concat(user_name) from sys_user where dept_id = 100 -- PostgreSQL select string_agg(user_name, ',') from sys_user where dept_id = 100

substring_index(a, ',', 1)在 PG 里要用split_part(a, ',', 1)。RuoYi 里用到这个的地方主要在部门树和角色权限的字符串处理上。注意split_part的索引是从 1 开始的,跟substring_index一致,但split_part只支持取单段,不像substring_index可以取前 n 段。如果是取前 n 段,得用array_to_string((string_to_array(a, ','))[1:n], ',')

locate(a, b)在 PG 里用position(a in b)或者strpos(b, a)。注意参数顺序,这两个函数跟locate不太一样,容易写反。

replaceupperlowertrimlength这些两边都有,语义基本一致。但length在 PG 里对text返回字符数,对bytea返回字节数,MySQL 的length默认返回字节数(char_length才是字符数),用的时候留意一下。

4.4 find_in_set 与树形结构查询

这是个特别经典的坑。RuoYi 的部门表sys_dept有个ancestors字段,存的是祖先路径,比如0,100,101。查某个部门及其所有下级的时候,SQL 里会用:

find_in_set(#{deptId}, ancestors)

PG 完全没有find_in_set。最直接的改法是用数组:

#{deptId} = any(string_to_array(ancestors, ','))

这样写语义等价,性能也还行。如果数据量大,可以给ancestors加 GIN 索引,但前提是把字段类型改成text[]存数组,而不是逗号分隔字符串。那就涉及数据迁移了,不是简单替换能解决的。

还有一个替代写法是用like

ancestors like concat('%,', #{deptId}, ',%') or ancestors like concat(#{deptId}, ',%')

这个写法不好看,而且容易漏边界,我不推荐。string_to_arrayany干净得多。

RuoYi 里还有别的地方用了find_in_set,比如角色查部门数据权限的时候。全局搜一遍find_in_set,见一个改一个。

提示:PG 的any配合数组很好用,但要注意数组元素的类型。string_to_array返回text[]deptId如果是 bigint,比较时会自动转换,但为了稳妥可以显式#{deptId}::bigint = any(...)

4.5 插入语句与主键回填

RuoYi 的 Mapper 里有一些批量插入、insert ignoreon duplicate key update之类的写法。

insert ignore into是 MySQL 特有的,用来忽略重复主键错误。PG 里用:

insert into sys_config(...) values(...) on conflict do nothing

或者指定冲突列:

on conflict (config_key) do nothing

on duplicate key update在 PG 里是:

insert into sys_config(config_key, config_value) values(#{configKey}, #{configValue}) on conflict (config_key) do update set config_value = excluded.config_value

注意excluded是 PG 里的关键字,代表要插入的那一行。这个语法差异必须记住。

主键回填这块,RuoYi 的useGeneratedKeys="true" keyProperty="userId"写法在 PG 下是能用的,PG 的 JDBC 驱动在Statement.RETURN_GENERATED_KEYS时会自动追RETURNING *。但有个前提:表必须有 identity 或 serial 主键。如果你的表没设自增(比如用雪花 ID),那就不能靠回填,得在 Java 层手动赋值。RuoYi-Vue-Plus 用的是 MyBatis-Plus 的IdType.ASSIGN_ID,走的是雪花算法,本来就不依赖数据库自增,这块反而更省事。

4.6 代码生成器的隐藏工作量

这是我觉得最坑的一块,因为很多人根本没想到代码生成器也要改。

RuoYi 的代码生成器靠查询information_schema来获取表结构。它默认的 SQL 是 MySQL 方言:

select table_name, table_comment, create_time, update_time from information_schema.tables where table_schema = (select database())

这里有两个问题。第一,PG 里没有database()函数,要用current_schema()或者直接写'public'。第二,PG 的information_schema.tables没有table_comment这一列,表注释存在pg_catalog里,得用obj_description取。

改写成 PG 版本大概是这样:

select c.relname as table_name, obj_description(c.oid) as table_comment from pg_class c join pg_namespace n on n.oid = c.relnamespace where c.relkind = 'r' and n.nspname = 'public' order by c.relname

列信息的查询同理,PG 里查字段注释用col_description(attrelid, attnum),判断是否可空要处理 PG 返回的 boolean。RuoYi 原来的 SQL 里写了is_nullable = 'NO',PG 的is_nullable返回的是boolean类型,'NO'这个字符串根本比不了,得改成not is_nullable或者把查询结果显式转成字符串。

这段要彻底重写的话,工作量不小。如果你项目里根本不用代码生成功能(很多生产项目上手就把它删了),那可以直接跳过。但如果要用,就要把GenTableMapper.xmlGenTableColumnMapper.xml里的查询全部换成 PG 版本,字段类型映射那块也要改——MySQL 的varchar(30)和 PG 的character varying(30)字符串不一致,RuoYi 生成实体类时是按类型字符串做映射的,PG 返回的format_type结果是character varyingbiginttimestamp without time zone这种,得加一层映射逻辑。

5. 常见报错与排查速查

迁移过程中报错五花八门,但归纳起来就那么几十种。这一章把最高频的整理成表,再补几个我踩过之后印象特别深的。

5.1 报错对照表

报错信息根因解决
relation "dual" does not existDruid 校验语句用了 MySQL 的 DUAL改成SELECT 1
duplicate key value violates unique constraint序列没同步setval重置序列
column ... is of type integer but expression is of type character varying参数类型不匹配连接串加stringtype=unspecified,或 SQL 显式转型
function date_format(...) does not exist用了 MySQL 日期函数改成to_char
function find_in_set(...) does not exist部门树查询用了 MySQL 函数改成any(string_to_array(...))
function group_concat(...) does not exist聚合拼接用了 MySQL 函数改成string_agg
syntax error at or near "limit"分页方言没改helperDialectDbType
column "xxx" does not exist大小写问题或字段名错误统一小写,检查字段名
syntax error at or near ""`建表或 SQL 里残留反引号去掉反引号
null value in column ... violates not null constraint初始化数据缺字段,或序列/默认值问题补默认值或修正数据
could not determine data type of parameterMyBatis 传 null 参数时 PG 无法推断jdbcType或在连接串加stringtype=unspecified
operator does not exist: boolean = integerboolean 和数值混用SQL 显式转型或改字段类型
relation "QRTZ_..." does not existQuartz 表没建或大小写不对用 PG 版脚本,注意表名大小写

关于could not determine data type of parameter,这个特别值得说。MySQL 里传 NULL 参数它经常能蒙对,PG 不行,它会要求你明确类型。最常见的场景是where create_time > #{startTime}startTime是 null,MyBatis 把 null 传过去,PG 不知道这个 null 是什么类型,直接报错。解决办法是在 Mapper 里写#{startTime, jdbcType=TIMESTAMP},或者用<if test="startTime != null">包起来避免传空。RuoYi 里大部分动态 SQL 都有if判断,但总有漏网的。

5.2 上线前自查清单

迁移完别急着上线,按这个清单过一遍:

  • 所有pom.xml里的 MySQL 驱动是否清干净,有没有依赖传递带进来
  • 数据源 URL、驱动类、校验语句、大小写是否正确
  • 所有自增序列是否setval重置过,特别是初始化数据里显式插了 ID 的表
  • 全局搜索date_formatfind_in_setgroup_concatifnullsysdateinsert ignoreon duplicate key、反引号,确认无残留
  • PageHelper 或 MyBatis-Plus 的分页方言是否切换
  • Quartz 的 PG 脚本是否执行、代理类是否配置
  • 代码生成器的元数据查询是否适配(如果还要用)
  • 所有char(1)字段在 PG 里确认比较行为正常,特别是联合索引场景
  • 时间字段的时区行为在测试环境验证一下,插入和查询不要有 8 小时偏差
  • 用真实数据量跑一遍慢查询,PG 的执行计划逻辑跟 MySQL 不一样,原来靠索引的地方可能需要重建索引

5.3 我的几条实操心得

第一条,迁移顺序要反过来。很多人习惯先把数据导进去再改代码,结果一跑全是报错,分不清是表结构问题还是 SQL 问题。我的做法是:先用 PG 建一套空表,把框架跑起来,让所有查询 SQL 都能在没有数据的表上执行通过(返回空结果也行),确认语法层面没毛病,再导数据。这样问题定位简单得多。

第二条,stringtype=unspecified不是万能的。它解决了大部分参数类型问题,但也可能掩盖真正的类型错误。如果某个查询在加了它之后结果不对(不是报错,是结果不对),那就要警惕了。我遇到过varcharchar比较时因为类型推断导致索引失效,加了这个参数之后不报错了,但查询变成全表扫描。这种情况要单独分析执行计划。

第三条,数据导出导入用COPY而不是INSERT。PG 的COPY命令处理大批量数据快得多,而且对转义的处理跟 MySQL 不一样,直接用INSERT导几百万行日志表会很痛苦。用pg_dump从 MySQL 导出的 SQL 要经过转换才能用,推荐的做法是用工具(比如用 Java 写个临时导入程序,或者用COPY ... FROM STDIN配合 CSV)把数据转成 PG 能认的格式。

第四条,布尔值和char(1)的抉择要一次定死。项目里如果status字段有的用char(1)、有的用boolean,后期维护会非常痛苦。我倾向于全部保持varchar(1),跟原来的代码习惯兼容,改动最小,风险最低。等系统稳定运行一段时间,再考虑是否做面向 PG 的深度优化。

第五条,别忘了 Docker 环境。热词里有ruoyi radius docker这种关键词,说明不少人是容器化部署。容器里换库要注意:JDBC 连接串里的主机名从mysql改成postgresql(取决于 compose 里的服务名),数据卷要重新挂载,docker-compose.yml里的健康检查命令mysqladmin ping要改成pg_isready。这些看起来是小事,但第一次做不熟的话,光调试「容器起来了但应用连不上」就能耗掉一下午。

还有一个坑我单独拎出来说:PG 的默认事务隔离级别和 MySQL 不一样。MySQL 默认REPEATABLE READ,PG 默认READ COMMITTED。RuoYi 的很多业务代码是依赖 MySQL 的可重复读语义跑起来的,比如先查再改再查,在 PG 的读已提交下可能读到不一样的结果。虽然大部分场景没问题,但如果你的业务里有并发扣库存、并发更新状态这类逻辑,迁移后要重点测一下。真遇到问题,可以通过spring.datasource.hikari.transaction-isolation或者连接参数调整,但更根本的做法是让业务代码不依赖数据库的隔离级别特性。

这一路迁下来,我的整体感受是:RuoYi 换 PG 不是「改配置」级别的活,而是「把脚手架里所有 MySQL 暗语翻译一遍」的活。翻译工作量跟项目复杂度成正比,最省事的路径是先用最小可用集(登录、菜单、用户管理)跑通,再逐个模块推进。真把这一遍走完,你对 RuoYi 的 SQL 分布会有全新的认识,以后再遇到任何数据库适配问题,心态都会稳很多。

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

MySQL在Windows上的完整安装配置指南:从下载到排错

说实话&#xff0c;我见过太多人在MySQL上栽跟头了。有人从网上随便找了个安装包&#xff0c;一路Next装完&#xff0c;结果打开命令行一闪而过&#xff1b;有人好不容易装好了&#xff0c;写代码连库却报Access denied&#xff1b;还有人把数据库折腾了一整天&#xff0c;最后…

作者头像 李华
网站建设 2026/9/18 12:04:16

防窥膜行业研究报告自动化:Python数据流水线与PPTX生成

简介&#xff1a;这份防窥膜行业研究PPT面向市场分析人员、企业战略与投资决策者&#xff0c;以及关注消费电子功能膜赛道的从业者&#xff0c;可用于快速了解行业格局、梳理竞争要素并辅助项目论证。内容围绕防窥膜的定义与工作原理展开&#xff0c;依次覆盖中国防窥膜行业发展…

作者头像 李华
网站建设 2026/9/18 12:00:07

ANSYS CFX自定义函数数据导入全指南:路径、插值与USERSUB实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 12:00:03

Visual Studio中C#开发必备快捷键:从补全到调试重构

写代码这件事&#xff0c;真正拉开效率差距的往往不是打字速度&#xff0c;而是右手离开键盘去摸鼠标的次数。我见过不少C#开发者在Visual Studio里写代码时&#xff0c;光标在类和方法之间跳转全靠鼠标点&#xff0c;智能提示没弹出来就用鼠标去点菜单&#xff0c;调试时断点加…

作者头像 李华