单表到两千万行、B+ 树从三层变四层,写入开始排队;或者一张表的写入把整个库的 IO 吃满——这时候分片的念头就来了。但分片不是"配一下 %2"那么简单:路由规则配错会查不到数据,归并做不好会让 LIMIT 变成全表拉取,主键不换会让自增 ID 直接撞车。
这篇按"它是什么 → 怎么配 → 一次查询怎么走 → 哪里会翻车 → 什么时候别用"的顺序拆。
1. 客户端分片:没有代理进程
| 对比 | ShardingSphere-JDBC | ShardingSphere-Proxy |
|---|---|---|
| 形态 | jar 包,改写 DataSource |
独立进程,监听 3307 |
| 网络跳数 | 无额外跳,应用直连 MySQL | 多一跳 |
| 语言 | 仅 Java | 任意(走 MySQL 协议) |
| 运维 | 跟应用一起发布 | 独立部署、独立扩缩 |
| 配置 | YAML / Java API | YAML + 控制台 |
| 适合 | Java 单体、Spring Boot | 多语言、DBA 想统一入口 |
Sharding-JDBC 的本质是在 JDBC 层做 SQL 改写:它骗应用说"这是一张表",实际把一条逻辑 SQL 拆成 N 条物理 SQL,发到不同库,再把结果拼回来。所以它不理解业务,只看分片键。
2. 接入:5.5.x 走 Driver,5.3.x 走 starter
// 5.5.0:以 JDBC Driver 形式接入
// <dependency>
// <groupId>org.apache.shardingsphere</groupId>
// <artifactId>shardingsphere-jdbc</artifactId>
// <version>5.5.0</version>
// </dependency>
spring:
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:sharding.yaml
按官方 5.5.0 文档核对,配置已从 spring.shardingsphere.* 迁到独立的 sharding.yaml,spring.datasource.url 只是一个指向该文件的入口。5.3.x 时代常用的 shardingsphere-jdbc-core-spring-boot-starter 走的是 spring.shardingsphere.rules.* 那套前缀,两者不能混写。
3. 核心概念
| 概念 | 含义 | 配错的表现 |
|---|---|---|
| 逻辑表 | 代码里写的表名 t_order |
不存在则报"表未配置" |
| 真实表 | 物理表 t_order_0 / t_order_1 |
与实际库表不符则 Table not exists |
| 数据源 | ds_0 / ds_1 |
Hikari 参数写错则启动即失败 |
| 分片键 | 决定路由的列,如 user_id |
选错列会导致数据倾斜或全路由 |
| 分片算法 | INLINE / MOD / HASH_MOD / INTERVAL |
表达式写错查不到数据 |
| 分片策略 | STANDARD(单列)/ COMPLEX(多列)/ HINT / NONE |
用了 COMPLEX 却只配一个算法 |
| 绑定表 | 分片规则一致、JOIN 可下推 | 不配则跨库 JOIN 直接报错 |
| 广播表 | 每个库一份全量小表 | 误当普通表分片,字典查不全 |
4. inline 与 standard
databaseName: sharding_db
dataSources:
ds_0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://10.0.0.5:3306/clarity_0?useSSL=false
username: clarity
password: ${DB_PASS}
ds_1:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://10.0.0.6:3306/clarity_1?useSSL=false
username: clarity
password: ${DB_PASS}
rules:
- !SHARDING
tables:
t_article:
actualDataNodes: ds_${0..1}.t_article_${0..1}
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: article_inline
keyGenerateStrategy:
column: id
keyGeneratorName: snowflake
shardingAlgorithms:
article_inline:
type: INLINE
props:
algorithm-expression: t_article_${user_id % 2}
snowflake:
type: SNOWFLAKE
props:
worker-id: 1
INLINE 是 Groovy 表达式,最直观也最容易被滥用:t_article_${user_id % 2} 只看单列取模。多列、范围、哈希取模这类需求要用 STANDARD + CLASS_BASED,或 COMPLEX 策略:
| 算法 | 表达式/参数 | 路由能力 |
|---|---|---|
INLINE |
Groovy 表达式 | 单列,支持简单运算 |
MOD |
sharding-count: 4 |
取模,均匀 |
HASH_MOD |
sharding-count: 4 |
先哈希再取模,避免热点 |
INTERVAL |
sharding-count + 时间格式 |
按时间区间分片 |
CLASS_BASED |
algorithmClassName |
自定义,范围/多列靠它 |
5. 一次查询怎么走:解析 → 改写 → 路由 → 归并
| 阶段 | 做的事 | 出错的样子 |
|---|---|---|
| 解析 | 用词法/语法树把 SQL 拆成 AST | 方言不支持则报 SQL parse error |
| 改写 | 逻辑表名换真实表名,补列、改分页 | ORDER BY 列没在结果集里被自动补上 |
| 路由 | 按分片键算目标库表 | 无分片键 → 全路由(广播到所有表) |
| 归并 | 合并多结果集,排序、聚合、分页 | LIMIT 变内存排序,内存涨 |
最容易忽视的是全路由:WHERE 不带分片键,SQL 就发到所有物理表再归并——一条查询等于 4 条 SQL 加一次内存合并。
带不了分片键时用 Hint 强制路由:
try (HintManager hint = HintManager.getInstance()) {
hint.addDatabaseShardingValue("t_article", 3);
hint.addTableShardingValue("t_article", 3);
jdbcTemplate.queryForList("select * from t_article where id = 42");
}
6. 归并的六个坑
| SQL 形态 | 行为 | 规避 |
|---|---|---|
ORDER BY + LIMIT |
必须内存排序后截断 | 分片键与排序列一致,或改游标分页 |
COUNT(DISTINCT x) |
各分片去重后再全局去重 | 结果集大时内存压力大,考虑预聚合表 |
AVG(x) |
改写成 SUM/COUNT 再算 |
注意 COUNT 为 0 的分片 |
跨库 JOIN |
不支持(除绑定表) | 配绑定表,或改成两次查询 + 应用层拼 |
UNION |
需要归并去重 | 尽量改 UNION ALL |
5.x 的 SQL Federation 引擎能处理部分跨库 JOIN,但它是可选增强,语义与性能都有边界,别当成"JOIN 照旧"的许可证。
绑定表是这里唯一的正解:t_article 与 t_article_tag 用同一个 user_id 分片,配 bindingTables: [t_article, t_article_tag],JOIN 就能下推到单个库的单个分片:
rules:
- !SHARDING
bindingTables:
- t_article, t_article_tag
broadcastTables:
- t_dict
7. 分布式主键
自增 ID 在分片下必然冲突。三个选项:
| 方案 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| 雪花 | type: SNOWFLAKE |
有序、趋势递增、64 位 | 依赖时钟,worker-id 要唯一 |
| UUID | type: UUID |
无状态 | 无序,作主键伤 B+ 树 |
| 自定义 | CLASS_BASED |
可租户前缀 | 要自己保证唯一 |
雪花是默认选择,但 worker-id 必须按实例分配——两个应用实例写同一个值,同一毫秒内就会撞。容器环境下用环境变量注入,或改用 worker-id-type: IP 之类由框架推导(按官方 5.5.0 文档核对支持的取值)。
业务主键(订单号)不要用雪花直接暴露,独立生成器更稳。
8. 分布式事务
| 类型 | 配置 | 一致性 | 性能 | 适用 |
|---|---|---|---|---|
LOCAL |
默认 | 无跨库保证 | 最好 | 单库操作或可接受最终一致 |
XA |
providerType: Atomikos / Narayana |
强一致(两阶段提交) | 差,锁持有时间长 | 资金、库存扣减 |
BASE |
providerType: Seata |
最终一致 | 中 | 长事务、跨服务 |
rules:
- !TRANSACTION
defaultType: XA
providerType: Atomikos
现实建议:优先设计成单分片事务(同一 user_id 的数据落在同一库),把分布式事务当成兜底而不是常规手段。XA 的 prepare 阶段会持有行锁直到 commit,高并发下容易连锁超时。
9. 扩容:最难的不是配置
从 2 库 4 表扩到 4 库 8 表,actualDataNodes 改一行就完事,数据不会自己搬家。
| 阶段 | 动作 | 风险 |
|---|---|---|
| 双写 | 新旧规则同时写 | 写放大,需保证幂等 |
| 存量迁移 | 按分片键批量搬迁 | 迁移期间增量如何追平 |
| 校验 | 逐条比对行数与 checksum | 耗时长,期间仍在写 |
| 切读 | 灰度把读流量切到新规则 | 路由错误导致查不到 |
| 下线旧表 | 观察期后删除 | 不可逆 |
5.x 自带 Scaling(数据迁移)能力,基于 CDC 追增量,但仍需要业务侧配合停写窗口或双写方案。
10. 什么时候不该分片
| 现状 | 先做什么 | 为什么 |
|---|---|---|
| 单表 < 1000 万 | 加索引、优化慢 SQL | 分片带来的复杂度远大于收益 |
| 读多写少 | 读写分离 / 缓存 | 分片解决的是写入与容量 |
| 历史数据占大头 | 归档冷数据 | 热数据可能只有几百万 |
| 按时间自然分区 | MySQL 分区表 | 单机内解决,无需中间件 |
| 团队没有 DBA | 先别分 | 出问题时无人能定位 |
经验线:单表写入 QPS 接近单库上限、或单表容量让备份/DDL 变得不可行时,才轮得到分片。分片一旦落地,JOIN、事务、分页、聚合、扩容全部要重新设计——这是一次架构级决策,不是一次配置优化。
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
启动报找不到 sharding.yaml |
classpath: 路径错,或文件没打进 jar |
确认在 resources 根目录 |
| 查询结果为空 | 分片键值算出的表不存在,或 INLINE 表达式用了负数取模 |
用 actualDataNodes 手算核对 |
报 Table t_order not exists |
真实表名与 actualDataNodes 不匹配 |
核对物理表命名 |
| 慢查询突然变多 | 无分片键触发全路由 | 补分片键;或接受但加缓存 |
LIMIT 深分页极慢 |
内存归并 | 改游标分页(WHERE id > ?) |
| 主键重复 | 多实例 worker-id 相同 |
按实例注入唯一值 |
JOIN 直接报错 |
未配绑定表 | 配 bindingTables 或拆查询 |
12. 边界
| 场景 | 更合适的方案 | 理由 |
|---|---|---|
| 多语言混布 | ShardingSphere-Proxy | JDBC 版只有 Java 生态 |
| 需要强一致的跨库事务 | 单库设计 + 业务补偿 | XA 的代价常被低估 |
| 报表/OLAP 类聚合 | 同步到数仓 / ClickHouse | 分片库上做多维聚合是自找麻烦 |
| 已有大量跨表 JOIN 的旧系统 | 先重构查询 | 分片会让这些 SQL 全部失效 |
13. 自测
- 客户端分片和代理分片,各自多付出的是什么成本?
- 一条 SQL 没带分片键,Sharding-JDBC 会怎么处理?
- 为什么
ORDER BY+LIMIT在分片下必须内存排序? - 绑定表解决了什么问题,前提条件是什么?
XA与BASE的差别体现在锁的持有时间上,具体是怎样的?- 单表 800 万行、写入不高,该不该分片?先做什么?