简介:本资源是一份面向Java后端开发者与分布式系统学习者的分库分表实战项目包,聚焦企业级大数据量场景下的数据库水平扩展难题,以Sharding-JDBC为核心框架实现多数据源动态切换与透明化分片。资源共73个文件,涵盖18个Java业务与配置类、21个编译后class文件、18个XML配置与映射文件(含MyBatis映射及Sharding规则定义)、3个properties配置项及SQL建表脚本等,整体仅66KB,轻量易导入,结构清晰体现SSMDemo典型Maven工程布局(含pom.xml、src/main、mybatistest.sql等关键模块)。已有2068人学习下载,适合中高级开发者快速掌握分片路由逻辑、多数据源初始化方式及Sharding-JDBC在SSM整合环境中的落地细节。读者可直接运行项目理解分库分表配置加载流程、观察SQL自动路由效果,并基于现有结构拓展范围分片、分布式事务等进阶实践。
1. 分库分表,多数据源的切换:不是加个注解就完事,而是要让每个SQL知道自己该去哪张物理表、哪个数据库里找人
“分库分表,多数据源的切换”——这八个字在中大型系统架构图里高频出现,但真落到代码里,它从来不是一句@DS("slave1")就能闭环的事。我见过太多团队在压测阶段突然发现:订单查不到、库存扣重了、流水对不上账,最后追到根上,是同一个逻辑事务里,A服务用了主库写,B服务却从缓存兜底后切到了只读从库查旧快照;或是分表键没对齐,用户ID取模分了16张表,而统计任务按时间范围扫全表时,却只连了其中3个分片库,漏掉了70%的数据。这不是配置错误,是路由策略、事务边界、连接生命周期三者没对齐的系统性失焦。它适合正在把单体MySQL撑到500万行/日写入、或已接入ShardingSphere但总在跨库JOIN和分布式ID生成上反复调试的后端工程师。如果你还在用application.yml里硬写spring.shardingsphere.datasource.names=ds0,ds1,ds2就以为搞定了分库分表,那这篇笔记就是给你留的后悔药——我们不讲概念,直接拆解:路由规则怎么写才不漏数据、动态数据源怎么切才不污染线程、XA事务在分库场景下为什么建议关掉、以及最关键的——当运维半夜打电话说“ds1库磁盘满了”,你该怎么在不重启服务的前提下把新流量切走。
2. 从单数据源到多数据源:Spring Boot + AbstractRoutingDataSource 的最小可运行骨架
分库分表的本质,是让应用层感知不到物理库表的分裂,但又必须精准控制每条SQL的落点。Spring生态里最轻量、最可控的落地方式,不是一上来就堆ShardingSphere,而是先用AbstractRoutingDataSource搭出一个“会思考”的数据源代理。它不负责分片逻辑,只负责在每次获取Connection前,问一句:“这次该用哪个真实数据源?”。这个决策权,交给你自己写。
2.1 定义动态数据源上下文与路由键
核心是维护一个线程级的“路由线索”。我们不用ThreadLocal手动传,而是封装成工具类,避免业务代码到处set()和remove():
public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT_HOLDER = ThreadLocal.withInitial(() -> "ds_master"); public static void setDataSource(String dataSourceName) { CONTEXT_HOLDER.set(dataSourceName); } public static String getDataSource() { return CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }提示:
ds_master是默认值,必须存在且不能为null,否则AbstractRoutingDataSource会抛IllegalStateException。这个默认值不是摆设——它会在全局异常拦截器、定时任务、甚至某些异步线程里成为兜底保障。
2.2 实现路由逻辑:从Key到真实DataSource的映射
继承AbstractRoutingDataSource,重写determineCurrentLookupKey()方法。注意:返回值类型必须是Object,但实际用字符串做key最稳妥:
@Configuration public class DynamicDataSourceConfig { @Bean @Primary public DataSource dynamicDataSource( @Qualifier("masterDataSource") DataSource master, @Qualifier("slaveDataSource") DataSource slave) { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("ds_master", master); targetDataSources.put("ds_slave", slave); AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() { @Override protected Object determineCurrentLookupKey() { // 这里返回的key,必须和targetDataSources的key完全一致(大小写敏感) return DataSourceContextHolder.getDataSource(); } }; routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(master); // 必须设置! return routingDataSource; } }关键参数说明:
targetDataSources:Map的key是逻辑名(如"ds_master"),value是Spring容器里已注册的真实DataSourceBean。setDefaultTargetDataSource:当determineCurrentLookupKey()返回null或未匹配时的兜底数据源,必须显式设置,否则启动报错。determineCurrentLookupKey():每次getConnection()都会调用此方法。它的执行时机早于MyBatis的SqlSession创建,因此能在SQL执行前完成路由。
2.3 在DAO层注入路由意图:用AOP统一管理读写分离
手动在每个Service方法开头DataSourceContextHolder.setDataSource("ds_slave")?太脆弱。我们用AOP,在方法执行前自动识别读/写意图:
@Aspect @Component public class DataSourceAspect { @Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)") public void transactionalMethod() {} @Around("transactionalMethod()") public Object routeByTransaction(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); Transactional tx = method.getAnnotation(Transactional.class); if (tx != null && tx.readOnly()) { DataSourceContextHolder.setDataSource("ds_slave"); } else { DataSourceContextHolder.setDataSource("ds_master"); } try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); // 必须清理!否则线程复用时污染后续请求 } } }注意:
finally块里的clear()是血泪经验。Tomcat默认用线程池处理HTTP请求,若不清理,下一个请求可能沿用上一个请求的ds_slave,导致写操作误发到从库,报Read-only database错误。
3. 分库分表的核心:ShardingSphere-JDBC 的分片策略配置与实战校验
当数据量突破单库瓶颈(比如订单表超2000万行),读写分离已不够,必须物理拆分。ShardingSphere-JDBC 是当前Java生态最成熟的分片中间件,它以JDBC Driver形式嵌入应用,零侵入改造现有DAO。但它的配置不是填空题,而是逻辑题——分片键选错、算法写歪,会导致数据倾斜、查询全扫、跨库聚合失效。
3.1 分片键选择:为什么用户ID比订单时间更适合作为分库键?
分库(Database Sharding)和分表(Table Sharding)可以独立配置,但分库键必须是分表键的超集,否则路由无法收敛。常见误区是用create_time分库——看似均匀,实则灾难:
- 热点问题:大促期间所有订单集中在几秒内,全部路由到同一库;
- 范围查询失效:
WHERE create_time BETWEEN '2024-01-01' AND '2024-01-31'需要遍历所有库,丧失分片价值。
正确做法:用高基数、稳定、业务强相关的字段,如user_id。它天然满足:
- 均匀性:用户注册是长尾分布,ID哈希后各库负载接近;
- 关联性:订单、地址、积分等表都含
user_id,可保证“同用户数据落在同库”,避免跨库JOIN; - 可预测性:前端传
user_id,后端可提前路由,无需查元数据。
3.2 配置YAML:精准控制分库+分表的两层路由
以下配置实现:按user_id分4库(ds_0~ds_3),每库内按order_id分8表(t_order_0~t_order_7):
spring: shardingsphere: props: sql-show: true # 开发期必开,看实际执行的SQL发往哪个库表 datasource: names: ds_0,ds_1,ds_2,ds_3 ds_0: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://db0:3306/order_db?serverTimezone=UTC username: root password: pwd # ... ds_1 ~ ds_3 同理 rules: - !SHARDING tables: t_order: actual-data-nodes: ds_${0..3}.t_order_${0..7} table-strategy: standard: sharding-column: order_id sharding-algorithm-name: t_order_table_inline database-strategy: standard: sharding-column: user_id sharding-algorithm-name: t_order_db_inline sharding-algorithms: t_order_db_inline: type: INLINE props: algorithm-expression: ds_${user_id % 4} # 分库:user_id对4取模 t_order_table_inline: type: INLINE props: algorithm-expression: t_order_${order_id % 8} # 分表:order_id对8取模关键参数深挖:
actual-data-nodes: 模板表达式,${0..3}生成ds_0,ds_1,ds_2,ds_3,${0..7}生成t_order_0~t_order_7,最终组合出32个物理节点;sharding-column: 必须是SQL WHERE条件中出现的列,否则ShardingSphere无法解析路由;algorithm-expression: 表达式里变量名必须和sharding-column值完全一致(大小写敏感),user_id % 4中user_id必须是实体类字段名,不是数据库列名。
3.3 执行计划验证:用EXPLAIN确认SQL是否真的下推到单库单表
配置完别急着压测,先用EXPLAIN看路由是否生效:
-- 在任意ShardingSphere-JDBC代理的数据库连接中执行 EXPLAIN SELECT * FROM t_order WHERE user_id = 12345 AND order_id = 67890;预期返回:
| data_node | sql | |-----------|---------------------------------------------------------------------| | ds_1 | SELECT * FROM t_order_2 WHERE user_id = 12345 AND order_id = 67890 |如果data_node显示ds_0,ds_1,ds_2,ds_3全部出现,说明user_id未被识别为分片键——检查实体类@TableField是否标注了value = "user_id",或MyBatis XML中是否用了#{userId}而非#{user_id}导致参数名不匹配。
4. 避坑指南:分库分表与多数据源切换中5个高频翻车现场
分库分表项目上线后,80%的线上故障源于配置与认知偏差。以下是我在三个模拟项目X中踩过的坑,按发生频率排序,每条附带可复现的验证步骤。
4.1 现象:NoNodeAvailableException报错,但所有数据库连接测试都通
原因:ShardingSphere的actual-data-nodes配置中,ds_${0..3}生成的库名,与datasource.names定义的ds_0,ds_1,ds_2,ds_3不一致(如少了个下划线,写成d0,d1)。ShardingSphere在启动时不会校验节点是否存在,直到第一条SQL执行才尝试连接,此时找不到对应数据源。
解决:
- 检查
datasource.names与actual-data-nodes中的库名模板是否字符级一致; - 启动时加JVM参数
-Dorg.apache.shardingsphere.mode.repository.type=ZooKeeper(若用ZK模式)可提前暴露配置错误; - 在
application.yml中开启sql-show: true,观察启动日志是否有Can't find data source字样。
4.2 现象:分页查询LIMIT 10,10结果重复或漏数据
原因:ORDER BY create_time LIMIT在分库环境下,各库返回自己的前20条,合并后全局序错乱。ShardingSphere默认不改写此类SQL,需显式启用pagination功能。
解决:
在application.yml中添加:
spring: shardingsphere: props: query-with-cipher-column: false # 必须开启分页修正 sql-show: true rules: - !SHARDING # ... 其他配置 binding-tables: t_order,t_order_item # 若有关联表,必须声明绑定关系并在SQL中强制使用ORDER BY+LIMIT组合,避免无序分页。
4.3 现象:@Transactional方法内调用另一个@Transactional方法,从库读取到未提交数据
原因:Spring默认PROPAGATION_REQUIRED,内层事务复用外层Connection。但AbstractRoutingDataSource的determineCurrentLookupKey()在Connection创建后即固定,不会因内层方法@Transactional(readOnly=true)而切换。
解决:
- 方案1(推荐):将读操作抽离为独立Service,用
REQUIRES_NEW传播行为,确保新开Connection; - 方案2:在AOP中增强逻辑,检测嵌套事务时强制重置
DataSourceContextHolder,但需谨慎处理异常回滚。
4.4 现象:批量插入INSERT INTO t_order VALUES(...),(...)路由到多个库,性能暴跌
原因:ShardingSphere对批量SQL的路由是逐条计算的。若order_id分散,100条INSERT可能打到8个不同库,网络往返激增。
解决:
- 业务层预聚合:按
user_id % 4分组,每组内再按order_id % 8分表,生成8个独立INSERT语句; - 或改用
sharding-jdbc-spring-boot-starter4.1.1+版本,开启rewrite-batch-inserts: true(需MySQL驱动8.0.21+)。
4.5 现象:SELECT COUNT(*) FROM t_order返回结果远小于实际行数
原因:COUNT聚合未下推到各库执行,ShardingSphere默认只在单库执行并返回。这是设计使然,非Bug。
解决:
- 方案1:用
SELECT COUNT(*) FROM t_order+UNION ALL手写跨库聚合(不推荐); - 方案2(生产首选):接入Elasticsearch同步订单数据,COUNT走ES聚合;
- 方案3:接受近似值,在
application.yml中配置props.sql-show: true,观察日志中各库返回的COUNT,手动相加(仅限离线校验)。
5. 生产就绪:动态数据源热切换与分片元数据一致性保障
当DBA通知“ds_2库磁盘告警,需迁移10%流量到ds_4”,或者业务方要求“新注册用户全部路由到新库”,硬重启服务是下策。真正的生产就绪能力,是让分片策略和数据源列表支持运行时变更,且不中断任何请求。
5.1 数据源热加载:基于Spring Cloud Config + RefreshScope 的动态刷新
ShardingSphere本身不支持运行时增删数据源,但我们可以在其外层再包一层动态代理。核心思路:让AbstractRoutingDataSource的targetDataSourcesMap支持运行时更新,并触发afterPropertiesSet()重初始化。
@Component @RefreshScope // 关键:使Bean支持配置刷新 public class HotSwappableDataSource extends AbstractRoutingDataSource { private final Map<Object, Object> dynamicTargetDataSources = new ConcurrentHashMap<>(); @PostConstruct public void init() { // 初始加载 reloadFromConfig(); } public void reloadFromConfig() { // 从Config Server拉取最新数据源配置,构建新的targetDataSources Map<Object, Object> newSources = fetchLatestDataSources(); dynamicTargetDataSources.clear(); dynamicTargetDataSources.putAll(newSources); // 强制ShardingSphere重新加载(需反射调用) try { Field field = AbstractRoutingDataSource.class.getDeclaredField("resolvedDataSources"); field.setAccessible(true); field.set(this, newSources); } catch (Exception e) { log.error("Failed to refresh resolvedDataSources", e); } } @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }配合Spring Cloud Config,当application-dev.yml中spring.shardingsphere.datasource.ds_4新增时,调用/actuator/refresh端点即可触发reloadFromConfig()。
5.2 分片策略热更新:用ZooKeeper存储分片规则,监听节点变化
ShardingSphere原生支持ZooKeeper作为注册中心存储分片规则。启用后,所有分片策略(如algorithm-expression)都存于ZK节点/sharding-rules/t_order/database-strategy/standard。运维可通过ZK客户端直接修改,ShardingSphere会自动监听并重载。
# application.yml spring: shardingsphere: mode: type: Cluster repository: type: ZooKeeper props: namespace: sharding-demo server-lists: zk1:2181,zk2:2181,zk3:2181 retry-intervalMilliseconds: 500 time-to-live-seconds: 60提示:ZK模式下,
actual-data-nodes必须用ds_${0..3}.t_order_${0..7}这种静态模板,不能用ds_${online_status == 'true' ? 0 : 1}等动态表达式,否则ZK无法序列化。
5.3 元数据一致性校验:用Python脚本每日比对各库表结构与行数
分库后最怕“某库表结构漏改”。我们用一个轻量脚本,每天凌晨扫描所有分片库,输出差异报告:
#!/usr/bin/env python3 # check_sharding_consistency.py import pymysql import sys CONFIGS = { "ds_0": {"host": "db0", "db": "order_db"}, "ds_1": {"host": "db1", "db": "order_db"}, # ... ds_2, ds_3 } def get_table_schema(conn, table_name): with conn.cursor() as cur: cur.execute(f"SHOW CREATE TABLE {table_name}") return cur.fetchone()[1] def get_row_count(conn, table_name): with conn.cursor() as cur: cur.execute(f"SELECT COUNT(*) FROM {table_name}") return cur.fetchone()[0] if __name__ == "__main__": schemas = {} counts = {} for ds_name, conf in CONFIGS.items(): conn = pymysql.connect(**conf, charset='utf8mb4') schemas[ds_name] = get_table_schema(conn, "t_order_0") counts[ds_name] = get_row_count(conn, "t_order_0") conn.close() # 比对schema base_schema = list(schemas.values())[0] for ds, schema in schemas.items(): if schema != base_schema: print(f"❌ Schema mismatch in {ds}") # 比对行数(允许5%误差) base_count = list(counts.values())[0] for ds, cnt in counts.items(): if abs(cnt - base_count) / base_count > 0.05: print(f"⚠️ Row count skew in {ds}: {cnt} vs {base_count}")将此脚本加入Crontab,输出重定向到企业微信机器人,异常时秒级告警。
我习惯在每次上线分片策略前,先跑一遍这个脚本,再用EXPLAIN验证3个典型SQL的路由路径。不是信不过配置,是信不过人脑对复杂表达式的穷举能力。分库分表没有银弹,只有把每一步的“为什么这样”刻进肌肉记忆,才能在半夜告警电话响起时,手指不抖地敲出curl -X POST http://localhost:8080/actuator/refresh。希望帮到你。
本文还有配套的精品资源,点击获取