分库分表与多数据源切换:路由实现与避坑指南
简介面向Java后端开发者的分库分表与多数据源切换实践资源以SSMDemo示例项目为载体演示Sharding-JDBC如何透明化完成数据分片、路由与多数据源装配帮助解决单库性能瓶颈与跨库操作一致性难题。资源包共73个文件包含Java业务源码、编译后的class文件、MyBatis映射XML、Spring属性配置、SQL初始化脚本以及Eclipse工程元数据等压缩包整体仅66KB轻量便携可直接导入IDE查看运行。目前已有2068人学习下载。示例项目完整展示了分片规则配置、启动类初始化、数据源对象创建等关键步骤并附带数据库建表脚本便于还原演示环境通过运行调试可直观理解精确分片、范围分片等策略的配置方式以及SQL如何被自动解析并路由至正确的库表。同时还能学习到如何配置数据源信息、定义分片规则、处理跨库跨表关联查询与分布式事务为实际搭建高可用、可扩展的数据库架构提供参考。无论是初学者入门分库分表还是开发者将Sharding-JDBC集成到现有项目这份资源都能提供清晰的参考路径与可复用模板。1. 分库分表和多数据源切换为什么这两个词总是绑在一起出现某电商平台的核心订单表到了千万级之后一次范围查询直接拖垮主库A同学被拉进去救火。他当时的第一反应不是加索引而是先看这张表的写入 QPS 和存储容量——数据量到这个规模单库单表的写入瓶颈已经和索引无关了。真正要解决的问题是让数据分散到多个物理库同时让应用层清楚每一次请求应该走哪个库。这就是“分库分表 多数据源的切换”要解决的完整链路分库分表负责把数据拆开多数据源切换负责在应用运行时把 SQL 正确路由到目标库。很多团队只做了前者上线后发现查询全部乱了套就是因为没把这两件事当成一个整体来设计。本文会从方案选型讲起一路落到路由实现、参数配置和上线前的验证方法让你既能看懂别人怎么搭的也能自己动手复现一套。2. 先理清拆分维度分库分表不是无脑拆而是容量和访问模式倒逼出来的决策2.1 垂直拆分和水平拆分的边界什么情况该分库什么情况该分表很多读者一上来就问“分库分表怎么做”我的习惯是先反问一句你现在是库撑不住了还是单表撑不住了这两者对症的方案完全不同。常见做法里垂直拆分是把一张大表按业务域拆成多张表比如把订单基础信息和订单扩展信息拆开解决的是字段过多、行宽过大带来的 IO 放大水平拆分则是把同一张表的数据按某个字段散到多个库或表中解决的是单表数据量过大、索引层级过深的问题。从容量上算一笔账会更清楚。假设单表能稳定支撑的数据量在 500 万到 1000 万行左右超过这个量级后 B 树层级加深写入和查询的随机 IO 都会明显劣化。如果一个订单表一年产生 3000 万行预留两年容量就是 6000 万行那么水平拆成 8 张表或 16 张表就足够如果单库的磁盘空间或 IOPS 也到了瓶颈就要把表分布到多个物理库上。注意这里有个很容易混淆的点分表和分库可以独立做也能组合做。只分表不分库数据还是落在一个实例上解决不了实例级别的资源竞争只分库不分表单表数据量可能依然过大。所以选型前先测一下当前库的容量水位和慢 SQL 分布再决定拆分粒度。2.2 从单数据源到多数据源应用访问路径发生了什么变化分库分表之前应用的访问路径很简单数据源 - Connection - SQL。分库分表之后这个路径变成了“逻辑数据源 - 路由规则 - 物理数据源”其中最关键的一环就是多数据源的切换。这里说的“切换”不是主从切换那种故障转移而是每次请求在多个物理库之间做路由选择。我一般会这样理解分库分表之后数据源的数量不再是“一个”而是一个“数据源集合”。应用代码里不应该出现十几个DataSource的硬编码而是通过一个路由入口来分发。常见的做法是引入一个路由层内部维护“分片键值 - 目标数据源”的映射关系。这个路由层在框架里通常叫数据源路由DS Route在读写分离场景里叫动态数据源。标题里“多数据源的切换”本质就是这个路由层的设计与实现。如果把这一步想清楚了后面的代码就很自然分库分表组件负责解析 SQL 里的分片键路由层负责根据分片键的值选定数据源JDBC 层负责把 SQL 发送到目标库执行。三个环节各司其职互相配合。下面用一个最小代码结构来说明这个路由层长什么样。// DynamicDataSourceRouter.java Component public class DynamicDataSourceRouter extends AbstractRoutingDataSource { // 分片键 - 数据源标识 的映射 Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceKey(); } }这段代码里的DataSourceContextHolder是一个基于ThreadLocal的上下文容器每次请求进入时由分库分表组件或拦截器写入当前分片键对应的数据源标识。AbstractRoutingDataSource是 Spring 提供的路由数据源抽象类运行时通过determineCurrentLookupKey()的返回值从目标数据源 Map 中取出真正的连接。关于参数和选型的几个要点如果分片键是orderId那么DataSourceContextHolder里存的就是类似ds_0、ds_1这样的逻辑名对应的物理库配置写在application.yml或注册中心里如果你的场景是读写分离那么这个 key 就是master或slave_0。这里有个常见的坑ThreadLocal用完必须清理否则线程池复用线程时会把上一个请求的数据源标识带到下一个请求里。这个我们会在后面的避坑章节详细展开。3. 从逻辑表到物理表用一套标准配置把路由规则落到工程里3.1 分片策略先于代码确认你要按什么键、拆多少片动手写代码之前先确定路由规则。我见过不少项目上来就写代码结果分片键在需求评审时没过回炉重改的成本极高。分片策略的选择应该基于业务访问模式如果 90% 的查询都带用户 ID按user_id分片最合理如果是订单场景order_id和user_id往往同时出现在查询条件里这时候需要考虑冗余或绑定表的设计。常见的分片方式有取模、哈希取模、按时间范围分片。取模实现简单适合数据分布相对均匀的场景时间范围分片适合流水型数据比如订单、日志方便归档和冷热分离。用取模做例子如果分 16 个物理表那么shard order_id % 16。这个计算看起来很直接但要注意取模的基数一旦定下来后续扩容时所有数据都要重新分布这也是为什么很多人说“取模一时爽扩容火葬场”。业界常用的方案中标准架构里对逻辑表和物理表的映射、分片算法的声明、数据源组的管理都做成了可配置项。拿某个开源分库分表中间件举例它的核心配置长这样# sharding-config.yaml dataSources: ds_0: url: jdbc:mysql://host0:3306/order_db_0 username: root password: **** ds_1: url: jdbc:mysql://host1:3306/order_db_1 rules: sharding: tables: t_order: actualDataNodes: ds_${0..1}.t_order_${0..15} tableStrategy: standard: shardingColumn: order_id shardingAlgorithmName: order_id_hash_mod keyGenerateStrategy: column: id type: SNOWFLAKE shardingAlgorithms: order_id_hash_mod: type: HASH_MOD props: sharding-count: 16这段配置的逻辑是把t_order这张逻辑表映射到ds_0和ds_1两个数据源下的 16 张物理表分片键是order_id分片算法是HASH_MOD分片数量 16。应用代码里写的 SQL 仍然是select * from t_order where order_id xxx中间件会把逻辑表解析成实际物理表的 SQL再路由到对应的数据源执行。表格可以更直观表达这个映射关系路由项配置值说明逻辑表t_orderSQL 里写的表名物理表分布ds_0.t_order_0 ~ ds_0.t_order_15, ds_1.t_order_0 ~ ds_1.t_order_15实际存储位置分片键order_id决定数据落在哪张表分片算法HASH_MOD sharding-count16取哈希后取模主键生成SNOWFLAKE全局唯一避免多节点自增冲突这里有个参数值得重点说明sharding-count是 16而数据源是 2 个意味着每个库下 8 张表。这个比例可以根据库的容量调整常见做法是先用公式估算总表数再按数据源数量均分。如果你预期的数据量是 6000 万行单表 500 万行那 16 张表足够如果增长快初期就把分片数设成 32 或 64宁可浪费一点空表也不要中期扩容。3.2 读写分离与分库分表并存多数据源切换的另一种常见组合分库分表之外“多数据源的切换”还有一个高频场景是读写分离。很多团队是在分库分表做完之后发现主库的读压力还是大于是给每个分片库挂只读从库这时候应用不仅要按分片键路由还要判断这条 SQL 是读还是写决定走主库还是从库。这个过程要解决两个问题第一事务内的读操作不能走从库否则会出现主从延迟导致的事务内读不到已写入数据第二强制路由和提示性路由要分开比如实时性要求极高的查询需要强制走主库。常见的实现方式是通过 Hint 或注解来标记当前 SQL 的路由策略。下面是一个典型的动态数据源注解实现Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { String value() default master; }在服务方法上标注DS(slave_0)通过 AOP 切面在方法执行前把数据源标识写入DataSourceContextHolder方法结束后清除。这套机制和分片路由可以共存先按分片键确定分片库再按读写标识决定主从库。需要注意一旦你用了事务Transactional数据源连接会在事务期间被绑定中途切换数据源不会生效这属于 Spring 事务管理的边界很多人在这个坑里栽过。4. 多数据源切换的五个经典翻车现场现象、原因与解决方案4.1 线程池上下文串线查出来的是别的用户的数据现象应用上线后偶尔出现个别用户查到了别人的订单数据日志里没有报错数据源路由看起来也是对的但结果就是错了。原因ThreadLocal里的数据源标识在线程复用后没有被清理。Tomcat 默认线程池会复用线程上一次请求在finally里没有调用DataSourceContextHolder.clear()下一次请求拿到同一线程时读到的是上一个请求遗留的数据源标识导致路由错误。解决在AbstractRoutingDataSource的入口和出口都做清理或者在拦截器的afterCompletion里强制清除。代码习惯上所有写入ThreadLocal的地方必须配对try-finally清理。这个问题的隐蔽性在于它不是必现只有并发高、线程复用频繁时才暴露排查起来非常消耗耐心。4.2 跨库 JOIN 爆出“表不存在”现象分库分表上线后某条关联查询直接报表不存在但单表查询都正常。原因分库分表后物理表分布在不同的数据源实例上数据库层面的 JOIN 无法跨实例执行。除非两张表使用一致的分片键并配置成绑定表否则原来的 JOIN 语句必然失败。解决常见的做法是冗余字段或冗余表把关联查询改成两次单表查询在应用内存里组装或者把需要 JOIN 的表按同一分片键分片并配置成绑定表保证 join 发生在同一个物理库内。我的经验是能避免 JOIN 就避免这不仅是为分库分表考虑也是为后面的扩展性留余地。4.3 事务跨越多个数据源数据一致性没人兜底现象一个业务操作同时更新了订单表和账户流水表业务代码报了异常部分数据回滚了部分没有账对不上。原因默认的本地事务只能保证单数据源内的事务一致性一旦 SQL 路由到了不同数据源Transactional就失效了每个数据源各自提交整体没有原子性。解决事务边界要先收窄尽量把一次操作限制在同一个数据源内。如果业务上无法避免跨库事务需要引入分布式事务方案但分布式事务会带来性能损耗和复杂度上升不是首选。我的建议是要么通过调整分片键设计业务聚合边界要么明确接受最终一致性引入事务消息或对账任务。这里没有银弹。4.4 分片键没出现在 SQL 里路由直接跑全库现象某个查询接口在分库分表后响应时间从 50ms 涨到 5s查了一下SQL 被广播到了所有分片库执行。原因SQL 的 where 条件里没有分片键中间件无法确定目标分片只能全库扫描聚合结果。这是分片路由的兜底行为能查对但性能不可接受。解决业务查询必须带上分片键这属于使用规范问题。如果确实存在大量没有分片键的查询要评估这些查询是否适合放在分库分表后的结构里常见做法是额外维护一份汇总表或使用搜索引擎而不是硬扛全库扫描。4.5 分片键选错扩容变成了数据重分布大工程现象项目发展到后期某个分片的容量明显比其他分片涨得快部分库已经告急但取模算法定了 16 分片无法在线调整。原因分片键选的是创建时间业务上大部分查询和写入都集中在最近一个月导致新数据全部落在同一个分片上热点数据没能被分散。解决分片键要选业务访问均匀的字段比如卖家 ID、客户 ID 这种本身就具备分散性的业务主键而不是时间戳。如果已经踩了这个坑常规解法是重新设计分片键、双写迁移、按新键重建分片。注意这个过程的复杂度极高要做好灰度切换和回滚预案。这也是我在规划阶段反复强调“分片键不可变”的原因运行期换分片键的代价远超预期。5. 从验证到交付一套可复现的联调与验证方法5.1 本地模拟多数据源环境最小成本验证路由逻辑很多团队在开发环境里只有单库分库分表逻辑就是在本地单库上跑通的到了测试环境接入真实多库才暴露问题。我的习惯是本地直接模拟多数据源成本很低能提前暴露大部分路由问题。做法是本地启动多个 MySQL 实例也可以用 Docker 起两个容器每个容器里建多个库模拟分库分表后的物理拓扑。# 用 docker 本地模拟两库四表 docker run -d --name mysql_ds0 -p 3306:3306 mysql:8 docker run -d --name mysql_ds1 -p 3307:3306 mysql:8 # 分别建库 mysql -h127.0.0.1 -P3306 -e create database order_db_0 mysql -h127.0.0.1 -P3307 -e create database order_db_1这段命令会起两个独立的 MySQL 实例分别映射宿主机的 3306 和 3307 端口。你可以分别在两个实例下建多张表然后把这套多数据源配置指向本地。这样做的最大价值在于路由是否正确、跨库操作是否报错改动一行配置就能验证不需要等测试环境排队。验证时优先使用真实的数据访问层压测而不是只测 DAL 层的单元用例。一套完整的路由验证至少覆盖以下三类用例带分片键的查询能命中的是单个物理表不带分片键的操作不报错但符合预期写入和读取能保持一致的分片分布。5.2 压测关注什么不只是 QPS还有连接池和路由开销上线前压测很多团队只盯着 QPS 和 RT忽略了一个关键项——连接池的大小。分库分表之后每个物理数据源都需要独立的连接池配置。如果连接池按原先单库的经验设置多数据源场景下每个连接池的连接数会偏低高并发时连接等待会成为新的瓶颈。spring: datasource: dynamic: datasource: ds_0: url: jdbc:mysql://host0:3306/order_db_0 hikari: maximum-pool-size: 20 ds_1: url: jdbc:mysql://host1:3306/order_db_1 hikari: maximum-pool-size: 20连接池参数要按数据源数量折算。假设单库方案时连接池上限是 50现在有 2 个分片库、4 个只读库每个数据源配 20总连接数是 6 * 20 120这样整体连接资源比单库方案还多但要确认数据库实例自身的最大连接数是否允许。压测时还要关注路由本身的耗时。AbstractRoutingDataSource的查找是内存操作开销微秒级但如果你的路由实现里包含了额外的网络调用或复杂的计算逻辑这个开销会被放大。路由逻辑应该保持无状态、无 IO这是它的底线。6. 分库分表多数据源切换的最后一公里上线前最值得投入的一件事到了可以上线的阶段我建议先做一次数据迁移预演这是整个链路里最容易被低估、也最值得投入的地方。分库分表上线不只是应用发一次版本它意味着存量数据要从单库搬到多库多表数据迁移方案直接决定线上切换是否顺利。常见的做法是双写 校验 切换三步走。应用先保持写单库同时把写操作复制一份到分库分表后的目标结构上每日跑对账任务比对两边的数据一致性。对账通过后把读流量灰度切到新链路观察一段时间无异常后再下线旧库。这里有个细节双写的顺序应该是先写新库再写旧库一旦写新库失败要立刻告警否则会出现新的数据没过去、对账还查不出来的情况。灰度切换的比例建议从 5% 开始。5% 流量的目的是验证路由逻辑和连接池配置确认没有大问题后再逐步推高到 10%、30%、100%。整个过程中要有可回滚的开关。关于回滚我踩过一次很深的坑当时灰度到 30% 时发现聚合查询超时紧急回滚到单库但因为双写的旧链路还在回滚本身没造成数据损失但排查发现灰度链路已经写入的一部分数据在旧库里不存在最后靠对账任务补回来了。所以回滚方案要在灰度前就把数据回流路径设计好而不是等到出问题时再想。还有一个上线前必做的小事把所有涉及分片键的慢 SQL 抓出来逐个确认它们的执行计划。分库分表后很多原来能走索引的查询会因为路由条件变化而改变执行计划尤其是范围查询和排序分页。不要等到线上出问题再去优化压测阶段就应该用真实数据量跑一遍这些 SQL。这套方案值不值得做取决于你的业务是否真的到了容量或性能瓶颈。如果单库能撑住分库分表带来的复杂度不值得主动引入但如果你已经预见到未来一年的数据增长会突破阈值尽早设计分片键和多数据源路由比亡羊补牢要省太多力气。某开发者经常说一句话分库分表不是技术炫技它是数据规模倒逼出来的工程决策尊重这个前提你的取舍就不会跑偏。希望这篇笔记能帮你少踩几个坑祝你的多数据源切换一次就成。本文还有配套的精品资源点击获取