☰
ShardingJDBC分库分表入门:分片键设计、配置实战与生产避坑指南
2026/10/15 14:31:40 网站建设 项目流程

这两年总有同事问我,说订单表已经两千多万行了,到底要不要拆表?我的回答通常是:先别急着拆,先想想你的数据访问规律到底是什么。很多人被性能问题逼到分库分表面前,却没搞清楚中间件到底是怎么工作的,结果配置瞎写,拆完还不如不拆。所以我想用一个系列,带大家把Sharding JDBC好好入个门,从原理到配置再到踩坑,一次性说透。

这篇是入门第一篇,适合有Spring Boot基础、但没实际碰过分库分表的同学。我会讲清楚三件事:它为什么值得用、分片键应该怎么定、怎么搭出一个最小可跑的工程,最后再聊聊生产环境里那些绕不开的坑。废话不多说,直接从我的选型经历开始。

1. 为什么我最后选了Sharding JDBC:一次真实方案选型的心路历程

1.1 我是从一张“查不动”的订单表开始重视分库分表的

先说个真实场景。某社区团购项目的订单表,数据量从百万涨到一千八百万只用了不到一年。一开始单表查询毫无压力,到后面按用户查订单列表,带状态筛选、时间排序,最慢能跑到三秒多。数据库CPU稳定在80%左右,慢查询日志里全是这张表。业务方天天催,DBA给的第一个方案是“加索引”,加了几个之后收效甚微——单表数据量太大,索引再准,回表和扫描的开销也摆在那里。

这时候才意识到,单表优化已经到了天花板,必须做数据拆分。拆分有两种思路:先把数据拆到多张表里,叫分表;再把表拆到多个数据库实例上,叫分库。大多数团队一开始只做分表就够了,但为了以后扩展,架构上最好连分库的余地一起留出来。

1.2 客户端模式和代理模式,我到底在纠结什么

我当时的选型范围不算大,核心就两个方向:一种是引入独立的数据库中间件服务,应用把SQL发给中间件,由它转发到底层数据库;另一种是直接用嵌入在应用进程里的客户端组件,让应用自己的数据源代理层来完成分片逻辑。Sharding JDBC属于后者,它和Sharding Proxy是两个不同的交付形态。

我花了一些时间对比这两条路:

对比点客户端方案(Sharding JDBC)代理方案(Sharding Proxy)
部署形态内嵌在应用内,以数据源形式存在独立进程,应用无感知
应用侵入性需要改数据源配置和部分SQL写法应用基本不用改
运维成本低,应用发版即可更新规则高,需要额外运维代理集群
性能损耗极小,本地解析和路由有一定网络开销
跨语言支持Java优先支持多语言客户端
规则变更需应用重启或动态刷新可动态调整,不重启应用

我们的团队规模不大,后端全是Java,技术栈统一,没有多语言接入的强需求。代理模式相对更重,需要额外维护一套集群,这个成本对当时的团队来说偏大。加上应用本身并发量没有夸张到必须让数据库连接池隔离在独立进程后面的程度,所以客户端模式明显更适合现阶段。

1.3 Sharding JDBC的三个核心工作:路由、改写、合并

选型定了之后,得知道它内部到底干了什么。很多人以为Sharding JDBC只是“帮你把一条SQL拆成多条”,其实没有这么简单,它做了三件事。

第一是路由。收到一条逻辑SQL,比如select * from order_t where user_id = 123,它会根据分片规则计算出这单到底落在哪张表、哪个库上。这里的“逻辑表”可以理解为一个虚拟的物理表集合,底层对应真实表。

第二是SQL改写。路由到具体表之后,原始的SQL需要把逻辑表名替换成真实表名。比如把from order_t改写为from order_t_0,同时把分片键的条件也做重写,确保查询落在正确的物理分片。

第三是结果合并。如果一条查询命中了多个分片,中间件会把多条查询的结果进行内存汇总、排序甚至分页,然后把合并后的结果返回给应用。这一层如果没有处理好,分页和排序特别容易出问题,后面我会单独讲。

用一个像点样的比喻:路由就像外卖平台的派单系统,先按区域把订单分给对应骑手,SQL改写就是把商家的地址栏换成具体的门店,结果合并就像把所有骑手的配送结果汇总到你手里。理解完这三个步骤,你就知道它本质上是一个“聪明的SQL分发器”。接下来最关键的问题,就是怎么设计分片规则。

2. 所有配置之前,最要命的是分片键怎么定

2.1 分片键选错,后面越想救越难救

我见过不少项目,配置一时爽,上线两行泪。问题基本都出在分片键的选择上。分片键是决定数据行要被分到哪张表的那个字段,它的选择直接决定你未来所有查询的写法和性能。

有一个很残酷的事实:分片键一旦定了,业务查询就必须围绕它来设计。如果你的系统高频查询里根本没有这个字段,那中间件就只能全表路由,也就是说一条查询会被广播到所有分片上去执行,最后再合并。这种查询在分片数量小的时候还能忍,分片一多直接就是灾难。

选分片键的核心原则是:它对大多数核心查询是可过滤条件,最好还是等值条件。不能是那种只在少数管理端报表里才会出现的字段,更不能选高基数和低基数都很极端的字段。

2.2 按用户维度分,和按订单维度分,是两种完全不同的人生

以订单系统为例,最常见的分片键有两个:user_id和order_id。

如果你选user_id为分片键,那么同一个用户的所有订单都会落在一张固定表里,按用户查订单列表天然就是单分片查询,中间件一步就能定位到目标物理表。代价是,如果你想按订单号精确查询某一条订单,比如后台根据order_id=20240318001去查,逻辑上就必须先知道这个订单属于哪个用户,否则就只能广播查询。

如果你选order_id为分片键,那按订单号查会非常快,但同一个用户的订单会被打散到多张表里,按用户查订单列表就必须跨分片聚合,还得在内存里做排序和分页,性能差很多。

我在那个社区团购项目里选了user_id,原因很简单:系统是C端产品,用户端流量占比超过九成,所有高频查询都是按当前登录用户来查的。后台的按订单号查询虽然也有,但频率低、并发小,而且可以通过维护用户-订单映射关系来规避广播查询。这是一个典型的“高频优先”决策。

2.3 取模到底是怎么算的,一个例子让你彻底明白

分片策略有很多种,但入门最常见、也最好理解的就是取模。我先把取模讲明白,后面你在配置里才不至于瞎写。

假设一个库里有4张订单表,分片键是user_id,分片算法为user_id % 4,那么路由结果就是:

  • user_id = 101,因为 101 % 4 = 1,所以数据落到order_t_1
  • user_id = 102,因为 102 % 4 = 2,落到order_t_2
  • user_id = 103,因为 103 % 4 = 3,落到order_t_3
  • user_id = 104,因为 104 % 4 = 0,落到order_t_0

分片键的值和分片序号之间的关系,通过取模运算固定下来。这里的“4”在Sharding JDBC里叫做分片数量,配置好之后它会自动帮你完成这一层换算。你只需要在配置里告诉它:逻辑表是order_t,物理表是order_t_0到order_t_3,分片键是user_id,分片策略是取模。剩下的路由它自己搞定。

但这里有一个问题:取模对连续递增的ID非常友好,分布均匀。可如果你的用户ID是那种末尾带规律的随机数,或者某些大客户批量注册用户,就很容易让某几张表的数据量暴涨。

2.4 数据倾斜问题:为什么某几张表会被打爆

我记得有一次复盘线上数据,发现order_t_0的数量比其他表多出一倍还多,一查原因,是几个大客户的小号刷单导致的。不少人在配置阶段忽略了分片键的“分布均匀性”问题,结果系统上线后某部分分片内的数据量异常大,查询性能形成了“木桶效应”。

解决思路有三个层级。最简单的,是在取模之前先对分片键做一次哈希散列,让分布更随机。但哈希散列本身会带来新的问题:当分片数量需要从4扩到8时,原来按hash(user_id) % 4计算出来的数据,在% 8的规则下大概率会跑到另外的分片,这时就需要把历史数据重新分布。

更稳的做法是引入“虚拟桶”的概念:创建比实际分片数多很多的虚拟节点,通过虚拟节点再映射到真实分片,这样扩分片时只需要调整映射关系,不需要全量搬迁。不过虚拟桶实现复杂度高,大多数团队前期都用不上。

我的建议是:初期数据量不大时,直接取模加上合理的主键设计,配合业务上对“大客户”单独分桶的策略,已经够用了。不要一上来就追求终极架构,先把第一步跑稳。

3. 搭一个Spring Boot接入Sharding JDBC的最小可跑工程

3.1 依赖和版本:这一步最容易踩雷

先说版本。Sharding JDBC的groupId是org.apache.shardingsphere,在较新的版本里已经改名叫shardingsphere-jdbc-core。这里要提醒一下:网上大量博客用的是老版本5.0或5.1的配置方式,而5.2和5.4在配置项上有一些变化。建议直接用当前5.4.x稳定版本,不要用5.0,更不要用3.x那种老古董。

在你新建的Spring Boot工程里,引入依赖大概是这样的:

<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core</artifactId> <version>5.4.1</version> </dependency>

如果你的Spring Boot版本是3.x,注意需要匹配对应的JDK和Spring版本。这个依赖会和现有的数据源、MyBatis等框架兼容得很好,因为它本质上就是把你原有的DataSource替换成了ShardingSphere代理过的DataSource。

还有一个特别容易踩的坑:不要把shardingsphere-jdbc-core和一整套Spring Boot starter混着引导致冲突。实际项目中引入上面这一个核心依赖就够了,不需要再去引入什么shardingsphere-spring-boot-starter的旧坐标。引入重复坐标,轻则配置不生效,重则启动直接报数据源循环依赖。

3.2 用“2库4表”做例子,给你一份完整的配置文件

我以“2个数据库实例、每个库2张表”这个更贴近真实分库分表的配置来演示。假设我们的业务库分别叫order_db_0和order_db_1,每个库里都建了order_t_0和order_t_1两张物理表,逻辑表就是order_t。

先看一下application.yml里最核心的配置:

spring: shardingsphere: mode: type: Memory props: sql-show: true datasource: names: order_db_0,order_db_1 order_db_0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/order_db_0?useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 order_db_1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/order_db_1?useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 rules: sharding: tables: order_t: actual-data-nodes: order_db_$->{0..1}.order_t_$->{0..1} table-strategy: standard: sharding-column: user_id sharding-algorithm-name: table-mod key-generate-strategy: column: id key-generator-name: snowflake sharding-algorithms: table-mod: type: MOD props: sharding-count: 2 key-generators: snowflake: type: SNOWFLAKE

这份配置里,actual-data-nodes非常重要,它声明了逻辑表映射的物理位置。order_db_$->{0..1}.order_t_$->{0..1}的含义是:库名在A到B之间取,表名在C到D之间取,组合出来的实际数据节点就是:

  • order_db_0.order_t_0
  • order_db_0.order_t_1
  • order_db_1.order_t_0
  • order_db_1.order_t_1

你可能注意到,上面配置里我只写了表级分片策略,但没有写库级分片策略。这意味着库和表的分片规则目前是“同一套取模逻辑”。如果你想实现更复杂的“库%2、表%4”这种两层分片,就需要再单独配置database-strategy。这个我建议你入门阶段先不要碰,它会显著增加排查路由的难度。

3.3 建表逻辑和路由验证:从SQL日志里看到真相

配置写完之后,数据库那边要真的建表。以order_db_0为例,你要执行的建表语句大概是:

CREATE TABLE `order_t_0` ( `id` bigint NOT NULL, `user_id` bigint NOT NULL, `order_amount` decimal(10,2) DEFAULT NULL, `status` int DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

记住,物理表结构的字段必须完全一致,否则中间件做结果合并时会因为字段元数据不一致而报错。id字段不要用数据库自增,我在第5章会细讲原因。

写完配置、启动项目后,设置sql-show: true,你会在控制台看到每次查询被改写之后的真实SQL。比如执行:

orderMapper.selectByUserId(1001L);

控制台日志大概会显示类似这样的内容:

Actual SQL: order_db_0 ::: select * from order_t_0 where user_id = 1001

这说明路由成功,请求被指向了order_db_0上的order_t_0。看到日志的这一刻,恭喜你,你的第一个分库分表应用已经跑通了。关键是你得学会看日志里的Actual SQL,这是排查一切路由问题的第一入口。

3.4 最小工程里最容易出现的两个启动问题

很多同学照着配置走完,启动时还是会遇到问题。我这里列两个最常见的。

第一个是data source is empty错误,通常是因为datasource.names和实际配置的数据源Key不一致。我刚才配置里写的names是order_db_0,order_db_1,YAML里也必须刚好是这两个属性名,中间不能多空格,不能加下划线,严格一致。

第二个是分片策略不匹配导致路由报错。如果你执行一条不带分片键的查询,且没有配置任何兜底策略,默认情况下会尝试全路由,但如果你的规则里没有允许,就会直接抛异常。解决方法是给查询加上分片键条件,或者把这个场景设计成走广播表。

4. 读写分离加上绑定表:单库分表项目继续升级

4.1 读写分离怎么配,主从延迟要怎么办

分库分表真正跑顺之后,很多团队会顺手把读写分离一起做了。Sharding JDBC支持在同一个引擎里同时配置读写分离和分片规则。前提是你已经有了主从复制的MySQL环境。

配置思路也很直观:在数据源列表里,把主库和从库分别定义出来,然后通过一个动态读写分离规则告诉中间件哪些是主库、哪些是从库。比如:

rules: readwrite-splitting: >binding-tables: - order_t,order_item_t

很多人忽略了这一步,结果分片后join性能反而比单表还差。这在分库分表场景里算是一个“隐形坑”,配置不写上,查询正常跑,但性能慢慢拖垮你。

4.3 广播表:适合放那种不需要分片的小表

除了绑定表,还有一类表叫“广播表”。这类表的特点是:数据量很小,但是高频关联查询经常用到。最典型的就是状态字典表、地区表、商品类目表。广播表的处理方式很粗暴:在每一个物理分片里都复制一份完全相同的数据。

比如我有order_db_0和order_db_1两个库,广播表dict_status会在两个库里都建一张一模一样的表。任何写入操作,中间件会同时改写并写入两个库的所有分片,读的话随便读哪个分片都行。

这样做的价值就是,业务查询防不胜防地带上join dict_status的时候,永远不用跨库。配置里加一句:

broadcast-tables: - dict_status

就完事了。数据一致性方面,广播表因为数据量小,写入频率低,同步压力不大,但如果出现写入失败,会让各分片间数据不一致,建议从业务方控制广播表的变更频率。

4.4 强制路由的Hint用法,别等出了问题才想起来

说到强制主库路由和强制分片路由,有个东西不得不提,就是Hint。Sharding JDBC默认的自动路由是根据SQL里的分片键算的,但有些场景很难把条件写进SQL。最简单的例子:后台管理系统没有当前登录用户,但想按某个用户查他的全部订单。没有Hint的话,中间件根本算不出分片。

这时候可以用HintManager来手动指定分片目标:

try (HintManager hintManager = HintManager.instance()) { hintManager.addDatabaseShardingValue("order_t", 1001L); hintManager.addTableShardingValue("order_t", 1001L); // 执行真正的查询 }

这种写法可以在Mapper接口调用前手动设置路由目标,适合那些“代码里才知道分片键”的情况。但Hint是一把双刃剑,大量使用会让中间件的自动分片失效,也会让你在排查问题时无从下手。我的建议是:能通过SQL条件自动路由的,不要用Hint;只有后台查询等少数场景再用,并且要集中封装,不要在业务代码里到处散落。

5. 生产环境里那些绕不开的坑:分布式主键、跨片事务和聚合查询

5.1 分布式主键为什么不能依赖数据库自增

如果你用的还是auto_increment,配好分库分表之后,第一个炸的就是主键。两个库、四张表,每张表都从1开始自增,很快就会出现主键重复,一旦主键冲突,插入直接失败。

Sharding JDBC内置了分布式主键生成方案,默认使用雪花算法。你可以在配置的key-generate-strategy里声明,让中间件在插入时自动生成全局唯一的ID。

key-generate-strategy: column: id key-generator-name: snowflake

雪花算法生成的是一个64位的Long,由时间戳、工作进程ID和序列号拼接而成。整体趋势递增,非常适合做数据库索引。但要注意,如果你是多机房部署,不同机房需要分配不同的worker-id,否则时间戳和进程ID组合时可能碰撞。

这里要提醒一句:雪花算法依赖服务器时钟。如果某台机器的时钟发生大幅回拨,生成的ID可能出现重复或乱序。基础设施的NTP同步策略最好做一下,不要让时钟随便乱跳。

5.2 跨分片聚合查询到底怎么走,分页会让你心凉一截

跨分片查询是分库分表项目里最绕不开的痛点。以select * from order_t where status = 1 order by create_time desc limit 10 offset 20为例,这条SQL在单表上就是一条普通的分页查询,在4个分片上执行时,中间件会先让每个分片各自执行一条limit查询,然后把所有结果合并到内存里,再做一次全局排序和手动分页。

这里有个反直觉的点:为了让全局第21到30条数据正确,每个分片不能只取10条,必须多取一些。因为每个分片里符合条件的数据可能都多于10条,最终全局排名前30的数据可能全部来自某一个分片。所以中间件通常会改写SQL,从“最坏情况”出发,让每个分片返回足够多的结果,再在内存里做合并。

这也导致一个现实问题:分页越深,内存开销越大。offset到10万的时候,中间件几乎要把所有分片的上万条数据拉到内存,系统不卡才有鬼。如果你的业务里存在这种深分页查询,我建议用“游标翻页”代替“页码翻页”,也就是通过上次查询的最后一条记录的ID作为下次查询的起始条件,把深分页问题从根源上消灭掉。

5.3 事务的升级路径:从本地事务到分布式事务

分库分表之前,一个订单的创建、明细插入、库存扣减可能在一个数据库事务里完成,要么全成功要么全失败。分库之后,数据可能落在不同的物理库上,本地事务就管不住了,必须引入分布式事务方案。

大多数入门项目应该清醒一点:先想清楚是否真的需要强一致。如果允许最终一致,柔性事务是完全够用的选择;如果必须强一致,可以考虑基于XA规范的事务方案,它会让所有分片在同一个全局事务里提交或回滚。

Sharding JDBC本身对分布式事务提供了抽象接口,但具体实现你还需要接入外部的分布式事务框架。这里我不展开讲,因为这是一个更大的话题。我只给一个阶段建议:如果你的分库分表还处在单库分表阶段,那么本地事务大概率还够用;一旦真到了跨库跨服务,优先评估业务能不能接受最终一致,再考虑上XA这类强一致性方案。

5.4 扩容这件事,我劝你别等到磁盘满了才想

最后聊一个很多入门教程不会讲的痛点:扩容。取模分片最麻烦的就是扩容。比如现在4张表变8张表,原来user_id % 4的数据位置全部要变,%8之后几乎所有历史数据都要重新分布。这是一次全量数据迁移,工作量和风险都相当大。

我在项目里被这件事教育过之后,后续再做数据拆分设计,会提前考虑两种方案:一种是前期就把分片数设定得足够大,比如直接规划64个分片,前期只选择其中一部分使用,之后慢慢启用剩余分片;另一种是引入一致性哈希,让扩分片的代价控制在局部数据迁移。两种方案都有代价,但都比“上线一年后面对全量搬迁”舒服得多。

还有个更现实的问题:扩容不只是中间件配置变化,还涉及历史数据的ReadWrite迁移、校验、灰度切流。所以如果领导突然跟你说“这个月表要翻一倍”,别急着改配置,先做一个迁移方案再说。


写到这里,Sharding JDBC的入门路径基本清晰了:先搞懂它做了什么,再想清楚分片键怎么定,然后跑通一个最小工程,再慢慢加入读写分离和绑定表,最后在实施中反复打磨对分布式主键、跨片查询和事务的理解。坦率讲,单是“分片键怎么选”这关,就已经能筛掉大部分人。我个人建议,你从小体量项目或者某个内部系统开始试验,不要把核心链路一下子就拆得四分五裂。配置上打开sql-show多观察实际路由,比什么高深的理论都实在。分库分表不是银弹,但它是一把值得学好的工具,希望你少走我走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询