Ruoyi 从 MySQL 迁 PostgreSQL 的隐藏坑与改造清单
2026/9/18 12:49:52 网站建设 项目流程

1. 改完驱动和连接串之后,真正的麻烦才刚开始

很多团队对"数据库迁移"这件事的心理预期是:改几行配置,重启,跑通,收工。我上个月帮一个朋友的运维后台做这件事时也是这么想的——Ruoyi 这套框架的表结构规范、SQL 集中、MyBatis 映射清晰,看起来是个非常好迁移的样本。结果是从下午两点折腾到第二天中午,中间经历了登录接口 500、部门树查不出来、定时任务全部卡死、代码生成页面白屏四个阶段。

这篇文章不讲"数据库迁移的意义"这种废话,只讲现象、原因和改法。核心结论先摆在前面:Ruoyi 从 MySQL 切到 PostgreSQL,配置文件的改动量不到整个工作量的 10%,剩下 90% 分布在建表脚本、MyBatis XML、代码生成模块的自省 SQL、以及一批"MySQL 帮你兜住了但 PG 不兜"的隐式行为上。如果你手上正好有这么一套系统,或者只是想把 Ruoyi 当成练手项目跑在 PG 上,下面这些坑你大概率一个都躲不掉。

1.1 三处配置改完之后,登录接口为什么还是 500

先说我当时改了什么,这也是任何迁移都会先动手的地方。

pom.xml里把 MySQL 驱动换掉,注意 Spring Boot 的依赖管理里对两个驱动都有版本托管,所以不用自己写版本号:

<!-- 移除 --> <!-- <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> --> <!-- 换成 --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> </dependency>

然后是数据源,Ruoyi 用的是 Druid,配置集中在application-druid.yml

spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driverClassName: org.postgresql.Driver druid: master: url: jdbc:postgresql://127.0.0.1:5432/ruoyi?currentSchema=public&stringtype=unspecified username: ruoyi password: 你的密码 initialSize: 5 minIdle: 10 maxActive: 20 validationQuery: SELECT 1

注意validationQuery这一行。Ruoyi 原版写的是SELECT 1 FROM DUAL,MySQL 支持FROM DUAL这种 Oracle 风格的写法,但 PostgreSQL 里根本没有dual这张表,启动时会报relation "dual" does not exist。这个错误的迷惑性在于它出现在连接池初始化阶段,日志里刷的是一堆连接创建失败,你很容易误以为是账号密码或者 pg_hba 配置问题,实际上改一个字就完事。

stringtype=unspecified这个参数不是必需的,但如果你的业务表里有json/jsonb字段,或者用了 PG 特有的类型,不加它就会遇到column "xxx" is of type jsonb but expression is of type character varying这类报错。它的作用是把 JDBC 的字符串参数以"未指定类型"的方式传给服务端,让 PG 自己去做类型推断。

第三处是分页插件。Ruoyi 的application.yml里有:

pagehelper: helperDialect: mysql supportMethodsArguments: true params: count=countSql

helperDialect必须改成postgresql。这个值决定了 PageHelper 生成的分页 SQL 长什么样:mysql方言生成LIMIT ?, ?,而 PostgreSQL 只认LIMIT ? OFFSET ?,不改的话所有startPage()开头的列表查询会直接抛语法错误。

1.2 一张改动范围对照表,先看清战场

我习惯在动手前先把改动面列清楚,避免改到一半发现漏了一层。这张表是我这次实际改过的东西:

层次需要改动的内容不改的后果
依赖MySQL 驱动换 PostgreSQL 驱动启动即报找不到驱动类
连接池URL、驱动类名、validationQuery连接池初始化失败
建表脚本自增、注释、引擎、字符集语法脚本根本跑不起来
分页helperDialect所有分页查询报语法错
MyBatis XMLfind_in_setdate_formatifnull等函数相关功能静默返回空或报错
代码生成模块自省 SQL 与类型映射代码生成页白屏或生成错误实体
定时任务Quartz 表脚本任务表建不起来或任务不执行
数据搬运序列重置、时间精度、空值处理新增数据主键冲突
业务语义模糊查询大小写、排序 NULL 位置功能不报错但结果不对

最后一行是最阴的。前面几项出错至少会给你一个红字日志,而业务语义层面的差异,表现是"查出来的数据顺序变了""搜 Admin 搜不到 admin 用户",测试用例覆盖不到就等着上线后被用户投诉。

1.3 建库这一步别偷懒,编码和排序规则都要定

PostgreSQL 的库一旦建好,ENCODINGLC_COLLATE想改基本等于重建库,所以建库的时候就要定清楚:

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

为什么用template0?因为template1可能已经被改过编码或者装了扩展,用它做模板会导致新库继承一堆你不需要的东西,甚至直接建不出来。为什么排序规则用en_US.UTF-8而不是某种中文 locale?因为 locale 主要影响字符串比较和排序,en_US.UTF-8在大多数场景下行为稳定、性能可控,中文按 Unicode 码位排序,对后台管理系统够用。如果你的业务对中文姓名排序有强需求,那要单独考虑,但那是应用层排序的问题,不该让数据库 locale 来背。

2. 建表脚本的方言翻译:正则替换一定会翻车

Ruoyi 的sql目录里通常会放不同数据库版本的初始化脚本,我手上的版本里确实带了 PostgreSQL 版本,但有两个现实问题:一是脚本版本可能落后于代码(后来加的表、加的字段没有同步);二是即便表建好了,代码生成模块和业务 XML 里的 SQL 仍然是 MySQL 方言。所以实际做法通常是:以 MySQL 脚本为基准,人工翻译一遍,翻译过程中把每一处差异记下来。

2.1 自增主键:serial、identity 和序列断档

MySQL 的写法:

CREATE TABLE sys_user ( user_id bigint(20) NOT NULL AUTO_INCREMENT, ... PRIMARY KEY (user_id) ) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';

PG 里我推荐用标准的 identity 列,而不是老式的serial

CREATE TABLE sys_user ( user_id bigint GENERATED BY DEFAULT AS IDENTITY, ... PRIMARY KEY (user_id) );

serial的本质是"建一个序列 + 把列默认值设成nextval",identity是 SQL 标准语法,语义更清楚,权限管理也更规范。两者都能被 MyBatis 的useGeneratedKeys接住,区别在于serial创建的序列名是<表名>_<列名>_seq,很多运维脚本会硬编码这个命名,换identity之后序列名规则变了,需要留意。

这里有个必须提前想清楚的问题:GENERATED BY DEFAULT还是GENERATED ALWAYSALWAYS意味着除了OVERRIDING SYSTEM VALUE之外不允许手写主键值,从 MySQL 搬历史数据时会非常难受,因为你需要带着原来的 ID 导入。所以迁移场景下选BY DEFAULT,等数据全部搬完、验证通过之后再考虑要不要收紧。

真正的坑在数据导入之后。用COPY或者工具把带 ID 的历史数据灌进去,序列本身并不知道当前最大值在哪,下一次插入还是会从 1 开始,直接主键冲突。补救语句:

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

pg_get_serial_sequence的好处是它同时兼容serialidentity,不用你手动拼序列名。这张表导完就要执行一次,别等报错了才想起来。

2.2 必须删掉的 MySQL 专有装饰:反引号、ENGINE、内联 COMMENT

MySQL 脚本里有一堆"装饰性语法"在 PG 里是硬错误,我把遇到的处理方式列一下:

-- MySQL `user_id` bigint(20) NOT NULL COMMENT '用户ID', `user_name` varchar(30) DEFAULT '' COMMENT '用户账号', -- PostgreSQL user_id bigint NOT NULL, user_name varchar(30) DEFAULT '',

反引号在 PG 里要全部去掉,或者换成双引号,但强烈建议直接去掉。原因是 PG 对未加引号的标识符会统一折叠成小写,加了双引号之后大小写就被锁定,"user_name"user_name在 PG 眼里是两个不同的列,一旦哪个地方漏了引号,报错信息会非常费解。

ENGINE=InnoDBAUTO_INCREMENT=100DEFAULT CHARSET=utf8mb4COLLATE=utf8mb4_general_ci这些统一删掉。字符集和排序规则在 PG 里是库级别和列级别的概念,用CREATE DATABASE时的设置或者COLLATE "en_US.UTF-8"实现,不需要逐表声明。

注释要用独立的COMMENT ON语句:

COMMENT ON TABLE sys_user IS '用户信息表'; COMMENT ON COLUMN sys_user.user_name IS '用户账号'; COMMENT ON COLUMN sys_user.status IS '账号状态(0正常 1停用)';

表不多的话手写就行,表多了可以拿工具生成。这里有个小提醒:COMMENT ON的注释信息对 Ruoyi 是有实际用处的——代码生成模块会读取表注释和列注释,生成实体类上的 Swagger 注解和前端表单标签。所以不要因为麻烦就把注释丢了,否则生成的代码会变成一堆userName没有中文说明。

int(11)bigint(20)这种带显示宽度的写法也要清理。PG 的整型只有smallintintegerbigint三种,括号里的数字对它毫无意义。但注意tinyint(1)要单独判断,它在 MySQL 里经常被当作布尔值用,而在 PG 里你需要决定它到底该映射成smallint还是boolean。我的建议是:如果原代码里用 0/1 做判断,就老老实实映射成smallint,别为了"看起来优雅"改成boolean,因为 MyBatis 传Integer参数给 boolean 列会直接报类型错误,改一列要牵动一串 Java 代码和前端逻辑。

2.3 时间列的默认值和"自动更新"没了

MySQL 里很常见的这行:

create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

搬到 PG 之后,ON UPDATE CURRENT_TIMESTAMP这个特性完全不存在,PG 没有列级别的自动更新时间戳功能(虽然新版本有GENERATED ... AS但那是计算列,不能引用now())。你有两个选择:

第一个选择,也是最省事的:不依赖数据库,让代码来维护。Ruoyi 的实体基类本身就有createTimeupdateTime,MyBatis 的 insert/update 语句里也会显式赋值,所以大多数业务表实际上根本不需要数据库层面的自动更新,把ON UPDATE删掉即可。

第二个选择:写触发器。

CREATE OR REPLACE FUNCTION trg_set_update_time() RETURNS trigger AS $$ BEGIN NEW.update_time := now(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_sys_user_update_time BEFORE UPDATE ON sys_user FOR EACH ROW EXECUTE FUNCTION trg_set_update_time();

我的经验是,除非你有明确的理由(比如多个系统共用一个库,不能保证所有写入方都维护时间字段),否则不要上触发器。触发器会让排查问题时多一层不可见的逻辑,而且批量更新时性能损耗不小。

还有个细节容易被忽略:时间精度。MySQL 的datetime默认是 0 位小数(精确到秒),PG 的timestamp默认是微秒精度。从 MySQL 搬过来的数据都是整秒,问题不大,但新写入的数据会带微秒。如果前端展示时不格式化,可能会出现2024-06-01 12:00:00.123456这种尴尬内容;更麻烦的是如果你有"按秒比对时间"的逻辑,会出现明明看着相等却不相等的情况。稳妥做法是建表时统一写timestamp(0),把精度卡死在秒,和原来的语义对齐:

create_time timestamp(0) DEFAULT now(), update_time timestamp(0) DEFAULT now(),

3. MyBatis XML 里的 MySQL 函数,一条一条揪出来

这一步是整个迁移里最耗时间也最容易漏的。Ruoyi 的 Mapper XML 数量不少,好在用到的 MySQL 专有函数种类有限,我最后是靠在项目里全局搜关键词来保证覆盖率的。

3.1 find_in_set:部门树查询的重灾区

先说我踩得最惨的一个。Ruoyi 的部门表sys_dept有个ancestors字段,存的是"祖级列表",形如0,100,101。部门列表查询和子部门更新里都用到了find_in_set

<if test="deptId != null and deptId != 0"> AND (dept_id = #{deptId} OR find_in_set(#{deptId}, ancestors)) </if>

PostgreSQL 没有find_in_set这个函数,也没有等价的直接替换。改法我给两种,各有适用场景:

-- 写法一:先把逗号串拆成数组,再用 ANY 判断 AND (dept_id = #{deptId} OR #{deptId}::text = ANY (string_to_array(ancestors, ','))) -- 写法二:两头补逗号做子串匹配,不需要类型转换 AND (dept_id = #{deptId} OR position(',' || #{deptId} || ',' in ',' || ancestors || ',') > 0)

写法一更直观,但要注意类型:ancestors是字符串,拆分后是text[],而#{deptId}传进来通常是Long,必须显式转成text再比较,否则会报operator does not exist: text = bigint。写法二不需要类型转换,因为||拼接时会自动把数字转字符串,但缺点是用不上索引,数据量大时全表扫描。

我最终选了写法一,并且在ancestors上建了一个 GIN 索引来配合数组匹配。但说实话,几千条部门数据的量级,怎么写都无所谓,别在这上面纠结太久。

3.2 date_format 到 to_char:时间范围查询的改写

Ruoyi 的用户列表、操作日志、登录日志这几个查询里都有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 里对应的是to_char,但格式串的写法完全不同:

<if test="params.beginTime != null and params.beginTime != ''"> AND to_char(u.create_time, 'YYYYMMDD') &gt;= to_char(#{params.beginTime}::timestamp, 'YYYYMMDD') </if>

两个注意点。第一,PG 的to_char格式模板里YYYY是四位年、YY是两位年,和 MySQL 的%y/%Y对应关系别搞混,写错了不会报错,只是比较结果莫名其妙。第二,#{params.beginTime}传进来是字符串,必须显式::timestamp转换,否则 PG 会报找不到to_char(character varying, unknown)这样的函数。

其实我更推荐直接改成时间范围比较,把函数去掉:

AND u.create_time &gt;= #{params.beginTime}::timestamp AND u.create_time &lt; (#{params.beginTime}::timestamp + interval '1 day')

这样能用上create_time上的索引,比to_char之后再比较快得多——后者对每一行都要做一次函数计算,属于典型的"索引失效"写法。这个优化点在 MySQL 里其实也存在,只是原来没人管。

3.3 一份高频函数替换对照表

我把这次实际改到的函数整理成了一张表,基本覆盖 Ruoyi 会碰到的场景:

MySQL 写法PostgreSQL 写法备注
IFNULL(a, b)COALESCE(a, b)COALESCE 支持多个参数
DATE_FORMAT(t, '%Y-%m-%d')TO_CHAR(t, 'YYYY-MM-DD')格式模板不同
NOW()/SYSDATE()NOW()PG 里 SYSDATE 不存在
CURDATE()CURRENT_DATE注意没有括号
GROUP_CONCAT(x)STRING_AGG(x, ',')必须指定分隔符
CONCAT(a, b, c)a || b || cCONCAT(...)PG 9.1+ 支持 CONCAT
LIMIT 10, 20LIMIT 20 OFFSET 10参数顺序反的,别搞错
INSERT IGNOREON CONFLICT DO NOTHING需要唯一约束配合
ON DUPLICATE KEY UPDATEON CONFLICT (col) DO UPDATE SET必须指定冲突列
UNIX_TIMESTAMP(t)EXTRACT(EPOCH FROM t)返回类型是 numeric
SUBSTRING_INDEX(s, ',', 1)split_part(s, ',', 1)语义基本一致
DATE_ADD(t, INTERVAL 1 DAY)t + interval '1 day'运算符形式

LIMIT那一行我要单独强调:MySQL 是LIMIT 偏移量, 行数,PG 是LIMIT 行数 OFFSET 偏移量,两个参数的位置是反的。如果只是把LIMIT #{offset}, #{rows}机械替换成LIMIT #{offset} OFFSET #{rows},语法不报错但结果全错,而且错得很有规律——第一页看起来正常,翻到第二页就开始重复或跳数据,特别容易在测试阶段漏过去。

3.4 事务 aborted:批量操作里最不直观的失败

这个坑和函数无关,但杀伤力最大。PostgreSQL 有一个 MySQL 没有的行为:在一个事务里,只要有一条语句报错,整个事务就被标记为 aborted 状态,后续所有语句一律拒绝执行,直到你显式ROLLBACK。报错长这样:

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

为什么这个在 Ruoyi 里特别容易撞上?因为很多业务代码是这种结构:在一个@Transactional方法里循环处理一批数据,某一条失败被try-catch吞掉,继续处理下一条。在 MySQL 下这没问题,失败的那条不影响其他条;在 PG 下,第一条报错之后,剩下的全部因为事务处于 aborted 状态而失败,而错误日志里刷的是那句看不懂的 "commands ignored",真正的根因(第一条报错的详细内容)反而被淹没了。

正确的处理方式是给每条记录加独立的保存点,或者干脆让循环里每一条都跑在独立事务里:

// 用保存点隔离单条失败 for (SysUser user : list) { Savepoint sp = TransactionAspectSupport.currentTransactionStatus() .createSavepoint(); try { userMapper.insertUser(user); } catch (Exception e) { TransactionAspectSupport.currentTransactionStatus() .rollbackToSavepoint(sp); log.error("单条导入失败: {}", user.getUserName(), e); } }

排查这个问题的技巧是:看日志时不要只看最后一句,往上翻找第一条ERROR,那条才是真正的病因。

4. 代码生成模块:information_schema 在 PG 上是另一套东西

Ruoyi 的代码生成器是从数据库的元数据表里读取表结构和字段信息,然后用 Velocity 模板生成 Java、XML、Vue 代码。这块的 SQL 全部写在GenTableMapper.xmlGenTableColumnMapper.xml里,是 MySQL 方言,迁移时必然要重写。

4.1 database() 函数和 current_schema() 的区别

MySQL 版本长这样:

select table_name, table_comment, create_time, update_time from information_schema.tables where table_schema = (select database()) and table_name not like 'qrtz_%' and table_name not in (select table_name from gen_table)

database()是 MySQL 专有函数,返回当前连接的库名。PG 里没有这个函数,取而代之的是current_schema()(返回当前 search_path 下的第一个 schema,通常是public)。所以条件改成:

where table_schema = current_schema()

这里有个隐含前提:你的表确实建在public下。如果建在自定义 schema 里,要么改连接串里的currentSchema参数,要么把current_schema()换成硬编码的 schema 名。

4.2 列注释和主键标记,得去 pg_catalog 里捞

information_schema.columns这张视图在 PG 里是存在的,但它不提供注释信息,也不直接告诉你哪个列是主键。MySQL 版本的 SQL 依赖column_commentcolumn_keyextra这三个字段,PG 一个都没有。所以这段只能重写成查系统目录:

select a.attname as column_name, col_description(a.attrelid, a.attnum) as column_comment, format_type(a.atttypid, a.atttypmod) as column_type, a.attnotnull as is_required, (select count(*) from pg_index i where i.indrelid = a.attrelid and a.attnum = any(i.indkey) and i.indisprimary) as is_pk, (pg_get_expr(ad.adbin, ad.adrelid) like 'nextval%') as is_increment from pg_attribute a join pg_class c on c.oid = a.attrelid join pg_namespace n on n.oid = c.relnamespace left join pg_attrdef ad on ad.adrelid = a.attrelid and ad.adnum = a.attnum where n.nspname = current_schema() and c.relname = #{tableName} and a.attnum > 0 and not a.attisdropped order by a.attnum

几个关键点解释一下。col_description是读取列注释的标准函数,配合COMMENT ON COLUMN写入的注释使用,所以前面建表时坚持写注释这一步在这里得到了回报。format_type会把类型连同长度一起返回,比如character varying(30)numeric(10,2),比information_schemadata_type更好用,因为它保留了精度信息,生成实体类时能直接判断。is_pk用子查询判断列是否在任意一个主键索引里,不能只看pg_index.indisprimary而不加any(indkey)的关联条件。is_increment这里是靠判断默认值表达式是否以nextval开头,能覆盖serial,对identity列则要走另一套判断(attidentity字段不为空)。

4.3 data_type 值域不同,类型映射逻辑要顺手改掉

即便字段查出来了,还有第二个问题:Ruoyi 的GenUtils里有一段按数据库类型字符串判断 Java 类型的逻辑,它认识的类型名是 MySQL 那一套。PG 返回的类型名不一样,对照关系大致是:

MySQLPostgreSQL 返回Java 类型
varchar/charcharacter varying/characterString
intintegerInteger
bigintbigintLong
datetime/timestamptimestamp without time zoneDate
decimalnumericBigDecimal
tinyintsmallintInteger
texttextString
blobbyteabyte[]

如果你不改代码里的判断逻辑,生成的实体类字段类型会退化成默认值(通常是 String),编译能过但运行起来类型不匹配,反而更难查。我的做法是在GenUtils里加一层前置转换,把 PG 的类型名先映射回内部统一的标识,再走原来的判断分支,这样改动最小。

另外提醒一句:代码生成生成的 Mapper XML 模板里,默认带的是 MySQL 的find_in_set、反引号包裹的列名、以及limit分页片段。也就是说你辛辛苦苦把生成器改通了,生成出来的代码还要再过一遍,把模板里这些方言也改掉,否则下次生成新模块又要重新踩一遍。

5. 不报错但结果不对的几个隐藏差异

这一节的内容全部属于"装死型"问题:程序跑得好好的,日志干干净净,但业务数据就是不对。

5.1 LIKE 的大小写敏感,把用户唯一性撞没了

MySQL 默认的排序规则是xxx_general_ci,结尾的ci就是 case insensitive,所以LIKE '%admin%'LIKE '%ADMIN%'结果一样,user_name = 'Admin'能匹配到admin。PostgreSQL 的LIKE大小写敏感的。

这个差异在 Ruoyi 里会带来两个连锁反应。第一,用户登录:如果数据库里存的是admin,用户在登录框里输入Admin,MySQL 下能进去,PG 下直接"用户名或密码错误"。第二,模糊搜索:搜索框里输入大写字母就搜不到东西,用户会觉得"这系统怎么时好时坏"。

处理方式取决于你对业务语义的判断:

-- 方式一:用 ILIKE,只在需要忽略大小写的查询里改 where user_name ilike '%' || #{userName} || '%' -- 方式二:给列装 citext 扩展,让整列都大小写不敏感 CREATE EXTENSION IF NOT EXISTS citext; ALTER TABLE sys_user ALTER COLUMN user_name TYPE citext;

方式一改动可控,但要求你把每个模糊查询都过一遍,容易漏。方式二一劳永逸,代价是citext列的索引和比较行为与普通varchar不同,而且需要数据库层面有装扩展的权限。我最终选的是方式一,并且专门写了一条 SQL 检查清单:把项目里所有like concat(...)的地方列出来逐一确认。

顺带说一句,用户名重复注册这个场景也要检查。原来依赖"唯一索引 + 大小写不敏感"挡住的两个用户名,在 PG 下会变成两个合法用户,这是安全问题,不是体验问题。

5.2 ORDER BY 里 NULL 排到哪边去了

MySQL 排序时认为 NULL 最小,ORDER BY sort ASC时 NULL 排最前面。PostgreSQL 默认认为 NULL 最大,同样的语句 NULL 会排到最后。后台管理系统里sortorder_num这类排序字段经常有 NULL 值,排序变化会直接改变菜单顺序、字典项顺序。

要显式声明:

ORDER BY order_num ASC NULLS FIRST

或者建表时就给排序字段加上DEFAULT 0+NOT NULL,从源头上消灭 NULL。我更倾向于后者,排序字段本来就不该允许为空。

5.3 类型隐式转换变严格:status = 0突然报错

MySQL 会做非常宽松的隐式类型转换,where status = 0statusvarchar也能跑。PostgreSQL 严格得多,这种比较会直接抛:

ERROR: operator does not exist: character varying = integer

Ruoyi 里大量使用char(1)存状态(0正常、1停用),前端传参可能是数字、可能是字符串。原来没问题,切到 PG 之后动态 SQL 里的某个条件就会炸。解法是在 XML 里显式转换,或者统一参数类型:

<if test="status != null and status != ''"> AND status = #{status}::text </if>

同类问题还有boolean列接收Integer参数、numeric列接收字符串。排查方法很土但很有效:把 PG 的日志级别调高,把所有报operator does not exist的语句收集起来,一次性改完。

5.4 Quartz 表:blob 要换成 bytea,表名别加引号

Ruoyi 的定时任务用的是 Quartz 持久化存储,qrtz_*那一组表必须自己建。Quartz 官方提供了 PostgreSQL 版本的建表脚本,直接拿来用比手工翻译 MySQL 版本靠谱得多。手工翻译最容易错两处:MySQL 的blob在 PG 里要写byteaqrtz_locks这类表如果没有主键,Quartz 的集群模式下会报锁定失败。

还有一个特别隐蔽的坑:建表时千万不要给表名加双引号。如果你写了CREATE TABLE "QRTZ_LOCKS",PG 会严格保留大写,而 Quartz 内部执行 SQL 时用的是不加引号的QRTZ_LOCKS,会被折叠成小写qrtz_locks,结果就是"表明明建了却找不到"。

6. 数据搬家与上线验证的实际操作顺序

前面都是改造,这一节讲怎么把数据安全地挪过去、怎么确认没挪丢。

6.1 三种搬数据方式,我最后选了哪种

我试过三种方式,说说各自的实际情况。

第一种是pgloader,命令行一键从 MySQL 拉数据到 PG,会自动做类型映射。优点是快,几十万行几分钟搞定;缺点是它的类型映射有自己的想法,tinyint(1)默认给转成booleandatetime精度处理也和预期不一样,而且出错时的报错信息比较难定位到具体行。适合表结构简单、数据量大的场景。

第二种是图形化工具的"数据传输"功能(Navicat、DBeaver 之类)。优点是能可视化地逐表确认字段映射,出问题能暂停;缺点是表多了点鼠标点到手酸,而且大表容易超时。适合表数量不多、需要人工核对映射的场景。

第三种是导出 CSV 再用COPY导入:

# MySQL 侧导出 mysql -h127.0.0.1 -uroot -p -e " SELECT user_id, user_name, nick_name, status FROM ruoyi.sys_user " --batch --raw > sys_user.tsv # PostgreSQL 侧导入 psql -h127.0.0.1 -U ruoyi -d ruoyi -c " COPY sys_user(user_id, user_name, nick_name, status) FROM '/tmp/sys_user.tsv' WITH (FORMAT text, NULL 'NULL'); "

这种方式最可控,每一列都在你眼皮底下,字段类型不匹配会立刻报错并指出行号。缺点是表多的时候脚本要写一堆,而且要注意NULL值的表示——MySQL 导出时 NULL 会写成\NCOPYNULL选项要对应上,否则空值会变成字符串 "NULL" 存进库里,这种脏数据事后极难清理。我最后是混合用的:核心业务表(用户、角色、菜单、字典)用第三种,日志类大表用第一种。

6.2 导完之后必须做的四项复核

数据导入完成只是开始,下面四件事我是当成上线前的硬性检查项来做的:

第一项,序列重置。前面提过,每张有自增主键的表都要执行一次setval。漏一张表,对应的功能在新增数据时就炸。可以写个脚本批量生成这些语句:

SELECT 'SELECT setval(''' || pg_get_serial_sequence(table_name, column_name) || ''', COALESCE((SELECT MAX(' || column_name || ') FROM ' || table_name || '), 1));' FROM information_schema.columns WHERE table_schema = current_schema() AND column_default LIKE 'nextval%';

把查出来的语句批量执行一遍,比一张张表手工确认靠谱。

第二项,行数比对。每张表在 MySQL 和 PG 各查一次count(*),对不上的表要立即查原因。通常对不上是因为导入中途报错被跳过了,或者某些行的主键冲突被ON CONFLICT DO NOTHING静默忽略了——后者尤其危险,因为它不报错。

第三项,时间字段抽查。随机抽几张表,把create_time的最大值、最小值、以及某几条具体记录拿出来对比。重点看有没有出现时区偏移 8 小时的情况。如果出现了,检查三处:JVM 的默认时区、Druid 的connectionInitSql里是否设置了时区、以及连接串里有没有类似options=-c%20TimeZone=Asia/Shanghai的配置。

第四项,功能冒烟。我列的清单是:登录(含大小写不同的用户名)、菜单树加载、用户列表分页翻到第二页、按时间范围筛选日志、部门树展开、角色分配、定时任务手动执行一次、代码生成页面打开并导入一张表、Excel 导出。这九项能覆盖住绝大多数被迁移影响的路径。

6.3 灰度与回滚的准备

数据库迁移最难的地方在于回滚成本高。我的做法是:原 MySQL 库在整个验证期内保持只读但完整,不删、不停、不清理,直到新库稳定运行两周。这期间如果 PG 侧出现无法快速定位的问题,把应用配置切回 MySQL 只需要改一行 URL 加重启,五分钟能恢复业务,心理压力小很多。

另外一个细节是 Druid 的连接池参数。PG 默认的max_connections是 100,而 Ruoyi 的 Druid 配置里maxActive如果照抄 MySQL 那套设成 50、100,多层部署加起来很容易把连接数打满,报出remaining connection slots are reserved这种错误。单实例建议maxActive控制在 20 以内,minIdle设 5 到 10 就够,PG 的连接开销比 MySQL 大,连接池不是越大越好。

最后分享一个我们上线之后才补上的小细节:PG 的表膨胀问题。Ruoyi 里大量使用逻辑删除(del_flag),删除操作只是把标记位改掉,物理行还在;加上频繁的 UPDATE,PG 的 MVCC 机制会产生死元组,靠 autovacuum 后台清理。如果业务更新特别频繁,某几张表的膨胀率会很高,查询越来越慢。上线后我加了个定时任务,每周跑一次VACUUM ANALYZE对核心表做维护,顺便用pg_stat_user_tables里的n_dead_tup字段监控膨胀情况。这是 MySQL 时代完全不需要考虑的运维动作,但到了 PG 就是常规操作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询