最开始接到这个需求时,我以为只是给 MyBatis 的databaseId写个配置模板,真正动手才发现,Spring Boot + MyBatis Plus 项目里要把DatabaseId用好,涉及的细节远比想象中多:自动装配、XML 加载机制、动态上下文变量、分页与方言的边界……哪一个没搞透,都会在生产环境给你埋雷。
这篇文章不打算写成 API 文档的复述,而是从一个真实遇到过的问题出发:同一套系统要在 MySQL 和 PostgreSQL 上部署,BaseMapper 的自带 CRUD 完全没问题,分页插件也能自动处理,但自定义 SQL 里那句LIMIT #{offset}, #{size}在 PG 环境直接翻车。我花了一个下午排查,最后把眼光落到了 MyBatis 的 DatabaseId 机制上。如果你也在做多数据库兼容,或者正在从单数据库迁移到另一种数据库,这篇文章应该能帮你少走不少弯路。
1. DatabaseId 解决了什么问题:一段先踩坑的经历
1.1 从一条分页 SQL 说起
项目是一个典型的 Spring Boot + MyBatis Plus 单体服务,开发阶段一直跑在 MySQL 8 上。某个客户要求部署到 PostgreSQL 环境,我本以为 MyBatis Plus 的分页插件已经做了方言适配,换库只是改个数据源配置的事。结果客户环境一启动,跑批任务只处理了前十条数据就“正常结束”了。查日志发现,某个自定义 Mapper 方法里写的是:
<select id="selectPendingOrders" resultType="com.example.entity.Order"> SELECT * FROM t_order WHERE status = 'PENDING' ORDER BY create_time LIMIT #{offset}, #{size} </select>在 MySQL 里LIMIT 20, 10表示跳过 20 条取 10 条,但在 PostgreSQL 里,LIMIT后面只能跟一个参数实现“取多少条”,“跳过多少条”必须用OFFSET。于是LIMIT 20, 10在 PG 中变成了“取 20 条后忽略 10 条”,实际只返回 10 条,跑批任务以为处理完了,后续的数据全被漏掉。
这个 bug 不报错、不警告,只在数据量超过分页阈值后静默出错,是最难排查的那一类问题。
1.2 我试过的几种方案
问题确认后,我列了几种候选方案,挨个评估:
- 改代码适配:把所有自定义 SQL 里的分页语法改成兼容写法。听上去简单,但项目里类似 SQL 有几十处,而且 MySQL 和 PG 的日期函数、序列、树形查询都有差异,改一处漏一处。
- 按环境加载不同 Mapper XML:用 Spring Profile 控制扫描不同目录下的 XML。能解决问题,但同一个方法要维护两份 XML,公共部分无法复用,改字段时要同步改多处,维护成本高。
- 用 MyBatis 的 DatabaseId:同一个
statement id下,根据当前数据库自动选择对应 SQL 片段。这套机制是 MyBatis 原生的,不依赖 Spring,也不需要额外引入依赖。
我当然选了第三种,也因为这条路踩了几个坑,才把整个机制彻底翻了一遍。
1.3 MyBatis 为什么会设计 DatabaseId
简单说,Mapper 接口的方法名相当于一个“查询接口”,SQL 是它的“实现”。当同一个接口需要在不同数据库上给出不同实现时,MyBatis 希望你能在statement上标注一个databaseId属性,运行环境是什么数据库,就加载哪条 SQL。这就好比同一把钥匙要能开不同锁芯的锁:钥匙的齿形不同,但拿钥匙的手是同一个。
它和单纯的“多环境配置”最大的区别是:数据库 ID 不是靠 Spring Profile 事先写死的,而是 MyBatis 在运行时主动检查当前DataSource的DatabaseMetaData.getDatabaseProductName(),再通过DatabaseIdProvider映射成一个短 ID。这意味着同一套代码、同一个配置包,在 MySQL、PostgreSQL 上部署时能自动选择对应的 SQL 分支,不需要改任何外部配置。
2. Spring Boot + MyBatis Plus 中配置 DatabaseIdProvider
2.1 注册 DatabaseIdProvider Bean
MyBatis 提供了一个具体的实现类VendorDatabaseIdProvider,它负责读取数据库产品名,再跟自定义映射表做比对,输出一个我们约定的短 ID。Spring Boot 环境下,只需要在配置类里声明一个 Bean:
package com.example.config; import org.apache.ibatis.mapping.DatabaseIdProvider; import org.apache.ibatis.mapping.VendorDatabaseIdProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.Properties; @Configuration public class MybatisDatabaseIdConfig { @Bean public DatabaseIdProvider databaseIdProvider() { VendorDatabaseIdProvider provider = new VendorDatabaseIdProvider(); Properties props = new Properties(); props.setProperty("MySQL", "mysql"); props.setProperty("Oracle", "oracle"); props.setProperty("PostgreSQL", "postgresql"); props.setProperty("Microsoft SQL Server", "sqlserver"); props.setProperty("H2", "h2"); props.setProperty("SQLite", "sqlite"); provider.setProperties(props); return provider; } }这里的 key 是数据库产品名,也就是DatabaseMetaData.getDatabaseProductName()返回的原始值;value 是我们希望 MyBatis 使用的短 ID。以后在 XML 里写databaseId="mysql"还是databaseId="postgresql",都以这里的 value 为准。
有人会问:VendorDatabaseIdProvider不是自带默认映射吗,为什么还要自己配置?因为默认映射在不同 MyBatis 版本里有差别,比如 SQL Server 在旧版本里映射成ms-sql,新版本又可能是sqlserver,与其依赖版本行为,不如显式声明,至少在团队内部能形成一个统一约定。
2.2 它是怎么被自动装配的
如果你用的是mybatis-plus-boot-starter,MyBatis Plus 的自动配置类在创建SqlSessionFactory时,会从 Spring 容器里找DatabaseIdProvider类型的 Bean,找到后调用factory.setDatabaseIdProvider(...)注入进去。所以上面的@Bean声明完之后,理论上不需要再写别的代码。
这个“理论上”有个前提:你没有手动创建自定义的SqlSessionFactoryBean。如果你因为某些特殊需求自己写了一个@Bean SqlSessionFactory,那就得手动调用factory.setDatabaseIdProvider(databaseIdProvider()),否则自动配置的逻辑会被整个覆盖掉。这也是我后来在一个老项目里排查半天才发现的隐藏问题。
2.3 如何快速确认配置生效
配置完成后,第一步不是急着写 SQL,而是先确认Configuration里的databaseId被正确赋值。最简单的办法是写一个集成测试打印出来:
@SpringBootTest class DatabaseIdVerificationTest { @Autowired private SqlSessionFactory sqlSessionFactory; @Test void printCurrentDatabaseId() { String databaseId = sqlSessionFactory.getConfiguration().getDatabaseId(); System.out.println("current databaseId = " + databaseId); } }如果输出的是mysql、postgresql这样的值,说明 Provider 装配成功;如果输出null,哪怕 XML 里写了databaseId属性,对应 SQL 也根本不会被加载。这个“输出为 null”的情况我在后面第五节会详细讲,是一个很容易踩的坑。
3. XML SQL 中 databaseId 的两种用法
3.1 独立分支:同一个 statement id 按数据库区分
最直观的用法,是在同一条select上写两个不同的databaseId:
<select id="selectPendingOrders" resultType="com.example.entity.Order" databaseId="mysql"> SELECT * FROM t_order WHERE status = 'PENDING' ORDER BY create_time LIMIT #{offset}, #{size} </select> <select id="selectPendingOrders" resultType="com.example.entity.Order" databaseId="postgresql"> SELECT * FROM t_order WHERE status = 'PENDING' ORDER BY create_time LIMIT #{size} OFFSET #{offset} </select>调用方依然是orderMapper.selectPendingOrders(page, size),完全感知不到底层有两个 SQL 分支。MyBatis 在解析 XML 时,会按当前环境筛选:当前是 MySQL,就加载databaseId="mysql"的语句;当前是 PG,就加载databaseId="postgresql"的语句;其他分支会被直接丢弃。这样就实现了“同一接口,多数据库实现”,代码层零侵入。
这种写法的好处是每个分支的 SQL 都很纯粹,不容易因为动态判断造成 SQL 拼接顺序混乱;坏处则是复制量大,如果 SQL 有 50 行,两个分支就是 100 行,后续调整字段时很容易漏改其中一个分支。
3.2 合并写法:用动态上下文变量 _databaseId 判断
如果两个数据库的 SQL 整体结构一致,只有某个局部语法不同,那么更推荐用 MyBatis 提供的上下文变量_databaseId做局部动态判断:
<select id="selectPendingOrders" resultType="com.example.entity.Order"> SELECT * FROM t_order WHERE status = 'PENDING' ORDER BY create_time <choose> <when test="_databaseId == 'mysql'"> LIMIT #{offset}, #{size} </when> <when test="_databaseId == 'postgresql'"> LIMIT #{size} OFFSET #{offset} </when> <otherwise> LIMIT #{offset}, #{size} </otherwise> </choose> </select>_databaseId是 MyBatis 在配置DatabaseIdProvider后注入到上下文里的一个隐藏变量,可以直接在 OGNL 表达式中访问。这里要注意,它的值必须和VendorDatabaseIdProvider里配置的 value 完全一致,大小写也要一致。
这种写法的核心优势是“增量式差异”:先写一个默认版本,再在分歧点做微调,而不是把整个 SQL 复制一份。项目里如果多数据库支持会越来越多,我一般更倾向于这种写法,因为它在代码阅读时,一眼就能看出不同数据库之间的差异点到底在哪。
3.3 两种写法的取舍,以及注解 SQL 的边界
选择标准其实不难:差异点多、每个分支的 SQL 差异很大时用独立分支;90% 相同、只有一两句方言不同时用_databaseId。我的经验法则是一段 SQL 超过 30 行就尽量用独立分支,否则动态判断嵌套太多,OGNL 表达式反而变成了可读性杀手。
另外注意,注解 SQL(比如@Select("..."))本身不支持声明databaseId属性。如果想在注解场景做方言分支,只能通过@SelectProvider的方式,在 Provider 类里根据当前环境手动拼 SQL。但这里有两个麻烦:一是 Provider 方法里拿_databaseId不方便,二是字符串拼接 SQL 可读性远不如 XML。所以我的建议很直接:凡是涉及方言分支的 SQL,一律放到 XML 里写,注解 SQL 只处理那些“各数据库写法一致”的简单查询。
4. 实际项目中的多数据库兼容设计:分页、主键、日期与树形
4.1 分页差异:不要把方言写死在 SQL 里
分页是方言差异的重灾区。除了文章开头提到的 MySQL 和 PG 的LIMIT/OFFSET差异,Oracle 12c 之前用ROWNUM包三层子查询,12c 之后可以用FETCH FIRST,SQL Server 则是TOP+ROW_NUMBER()。如果每个库都自己写一套完整分页,维护量会非常大。
更合理的做法是:把分页参数交给 MyBatis Plus 的分页插件,让PaginationInnerInterceptor根据方言统一包装 SQL。你在 XML 里只写WHERE和ORDER BY,不要自己写LIMIT:
<select id="selectPendingOrders" resultType="com.example.entity.Order"> SELECT * FROM t_order WHERE status = 'PENDING' ORDER BY create_time </select>Mapper 方法接收IPage参数,分页插件会在执行前自动帮你在 SQL 尾部追加对应方言的分页语法。这样,MySQL、PG、Oracle、SQL Server 的物理分页全部交给拦截器,databaseId只需要处理那些“分页插件管不到”的方言问题。
但要特别提醒:如果你的自定义 SQL 里已经手动写了LIMIT,又同时配置了分页插件,插件会在外层再包一层分页,结果就是出现“分页套分页”的诡异 SQL,数据量一大就会出现重复或丢失。所以用 MyBatis Plus 的分页能力时,SQL 里就不要自己写分页关键字,这是团队代码规范里必须写明的一条。
4.2 插入主键生成:自增与序列的典型写法
自增主键和序列的差异,也是多数据库兼容里绕不开的问题。MySQL 依赖AUTO_INCREMENT,Oracle 和 PostgreSQL 早期版本依赖序列对象。使用databaseId可以优雅地区分:
<insert id="insertOrder" parameterType="com.example.entity.Order" useGeneratedKeys="true" keyProperty="id" databaseId="mysql"> INSERT INTO t_order(order_no, amount, status) VALUES (#{orderNo}, #{amount}, #{status}) </insert> <insert id="insertOrder" parameterType="com.example.entity.Order" databaseId="oracle"> <selectKey keyProperty="id" order="BEFORE" resultType="java.lang.Long"> SELECT SEQ_ORDER_ID.NEXTVAL FROM DUAL </selectKey> INSERT INTO t_order(id, order_no, amount, status) VALUES (#{id}, #{orderNo}, #{amount}, #{status}) </insert>注意selectKey的order属性:Oracle 需要在插入前先取序列值,所以order="BEFORE";MySQL 需要在插入后通过LAST_INSERT_ID()拿自增主键,所以交给useGeneratedKeys处理。这些细节如果不用databaseId区分,就只能通过代码里冗长的if判断来处理,非常不优雅。
4.3 日期函数与树形查询的差别
日期获取函数在不同数据库里差异也很大:MySQL 是NOW(),Oracle 是SYSDATE,PostgreSQL 是CURRENT_TIMESTAMP,SQL Server 是GETDATE()。如果 SQL 里要取当前时间做判断,建议优先通过 Java 层统一传入时间参数,而不是在 SQL 里写数据库特有函数,这样能从源头上减少方言分支。
实在要在 SQL 里获取当前时间时,可以借助_databaseId做局部判断:
<select id="selectTodayOrders" resultType="com.example.entity.Order"> SELECT * FROM t_order WHERE create_time <![CDATA[ >= ]]> <choose> <when test="_databaseId == 'mysql'">NOW()</when> <when test="_databaseId == 'oracle'">SYSDATE</when> <when test="_databaseId == 'postgresql'">CURRENT_TIMESTAMP</when> <otherwise>NOW()</otherwise> </choose> </select>树形查询更是方言差异的重灾区。Oracle 有CONNECT BY PRIOR,MySQL 8 可以用WITH RECURSIVE,但老版本 MySQL 只能靠递归存储过程或者程序递归。这种场景很难用统一的 SQL 表达,用databaseId独立分支是最实际的方案——因为每个分支的 SQL 结构完全不同,硬要合并成一个动态 SQL,反而会让代码没法看。
4.4 与 MyBatis Plus 分页插件的边界
最后要把这条边界划清楚:MyBatis Plus 的分页插件解决的是“物理分页语法”问题,databaseId解决的是“SQL 本身在不同数据库上的方言差异”问题。两者不冲突,但也不能互相替代。分页插件处理的是最终 SQL 的包装,它不知道你 SQL 里的GROUP_CONCAT、CONNECT BY、序列取值这些方言写法是否兼容目标数据库。
所以我的项目分工是:能用BaseMapper和LambdaQueryWrapper解决的查询,绝不写自定义 SQL;必须写自定义 SQL 时,先思考分页是否交给插件,再思考哪些局部语法需要databaseId分支;最后考虑这段 SQL 是不是差异太大,直接拆成独立分支更清晰。
5. databaseId 的加载匹配逻辑与最容易踩的坑
5.1 源码里的判断逻辑
排查问题的过程中,我翻了一下 MyBatis 解析 Mapper XML 的源码,发现它对databaseId的处理逻辑大致是这样的:
- 从 XML 节点上读取
databaseId属性。 - 如果
Configuration.getDatabaseId()不为 null(说明 Provider 装配成功),那么只有满足“databaseId属性为空”或“databaseId属性等于当前值”的语句才会被加载。 - 如果
Configuration.getDatabaseId()为 null(说明 Provider 没装配上),那么带有databaseId属性的语句会被直接跳过,不带databaseId的语句正常加载。
这个逻辑解释了一个非常容易踩的坑:在 XML 里写了databaseId="mysql",但容器里没有注册DatabaseIdProvider,那么这条 SQL 不会被加载,运行时会报Invalid bound statement (not found)。我当时排查时,第一反应是 Mapper XML 没扫到,检查了半天扫描路径,最后才发现是 Provider 没生效。
5.2 坑:Bean 注册了但没有生效
DatabaseIdProviderBean 注册了,但Configuration.getDatabaseId()仍然是 null,这种情况我遇到过几次,原因通常是这三个之一:
第一,项目里自定义了SqlSessionFactoryBean,把自动配置的注入逻辑覆盖掉了。这种情况需要在自定义工厂里手动setDatabaseIdProvider。
第二,用了老版本 MyBatis Plus,自动配置类不支持读取DatabaseIdProvider。升级到较新的 3.5.x 版本,或者自己手动加工厂配置。
第三,多数据源场景下,某个SqlSessionFactory绑定的是代理数据源,产品名可能被代理包装掩盖,导致获取不到真实的数据库产品名。动态数据源项目里,最好确认一下DatabaseMetaData拿到的产品名是目标库的,而不是连接池代理层返回的。
5.3 坑:大小写与 SQL Server 的别名
databaseId的值是大小写敏感的。_databaseId == 'MySQL'和_databaseId == 'mysql'完全是两个概念。我习惯在配置里统一用小写短 ID,团队代码规范里也明确要求小写,避免后面一群人各自写各自的大小写。
另一个细节是 SQL Server 的映射。VendorDatabaseIdProvider的默认映射在不同版本里不一样,曾经有一段时期默认输出是ms-sql,而不是sqlserver。如果你不显式配置映射表,而是依赖默认行为,那么在 XML 里写databaseId="sqlserver"就永远匹配不上。解决办法就是我在第二节里写的:不要依赖默认映射,显式声明 properties,让团队内部 ID 统一可控。
5.4 坑:不要把 databaseId 用在公共 sql 片段上
我在项目里习惯把公共查询字段抽成<sql id="BaseColumnList">,一开始想当然地给<sql>片段也加了databaseId属性,结果发现加载行为很奇怪:部分语句能用,部分报错。排查后确认,databaseId最好只用在select/insert/update/delete这些最终语句节点上,用在<sql>片段上时,各版本解析行为不完全一致,容易产生“片段被跳过但引用者还在”的状态。
稳妥做法是:<sql>片段只抽那些“所有数据库写法一致”的内容;一旦片段本身存在方言差异,就不要抽出来了,直接在最终分支里分别写。
5.5 坑:公共 SQL 想省事,用了不兼容的函数
最后一个坑不是配置上的,而是写 SQL 时的惯性思维。比如 MySQL 的GROUP_CONCAT、DATE_FORMAT,在 PG 里对应的是STRING_AGG、TO_CHAR,写法完全不同。如果你在一个没有databaseId分支的 SQL 里使用了这类函数,换库之后要么直接报错,要么返回错误结果。这类问题属于“代码审查死区”,因为开发环境永远是 MySQL,只有到了客户环境才暴露。我现在的做法是:涉及这类函数时即使只有一个数据库在用,也要把这段 SQL 设计成带databaseId的可扩展结构,并预留另一分支的占位实现,逼着自己思考兼容性问题。
6. 我的几点实操体会
这套机制完整跑通后,回头再看,有几点体会比较深。
第一,不要等客户环境出问题再配置DatabaseId。如果项目定位是“可能多数据库部署”,在项目第一天就应该把DatabaseIdProviderBean 配上,哪怕所有 XML 暂时都不写databaseId,也不会产生任何副作用,但后续需要分支时直接就能用。
第二,_databaseId和独立分支不是二选一,可以混用。我的原则是:局部方言差异用_databaseId,整体 SQL 结构差异大就用独立分支。两种方式共存并不冲突,关键是团队规范里要写明“什么场景用哪种”。
第三,多数据库兼容的最终保障是集成测试。我在项目里搭了一个测试配置,用 H2 模拟一部分 MySQL 行为,再单独建一个 PostgreSQL 测试实例,CI 里会同时跑一遍完整 Mapper 的集成测试。只有自动化测试能防止“改了一边忘了另一边”的低级错误。
最后分享一个小技巧:如果你只是某个复杂报表 SQL 需要多库分支,又不想全局注册DatabaseIdProvider,可以先不启用databaseId,在Mapper.xml里用${databaseType}这种自定义变量来代替。但这种方式需要手动在参数里传入数据库类型,不够透明,所以我最终仍然选择了全局 Provider,因为从长期维护角度看,让 MyBatis 自己感知数据库类型,才是最省心的方案。