
简介面向需要掌握 Spring Boot 多数据源与分库分表实践的 Java 开发者这份工程示例围绕同库水平分表、多数据源动态切换与连接池管理展开技术栈采用 MyBatis-Plus 的 dynamic-datasource、Sharding-JDBC 和 Druid适合已具备 Spring Boot 基础、希望理解分片规则、数据源路由及事务边界的读者对照学习。压缩包共 162 个文件、约 164KB主体为 115 个 XML 映射与配置、14 个 Java 源码及对应编译产物、6 个 YAML 与 3 个 properties 环境配置另含 JUnit 测试、日志、Maven 包装脚本和依赖 jar目录组织接近可直接运行的工程。已有 625 人学习。读者可从中获得多数据源接入、分库分表规则配置、分片键选择、实体与 Mapper 分层、单元测试验证及 SQL 日志排查等完整线索便于快速复现实验。对希望把数据源切换、分片算法配置和连接池参数迁移到自身业务的中级开发者也具备直接参考价值。1. 多数据源与分库分表为什么“加个注解就切换”往往一开始就是错的很多人对多数据源的第一印象是“加一个DS注解就能切换”。但在 Spring Boot 项目里一旦真的同时接入多个数据源并执行分库分表最先出问题的往往不是 SQL 写错而是事务、连接池和路由时机。表象是“有时候查到了别的库的数据”底层却是数据源在事务开启那一刻就被绑定到线程上了。Spring Boot 里“多数据源 分库分表”这件事值得搞懂的不是某一个注解怎么用而是路由决策发生在请求生命周期的哪一步、绑定在哪个上下文里、什么时候必须释放。一旦把这条链路讲清楚用注解切换数据源、解析分片键、处理分布式事务都是这条链路上的具体实现。2. 路由的时机分片键是在哪一步被消费掉的2.1 从 DataSource 到路由数据源的替换点Spring 的JdbcTemplate、MyBatis 的SqlSessionFactory最终都会向容器要一个DataSource然后调getConnection()。分库分表框架要做的第一件事就是把这个“真实数据源”替换成一个路由数据源——它本身也实现DataSource接口但getConnection()返回的不是物理连接而是一层包装。// 路由数据源的骨架不是真的连库而是决定连哪个库 public class RoutingDataSource extends AbstractDataSource { // 真正持有 N 个物理数据源key 形如 ds0 / ds1 / ds0.t_order_0 private MapString, DataSource targetDataSources; Override public Connection getConnection() throws SQLException { // 关键路由依据来自 ThreadLocal而不是方法参数 String dsKey DataSourceContextHolder.get(); if (dsKey null) { // 没有显式指定时退化到默认库这一步是很多串库事故的源头 dsKey ds0; } DataSource target targetDataSources.get(dsKey); if (target null) { throw new IllegalStateException(no datasource bound: dsKey); } return target.getConnection(); } }这段代码说明了一个本质路由不是“每次查询时按 SQL 重新算”而是“按当前线程上下文里已经算好的 key 去取”。DataSourceContextHolder通常是ThreadLocal实现这意味着路由结果的有效范围是当前线程 当前绑定周期。参数说明上targetDataSources的 key 设计成两级ds0表示库、ds0.t_order_0表示库加表是常见做法因为库路由和表路由的时机并不相同库通常在进入方法前就能定表往往要解析 SQL 里的分片键才能定。2.2 库路由与表路由为什么必须分开算很多人把“分库”和“分表”当成一件事实际上它们在实现上是两个阶段。库路由的输入通常是业务维度的标识用户 ID、租户 ID在方法入口由注解或拦截器给出表路由的输入是 SQL 语句里的分片键值必须等 SQL 生成之后、执行之前才能拿到。// 表路由的典型插入点SQL 已经拼好但还没交给 JDBC public class ShardingInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; // args[1] 是参数对象分片键通常藏在里面 Object param args[1]; // 1. 从参数中抽取分片键值可能是 userId、orderId ShardingValue sv ShardingKeyExtractor.extract(ms.getId(), param); // 2. 用分片算法算出真实表名t_order - t_order_3 String actualTable ShardingAlgorithm.doShard(sv); DataSourceContextHolder.setTable(actualTable); try { // 3. 改写的 SQL 在后续 StatementHandler 中生效 return invocation.proceed(); } finally { // 4. 必须清理否则线程复用会串表 DataSourceContextHolder.clearTable(); } } }这段代码里有几个参数值得注意。ms.getId()是 MyBatis 的语句全限定名常被用作“哪条 SQL 需要分片”的判断依据没有配置分片规则的语句要原样放行否则全局查询会报错。ShardingKeyExtractor是容易出问题的地方分片键可能藏在Map、Param注解参数、甚至是嵌套对象里抽取失败就直接抛异常绝不能“抽不到就当默认值”因为静默降级会让数据写进错误的表。finally里的清理同样关键。线程池复用的线程如果不清理ThreadLocal下一次请求可能拿到上一次的表名这类 bug 的现象是“偶发查到别人的数据”排查时你会怀疑索引、怀疑缓存最后发现是上下文没清干净算是典型的一个坑。2.3 事务开启的一瞬间路由就被固定了这是整个机制里最反直觉的部分。Spring 的DataSourceTransactionManager在doBegin()时会调用dataSource.getConnection()并把这个连接放进TransactionSynchronizationManager的ThreadLocal里。也就是说事务一旦开启后续所有getConnection()都会拿到同一个连接路由决策不会再发生第二次。// 伪代码Spring 事务同步管理器的关键行为 protected void doBegin(Object transaction, TransactionDefinition definition) { DataSourceTransactionObject txObject (DataSourceTransactionObject) transaction; if (txObject.getConnectionHolder() null) { // 这里调用的是路由数据源路由结果此刻被固化 Connection con obtainDataSource().getConnection(); txObject.setConnectionHolder(new ConnectionHolder(con)); } // 后续无论怎么切 DS只要还在同一事务里拿到的都是这根连接 TransactionSynchronizationManager.bindResource(obtainDataSource(), txObject.getConnectionHolder()); }由此可以推出几条硬性约束在事务内切换数据源是无效的除非你显式地把事务挂起再重新开启跨库写操作不可能靠一个本地事务兜住必须引入两阶段提交或最终一致性方案分表路由如果发生在事务开启之后那事务绑定的连接对应的库必须是分表所在的那个库。常见的实现思路是“先确定库再开事务最后做表路由”顺序乱了就会翻车。提示判断一个方法里数据源是否真的切换成功不要看日志里打印的注解要看连接的实际 URL。在getConnection()出口打印connection.getMetaData().getURL()是最省事的一个办法。3. 分片算法与分布式主键数据分布得均匀主键又不能撞3.1 取模、范围、哈希三种分片算法的取舍分片算法决定了数据怎么落到具体库表它的选择直接决定了后续扩容的难度。取模最简单均匀性也最好但扩容时需要数据迁移范围分片对范围查询友好但容易出现热点比如按时间分片时写入永远集中在最新一张表上一致性哈希在扩容时迁移量小但实现复杂度高且节点少时倾斜明显。算法均匀性范围查询扩容成本适用场景取模高差需扫全部分片高几乎全量迁移分片数稳定、按主键等值查询范围低易热点好低只增新分片时间序列、归档类数据一致性哈希中差低节点频繁增减的弹性场景有一类做法是“先按业务维度分库再按主键取模分表”它其实是在折中库的数量少、变更成本高用业务维度保证同租户数据在同一个库便于做库内事务表的数量多、都在一个库里用取模保证均匀。这个组合在真实项目里出现的频率最高因为它把“跨库”这个最难的问题压缩到了最低。3.2 为什么自增主键在分表之后必然失效单表时AUTO_INCREMENT由数据库保证全局唯一分表之后每张表各管各的t_order_0和t_order_1都会从 1 开始发号主键冲突是必然的。这是分库分表里少数“只要做了就一定会遇到”的问题没有例外。// 雪花算法的核心时间戳 机器位 序列号拼成一个 long public class SnowflakeIdGenerator { private static final long EPOCH 1700000000000L; // 自定义起始时间决定可用年限 private static final long WORKER_BITS 10L; // 机器位最多 1024 个节点 private static final long SEQ_BITS 12L; // 序列位单节点每毫秒 4096 个 private long lastTimestamp -1L; private long sequence 0L; private final long workerId; public synchronized long nextId() { long now System.currentTimeMillis(); if (now lastTimestamp) { // 时钟回拨直接抛错比悄悄等待更安全否则可能发出重复 ID throw new IllegalStateException(clock moved backwards); } if (now lastTimestamp) { sequence (sequence 1) ((1 SEQ_BITS) - 1); if (sequence 0) { // 同一毫秒序列号用尽自旋到下一毫秒 while ((now System.currentTimeMillis()) lastTimestamp) { } } } else { sequence 0L; } lastTimestamp now; return ((now - EPOCH) (WORKER_BITS SEQ_BITS)) | (workerId SEQ_BITS) | sequence; } }这段代码的参数背后都是成本计算。EPOCH决定 ID 可用到哪一年41 位时间戳大约能用 69 年WORKER_BITS为 10 意味着最多 1024 个节点同时发号容器化环境下节点数一旦超过这个值就必须扩位否则会撞号SEQ_BITS为 12 意味着单节点每毫秒上限 4096 个超过就要等到下一毫秒。时钟回拨那段是最有争议的地方等待方案在容器里表现不稳定直接抛错则会把问题暴露给调用方常见做法是配合 NTP 校时加上“回拨幅度小就等待、幅度大就告警”。注意雪花 ID 是有序的但如果把它用作分片键跨毫秒的连续 ID 会集中在最新的分片上导致写入热点。分片键最好选业务属性而不是发号器产物。3.3 分片键选错后面全是补救分片键的选择标准只有两条查询必须能带上它值必须足够分散。选了用户 ID 作为分片键按订单号查就要扫全部分片选了订单 ID按用户查历史订单就要扫全部分片。很多系统最终会引入“基因法”生成订单 ID 时把用户 ID 的低位编码进去这样订单 ID 自身就隐含了分片信息两种查询都能命中。这个做法不是没有代价的。ID 里携带业务信息意味着 ID 的生成逻辑和分片规则耦合在一起日后调整分片数会非常麻烦。我一般会把它理解为一种“用 ID 长度换查询便利”的权衡只有在查询模式确实双重的时候才值得。4. 避坑与排查那些跑起来才发现的问题4.1 事务内切库静默失效现象方法上标了切换注解日志里也打印了目标库但写进去的数据仍然出现在原库里。原因事务已经开启连接被绑定注解只改了ThreadLocal里的 key却没有影响已经拿到的连接。解决把切换动作提到事务方法之外由外层方法先切库再调用带Transactional的内层方法或者用编程式事务配合事务挂起。排查手段是打印连接的真实 URL而不是看注解值。4.2 线程池复用导致上下文串号现象压测时偶发“查到不属于自己的数据”且不可复现。原因ThreadLocal里的分片信息没在请求结束时清理下一个请求复用了同一个线程读到了残留值。解决在拦截器的finally里强制清理或者在过滤器最外层统一remove()。这类问题的迷惑性很强因为单线程调试永远不会出现只有并发时才暴露。4.3 跨库分页的 total 是错的现象分页查询第一页正常翻到后面数据重复或缺失总数也对不上。原因LIMIT offset, size被下推到每个分片执行各分片返回的是自己局部有序的结果归并后整体顺序并不正确。解决改为按分片键路由到单库查询或者使用“二次查询法”先取各分片的前 offsetsize 条算出全局最小边界再回各分片补查。这条限制从机制上无法回避只能绕。4.4 全局表被误当成普通表分片现象字典表、配置表在各库里各有一份更新后数据不一致。原因这类表没有分片键被默认规则随机路由到了某个库。解决把它们注册为广播表写操作同步到所有分片读操作任取一个。代价是写放大所以只适合低频变更的小表不要拿它处理业务主表。4.5 连接池数量按单库估算现象分片数从 4 扩到 16 后数据库连接数暴涨触发服务端上限。原因每个物理数据源都持有独立的连接池总数等于“分片数 × 每池连接数”。解决按“总连接预算 ÷ 分片数”反算每池上限同时区分读写池分片越多单池就要越小否则扩容时被连接数卡住。5. 设计取舍一致性与复杂度你只能选一个压住多数据源加分库分表这套组合本质上是在用应用层的复杂度换单库的容量上限。这个交换划不划算取决于业务能不能接受“跨分片查询变慢、跨库事务变最终一致”这两条代价。如果不能接受那就该先做读写分离、加缓存、做冷热分离把分库分表往后排——这些手段的复杂度低一个量级能撑住的量级却往往被低估。真要上我倾向的验证顺序是先把路由层单独抽出来做单元测试覆盖“分片键缺失、类型不匹配、分片数变更”三类边界再用影子库跑一遍全链路重点看事务边界内的路由行为是否和预期一致最后才是压测因为性能问题通常是最后暴露的而正确性问题必须提前锁死。判断路由是否真的生效最可靠的办法始终是看连接元数据里的 URL而不是信任框架日志——日志打的是“准备切到哪”不是“实际连到哪”这中间的差值就是绝大多数问题的来源。有一个取舍我踩过坑早期为了省事把库路由和表路由合并成一个注解结果遇到“同库不同表要跨表查询”的场景时注解语义就打架了。后来拆成两层配置库路由固定在入口表路由交给 SQL 解析边界才清晰。复杂的东西拆开看都不难难的是拆的时机——等你发现语义冲突再拆改动面已经很大了。从那以后我每次接入新的分片规则都强制先跑一遍“分片键为 null”的用例看它是抛异常还是静默走默认库。这个习惯帮我拦下过好几次数据写错库的事故。希望帮到你。本文还有配套的精品资源点击获取