ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

若依迁移openGauss:数据库适配与SQL兼容实践

若依迁移openGauss:数据库适配与SQL兼容实践

1. 换库这件事的边界:先想清楚到底要改哪几层

把一套后台管理系统从常见的单机数据库切到 openGauss,第一反应往往是"改个驱动、改个 URL 就完事了"。真动手之后才会发现,驱动只是最表层的一步,真正吃时间的是 SQL 语法、映射规则、框架里写死的元数据查询,以及一堆"不报错但结果不对"的隐性问题。用若依这套框架起项目的人应该都有体会:它自带的代码生成器、定时任务、多数据源、字典管理这些东西开箱即用很爽,但它们的底层实现里藏着大量对特定数据库的默认假设,换库的时候这些假设会一个个跳出来找你。

这篇记录面向的是已经决定或正在评估把若依迁到 openGauss 的开发者,不管你是第一次接触信创环境适配,还是已经在改 SQL 改到怀疑人生,下面这些坑大概率你都绕不开。我把整个过程拆成连接层、建表与语法层、MyBatis 映射层、MyBatis-Plus 增强层、框架自身适配这五块,每块都给出我实际验证过的改法和判断依据,涉及参数的地方顺带说明为什么这么定。

1.1 一套后台系统换库,实际动的是四层

很多人把"适配数据库"理解成一个点,实际上是四条独立的链路,任何一条没打通都会卡住:

层级具体内容典型症状改动量
连接层驱动、URL、连接池、schema启动即失败,连接超时小,但容易反复
语法层建表脚本、函数、upsert、分页运行期 SQL 报错大,占七成工作量
映射层列别名、主键回填、参数类型不报错但数据为空或错位中,最隐蔽
框架层代码生成器、定时任务、多数据源功能模块局部失灵中,需要读源码

这个分层很重要,因为排查问题的顺序必须按它来。我见过有人一上来就怀疑 MyBatis-Plus 分页插件有 bug,折腾半天,实际上是连接串里 schema 写错了,查的是空表。先验证连接是否真的通、查的是不是你以为的那个库,再往下查语法和映射,这个顺序能省掉大量无效排查。

1.2 版本基线与兼容模式的取舍

openGauss 在建库时可以指定兼容模式,早期常见的做法是选 B 兼容(偏向 MySQL 语法),想着这样能少改点 SQL。我的建议是:除非你的存量 SQL 里有大量无法改动的存储过程,否则老老实实选默认的 A 兼容模式。

理由很实际。B 兼容模式确实能让一部分IFNULL、LIMIT a,b之类的写法跑起来,但它是"部分兼容",不是"等价兼容"。你在 B 模式下写通的 SQL,换个函数、换个场景照样报错,而报错时你会误以为语法是对的,排查方向就偏了。更麻烦的是,MyBatis-Plus、Druid、Quartz 这些组件判断方言靠的是数据库类型,它们并不知道你开了什么兼容模式,该按 PostgreSQL 系走的逻辑还是那么走。

所以我整条链路的前提是:按 PostgreSQL 系的规则来写 SQL,把兼容模式当成不存在。这个前提下,绝大多数问题都有明确的、可预期的解法,不会出现"昨天能跑今天不行"的玄学。

另外几个版本相关的注意点我先列在这儿,后面展开:

  • 驱动版本必须和 JDK 版本对齐,新版驱动对 JDK 8 的支持情况要确认清楚,别拿 JDK 8 去跑只支持高版本 JDK 的驱动包。
  • 驱动坐标在不同版本之间改过名字,从旧坐标迁到新坐标时,很容易出现"依赖里有俩驱动"导致类冲突的情况。
  • 先用最小工程验证连接与一条简单查询,再往若依里搬,不要直接在完整项目里试错。

2. 连接层:驱动、URL 与连接池的第一轮踩坑

连接层看起来最简单,实际上是我返工最多的地方。原因在于这里的错误信息往往很含糊——"连接被拒绝"可能是端口问题、可能是监听地址问题、可能是认证方式问题、也可能是驱动根本没加载上,四种情况的报错长得差不多。

2.1 驱动坐标与 URL 参数怎么写

驱动类名是org.opengauss.Driver,这一点没得商量,配成 PostgreSQL 的org.postgresql.Driver在某些场景下能连通,但拿不到 openGauss 特有的能力和正确的类型元数据,属于给自己埋雷。

Maven 依赖按你实际使用的版本号来,这里只强调坐标变更这件事:老项目里可能写着某个旧坐标,新版本换成了org.opengauss:opengauss-jdbc。升级时务必先排除旧依赖,我踩过一次,两个驱动包同时在 classpath 里,最后加载的是哪个完全看类加载顺序,表现就是"同一份配置在不同机器上行为不一样",排查了一整天才发现是依赖冲突。

URL 的写法:

url: jdbc:opengauss://127.0.0.1:15432/ruoyi?currentSchema=public&ApplicationName=ruoyi-admin&batchMode=off&loginTimeout=5&socketTimeout=60

参数逐个说下我为什么这么配:

  • currentSchema:这一项强烈建议显式写死。不写的话走数据库默认搜索路径,多 schema 环境下很容易查错表。
  • ApplicationName:纯粹为了排查方便,数据库侧的会话视图里能看到是哪个应用连上来的,出问题一眼定位。
  • batchMode:设为off是求稳。批处理模式在部分版本上对返回结果的处理和预期不一致,如果你的业务里有依赖批处理返回值的逻辑,开了容易出诡异问题。
  • loginTimeout/socketTimeout:必须有。没有超时设置的连接池,一旦数据库侧出问题,应用线程会被无限期挂住,表现是整个服务假死。这两个值按你实际网络情况定,内网 5 秒登录超时足够。
  • reWriteBatchedInserts:批量插入优化参数,后面在批量写入那节细说,默认不开。

注意:URL 里的&在 YAML 里不需要转义,但如果你写在 properties 文件或者拼接在 shell 命令里,记得处理转义,这个低级错误我见过不止一次。

2.2 Druid 配置与校验语句

若依默认用 Druid 做连接池,配置在数据源那一段。切换到 openGauss 时,最小改动是保留 Druid,只改驱动类名、URL、用户名密码,然后把dbType调对。较新版本的 Druid 已经内置了 openGauss 的方言枚举,直接写opengauss即可;如果你的 Druid 版本比较老,没有这个枚举,写postgresql也能正常工作,区别只是监控页面显示的类型名不一样,不影响功能。

spring: datasource: druid: master: driver-class-name: org.opengauss.Driver url: jdbc:opengauss://127.0.0.1:15432/ruoyi?currentSchema=public username: your_user password: your_password validation-query: SELECT 1 test-while-idle: true test-on-borrow: false test-on-return: false keep-alive: true

这里有两个坑值得单独说。

第一,validation-query千万不要照抄某些模板里的SELECT 1 FROM DUAL,那是另一套数据库的写法,openGauss 里没有DUAL这个伪表,连接池校验会全部失败,表现就是隔一段时间所有连接被判定为无效然后疯狂重建,日志里刷一堆校验异常。

第二,test-on-borrow我建议关掉。每借一次连接就发一次校验 SQL,高并发下这部分开销很可观,而且真正的故障场景它也不一定能提前发现。用test-while-idle加keep-alive的组合更合适,空闲连接定期探活,坏连接提前剔除。

2.3 schema、大小写与 search_path

这一节是整篇里最容易被低估的部分。openGauss 属于 PostgreSQL 系,标识符处理规则和很多人习惯的那套完全相反:

  • 不加双引号的标识符,会被统一折叠成小写。
  • 加了双引号,就严格按你写的大小写来,且之后每次引用都必须带引号。

这两条规则会带来一个非常迷惑的现象:你用工具执行CREATE TABLE SYS_USER (...),建出来的表其实是sys_user,因为没加引号,被折叠了。然后你写SELECT * FROM SYS_USER,也折叠成小写,能查到,看起来一切正常。但如果你在建表时手贱加了双引号CREATE TABLE "SYS_USER",那表名就真的是大写,之后所有不带引号的查询都会报"表不存在"。

所以我的做法是统一小写加下划线,建表脚本、Mapper XML、代码生成器模板全部保持一致,不给自己留第二种可能。若依自带的表名本来就是小写风格,这块冲突不大,真正出问题的是你后来加的业务表,尤其是从别的系统导过来的、习惯全大写的那些。

currentSchema和搜索路径的关系也要理清:搜索路径决定了一个不带 schema 前缀的表名去哪儿找。如果你在建库时把业务表放在了非public的 schema 下,而连接串里currentSchema没跟着改,那就会撞上"表明明存在却查不到"的经典问题。我的建议是业务表统一放一个 schema,连接串里显式指定,SQL 里全部不带 schema 前缀,这样将来换 schema 只改一处。

3. 建表脚本与 SQL 语法:迁移量最大的一块

如果要给这次适配的各项工作量排个序,建表脚本和 SQL 语法改造能占七成。原因不复杂:若依生态里大量的示例 SQL、代码生成器模板、初始化脚本,都是围绕另一套数据库写的,里面有太多默认假设。这一节我按"建表语句怎么改""保留字怎么处理""函数怎么替换""批量与 upsert 怎么写"四条线展开,每条都给可直接抄的写法。

3.1 从 MySQL 建表语句到 openGauss 的逐项改写

先看一个典型的原始建表语句(若依代码生成器产出的风格),然后逐项说改哪儿:

CREATE TABLE `biz_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` varchar(64) NOT NULL COMMENT '单号', `amount` decimal(12,2) DEFAULT NULL COMMENT '金额', `remark` longtext COMMENT '备注', `del_flag` char(1) DEFAULT '0' COMMENT '删除标志', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='业务订单';

改成 openGauss 之后:

CREATE TABLE biz_order ( id bigserial PRIMARY KEY, order_no varchar(64) NOT NULL, amount numeric(12,2) DEFAULT NULL, remark text, del_flag char(1) DEFAULT '0', create_time timestamp(0), update_time timestamp(0) ); CREATE INDEX idx_order_no ON biz_order (order_no); COMMENT ON TABLE biz_order IS '业务订单'; COMMENT ON COLUMN biz_order.id IS '主键'; COMMENT ON COLUMN biz_order.order_no IS '单号'; COMMENT ON COLUMN biz_order.amount IS '金额'; COMMENT ON COLUMN biz_order.remark IS '备注'; COMMENT ON COLUMN biz_order.del_flag IS '删除标志'; COMMENT ON COLUMN biz_order.create_time IS '创建时间'; COMMENT ON COLUMN biz_order.update_time IS '更新时间';

逐项对照的规则表:

原写法目标写法说明
反引号`col`直接去掉,或双引号双引号会锁定大小写,谨慎使用
bigint(20)bigint括号里的宽度参数不生效,写了会报错
int(11)integer同上
AUTO_INCREMENTbigserial或独立序列见主键回填一节
longtext/mediumtexttext长度上限足够用
blobbytea二进制字段
datetimetimestamp(0)精度参数很关键,见下
tinyint(1)boolean或smallint布尔语义要确认清楚
doubledouble precision单词不能省
unsigned不支持,改用更大范围的类型有这个修饰的字段必须逐个检查
ENGINE=InnoDB整段删除无对应概念
DEFAULT CHARSET整段删除库级字符集在创建数据库时定
内联KEY idx_x (col)独立CREATE INDEX建表语句里不支持
COMMENT '...'COMMENT ON ... IS '...'独立语句
ON UPDATE CURRENT_TIMESTAMP不支持,应用层填充或用触发器

这里有两个细节我必须展开说,因为它们在文档里不太显眼,但在实际项目里影响很大。

第一个是timestamp(0)的精度参数。timestamp类型默认精度是 6 位,也就是微秒级。如果你的 Java 实体用的是LocalDateTime,序列化出来会带上毫秒甚至微秒,前端展示成2024-05-01 12:00:00.000000,用户看着别扭,还容易在前后端时间比较上出问题。写成timestamp(0)就把精度锁在秒级,写入时自动截断,展示干净。如果你确实需要毫秒,就写timestamp(3),别用默认值图省事。

第二个是ON UPDATE CURRENT_TIMESTAMP的缺失。这是从 MySQL 迁过来最容易被漏掉的功能,因为它是"隐式生效"的:以前你只更新业务字段,更新时间自动变了;现在不会了,更新完时间还是旧的,而且不报错,要过几天看数据才发现全是错的。处理方式有两个,我推荐前者:

  • 在实体类上用 MyBatis-Plus 的@TableField(fill = FieldFill.INSERT_UPDATE)配合MetaObjectHandler自动填充,这种做法是应用层可控的,换库不受影响,也是若依这类项目本来的习惯;
  • 用数据库触发器,好处是任何途径的更新都能覆盖,坏处是排查问题时多一层黑盒,而且触发器本身也有维护成本。

3.2 保留字与双引号:一个必须提前扫一遍的雷区

PostgreSQL 系的保留字列表和很多人熟悉的那套差别不小,最典型的几个:user、order、group、desc、asc、check、default、position、current_date、current_time、level、session_user、authorization。

其中user、order、group这三个在业务表里出现频率极高。字段叫level(等级)、叫desc(描述)、表名叫order,这些写法在原来那套数据库里可能只是警告,换过来直接报语法错误。

处理策略按优先级排:

  1. 改名。字段desc改成remark或description,level改成grade或level_code。这是最干净的做法,一次改完永久生效。
  2. 加双引号。SELECT "desc" FROM t。能用,但代价是你所有手写 SQL、所有 Mapper、代码生成器模板、甚至连带的前端字段映射都得记住加引号,漏一处就是运行时错误。
  3. 加前缀。业务字段统一加个小前缀,比如biz_level,既避开保留字又保持了可读性。

我选的是方案一和方案三结合。理由很朴素:你不可能记住所有保留字,也不可能保证以后新加的字段不撞上。改名是一次性成本,加引号是持续性成本。

具体的扫描方法,在建表之前把所有.sql文件过一遍,用关键词匹配快速筛:

grep -inE "\`(user|order|group|desc|asc|level|check|default|position|current_date|current_time)\`" -r ./sql/

匹配出来的逐个判断。注意status、type、value、size、name这些不是保留字,不用紧张,网上有些列表把非保留关键字也列进去了,照着改会做无用功。

3.3 常用函数替换对照:这是报错最密集的地方

函数不兼容是运行期报错的主要来源,而且往往是"点开某个菜单就 500",测一个修一个,效率极低。我的做法是先把项目里所有 Mapper XML 里的函数提取出来,做成一张对照表,一次性批量替换,而不是等它报错。

原函数 / 写法openGauss 写法备注
IFNULL(a, b)COALESCE(a, b)最常用,全局替换
IF(cond, a, b)CASE WHEN cond THEN a ELSE b END
DATE_FORMAT(t, '%Y-%m-%d')to_char(t, 'YYYY-MM-DD')格式串规则不同,要逐个别
NOW()now()或current_timestamp大小写不敏感,能直接用
CURDATE()current_date
DATE_SUB(NOW(), INTERVAL 7 DAY)now() - interval '7 day'注意引号位置
DATEDIFF(a, b)date_part('day', a - b)或直接相减取天数
UNIX_TIMESTAMP(t)extract(epoch from t)返回类型要确认
FROM_UNIXTIME(n)to_timestamp(n)注意单位是秒
GROUP_CONCAT(col)string_agg(col, ',')分隔符必须显式给
FIND_IN_SET(a, b)a = ANY(string_to_array(b, ','))语义一致
SUBSTRING_INDEX(s, ',', 1)split_part(s, ',', 1)索引从 1 开始
LIMIT offset, sizeLIMIT size OFFSET offset注意参数顺序反了
RAND()random()
CONCAT(a, b)可以沿用但任一参数为 null 结果就是 null
LPAD/RPAD/REPLACE/TRIM可以沿用基本一致

LIMIT这条我要专门强调,因为它是最容易改错的一处。原来写LIMIT 10, 20意思是跳过 10 条取 20 条;改成LIMIT 20 OFFSET 10,顺序是反的。改的时候如果只是机械地把逗号换掉,写成了LIMIT 10 OFFSET 20,语法合法、结果全错,而且分页总条数是对的,只有列表内容不对,非常难发现。用 MyBatis-Plus 的分页插件的话不会有这个问题,但项目里手写的分页 SQL 一定要逐个核对。

3.4 批量写入与 upsert:语法差异最大的一块

批量插入本身两边都支持多 values 语法,问题不大。真正要改的是两类"带语义"的写入。

第一类是"存在则更新,不存在则插入"。原来的写法:

INSERT INTO biz_config (config_key, config_value) VALUES (#{k}, #{v}) ON DUPLICATE KEY UPDATE config_value = #{v};

openGauss 用ON CONFLICT:

INSERT INTO biz_config (config_key, config_value) VALUES (#{k}, #{v}) ON CONFLICT (config_key) DO UPDATE SET config_value = excluded.config_value;

两个关键点:ON CONFLICT后面必须跟上冲突的列(或约束名),不能像原来那样隐式依赖主键;更新时引用"要插入的那一行"用excluded.前缀,不能直接写参数占位符——虽然写#{v}也能跑,但语义上不严谨,遇到复杂场景会出偏差。

第二类是"存在则跳过"。原来是INSERT IGNORE,改成:

INSERT INTO biz_config (config_key, config_value) VALUES (#{k}, #{v}) ON CONFLICT (config_key) DO NOTHING;

这个转换很直观,但要检查原来的INSERT IGNORE是不是还兼职了"忽略其他类型的错误"——INSERT IGNORE在原来那套库里会吞掉很多错误,比如类型转换失败、字段超长。改成ON CONFLICT DO NOTHING之后,只有唯一约束冲突会被忽略,其他错误统统抛出。这是个好事,但会让原本"静默失败"的地方突然开始报错,心理上要有准备。

批量插入的性能参数reWriteBatchedInserts=true值得开,它会把多条单行 insert 改写成多值 insert,减少网络往返。我实测在千条级别的批量写入上提升明显。但要注意:这个参数和ON CONFLICT组合使用时行为需要你亲自验证一遍,别直接上线。

4. MyBatis 映射层:那些不报错但结果不对的坑

语法层的错至少会报出来,映射层的错往往静悄悄地发生:查询成功、返回 200、数据是空的或者全 null。这类问题排查成本最高,因为你的第一反应通常是"数据没写进去",然后去查数据库,发现有数据,再回来怀疑缓存,最后才想到映射。

4.1 列别名大小写与结果映射

这条我单列出来,因为它是本项目里最隐蔽的一个坑。

看这个 SQL:

SELECT id, order_no AS orderNo, create_time AS createTime FROM biz_order

在原数据库里这么写没问题,orderNo这个别名会被原样保留,能对上 Java 实体里的orderNo属性。但在 openGauss 里,不带双引号的别名会被折叠成小写,实际返回的列名是orderno和createtime。MyBatis 拿这个列名去和实体属性匹配,orderno和orderNo大小写对不上,映射失败,属性就是 null。

三种解法,按推荐度排:

  1. 关键:开启下划线转驼峰映射,并且别名完全不用写。map-underscore-to-camel-case: true,SQL 里直接SELECT order_no, create_time,MyBatis 自动把order_no映射到orderNo。这是最省事的,也不用操心大小写。
  2. 别名加双引号:AS "orderNo"。准确有效,但每个别名都得记着加,漏一个就 null 一个。
  3. 显式 resultMap:用<result column="order_no" property="orderNo"/>逐字段声明。最稳,但最啰嗦。

我选方案一为主、方案三兜底。方案三主要用在字段名和属性名差异较大、或者有复杂嵌套的查询上。

若依本身默认是开了下划线转驼峰的,这个配置一般不用动。但要注意别在某处为了"修复"某个问题临时关掉,关掉之后全项目所有自动映射都会失效,连锁反应很大。

4.2 主键回填与序列:一个必须换掉的默认机制

原来那套机制里,插入之后想拿到自增主键,用useGeneratedKeys="true"加keyProperty就行。换到 openGauss 之后,这个机制能不能用,取决于驱动实现细节,而且很脆弱——某些版本能拿到,某些版本拿到的是整行数据,某些版本直接抛异常。

翻看驱动实现会发现,Statement.RETURN_GENERATED_KEYS在 PostgreSQL 系的驱动里需要显式指定"我要返回哪些列",不指定的话行为不确定。MyBatis 的默认实现是不指定列名的,所以就会出现"时灵时不灵"。

我最后采用的是序列 +selectKey的显式方案,逻辑清晰、行为确定:

<insert id="insertOrder" parameterType="BizOrder"> <selectKey keyProperty="id" resultType="java.lang.Long" order="BEFORE"> SELECT nextval('seq_biz_order') </selectKey> INSERT INTO biz_order (id, order_no, amount, create_time) VALUES (#{id}, #{orderNo}, #{amount}, #{createTime}) </insert>

配套的序列创建语句:

CREATE SEQUENCE seq_biz_order START WITH 1 INCREMENT BY 1 NO CYCLE; ALTER SEQUENCE seq_biz_order OWNED BY biz_order.id;

OWNED BY这行让序列跟着表走,删表时序列一起删,避免留下孤儿序列。这一步容易忘,久了数据库里会积一堆没人用的序列。

如果你用 MyBatis-Plus,还有个更省事的路子:主键策略直接改成ASSIGN_ID(雪花 ID),完全绕开数据库自增。这个方案对后台系统几乎没缺点:不依赖数据库特性,换库无感,ID 全局唯一,分库分表也不用改。唯一要注意的是 ID 是 19 位长整型,前端 JS 处理时会丢精度,需要在序列化时转成字符串。若依本身对这点有处理,用之前确认一下你的版本有没有。

4.3 参数类型与隐式转换:从宽松到严格的适应期

原来那套数据库在类型转换上非常宽容,WHERE id = '123'(id 是整型,传了字符串)能跑,日期字段传空串也能跑。openGauss 严格得多,integer = character varying这种比较会直接报"操作符不存在"。

典型报错长这样:

ERROR: operator does not exist: integer = character varying

原因是 MyBatis 传参数时用的是setString,而目标列是整型。解决办法:

  • 实体字段类型和数据库列类型严格对齐,别用 String 接 Integer 列。这是根本解法。
  • 实在要对齐不了的,在占位符里显式指定类型:#{id,jdbcType=INTEGER},或者在 SQL 里转换:WHERE id = #{id}::integer。

还有一个更隐蔽的场景:空字符串和 null 的区别。原来那套库里某些情况下''会被当成 null 处理,openGauss 里不会,''就是长度为 0 的字符串,'' IS NULL返回 false。所以如果你的查询条件里有AND remark != null这种写法,遇到空串字段会全部漏掉。统一用IS NOT NULL和IS NULL,别依赖隐式转换。

4.4 用 databaseId 做双库兼容:一个被低估的机制

如果你的项目需要同时支持原来那套库和 openGauss(比如私有化部署时客户环境不统一),那前面说的"直接改 SQL"就行不通了,得让同一套代码能跑两种库。

MyBatis 内置了databaseIdProvider,配置如下:

<bean id="databaseIdProvider" class="org.apache.ibatis.mapping.VendorDatabaseIdProvider"> <property name="properties"> <props> <prop key="MySQL">mysql</prop> <prop key="PostgreSQL">postgresql</prop> <prop key="openGauss">postgresql</prop> </props> </property> </bean>

openGauss的DatabaseMetaData.getDatabaseProductName()返回值按你实际驱动为准,配之前先打出来看一眼。配上之后,SQL 可以按库写两份:

<select id="selectList" resultMap="BaseResultMap"> <if test="_databaseId == 'mysql'"> SELECT ... LIMIT #{offset}, #{size} </if> <if test="_databaseId == 'postgresql'"> SELECT ... LIMIT #{size} OFFSET #{offset} </if> </select>

这个机制的代价是 Mapper 会变得臃肿,所以我只用在两种库语法差异确实无法统一的地方,比如分页、upsert、字符串聚合。大部分查询用标准语法写,两边都能跑,就不用拆。

5. MyBatis-Plus:方言、分页与主键策略

若依老版本用 MyBatis,新版本已经切到 MyBatis-Plus。如果项目里有 MyBatis-Plus,这一节的问题你一定会遇到,而且都是"配置项写错一个字符就全盘失效"的类型。

5.1 分页插件与 DbType:写错方言的表现是什么

分页插件的方言配置:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor page = new PaginationInnerInterceptor(); page.setDbType(DbType.POSTGRE_SQL); page.setMaxLimit(500L); page.setOverflow(false); interceptor.addInnerInterceptor(page); return interceptor; }

关键点在DbType.POSTGRE_SQL。如果这里没配,插件会去自动探测,探测逻辑在某些驱动版本上识别不出来,退化成默认方言,生成的 SQL 语法不对。表现是:查询总数是对的,但数据列表永远为空或者只返回第一条。

setMaxLimit(500L)和setOverflow(false)是防呆用的。maxLimit限制单页最大条数,防止有人传pageSize=999999把库拖垮;overflow=false表示页码超出总页数时不做处理(返回空),而不是回到第一页——这两种行为在业务上含义完全不同,选错了会让前端分页组件的表现很怪。

如果你同时用了 PageHelper(有些项目两套混用),记得它也有一份独立的方言配置:

pagehelper: helper-dialect: postgresql reasonable: false support-methods-arguments: true

helper-dialect不配的话,PageHelper 默认按 MySQL 生成LIMIT a,b,直接语法错误。

5.2 主键策略与逻辑删除

主键策略前面提过一嘴,这里展开说清楚三个选项的适用场景:

策略依赖数据库换库影响适用场景
IdType.AUTO是,需要自增列或序列默认值需改造建表与驱动存量表已用自增,改不动
IdType.ASSIGN_ID否,应用层生成无新表首选
IdType.INPUT否,完全手动无有特殊 ID 规则

新表我全部用ASSIGN_ID。存量表如果已经用了AUTO,那就老老实实把序列配好,让表的id列默认值指向序列:

ALTER TABLE biz_order ALTER COLUMN id SET DEFAULT nextval('seq_biz_order');

这样即使实体不显式赋值,插入时数据库也能自己生成。但注意IdType.AUTO隐含了对useGeneratedKeys的依赖,也就是回到了上一节说的回填问题。稳妥的做法是:AUTO加selectKey双保险,或者干脆改成ASSIGN_ID。

逻辑删除这块,@TableLogic的配置里有value和delval两个值,默认是 0 和 1。如果你的del_flag列是char(1)类型,配置里就得写成字符串'0'和'1';如果是boolean类型,就得写false和true。这个类型不匹配不会报错,只会让"已删除"的数据依然被查出来,非常阴险。

5.3 批量操作与类型处理器

批量插入我一般不用 MyBatis-Plus 自带的saveBatch,因为它底层还是一条条发,只是做了批处理合并,性能提升有限。真要用的话,务必在 URL 上开reWriteBatchedInserts=true,否则saveBatch就是"看起来批量的单条循环"。

更可控的做法是手写多值插入:

<insert id="batchInsert"> INSERT INTO biz_order (id, order_no, amount, create_time) VALUES <foreach collection="list" item="it" separator=","> (#{it.id}, #{it.orderNo}, #{it.amount}, #{it.createTime}) </foreach> </insert>

一次拼 500 到 1000 条比较合适,太多会撞上 SQL 长度限制和参数个数上限。

类型处理器这块,jsonb字段是最麻烦的。openGauss 有 JSON 类型,但 MyBatis 没有内置的jsonb处理器,直接写会报"无法推断类型"。需要自定义:

public class JsonbTypeHandler extends BaseTypeHandler<Object> { @Override public void setNonNullParameter(PreparedStatement ps, int i, Object param, JdbcType jdbcType) throws SQLException { PGobject obj = new PGobject(); obj.setType("jsonb"); obj.setValue(JSON.toJSONString(param)); ps.setObject(i, obj); } // getNullableResult 从 rs.getString 反序列化回来 }

关键在ps.setObject而不是ps.setString,用setString会报类型不匹配。如果你的项目里 JSON 字段很多,建议干脆存成text,应用层自己做序列化,省掉这一层麻烦——后台管理系统的 JSON 字段通常不参与数据库侧查询,存text完全够用。

6. 若依框架自身的适配点

前面几节都是通用问题,这一节专门讲若依这套框架特有的、必须改源码或改配置的地方。这些点比较分散,但每一个都能让某个功能模块直接失灵。

6.1 代码生成器的元数据查询

若依的代码生成器会读数据库的元数据来生成代码,它内部的查询大概是这个路子:从information_schema.tables里按table_schema = (select database())过滤出当前库的表,再读table_comment、create_time这些字段。

问题在于,database()这个函数在 openGauss 里不存在,information_schema.tables里也没有create_time、update_time、table_comment这几个字段。所以点开"代码生成"菜单想导入表,直接报错。

改法有两步:

第一步,把database()换成current_schema():

-- 原 WHERE table_schema = (SELECT database()) -- 改 WHERE table_schema = current_schema()

第二步,表注释和时间字段的取值要换一套查法。information_schema里拿不到注释,得走系统目录:

SELECT c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema() AND c.relkind = 'r'

时间字段如果只是展示用,直接去掉或者用固定值填充;如果业务上确实要按创建时间排序,那就得自己建一张元数据表来记录,information_schema是给不了你的。

改完之后还有一层:生成出来的建表语句模板也得改。若依的模板文件里写死了AUTO_INCREMENT、ENGINE=InnoDB、反引号这些东西,照着第 3.1 节那张对照表把模板改一遍。这块改完是长期收益,以后所有新业务表都不用再手动转了。

6.2 定时任务与 Quartz 表

若依的定时任务模块基于 Quartz,它自己有一整套QRTZ_开头的表。这套表的建表脚本是分数据库的,得换成 PostgreSQL 版本的那份(openGauss 语法能对上)。

除了建表脚本,还有一个必改项:驱动代理类。

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

不配这个的话,Quartz 会用默认的StdJDBCDelegate,里面有些 SQL 是带方言假设的,典型表现是任务能建、能触发,但执行记录写不进去,或者集群模式下节点抢锁异常。这个配置藏得比较深,报错信息也不指向它,很容易卡住。

另外,Quartz 表里的时间字段如果有bigint存时间戳的,不用动;如果有timestamp类型的,按前面说的加上精度参数就行。

6.3 多数据源与初始化脚本

若依的多数据源是通过@DataSource注解加 AOP 切面实现的,配置里master和slave两套。切库的时候两套都要改,别只改了主库,从库还指在原来那套上,表现就是"读操作走从库时报错,写操作正常",很容易误判成只读事务的配置问题。

初始化脚本这块,若依自带的ry_20xxx.sql和quartz.sql都要整体转一遍。我的做法是先跑一遍脚本收集所有报错,再按报错逐条改,比逐行读脚本快得多。改的过程中把前面几节的规则表放在旁边对照,效率会高不少。

还有一个容易忽略的:sys_config表里存了一些系统参数的默认值,比如上传路径、验证码开关。这些值本身和数据库无关,但字段长度如果按原来的varchar(100)定,某些配置项(比如多级路径)会超长报错,建议统一放宽到varchar(500)。

7. 排错清单:从报错到定位的固定路径

最后这部分是我自己整理的一份速查表,出问题的时候按这个排查,比凭感觉试要快得多。

7.1 高频报错速查表

报错信息关键词大概率原因处理方式
No suitable driver驱动类名写错或依赖没进来确认org.opengauss.Driver,检查依赖树
Connection refused端口错、监听地址没开、认证方式不匹配先用命令行客户端连一次验证
relation "xxx" does not existschema 不对或大小写不匹配检查currentSchema,检查是否用了双引号
operator does not exist: integer = character varying参数类型和列类型不匹配实体类型对齐,或加jdbcType
syntax error at or near "LIMIT"分页写法还是旧的逗号形式改LIMIT n OFFSET m
column "xxx" does not exist别名被折叠成小写或字段不存在用下划线转驼峰,别写别名
null value in column "id" violates not-null主键没生成配序列,或改ASSIGN_ID
duplicate key value violates unique constraint原INSERT IGNORE改成了普通插入加ON CONFLICT DO NOTHING
cannot cast type xxx to yyy隐式转换不成立显式::type或用CAST
function database() does not exist代码生成器元数据查询改current_schema()
查询结果全为 null列别名大小写映射失败开驼峰映射,去掉别名

7.2 我的排查顺序

遇到问题我按这个顺序走,基本不会绕远路:

第一步,确认连的是哪个库、哪个 schema。在应用里随便找个接口执行一条SELECT current_database(), current_schema(),把结果打出来。这一步能排除掉至少三成的"诡异问题"——很多时候你以为在查 A 库,实际连的是 B 库。

第二步,把报错的 SQL 单独拎出来手工执行。从日志里复制完整的 SQL(带参数值),在数据库客户端里跑一遍。如果手工能跑、程序报错,那就是参数绑定或类型映射的问题;如果手工也报错,那就是 SQL 语法问题,照前面的对照表改。

第三步,检查 MyBatis 的映射配置。查询不报错但数据为空,先看map-underscore-to-camel-case有没有开,再看 SQL 里有没有写会被折叠的别名。

第四步,才去怀疑框架层。分页插件、多数据源、事务这些,出问题的概率其实比 SQL 本身低得多,但一旦出问题排查最费劲,所以放到最后。

7.3 几个让我印象深刻的教训

教训一:别用SELECT *做验证。我一开始为了快速验证连接,写了SELECT * FROM sys_user,结果因为列别名折叠,返回的 Map 里全是小写 key,和实体对不上,误以为数据有问题。验证连接用SELECT 1就够了,要看数据就明确列出字段名。

教训二:批量改 SQL 之前先备份。我用脚本批量把IFNULL换成COALESCE,顺手把IFNULL出现在字符串常量里的地方也换了(比如某个提示语里就有这几个字),导致文案变成了COALESCE。虽然不影响功能,但很尴尬。批量替换务必加词边界,或者替换后 diff 一遍。

教训三:事务里混用两种写法会锁表。有个业务逻辑在一个事务里先ON CONFLICT DO UPDATE再SELECT ... FOR UPDATE,在特定并发下出现了锁等待。原因是ON CONFLICT的加锁行为和普通UPDATE不完全等价。这类问题不好复现,建议是把"先查再插"和"直接 upsert"两种风格在项目里统一成一种,别混着用。

教训四:时区。这个和数据库关系不大,但换库时特别容易出。原来的库和应用服务器时区如果碰巧一致,你可能从来没注意过时区配置。换到 openGauss 之后,如果timestamp和timestamptz用混了,时间会整体偏移 8 小时。建表统一用timestamp(不带时区),应用层统一LocalDateTime,两边都别碰时区转换,这是最省心的组合。

整套适配走下来,我的整体感受是:真正难的不是某一个具体的语法报错,而是建立起"这个库会怎么理解我写的 SQL"的直觉。前三天改得最痛苦,每个报错都要查;到后面看到一段 SQL 就能预判哪儿会出问题,速度就上来了。如果你正准备做这件事,建议先拿一个只有两三张表的小模块跑通全流程——从建表、连库、CRUD、分页、批量写入到代码生成器,把这条链路走完,心里就有底了,再往大项目上搬。这个顺序比一上来就改全量脚本要靠谱得多,因为前面踩的坑在小模块里成本最低。

返回列表