ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

去年年底我接手一个项目,业务上有个很典型的需求:用户数据和订单数据必须分库存放,但后端只有一个 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 或 DELETE,WallFilter 可以拦截这类高危 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>

这里有个版本问题要注意:如果你用的是 Spring Boot 3.x,Druid 的 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true 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 防火墙。没配 stat,Druid 监控页里看不到 SQL 明细。
  • test-while-idle配合validation-query: SELECT 1,Druid 会在连接空闲时做探活,避免拿到死连接。
  • initial-size建议等于min-idle,让连接池启动就预热好,避免刚开始几个请求特别慢。

3. 动态数据源核心实现

3.1 路由引擎:用 AbstractRoutingDataSource 做调度

Spring 框架本身提供了一个抽象类AbstractRoutingDataSource。它的工作原理很简单:内部维护一个Map<Object, 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 ThreadLocal<String> 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 key,SQL 全跑到了从库。所以清理动作必须放在 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 ServletRegistrationBean<StatViewServlet> statViewServlet() { ServletRegistrationBean<StatViewServlet> registration = new ServletRegistrationBean<>(); registration.setServlet(new StatViewServlet()); registration.addUrlMappings("/druid/*"); registration.addInitParameter("loginUsername", "druid"); registration.addInitParameter("loginPassword", "druid@2024"); registration.addInitParameter("allow", "127.0.0.1"); return registration; } @Bean public FilterRegistrationBean<WebStatFilter> webStatFilter() { FilterRegistrationBean<WebStatFilter> 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绑定各自的前缀,最后组装成一个 DynamicDataSource:

@Configuration 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) { Map<Object, 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 List<Order> listOrdersByUser(Long userId) { return orderMapper.selectByUserId(userId); } }

这里listOrdersByUser查询订单库,走 slave;createUser写用户库,走 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明显高于 master;master 库写并发相对低,但每条写事务耗时长,所以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 路径不一致

多数据源方案共用一套 SqlSessionFactory,Mapper 扫描路径必须精确。如果@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)注解,需要逐个排查补上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进