一、背景
平台是多数据源共存的:主库 Oracle,另有 OceanBase(Oracle 兼容模式)库,o32 等模块还接入了 MySQL。两个痛点:
- XML SQL 写一份不够用------同一查询在 Oracle 语法和 MySQL 语法下写法不同,不可能每个环境维护一套 Mapper;
- 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 会在加载期自动算出当前库的 databaseId(oracle / 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 已有方言版则被丢弃)。
注意:匹配是大小写敏感的 equals ,databaseId 属性值必须与 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();
}
}
MasterDataSourceConfig、O32DataSourceConfig、TimsDataSourceConfig 等都调用它构建,因此每个数据源都会独立计算自己的 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. 注意事项与坑
- databaseId 匹配是大小写敏感的
equals:XML 属性值必须与 Properties 的 value 完全一致(如小写databaseId="mysql")。写成MYSQL会匹配失败,语句被静默丢弃、只剩兜底版; - OceanBase 模式检测分支的触发条件 :
getDbType() == DbType.OCEAN_BASE依赖父类dbType字段(构造参数或setDbType设置)。未显式设置时该分支不会触发,将直接落入父类JdbcUtils的 URL 探测(3.5.5 对OCEAN_BASE默认给 MySqlDialect)。要启用产品名细分,需保证拦截器的dbType被设置为OCEAN_BASE; - 方言对象按需创建、不做缓存 :
findIDialect每次新建方言对象(方言无状态,开销可忽略);若想缓存需注意多数据源共存时不能共用一个方言实例; - 多数据源下拦截器是单例共享的:方言决策必须基于"每次调用时的连接",不能把某次探测结果存成全局状态,否则并发下会串扰。
八、总结
这套方案的精华在于:不写死任何环境信息,让框架在正确的时机去问正确的对象------手写 SQL 用 MyBatis 原生 databaseId 机制在加载期完成路由;自动分页 SQL 由自定义拦截器在每次查询时读取连接产品名,穿透 OceanBase 的双模式差异,精确选择 ROWNUM 或 LIMIT 方言。改动只有两个 Bean,业务代码零侵入,新增数据源无需任何代码调整。