08-多数据源配置:一个项目连接多个MySQL数据库

📅 2026/8/21 22:17:28
08-多数据源配置:一个项目连接多个MySQL数据库
多数据源配置一个项目连接多个MySQL数据库黒漂技术佬 · 2026年7月前言某无人售货柜公司起步时只有一个数据库订单、商品、用户全堆在一起。后来业务扩张门店从 10 家变成 200 家单库扛不住了。更麻烦的是运营系统要查历史数据做报表而历史数据被归档到了另一个库。研发同学一脸懵一个 SpringBoot 项目怎么同时连两个 MySQL还有种场景主库负责写从库负责读。写入走主库保证数据一致性读取走从库分担压力。这也是两个数据源。多数据源不是什么高深技术但配置不当会踩一堆坑事务跨库怎么办连接池要不要分开切换数据源时忘加注解导致写到了从库怎么办这篇就把多数据源的配置和使用讲清楚。一、什么场景需要多数据源1.1 读写分离主库Master负责写操作从库Slave负责读操作。这是 MySQL 主从复制架构下的标准玩法。应用 -- [写] -- 主库 (Master) | -- [读] -- 从库 (Slave) ← (主库同步)好处读请求分摊到从库主库压力小了整体 QPS 上去了。注意主从复制有延迟毫秒到秒级。刚写入主库的数据立刻去从库查可能查不到。这就是读写延迟问题后面会讲怎么处理。1.2 多业务库不同业务模块用不同的数据库。比如订单库存储订单、支付记录商品库存储商品信息、库存用户库存储用户信息、积分好处业务隔离一个库出问题不影响其他业务单库数据量小便于维护。1.3 历史数据归档热数据近3个月在主库冷数据3个月以前归档到历史库。报表系统查历史库不影响线上交易。二、SpringBoot 多数据源方案2.1 方案对比方案原理优点缺点dynamic-datasource基于 AOP 注解切换配置简单注解切换灵活跨库事务需额外处理手动配置 DataSource自己注册多个 DataSource完全可控配置繁琐代码量大ShardingSphere分库分表中间件支持分库分表读写分离较重学习成本高日常项目中 90% 的场景用dynamic-datasource就够了。它来自 MyBatis-Plus 团队和 SpringBoot 整合极好。2.2 引入依赖dependencygroupIdcom.baomidou/groupIdartifactIddynamic-datasource-spring-boot-starter/artifactIdversion4.3.0/version/dependency注意引入这个依赖后SpringBoot 默认的spring.datasource配置会被接管你需要用新的配置格式。2.3 配置文件spring:datasource:dynamic:primary:master# 默认数据源strict:false# 未匹配到数据源时是否报错datasource:master:# 主库写url:jdbc:mysql://192.168.1.10:3306/vending_machine?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghaiusername:rootpassword:Master123driver-class-name:com.mysql.cj.jdbc.Driverslave:# 从库读url:jdbc:mysql://192.168.1.11:3306/vending_machine?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghaiusername:rootpassword:Slave123driver-class-name:com.mysql.cj.jdbc.Driverorder_db:# 订单库独立业务库url:jdbc:mysql://192.168.1.20:3306/order_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghaiusername:rootpassword:Order123driver-class-name:com.mysql.cj.jdbc.Driver配置解释primary: master未指定数据源时默认用 masterstrict: false未匹配数据源时用 primary 兜底不报错。设为 true 则抛异常每个数据源独立配置 url、username、password底层各自维护独立的连接池三、DS 注解切换数据源3.1 基本用法DS是 dynamic-datasource 提供的注解加在方法或类上指定该方法/类使用哪个数据源。ServicepublicclassOrderService{AutowiredprivateOrderMapperorderMapper;// 默认用 masterprimary配置的Transactional(rollbackForException.class)publicvoidcreateOrder(OrderDOorder){orderMapper.insert(order);}// 指定用从库读DS(slave)publicOrderDOgetOrderById(Longid){returnorderMapper.selectById(id);}// 指定用订单库DS(order_db)publicListOrderDOqueryHistoryOrders(LonguserId){returnorderMapper.selectByUserId(userId);}}作用范围加在方法上只对该方法生效加在类上该类所有方法都使用指定数据源方法上的注解优先级更高3.2 加在 Mapper 层更推荐把DS加在 Mapper 层按业务维度划分DS(slave)publicinterfaceProductReadMapper{ProductDOselectById(Longid);ListProductDOselectByCategory(LongcategoryId);}DS(master)publicinterfaceProductWriteMapper{intinsert(ProductDOproduct);intupdateById(ProductDOproduct);intdeductStock(Longid,intqty);}这样 Service 层不用关心数据源切换调对应的 Mapper 就自动走对应的数据源。3.3 DS 的原理DS本质上是通过 AOP 拦截方法调用在方法执行前把数据源 key 存到ThreadLocal中DynamicRoutingDataSource根据 ThreadLocal 里的 key 路由到对应的DataSource。方法执行完后清除 ThreadLocal。这也是为什么DS不能跨线程——子线程拿不到主线程 ThreadLocal 里的数据源 key。如果你在Async方法里用DS需要手动在子线程里设置数据源。四、事务跨数据源问题这是多数据源最大的坑。4.1 问题复现Transactional(rollbackForException.class)publicvoidtransferOrder(LongorderId){// 1. 从 order_db 删订单orderMapper.deleteById(orderId);// DS(order_db)// 2. 写入 master 库archiveMapper.insert(order);// DS(master)// 3. 抛异常thrownewRuntimeException(出错了);}你期望两个操作都回滚。但实际上只有primary数据源参与了事务其他数据源的操作已经提交了。结果是 order_db 里的订单被删了master 里的归档记录没写进去——数据丢了。4.2 为什么会这样Spring 的Transactional管理的是单个DataSource的事务。DynamicRoutingDataSource虽然能切换数据源但 Spring 事务管理器在事务开始时就绑定了 primary 数据源的 Connection后续切换数据源走的不是同一个 Connection自然不在同一个事务里。4.3 解决方案方案一避免跨库事务推荐把跨库操作拆分各自在独立事务中执行通过消息队列或补偿机制保证最终一致性。publicvoidtransferOrder(LongorderId){// 1. 独立事务归档到 masterarchiveService.archiveOrder(orderId);// Transactional DS(master)// 2. 独立事务删除 order_db 的订单orderService.deleteOrder(orderId);// Transactional DS(order_db)}如果第二步失败了第一步的归档数据已经在了后续可以定时任务做补偿。方案二分布式事务Seata引入 Seata 框架用GlobalTransactional替代Transactional。Seata 通过 TC事务协调器管理多个数据源的全局事务。GlobalTransactional(rollbackForException.class)publicvoidtransferOrder(LongorderId){orderMapper.deleteById(orderId);// order_dbarchiveMapper.insert(order);// master}代价是性能开销大多一次 TC 通信、undo_log 记录不到万不得已不要用。方案三JTA 事务AtomikosSpringBoot 支持 JTA 分布式事务引入 Atomikos 即可。但 JTA 性能更差且对 MySQL XA 事务的支持有坑不推荐。总结跨库事务的最佳实践是避免——拆分成独立事务 消息队列/补偿机制。分布式事务框架是最后的兜底手段不是首选。五、主从读写分离配置实战5.1 场景描述无人售货柜系统写操作下单、扣库存、更新状态走主库读操作查商品、查订单列表、查报表走从库部分对实时性要求高的读操作支付后查订单状态走主库5.2 完整配置spring:datasource:dynamic:primary:masterstrict:falsehikari:# 全局连接池配置max-lifetime:1800000# 连接最大生命周期30分钟connection-timeout:30000max-pool-size:20min-idle:5datasource:master:url:jdbc:mysql://192.168.1.10:3306/vending_machine?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseusername:rootpassword:Master123driver-class-name:com.mysql.cj.jdbc.Driverhikari:# 单独配置主库连接池max-pool-size:20# 写操作少连接池小一些min-idle:5slave:url:jdbc:mysql://192.168.1.11:3306/vending_machine?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseusername:rootpassword:Slave123driver-class-name:com.mysql.cj.jdbc.Driverhikari:max-pool-size:50# 读操作多连接池大一些min-idle:105.3 读写分离的 Mapper 划分// 读操作走从库 DS(slave)publicinterfaceProductQueryMapperextendsBaseMapperProductDO{ListProductVOqueryProductList(Param(query)ProductQueryDTOquery);ProductDetailVOqueryProductDetail(Param(id)Longid);ListDeviceStockVOqueryDeviceStock(Param(deviceId)LongdeviceId);}// 写操作走主库 DS(master)publicinterfaceProductCommandMapperextendsBaseMapperProductDO{intinsert(ProductDOproduct);intupdateById(ProductDOproduct);intdeductStock(Param(id)Longid,Param(qty)intqty);}5.4 处理主从延迟主从延迟是读写分离的天然问题。刚写入主库的数据从库还没同步过来用户去查就查不到。场景用户支付成功 → 跳转订单列表页 → 查到订单状态还是待支付。解决方案对实时性要求高的查询强制走主库。ServicepublicclassOrderService{AutowiredDS(slave)privateOrderQueryMapperorderQueryMapper;AutowiredDS(master)privateOrderCommandMapperorderCommandMapper;/** * 支付回调后查询订单强制走主库 */DS(master)// 覆盖Mapper上的DS(slave)publicOrderVOqueryOrderAfterPay(LongorderId){returnorderCommandMapper.selectById(orderId);}/** * 普通查询走从库 */publicOrderVOqueryOrder(LongorderId){returnorderQueryMapper.selectById(orderId);}}经验法则写操作后的查询走主库列表查询、报表查询走从库。支付场景永远走主库因为对一致性要求最高。六、多数据源注意事项6.1 连接池独立每个数据源必须维护独立的连接池。别想着共享连接池——不同数据库的网络延迟、负载不同混在一起管理会互相拖累。dynamic-datasource 默认每个数据源用独立的 HikariCP 连接池你只需要为每个数据源配置hikari参数即可。6.2 事务边界清晰加Transactional的方法只有primary数据源参与事务。如果你在Transactional方法里切换了数据源非 primary 的操作不在事务内。Transactional// 只管理 master 的事务DS(master)publicvoidbatchProcess(){// 这些在 master 事务内productMapper.updateById(product);orderMapper.insert(order);// 这个不在事务内slave 的操作已提交slaveMapper.updateStat(stat);}6.3 DS 和 Transactional 的顺序DS必须在Transactional之前生效外层否则事务已绑定了 primary 的 ConnectionDS切换不了。Spring AOP 默认按注解的顺序执行DS的切面优先级高于Transactional一般不会有问题。但如果你自定义了 AOP 顺序要注意别搞反。6.4 从库延迟监控读写分离要监控从库延迟延迟超过阈值比如 5 秒要告警-- 在从库执行SHOWSLAVESTATUS\G-- 关注 Seconds_Behind_Master 字段延迟过大时可以临时把读流量切到主库等从库追上再切回来。七、无人售货柜多门店数据场景7.1 场景描述无人售货柜连锁品牌200 门店每家门店有 2-5 台设备。需要实现门店管理员只能看自己门店的数据总部管理员可以看所有门店数据各门店数据在同一个数据库按store_id区分这种场景其实不需要多数据源用store_id字段过滤即可。但如果某些大客户比如某连锁酒店要求数据物理隔离就需要按客户分配独立数据库。7.2 按租户切换数据源/** * 根据租户ID切换数据源 */publicclassDataSourceContext{privatestaticfinalThreadLocalStringCONTEXTnewThreadLocal();publicstaticvoidsetDataSource(Stringds){CONTEXT.set(ds);}publicstaticStringgetDataSource(){returnCONTEXT.get();}publicstaticvoidclear(){CONTEXT.remove();}}/** * 拦截器根据请求头中的租户ID切换数据源 */ComponentpublicclassTenantDataSourceInterceptorimplementsHandlerInterceptor{OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){StringtenantIdrequest.getHeader(X-Tenant-Id);if(tenantId!null){// 租户ID映射到数据源名Stringdstenant_tenantId;DataSourceContext.setDataSource(ds);DynamicDataSourceContextHolder.push(ds);}returntrue;}OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex){DynamicDataSourceContextHolder.poll();DataSourceContext.clear();}}配置文件动态添加数据源ConfigurationpublicclassDynamicDataSourceConfig{AutowiredprivateDataSourcedataSource;// DynamicRoutingDataSourcePostConstructpublicvoidinit(){DynamicRoutingDataSourceds(DynamicRoutingDataSource)dataSource;// 从租户配置表加载所有租户的数据源配置ListTenantConfigtenantsloadTenantConfigs();for(TenantConfigtenant:tenants){DataSourcePropertypropertynewDataSourceProperty();property.setUrl(tenant.getDbUrl());property.setUsername(tenant.getDbUser());property.setPassword(tenant.getDbPassword());property.setDriverClassName(com.mysql.cj.jdbc.Driver);DataSourcetenantDsDataSourceCreator.createDataSource(property);ds.addDataSource(tenant_tenant.getId(),tenantDs);}}}这样每个大客户的数据在独立数据库里物理隔离互不影响。小客户还是共享库用store_id逻辑隔离。八、手动配置多数据源了解即可如果你不想用 dynamic-datasource也可以手动配置。核心思路是注册两个DataSource两个SqlSessionFactory两个MapperScan分别绑定到不同的 Mapper 包。ConfigurationMapperScan(basePackagescom.example.mapper.master,sqlSessionFactoryRefmasterSqlSessionFactory)publicclassMasterDataSourceConfig{BeanPrimaryConfigurationProperties(spring.datasource.master)publicDataSourcemasterDataSource(){returnDataSourceBuilder.create().type(HikariDataSource.class).build();}BeanPrimarypublicSqlSessionFactorymasterSqlSessionFactory(Qualifier(masterDataSource)DataSourceds)throwsException{SqlSessionFactoryBeanbeannewSqlSessionFactoryBean();bean.setDataSource(ds);bean.setMapperLocations(newPathMatchingResourcePatternResolver().getResources(classpath:mapper/master/*.xml));returnbean.getObject();}}ConfigurationMapperScan(basePackagescom.example.mapper.slave,sqlSessionFactoryRefslaveSqlSessionFactory)publicclassSlaveDataSourceConfig{BeanConfigurationProperties(spring.datasource.slave)publicDataSourceslaveDataSource(){returnDataSourceBuilder.create().type(HikariDataSource.class).build();}BeanpublicSqlSessionFactoryslaveSqlSessionFactory(Qualifier(slaveDataSource)DataSourceds)throwsException{SqlSessionFactoryBeanbeannewSqlSessionFactoryBean();bean.setDataSource(ds);bean.setMapperLocations(newPathMatchingResourcePatternResolver().getResources(classpath:mapper/slave/*.xml));returnbean.getObject();}}手动配置的好处是完全可控缺点是代码量大、每加一个数据源就要加一个配置类。2 个数据源还能忍3 个以上就建议用 dynamic-datasource 了。总结多数据源配置的核心要点优先用dynamic-datasource注解切换配置简单够用 90% 的场景DS加在 Mapper 层读写分离时按读/写 Mapper 划分比方法级注解更清晰跨库事务要避免拆成独立事务 补偿机制别一上来就上 Seata连接池独立配置读写分离时从库连接池要大于主库主从延迟要处理实时性要求高的查询强制走主库监控从库延迟Seconds_Behind_Master超过阈值要告警多数据源不是炫技是业务驱动的架构选择。先用单库跑起来遇到瓶颈了再拆别为了看起来高级就一上来搞读写分离。