首页 / 资讯中心 / 文章详情

Spring Boot 集成 Druid 实现动态多数据源与读写分离实战

Spring Boot 集成 Druid 实现动态多数据源与读写分离实战 ★ FEATURED ARTICLE
去年年底我接手一个项目业务上有个很典型的需求用户数据和订单数据必须分库存放但后端只有一个 Spring Boot 服务所有接口都要共用一套 Mapper 和 Service。更麻烦的是业务高峰期要能把一部分读请求切到只读库降低主库压力。折腾了一圈最后用 Spring Boot 集成 Druid 做了一套可动态切换的多数据源方案这篇文章就把完整思路、核心代码、还有我踩过的几个大坑整理出来。如果你也在做读写分离、多业务库隔离或者正在纠结多数据源方案怎么落地这篇文章应该能帮你省不少时间。我会尽量把参数、代码、踩坑细节都写清楚方便你直接对照修改。1. 项目背景与方案选型1.1 为什么一个项目里会出现多数据源很多人一开始觉得多数据源是架构设计没做好才需要的但实际业务里太常见了。我这边的场景是历史包袱很重的系统用户中心和订单中心本来就分属两套库但服务层已经统一到一个后端工程里。如果强行把两套库合并涉及的数据迁移和数据权限隔离工作量大得吓人根本不现实。另一种常见场景是读写分离。虽然很多云数据库自带代理层但自建机房、混合云部署的场景下应用层读写分离依然很普遍。主库负责写入从库分担查询连接池层面就需要支持“同一个 Service 方法里查询走从库、写入走主库”这种调度。所以多数据源的核心价值不是炫技而是用最少的改造成本让一个应用进程可以同时访问多个数据库实例并且能在运行时决定某条 SQL 走哪个库。1.2 连接池选型Druid 的优势在哪里Spring Boot 默认连接池是 HikariCP性能确实强但它偏“轻量”没有内置的监控面和控制台。生产环境一旦出现连接泄漏、慢 SQL 变多、连接池被打满HikariCP 只能靠外部监控系统或者看日志才能发现排查效率很低。Druid 在我这边的定位是既要连接池能力又要 SQL 级监控。它能通过内置的 StatFilter 统计每个 SQL 的执行频率、耗时、返回行数能通过 WallFilter 做简单的 SQL 防火墙还能提供一个 /druid/ 的 Web 监控页直接看活跃连接数、SQL 执行情况、慢 SQL 列表。对于多数据源场景Druid 还能很方便地按数据源分别展示统计信息哪个库出了问题一眼就能定位。还有一个很实际的点Druid 的防御性能力。比如线上误操作执行全表 UPDATE 或 DELETEWallFilter 可以拦截这类高危 SQL。对于多数据源环境库多了之后操作面也大这类保护非常有意义。1.3 两条路线动态路由还是多套 DataSource多数据源在 Spring 里大概有两种落地思路。第一种是“静态多套 DataSource”主库一个 DataSource Bean从库一个 DataSource Bean每个 DataSource 配合独立的 SqlSessionFactory、独立的事务管理器Mapper 分包扫描。这种方案隔离性最好但配置量大而且 Mapper 必须物理分包业务代码里跨库操作变得很别扭。第二种是“动态路由 DataSource”对外只暴露一个主 DataSource内部用 Spring 自带的 AbstractRoutingDataSource 做路由根据当前线程上下文决定某个 SQL 用哪个目标库。配合注解和切面业务代码只需要在方法上打一个标签非常轻量。我最终选了动态路由这条路原因很直接项目里大部分 Service 方法的数据源是确定的用注解标记比拆 Mapper 包要省事而且两套库的表结构差异不大共用一套 SqlSessionFactory 和实体类完全可行。如果你对框架原理吃透一点后续接分布式事务、接分库分表中间件这个方案的扩展性也更好。2. 环境准备与基础搭建2.1 版本组合与依赖引入项目基础是 Spring Boot 2.5.x JDK 8数据库是 MySQL 8.x。多数据源方案里最关键的依赖是 Druid 和 MyBatis我自己用 Maven 管理版本组合放在下面。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.0/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies这里有个版本问题要注意如果你用的是 Spring Boot 3.xDruid 的 Servlet 类涉及 jakarta.servlet 替换1.2.20 之前的版本会踩兼容性坑。建议 Spring Boot 2.x Druid 1.2.20 这种组合最稳Spring Boot 3.x 项目要专门确认 Druid 的适配版本。2.2 关闭自动配置最容易忽略的第一步Spring Boot 的 DataSourceAutoConfiguration 会在 classpath 里只有一个 DataSource 时自动装配。我们现在要手动创建两个数据源这个自动装配机制反而会干扰初始化顺序最常见的报错就是Failed to configure a DataSource: url attribute is not specified。我建议在主启动类里直接把自动配置关掉SpringBootApplication(exclude { DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class }) public class MultiDataSourceApplication { public static void main(String[] args) { SpringApplication.run(MultiDataSourceApplication.class, args); } }如果你的项目引入了 druid-spring-boot-starter还需要考虑排除它内部的 DruidDataSourceAutoConfigure不然它可能在启动阶段再创建一个默认数据源导致多个 DataSource Bean 同时存在。关闭自动配置之后数据源的创建完全交给自己的配置类行为可控。2.3 配置文件多个数据源参数如何组织我习惯把两个库的连接池参数直接放到各自的前缀下这样每个库的配置是独立的排查线上问题也更直观。核心配置如下spring: datasource: master: url: jdbc:mysql://localhost:3306/user_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000 validation-query: SELECT 1 test-while-idle: true test-on-borrow: false test-on-return: false filters: stat,wall slave: url: jdbc:mysql://localhost:3306/order_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000 validation-query: SELECT 1 test-while-idle: true test-on-borrow: false test-on-return: false filters: stat,wall注意几个参数filters: stat,wall一定要配stat负责 SQL 统计wall负责 SQL 防火墙。没配 statDruid 监控页里看不到 SQL 明细。test-while-idle配合validation-query: SELECT 1Druid 会在连接空闲时做探活避免拿到死连接。initial-size建议等于min-idle让连接池启动就预热好避免刚开始几个请求特别慢。3. 动态数据源核心实现3.1 路由引擎用 AbstractRoutingDataSource 做调度Spring 框架本身提供了一个抽象类AbstractRoutingDataSource。它的工作原理很简单内部维护一个MapObject, Object的目标数据源集合每次调用getConnection()时会调用子类实现的determineCurrentLookupKey()返回一个 key然后用这个 key 去 map 里找真正的 DataSource。我把主从两个库注册成master和slave两个 key路由类很轻量public class DynamicDataSource extends AbstractRoutingDataSource { public static final String MASTER master; public static final String SLAVE slave; Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSourceKey(); } }这个类一旦注册成唯一的 DataSource Bean业务层的 SqlSessionFactory、事务管理器、MyBatis 就都面向这个“门面” DataSource 工作真正连哪个库由路由类决定。3.2 数据源上下文ThreadLocal 存储路由标识既然要在运行时动态决定 key那必须把 key 存在某个当前线程可访问的地方。我选择用 ThreadLocal 封装一个上下文工具类存取当前线程的数据源标识public class DynamicDataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void setDataSourceKey(String key) { CONTEXT.set(key); } public static String getDataSourceKey() { return CONTEXT.get(); } public static void clearDataSourceKey() { CONTEXT.remove(); } }这里有一个容易踩坑的点如果用完不清理 ThreadLocal线程池里的线程复用时会把上一次调用的数据源 key 带到下一次请求里出现严重的串库问题。比如 A 请求设置了 slave处理完没清理B 请求虽然是主库写操作却因为线程池复用读到了残留下来的 slave keySQL 全跑到了从库。所以清理动作必须放在 finally 或者切面的后置通知里。另外记住Async异步场景下 ThreadLocal 不会自动传给子线程异步方法内部需要重新设置数据源 key。3.3 自定义注解加切面给方法打上数据源标签有了路由类还需要一个业务层使用的入口。我定义了一个DataSource注解允许配置在 Service 类或方法上Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface DataSource { String value() default DynamicDataSource.MASTER; }然后写一个切面在目标方法执行前设置 key方法结束后清理 key。注意我用的是Around保证异常路径下也能清理Aspect Component Order(0) public class DataSourceAspect { Around(annotation(dataSource)) public Object switchDataSource(ProceedingJoinPoint point, DataSource dataSource) throws Throwable { DynamicDataSourceContextHolder.setDataSourceKey(dataSource.value()); try { return point.proceed(); } finally { DynamicDataSourceContextHolder.clearDataSourceKey(); } } }Order(0)这里很关键后面讲事务问题的时候会细说。简单理解要保证数据源切面尽可能先执行避免事务已经拿到默认连接后切面才切换库那样切换就失效了。3.4 Druid 监控一劳永逸的连接池体检多数据源上线后连接池数量翻倍问题定位难度也翻倍。Druid 的监控页是刚需我把监控模块单独做了一个配置类Configuration public class DruidMonitorConfig { Bean public ServletRegistrationBeanStatViewServlet statViewServlet() { ServletRegistrationBeanStatViewServlet registration new ServletRegistrationBean(); registration.setServlet(new StatViewServlet()); registration.addUrlMappings(/druid/*); registration.addInitParameter(loginUsername, druid); registration.addInitParameter(loginPassword, druid2024); registration.addInitParameter(allow, 127.0.0.1); return registration; } Bean public FilterRegistrationBeanWebStatFilter webStatFilter() { FilterRegistrationBeanWebStatFilter registration new FilterRegistrationBean(); registration.setFilter(new WebStatFilter()); registration.addUrlPatterns(/*); registration.addInitParameter(exclusions, *.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*); return registration; } }生产环境务必把/druid/*监控页和allow白名单收口不要暴露到公网。我一般只允许运维网段访问再配一个强度足够的登录密码。4. 实操环节完整落地过程4.1 配置类逐个落地数据源配置类是整套方案的核心。我是这样做的为每个库创建独立的 DruidDataSource Bean然后用ConfigurationProperties绑定各自的前缀最后组装成一个 DynamicDataSourceConfiguration public class DruidDataSourceConfig { Bean ConfigurationProperties(spring.datasource.master) public DataSource masterDataSource() { return new DruidDataSource(); } Bean ConfigurationProperties(spring.datasource.slave) public DataSource slaveDataSource() { return new DruidDataSource(); } Bean Primary public DynamicDataSource dataSource(DataSource masterDataSource, DataSource slaveDataSource) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(DynamicDataSource.MASTER, masterDataSource); targetDataSources.put(DynamicDataSource.SLAVE, slaveDataSource); DynamicDataSource dynamicDataSource new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(masterDataSource); return dynamicDataSource; } }有几个细节必须说清楚Primary一定要加在 DynamicDataSource 上。因为容器里现在有三个 DataSource 类型的 Bean而 MyBatis 的自动配置、事务管理器默认只注入一个 DataSource必须用Primary指定唯一入口。defaultTargetDataSource我设置成 master防止某些场景下 ThreadLocal 里没有 key路由走到默认主库。如果设置成 null找不到 key 时会直接报错。两个数据源的ConfigurationProperties各自绑定独立的 url、username、password 和连接池参数。注意我这里把连接池参数放在每个库自己下面好处是每个库可以配置不同的连接池上限。比如订单库压力大max-active 可以调到 50用户库保持 20。接下来是 MyBatis 侧的配置。因为共用一套 SqlSessionFactory需要把 DynamicDataSource 注入进去Configuration MapperScan(basePackages com.example.mapper, sqlSessionFactoryRef sqlSessionFactory) public class MybatisConfig { Bean public SqlSessionFactory sqlSessionFactory(DynamicDataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factoryBean new MybatisSqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setTypeAliasesPackage(com.example.entity); PathMatchingResourcePatternResolver resolver new PathMatchingResourcePatternResolver(); factoryBean.setMapperLocations(resolver.getResources(classpath*:mapper/*.xml)); org.apache.ibatis.session.Configuration configuration new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); configuration.setDefaultFetchSize(100); factoryBean.setConfiguration(configuration); return factoryBean.getObject(); } Bean public DataSourceTransactionManager transactionManager(DynamicDataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }注意这里的transactionManager接收的是 DynamicDataSource而不是某个具体的 masterDataSource。这样事务管理器在开启事务时会先通过路由类拿到正确目标库的连接事务边界才能和数据源路由对齐。4.2 业务层怎么用注解配置全部落地后业务层只需要在需要固定数据源的方法上打注解Service public class UserOrderService { private final UserMapper userMapper; private final OrderMapper orderMapper; public UserOrderService(UserMapper userMapper, OrderMapper orderMapper) { this.userMapper userMapper; this.orderMapper orderMapper; } DataSource(DynamicDataSource.MASTER) Transactional(rollbackFor Exception.class) public void createUser(User user) { userMapper.insert(user); } DataSource(DynamicDataSource.SLAVE) public ListOrder listOrdersByUser(Long userId) { return orderMapper.selectByUserId(userId); } }这里listOrdersByUser查询订单库走 slavecreateUser写用户库走 master。每个方法的数据源都是明确的切面会在进入方法前设置好对应 key。4.3 事务与数据源切换的雷区多数据源方案里最容易出问题的不是配置而是事务。我遇到过最典型的坑在同一个事务方法里先查再改查询想走从库更新想走主库。理论上数据源切面可以切换到两个 key但实际上一旦Transactional开启Spring 事务管理器会在线程上绑定一个 Connection后续所有 SQL 都复用这个连接数据源路由已经没机会再切换了。说得更细一点Transactional和自定义切面都有执行顺序问题。我用Order(0)让数据源切面优先执行这样事务管理器获取连接时ThreadLocal 里已经有正确 key连接会从正确库里取出来。但如果同一个事务方法内部想切换两次数据源第一次事务开始时的连接已经绑定第二次切换 key 不会产生任何效果。连接还是第一次拿到的那个。如果自定义切面和事务切面顺序没控制好事务甚至可能在默认主库上开启整个方法都跑到了主库。所以我的经验是Transactional方法的数据源必须在方法入口就确定不要想着事务执行到一半再去切。跨库操作请拆分成多个不带事务的小方法或者单独放到不同 Service 里让每个方法各自拥有独立事务边界。如果两个库之间需要强一致本地事务已经解决不了得上分布式事务方案。下面的表是我在实际项目中总结出来的决策方式场景推荐做法方法只需要访问一个库且需要事务方法上同时加DataSource和Transactional方法需要先查从库再写主库拆成两个方法主库写方法单独加Transactional查询列表和详情没有写操作只加DataSource(SLAVE)不加事务跨库强一致考虑分布式事务组件本地事务放弃4.4 数据源粒度与连接池参数调整多数据源还有一个容易被忽略的操作细节连接池参数不要盲目照搬。两个库的并发量不一样连接池配置也应该不一样。我实际调整时是这样做的slave 库因为承担了大量查询流量max-active明显高于 mastermaster 库写并发相对低但每条写事务耗时长所以max-wait调大避免写请求频繁拿不到连接。这些参数不是越大约好。连接池过大数据库本身无法支撑那么多连接反而会出现大量 TIME_WAIT。建议先压测再上线迭代调整。5. 常见问题与排查实录5.1 启动报错Failed to configure a DataSource这个报错几乎是初期的老朋友。原因通常是 Spring Boot 找不到可用的 DataSource Bean或者自动配置试图基于配置创建数据源但缺少 url。排查思路确认启动类是否排除了DataSourceAutoConfiguration。确认masterDataSource、slaveDataSource、DynamicDataSource三个 Bean 是否都创建成功。可以在配置类里临时加日志输出。确认MapperScan的sqlSessionFactoryRef正确指向了你定义的 SqlSessionFactory而不是自动配置的默认工厂。5.2 Druid 监控页有界面但 SQL 统计为空监控页面能打开说明 StatViewServlet 没问题。SQL 统计为空九成是因为数据源没有配置filters: stat。Druid 的 SQL 统计依赖 StatFilter这个过滤器数据源内部要开启。检查你自己的配置里有没有filters: stat,wall。如果配了还是没有再看 WebStatFilter 的 exclusions 是否把/druid/*也排除了这个不影响 StatFilter但会影响 Web 请求统计。5.3 写了 DataSource 但数据源没切过去这种情况分两种。第一种是切面压根没生效。检查切面类是否被 Spring 扫描到注解是否放在了 Spring 管理的 Service 方法上以及调用方式是否为代理调用。如果在一个类内部用this.xxx()调用同类方法注解切面不会生效。第二种是事务已开启导致切换失效。就像第 4.3 节说的先看方法有没有Transactional如果有数据源切换只能在事务入口之前生效。排查时可以先把事务注解去掉再测切换定位很快。5.4 线程复用导致数据源串库这个坑隐蔽但有代表性。我在压测时发现某个纯查询接口偶尔会把写操作带到从库半天没找到原因最后发现是线程池复用导致 ThreadLocal 里残留了上一次调用的 key。根治办法就是切面里finally清理保证每个线程用完立即 remove。同时我建议在 DynamicDataSource 的determineCurrentLookupKey()里加一个兜底判断如果 key 不在master和slave白名单里直接返回 null 走默认源。这样即使出现脏数据最坏也只是走到默认主库不会因为 key 拼写错误导致连接获取失败。5.5 Mapper 扫描与 XML 路径不一致多数据源方案共用一套 SqlSessionFactoryMapper 扫描路径必须精确。如果MapperScan的包路径和实际 Mapper 接口包不一致或者 XML 路径配置错误最常见的现象是启动正常、调用时提示Invalid bound statement (not found)。建议MapperScan的 basePackages 指向 Mapper 接口的具体包。factoryBean.setMapperLocations里的classpath*:mapper/*.xml确保能匹配到实际 XML 文件位置。如果对象字段和表字段使用下划线风格一定要开启mapUnderscoreToCamelCase不然查询结果全是 null。6. 一些个人经验和优化建议这套方案我在项目里落地之后整体运行比较稳定但回过头来有几个体会特别深。第一能不动多数据源就不动。如果两套库的数据边界很清晰长远看还是建议拆微服务或者独立出专用的查询服务。动态路由虽然开发方便但它本质上是把多库的复杂性藏在了框架层团队里每个人都要清楚“切数据源”这件事的边界和限制否则后期容易出现跨库事务这种难排查的问题。第二连接池监控一定要提前配上。多数据源跑起来之后连接池数量翻倍没有监控的话慢 SQL、连接数飙升、连接泄漏这些问题都只能靠用户投诉才暴露。Druid 的用户体验足够安逸了登录监控页看一遍基本能定位到很多潜在隐患。上线之前一定把/druid/*的访问白名单收好。第三事务边界要统一约定。我们团队在代码规范里明确规定Transactional方法和DataSource注解必须写在同一个方法上而且一个事务方法内不允许切换数据源。这条约定执行之后多数据源的报错数量大幅减少。第四如果你不想自己造轮子可以考虑直接使用现成的多数据源 Starter很多是基于AbstractRoutingDataSource实现的原理和这篇文章讲的几乎一致。自己实现的好处是完全掌控代码细节排查问题不虚。两者没有绝对优劣只看你的团队更在意长期维护成本还是快速落地。最后分享一个小技巧上线前可以用一段压测脚本模拟“全部走主库”和“查询走从库”两种流量观察两边的连接池曲线。如果 slave 连接数增长过快而 master 比较平稳说明读写分离生效正常如果 master 连接数也同步增长说明代码里可能还有大量查询没有打上DataSource(SLAVE)注解需要逐个排查补上。
阅读完成 · 觉得有帮助?
咨询建站