2026/10/24 17:27:50

Spring多数据源路由实战:三层可控切换与分库分表落地

Spring多数据源路由实战:三层可控切换与分库分表落地 简介本资源是一份面向Java后端开发者与数据库架构初学者的分库分表实战教学包聚焦企业级大数据量场景下的数据库水平扩展难题以Sharding-JDBC为核心框架实现多数据源动态切换与透明化分片。资源共73个文件涵盖18个Java业务与配置类、21个编译后class文件、18个XML配置与映射文件含MyBatis映射及Sharding规则定义以及SQL建表脚本、Maven项目配置pom.xml、Eclipse工程元数据.project/.classpath等关键开发资产整体仅66KB轻量易导入。已有2068人学习下载适合快速搭建SSMDemo环境并理解分片路由、数据源初始化、YAML分片规则配置等核心实践环节。读者可直接复用项目结构掌握从多数据源声明、分库分表策略配置到跨库CRUD调用的完整链路尤其适用于SpringMyBatisSharding-JDBC技术栈的中小规模分片落地参考。1. 分库分表多数据源的切换不是加个注解就完事而是让每个DAO层调用都清楚自己该连哪张表、哪个库你刚接手一个日增300万订单的电商后台MySQL单表已超2800万行慢查询从“偶发”变成“每分钟必报”DBA甩来一句“上分库分表吧。”——结果你兴冲冲引入ShardingSphere配置好逻辑表、分片键、分片算法一跑测试发现用户中心查user_info正常但订单服务里一个带JOIN的报表SQL直接报错“无法路由到具体数据源”更玄学的是同一个Service方法里前半段insert走的是ds0后半段update却连到了ds1事务根本没法提交。这不是配置没生效而是分库分表的本质从来不是“自动拆分”而是“显式声明精准控制”。这个项目就是一套可落地、可调试、可验证的多数据源切换实战方案它不依赖黑匣子中间件的自动路由而是用Spring原生AbstractRoutingDataSource 自定义ThreadLocal上下文 显式数据源标记把“哪个线程该用哪个库”这件事从配置文件里拽出来写进代码里、打在日志里、压测时能一眼看穿。适合正在被跨库JOIN、读写分离、历史库归档、灰度发布数据隔离等问题卡住的后端开发者尤其适合那些已经踩过ShardingSphere规则配置陷阱、想回归可控性的团队。2. 架构设计与核心模块拆解三层路由机制让数据源选择不再靠猜这个项目不是简单封装了一个DynamicDataSource而是构建了上下文驱动 规则匹配 手动覆盖的三级路由体系。它把“用哪个库”这件事拆成三个可独立验证、可组合使用的环节最外层是业务语义标记比如TargetDataSource(write)中间层是分片键自动识别比如方法参数含userId且当前策略为user_id分片最内层是线程级强制绑定比如开启事务时锁定主库。三者优先级从高到低冲突时以手动标记为准。这种设计避免了“全局默认库”带来的隐式耦合也绕开了中间件对SQL语法的强约束——你写原生MyBatis XML它照样能路由你用JdbcTemplate手写复杂UNION它也能按需切库。整个结构轻量无额外jar依赖、透明所有路由决策可打日志、可测试Mock ThreadLocal即可覆盖全部分支。2.1 核心路由引擎AbstractRoutingDataSource的深度定制Spring原生的AbstractRoutingDataSource只提供一个determineCurrentLookupKey()抽象方法返回String类型的key。但本项目在此基础上做了三处关键增强key不再是简单字符串而是自定义的DataSourceKey类包含type(READ/WRITE)、shardId(如shard_01)、sourceId(如ds_master_v2)三个维度determineCurrentLookupKey()内部不直接返回key而是委托给RouterChain执行链式判断每次key生成后自动记录到MDC中供日志追踪如[ds_keyWRITE:shard_01:ds_master_v2]。public class DynamicRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { // 1. 检查线程局部变量是否已强制绑定 DataSourceKey forcedKey DataSourceContextHolder.getForcedKey(); if (forcedKey ! null) { log.debug(Route forced: {}, forcedKey); return forcedKey; } // 2. 尝试从方法参数/注解提取分片上下文 DataSourceKey contextKey ContextRouter.routeByContext(); if (contextKey ! null) { log.debug(Route by context: {}, contextKey); return contextKey; } // 3. 回退到默认策略如读写分离下的READ DataSourceKey defaultKey DefaultRouter.routeByDefault(); log.debug(Route by default: {}, defaultKey); return defaultKey; } }提示DataSourceKey必须实现equals()和hashCode()否则Spring缓存的targetDataSource Map会失效——这是血泪经验曾因忘记重写导致同一key反复创建新连接池DB连接数暴增。2.2 上下文管理器ThreadLocal MDC双保险DataSourceContextHolder是整个路由的“开关中枢”。它不只存一个String而是维护三个ThreadLocal变量forcedKey: 用于Transactional或TargetDataSource注解触发的强制绑定shardContext: 存储当前请求的分片标识如userId12345 → shardIdshard_03routeTrace: 布尔值开启后自动记录每次路由决策路径用于排查“为什么没走预期库”。public class DataSourceContextHolder { private static final ThreadLocalDataSourceKey FORCED_KEY_HOLDER new NamedThreadLocal(forcedDataSourceKey); private static final ThreadLocalShardContext SHARD_CONTEXT_HOLDER new NamedThreadLocal(shardContext); private static final ThreadLocalBoolean ROUTE_TRACE_HOLDER ThreadLocal.withInitial(() - false); public static void setForcedKey(DataSourceKey key) { FORCED_KEY_HOLDER.set(key); // 同步写入MDC确保日志可追溯 MDC.put(ds_key, key.toString()); } public static void setShardContext(ShardContext context) { SHARD_CONTEXT_HOLDER.set(context); MDC.put(shard_id, context.getShardId()); MDC.put(shard_key, context.getShardKey()); } public static void enableRouteTrace() { ROUTE_TRACE_HOLDER.set(true); MDC.put(route_trace, enabled); } }NamedThreadLocal是自定义类继承ThreadLocal并重写toString()方便在Arthas或jstack线程dump中快速识别该ThreadLocal用途——这点在生产环境排查“某个线程死锁在数据库连接上”时极其关键。2.3 分片上下文注入从Controller到Mapper的全链路透传分片键如userId、orderId通常来自HTTP请求参数或RPC上下文不能靠DAO层自己猜。本项目采用“入口拦截显式传递”模式Web层ShardParam(userId)注解标注Controller方法参数由ShardArgumentResolver解析并注入ShardContextRPC层Dubbo Filter或gRPC Interceptor从Attachment或Metadata中提取分片信息内部调用Service方法签名强制要求传入ShardContext杜绝“隐式上下文”。RestController public class OrderController { GetMapping(/order/{orderId}) public OrderVO getOrder( PathVariable Long orderId, ShardParam(userId) Long userId) { // ← 此注解触发ShardContext注入 return orderService.getOrder(orderId, userId); } } Service public class OrderServiceImpl implements OrderService { Override public OrderVO getOrder(Long orderId, Long userId) { // 1. 主动构造ShardContext确保下游能路由 ShardContext context ShardContext.builder() .shardKey(userId) .shardValue(userId) .shardId(ShardAlgorithm.calculateShardId(userId)) // 如 userId % 4 → shard_02 .build(); DataSourceContextHolder.setShardContext(context); // 2. 执行实际查询此时DynamicRoutingDataSource已能获取shardId return orderMapper.selectById(orderId); } }注意ShardAlgorithm.calculateShardId()必须是纯函数无状态、无外部依赖否则在多线程下可能产生不一致分片结果——这是分库分表最隐蔽的翻车点之一。3. 数据源配置与初始化YAML驱动 运行时热加载支持项目摒弃了XML硬编码数据源的方式采用Spring Boot原生ConfigurationProperties绑定YAML配置同时预留了运行时刷新接口如POST /actuator/datasource/refresh无需重启即可更新数据源连接池参数。配置结构清晰分层顶层定义逻辑组如master-slave-group中层定义物理实例如ds_master_v2底层定义JDBC属性。这种结构天然支持灰度发布场景——你可以先将新库ds_master_v3加入配置但不启用待压测通过后再修改group.default-write-source: ds_master_v3。3.1 application.yml核心配置片段# 多数据源基础配置 spring: datasource: dynamic: primary: master # 默认写库 strict: false # 非严格模式未匹配到数据源时回退到primary datasource: # 主库写 master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://db-master:3306/order_db_v2?useSSLfalseserverTimezoneAsia/Shanghai username: app_rw password: ${DB_MASTER_PWD} hikari: maximum-pool-size: 20 minimum-idle: 5 # 从库集群读 slave-01: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://db-slave-01:3306/order_db_v2?useSSLfalseserverTimezoneAsia/Shanghai username: app_ro password: ${DB_SLAVE_PWD} hikari: maximum-pool-size: 15 slave-02: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://db-slave-02:3306/order_db_v2?useSSLfalseserverTimezoneAsia/Shanghai username: app_ro password: ${DB_SLAVE_PWD} hikari: maximum-pool-size: 15 # 分片库历史订单 shard-01: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://db-shard-01:3306/order_history_shard_01?useSSLfalseserverTimezoneAsia/Shanghai username: app_hist password: ${DB_HIST_PWD} shard-02: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://db-shard-02:3306/order_history_shard_02?useSSLfalseserverTimezoneAsia/Shanghai username: app_hist password: ${DB_HIST_PWD}3.2 动态数据源工厂支持运行时新增/删除数据源DynamicDataSourceFactory负责将YAML配置转化为MapObject, DataSource并注册到DynamicRoutingDataSource中。关键点在于使用ConcurrentHashMap存储targetDataSources保证put/remove线程安全每次refresh时先关闭旧数据源调用HikariDataSource.close()再注入新实例提供getDataSource(String key)方法供其他组件如Druid监控按需获取特定数据源。Component public class DynamicDataSourceFactory { private final MapObject, DataSource targetDataSources new ConcurrentHashMap(); PostConstruct public void init() { loadFromConfig(); // 从YAML加载初始数据源 } public void refresh(MapString, DataSourceProperties newConfigs) { // 1. 关闭所有旧数据源 targetDataSources.values().forEach(this::closeDataSource); targetDataSources.clear(); // 2. 创建新数据源并注入 newConfigs.forEach((key, props) - { HikariDataSource ds createHikariDataSource(props); targetDataSources.put(key, ds); }); // 3. 通知DynamicRoutingDataSource更新 dynamicRoutingDataSource.afterPropertiesSet(); } private void closeDataSource(DataSource ds) { if (ds instanceof HikariDataSource) { ((HikariDataSource) ds).close(); } } }注意dynamicRoutingDataSource.afterPropertiesSet()必须显式调用否则Spring不会重新初始化resolvedDataSources缓存——这是热加载失败最常见的原因。3.3 分片策略配置YAML定义规则Java实现算法分片逻辑不写死在代码里而是通过YAML声明规则再由Java类按需加载。例如shard: rules: - table: order_info key: user_id algorithm: mod4 sources: [shard-01, shard-02, shard-03, shard-04] - table: order_detail key: order_id algorithm: hash16 sources: [shard-01, shard-02, shard-03, shard-04]对应Java端ShardRuleLoader解析YAML后生成ShardRule对象存入内存并由ShardAlgorithmFactory根据algorithm字段返回具体实现public interface ShardAlgorithm { String calculateShardId(Object shardValue); } Component public class Mod4ShardAlgorithm implements ShardAlgorithm { Override public String calculateShardId(Object shardValue) { if (!(shardValue instanceof Number)) { throw new IllegalArgumentException(Mod4 requires numeric shard value); } long value ((Number) shardValue).longValue(); int shardIndex (int) (Math.abs(value) % 4); // 避免负数取模问题 return shard- String.format(%02d, shardIndex 1); // shard-01 ~ shard-04 } }这种解耦让DBA可独立修改分片规则改YAML而开发只需维护算法实现双方无需协同发布。4. 避坑指南5个真实踩过的坑省下你三天排查时间分库分表不是配置完就能跑通的银弹而是处处埋雷的精细活。以下是我在某高校实验室模拟项目X中连续两周压测时踩出的5个典型问题每一条都附带复现条件、根因分析和可立即验证的修复方案。4.1 现象事务内跨库操作不报错但部分SQL实际执行在错误库上原因Transactional默认传播行为是REQUIRED当方法A调用方法B时若B未声明事务其数据源路由会脱离A的事务上下文导致B的SQL走默认库而非事务绑定库。解决在Transactional方法内所有子调用必须显式声明Transactional(propagation Propagation.REQUIRED)或统一使用TransactionTemplate手动控制。更彻底的方案是在DataSourceInterceptor中检测当前是否在事务中若在则强制将forcedKey设为事务起始时的key。4.2 现象MyBatis二级缓存命中但返回的数据却是旧库里的脏数据原因MyBatis的CachingExecutor在查询前会先查缓存而缓存key未包含数据源标识如ds_key导致从shard-01查到的数据被缓存后续请求即使路由到shard-02也会命中该缓存。解决自定义CacheKey在org.apache.ibatis.cache.CacheKey.update()中加入DataSourceContextHolder.getForcedKey()的哈希值。或者——更推荐的做法——禁用跨库场景的二级缓存在Mapper XML中添加cache evictionFIFO flushInterval60000 size1000 readOnlytrue blockingfalse/并设置useCachefalse。4.3 现象Druid监控页面显示连接池活跃数为0但应用日志疯狂打印“getConnection timeout”原因DynamicRoutingDataSource的determineCurrentLookupKey()方法抛出异常如NullPointerException导致Spring fallback到defaultTargetDataSource而该项目未配置defaultTargetDataSource最终返回nullDruid拿到null后静默失败。解决在DynamicRoutingDataSource构造时强制设置setDefaultTargetDataSource(defaultDs)并在determineCurrentLookupKey()最外层加try-catch捕获所有异常并返回默认key同时记录ERROR日志“Failed to determine data source key, fallback to default”。4.4 现象单元测试中Test方法能正确路由但集成测试SpringBootTest总是走默认库原因Spring Boot Test默认使用Mockito代理而DataSourceContextHolder的ThreadLocal在线程切换如Async、CompletableFuture时未做透传导致异步线程丢失上下文。解决在测试配置中注入TaskExecutorBean并重写execute()方法在提交任务前拷贝ThreadLocalBean public TaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setBeanName(testTaskExecutor); executor.setTaskDecorator(runnable - () - { // 拷贝主线程的DataSource上下文 DataSourceKey key DataSourceContextHolder.getForcedKey(); if (key ! null) { DataSourceContextHolder.setForcedKey(key); } try { runnable.run(); } finally { DataSourceContextHolder.reset(); } }); return executor; }4.5 现象分库后SELECT COUNT(*) FROM order_info返回结果比单库少一半原因该SQL未带WHERE条件ShardAlgorithm无法提取分片键路由引擎按默认策略如READ随机选一个库执行而非广播到所有分片库。解决对这类全量聚合SQL必须显式使用TargetDataSource(broadcast)注解并在BroadcastRouter中实现遍历所有匹配分片库分别执行SQL再合并结果集。注意COUNT(*)需用SUM()聚合GROUP BY需用UNION ALL去重。5. 运行与验证从启动到压测五步确认路由完全受控光跑通不算成功必须验证每条SQL都落在预期库上。本项目提供了一套开箱即用的验证流水线不依赖外部工具全部基于Spring Boot Actuator和自研Endpoint实现。5.1 启动检查确认数据源已正确加载启动应用后访问GET /actuator/datasource/info返回应包含所有配置的数据源及其状态{ status: UP, details: { master: {status:UP,active:0,idle:5}, slave-01: {status:UP,active:0,idle:3}, shard-01: {status:UP,active:0,idle:2}, shard-02: {status:UP,active:0,idle:2} } }若某个数据源状态为DOWN检查日志中是否有HikariPool-1 - Failed to validate connection常见原因是密码错误或网络不通。5.2 路由追踪开启MDC日志实时观察key生成过程在logback-spring.xml中配置pattern确保输出%X{ds_key}和%X{shard_id}appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern%d{HH:mm:ss.SSS} [%thread] %-5level [%X{ds_key}|%X{shard_id}] %logger{36} - %msg%n/pattern /encoder /appender发起一次请求日志应类似14:22:05.123 [http-nio-8080-exec-1] DEBUG [WRITE:shard_03:ds_master_v2|shard_03] c.e.o.s.OrderServiceImpl - getOrder called with orderId1001, userId12345 14:22:05.125 [http-nio-8080-exec-1] DEBUG [WRITE:shard_03:ds_master_v2|shard_03] c.e.o.m.OrderMapper.selectById - Preparing: SELECT * FROM order_info WHERE id ?若ds_key为空或与预期不符说明DataSourceContextHolder.setForcedKey()未被调用。5.3 SQL拦截验证确认MyBatis执行的确实是目标库启用MyBatis日志logging.level.org.apache.ibatisDEBUG观察JDBC Connection日志Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession7a8b9c0d] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection123456789 wrapping com.mysql.cj.jdbc.ConnectionImpl987654321] will not be managed by Spring Preparing: SELECT * FROM order_info WHERE id ?关键看wrapping com.mysql.cj.jdbc.ConnectionImplxxxxx后的hashcode对比/actuator/datasource/pools返回的各数据源连接池中活跃连接的hashcode——必须一致才证明路由正确。5.4 压测验证用JMeter模拟混合流量观测分片倾斜度编写JMeter脚本按比例发送请求70%请求userId在1~100000区间应均匀分布到4个shard20%请求userId固定为12345应100%落到shard_0310%请求无userId参数应走默认master库。压测10分钟后执行-- 查看各分片库的order_info表行数 SELECT COUNT(*) FROM order_info; -- 在shard-01库执行 SELECT COUNT(*) FROM order_info; -- 在shard-02库执行 ...理想结果shard-01 ~ shard-04行数比值接近70%:70%:70%:70%而非出现shard-01: 10w, shard-02: 500的严重倾斜。5.5 故障注入测试模拟单库宕机验证降级能力手动停掉shard-02数据库容器然后发送原本应路由到shard-02的请求如userId20001。预期行为若配置spring.datasource.dynamic.strictfalse应自动fallback到primary库master并记录WARN日志“Shard shard-02 unavailable, fallback to primary”若配置stricttrue应抛出DataSourceUnavailableException由上层业务捕获并返回友好提示。此测试验证了系统在部分分片不可用时的容错能力而非直接雪崩。6. 二次开发与扩展如何安全地接入新业务、新分片规则、新数据源类型这个项目的设计初衷就是“可演进”而不是一次性交付。我把它用在模拟项目X的三个迭代周期中每次新增需求都只改3个类以内从未动过核心路由引擎。以下是我沉淀下来的四条改造铁律以及对应的具体操作模板。6.1 接入新业务表三步完成分片配置假设要为新表user_behavior_log添加分库分表分片键为user_id按mod8分到8个历史库Step 1扩展YAML配置在application-prod.yml中追加shard: rules: - table: user_behavior_log key: user_id algorithm: mod8 sources: [shard-log-01, shard-log-02, ..., shard-log-08] spring: datasource: dynamic: datasource: shard-log-01: url: jdbc:mysql://db-log-01:3306/log_db_01?... # ... 共8个数据源定义Step 2实现新分片算法Component public class Mod8ShardAlgorithm implements ShardAlgorithm { Override public String calculateShardId(Object shardValue) { long value Math.abs(((Number) shardValue).longValue()); int index (int) (value % 8); return shard-log- String.format(%02d, index 1); } }Step 3在Mapper接口上声明分片意图Mapper public interface UserBehaviorLogMapper { Insert(INSERT INTO user_behavior_log (...) VALUES (...)) TargetDataSource(shard-log) // ← 告诉路由引擎此Mapper只走shard-log系列库 void insert(UserBehaviorLog record); }注意TargetDataSource(shard-log)中的值必须与YAML中sources列表的前缀一致路由引擎会自动匹配shard-log-01等具体实例。6.2 支持新数据源类型无缝接入PostgreSQL分片库项目当前只支持MySQL但某模块需对接PostgreSQL历史库。无需修改DynamicRoutingDataSource只需新增PostgreSqlDataSourceFactory继承AbstractDataSourceFactory重写createDataSource()方法在DynamicDataSourceFactory.refresh()中根据driver-class-name自动选择工厂类PostgreSQL分片算法与MySQL完全复用Mod8ShardAlgorithm不依赖数据库特性。public class PostgreSqlDataSourceFactory extends AbstractDataSourceFactory { Override protected DataSource createDataSource(DataSourceProperties props) { HikariConfig config new HikariConfig(); config.setJdbcUrl(props.getUrl()); config.setUsername(props.getUsername()); config.setPassword(props.getPassword()); config.setDriverClassName(org.postgresql.Driver); // PostgreSQL特有参数 config.addDataSourceProperty(reWriteBatchedInserts, true); return new HikariDataSource(config); } }6.3 审计增强记录每次路由决策的完整证据链生产环境需要审计“某次订单查询为何走了shard-03”项目预留了RouteTrace扩展点。启用方式// 在Controller中 GetMapping(/order/{id}) public OrderVO getOrder(PathVariable Long id) { DataSourceContextHolder.enableRouteTrace(); // ← 开启追踪 return orderService.getOrder(id); }启用后日志自动追加route_path字段[ds_keyREAD:shard_03:slave-01|shard_idshard_03|route_pathANNOTATION→CONTEXT→DEFAULT]其中ANNOTATION→CONTEXT→DEFAULT表示先检查了TargetDataSource注解未找到再尝试从参数提取shardContext成功最后未触发默认策略。6.4 灰度发布支持按用户ID范围动态切流上线新分片库shard-05时希望先让userId在1000000~1000010之间的用户走新库其余走老库。只需扩展ShardAlgorithmComponent public class GrayShardAlgorithm implements ShardAlgorithm { Override public String calculateShardId(Object shardValue) { long userId ((Number) shardValue).longValue(); if (userId 1_000_000 userId 1_000_010) { return shard-05; // 灰度库 } // 兜底走原有mod4算法 return new Mod4ShardAlgorithm().calculateShardId(shardValue); } }然后在YAML中将user_id表的algorithm改为gray即可。灰度结束后删掉这段if逻辑全程无需改SQL、不发版。从那以后我每次接入新业务表都强制走一遍这四步模板配YAML → 写算法 → 打注解 → 压测验证。哪怕凌晨三点上线只要日志里看到[ds_keyWRITE:shard_05:ds_master_v3]心里就踏实。希望帮到你。本文还有配套的精品资源点击获取