1. 项目概述:为什么多数据源不是“加个配置”就能搞定的事
SpringBoot配置多数据源——这行标题在技术社区里出现频率高得有点反常。它不像“SpringBoot集成Redis”那样有明确边界,也不像“SpringBoot打包Docker镜像”那样步骤清晰可复现。它背后藏着的是真实业务场景里反复被撕扯的三重矛盾:主库写压力扛不住、从库读延迟吃不消、业务代码里硬编码数据源ID越写越乱。我见过太多团队,在压测报告出来前,还觉得“加个@DS注解就完事了”,结果一上生产,订单创建失败、用户余额查不准、报表导出卡死,全是因为那个被当成装饰品的@Primary数据源,在某个深夜悄悄把读请求也打到了主库上。
核心关键词“动态切换”“主从复制”“读写分离”,其实对应着三个不同层级的问题:运行时决策能力(切)、数据库层基础架构(复)、应用层流量治理(分)。很多人误以为只要用Druid+AbstractRoutingDataSource就能一劳永逸,但实测下来,90%的失败案例都卡在同一个地方:没有区分“逻辑读写分离”和“物理读写分离”的边界。前者是代码里告诉框架“这个方法该走哪个库”,后者是数据库集群本身是否真的具备主从同步能力、同步延迟是否可控、从库是否允许被读取。如果DBA那边主从延迟动辄3秒,你代码里再优雅地切到slave,查出来的也是过期数据。
这个内容适合三类人:第一类是刚接手遗留系统的后端开发,发现DAO层全是jdbcTemplate.queryForList("select * from user where id = ?", id)这种裸SQL,想加读写分离却不敢动;第二类是正在做高并发改造的技术负责人,需要评估方案对现有事务、分页、分布式锁的影响;第三类是准备面试的中级工程师,想搞懂“为什么@Transactional和@DS一起用会失效”。它不教你怎么搭一个能跑的Demo,而是带你拆开每一个螺丝,看清弹簧怎么弹、垫片怎么压、螺纹牙距是不是匹配。接下来所有内容,都基于一个真实压测场景:某电商平台大促期间,商品详情页QPS从2000飙到18000,主库CPU持续95%,而从库闲置率超70%——我们就是从这里开始动手的。
2. 整体设计思路与方案选型逻辑
2.1 为什么放弃ShardingSphere-JDBC做读写分离?
看到标题里“动态切换”,很多人第一反应是ShardingSphere。但我们在某电商项目中实测对比后,主动放弃了它作为读写分离主方案。原因很实在:ShardingSphere-JDBC的读写分离是“连接级路由”,而我们真正需要的是“事务级路由”。举个例子:一个下单接口,要先查库存(读)、再扣减(写)、最后记录日志(写)。ShardingSphere默认按SQL类型判断读写,遇到SELECT ... FOR UPDATE这种语句,它会直接路由到主库——这没问题。但问题出在“查库存”这一步:如果库存服务调用的是另一个微服务的HTTP接口,而那个接口内部用的是MyBatis的selectOne,ShardingSphere根本看不到这条SQL,只能按默认策略走主库。结果就是,本该分流到从库的10万次库存查询,全压在了主库上。
我们做了组压测:同样QPS 12000,ShardingSphere方案下主库CPU峰值92%,而自研AbstractRoutingDataSource方案只有68%。差距在哪?关键在路由决策时机。ShardingSphere在JDBC Connection获取时做路由,而我们的方案在MyBatis Executor执行前、甚至在Spring AOP切面里就完成了数据源绑定。这意味着:哪怕你用RestTemplate调第三方服务,只要那个服务的方法上打了@ReadOnly注解,我们就能在它的DAO层执行前,把当前线程的数据源上下文切换过去。这不是炫技,而是应对微服务拆分后“跨进程读写分离”必须补上的能力。
2.2 AbstractRoutingDataSource不是银弹,它解决不了什么?
网上90%的教程把AbstractRoutingDataSource吹成万能胶,但必须说清楚它的硬伤:它无法处理嵌套事务中的数据源冲突。比如方法A加了@Transactional和@ReadOnly,里面调用了方法B,而B也加了@Transactional但没标只读。Spring事务传播机制会让B复用A的事务,但AbstractRoutingDataSource的determineCurrentLookupKey()方法在每次getConnection()时都会被调用——这时候它该返回master还是slave?我们试过用ThreadLocal存“当前事务是否只读”,结果发现:当B方法里执行INSERT时,框架依然从slave数据源拿连接,直接报错。最终解决方案是:强制约定“最外层事务决定数据源”,内层方法不得覆盖外层的只读标识。这听起来像妥协,但恰恰是生产环境最稳的实践:用规范约束代替技术兜底。
另一个常被忽略的点是数据源健康检查。很多团队配了两个从库,但没做故障转移。当从库A宕机,所有读请求瞬间打到从库B,B直接雪崩。我们在AbstractRoutingDataSource的子类里重写了determineCurrentLookupKey(),加入健康检查缓存:每30秒用SELECT 1探活,失败则标记该数据源为DOWN,并在本地缓存10分钟。下次路由时,自动跳过DOWN状态的节点。这个逻辑看似简单,但避免了某次凌晨3点从库主备切换失败导致的整站读服务不可用。
2.3 主从复制不是数据库的事,更是应用的事
“主从复制”这个词容易让人误以为只要DBA配好binlog就行。但实际落地时,应用层必须直面三个血淋淋的事实:第一,MySQL半同步复制不是强一致。即使开了rpl_semi_sync_master_wait_for_slave_count=1,主库commit返回成功后,从库可能还在apply relay log,此时读从库大概率拿到旧数据。第二,网络抖动会导致复制延迟突增。我们监控过某次机房光纤被挖断,主从延迟从50ms飙到12秒,而应用层毫无感知,继续往从库发读请求。第三,从库只读权限不等于安全。有些DBA给从库账号开了SUPER权限,结果运维误操作STOP SLAVE,整个读链路中断。
所以我们在方案里加了“延迟感知读”机制:每个从库连接池配置里增加slave-delay-threshold=500(单位毫秒),应用启动时定期执行SHOW SLAVE STATUS,解析Seconds_Behind_Master。当延迟超过阈值,自动将该从库权重降为0,流量切到其他从库或主库。这个值不是拍脑袋定的——我们统计过业务SLA:商品详情页要求数据新鲜度<1秒,而订单列表可以接受3秒延迟。所以对商品库,阈值设500ms;对订单库,设2500ms。这种差异化配置,让读写分离真正服务于业务,而不是成为DBA和开发互相甩锅的借口。
3. 核心细节解析与实操要点
3.1 数据源路由键的设计:别再用字符串硬编码了
几乎所有教程都教你这样写:
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceType(); } }然后在DataSourceContextHolder里用ThreadLocal<String>存"master"或"slave"。这在单体应用里能跑,但一到微服务就崩。问题出在线程模型错位:Spring Cloud Gateway用Netty线程池,Feign用HttpClient线程池,而你的ThreadLocal只在Controller线程里设置,到Feign回调时已经丢失。我们改用TransmittableThreadLocal(阿里开源的TTL库),但它也不是万能的——当用@Async异步任务查从库时,TTL默认不传递上下文。
最终方案是:路由键不存字符串,而存一个带业务语义的枚举。定义:
public enum DataSourceType { MASTER(1, "master"), SLAVE_ORDER(2, "slave_order"), // 订单从库 SLAVE_PRODUCT(3, "slave_product"), // 商品从库 SLAVE_REPORT(4, "slave_report"); // 报表从库 private final int weight; // 权重,用于负载均衡 private final String key; DataSourceType(int weight, String key) { this.weight = weight; this.key = key; } // getter略 }关键来了:determineCurrentLookupKey()返回的不是String,而是这个枚举实例。为什么?因为枚举可以携带元数据。比如SLAVE_ORDER的weight=3,SLAVE_PRODUCT的weight=5,我们在路由算法里实现加权轮询,让流量按权重分发到不同从库。更重要的是,枚举可以被序列化——当Feign调用时,我们把DataSourceType作为请求头X-Data-Source: SLAVE_PRODUCT透传,下游服务收到后,直接用DataSourceType.valueOf(header)还原,完美解决线程上下文丢失问题。这个设计让路由键从“开关”升级为“路由指令”,后续扩展分库分表时,只需新增枚举值,不用改核心路由逻辑。
3.2 事务与只读标识的协同机制:@Transactional和@ReadOnly如何共存?
这是踩坑最多的地方。Spring的@Transactional默认readOnly=false,而我们想实现“方法A加了@Transactional(readOnly=true),就自动走从库”。但问题在于:@Transactional的readOnly属性只影响Hibernate的二级缓存和JDBC Connection的setReadOnly(true)调用,它不会触发数据源切换。很多教程教你在TransactionSynchronizationAdapter里监听事务事件,但这太重了,且无法处理非事务方法里的读请求。
我们的解法是:用AOP切面+自定义注解双驱动。定义@ReadOnly注解:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface ReadOnly { String value() default ""; // 指定从库分组,如"order" }然后写切面:
@Aspect @Component public class ReadOnlyAspect { @Around("@annotation(readOnly)") public Object aroundReadOnly(ProceedingJoinPoint joinPoint, ReadOnly readOnly) throws Throwable { String group = StringUtils.hasText(readOnly.value()) ? readOnly.value() : "default"; DataSourceContextHolder.setDataSourceType( DataSourceType.getSlaveByGroup(group) ); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); } } }重点在finally块里的clear()——必须清空,否则线程复用时会污染下一个请求。但这就引出新问题:如果@ReadOnly方法里调用了@Transactional方法,clear()会把事务需要的master数据源也清掉。所以我们在DataSourceContextHolder里加了嵌套计数:
private static final ThreadLocal<Integer> NESTED_COUNT = new ThreadLocal<>(); private static final ThreadLocal<DataSourceType> DATA_SOURCE_HOLDER = new ThreadLocal<>(); public static void setDataSourceType(DataSourceType dataSourceType) { Integer count = NESTED_COUNT.get(); if (count == null || count == 0) { DATA_SOURCE_HOLDER.set(dataSourceType); } NESTED_COUNT.set(count == null ? 1 : count + 1); } public static void clear() { Integer count = NESTED_COUNT.get(); if (count != null && count > 1) { NESTED_COUNT.set(count - 1); } else { DATA_SOURCE_HOLDER.remove(); NESTED_COUNT.remove(); } }这样,@ReadOnly和@Transactional嵌套时,clear()只会减计数,直到最外层退出才真正清除。这个设计让事务和读写分离不再打架,而是像齿轮一样咬合转动。
3.3 连接池参数的魔鬼细节:为什么HikariCP比Druid更适合读写分离?
选连接池时,很多人凭印象选Druid,觉得它监控全、功能多。但在读写分离场景下,HikariCP的几个特性让它成为更优解:
连接泄漏检测更精准:Druid的
removeAbandonedOnBorrow是粗粒度的(整个borrow过程超时才回收),而HikariCP的leakDetectionThreshold能精确到毫秒级。读写分离时,从库连接常因网络抖动假死,HikariCP能在30秒内发现并销毁,Druid可能要等5分钟。连接验证更轻量:HikariCP默认用
isValid()验证连接,而Druid默认执行SELECT 1。对从库来说,频繁SELECT 1会增加IO压力。我们把HikariCP的connection-test-query设为空,改用isValid(),实测从库QPS提升12%。初始化连接数控制更灵活:Druid的
initialSize是全局的,而HikariCP支持每个数据源独立配置。我们给主库设minimumIdle=20(扛写压力),从库设minimumIdle=5(读请求突发时快速扩容),避免从库连接池常年闲置浪费内存。
具体配置对比:
| 参数 | HikariCP(推荐) | Druid(慎用) | 说明 |
|---|---|---|---|
maximumPoolSize | 主库100,从库30 | 全局统一80 | 从库连接数应为主库1/3,避免争抢主库资源 |
connection-timeout | 30000 | 60000 | 从库响应慢时,快速失败比长等待更利于熔断 |
idle-timeout | 600000 | 1800000 | 从库空闲连接回收更快,节省DB连接数 |
max-lifetime | 1800000 | 3600000 | 避免从库连接因主从切换变长 |
提示:不要迷信“连接池越大越好”。我们压测发现,当从库连接池超过50,MySQL的
Threads_connected接近max_connections的80%时,从库性能反而下降。这是因为MySQL的连接管理本身有开销,不是纯线性增长。
4. 实操过程与核心环节实现
4.1 从零搭建动态多数据源:5个关键配置文件详解
4.1.1 application.yml:数据源基础配置
spring: datasource: dynamic: primary: master # 默认数据源 strict: false # true时未匹配到数据源直接报错 datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://master-db:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 hikari: minimum-idle: 20 maximum-pool-size: 100 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 slave-order: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://slave-order-db:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai username: readonly password: 123456 hikari: minimum-idle: 5 maximum-pool-size: 30 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 slave-product: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://slave-product-db:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai username: readonly password: 123456 hikari: minimum-idle: 5 maximum-pool-size: 30 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000注意hikari下的参数是HikariCP原生配置,不是Spring Boot的spring.datasource.hikari.*。因为dynamic-datasource-spring-boot-starter会自动识别hikari节点下的配置。
4.1.2 DataSourceConfig.java:动态数据源注册核心
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties("spring.datasource.dynamic.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.dynamic.datasource.slave-order") public DataSource slaveOrderDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.dynamic.datasource.slave-product") public DataSource slaveProductDataSource() { return DataSourceBuilder.create().build(); } @Bean public DynamicDataSource dynamicDataSource() { DynamicDataSource dynamicDataSource = new DynamicDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put(DataSourceType.MASTER, masterDataSource()); targetDataSources.put(DataSourceType.SLAVE_ORDER, slaveOrderDataSource()); targetDataSources.put(DataSourceType.SLAVE_PRODUCT, slaveProductDataSource()); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(masterDataSource()); // 默认走主库 return dynamicDataSource; } }关键点:DynamicDataSource必须继承AbstractRoutingDataSource,且determineCurrentLookupKey()方法返回的key必须和targetDataSources的key完全一致(即枚举实例,不是字符串)。
4.1.3 DataSourceType.java:路由键枚举(含权重计算)
public enum DataSourceType { MASTER(10, "master"), SLAVE_ORDER(3, "slave_order"), SLAVE_PRODUCT(5, "slave_product"); private final int weight; private final String key; DataSourceType(int weight, String key) { this.weight = weight; this.key = key; } // 根据权重随机选择从库 public static DataSourceType chooseSlave() { int totalWeight = Arrays.stream(values()) .filter(type -> type.name().startsWith("SLAVE_")) .mapToInt(DataSourceType::getWeight) .sum(); int random = ThreadLocalRandom.current().nextInt(totalWeight); int current = 0; for (DataSourceType type : values()) { if (!type.name().startsWith("SLAVE_")) continue; current += type.weight; if (random < current) return type; } return SLAVE_ORDER; // fallback } }这个chooseSlave()方法实现了加权轮询,让商品库(权重5)承接62.5%的读流量,订单库(权重3)承接37.5%,避免流量倾斜。
4.1.4 DataSourceContextHolder.java:线程上下文管理器
public class DataSourceContextHolder { private static final ThreadLocal<DataSourceType> CONTEXT_HOLDER = new TransmittableThreadLocal<>(); private static final ThreadLocal<Integer> NESTED_COUNT = new ThreadLocal<>(); public static void setDataSourceType(DataSourceType dataSourceType) { Integer count = NESTED_COUNT.get(); if (count == null || count == 0) { CONTEXT_HOLDER.set(dataSourceType); } NESTED_COUNT.set(count == null ? 1 : count + 1); } public static DataSourceType getDataSourceType() { return CONTEXT_HOLDER.get(); } public static void clear() { Integer count = NESTED_COUNT.get(); if (count != null && count > 1) { NESTED_COUNT.set(count - 1); } else { CONTEXT_HOLDER.remove(); NESTED_COUNT.remove(); } } }必须用TransmittableThreadLocal,否则Feign、@Async、CompletableFuture都会丢失上下文。
4.1.5 ReadOnlyAspect.java:只读切面实现
@Aspect @Component public class ReadOnlyAspect { @Around("@annotation(readOnly)") public Object aroundReadOnly(ProceedingJoinPoint joinPoint, ReadOnly readOnly) throws Throwable { String group = StringUtils.hasText(readOnly.value()) ? readOnly.value() : "default"; DataSourceType type = DataSourceType.getSlaveByGroup(group); if (type == null) { type = DataSourceType.chooseSlave(); // 无指定分组时随机选 } DataSourceContextHolder.setDataSourceType(type); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); } } }getSlaveByGroup()是枚举里的静态方法,根据group名返回对应枚举值,比如getSlaveByGroup("order")返回SLAVE_ORDER。
4.2 健康检查与故障转移:让从库宕机不拖垮系统
4.2.1 SlaveHealthChecker.java:从库心跳检测
@Component public class SlaveHealthChecker implements CommandLineRunner { @Autowired private DataSource dynamicDataSource; private final Map<String, Long> lastHealthyTime = new ConcurrentHashMap<>(); private final Map<String, Boolean> isHealthy = new ConcurrentHashMap<>(); @PostConstruct public void init() { // 初始化所有从库为健康 Arrays.stream(DataSourceType.values()) .filter(type -> type.name().startsWith("SLAVE_")) .forEach(type -> { isHealthy.put(type.getKey(), true); lastHealthyTime.put(type.getKey(), System.currentTimeMillis()); }); } @Scheduled(fixedDelay = 30000) // 每30秒检测一次 public void checkAllSlaves() { Arrays.stream(DataSourceType.values()) .filter(type -> type.name().startsWith("SLAVE_")) .forEach(this::checkSlave); } private void checkSlave(DataSourceType type) { try (Connection conn = dynamicDataSource.getConnection()) { if (conn.isValid(2000)) { // 2秒内验证 isHealthy.put(type.getKey(), true); lastHealthyTime.put(type.getKey(), System.currentTimeMillis()); } else { isHealthy.put(type.getKey(), false); log.warn("Slave {} is unhealthy", type.getKey()); } } catch (Exception e) { isHealthy.put(type.getKey(), false); log.error("Check slave {} failed", type.getKey(), e); } } public boolean isSlaveHealthy(String key) { return isHealthy.getOrDefault(key, false); } public long getLastHealthyTime(String key) { return lastHealthyTime.getOrDefault(key, 0L); } }4.2.2 DynamicDataSource.java:路由时集成健康检查
public class DynamicDataSource extends AbstractRoutingDataSource { @Autowired private SlaveHealthChecker healthChecker; @Override protected Object determineCurrentLookupKey() { DataSourceType type = DataSourceContextHolder.getDataSourceType(); if (type == null) { return DataSourceType.MASTER; // 默认走主库 } if (type.name().startsWith("SLAVE_") && !healthChecker.isSlaveHealthy(type.getKey())) { // 从库不健康,降级到主库 log.warn("Slave {} is down, fallback to master", type.getKey()); return DataSourceType.MASTER; } return type; } }这个降级逻辑是保命的关键。当从库宕机,所有读请求自动切回主库,虽然主库压力增大,但至少服务不挂。等DBA修复后,健康检查自动恢复路由。
4.3 延迟感知读:让应用知道从库有多“老”
4.3.1 DelayAwareRouting.java:基于延迟的智能路由
@Component public class DelayAwareRouting { @Autowired private JdbcTemplate jdbcTemplate; public DataSourceType routeByDelay(DataSourceType preferred) { if (!preferred.name().startsWith("SLAVE_")) { return preferred; } try { // 查询从库延迟 String sql = "SHOW SLAVE STATUS"; Map<String, Object> status = jdbcTemplate.queryForMap(sql); Number delay = (Number) status.get("Seconds_Behind_Master"); long secondsBehind = delay == null ? 0 : delay.longValue(); // 获取配置的阈值 int threshold = getDelayThreshold(preferred); if (secondsBehind <= threshold) { return preferred; // 延迟达标,走指定从库 } else { // 延迟超标,找延迟最小的从库 return findLeastDelayedSlave(); } } catch (Exception e) { log.error("Get slave delay failed", e); return DataSourceType.MASTER; // 异常时降级 } } private int getDelayThreshold(DataSourceType type) { switch (type) { case SLAVE_ORDER: return 2500; // 订单库容忍3秒 case SLAVE_PRODUCT: return 500; // 商品库容忍0.5秒 default: return 1000; } } private DataSourceType findLeastDelayedSlave() { // 简化版:遍历所有从库,执行SHOW SLAVE STATUS取最小延迟 // 实际项目中会缓存结果,避免每次路由都查 return DataSourceType.chooseSlave(); } }4.3.2 在路由切面中集成延迟感知
@Aspect @Component public class DelayAwareAspect { @Autowired private DelayAwareRouting delayRouting; @Around("@annotation(readOnly)") public Object aroundDelayAware(ProceedingJoinPoint joinPoint, ReadOnly readOnly) throws Throwable { DataSourceType type = DataSourceContextHolder.getDataSourceType(); if (type == null) { type = DataSourceType.chooseSlave(); } // 关键:路由前检查延迟 DataSourceType routedType = delayRouting.routeByDelay(type); DataSourceContextHolder.setDataSourceType(routedType); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); } } }这个设计让读写分离不再是静态配置,而是随从库状态动态调整的活系统。某次主从网络抖动,延迟从200ms飙到8秒,系统自动把商品详情页流量切到主库(短暂牺牲一致性保可用),而报表导出继续走从库(容忍延迟),真正做到“分级保障”。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
| 所有读请求都走主库 | @ReadOnly注解没生效,或AOP没扫描到 | curl -X GET http://localhost:8080/actuator/mappings | grep ReadOnlyAspect | 检查@EnableAspectJAutoProxy是否开启,包扫描路径是否包含切面类 |
| 从库查询返回旧数据 | 主从延迟过高,或应用没配置延迟感知 | mysql -h slave-db -e "SHOW SLAVE STATUS\G" | grep Seconds_Behind_Master | 调低delay-threshold,或临时关闭读写分离 |
No qualifying bean of type 'javax.sql.DataSource' | dynamic-datasource-spring-boot-starter版本与Spring Boot不兼容 | mvn dependency:tree | grep dynamic | 升级starter到最新版,或降级Spring Boot |
| 切换数据源后事务失效 | @Transactional和@ReadOnly嵌套,clear()过早执行 | 在DataSourceContextHolder.clear()加日志,观察调用栈 | 改用嵌套计数方案(见3.2节) |
| 从库连接池耗尽 | 从库连接数配置过大,或健康检查没启用 | show status like 'Threads_connected'; | 调小maximum-pool-size,启用SlaveHealthChecker |
5.2 我踩过的3个深坑及独家避坑技巧
坑1:MyBatis的@SelectProvider绕过AOP
某次上线后发现,用@SelectProvider动态生成SQL的方法,@ReadOnly注解完全不生效。调试发现:@SelectProvider的SQL是在MyBatis的MapperMethod.execute()里拼装的,而我们的AOP切在@ReadOnly方法上,但execute()是MyBatis内部调用,没经过Spring代理。解决方案:在MyBatis拦截器里做二次路由。
@Component @Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class MyBatisReadonlyInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; if (ms.getSqlCommandType() == SqlCommandType.SELECT) { // 检查Mapper接口方法是否有@ReadOnly String mapperClassName = ms.getMapperType().getName(); String methodName = ms.getSqlCommandType().name(); // 通过反射获取方法上的注解... if (hasReadOnlyAnnotation(mapperClassName, methodName)) { DataSourceContextHolder.setDataSourceType(DataSourceType.chooseSlave()); } } return invocation.proceed(); } }这个拦截器确保:哪怕你用@SelectProvider、@Select、XML SQL,只要它是SELECT,就走只读路由。
坑2:Feign调用时Header透传丢失Cookie
我们用X-Data-Source透传路由键,但某次发现Feign调用后,下游服务收不到这个Header。查文档发现:Feign默认不传递Cookie以外的Header。解决方案:自定义Feign RequestInterceptor。
@Bean public RequestInterceptor feignRequestInterceptor() { return template -> { String dsType = DataSourceContextHolder.getDataSourceType() != null ? DataSourceContextHolder.getDataSourceType().getKey() : ""; if (StringUtils.hasText(dsType)) { template.header("X-Data-Source", dsType); } }; }同时下游服务的ReadOnlyAspect里,要从Header里读取:
@Around("@annotation(readOnly)") public Object aroundReadOnly(ProceedingJoinPoint joinPoint, ReadOnly readOnly) throws Throwable { HttpServletRequest request = getCurrentRequest(); String header = request.getHeader("X-Data-Source"); DataSourceType type = StringUtils.hasText(header) ? DataSourceType.valueOf(header.toUpperCase()) : DataSourceType.chooseSlave(); DataSourceContextHolder.setDataSourceType(type); // ...其余逻辑 }坑3:单元测试里数据源切换失效
写JUnit测试时,@ReadOnly方法总是走主库。原因是:@Transactional测试默认用@Rollback,而@Rollback会创建新的事务,导致DataSourceContextHolder的ThreadLocal被重置。解决方案:测试类上加@TestConfiguration,手动管理数据源上下文。
@SpringBootTest class DataSourceTest { @Test void testReadOnly() { // 手动设置数据源 DataSourceContextHolder.setDataSourceType(DataSourceType.SLAVE_PRODUCT); try { productService.getProduct(1L); // 断点看SQL执行的库 } finally { DataSourceContextHolder.clear(); } } }5.3 生产环境必须监控的5个指标
光配好不够,得看得见。我们在Prometheus里埋了这些指标:
datasource_route_total{type="master",env="prod"}:主库路由次数,突增说明读写分离失效datasource_route_total{type="slave_order",env="prod"}:订单从库路由次数,持续为0说明健康检查误判slave_delay_seconds{instance="slave-order-db"}:从库延迟秒数,告警阈值设为2500hikaricp_connections_active{pool="slave-product"}:商品从库活跃连接数,超过25触发扩容datasource_fallback_total{reason="unhealthy",env="prod"}:因从库不健康降级次数,1小时内>10次需人工介入
这些指标配上Grafana看板,让我们在用户投诉前就发现异常。某次大促,看板显示slave_delay_seconds在14:23突然从300ms跳到12000ms,我们立刻登录DBA平台,发现是主库磁盘IO打满,提前扩容了SSD,避免了服务雪崩。
6. 性能压测对比与效果验证
6.1 压测环境与方法论
我们用JMeter模拟真实流量:
- 场景1(读多写少):商品详情页(90%读,10%写),QPS 15000
- 场景2(混合读写):下单流程(查库存+扣减+记日志),QPS 3000
- 场景3(纯写):秒杀扣减,QPS 5000
对比方案:
- Baseline:单数据源,所有请求走主库
- 方案A:ShardingSphere-JDBC读写分离
- 方案B:本文实现的动态多数据源
硬件配置:
- 应用服务器:4核8G × 3台(Nginx+SpringBoot)
- MySQL主库:8核16G,SSD
- MySQL从库:4核8G × 2台,SSD
6.2 关键指标对比结果
| 指标 | Baseline | 方案A(ShardingSphere) | 方案B(本文方案) | 提升 |
|---|---|---|---|---|
| 主库CPU峰值 | 98% | 82% | 65% | ↓33% |
| 从库CPU均值 | 12% | 45% | 68% | ↑23% |
| P99响应时间(读) | 420ms | 280ms | 190ms | ↓55% |
| P99响应时间(写) | 180ms | 175ms | 165ms |