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_INCREMENT | bigserial或独立序列 | 见主键回填一节 |
longtext/mediumtext | text | 长度上限足够用 |
blob | bytea | 二进制字段 |
datetime | timestamp(0) | 精度参数很关键,见下 |
tinyint(1) | boolean或smallint | 布尔语义要确认清楚 |
double | double 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,这些写法在原来那套数据库里可能只是警告,换过来直接报语法错误。
处理策略按优先级排:
- 改名。字段
desc改成remark或description,level改成grade或level_code。这是最干净的做法,一次改完永久生效。 - 加双引号。
SELECT "desc" FROM t。能用,但代价是你所有手写 SQL、所有 Mapper、代码生成器模板、甚至连带的前端字段映射都得记住加引号,漏一处就是运行时错误。 - 加前缀。业务字段统一加个小前缀,比如
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, size | LIMIT 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。
三种解法,按推荐度排:
- 关键:开启下划线转驼峰映射,并且别名完全不用写。
map-underscore-to-camel-case: true,SQL 里直接SELECT order_no, create_time,MyBatis 自动把order_no映射到orderNo。这是最省事的,也不用操心大小写。 - 别名加双引号:
AS "orderNo"。准确有效,但每个别名都得记着加,漏一个就 null 一个。 - 显式 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: truehelper-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 exist | schema 不对或大小写不匹配 | 检查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、分页、批量写入到代码生成器,把这条链路走完,心里就有底了,再往大项目上搬。这个顺序比一上来就改全量脚本要靠谱得多,因为前面踩的坑在小模块里成本最低。