2635 字
约 8 分钟
18
S06-X06 · Sharding-JDBC:分库分表的路由、归并与分布式主键

单表到两千万行、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. 自测

  1. 客户端分片和代理分片,各自多付出的是什么成本?
  2. 一条 SQL 没带分片键,Sharding-JDBC 会怎么处理?
  3. 为什么 ORDER BY + LIMIT 在分片下必须内存排序?
  4. 绑定表解决了什么问题,前提条件是什么?
  5. XA 与 BASE 的差别体现在锁的持有时间上,具体是怎样的?
  6. 单表 800 万行、写入不高,该不该分片?先做什么?
S06-X06 · Sharding-JDBC:分库分表的路由、归并与分布式主键
https://www.clxhxhhr.top/posts/5667/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。