最近一个交付项目里,客户要求把自己的环境从MySQL迁到PostgreSQL。我们的产品线是Spring Boot + MyBatis那一套,XML里写了不少MySQL方言,分页用的LIMIT、JSON字段操作、日期格式化函数,全和MySQL绑定。按老办法,我得在Service层一个个判断当前数据库类型,或者干脆复制一套Mapper XML,改一个业务字段要同步维护好几份文件。后来我把MyBatis的databaseId机制彻底用了起来,才真正厘清"同一套代码兼容多个数据库"这件事该怎么做。这篇就以databaseId和databaseIdProvider为主线,讲讲最简使用方式、底层匹配规则,以及实际项目里我踩过的坑。适合正在维护多数据库产品、做SaaS交付,或者面试前想搞懂MyBatis方言切换底层逻辑的同学参考。
1. 同一套代码适配多套数据库:我为什么盯上databaseId
1.1 产品交付遇到多数据库,最朴素的做法是怎么变复杂的
一开始项目只支持MySQL,数据库方言都集中在Mapper XML里。后来客户A要求Oracle,客户B要求PostgreSQL,我第一反应是复制Mapper XML,改SQL,再根据配置切换mapper-locations路径。这个方案维护成本很快就炸了:业务表120多张,每张表三四个查询语句,改一个字段要同步改三套XML。有一次我改了MySQL分支的别名逻辑,Oracle和PostgreSQL的XML忘了同步,客户现场半个月后暴露出来,排查成本极高。
接着我转向动态SQL方案,在XML里用<if test="dbType == 'postgresql'">来判断方言分支。SQL量少的时候还能撑住,一旦SQL复杂起来,条件分支和业务判断混在一起,阅读体验极差。而且Service层必须显式传入dbType,Mapper接口的方法签名也被这个参数污染了。本质上这两种方案都是在把"数据库方言差异"当成业务代码的一部分在维护,全写在了错误的位置。
1.2 换个视角:方言差异应当由SQL映射层自己解决
databaseId的思路和上面完全不同:它在MyBatis的SQL映射层提供了一套"按数据库类型选择SQL语句"的机制。我不需要在Service层做任何判断,不需要传任何表示数据库类型的参数,只需要在XML里针对不同的databaseId各写一份SQL。运行时MyBatis会根据当前连接的数据库类型,自动挑选对应的SQL执行。
这相当于把"方言差异"从业务代码里剥离出去,下沉到映射层里。对于分页、主键生成、日期格式化这类强方言差异点,databaseId是最自然的落点。比如MySQL的LIMIT ?, ?分页和Oracle的ROWNUM分页,写在同一个XML、同一个select节点ID下,数据库一变化,选择就自动切换,业务层零改动。
1.3 先分清databaseId和多数据源的关系
一搜databaseId,经常连带到"多数据源"这个词,很多人会把两者混为一谈。我之前也犯过这个糊涂。多数据源指的是一个应用连多个数据库实例,重点是DataSource连接池的隔离;databaseId则是针对同一个Mapper映射逻辑,在不同数据库产品类型之间做SQL方言切换。两者是不同维度的事:多数据源解决"连谁",databaseId解决"连上之后SQL按什么方言写"。
如果你的多数据源恰好是不同类型的数据库——比如主库PostgreSQL、从库MySQL——两个概念就会同时出现,这也是标题里"兼容多数据源的databaseId"的真实语境。把这个边界想清楚,后面配置时才不会糊。
2. databaseId与databaseIdProvider的执行链路:从DataSource到Mapper文件的匹配规则
2.1 DatabaseIdProvider接口到底在干嘛
先看接口定义:
public interface DatabaseIdProvider { String getDatabaseId(DataSource dataSource) throws SQLException; }MyBatis在构建SqlSessionFactory的时候,会拿到当前配置的数据源,调用databaseIdProvider.getDatabaseId(dataSource)得到一个字符串,作为当前的数据库标识。这个字符串会存进Configuration.databaseId字段。之后XML解析阶段,Mapper文件里凡是带databaseId属性的语句,都会被单独挑出来登记,并在运行时和当前Configuration.databaseId做匹配。
官方已经给了一个实现类——VendorDatabaseIdProvider,它的工作流程很直接:
- 从数据源拿到连接,再拿
DatabaseMetaData; - 调用
getDatabaseProductName()得到数据库产品名,比如"MySQL"、Oracle返回"Oracle"、PostgreSQL返回"PostgreSQL"; - 把产品名统一转成小写、去掉空格,做内部别名归一化;
- 和
properties配置里的键做匹配,匹配成功就返回properties里配置的值。
这里有个细节:映射键如果不配置,VendorDatabaseIdProvider会直接返回归一化后的产品名作为databaseId。所以理论上不写setProperties也能用,只是返回的字符串是"mysql"、"oracle"这种原始名。这有一个好处,它完全基于数据库连接来识别,不需要我手动在配置里声明当前环境是MySQL还是PostgreSQL,避免了配置文件写错导致和实际连接不一致的问题。
2.2 XML解析阶段是怎么用databaseId的
MyBatis启动时,XMLMapperBuilder会逐个解析Mapper XML里的语句节点,解析过程中判断语句上的databaseId属性和当前Configuration.databaseId的关系:
- 如果语句带databaseId,且等于当前databaseId,则注册成功,作为运行时候选;
- 如果语句带databaseId,且不等于当前databaseId,则跳过注册;
- 如果语句不带databaseId,永远注册,作为默认兜底。
这一步的源码核心在XMLStatementBuilder和MapperBuilderAssistant里,一句话概括:MyBatis在元数据层预留了一个"当前SQL方言ID",注册语句时做一次条件过滤。理解这点很重要,后面排查"为什么我的SQL没有走到预期分支"时,第一步一定是确认当前Configuration.databaseId的值是否真的等于XML里的databaseId,而不是先怀疑驱动问题。
2.3 匹配规则的微妙之处
用表格表达不同的匹配结果更直观:
| 场景 | 当前databaseId = mysql | 当前databaseId = oracle | 当前databaseId = 未设置 |
|---|---|---|---|
| 带databaseId="mysql"的语句 | 注册 | 跳过 | 跳过 |
| 带databaseId="oracle"的语句 | 跳过 | 注册 | 跳过 |
| 不带databaseId的语句 | 注册 | 注册 | 注册(兜底) |
注意第三行:不带databaseId的语句永远注册,这正是"默认语句兜底"行为的来源。实际写SQL时,如果某个查询在不同数据库下写法完全一样,我就只写一份不带databaseId的;只有方言差异明确的查询才拆分支。这样既减少重复,又保证极端情况——比如新增一种数据库但没写对应分支——不至于直接报找不到SQL。
3. Spring Boot项目里完整接入databaseId的落地方式
3.1 引入依赖和基础配置
如果是Spring Boot 2.x项目,最省事的组合是:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency>Spring Boot 3.x要切换到mybatis-spring-boot-starter3.x版本。搜索热词里"springboot版本太高"的问题,多数和starter版本不匹配有关。我遇到过Spring Boot 3.2配mybatis starter 2.2,自动配置直接失效,SqlSessionFactory都没创建,更别提databaseId。
YAML里常规配置就够了,不需要额外加databaseId专属配置:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true3.2 注册DatabaseIdProvider Bean的方式(最推荐)
Spring Boot的MybatisAutoConfiguration在构建SqlSessionFactory时,会先去Spring容器里查找有没有类型为DatabaseIdProvider的Bean,找到就直接注入。所以接入databaseId最干净的一步,是声明一个Bean:
@Configuration public class MybatisDatabaseIdConfig { @Bean public DatabaseIdProvider databaseIdProvider() { VendorDatabaseIdProvider provider = new VendorDatabaseIdProvider(); Properties properties = new Properties(); properties.put("MySQL", "mysql"); properties.put("Oracle", "oracle"); properties.put("PostgreSQL", "postgresql"); provider.setProperties(properties); return provider; } }这里的Properties键值对,前面是VendorDatabaseIdProvider识别用的产品名,后面是我在XML里使用的databaseId标识。建议统一用小写命名,避免在XML里写SQL时还要纠结大小写。配置完毕之后,mybatis.mapper-locations不需要任何特殊处理,同一个XML里可以同时包含不同databaseId的同名statement。
3.3 自定义DatabaseIdProvider应对非标准驱动
某些中间件数据库、云数据库网关的驱动返回的产品名可能不是标准值。比如有些国产数据库驱动会返回"MySQL"但实际行为接近Oracle,或者返回的产品名带着一大堆版本号。这时候VendorDatabaseIdProvider可能匹配不上。我一般直接写一个自定义实现,做一次字符串清洗:
public class CustomDatabaseIdProvider implements DatabaseIdProvider { private Properties properties; @Override public void setProperties(Properties properties) { this.properties = properties; } @Override public String getDatabaseId(DataSource dataSource) throws SQLException { try (Connection connection = dataSource.getConnection()) { String productName = connection.getMetaData().getDatabaseProductName(); String dbId = productName.toLowerCase().replaceAll("[^a-z0-9]", ""); if (properties != null && properties.containsKey(dbId)) { return properties.getProperty(dbId); } if (dbId.startsWith("mysql")) return "mysql"; if (dbId.startsWith("postgresql")) return "postgresql"; if (dbId.startsWith("oracle")) return "oracle"; return dbId; } } }自定义的好处很直接:我可以放心把产品名里的非字母数字字符都去掉,避免"MySQL 8.0.36"这种带版本号的返回值影响映射。还有一点要特别注意,实现接口必须保留setProperties方法,MyBatis会通过反射调用它把属性传进去,漏掉这个方法在某些场景下会直接空指针。
3.4 多数据源时每个SqlSessionFactory各挂各的databaseId
如果你的多数据源是两个不同类型的数据库,手动创建SqlSessionFactory时,要让每个Factory都注入同一个DatabaseIdProviderBean,或者分别创建实例。关键在于:databaseId结果是在创建SqlSessionFactory时确定的,一旦Factory创建完成,它所属的那一批Mapper XML就只能按那一个databaseId来匹配。
所以主库PostgreSQL、从库MySQL、用两套SqlSessionFactory时,每个Factory的Configuration.databaseId是各自计算的。硬把两个数据源塞进同一个SqlSessionFactory是不行的,那套AbstractRoutingDataSource动态路由方案只能解决连接切换问题,管不了SQL方言选择。这一点后面讲踩坑时会展开。
4. Mapper XML中databaseId的实战写法与细节
4.1 同一statementId的数据库分支写法
最典型的场景是分页。MySQL和PostgreSQL都支持LIMIT,Oracle不行。假设我要写一个findUsersByPage查询:
<select id="findUsersByPage" resultType="com.example.User" databaseId="mysql"> SELECT id, name, created_at FROM user ORDER BY id LIMIT #{offset}, #{pageSize} </select> <select id="findUsersByPage" resultType="com.example.User" databaseId="postgresql"> SELECT id, name, created_at FROM user ORDER BY id LIMIT #{pageSize} OFFSET #{offset} </select> <select id="findUsersByPage" resultType="com.example.User" databaseId="oracle"> SELECT * FROM ( SELECT t.*, ROWNUM rn FROM ( SELECT id, name, created_at FROM user ORDER BY id ) t WHERE ROWNUM <= #{offset} + #{pageSize} ) WHERE rn > #{offset} </select>注意Oracle分支里用了<和>,这是XML转义,写SQL时最容易漏。三个statement使用同一个idfindUsersByPage,它们不会冲突,因为MyBatis注册时其实是以namespace + id + databaseId作为内部唯一标识的。
4.2 databaseId和动态SQL的组合使用
databaseId不是只能整段替换。SQL主体相同的场景,可以先写一份不带databaseId的主查询,再用<if>分隔方言片段:
<select id="queryUserList" resultType="com.example.User"> SELECT id, name, created_at FROM user <where> <if test="name != null and name != ''"> AND name = #{name} </if> </where> <if test="offset != null and pageSize != null"> <choose> <when test="_databaseId == 'mysql'"> LIMIT #{pageSize} OFFSET #{offset} </when> <when test="_databaseId == 'postgresql'"> LIMIT #{pageSize} OFFSET #{offset} </when> <when test="_databaseId == 'oracle'"> AND id IN (SELECT id FROM (SELECT id, ROWNUM rn FROM user ORDER BY id) WHERE rn > #{offset} AND rn <= #{offset} + #{pageSize}) </when> </choose> </if> </select>这里有个MyBatis内置参数_databaseId,它可以直接在动态SQL里取到当前数据库ID。官方提供这个参数,就是为了让动态SQL有机会根据数据库类型做小范围判断。我的经验是:_databaseId适合做小差异分支,差异大的场景优先拆成完整statement,更清晰。
4.3 默认语句兜底与新增数据库时的包容性
不带databaseId的SQL在所有数据库下都会注册。我的习惯是:公共SQL不挂databaseId,方言差异大的拆分支。这样一旦遇到一个没配置databaseId的数据库类型,比如SQL Server,至少公共SQL还能跑;如果某个方法只有方言分支而没有默认版本,新数据库接入时就会直接报statement not found,问题暴露得很清晰。
这里面有个隐蔽的坑:如果某个statementId在XML里存在无databaseId版本,同时当前databaseId下也存在带databaseId的精确版本,MyBatis究竟用哪个?我在MyBatis 3.5.x源码MapperBuilderAssistant.addMappedStatement里确认过:带databaseId的与无databaseId的都会被保存,最终选择时会先按精确的id查,查不到再回退到无databaseId的版本。换句话说,精确分支优先,兜底分支保底。
5. 我踩过的坑与问题排查链路
5.1 databaseId完全没有被识别的排查路线
有次接手一个已有项目,我加了DatabaseIdProvider Bean后,XML里的databaseId分支怎么都不生效。排查步骤可以总结成一条链路:
- 先看启动日志里
SqlSessionFactory构建时有没有加载DatabaseIdProvider。VendorDatabaseIdProvider匹配失败时通常会打印一行debug日志,说明它读到了什么产品名。 - 如果没加载,优先怀疑starter版本不兼容,或者Bean没被扫描到。Spring Boot 2.7.0以上配合mybatis-spring-boot-starter 2.x版本,
MybatisAutoConfiguration的注入条件可能不满足。最简单的验证方法是在databaseIdProvider的Bean方法里加一个启动日志,或者在getDatabaseId()里断点。断点不触发,说明Bean根本没被使用。 - 如果Bean触发了但配置不生效,打印返回值。确认返回值和XML里的databaseId字符串完全一致——比如大小写不一致,
"MYSQL"和"mysql"怎么都匹配不上。 - 最后一种情况是项目里有多个
SqlSessionFactory,databaseIdProvider只挂到了其中一个Factory上。
这套链路每次都能帮我快速收敛,光靠肉眼检查配置远远不够。
5.2 动态数据源路由场景里databaseId被错误识别
有次配置了读写分离,主库是PostgreSQL,从库也是PostgreSQL,但从库连的是云数据库网关地址,网关驱动返回的产品名是"PostgreSQL-compatible"。当时用了AbstractRoutingDataSource做动态路由,两个SqlSessionFactory共用了同一个DatabaseIdProvider。结果启动时SqlSessionFactory拿到的连接走的是默认数据源,产品名被识别成奇怪的字符串,SQL分支全乱套。
这个问题引出一个更底层的事实:databaseId是构建期锁定的,不是运行时每条SQL动态计算。就算你用动态数据源在运行时切来切去,Configuration.databaseId在Factory创建那一刻就已经固定了,后续不会跟着连接走。所以动态路由 + 多数据库类型这个组合,用单个SqlSessionFactory是解决不了方言切换的,必须拆成多个SqlSessionFactory。
排查这种问题,我给每个数据源打印产品名:
try (Connection c = dataSource.getConnection()) { System.out.println(c.getMetaData().getDatabaseProductName()); }确认哪个数据源产品名异常后,我在CustomDatabaseIdProvider里增加按JDBC URL特征做二次归一的逻辑,才把问题彻底压住。
5.3 版本太高带来的连锁反应
搜索热词里"springboot版本太高"确实是从Spring Boot 3.x开始的高频问题。Spring Boot 3要求JDK 17,javax.*切换为jakarta.*,如果还用旧版mybatis starter,自动配置直接跳过,DatabaseIdProvider不会被注入。
我的建议是Spring Boot 3.x项目直接从mybatis-spring-boot-starter3.0.3起步,不要用2.x的兼容补丁。另外Spring Boot 3.2以后,自动装配注册方式改成了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,老版本自定义自动配置类可能不再生效。这一点排查databaseIdProvider不加载时也要考虑进去。
5.4 用SQL日志验证选路结果
想确认运行时到底走了哪个SQL分支,最管用的不是猜,是打印实际SQL日志:
logging: level: com.example.mapper: debugMyBatis会打印每条预编译SQL和参数,看到当前语句是LIMIT还是ROWNUM,一眼就知道databaseId有没有生效。如果你项目里接入了MyBatis拦截器做分页或审计,要注意拦截器在Executor层执行时,拿到的已经是选好的MappedStatement,SQL分支已经定了。不要再在拦截器里根据数据库类型二次修改SQL,除非你做的是统一的分页插件。我见过有人在分页拦截器里塞了一套方言判断,和databaseId分支相互打架,最后两边都改,极其难维护。选一个机制就好,别叠加。
在我实际的项目里,databaseId真正帮我省下来的,不只是XML文件的数量,更是每次业务改动时"改一处还是改三处"的决策成本。它是一个轻量又明确的机制——不需要引入额外中间件,不需要改造现有Mapper结构,只需要定义好Provider、在XML里按方言拆分statement即可。但这套方案也有边界:它只解决SQL方言问题,跨数据库的锁行为、事务隔离级别、性能特征仍要靠SQL本身和测试来兜底。
最后再分享一个个人习惯:不管用哪种数据库,同一张表的查询分支我都会写一条不带databaseId的默认SQL作为保底。宁可它平时不执行,也不能在数据库类型没映射上时让服务直接白屏。希望这篇对正在做多数据源兼容改造的你有点帮助。