ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot多数据源与分库分表实战:动态路由与分片方案

Spring Boot多数据源与分库分表实战:动态路由与分片方案

简介:这份资源面向需要掌握多数据源与分库分表技术的 Spring Boot 开发者,聚焦同一数据库内水平分表与跨库分库的落地实现。项目基于 Spring Boot 搭建,多数据源部分采用 mybatis-plus 的 dynamic-datasource,分库分表由 sharding-jdbc 完成,连接池管理使用 alibaba 的 druid-spring-boot-starter,并继承 MyBatis-Plus 简化数据库操作、引入 Lombok 减少样板代码,配合 JUnit 编写测试用例验证分片效果。压缩包共 162 个文件,以 115 个 xml 映射与配置、14 个 java 源码、14 个 class 编译文件为主,另有 6 个 yml、3 个 properties 等配置及日志文件,整体约 164KB,结构紧凑便于按模块查阅。已有 624 人学习下载,适合想理解动态数据源切换、分片规则配置与测试验证思路的中级开发者参考借鉴。

1. 多数据源与分库分表:一套能扛住数据量增长的 Spring Boot 落地方案

单库单表跑到几百万行之后,查询开始变慢,加索引也只能续命一阵;再往后业务方要求读写分离、按租户隔离、按时间归档,一个数据源已经不够用了。这套方案要解决的就是这个阶段的问题:在 Spring Boot 里同时接入多个数据源,并把大表按规则拆到多个库、多张表里,让写入分散、查询可控、扩容有路径。它适合正在做毕设或课程设计、需要一套完整数据访问层设计的学生,也适合要交付一版可演进架构方案的从业者。下面从需求边界讲到总体设计,再落到动态数据源、分片路由、分布式主键和验收方法,中间穿插我踩过的坑。

2. 需求拆解与技术选型:先想清楚拆什么、凭什么拆

动手写代码之前,最容易被跳过的一步是需求拆解。很多方案一上来就画分库分表架构图,结果落地时发现根本没必要拆,或者拆完发现跨库查询把收益全吃掉了。我一般会先把数据按「增长方式」分类:订单、日志、流水这类只增不改、按时间或用户维度查询的表,是分片的主要对象;配置表、字典表、权限表这类数据量稳定、读多写少的表,留在单库即可。分片键的选择直接决定后续所有查询能不能走对路由,选错了就是血泪经验。

2.1 分片键与分片策略的取舍

分片键要满足两个条件:一是分布均匀,二是高频查询条件里能带上它。以订单场景为例,用用户 ID 做分片键,能保证同一用户的订单落在同一张表,按用户查订单是单表查询;但如果运营后台习惯按订单号查,就得额外维护一张「订单号 → 用户 ID」的映射表,或者让订单号本身携带分片信息。用时间做分片键则适合归档场景,按月分表,历史数据整表下线,但跨月查询要合并结果。

常见的分片算法有两类。取模分片实现简单、分布均匀,但扩容时需要数据迁移;范围分片按区间划分,扩容方便,但容易出现热点。实际项目里我倾向于「先取模、预留足够分片数」的做法,比如一开始就规划 16 个库或 64 张表,前期只用其中一部分,扩容时把部分分片整体迁走,避免全量重分布。

分片方式优点缺点适用场景
取模分片分布均匀、实现简单扩容需迁移数据数据量可预估、分片数一次规划到位
范围分片扩容方便、易归档易产生写热点按时间增长的流水、日志
哈希取模 + 预留兼顾均匀与扩容需要提前规划分片总数订单、用户类核心业务表

2.2 技术栈组合与选型理由

技术栈以 Spring Boot 为底座,数据访问层用 MyBatis-Plus 或原生 MyBatis,动态数据源用 AbstractRoutingDataSource 扩展,分片逻辑可以自研轻量路由组件,也可以引入成熟的分库分表中间件。选型时我一般看三点:团队是否熟悉、是否侵入业务代码、出问题能不能定位。自研路由组件的好处是逻辑透明,出问题能直接打断点;中间件的好处是功能全,但配置复杂、排查链路长。

连接池统一用 HikariCP,每个数据源独立配置连接池参数,避免一个慢查询把整个池子拖垮。分布式主键用雪花算法,保证跨库唯一且趋势递增。事务方面,单库内用本地事务,跨库场景尽量通过业务设计规避,确实需要时用补偿或最终一致性方案,不轻易上分布式事务框架。

提示:分片数不是越多越好。分片过多会让连接数、元数据管理、跨分片查询的成本同步上升,通常按「单表数据量控制在千万级以内」反推分片数即可。

3. 总体架构设计:动态数据源与分片路由的职责划分

架构上把数据访问层拆成三层:数据源路由层负责在多个物理数据源之间切换,分片路由层负责把逻辑表名和分片键换算成真实的库名和表名,持久层负责执行 SQL。三层各管一件事,边界清晰,后续加数据源或改分片规则时不用动业务代码。这一章讲清楚每层的职责和它们之间的协作方式。

3.1 三层结构与模块职责

数据源路由层的核心是一个继承 AbstractRoutingDataSource 的路由数据源,它持有一个数据源映射表和一个 ThreadLocal 上下文。业务代码通过注解或编程方式指定当前使用哪个数据源,路由层在 getConnection 时从上下文取出数据源标识,查映射表拿到真实数据源。分片路由层则是一个拦截器或插件,在 SQL 执行前解析表名和分片键,改写 SQL 中的逻辑表名为物理表名。

持久层保持常规写法,Mapper 里写逻辑表名,不感知分片细节。这样业务开发的心智负担最小,分片规则调整也只影响路由层。模块划分上,我一般拆成四个包:datasource(数据源配置与路由)、sharding(分片算法与 SQL 改写)、primarykey(分布式主键)、common(上下文与工具类)。

3.2 数据源上下文与切换机制

数据源上下文用 ThreadLocal 保存当前线程的数据源标识,配合 AOP 在方法入口设置、出口清理。这里有个容易忽略的点:异步线程和线程池不会自动传递 ThreadLocal,跨线程调用时必须手动传递上下文,否则路由会落到默认数据源上,表现为「数据写错库」这种玄学问题。

public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void set(String dataSourceKey) { CONTEXT.set(dataSourceKey); } public static String get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }

这段代码是上下文持有类,set 在方法进入时调用,clear 必须在 finally 里执行。如果不清理,线程池复用线程时会把上一次的数据源标识带进来,导致后续请求路由到错误的数据源。我见过最典型的翻车场景是:一个请求查了从库,线程归还池子后没清理,下一个请求本该走主库写入,结果写到了从库上,排查了半天才定位到 ThreadLocal 没清。

3.3 分片路由的执行流程

一次带分片键的查询,执行流程是这样的:拦截器解析 SQL 拿到逻辑表名,从参数中提取分片键值,用分片算法算出目标库和表,改写 SQL 后交给路由数据源执行。不带分片键的查询则有两种处理方式:广播到所有分片再合并结果,或者直接拒绝并提示必须带分片键。生产环境我倾向于后者,广播查询在分片数多的时候会把数据库打满。

public class ShardingInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { // 从 MappedStatement 中取出逻辑表名与分片配置 String logicTable = resolveLogicTable(invocation); Object shardValue = resolveShardValue(invocation); if (shardValue == null) { throw new IllegalArgumentException("查询必须携带分片键"); } // 计算物理表后缀,例如 order_03 int suffix = Math.abs(shardValue.hashCode()) % SHARD_COUNT; String physicalTable = logicTable + "_" + String.format("%02d", suffix); rewriteSql(invocation, logicTable, physicalTable); return invocation.proceed(); } }

拦截器里 resolveShardValue 从参数对象或 SQL 参数中提取分片键,suffix 的计算要和建表规则严格一致,建表时用同样的算法生成表名后缀。rewriteSql 只替换表名,不动其他结构,避免引入语法错误。分片数 SHARD_COUNT 建议配置化,不要硬编码在代码里,扩容时改配置比改代码安全。

4. 核心模块实现:从动态数据源到分布式主键

这一章落到具体实现,覆盖动态数据源配置、分片表创建规则、分布式主键生成和跨分片查询的处理。每个模块给出可运行的代码或配置,并说明参数含义和改动方式。实现顺序按依赖关系走:先有数据源,再有分片,最后解决主键和查询合并。

4.1 多数据源配置与动态路由

数据源配置用 YAML 声明多个数据源,每个数据源独立配置 URL、用户名和连接池参数。主数据源标记为 @Primary,其余数据源通过配置类注册为 Bean,统一交给路由数据源管理。

spring: datasource: master: jdbc-url: jdbc:mysql://host-a:3306/order_db?useSSL=false username: app_user password: ${DB_PASSWORD} hikari: maximum-pool-size: 20 connection-timeout: 3000 slave: jdbc-url: jdbc:mysql://host-b:3306/order_db?useSSL=false username: app_user password: ${DB_PASSWORD} hikari: maximum-pool-size: 10 connection-timeout: 3000

maximum-pool-size 按数据源的实际压力设置,主库写入多可以给大一些,从库读多但并发可控可以小一些。connection-timeout 不要设太长,拿不到连接时快速失败比长时间挂起更容易定位问题。密码用环境变量注入,不写死在配置文件里。

路由数据源的实现要点是重写 determineCurrentLookupKey,返回上下文中的数据源标识,并在初始化时把数据源映射表注入进去。默认数据源设为主库,保证没有显式指定时不会误路由。

4.2 分片表创建与 SQL 改写规则

物理表的命名规则要统一,比如 order_00 到 order_15,后缀补零保证排序和可读性。建表脚本按分片数批量生成,避免手工建表漏掉某张。SQL 改写只替换表名,不改列名和条件,改写后的 SQL 要能通过数据库的语法校验。

-- 按分片数批量生成物理表,示例为 16 张 CREATE TABLE order_00 LIKE order_template; CREATE TABLE order_01 LIKE order_template; -- ... 依次到 order_15

用模板表加 LIKE 的方式建表,能保证所有分片表结构一致,后续加字段时也按同样方式批量执行。改表结构时要注意:所有分片表必须同步变更,漏掉一张就会在路由到该分片时报字段不存在。我一般把建表和改表脚本纳入版本管理,执行前先校验分片表数量是否与配置一致。

4.3 分布式主键与跨分片查询处理

分布式主键用雪花算法,机器 ID 从配置或注册中心获取,保证多实例部署时不冲突。主键生成器封装成工具类,业务代码调用 nextId 即可,不感知算法细节。

public class SnowflakeIdGenerator { private final long workerId; private final long epoch = 1700000000000L; private long sequence = 0L; private long lastTimestamp = -1L; public SnowflakeIdGenerator(long workerId) { this.workerId = workerId; } public synchronized long nextId() { long timestamp = System.currentTimeMillis(); if (timestamp < lastTimestamp) { throw new IllegalStateException("时钟回拨,拒绝生成 ID"); } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & 4095; if (sequence == 0) { timestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; return ((timestamp - epoch) << 22) | (workerId << 12) | sequence; } }

workerId 的位数决定最大实例数,sequence 的位数决定单毫秒最大并发。时钟回拨是雪花算法的经典问题,直接抛异常比生成重复 ID 安全,上层捕获后重试即可。跨分片查询方面,带分片键的查询走单分片,不带分片键的统计类查询走异步并行查询加内存合并,合并时注意分页和排序要在内存里重新做,不能简单拼接各分片的结果。

5. 避坑与排查:分库分表落地时最容易翻车的五个点

分库分表的坑大多不在代码本身,而在边界条件和运维细节上。下面五条是我在项目里真实遇到或见别人踩过的,按「现象 → 原因 → 解决」写,照着排查能省不少时间。

5.1 事务跨库后部分成功

现象是下单接口偶发数据不一致,订单写进去了但库存没扣。原因是本地事务只能保证单库,跨库操作不在同一个事务里。解决办法是把跨库写操作改成先写本地消息表再异步补偿,或者通过业务设计让相关数据落在同一分片,从根上避免跨库事务。

5.2 分片键为空导致路由失败

现象是某些查询报「无法确定分片」或直接查不到数据。原因是参数里分片键为 null,路由层没有兜底逻辑。解决方式是在拦截器里对空分片键做显式校验并抛出明确异常,同时在接口层做参数校验,把问题挡在入口。

5.3 连接池耗尽引发雪崩

现象是高峰期大量请求超时,日志里全是获取连接失败。原因是分片数多、每个分片独立连接池,总连接数超过数据库上限。解决办法是控制单池大小、设置合理的超时时间,并对非核心查询做限流,必要时把广播查询改成异步。

5.4 扩容后历史数据路由错位

现象是扩容后部分老数据查不到。原因是分片算法改了但历史数据没迁移,新旧规则不一致。解决方式是扩容前先做数据迁移和双写校验,迁移完成后用对账脚本逐条比对,确认无误再切流量。

5.5 慢查询拖垮整个分片

现象是某张分片表响应变慢,连带同库其他表也变慢。原因是分片键分布不均,热点数据集中在一张表上。解决办法是检查分片键的分布情况,必要时加盐或换分片键,同时给慢查询加监控,超过阈值自动告警。

6. 验收与答辩:怎么证明这套方案真的能跑

验收这套方案,我一般分三步走:功能验证、压力验证、故障验证。功能验证看分片路由是否正确,准备一批带不同分片键的数据,逐条写入后直接连数据库核对落表位置,再按分片键查询确认能命中;同时验证不带分片键的查询是否按预期拒绝或广播。压力验证用压测工具模拟并发写入,观察各分片的写入量是否均匀、连接池是否稳定、响应时间是否在可接受范围。故障验证手动停掉一个从库或制造一次慢查询,看路由层是否能快速失败、是否影响其他分片。

答辩时最容易被问的是「为什么这么拆」和「拆完怎么保证一致性」。前者用第 2 章的分片键取舍表回答,讲清楚数据增长方式和查询模式;后者用第 5 章的事务方案回答,说明跨库场景的补偿机制和最终一致性边界。建议准备一张分片分布表,现场展示数据实际落在哪些库表,比口头描述有说服力。

验收项验证方法通过标准
分片路由正确性写入后直连数据库核对数据落在预期分片表
分片均匀性统计各分片数据量最大最小分片偏差在可接受范围
连接池稳定性并发压测观察连接数无获取连接超时
故障隔离停从库或注入慢查询故障分片不影响其他分片

从那以后我每次做分库分表方案,都会在动手前先把分片键和查询模式列成一张表,确认高频查询都能带上分片键才开工,这个习惯帮我省掉了大量返工。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进