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=countSqlhelperDialect必须改成postgresql。这个值决定了 PageHelper 生成的分页 SQL 长什么样:mysql方言生成LIMIT ?, ?,而 PostgreSQL 只认LIMIT ? OFFSET ?,不改的话所有startPage()开头的列表查询会直接抛语法错误。
1.2 一张改动范围对照表,先看清战场
我习惯在动手前先把改动面列清楚,避免改到一半发现漏了一层。这张表是我这次实际改过的东西:
| 层次 | 需要改动的内容 | 不改的后果 |
|---|---|---|
| 依赖 | MySQL 驱动换 PostgreSQL 驱动 | 启动即报找不到驱动类 |
| 连接池 | URL、驱动类名、validationQuery | 连接池初始化失败 |
| 建表脚本 | 自增、注释、引擎、字符集语法 | 脚本根本跑不起来 |
| 分页 | helperDialect | 所有分页查询报语法错 |
| MyBatis XML | find_in_set、date_format、ifnull等函数 | 相关功能静默返回空或报错 |
| 代码生成模块 | 自省 SQL 与类型映射 | 代码生成页白屏或生成错误实体 |
| 定时任务 | Quartz 表脚本 | 任务表建不起来或任务不执行 |
| 数据搬运 | 序列重置、时间精度、空值处理 | 新增数据主键冲突 |
| 业务语义 | 模糊查询大小写、排序 NULL 位置 | 功能不报错但结果不对 |
最后一行是最阴的。前面几项出错至少会给你一个红字日志,而业务语义层面的差异,表现是"查出来的数据顺序变了""搜 Admin 搜不到 admin 用户",测试用例覆盖不到就等着上线后被用户投诉。
1.3 建库这一步别偷懒,编码和排序规则都要定
PostgreSQL 的库一旦建好,ENCODING和LC_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 ALWAYS。ALWAYS意味着除了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的好处是它同时兼容serial和identity,不用你手动拼序列名。这张表导完就要执行一次,别等报错了才想起来。
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=InnoDB、AUTO_INCREMENT=100、DEFAULT CHARSET=utf8mb4、COLLATE=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 的整型只有smallint、integer、bigint三种,括号里的数字对它毫无意义。但注意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 的实体基类本身就有createTime和updateTime,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') >= 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') >= 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 >= #{params.beginTime}::timestamp AND u.create_time < (#{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 || c或CONCAT(...) | PG 9.1+ 支持 CONCAT |
LIMIT 10, 20 | LIMIT 20 OFFSET 10 | 参数顺序反的,别搞错 |
INSERT IGNORE | ON CONFLICT DO NOTHING | 需要唯一约束配合 |
ON DUPLICATE KEY UPDATE | ON 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.xml和GenTableColumnMapper.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_comment、column_key、extra这三个字段,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_schema的data_type更好用,因为它保留了精度信息,生成实体类时能直接判断。is_pk用子查询判断列是否在任意一个主键索引里,不能只看pg_index.indisprimary而不加any(indkey)的关联条件。is_increment这里是靠判断默认值表达式是否以nextval开头,能覆盖serial,对identity列则要走另一套判断(attidentity字段不为空)。
4.3 data_type 值域不同,类型映射逻辑要顺手改掉
即便字段查出来了,还有第二个问题:Ruoyi 的GenUtils里有一段按数据库类型字符串判断 Java 类型的逻辑,它认识的类型名是 MySQL 那一套。PG 返回的类型名不一样,对照关系大致是:
| MySQL | PostgreSQL 返回 | Java 类型 |
|---|---|---|
varchar/char | character varying/character | String |
int | integer | Integer |
bigint | bigint | Long |
datetime/timestamp | timestamp without time zone | Date |
decimal | numeric | BigDecimal |
tinyint | smallint | Integer |
text | text | String |
blob | bytea | byte[] |
如果你不改代码里的判断逻辑,生成的实体类字段类型会退化成默认值(通常是 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 会排到最后。后台管理系统里sort、order_num这类排序字段经常有 NULL 值,排序变化会直接改变菜单顺序、字典项顺序。
要显式声明:
ORDER BY order_num ASC NULLS FIRST或者建表时就给排序字段加上DEFAULT 0+NOT NULL,从源头上消灭 NULL。我更倾向于后者,排序字段本来就不该允许为空。
5.3 类型隐式转换变严格:status = 0突然报错
MySQL 会做非常宽松的隐式类型转换,where status = 0里status是varchar也能跑。PostgreSQL 严格得多,这种比较会直接抛:
ERROR: operator does not exist: character varying = integerRuoyi 里大量使用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 里要写bytea;qrtz_locks这类表如果没有主键,Quartz 的集群模式下会报锁定失败。
还有一个特别隐蔽的坑:建表时千万不要给表名加双引号。如果你写了CREATE TABLE "QRTZ_LOCKS",PG 会严格保留大写,而 Quartz 内部执行 SQL 时用的是不加引号的QRTZ_LOCKS,会被折叠成小写qrtz_locks,结果就是"表明明建了却找不到"。
6. 数据搬家与上线验证的实际操作顺序
前面都是改造,这一节讲怎么把数据安全地挪过去、怎么确认没挪丢。
6.1 三种搬数据方式,我最后选了哪种
我试过三种方式,说说各自的实际情况。
第一种是pgloader,命令行一键从 MySQL 拉数据到 PG,会自动做类型映射。优点是快,几十万行几分钟搞定;缺点是它的类型映射有自己的想法,tinyint(1)默认给转成boolean,datetime精度处理也和预期不一样,而且出错时的报错信息比较难定位到具体行。适合表结构简单、数据量大的场景。
第二种是图形化工具的"数据传输"功能(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 会写成\N,COPY的NULL选项要对应上,否则空值会变成字符串 "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 就是常规操作。