ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis多数据库方言切换实战:databaseId配置与踩坑

MyBatis多数据库方言切换实战:databaseId配置与踩坑 项目里有套老系统数据库是Oracle后来新模块要拆出去用MySQL但业务逻辑几乎一样只是分页语法、函数用法这些细节对不上。当时第一反应是拆两个Mapper结果发现大量SQL只是方言不同拆开维护成本太高。后来用MyBatis的databaseId databaseIdProvider一套Mapper接口两套SQL方言按数据库类型自动切换问题直接解决。这篇文章就把这套玩法的完整实现和踩坑记录整理出来。1. 需求场景与方案选型思路1.1 什么情况下会用到databaseId多数据源在Spring Boot里通常有两种理解。一种是同时连多个库不同业务用不同数据源这种一般靠AbstractRoutingDataSource或者像MyBatis-Plus的多数据源插件解决。另一种是同一个项目要适配不同类型的数据库比如开发环境用MySQL、生产环境切Oracle或者像我当时这样一套代码要同时支撑Oracle和MySQL两套库。databaseId解决的是第二种场景。它的核心思路是同一个Mapper接口同一个Statement ID在XML里为不同数据库写不同的SQL实现由MyBatis在启动时根据当前连接的实际数据库类型自动挑出对应的那一条SQL来执行。这个方案最大的优势就是省事。不需要拆Mapper不需要改接口也不需要在Service层做if-else判断。XML里同一个id下可以写多个databaseId版本的SQLMyBatis自己会做路由选择。1.2 为什么不用AbstractRoutingDataSource硬切有人可能会问既然是多数据源用AbstractRoutingDataSource在运行时动态切换数据源不也能解决问题吗确实能但那是另一种玩法。AbstractRoutingDataSource解决的是“同一个SQL在不同库上执行”的问题它切的是数据源SQL本身不能变化。如果你的SQL在MySQL和Oracle上写法相同那用路由切换就够了。但实际业务里方言差异是躲不掉的。分页就是最典型的例子Oracle要用ROWNUM或OFFSET FETCHMySQL要写LIMIT。还有日期函数、字符串拼接、空值处理到处都有坑。这时候你需要的是“同一套Mapper、不同SQL实现”而不是“同一套SQL、不同库执行”。所以结论很简单SQL完全兼容就用AbstractRoutingDataSourceSQL有方言差异就用databaseId让MyBatis帮你分流。1.3 databaseIdProvider在整条链路里的位置要理解databaseId先看一条完整链路。Spring Boot启动MyBatis自动配置类开始初始化SqlSessionFactory在这个过程中MyBatis会尝试获取一个DatabaseIdProvider实现类。拿到之后它会去调用DatabaseMetaData.getDatabaseProductName()拿到当前数据库的产品名比如MySQL、Oracle。然后这个产品名会被丢给DatabaseIdProvider的getDatabaseId(DataSource)方法最终得到一个数据库标识也就是databaseId。这个databaseId是全局的一个SqlSessionFactory只有一个。所以它天然适合“一个应用对应一种数据库方言”的场景。如果你要在一个应用里同时跑两个不同类型的库用同一套XML那是不行的因为全局只能有一个databaseId。2. 核心原理拆解VendorDatabaseIdProvider到底做了什么2.1 DatabaseIdProvider接口设计MyBatis自己带了一个默认实现类org.apache.ibatis.mapping.VendorDatabaseIdProvider。这个类的源码非常简单核心就是实现了DatabaseIdProvider接口。接口里只有两个方法public interface DatabaseIdProvider { void setProperties(Properties p); String getDatabaseId(DataSource dataSource) throws SQLException; }setProperties用来接收你配置的别名映射getDatabaseId则根据数据源连接拿到真实数据库产品名然后按照映射规则输出一个自定义ID。如果匹配不上就返回null。2.2 默认的数据库产品名映射逻辑VendorDatabaseIdProvider内部有一段内置的映射关系。它会先通过dataSource.getConnection().getMetaData().getDatabaseProductName()拿到数据库产品名然后做一次大小写归一化处理再去匹配内置的映射表。private MapString, String properties new HashMap(); public String getDatabaseId(DataSource dataSource) throws SQLException { Connection conn dataSource.getConnection(); try { return getDatabaseName(conn); } finally { conn.close(); } } private String getDatabaseName(Connection conn) throws SQLException { String productName conn.getMetaData().getDatabaseProductName(); if (this.properties ! null) { for (Map.EntryString, String property : properties.entrySet()) { if (productName.contains(property.getKey())) { return property.getValue(); } } return null; } return productName; }注意看这段代码用的是contains而不是equals。这意味着只要数据库产品名字符串里包含你配置的key就算匹配成功而且按遍历顺序取第一个命中的结果。MySQL实际返回的产品名是MySQLOracle返回的是OraclePostgreSQL返回的是PostgreSQL这些都能直接命中MyBatis内置的映射。2.3 自定义映射的优先级问题VendorDatabaseIdProvider的setProperties方法其实是把传入的Properties直接覆盖到内部的properties集合里。源码是这样的public void setProperties(Properties p) { this.properties.putAll(p); }这里有一个需要注意的点MyBatis在XMLConfigBuilder里解析databaseIdProvider标签的时候会先new一个VendorDatabaseIdProvider然后检查有没有配置子标签property有的话就调用setProperties。所以你在XML里配置的映射会覆盖默认的内置映射。但重点来了由于匹配逻辑是遍历properties这个Map而Map的遍历顺序在JDK 8之后是不可预期的HashMap无序所以如果你的多个key都命中了同一个产品名最终匹配到哪个是随机的。比如你配置了MySQL和MySQL-5.7两个key实际拿到产品名MySQL时contains判断两个都能命中但结果取决于遍历顺序。这种小概率坑遇到了会非常难受建议key尽量写成互不包含的独立字符串。3. Spring Boot整合实战完整配置步骤3.1 项目基础环境准备先说一下我当时的项目环境Spring Boot 2.3.7.RELEASEMyBatis Spring Boot Starter 2.1.4数据库Oracle 11g MySQL 5.7JDK 1.8实际上这个方案对版本要求并不高核心依赖就是mybatis-spring-boot-starter。如果你用的是Spring Boot 3.x对应要换成mybatis-spring-boot-starter3.x版本原理不变。我的做法是为了演示方便用一个主数据源接MySQL另外配了一个测试数据源接Oracle。实际项目中通常是通过不同的环境配置来切但配置方式完全一样。3.2 基础依赖引入dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.1.4/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.oracle.database.jdbc/groupId artifactIdojdbc8/artifactId version19.8.0.0/version /dependency3.3 application.yml配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/test_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root123 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity主数据源这里是MySQL。如果按正常启动MyBatis会自动探测到当前的DatabaseProductName是MySQL然后自动把databaseId设为mysql。3.4 关键配置databaseIdProvider单数据源情况下MyBatis的自动配置已经足够。但如果你在XML里写了多个databaseId版本就需要配置DatabaseIdProvider把数据库产品名映射到你期望的标识上。在Spring Boot中通过Bean方式注入即可Configuration public class MyBatisConfig { Bean public DatabaseIdProvider databaseIdProvider() { VendorDatabaseIdProvider provider new VendorDatabaseIdProvider(); Properties properties new Properties(); properties.setProperty(MySQL, mysql); properties.setProperty(Oracle, oracle); properties.setProperty(PostgreSQL, postgresql); provider.setProperties(properties); return provider; } }这其实是MyBatis的VendorDatabaseIdProvider的一个特性当你在Spring容器里注册了这个Bean之后MybatisAutoConfiguration就会把它设置到SqlSessionFactory上。如果你不想写Java配置也可以使用application.yml的mybatis.configuration配置mybatis: configuration: database-id: oracle但这种写法是直接指定一个固定的databaseId不是让MyBatis自动探测实际项目里很少这么用。3.5 编写带databaseId的Mapper XML有了databaseIdProvider之后真正发挥作用的地方在Mapper XML里。?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper select idselectUserList resultTypecom.example.demo.entity.User SELECT * FROM user_info /select select idselectUserPage resultTypecom.example.demo.entity.User databaseIdmysql SELECT * FROM user_info ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select select idselectUserPage resultTypecom.example.demo.entity.User databaseIdoracle SELECT * FROM ( SELECT t.*, ROWNUM AS rn FROM ( SELECT * FROM user_info ORDER BY create_time DESC ) t WHERE ROWNUM lt; #{offset} #{pageSize} ) WHERE rn gt; #{offset} /select /mapper注意几个细节第一条selectUserList没有写databaseId它会作为所有数据库的兜底SQL。后面两条selectUserPage分别指定了mysql和oracleMyBatis在启动加载时会根据当前探测到的databaseId决定绑定哪一个SQL。3.6 源码验证启动后到底选了哪条SQL为了确认MyBatis选的是哪条SQL最直接的办法是开启日志logging: level: com.example.demo.mapper: debug启动项目后观察日志里Preparing后面的SQL内容。如果连的是MySQL会看到LIMIT分页如果连的是Oracle会看到ROWNUM包装的嵌套查询。这里还能顺带验证一个事MyBatis在初始化的时候如果找不到匹配的databaseId会直接抛出异常提示类似Could not find a databaseId handler for statement xxx。所以SQL一定要有兜底版本或者确保匹配规则准确。4. 多数据源兼容策略从单库到多库的演进方案4.1 动态切换和databaseId的搭配使用前面说了AbstractRoutingDataSource解决的是“不同库执行相同SQL”的问题databaseId解决的是“不同库执行不同SQL”的问题。实际项目里往往是两者结合先用动态数据源按业务或租户把连接切到不同的库再靠databaseId保证SQL方言的正确性。这种方案的代码结构大概是public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }然后在配置里给SqlSessionFactory设置上databaseIdProvider就可以实现多数据源 多SQL方言的完整兼容。这里要注意一个坑AbstractRoutingDataSource的determineCurrentLookupKey()返回的key必须和targetDataSources里的key一致否则会走到默认数据源。同时DatabaseIdProvider的探测是在SqlSessionFactory初始化时执行的用的是默认数据源所以如果你切换到的目标库类型和默认库不同databaseId还是按默认库来定。4.2 同一项目配置MySQL和Oracle双数据源有一种场景是一个Spring Boot项目里同时配置了MySQL和Oracle两个数据源接口层分别走不同的业务模块。此时如果你只注册一个SqlSessionFactorydatabaseId只能有一个。所以通常的做法是建两个SqlSessionFactory各自指定不同的数据源和databaseIdProvider。Configuration public class MultiSqlSessionFactoryConfig { Primary Bean(name mysqlSqlSessionFactory) public SqlSessionFactory mysqlSqlSessionFactory( Qualifier(mysqlDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); // 设置mapper xml路径 factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/mysql/*.xml)); return factoryBean.getObject(); } Bean(name oracleSqlSessionFactory) public SqlSessionFactory oracleSqlSessionFactory( Qualifier(oracleDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/oracle/*.xml)); return factoryBean.getObject(); } }这种模式虽然能用但两个SqlSessionFactory之间是完全隔离的无法共享Mapper接口。所以它更适合“两个模块各管各的库”这种物理隔离的业务。4.3 用多环境配置简化部署差异前面说的都是“一个应用同时在跑多种库”的场景。现实里更多的情况是同一套代码部署到客户A那里用的Oracle部署到客户B那里用的MySQL。这个时候连双数据源都不用配只需要把databaseIdProvider配好XML里写好几套方言的SQL部署时根据环境调整datasource.url即可。比如把application.yml拆成application-mysql.yml和application-oracle.yml# application-mysql.yml spring: datasource: url: jdbc:mysql://192.168.1.100:3306/business username: root password: 123456启动时通过--spring.profiles.activemysql或--spring.profiles.activeoracle来切换。代码一行不用改XML里已经覆盖了两种方言的SQL部署到哪种环境就用哪种方言。5. 常见问题与排查技巧实录5.1 databaseId不生效一直在执行兜底SQL这是我当时遇到最诡异的问题。XML里明明写了databaseIdoracle但连Oracle库执行时用的还是默认SQLdatabaseId像没配置过一样。排查思路分三步走。第一步确认DatabaseIdProvider是否真的被设置到了SqlSessionFactory上最简单的方式是在自定义Bean里打日志第二步检查databaseId的值到底是什么可以在getDatabaseId方法里把metaData.getDatabaseProductName()打印出来看第三步确认XML的databaseId值和Provider输出的值是否完全一致注意大小写。我当时的问题就出在大小写上。MySQL返回的产品名是MySQL我配置的key写成了mysqlcontains判断确实能命中但MyBatis的匹配逻辑是拿配置的value和XML里的databaseId参数做equals比较两边标准不一样一个坑踩一天。5.2 配置了databaseIdProvider但依然报“Invalid bound statement”如果SqlSessionFactory没有正确加载databaseIdProviderMyBatis在解析XML时会把所有带databaseId的语句全部忽略只保留不带databaseId的兜底SQL。如果你的XML里只有带databaseId的SQL没有兜底版本那么运行时Mapper接口会报Invalid bound statement (not found)。这个报错还容易让人误判成Mapper扫描路径配错了其实问题就出在databaseIdProvider没生效。// 错误的检查方式以为Mapper没扫到 // 实际是SQL没有被绑定 Bean public ConfigurationCustomizer configurationCustomizer() { return configuration - { // 不打日志根本发现不了这里的问题 System.out.println(configuration.getDatabaseId()); }; }建议在启动时直接打印configuration.getDatabaseId()如果输出null说明Provider没生效去查Bean配置如果输出mysql说明Route没问题去查XML里的databaseId值。5.3 一个Mapper里有多个同id语句MyBatis会不会报错这个地方很多人搞不清楚。同一个namespace id下如果存在多个SQL并且靠databaseId区分MyBatis在加载时不会报错。MappedStatement的key是namespace . id但同一个key下可以有多个MappedStatement最终保留哪个由databaseId的匹配结果决定。简单来说MyBatis在MapperBuilderAssistant里会做这样一件事先解析XML里所有的语句如果语句上标注了databaseId就先放到一个临时Map里等所有语句解析完后再回头从临时Map里挑出和当前configuration.getDatabaseId()匹配的那条注册到正式的mappedStatements集合里。如果存在多条同id语句且没有一条的databaseId和当前配置匹配那最终这个语句不会被注册调用时就会报Invalid bound statement。5.4 事务、连接池和databaseId的相互作用这里补充一个容易忽略的细节。DatabaseIdProvider在获取数据库产品名时会调用dataSource.getConnection().getMetaData()。如果用的是连接池这个getConnection()拿到的是一条实际物理连接。连接池的连接复用一般不影响探测结果因为getDatabaseProductName()是数据库Server端返回的固定值不会变。但有一种情况如果连接池配置了connectionInitSqls在连接初始化时执行了一些会话级设置理论上不会影响产品名。真正需要注意的是如果用了像MyCat、ShardingSphere这样的中间件它们作为Proxy暴露给JDBC的DatabaseProductName可能是它们自己定义的比如ShardingSphere返回的是ShardingSphere此时内置映射和自定义映射都匹配不上databaseId就会变成null所有带databaseId的SQL都会失效。5.5 MyBatis-Plus和databaseId能不能共存我后来在另一个项目里用了MyBatis-Plus一开始也担心兼容问题。实际上MyBatis-Plus完全基于MyBatis扩展而来databaseIdProvider的机制不受影响。但要注意的是MyBatis-Plus自带的分页插件PaginationInnerInterceptor已经帮你处理了多种数据库的分页方言。它的原理是在SQL执行前动态改写SQL根据DatabaseId判断当前数据库类型然后拼上对应的分页语句。所以如果项目里用了MyBatis-Plus你完全可以不写两条分页SQL只写一条普通查询让分页插件去自动适配。不过自定义的复杂SQL里如果用了数据库特有函数比如Oracle的NVL、MySQL的IFNULL这些还是得靠databaseId来区分。6. 扩展实践自定义DatabaseIdProvider6.1 为什么需要自定义VendorDatabaseIdProvider的匹配规则是contains内置映射覆盖了主流数据库但有时候确实不够用。比如某些国产数据库JDBC驱动返回的产品名五花八门像达梦返回DM DBMS人大金仓返回KingbaseESGBase返回GBase 8a。如果你直接用内置Provider这些产品名可能匹配不到你期望的ID或者更糟两个数据库返回的产品名互相包含导致误匹配。这时候就需要自己写一个DatabaseIdProvider实现。6.2 自定义实现套路public class CustomDatabaseIdProvider implements DatabaseIdProvider { private MapString, String properties new HashMap(); Override public void setProperties(Properties p) { for (String key : p.stringPropertyNames()) { properties.put(key, p.getProperty(key)); } } Override public String getDatabaseId(DataSource dataSource) throws SQLException { String productName getProductName(dataSource); if (productName null || productName.isEmpty()) { return null; } productName productName.toLowerCase(); for (Map.EntryString, String entry : properties.entrySet()) { if (productName.contains(entry.getKey().toLowerCase())) { return entry.getValue(); } } return null; } private String getProductName(DataSource dataSource) throws SQLException { try (Connection connection dataSource.getConnection()) { return connection.getMetaData().getDatabaseProductName(); } } }这里我用了contains加统一转小写的方式比VendorDatabaseIdProvider的大小写敏感匹配要宽容很多。实际项目中我还会做一层IP白名单过滤比如只对特定IP段的库应用某个规则但那种业务就太定制化了一般用不到。6.3 配置自定义ProviderBean public DatabaseIdProvider databaseIdProvider() { CustomDatabaseIdProvider provider new CustomDatabaseIdProvider(); Properties properties new Properties(); properties.setProperty(MySQL, mysql); properties.setProperty(Oracle, oracle); properties.setProperty(DM DBMS, dameng); properties.setProperty(KingbaseES, kingbase); provider.setProperties(properties); return provider; }XML里的databaseId就对应上面value的值。比如达梦数据库就写select idselectByCode databaseIddameng resultTypemap SELECT * FROM t_code WHERE code #{code} /select6.4 自定义Provider的源码级检查点在MybatisAutoConfiguration里有一段逻辑决定了databaseIdProvider是怎么被使用的。正常情况下它会在容器里查找DatabaseIdProvider类型的Bean如果找不到就用VendorDatabaseIdProvider作为默认。所以如果你自定义了DatabaseIdProvider但注册失败或者被ConditionalOnMissingBean挡住了MyBatis还是会走默认实现这个问题不太容易发现。建议在getDatabaseId方法里加一行日志启动时看一眼输出System.out.println(Detected database product name: productName);日志一旦打出来说明自定义Provider生效了后面就只管匹配规则对不对。7. 写在最后的实操体会这套databaseId方案我前后用了两年多从最开始解决Oracle和MySQL的双写问题到后来演进到一套代码支持四种数据库Oracle、MySQL、PostgreSQL、达梦核心逻辑没变过变的主要是匹配规则怎么写、兜底SQL怎么留。如果你刚开始上手我的建议是先把兜底SQL写全再写具体方言的SQL。兜底SQL的作用不只是“备胎”它其实是在帮你快速暴露问题。如果某个数据库产品名没匹配上兜底SQL会生效业务不会挂但你会发现“咦这里执行的不是我想要的方言版本”排查起来有个方向。还有一个小技巧产品名带版本号的数据库比如MySQL 5.7、PostgreSQL 13contains匹配很容易误伤。所以匹配key尽量用不带版本号的独立单词并且把容易互相包含的key放到独立的判断分支里。最后再分享一个我踩过的坑用VendorDatabaseIdProvider时databaseId的值会和SQL内容绑定在一起。如果哪天你改了properties里的value比如把mysql改成mysql5那么所有XML里写着databaseIdmysql的SQL全部失效。在多人协作的项目里这个改动很容易被忽略建议把properties的key-value约定写进团队的开发文档里避免后面对不上。
返回列表