简介:本资源是一套基于Spring Boot的多数据源与分库分表实战项目,面向Java后端开发者及数据库架构进阶学习者,聚焦高并发场景下的数据层扩展难题。项目整合Dynamic-Datasource实现多数据源动态路由,采用Sharding-JDBC完成逻辑库表拆分,并集成Druid连接池进行性能监控与管理;辅以MyBatis-Plus简化CRUD、Lombok减少模板代码、JUnit保障单元测试覆盖。压缩包共162个文件,含115个XML映射文件(定义SQL与结果映射)、14个Java业务类(如OrderController、UserTestServiceImpl等)、6个YML配置(含多源与分片规则)、以及日志、测试、构建相关文件,整体仅164KB,轻量易读。已有624人学习下载,提供开箱即用的完整工程结构、典型分表场景(如订单、用户)的代码实现、可直接运行的测试用例(FenkuApplicationTests.class等),便于快速理解多数据源切换与Sharding-JDBC分片策略落地细节。
1. 多数据源 + 分库分表:不是“加个注解就完事”的玄学,而是你线上订单查不出、库存对不上、报表跑崩时,唯一能靠得住的底牌
某次压测现场,A同学盯着监控面板上那条持续飙升的数据库连接数曲线,手心全是汗——单库单表扛不住日均300万订单的写入洪峰,主从延迟飙到47秒,下游报表服务批量超时。运维喊“再扛两小时就切读写分离”,DBA摇头说“主库IO已打满”。最后救场的,不是扩容,不是换云厂商,而是一套跑在Spring Boot里的多数据源+分库分表方案:把订单库按用户ID哈希拆成8个物理库,每个库再按时间范围分16张月表;同时把用户中心、商品目录、支付流水分别路由到独立的数据源。上线后,单点写入压力下降87%,跨库关联查询用ShardingSphere的BindingTable机制兜底,报表任务从“定时失败”变成“准时产出”。这不是高可用架构的终点,但它是绝大多数业务团队在流量突增、数据爆炸、SQL慢得像PPT翻页时,最该提前备好的后悔药——尤其当你用的是Spring Boot,又还没被逼到重写DAO层的地步。
2. 架构设计:三层路由模型 + Spring Boot自动装配,让分库分表从黑匣子变成可调试的白盒
2.1 数据路由的三层结构:逻辑层、数据源层、表层,缺一不可
分库分表不是简单地把SQL里的FROM order_202401替换成FROM order_${shardKey}。真实场景下,它必须解决三个维度的路由问题:
- 逻辑库路由(Database Routing):决定SQL发往哪个物理数据库实例(如
ds_order_0、ds_order_1); - 逻辑表路由(Table Routing):决定SQL操作哪张物理表(如
order_202401、order_202402); - 数据源路由(DataSource Routing):决定不同业务域(订单/用户/支付)走完全隔离的连接池与事务管理器。
Spring Boot本身不提供这三层能力,所以项目里必然引入ShardingSphere-JDBC(v5.3.2)作为核心路由引擎。它通过ShardingRuleConfiguration定义分片规则,再由SpringBootShardingDataSource完成自动装配。关键在于:逻辑库名(如ds_order)和逻辑表名(如order_info)必须全程贯穿于MyBatis的Mapper XML、@Select注解、甚至JPA的@Entity名称中——物理库表名只在ShardingSphere内部映射,业务代码永远看不到ds_order_3或order_info_202405。这点踩坑无数:有团队硬编码物理表名进SQL,结果分库后全报Table not found。
2.2 Spring Boot自动装配的核心配置类:ShardingDataSourceAutoConfiguration
项目启动时,ShardingDataSourceAutoConfiguration会扫描application.yml中所有以spring.shardingsphere.开头的属性,并构建ShardingSphereDataSource。这个Bean最终被注入到DataSource类型的所有依赖中(包括MyBatis的SqlSessionFactory)。配置结构如下:
spring: shardingsphere: mode: Standalone # 本地模式,生产环境可切Cluster(ZooKeeper) props: sql-show: true # 开发期必开,看实际执行的SQL datasource: ds_order_0: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.10:3306/order_db_0?useSSL=false username: root password: pwd123 ds_order_1: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.11:3306/order_db_1?useSSL=false username: root password: pwd123 # ... ds_order_2 ~ ds_order_7 共8个数据源 rules: - !SHARDING tables: order_info: actual-data-nodes: ds_order_$->{0..7}.order_info_$->{202401..202412} table-strategy: standard: sharding-column: create_time sharding-algorithm-name: tlogic-month database-strategy: standard: sharding-column: user_id sharding-algorithm-name: dlogic-hash sharding-algorithms: dlogic-hash: type: HASH_MOD props: sharding-count: 8 tlogic-month: type: DATE_TRUNC props: datetime-pattern: "yyyyMM" datetime-lower: "202401" datetime-upper: "202412"提示:
actual-data-nodes是核心!它声明了逻辑表order_info对应的所有物理节点路径,格式为数据源名.表名。ShardingSphere据此生成路由结果,绝不允许在SQL里写死物理表名。
2.3 分片算法的实现原理:为什么HASH_MOD不能用于时间字段?
分片算法不是魔法函数,而是确定性映射。以user_id分库为例,HASH_MOD算法本质是:
public class HashModAlgorithm implements StandardShardingAlgorithm<Comparable<?>> { private int shardingCount; @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Comparable<?>> shardingValue) { // availableTargetNames = ["ds_order_0", "ds_order_1", ..., "ds_order_7"] // shardingValue.getValue() = 123456789L long hash = Math.abs(shardingValue.getValue().hashCode()); // 防负数 int index = (int) (hash % shardingCount); // 8取模 → 0~7 return availableTargetNames.stream() .filter(x -> x.endsWith("_" + index)) .findFirst() .orElseThrow(() -> new RuntimeException("No target db found")); } }而时间字段必须用DATE_TRUNC:它把2024-05-20 14:30:00截断为202405,再匹配actual-data-nodes中的order_info_202405。如果误用HASH_MOD处理时间字段,会导致同一月份的数据散落到8个库的12张表里,彻底破坏数据局部性——这是新人最常翻车的点。
2.4 多数据源隔离:如何让用户服务和订单服务互不干扰?
当订单库、用户库、支付库物理隔离时,必须避免事务跨数据源。项目采用AbstractRoutingDataSource实现动态数据源路由:
@Component public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceType(); } } // 线程局部变量存储当前数据源标识 public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>(); public static void setDataSourceType(String dataSourceType) { CONTEXT_HOLDER.set(dataSourceType); } public static String getDataSourceType() { return CONTEXT_HOLDER.get(); } public static void clearDataSourceType() { CONTEXT_HOLDER.remove(); } }在Service方法上加自定义注解@DataSource("ds_user"),通过AOP拦截并调用DataSourceContextHolder.setDataSourceType("ds_user")。这样,MyBatis执行SQL前,DynamicDataSource就能根据线程变量返回正确的DataSourceBean。注意:此方案与ShardingSphere的ShardingSphereDataSource不冲突——ShardingSphere负责分库分表内的路由,DynamicDataSource负责跨域数据源切换。
3. 核心模块拆解:一个订单创建请求从Controller到落库的完整链路
3.1 Controller层:接收原始参数,不做任何分片逻辑
@RestController @RequestMapping("/api/orders") public class OrderController { @PostMapping public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) { // request包含 userId, productId, amount, createTime 等原始字段 Order order = orderService.createOrder(request); return ResponseEntity.ok(new OrderResponse(order.getId())); } }注意:Controller层绝对不感知分库分表。
userId和createTime原样传入Service,由下层框架处理路由。
3.2 Service层:事务边界 + 分片键校验 + 调用Mapper
@Service @Transactional(rollbackFor = Exception.class) public class OrderServiceImpl implements OrderService { @Resource private OrderMapper orderMapper; @Override public Order createOrder(OrderRequest request) { // 1. 强制校验分片键存在且合法 if (request.getUserId() == null || request.getCreateTime() == null) { throw new IllegalArgumentException("userId and createTime must not be null for sharding"); } // 2. 构建实体(注意:id由数据库自增,不设分片键) Order order = new Order(); order.setUserId(request.getUserId()); order.setProductId(request.getProductId()); order.setAmount(request.getAmount()); order.setCreateTime(request.getCreateTime()); order.setStatus("CREATED"); // 3. 执行插入 —— 此刻ShardingSphere自动计算路由 orderMapper.insert(order); // 4. 返回完整对象(含数据库生成的id) return order; } }3.3 Mapper层:使用逻辑表名,SQL保持原生风格
<!-- OrderMapper.xml --> <mapper namespace="com.example.mapper.OrderMapper"> <insert id="insert" parameterType="com.example.entity.Order" useGeneratedKeys="true" keyProperty="id"> INSERT INTO order_info (user_id, product_id, amount, create_time, status) VALUES (#{userId}, #{productId}, #{amount}, #{createTime}, #{status}) </insert> <select id="selectByUserId" resultType="com.example.entity.Order"> SELECT * FROM order_info WHERE user_id = #{userId} </select> <select id="selectByTimeRange" resultType="com.example.entity.Order"> SELECT * FROM order_info WHERE create_time BETWEEN #{startTime} AND #{endTime} </select> </mapper>关键点:
<insert>中表名是order_info(逻辑表),不是order_info_202405;SELECT语句中WHERE user_id = ?会被ShardingSphere识别为分库键,自动路由到对应库;WHERE create_time BETWEEN ? AND ?会被识别为分表键,自动下推到匹配的月表(如order_info_202404,order_info_202405);- 跨月查询会自动合并多个物理表结果,业务无感。
3.4 ShardingSphere的SQL改写过程:从逻辑SQL到物理SQL的三步转换
当执行SELECT * FROM order_info WHERE user_id = 12345 AND create_time > '2024-05-01'时,ShardingSphere内部发生:
- 解析(Parse):将SQL转为AST抽象语法树,识别出
order_info为逻辑表,user_id为分库列,create_time为分表列; - 路由(Route):
user_id=12345→12345 % 8 = 1→ 路由到ds_order_1;create_time > '2024-05-01'→ 匹配202405,202406,202407... → 生成物理表列表order_info_202405,order_info_202406;- 最终路由结果:
ds_order_1.order_info_202405,ds_order_1.order_info_202406, ...;
- 改写(Rewrite):将逻辑SQL改写为多条物理SQL:
SELECT * FROM order_info_202405 WHERE user_id = 12345 AND create_time > '2024-05-01'; SELECT * FROM order_info_202406 WHERE user_id = 12345 AND create_time > '2024-05-01'; -- ... 其他月份表 - 归并(Merge):将各物理库返回的结果集按
ORDER BY或GROUP BY归并,返回给MyBatis。
4. 常见问题排查:那些让你凌晨三点还在看日志的血泪经验
4.1 现象:Table 'ds_order_0.order_info' doesn't exist,但actual-data-nodes明明写了order_info_$->{202401..202412}
原因:ShardingSphere默认要求物理表必须真实存在。actual-data-nodes中的order_info_202401等表名,必须在ds_order_0库中已创建好。它不会自动建表,也不会忽略不存在的表。
解决:
- 启动前,用脚本批量创建所有分表:
for month in {202401..202412}; do mysql -h192.168.1.10 -uroot -ppwd123 order_db_0 -e " CREATE TABLE IF NOT EXISTS order_info_${month} LIKE order_info_template; " done - 或在
application.yml中开启props.sql-show=true,观察启动时ShardingSphere打印的初始化SQL,确认它是否尝试访问了未创建的表。
4.2 现象:SELECT * FROM order_info WHERE user_id = ?返回空,但直接连ds_order_1查order_info_202405有数据
原因:分片键user_id值类型不一致。Java中user_id是Long,但数据库字段是BIGINT UNSIGNED,ShardingSphere的HASH_MOD算法对负数处理异常(-12345 % 8 = -1,而期望是7)。
解决:
- 在分片算法中强制转正:
long hash = Math.abs(shardingValue.getValue().hashCode() & 0x7FFFFFFF); // 用位运算保正 - 或统一数据库字段为
BIGINT SIGNED,避免无符号整数溢出。
4.3 现象:跨库JOIN(如SELECT o.*, u.name FROM order_info o JOIN user_info u ON o.user_id = u.id)报错Cannot support multiple data sources in one query
原因:ShardingSphere-JDBC默认禁止跨数据源JOIN。order_info在ds_order_*,user_info在ds_user,属于不同数据源集群。
解决:
- 方案1(推荐):业务层拆解。先查
user_info获取u.name,再查order_info,内存组装; - 方案2:启用
Broadcast Table(广播表),将user_info同步到所有ds_order_*库,JOIN在单库内完成; - 方案3:升级到ShardingSphere-Proxy,在代理层支持跨库JOIN(但增加运维复杂度)。
4.4 现象:@Transactional失效,部分订单插入成功、部分失败,数据不一致
原因:@Transactional默认作用于DataSourceBean。当使用DynamicDataSource时,若AOP切面未正确代理到DynamicDataSource,事务会回滚到默认数据源(如HikariCP的dataSource),而非当前路由的ds_order_1。
解决:
- 确保
@EnableTransactionManagement与DynamicDataSource在同一包扫描路径; - 在
@Transactional方法内,显式调用DataSourceContextHolder.setDataSourceType("ds_order_1"); - 更稳妥做法:为每个数据源定义独立的
PlatformTransactionManager,如:
然后@Bean("orderTransactionManager") public PlatformTransactionManager orderTxManager(@Qualifier("ds_order_0") DataSource ds) { return new DataSourceTransactionManager(ds); }@Transactional("orderTransactionManager")指定管理器。
4.5 现象:sql-show=true日志里出现大量SELECT * FROM order_info_202401 WHERE user_id = ?,但业务代码只写了一次selectByUserId
原因:MyBatis的<select>未配置resultMap,导致ShardingSphere无法识别返回字段,被迫查询所有列(SELECT *)以保证归并正确性。
解决:
- 为每个Mapper明确配置
resultMap:<resultMap id="OrderResultMap" type="com.example.entity.Order"> <id property="id" column="id"/> <result property="userId" column="user_id"/> <result property="productId" column="product_id"/> <result property="amount" column="amount"/> <result property="createTime" column="create_time"/> <result property="status" column="status"/> </resultMap> <select id="selectByUserId" resultMap="OrderResultMap"> SELECT id, user_id, product_id, amount, create_time, status FROM order_info WHERE user_id = #{userId} </select> - 这样ShardingSphere归并时只需处理指定字段,且物理SQL更精准。
5. 运行与验证:从本地启动到生产灰度的四步落地法
5.1 本地开发环境:用H2内存库快速验证路由逻辑
生产环境用MySQL,但本地开发用H2可免去建库建表麻烦。ShardingSphere支持H2作为分片数据源:
spring: shardingsphere: datasource: ds_order_0: driver-class-name: org.h2.Driver jdbc-url: jdbc:h2:mem:order_db_0;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa password: ds_order_1: driver-class-name: org.h2.Driver jdbc-url: jdbc:h2:mem:order_db_1;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa password: rules: - !SHARDING tables: order_info: actual-data-nodes: ds_order_$->{0..1}.order_info_$->{0..1} database-strategy: standard: sharding-column: user_id sharding-algorithm-name: dlogic-h2 sharding-algorithms: dlogic-h2: type: INLINE props: algorithm-expression: ds_order_$->{user_id % 2}提示:H2的
INLINE算法比HASH_MOD更轻量,适合本地验证。启动后访问http://localhost:8080/h2-console,可直连mem:order_db_0查看分片后的表。
5.2 单元测试:用ShardingSphereJUnit4ClassRunner验证分片结果
不靠日志,用单元测试断言路由是否正确:
@RunWith(ShardingSphereJUnit4ClassRunner.class) @ShardingSphereTest( databases = { @Database(value = "ds_order_0", initScript = "init-order-0.sql"), @Database(value = "ds_order_1", initScript = "init-order-1.sql") } ) public class OrderShardingTest { @Test public void should_route_to_ds_order_1_when_userId_is_12345() { // 给定 Long userId = 12345L; // 当 List<RouteUnit> routeUnits = ShardingSphereDataSourceFactory .getRouteEngine() .route("SELECT * FROM order_info WHERE user_id = ?", Arrays.asList(userId)); // 那么 assertThat(routeUnits).hasSize(1); assertThat(routeUnits.get(0).getDataSourceName()).isEqualTo("ds_order_1"); // 12345 % 8 = 1 assertThat(routeUnits.get(0).getTableUnit().getActualTableName()).startsWith("order_info_"); } }5.3 生产部署:分库分表的三阶段灰度策略
直接全量切流风险极高。我们采用分阶段灰度:
| 阶段 | 操作 | 监控重点 |
|---|---|---|
| Phase 1:只读不写 | 将新分库分表数据源设为read-only,所有写请求仍走旧单库;用Canal监听旧库binlog,实时同步到新分库 | 新库数据一致性(对比count(*))、同步延迟 |
| Phase 2:读写分离 | 写请求10%切到新分库,读请求100%走新分库;旧库仅保留备份 | 新库写入成功率、主从延迟、连接池耗尽 |
| Phase 3:全量切换 | 写请求100%切到新分库;旧库停写,保留只读3天供回滚 | 全链路错误率、慢SQL数量、业务指标(下单成功率) |
关键动作:每次切流前,执行
SELECT COUNT(*) FROM order_info对比新旧库总量;切流后,用pt-table-checksum校验数据行级一致性。
5.4 生产监控:必须接入的5个核心指标
分库分表后,传统单库监控失效。必须新增:
- 分片键分布热力图:统计
user_id % 8的分布,确认是否倾斜(如ds_order_0承载60%流量); - 跨库查询次数:
ShardingSphereMetrics中sharding.query.cross-database.count; - 物理SQL平均耗时:对比
sharding.query.physical-time.avg与sharding.query.logical-time.avg,差值过大说明路由或归并瓶颈; - 连接池等待队列长度:各
ds_order_*的HikariCPpool.WaitingThreads; - 分表命中率:
ShardingSphere日志中[INFO] Actual SQL出现的物理表名,统计order_info_202405等是否覆盖全部12个月份。
6. 二次开发改造:从“能跑”到“可控”的三个实战技巧
6.1 自定义分片算法:支持按地域+时间双维度分库
业务提出新需求:华东区用户订单存ds_order_east_*,华北区存ds_order_north_*,且每区按月分表。原HASH_MOD无法满足。解决方案:继承ComplexKeysShardingAlgorithm,实现双键路由:
public class RegionTimeShardingAlgorithm implements ComplexKeysShardingAlgorithm<Comparable<?>> { @Override public Collection<String> doSharding(Collection<String> availableTargetNames, ComplexKeysShardingValue<Comparable<?>> shardingValues) { // 获取分片值 Map<String, Collection<Comparable<?>>> columnValues = shardingValues.getColumnNameAndShardingValuesMap(); Collection<Comparable<?>> regionValues = columnValues.get("region"); Collection<Comparable<?>> timeValues = columnValues.get("create_time"); if (regionValues == null || timeValues == null) { throw new UnsupportedOperationException("region and create_time must be provided"); } String region = regionValues.iterator().next().toString(); // 如 "east" String time = timeValues.iterator().next().toString(); // 如 "2024-05-20" String month = time.substring(0, 7).replace("-", ""); // "202405" // 构建物理节点名:ds_order_east_202405 String targetNode = "ds_order_" + region + "_" + month; // 确保目标节点存在 if (!availableTargetNames.contains(targetNode)) { throw new RuntimeException("Target node not found: " + targetNode); } return Collections.singletonList(targetNode); } }在application.yml中注册:
sharding-algorithms: region-time: type: CLASS_BASED props: strategy: COMPLEX algorithmClassName: com.example.sharding.RegionTimeShardingAlgorithm然后在分片规则中引用:
tables: order_info: actual-data-nodes: ds_order_east_$->{202401..202412}, ds_order_north_$->{202401..202412} database-strategy: complex: sharding-columns: region,create_time sharding-algorithm-name: region-time6.2 动态分表:按需创建新月表,避免预建10年表
预建202401..203412共132张表浪费空间,且DDL操作影响线上。改为运行时自动建表:
@Component public class DynamicTableCreator { @Resource private JdbcTemplate jdbcTemplate; public void ensureTableExists(String tableName, String templateTable) { String checkSql = "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name = ?"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class, tableName); if (count == 0) { String createSql = "CREATE TABLE " + tableName + " LIKE " + templateTable; jdbcTemplate.execute(createSql); log.info("Created table: {}", tableName); } } } // 在OrderService.createOrder中调用 public Order createOrder(OrderRequest request) { String monthTable = "order_info_" + DateUtils.formatMonth(request.getCreateTime()); dynamicTableCreator.ensureTableExists(monthTable, "order_info_template"); // ... 后续插入逻辑 }注意:
ensureTableExists需加分布式锁(如Redis Lock),防止并发创建。
6.3 分库分表下的全局ID:Snowflake + 数据源ID嵌入
@GeneratedValue(strategy = GenerationType.IDENTITY)在分库下失效。我们采用改进版Snowflake:
timestamp(41bit):毫秒时间戳;datacenterId(5bit):取自数据源编号(ds_order_0→0,ds_order_1→1);machineId(5bit):应用实例ID;sequence(12bit):毫秒内序列号。
这样生成的ID天然携带数据源信息,便于问题定位:
public class ShardingSnowflakeIdGenerator { private final long datacenterId; // 从DataSource名称解析,如"ds_order_3" → 3 public long nextId() { long timestamp = timeGen(); if (timestamp < lastTimestamp) { throw new RuntimeException("Clock moved backwards"); } if (lastTimestamp == timestamp) { sequence = (sequence + 1) & SEQUENCE_MASK; if (sequence == 0) { timestamp = tilNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; // datacenterId嵌入ID高位 return ((timestamp - TWEPOCH) << TIMESTAMP_LEFT_SHIFT) | (datacenterId << DATACENTER_ID_SHIFT) | (machineId << MACHINE_ID_SHIFT) | sequence; } }在Order实体中,id字段不再依赖数据库自增,而由Service生成:
order.setId(snowflakeIdGenerator.nextId()); orderMapper.insert(order);从那以后我每次设计分库分表方案,都强制走一遍“分片键压测”:用JMeter模拟10万user_id随机请求,画出各ds_order_*的QPS热力图,确保偏差<15%。如果发现ds_order_0独占40%流量,立刻检查user_id是否含大量0值或负数——这种前置验证,比线上报警后再切流省十倍力气。希望帮到你。
本文还有配套的精品资源,点击获取