OceanBase 双模式识别与 MyBatis-Plus 分页方言自动切换实战

一、背景

平台是多数据源共存的:主库 Oracle,另有 OceanBase(Oracle 兼容模式)库,o32 等模块还接入了 MySQL。两个痛点:

  1. XML SQL 写一份不够用------同一查询在 Oracle 语法和 MySQL 语法下写法不同,不可能每个环境维护一套 Mapper;
  2. MyBatis-Plus 分页插件只有一个 ------PaginationInnerInterceptor 写死 DbType.ORACLE 的话,MySQL 数据源会生成 ROWNUM 语法直接报错,反之亦然。

本文记录如何让系统自动识别当前数据源(含 OceanBase 的 Oracle/MySQL 兼容模式),自动切换 SQL 分支与分页方言

二、版本信息

组件 版本
MyBatis-Plus 3.5.5 (根 pom <mybatis-plus.version> 统一管理)
mybatis 3.5.13(传递依赖)
oceanbase-client 2.4.2
JDK / Spring Boot 17 / 2.7.18

三、两条相互独立的机制

层面 机制 作用
手写 XML SQL 按库路由 MyBatis 原生 databaseIdProvider XML 语句按 databaseId 属性自动选 Oracle 版 / MySQL 版
自动分页 SQL 的方言 自定义 CustomPaginationInterceptor(继承 PaginationInnerInterceptor),按连接产品名动态识别 OceanBase 的 Oracle/MySQL 模式 Oracle 模式生成 ROWNUM 分页,MySQL 模式生成 LIMIT 分页

两者都集中在 MybatisPlusConfig 配置类里。

四、机制一:databaseId 自动识别,XML SQL 按库路由

4.1 配置

java 复制代码
@Bean
public DatabaseIdProvider databaseIdProvider() {
    DatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider();
    Properties properties = new Properties();
    properties.setProperty("Oracle", "oracle");
    properties.put("DB2", "db2");
    properties.setProperty("MySQL", "mysql");
    properties.setProperty("H2", "h2");
    databaseIdProvider.setProperties(properties);
    return databaseIdProvider;
}

这样写的好处,是 MyBatis 会在加载期自动算出当前库的 databaseIdoracle / mysql),XML 里带 databaseId 属性的语句即可自动匹配路由,业务代码零感知。原理分三步。

4.2 原理第一步:驱动产品名 → databaseId

VendorDatabaseIdProvider(mybatis 3.5.13 源码)通过 JDBC 标准接口拿到产品名,再用 Properties 做 contains 匹配(大小写敏感) 映射成 databaseId:

java 复制代码
private String getDatabaseName(DataSource dataSource) throws SQLException {
    String productName = getDatabaseProductName(dataSource);   // DatabaseMetaData.getDatabaseProductName()
    if (this.properties != null) {
        for (Map.Entry<Object, Object> property : properties.entrySet()) {
            if (productName.contains((String) property.getKey())) {
                return (String) property.getValue();
            }
        }
        return null;
    }
    return productName;
}

而 oceanbase-client 2.4.2 的 JDBC4DatabaseMetaData 源码决定了双模式的识别结果:

java 复制代码
public String getDatabaseProductName() throws SQLException {
    if (connection.getProtocol().isOracleMode()) {
        return "Oracle";   // Oracle 兼容模式
    } else {
        return "MySQL";    // MySQL 兼容模式
    }
}

结论:

实际数据库 驱动产品名 命中 key databaseId
Oracle / OceanBase(Oracle 模式) Oracle Oracle oracle
MySQL / OceanBase(MySQL 模式) MySQL MySQL mysql

4.3 原理第二步:加载期按 databaseId 挑选语句

MyBatis 构建 SqlSessionFactory 时执行 databaseIdProvider.getDatabaseId(dataSource),把结果(如 mysql)存进 Configuration.databaseId;解析每个 XML 时,XMLMapperBuilder两轮语句注册(3.5.13 源码):

java 复制代码
private void buildStatementFromContext(List<XNode> list) {
    if (configuration.getDatabaseId() != null) {
        buildStatementFromContext(list, configuration.getDatabaseId()); // 第一轮:只注册方言版
    }
    buildStatementFromContext(list, null);                              // 第二轮:注册无 databaseId 的兜底版
}

每轮对每条语句调用 XMLStatementBuilder.databaseIdMatchesCurrent 决定去留:

java 复制代码
private boolean databaseIdMatchesCurrent(String id, String databaseId, String requiredDatabaseId) {
    if (requiredDatabaseId != null) {
        return requiredDatabaseId.equals(databaseId);   // 第一轮:必须与当前库 databaseId 精确 equals
    }
    if (databaseId != null) {
        return false;                                    // 第二轮:带 databaseId 的不匹配语句 → 丢弃
    }
    // 无 databaseId 的语句:同 id 已注册过方言版则丢弃,否则作为兜底保留
    MappedStatement previous = this.configuration.getMappedStatement(id, false);
    return previous.getDatabaseId() == null;
}

最终规则:

  • 语句带 databaseId 且与当前库 精确相等 → 第一轮注册,成为该 id 的唯一实现;
  • 语句带 databaseId 但不匹配 → 两轮都被跳过,不注册
  • 语句不带 databaseId → 第二轮兜底注册(若同 id 已有方言版则被丢弃)。

注意:匹配是大小写敏感的 equalsdatabaseId 属性值必须与 provider 返回值(上面 Properties 里的 value,如小写 mysql)完全一致。

4.4 XML 用法:通用版兜底 + 方言特例

项目中的真实例子(TrealdealMapper.xml,注意 databaseId="mysql" 是小写):

xml 复制代码
<!-- 无 databaseId:Oracle / OceanBase(Oracle模式) 等所有库兜底使用 -->
<select id="listAllByLDateAndLDailyInstructionNoIn" resultMap="BaseResultMap">
    select <include refid="Base_Column_List"/>
    from trade.thisrealdeal
    where (L_DATE, L_DAILY_INSTRUCTION_NO) in
    <foreach item="item" index="index" collection="dailyInstructionNoCollection"
             open="(" separator="," close=")">
        (#{item.lDate,jdbcType=DECIMAL}, #{item.LDailyInstructionNo,jdbcType=DECIMAL})
    </foreach>
    and C_VALID = '1'
</select>

<!-- databaseId="mysql":仅在 MySQL 库上替换兜底版(去掉 Oracle 的 schema 前缀写法) -->
<select id="listAllByLDateAndLDailyInstructionNoIn" databaseId="mysql" resultMap="BaseResultMap">
    select <include refid="Base_Column_List"/>
    from thisrealdeal
    where (L_DATE, L_DAILY_INSTRUCTION_NO) in
    <foreach item="item" index="index" collection="dailyInstructionNoCollection"
             open="(" separator="," close=")">
        (#{item.lDate,jdbcType=DECIMAL}, #{item.LDailyInstructionNo,jdbcType=DECIMAL})
    </foreach>
    and C_VALID = '1'
</select>

连接 MySQL 库时:第一轮注册 databaseId="mysql" 版本,第二轮发现同 id 已有方言版,兜底版被丢弃;连接 Oracle/OceanBase 库时:mysql 版不匹配被跳过,兜底版注册生效。路由发生在启动加载期,运行期零判断、零开销,Mapper 接口方法不用改一行。

4.5 让每个数据源的 SqlSessionFactory 都挂上 Provider

多数据源场景下每个数据源各建一个 SqlSessionFactory,统一入口是自定义的 CustomSqlSessionFactoryBean

java 复制代码
@Setter(onMethod_ = @Autowired)
public class CustomSqlSessionFactoryBean {
    private MybatisPlusInterceptor mybatisPlusInterceptor;
    private DatabaseIdProvider databaseIdProvider;

    public SqlSessionFactory provideSqlSessionFactory(DataSource dataSource, List<Resource> mapperLocations) throws Exception {
        MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPlugins(mybatisPlusInterceptor);
        if (this.databaseIdProvider != null) {
            factory.setDatabaseIdProvider(databaseIdProvider);  // 每个数据源都拿到同一个 Provider
        }
        // ...
        return factory.getObject();
    }
}

MasterDataSourceConfigO32DataSourceConfigTimsDataSourceConfig 等都调用它构建,因此每个数据源都会独立计算自己的 databaseId,互不影响。

五、机制二:CustomPaginationInterceptor 分页方言自适应

XML 手写 SQL 解决了,但 selectPage 的自动分页 SQL 是 PaginationInnerInterceptor 代码拼接的,不受 databaseId 控制。而且这里有个更深的问题:3.5.5 的 JdbcUtils 按 JDBC URL 识别数据库,只能识别到 DbType.OCEAN_BASE,无法区分 OceanBase 到底跑在 Oracle 模式还是 MySQL 模式 。于是自定义了 CustomPaginationInterceptor

5.1 配置

java 复制代码
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 使用 OceanBase 方言自适应拦截器,根据连接自动检测 Oracle/MySQL 模式
    CustomPaginationInterceptor paginationInnerInterceptor = new CustomPaginationInterceptor();
    paginationInnerInterceptor.setOverflow(false);
    interceptor.addInnerInterceptor(paginationInnerInterceptor);
    // ...
    return interceptor;
}

5.2 父类的三级方言选择逻辑

先看 3.5.5 PaginationInnerInterceptor.findIDialect(每次分页查询都会经 beforeQuery 调用它):

java 复制代码
protected IDialect findIDialect(Executor executor) {
    if (dialect != null) {
        return dialect;                                    // ① 直接指定的方言对象
    }
    if (dbType != null) {
        dialect = DialectFactory.getDialect(dbType);       // ② 写死的数据库类型
        return dialect;
    }
    return DialectFactory.getDialect(JdbcUtils.getDbType(executor)); // ③ 运行时按 JDBC URL 动态探测
}

JdbcUtils.getDbType 的 URL 识别规则(3.5.5 源码):

java 复制代码
public static DbType getDbType(String jdbcUrl) {
    String url = jdbcUrl.toLowerCase();
    if (url.contains(":mysql:") || url.contains(":cobar:")) return DbType.MYSQL;
    else if (url.contains(":oracle:")) return DbType.ORACLE;
    else if (url.contains(":oceanbase:")) return DbType.OCEAN_BASE;
    // ...
}

问题在于:jdbc:oceanbase: 只能识别成 OCEAN_BASE 一个值,而 3.5.5 的 DialectFactory 默认把 OCEAN_BASE 归到 mysql 同类 (默认给 MySqlDialect),Oracle 兼容模式的 OceanBase 就会拿到错误的 LIMIT 方言。

5.3 CustomPaginationInterceptor 的实现

CustomPaginationInterceptor 继承 PaginationInnerInterceptor,重写 findIDialect用连接的真实产品名区分模式

java 复制代码
/**
 * OceanBase 分页方言自适应拦截器
 * OceanBase 有 Oracle 模式和 MySQL 模式,MyBatis-Plus 的 JdbcUtils 只能从 JDBC URL
 * 检测到 DbType.OCEAN_BASE,无法区分具体模式。本拦截器通过 JDBC 连接的
 * DatabaseMetaData.getDatabaseProductName() 在每次分页查询时动态判断实际模式,
 * 从而选择正确的分页方言(OracleDialect 或 MySqlDialect)。
 */
@Slf4j
public class CustomPaginationInterceptor extends PaginationInnerInterceptor {

    @Override
    protected IDialect findIDialect(Executor executor) {
        if (getDialect() != null) {
            return getDialect();          // 已缓存
        }
        // OceanBase 连接:按产品名细分模式
        if (getDbType() == DbType.OCEAN_BASE) {
            try {
                if (executor.getTransaction() != null) {
                    Connection conn = executor.getTransaction().getConnection();
                    String productName = conn.getMetaData().getDatabaseProductName();
                    if ("mysql".equalsIgnoreCase(productName)) {
                        return new MySqlDialect();   // MySQL 兼容模式 → LIMIT
                    }
                    // "Oracle" / "oceanbase" / "oceanbase-oracle" 均按 Oracle 处理
                    return new OracleDialect();      // Oracle 兼容模式 → ROWNUM
                }
            } catch (Exception e) {
                log.warn("Failed to detect OceanBase mode from connection, fallback to default", e);
            }
        }
        // 非 OceanBase 连接,走父类默认检测逻辑(Oracle / MySQL / DB2 / H2 等)
        return super.findIDialect(executor);
    }
}

要点:

  • 判定依据DatabaseMetaData.getDatabaseProductName()(4.2 节已确认:oceanbase-client 2.4.2 在 Oracle 模式返回 Oracle,MySQL 模式返回 MySQL),比 URL 更精确,能穿透 OceanBase 的双模式;
  • 分派规则 :产品名是 mysql(忽略大小写)→ MySqlDialect(LIMIT 分页);其余(Oracle / oceanbase / oceanbase-oracle)一律 → OracleDialect(ROWNUM 分页),兜底稳健;
  • 非 OceanBase 数据源不受影响 :落回 super.findIDialect,Oracle / MySQL / DB2 / H2 等仍走父类三级逻辑;
  • 两种方言生成的分页 SQL(3.5.5 源码):
方言 生成的 SQL
OracleDialect SELECT * FROM (SELECT TMP.*, ROWNUM ROW_ID FROM (原SQL) TMP WHERE ROWNUM <= ?) WHERE ROW_ID > ?
MySqlDialect 原SQL LIMIT ?,?

5.4 最终路由效果

数据源 JDBC URL 分页方言 分页语法
Oracle 主库 jdbc:oracle:thin:@... OracleDialect(父类默认探测) ROWNUM
OceanBase(Oracle 模式) jdbc:oceanbase://... OracleDialect(产品名检测) ROWNUM
OceanBase(MySQL 模式) jdbc:oceanbase://... MySqlDialect(产品名检测) LIMIT
MySQL jdbc:mysql://... MySqlDialect(父类默认探测) LIMIT

六、两条机制的分工小结

databaseIdProvider CustomPaginationInterceptor
识别依据 DatabaseMetaData.getDatabaseProductName() 同样按产品名(对 OceanBase 再细分模式)
作用范围 XML 手写 SQL 的语句级路由 自动分页 SQL
生效时机 加载期一次性确定 每次分页执行期动态检测
OceanBase 双模式 产品名 Oracle/MySQL → databaseId oracle/mysql 产品名 mysql → LIMIT,其余 → ROWNUM

一句话总结:用 MyBatis 原生 databaseId 机制解决"手写 SQL 的双模式",用自定义分页拦截器按连接产品名解决"自动分页 SQL 的双模式"。

7. 注意事项与坑

  1. databaseId 匹配是大小写敏感的 equals :XML 属性值必须与 Properties 的 value 完全一致(如小写 databaseId="mysql")。写成 MYSQL 会匹配失败,语句被静默丢弃、只剩兜底版;
  2. OceanBase 模式检测分支的触发条件getDbType() == DbType.OCEAN_BASE 依赖父类 dbType 字段(构造参数或 setDbType 设置)。未显式设置时该分支不会触发,将直接落入父类 JdbcUtils 的 URL 探测(3.5.5 对 OCEAN_BASE 默认给 MySqlDialect)。要启用产品名细分,需保证拦截器的 dbType 被设置为 OCEAN_BASE
  3. 方言对象按需创建、不做缓存findIDialect 每次新建方言对象(方言无状态,开销可忽略);若想缓存需注意多数据源共存时不能共用一个方言实例;
  4. 多数据源下拦截器是单例共享的:方言决策必须基于"每次调用时的连接",不能把某次探测结果存成全局状态,否则并发下会串扰。

八、总结

这套方案的精华在于:不写死任何环境信息,让框架在正确的时机去问正确的对象------手写 SQL 用 MyBatis 原生 databaseId 机制在加载期完成路由;自动分页 SQL 由自定义拦截器在每次查询时读取连接产品名,穿透 OceanBase 的双模式差异,精确选择 ROWNUM 或 LIMIT 方言。改动只有两个 Bean,业务代码零侵入,新增数据源无需任何代码调整。

相关推荐
Devin~Y16 小时前
从内容社区到AI智能客服:Spring Boot + Spring Cloud + Spring AI 全栈实战面试拆解
java·spring boot·redis·elasticsearch·spring cloud·kafka·mybatis
隔窗听雨眠1 天前
OceanBase接入DeepSeek:数据库与AI的深度融合如何改写企业数据规则
数据库·人工智能·oceanbase
lv__pf1 天前
spring之整合mybatis【TL spring 12】
mysql·spring·mybatis
ChaHae-In2 天前
MyBatis动态SQL与MyBatis-Plus高效开发指南
数据库·oracle·mybatis
csdn2015_3 天前
springboot +mybatis 查询myql开启程序级别缓存
spring boot·缓存·mybatis
OceanBase数据库官方博客3 天前
基于OceanBase的Agent全栈落地路径与记忆引擎实践
oceanbase
knqiufan3 天前
DeepSeek Harness 插件玩法第一步:先把项目上下文装进去
agent·oceanbase·powercontext
C++、Java和Python的菜鸟4 天前
第1章 项目前置课-Mybatis-Plus
mybatis
jaysee-sjc4 天前
【JavaWeb】Tlias智能学习辅助系统|后端Web实战(登录认证)
java·开发语言·前端·学习·mybatis