聊到分库分表,很多人第一反应是:“我这张表已经几百万数据了,要不要分一下?”先别急着回答。我见过太多团队,单表三百万数据就喊着要上 ShardingSphere,结果分片方案设计得稀烂,半年之后业务 SQL 一大半没带分片键,每条查询全库全表路由,比不分片的时候还慢。反过来,也有单表跑到千万级还在硬扛的业务,因为访问模式足够简单,一条主键查询走索引就够了。所以 ShardingSphere 不是银弹,它是一个需要你理解原理、设计好分片规则、再谨慎落地的框架。
这篇笔记我想用一篇博文的容量,把我从“听过 ShardingSphere”到“能在生产环境设计分片方案”整个过程的思考、配置、踩坑和调优经验完整串一遍。内容环绕 ShardingJDBC 这条主线展开,会覆盖核心原理、分库分表配置、读写分离、分布式事务选型,以及线上常见的性能杀手和应对策略。如果你正准备在项目里引入分片方案,或者已经用了但总感觉某些查询慢得不对劲,这篇应该能给你一些参考。
1. 分库分表之前,先搞清楚 ShardingSphere 到底解决什么问题
1.1 单库瓶颈是怎么一步步逼着你做分片决策的
我在很多项目里观察到一个规律:单库性能出问题,往往不是某一个瞬间突然崩的,而是一步步逼近临界点。最典型的三类信号:
- 连接数压力。数据库连接池的连接数是有上限的,当应用实例扩容到一定数量后,每个实例占用的连接数加起来可能就压垮数据库了。
- 写入吞吐瓶颈。单库的写入 IO 能力有上限,日志型、流水型业务的写入量一旦上来,单机 IO 就成瓶颈,主从复制延迟也会持续放大。
- 大表的索引和锁成本。单表数据量过千万以后,B+ 树的层级变高,索引维护成本上升,DDL 变更更是麻烦,
ALTER TABLE一跑就是几分钟,直接拖垮线上。
这时候分库分表就成为一种必然选择。但注意,分库分表解决的是“容量”和“并发”问题,它解决不了 SQL 写得烂的问题。相反,它对 SQL 的写法提出了更高要求——跨分片的查询、没有分片键的查询,都会变成全路由扫描,性能反而更差。
1.2 ShardingJDBC 和 ShardingProxy 怎么选
ShardingSphere 这套体系里,有两个可以直接对接业务的入口:ShardingJDBC 和 ShardingProxy。很多新手第一步就卡在选哪个上。
ShardingJDBC 本质上是一个增强版的 JDBC 驱动,它以 jar 包的形式嵌入到业务应用里,应用直连各个真实的数据库分片。它走的是“客户端分片”路线,没有额外的中间件节点,所以少了一层网络转发,性能损耗很小。
ShardingProxy 则是一个独立部署的代理服务,它对外暴露一个数据库端口,应用连它就像连一个普通的 MySQL 一样。好处是语言无关,任何能用 MySQL 客户端的语言都能接,运维同学也可以直接用它做数据管理。
| 对比维度 | ShardingJDBC | ShardingProxy |
|---|---|---|
| 部署方式 | 应用内 jar 包 | 独立进程 |
| 网络链路 | 应用直连数据库 | 应用 → Proxy → 数据库 |
| 语言支持 | 仅 Java | 任意语言 |
| 性能损耗 | 极低 | 有额外网络开销 |
| 运维成本 | 低,跟随应用发布 | 需要单独维护 |
| 适合场景 | Java 应用内部改造 | 异构系统、跨团队统一接入 |
我个人的建议是:如果你的团队是 Java 技术栈,优先选 ShardingJDBC。理由很简单,少一个中间件节点,线上就少一类故障;而且数据分片逻辑和应用代码在一起,排错路径更短。ShardingProxy 更适合你需要让 PHP、Go、Python 多种语言统一接入分片环境,或者需要给数据部门一个透明查询入口的场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ShardingJDBC 核心原理:一条 SQL 的完整旅程
很多教程上来就贴配置,告诉你 actual-data-nodes 怎么写、分片算法怎么配,但如果你不理解一条 SQL 从进来到出去到底经历了什么,出问题的时候你根本无从下手。这一节我用一条 SELECT * FROM t_order WHERE order_id = 100 来拆解全流程。
2.1 SQL 解析:第一步是读懂方言
SQL 到达 ShardingJDBC 之后,第一件事就是被解析成抽象语法树(AST)。你可以把它理解成对一句话做“断句”和“切分主谓宾”——框架要识别出这是一条查询语句,查询目标是哪张表,过滤条件是什么,有没有排序、分页、聚合操作。
解析这一步非常关键,因为后续所有的路由、改写决策都建立在 AST 之上。ShardingSphere 内置了针对 MySQL、PostgreSQL、SQLServer、Oracle 等主流数据库的解析引擎,不同数据库的方言差异在这里会被统一处理掉。实际使用中,尽量保证分片后的所有真实库是同一种数据库,混用数据库虽然理论可行,但会让你的解析和改写环节承受更多不确定性。
2.2 路由:决定去哪个库哪张表
AST 解析完成之后,框架进入路由阶段。这是分片的核心决策环节——根据分片键的值和分片策略,算出这条 SQL 应该落到哪些真实的数据节点上。
还是以 WHERE order_id = 100 为例,如果分片算法是 order_id % 2,那么路由结果就是:100 对 2 取模等于 0,所以这条 SQL 只会命中 ds0.t_order_0 这一个物理表。这就是“单路由”,性能最好。
但如果查询条件里没有分片键,比如 SELECT * FROM t_order WHERE user_name = '张三',框架无法判断数据在哪个分片,就只能把 SQL 广播到所有分片去执行,然后归并结果。这就是“全路由”,也是分片环境下最大的性能隐患之一。全路由本身不可怕,可怕的是高频接口里出现全路由。
2.3 SQL 改写:把逻辑表名换成物理表名
路由决定了目标节点之后,SQL 还是原始的“逻辑表”写法,真实库里的表名是 t_order_0、t_order_1 这种物理名。所以框架要对 SQL 做一次改写——把 t_order 替换成实际要访问的物理表名。
改写的另一个常见场景是自动拼接分片条件。假如你现在写的是 WHERE user_id = 100,而分片键是 order_id,框架如果发现可以通过其他字段的映射关系推断出分片键取值,就会把条件自动补上,从而缩小路由范围。不过这种“自动推断”能力非常有限,生产环境还是应该老老实实在 SQL 里带上分片键的等值条件。
不要小看改写这一步,分布式场景下的分页、排序、聚合 SQL,改写的复杂度会明显上升。比如 LIMIT 10 OFFSET 100000,这行条件在后面归并阶段会引发一个大问题,稍后我会专门讲。
2.4 结果归并:把多个数据源的结果拼起来
SQL 被改写后发往各分片执行,每个分片都会返回一个结果集。归并阶段要做的事情,就是把多个结果集合并成一个完整的结果返回给应用。
归并有一类重要的区分:流式归并和内存归并。流式归并是指数据不需要全部加载到内存,像流水一样逐条处理,比如单纯的 ORDER BY 多路归并排序。内存归并则是把数据全部加载到内存后再处理,比如 GROUP BY 聚合、MAX/MIN 等函数,这类操作在数据量大的时候对内存的冲击非常明显。
还有一类特殊的归并是分页归并。假设 SQL 是 ORDER BY id LIMIT 5 OFFSET 100000,两个分片各自返回 100005 条数据?不是,框架会把每个分片的 100005 条数据都取回来,然后排序、再跳过前 100000 条,最终取 5 条返回。你本意只要 5 条数据,实际数据库层面可能扫描了几十万行,这就是深分页性能杀手。后面调优章节我会给对策。
3. 全量配置实操:从零搭一个可运行的分库分表工程
3.1 工程骨架与依赖
我用 Spring Boot 3.x + ShardingSphere 5.4.x 来做示例。选 5.x 是因为它在配置模型上比 4.x 清晰太多了,4.x 里的 spring.shardingsphere.datasource 虽然也能用,但新的分片规则 API 风格完全变了,新项目不要再用老版本。
xml复制<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.4.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
一个常见的踩坑点:如果你的 Spring Boot 是 3.x,务必使用 ShardingSphere 5.3.0 以上的版本,早期 5.2.x 在 Spring Boot 3 下会报循环依赖异常。另外,不要和 spring-boot-devtools 一起用,热重载经常导致数据源初始化混乱,这个坑我踩过一次之后,直接就把 devtools 从项目里删了。
3.2 核心配置逐项拆解
下面是一份最基础的分库分表 YAML 配置,两个库,每个库两张表,按 order_id 取模分片:
yaml复制spring:
shardingsphere:
datasource:
names: ds0, ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/ds0?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/ds1?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds$->{0..1}.t_order_$->{0..1}
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: order_inline
key-generate-strategy:
column: order_id
key-generator-name: snowflake
sharding-algorithms:
order_inline:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 2}
key-generators:
snowflake:
type: SNOWFLAKE
配置里的关键点,我建议你逐项理解:
datasource.names:声明有哪些真实数据源,相当于给每个分片起名字。这里 ds0 和 ds1 是两个独立的物理库。actual-data-nodes:逻辑表 t_order 对应的物理表位置。ds$->{0..1}.t_order_$->{0..1}是行表达式,等价于 ds0.t_order_0、ds0.t_order_1、ds1.t_order_0、ds1.t_order_1 四张物理表。注意这个表达式的准确性直接决定路由范围,写小了会导致数据写入时找不到表,写大了会让路由范围变大。table-strategy:分片策略。我这里用的是standard标准策略,它要求 SQL 里的分片键必须是等值条件或者 IN 条件。另一个常见的是complex复杂策略,支持多个分片键联合路由;如果没有分片键而想强制指定分片,就用hint策略。key-generate-strategy:分布式主键生成策略。分库分表之后,数据库的自增主键就废了,因为你无法保证跨库、跨表的主键全局唯一。这里用了雪花算法(SNOWFLAKE),生成 64 位 Long 型 ID,有序且全局唯一,对索引友好。
3.3 分片算法选型:INLINE / HASH_MOD / INTERVAL 怎么选
ShardingSphere 5.x 内置的算法类型不少,但生产环境真正常用的其实就几个,我把它们的适用场景和坑位都列出来:
| 算法 | 类型名称 | 适用场景 | 注意事项 |
|---|---|---|---|
| 行表达式 | INLINE | 整数或枚举类型分片键,取模/取余 | 分片键不能为 null,否则路由失败;表达式里不要写复杂函数 |
| 哈希取模 | HASH_MOD | 字符串分片键,先哈希再取模 | 哈希分布相对均匀,适合手机号、用户 ID 等字段 |
| 时间区间 | INTERVAL | 按时间分片,日志表/流水表 | 需要配置分片键的时间格式、区间大小,适合冷热数据分离 |
| 自定义 | CLASS_BASED | 业务规则复杂,内置算法不够用 | 实现 StandardShardingAlgorithm 接口,需要自己处理边界条件 |
关于分片算法的粒度,我特别想提醒一句:分片键的选择,比算法本身重要得多。分片键要选那些“查询条件里大概率会出现”的字段。订单表选 order_id 没问题,但用户订单查询往往是按 user_id 查的,那你就得考虑订单表和用户维度的关联查询怎么走。比较常见的做法是订单表按 user_id 分片,然后订单详情通过 user_id + order_id 联合定位,这样所有用户维度的查询都能收敛到单分片。
4. 读写分离:在分片之上再叠加一层读扩展
4.1 读写分离配置实战
分片解决的是容量和并发写的问题,但很多业务读多写少,单库的读能力也会成为瓶颈。ShardingSphere 支持在分片规则之上再叠加读写分离,逻辑上是“主库负责写,从库负责读,框架自动路由”。
配置上,你需要在 rules 里再加一组 readwrite-splitting 规则:
yaml复制spring:
shardingsphere:
rules:
readwrite-splitting:
data-sources:
readwrite_ds:
type: Static
props:
write-data-source-name: ds_primary
read-data-source-names: ds_replica_1, ds_replica_2
load-balancer-name: round_robin
load-balancers:
round_robin:
type: ROUND_ROBIN
这里有个容易混淆的点:readwrite_ds 是一个逻辑数据源,应用层连接它,框架内部把写操作路由到 ds_primary,把读操作按负载均衡算法分发到 ds_replica_1 和 ds_replica_2。如果在分片场景下使用,你会把读写分离的数据源作为分片配置里的 datasource,层层嵌套。
4.2 强制走主库的几种姿势
读写分离最大的坑是主从延迟。写完之后立刻读,从库还没来得及同步,查出来是旧数据。这种场景下,你需要在代码里强制让某条 SQL 走主库。
ShardingSphere 提供了一种 Hint 机制,可以手动指定路由:
java复制HintManager hintManager = HintManager.getInstance();
hintManager.setWriteRouteOnly();
try {
// 执行需要走主库的查询
orderMapper.selectById(orderId);
} finally {
hintManager.close();
}
还有一个容易被忽略的细节:事务内的读操作默认全走主库。原因很好理解,事务里写了数据还没提交,从库是读不到这些未提交数据的,如果事务内的读走了从库,就会出现“自己读不到自己写的数据”这种荒谬现象。所以如果你的业务是“先写后读”,但又不想开启大事务,可以用 Hint 强制走主库,而不是把整个方法包上 @Transactional。开启一个事务的成本和连接占用时间,在高并发下是实打实的性能损耗。
5. 分布式事务:从强一致到最终一致的选择题
分库分表意味着一次业务操作可能要跨多个库写入,本地事务已经管不住全局一致性了。分布式事务是分片方案里最容易被低估的复杂度,很多团队上线时用本地事务,出现数据不一致了才回头补方案。
5.1 三种事务方案的取舍逻辑
ShardingSphere 支持三种事务类型:LOCAL、XA、BASE。
LOCAL 就是默认的本地事务,每个分片各自管自己的事务,框架不做任何协调。性能最好,但跨分片的一致性完全无法保证。适合那些“允许短暂不一致”的场景,比如用户日志、非关键流水。
XA 是两阶段提交协议,通过 Atomikos 或 Narayana 这类事务管理器协调多个分片的事务。它的优点是强一致性,要么全成功要么全回滚;缺点是性能开销大,两阶段提交过程中会持有数据库连接和行锁,高并发下容易成为瓶颈。我见过不少项目上了 XA 之后,高峰期事务耗时从几十毫秒涨到几百毫秒,最后不得不拆业务。
BASE 是柔性事务,通过保证最终一致性来换取性能。ShardingSphere 整合了 Seata 的 AT 模式和 TCC 模式,把一个大事务拆成多个本地事务分支,通过事务协调器记录分支状态并异步补偿。
| 维度 | LOCAL | XA | BASE |
|---|---|---|---|
| 一致性 | 不保证 | 强一致 | 最终一致 |
| 性能损耗 | 无 | 高 | 中 |
| 实现复杂度 | 最低 | 中 | 较高 |
| 适用场景 | 单分片写、可容忍不一致 | 资金、库存等强一致场景 | 大部分跨分片业务 |
5.2 生产建议:怎么选最不折腾的方案
我的经验是:能不进分布式事务,就别进。
很多看起来需要跨分片事务的业务,如果能在分片设计阶段规避掉,后续会省掉非常多麻烦。比如订单和订单明细,天然是一对多的父表子表关系,如果你把两个表的第一个分片键都设为 user_id,那么一个用户的所有订单和明细都在同一个分片内,这些操作就还是本地事务。
如果是真正绕不开的跨分片写操作,优先考虑 BASE + Seata AT 模式。AT 模式对业务代码侵入很小,普通的 JDBC 操作它都能自动拦截记录回滚日志。唯一要注意的是,Seata 的 TC 服务器(事务协调器)本身又引入了新的运维组件,你得评估团队有没有能力维护。
XA 我只建议在资金类、库存扣减这类强一致且并发量可控的场景使用。上线前一定要做压力测试,把两阶段提交在高峰期连接池的占用情况测清楚,不然上线之后连接池被打满的故障大概率会找上门。
6. 线上踩坑清单与性能调优笔记
6.1 常见的“假性分片”问题
分片配置好了,SQL 也写了分片键,但性能还是差,这是我线上排查时遇到最多的情况。归纳起来有几个典型问题:
第一个是分片键被函数包裹。比如 WHERE DATE(create_time) = '2024-01-01',如果 create_time 是分片键,这种写法会导致框架无法从条件里提取分片键的值,直接退化成全路由。解决办法是把函数挪到等号右边,比如 WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02',这样框架才能识别出 create_time 的范围并做路由裁剪。
第二个是分片键用了 OR 连接非分片条件。WHERE order_id = 100 OR status = 1,因为 OR 连接了无法定位分片的 status 条件,整个条件就没办法精确定位,只能全路由。这个不光 ShardingSphere,任何中间件都处理不了,只能靠改写 SQL 或用 UNION 拆分。
第三个是 JOIN 查询里关联字段和分片键不一致。两张表关联查询,如果关联字段不是分片键,或者两个表的分片键不一致,JOIN 只能在内存中完成,甚至要拉取全量数据。生产环境应尽量避免跨分片 JOIN,一个可行的替代方案是:在应用层查两次,把结果拼装起来。看着多了一次查询,实际性能往往比数据库层做过一次大归并更快。
6.2 深分页与全路由的性能杀手
深分页的问题前面已经提过,这里是完整的性能画像。假设 t_order 分了两片,你执行:
sql复制SELECT * FROM t_order ORDER BY id LIMIT 10 OFFSET 100000;
框架的处理方式是把 LIMIT 10 OFFSET 100000 改写成 LIMIT 100010 OFFSET 0 下发到每个分片,然后每个分片都查回 100010 条数据,归并排序后再丢弃前 100000 条。数据量再翻几倍,内存和网络开销相当可观。
应对深分页有几个思路:
- 业务上改成“游标分页”:用
WHERE id > #{lastId} ORDER BY id LIMIT 10代替 OFFSET 分页。这是最推荐的方式,每次只查一页之后的数据,天然规避深分页问题。 - 如果必须要跳页,可以把第一页的主键列表查出来,再用主键回表查详情,避免全字段深分页。
- 如果是后台报表类的、允许一定延迟的查询,可以考虑基于宽表或数仓去跑,而不是直接压到在线分片库上。
全路由的问题比深分页更隐蔽。有的接口平时都带分片键,但某个特殊分支忘了带,线上直接触发全路由,流量一高就把所有分片库打爆。我的排查经验是:在数据库端开启慢查询日志,配合 EXPLAIN 看是不是走了全表,同时监控 ShardingSphere 的 SQL 解析日志,确认每条 SQL 实际路由到了几个分片。把“全路由 SQL”作为线上监控指标之一,比事后追查高效得多。
6.3 连接池配置与内存参数的隐藏成本
分片之后,业务应用需要同时连接多个数据源,连接池的数量会比之前倍增。假设原来一个应用 10 个连接就够了,分两个库之后,逻辑数据源的连接池是对每个物理库单独维护的,如果给每个物理库配置 10 个连接,总连接数就是 20,这还不算从库。
所以在配置 HikariCP 的 maximum-pool-size 时要格外克制。我的经验值是:单实例分片库连接数先从 5 + 实例数 起步,压测后逐步调整。连接数不是越大越好,MySQL 默认连接数上限就摆在那里,连接过多反而增加上下文切换开销。
另外,ShardingJDBC 的归并在数据量大时对 JVM 内存有冲击,尤其是包含大量 GROUP BY、ORDER BY 的分页查询。线上可以考虑给应用 JVM 堆内存留出余地,同时在 SQL 层面尽量限制单页数据和查询范围。
6.4 广播表和 ER 分片表的建模设计
实际落地分库分表时,不是所有表都需要分片。比如商品分类、地区字典、系统配置这类数据量小但不是被频繁 JOIN 的表,如果每个分片都放一份全量数据,就能避免跨分片 JOIN,这类表在 ShardingSphere 里叫广播表。
配置上非常简单,在分片规则里声明 broadcast-tables: t_config, t_dict,框架就会把广播表的写操作同步到所有分片,读操作只在当前路由到的分片里读。
还有一类父表子表,比如订单表和订单明细表,如果两张表的分片键不一致,插入子表时会因为找不到父表数据所在的分片而无法定位。ShardingSphere 提供了 ER 分片表机制,配置 binding-tables: t_order, t_order_item 之后,框架会保证两张表的分片策略一致,子表插入时会根据父表的分片键推导路由,从而让关联查询尽可能在一个分片内完成。
yaml复制rules:
sharding:
binding-tables:
- t_order, t_order_item
broadcast-tables:
- t_config
这个设计的价值在查询链路里体现得非常明显:有了绑定表关系,t_order JOIN t_order_item 的关联查询就会被路由到同一分片,不会触发跨分片 JOIN。我见过不少团队分片都上线了,JOIN 查询还是慢得不行,回头发现根本没配 binding-tables。
我最后想分享的一点体会是:ShardingSphere 这套东西,入门最快的方式不是看文档,而是自己在本地起两个 MySQL 实例,把官方示例跑通,然后故意写一些不带分片键的 SQL,观察路由日志里实际命中的分片数。这样的“破坏性实验”做几次,你对它的路由逻辑和性能边界会比看十遍文档都印象深刻。配置一时半会儿跑不通也别急,把 datasource、actual-data-nodes、分片算法这三层配置逐一排查,绝大多数问题都能定位。分库分表这条路一旦走上,后续的容量规划、监控告警、数据迁移都会跟着来,但先把分片规则和 SQL 规范化这两件事做好,后面的路会顺很多。
