这是一篇写完就能拿去跟架构师对线、也能拿去面试的长文。 全文基于 Apache ShardingSphere 5.x (5.4.x / 5.5.x 均适用,5.5.3 为当前最新稳定版),覆盖五件事: ① 什么时候该 分库分表、什么时候千万别分;② 分片键到底怎么选(含基因法完整推导);③ 完整可落地的配置与代码;④ 生产环境 20 个真实踩过的坑;⑤ 面试必问 30 题 + 面试官最爱挖的坑点。
一句话总纲:分库分表是"用复杂度和一致性换容量"的交易,不是性能优化手段。 谁把它当性能优化用,谁就在给自己埋雷。
目录
- 一、先别急着分:你真的需要分库分表吗
- 二、概念扫盲:面试官咬着问的术语
- [三、ShardingSphere-JDBC 上手:依赖、配置、代码](#三、ShardingSphere-JDBC 上手:依赖、配置、代码)
- 四、分片键选型:整个方案的生死线
- 五、分片算法与分布式主键
- 六、什么场景该用,什么场景千万别用
- [七、生产环境必踩的 20 个坑](#七、生产环境必踩的 20 个坑)
- [八、面试必问 30 题 + 高频追问与坑点](#八、面试必问 30 题 + 高频追问与坑点)
- 九、一页纸决策清单
一、先别急着分:你真的需要分库分表吗
1.1 单表为什么会到瓶颈:本质是 B+Tree 的高度
MySQL InnoDB 的主键索引是 B+Tree,默认页大小 16KB。假设主键是 bigint(8 字节),页内指针约 6 字节,那么一个非叶子节点能放的索引项数量是:
16 * 1024 / (8 + 6) ≈ 1170 个
假设单行数据 1KB,叶子节点每页能放约 16 行:
| B+Tree 高度 | 可承载行数 | 最坏磁盘 IO |
|---|---|---|
| 2 层 | 1170 × 16 ≈ 1.8 万 | 2 次 |
| 3 层 | 1170 × 1170 × 16 ≈ 2100 万 | 3 次 |
| 4 层 | ≈ 250 亿 | 4 次 |
关键结论 :3 层树就能装 2000 万行,4 层能装 250 亿。所以"单表数据量"本身不是性能断崖的原因。真正让人难受的是这三件事:
- 索引树变高 + 缓冲池命中率下降:Buffer Pool 大小固定,索引和数据膨胀后热数据装不进内存,命中率从 99% 掉到 90%,QPS 立刻腰斩。
- DDL 与备份变得不可接受 :一张 500GB 的表,
ALTER TABLE加个字段要几小时,备份恢复要一整天,出故障时 RTO 完全没法看。 - 写热点与锁竞争 :自增主键的
AUTO-INC锁、二级索引插入的页分裂、热点行的行锁排队,并发一上来 TPS 就上不去。
口径参考 :《阿里巴巴 Java 开发手册》建议单表行数超过 500 万行 或容量超过 2GB 时考虑分库分表。 实操体感:2000 万左右 开始明显吃力;如果行很宽(带 TEXT/BLOB),500 万就该动手 。 注意这是"推荐考虑"而不是"必须拆"------见过单表 8000 万行跑得很稳的(纯主键点查 + 冷数据),也见过 300 万行就崩的(宽表 + 复杂聚合 + 高并发写)。
1.2 瓶颈类型对照表:分库分表到底能解决什么
这是最容易被忽略、也是面试最常追问的一张表:
| 瓶颈类型 | 典型表现 | 分库分表能否解决 |
|---|---|---|
| 磁盘空间 / 备份慢 | 单表数百 GB,备份数小时 | ✅ 解决 |
| 连接数打满 | max_connections 占满,应用拿不到连接 |
✅ 只有分库能解决(连接数 = 库数 × 单库连接数) |
| 写锁竞争 | 热点行、自增锁、索引页分裂 | ✅ 分库分表都能缓解 |
| 读压力大 | 大量点查打在同一实例 | ✅ 分库(但先考虑读写分离,成本更低) |
| CPU 打满 | 复杂聚合、排序、大范围扫描 | ⚠️ 只有拆分后单库负载真的下降才有效 |
| 单条 SQL 本身就慢 | 没走索引、回表次数多、SQL 写得烂 | ❌ 完全不解决,分片后反而更慢 |
| 大事务导致主从延迟 | 主从延迟几十秒 | ❌ 不解决,甚至加剧 |
最后一行是最大的认知误区。 一条烂 SQL 在单库跑 2 秒,拆成 16 个分片后如果是全路由(不带分片键),会变成 16 个分片各跑 2 秒 + 中间件归并开销,总耗时可能超过 5 秒,并且一次吃掉 16 倍的连接和 CPU。
1.3 决策阶梯:按顺序做,别跳过
① SQL / 索引优化 ←────────────── 80% 的问题其实死在这一步,别上来就分库
② 缓存(Redis / Caffeine 本地缓存)挡住读流量
③ 读写分离(一主多从) ← 读多写少场景的性价比之王
④ 垂直拆分(按业务拆库、按字段拆表)
⑤ 分区表 PARTITION(单表内分区,对应用透明)
⑥ 冷热分离 / 历史数据归档 ← 很多"大表"其实 80% 是冷数据,归档后立刻瘦下来
⑦ 分库分表 ← 到这里才是真的没辙了
⑧ 换存储(NewSQL / ES / HBase / ClickHouse / 时序库)
一个真实案例 :某业务订单表 1.2 亿行,团队准备上 ShardingSphere。跑了次慢 SQL 分析后发现,70% 的慢查询来自一条没走索引的 WHERE status = ? AND create_time BETWEEN ? AND ?,加联合索引后 P99 从 3.2s 降到 80ms。再配合历史数据归档(1 年前订单进归档库),主表只剩 3000 万行,分库分表方案直接砍掉。
面试官挖坑点 :如果你说"我们单表数据量大所以分库分表了",下一个问题一定是"那你们做过索引优化和归档吗?为什么不上读写分离?"。答不上来 = 方案没经过思考。
二、概念扫盲:面试官咬着问的术语
2.1 四种拆分方式
| 类型 | 定义 | 解决什么 | 例子 |
|---|---|---|---|
| 垂直分库 | 按业务把表拆到不同库 | 业务解耦、连接数、单机负载 | 订单库 / 用户库 / 商品库 独立部署 |
| 垂直分表 | 把宽表按字段冷热拆成主表 + 扩展表 | 行变窄 → 单页放更多行 → Buffer Pool 效率 | user + user_ext(存 detail/TEXT) |
| 水平分表 | 同库内按规则拆成多张结构相同的表 | 单表索引体积、锁竞争 | order_0 ~ order_15 在同一个库 |
| 水平分库 | 把表按规则拆到多个库 | 加上了连接数、CPU、IO、磁盘的水平扩展 | order 拆到 4 个库,每库再分 4 张表 |
分库 vs 分表(面试必问区别):
- 只分表不分库 :所有分片还在同一实例上,连接数、CPU、磁盘 IO 上限没变,只是单表变小、索引变浅、锁竞争降低。跨分片 UNION/聚合由 MySQL 自己扛。成本低、改造小。
- 分库 :才真正把压力摊到多台机器,但也引入了跨库 JOIN、分布式事务、跨库归并三大麻烦。
2.2 ShardingSphere 三兄弟
| 产品 | 形态 | 语言支持 | 性能 | 适用场景 |
|---|---|---|---|---|
| ShardingSphere-JDBC | 客户端 Jar 包,实现 JDBC 接口 | 仅 Java | 无网络跳转,损耗最低 | Java 单体/微服务,绝大多数场景首选 |
| ShardingSphere-Proxy | 独立部署的数据库代理 | 任意语言(MySQL/PostgreSQL 协议) | 多一跳网络,约 20~30% 损耗 | 多语言混合、DBA 运维友好 |
| ShardingSphere-Sidecar | Service Mesh 形态 | 任意语言 | --- | K8s + Mesh 体系,生产落地少 |
Sharding-JDBC 和 ShardingSphere 的关系 :Sharding-JDBC 是 3.x 时代的名字,2018 年捐给 Apache 后改名 ShardingSphere,进入 4.x/5.x。面试时说 "Sharding-JDBC" 没问题,但要补一句"现在叫 Apache ShardingSphere-JDBC,5.x 之后配置格式和 4.x 不兼容",这是加分项。
对比 MyCat:
| 维度 | ShardingSphere-JDBC | MyCat |
|---|---|---|
| 架构 | 客户端直连,无中心节点 | 独立 Proxy 中间件 |
| 性能 | 高(等同原生 JDBC + 解析开销) | 多一跳,中间件自身易成瓶颈 |
| 运维 | 无额外部署,但升级要发版 | 独立运维,应用零感知 |
| 生态 | Apache 顶级项目,活跃 | 相对停滞 |
| 缺点 | 只支持 Java;连接数放大(应用实例 × 分片数) | 单点隐患,需做高可用 |
2.3 七个必须记住的核心术语
| 术语 | 含义 | 面试关键词 |
|---|---|---|
| 逻辑表 | 应用 SQL 里写的表名,如 t_order |
水平拆分表的统一标识 |
| 真实表 | 实际存在的物理表,如 t_order_0 |
actualDataNodes |
| 分片键 | 决定数据落到哪个分片的字段 | 方案生死线 |
| 分片算法 | 分片键 → 分片的映射函数 | MOD / HASH_MOD / RANGE / INTERVAL / CLASS_BASED |
| 绑定表 | 分片规则一致、互为主子表的表 | bindingTables,避免笛卡尔积 |
| 广播表 | 所有分片都存一份全量数据的小表 | broadcastTables,字典表/配置表 |
| 单表 | 不参与分片的表 | 5.x 支持混合部署 |
2.4 内核流程:SQL 是怎么被"改写"的
这是面试最高频的八股题之一,必须能一口气说出来:
SQL 解析(Parsing) → AST
SQL 路由(Routing) → 根据分片键 + 分片算法算出真实库表
SQL 改写(Rewriting) → 逻辑表名→真实表名、分页修正、补列、聚合改写
SQL 执行(Execution) → 并发下发到各分片(受 max-connections-size-per-query 控制)
结果归并(Merging) → 流式归并 / 内存归并 / 装饰者归并
"改写"环节具体改了什么(能举出例子 = 远超普通候选人):
sql
-- 1) 表名改写
-- 原始:SELECT * FROM t_order WHERE order_id = 1001;
-- 改写:SELECT * FROM t_order_1 WHERE order_id = 1001; (假设路由到 ds_1.t_order_1)
-- 2) 聚合改写:AVG 无法直接相加,改写成 SUM + COUNT
-- 原始:SELECT AVG(amount) FROM t_order;
-- 改写:SELECT SUM(amount) AS SUM_DERIVED, COUNT(amount) AS COUNT_DERIVED FROM t_order_0;
-- 归并:AVG = 总 SUM / 总 COUNT
-- 3) 分页改写:为保证结果正确,offset 必须下推
-- 原始:SELECT * FROM t_order ORDER BY create_time LIMIT 100, 10;
-- 改写:SELECT * FROM t_order_0 ORDER BY create_time LIMIT 0, 110;
-- 归并:各分片取回 110 行,内存排序后取第 100~110 行 ← 深分页灾难的根源
三、ShardingSphere-JDBC 上手:依赖、配置、代码
3.1 Maven 依赖
xml
<properties>
<shardingsphere.version>5.5.2</shardingsphere.version>
</properties>
<dependencies>
<!-- ShardingSphere-JDBC Spring Boot Starter -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>${shardingsphere.version}</version>
</dependency>
<!-- 使用 XA 分布式事务时需要 -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-transaction-xa-core</artifactId>
<version>${shardingsphere.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.4.0</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
</dependencies>
坑点前置提醒 :5.x 的坐标是
shardingsphere-jdbc-core-spring-boot-starter。如果你还在用sharding-jdbc-spring-boot-starter(4.x 老坐标),配置格式完全不同,不要混用 。Spring Boot 3 若启动报DataSource找不到,检查是否引入了spring-boot-starter-jdbc,以及自动装配是否被排除。
3.2 完整 YAML 配置(先看全貌)
场景:订单表 t_order,分 2 个库(ds_0/ds_1),每库 4 张表,共 8 个分片;分片键 user_id;t_order_item 作为绑定表;t_dict 作为广播表。
yaml
spring:
shardingsphere:
# ============ 1. 数据源配置 ============
datasource:
# 定义所有分片数据源名称,多个逗号分隔
names: ds_0,ds_1
# 第一个分片数据源:ds_0,对应数据库 order_db_0
ds_0:
# 连接池类型:HikariCP
type: com.zaxxer.hikari.HikariDataSource
# mysql8驱动类
driver-class-name: com.mysql.cj.jdbc.Driver
# 数据库连接地址
jdbc-url: jdbc:mysql://10.0.0.1:3306/order_db_0?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: xxx
# 该数据源连接池最大连接数(注意:单分片上限,不是全局)
maximum-pool-size: 20
# 连接池最小空闲连接
minimum-idle: 5
# 获取连接的超时时间,单位ms
connection-timeout: 30000
# 第二个分片数据源:ds_1,对应数据库 order_db_1
ds_1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://10.0.0.2:3306/order_db_1?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: xxx
maximum-pool-size: 20
minimum-idle: 5
# ============ 2. 分片核心规则 ============
rules:
# 分片规则标识固定写法 !SHARDING
- !SHARDING
tables:
# ---- 订单逻辑表 t_order(业务代码写逻辑表名,不用关心子表) ----
t_order:
# 真实数据节点表达式:ds_0~ds_1库,每个库下 t_order_0 ~ t_order_3 四张表
# 展开:ds_0.t_order_0,ds_0.t_order_1,ds_0.t_order_2,ds_0.t_order_3,ds_1.t_order_0...ds_1.t_order_3
actualDataNodes: ds_${0..1}.t_order_${0..3}
# 分库策略
databaseStrategy:
# standard:标准单分片键策略,支持 =、in 查询
standard:
# 分库分片字段:user_id
shardingColumn: user_id
# 引用下方定义的分片算法名称 db-inline
shardingAlgorithmName: db-inline
# 分表策略
tableStrategy:
standard:
# 分表分片字段:同样user_id,保证同用户落在同一个库同一张表
shardingColumn: user_id
# 引用下方定义的分片算法名称 tbl-mod
shardingAlgorithmName: tbl-mod
# 分布式主键生成策略
keyGenerateStrategy:
# 需要自动生成ID的字段名 order_id
column: order_id
# 引用下方主键生成器 snowflake
keyGeneratorName: snowflake
# ---- 订单项逻辑表 t_order_item(订单子表,和t_order绑定) ----
t_order_item:
# 和t_order完全一致的真实节点
actualDataNodes: ds_${0..1}.t_order_item_${0..3}
# 分库策略 和 t_order 保持一模一样,绑定表硬性要求
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: db-inline
# 分表策略 和 t_order 保持一模一样
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: tbl-mod
# 订单项主键自动生成
keyGenerateStrategy:
column: order_item_id
keyGeneratorName: snowflake
# ---- 绑定表配置:主子表分片规则一致,JOIN时在单分片内关联,禁止跨库join ----
bindingTables:
- t_order,t_order_item
# ---- 广播表(公共表):所有数据源都存在这张表,写入时同步到所有库,适合字典、地区 ----
broadcastTables:
- t_dict
- t_region
# ---- 自定义分片算法列表 ----
shardingAlgorithms:
# 分库算法 db-inline
db-inline:
# 算法类型:INLINE 行内表达式,Groovy语法
type: INLINE
props:
# 路由表达式 user_id % 2,结果0→ds_0,1→ds_1
algorithm-expression: ds_${user_id % 2}
# 分表算法 tbl-mod
tbl-mod:
# 内置取模MOD算法
type: MOD
props:
# 表总数,user_id % 4 得到0~3表编号
sharding-count: 4
# ---- 分布式主键生成器配置 ----
keyGenerators:
snowflake:
# 算法类型:雪花算法
type: SNOWFLAKE
props:
# 工作节点ID,集群内必须唯一,重复会ID冲突
worker-id: 123
# 时钟回拨最大偏移量,单位ms,回拨不超过该值可以等待,超过抛异常
max-vibration-offset: 1
# ============ 3. ShardingSphere 全局属性 ============
props:
# 是否打印SQL日志:打印逻辑SQL + 真实分片SQL;生产环境关闭,防止日志爆炸
sql-show: true
# 单条查询,单个数据库上允许开启的最大并行连接数,默认1串行,调大加快多分片查询
max-connections-size-per-query: 4
# 分片内核并行查询线程池大小,多分片并行查询使用
kernel-executor-size: 16
# 启动时是否校验所有分片表元数据(字段、结构)一致;调试可以false,生产建议true
check-table-metadata-enabled: false
3.3 建表 SQL(真实表)
sql
-- 在 ds_0、ds_1 两个库各执行一遍,创建 t_order_0 ~ t_order_3
CREATE TABLE t_order_0 (
order_id BIGINT NOT NULL COMMENT '订单ID(雪花)',
user_id BIGINT NOT NULL COMMENT '用户ID(分片键)',
order_no VARCHAR(64) NOT NULL COMMENT '订单号(业务唯一)',
status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态',
amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '订单金额',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (order_id),
KEY idx_user_time (user_id, create_time), -- 分片键必须进索引
KEY idx_ctime (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表-分片0';
注意三点:
- 不要建
AUTO_INCREMENT自增主键,主键由 Snowflake 生成(见 5.4)。 KEY idx_user_time (user_id, create_time)必须有------分片键让 SQL 落到单分片,联合索引让单分片内也走索引,两者缺一不可。t_order_1/2/3结构完全一致,生产用脚本批量建,别手敲。
3.4 业务代码:几乎零侵入
java
@Mapper
public interface OrderMapper {
/**
* 带分片键 → 单分片路由,性能最优
*/
@Select("SELECT * FROM t_order WHERE user_id = #{userId} AND order_id = #{orderId}")
Order findByUserAndOrderId(@Param("userId") Long userId, @Param("orderId") Long orderId);
/**
* 带分片键 + 排序分页 → 单分片路由,安全
*/
@Select("SELECT * FROM t_order WHERE user_id = #{userId} ORDER BY create_time DESC LIMIT #{offset}, #{size}")
List<Order> pageByUser(@Param("userId") Long userId, @Param("offset") int offset, @Param("size") int size);
/**
* ⚠️ 不带分片键 → 全路由!生产环境禁止
*/
@Select("SELECT * FROM t_order WHERE order_no = #{orderNo}")
Order findByOrderNo(@Param("orderNo") String orderNo);
}
这就是 ShardingSphere-JDBC 最大的卖点 :代码里写的还是 t_order,DAO 层零改造,分片逻辑全在配置里。代价 是开发容易忘记分片键的存在,写出全路由 SQL------所以必须靠 SQL 审计兜底(见坑 1)。
3.5 5.x 与 4.x 配置差异(老项目升级必看)
| 4.x(旧) | 5.x(新) |
|---|---|
spring.shardingsphere.sharding.tables.t_order.actual-data-nodes |
spring.shardingsphere.rules.sharding.tables.t_order.actualDataNodes |
spring.shardingsphere.sharding.default-database-strategy |
无 default,每张表必须显式配置 |
sharding-column |
shardingColumn |
| 规则平铺 | 统一收进 rules: - !SHARDING 列表 |
面试加分 :提一句"5.x 把配置从
sharding.*收进了rules列表,并用 YAML 的!SHARDING标签区分多种规则(分片 / 读写分离 / 加密 / 影子库)",说明你真的升级过。
四、分片键选型:整个方案的生死线
这一章是全文最值钱的部分。 分片键选错,后面所有优化都是补救;选对了,80% 的问题根本不会出现。
4.1 五条选型原则(按优先级排序)
原则一:覆盖最高频的查询维度(最高优先级)
分片键的第一职责是让绝大多数查询落到单分片 。唯一标准:你的 80% 查询里 WHERE 条件都带哪个字段?
判断方法(非常实操):
- 打开慢查询日志 / APM,统计近 30 天访问量 Top 20 的 SQL;
- 把它们 WHERE 条件里的字段列出来做词频统计;
- 出现频率最高的那个,就是分片键候选。
常见错误:按"数据看起来最均匀"选分片键 (比如选
create_time),结果所有查询都不带时间条件,全部全路由,系统直接雪崩。
原则二:数据分布均匀,不能有热点
以用户维度分片时,如果系统里有"大客户"(一个商家 ID 占 30% 订单),那个分片会单独过热。检测方法(上线前必须用真实数据跑):
sql
-- 倾斜检测:最大分片 / 平均分片 应 < 1.5
SELECT user_id % 64 AS shard, COUNT(*) AS cnt
FROM t_order GROUP BY user_id % 64
ORDER BY cnt DESC LIMIT 10;
原则三:值不可变(Immutable)
分片键一旦写入,绝对不能 UPDATE。 因为 UPDATE t_order SET user_id = ? WHERE order_id = ? 会导致数据需要跨分片迁移,ShardingSphere 默认禁止更新分片键(直接抛异常)。
新手常问"用户换手机号了怎么办"------所以手机号、邮箱、状态、时间这类可变的字段,天然不适合做分片键。
原则四:基数足够大,且类型稳定
- 基数太小:按
status(0~9)分片,最多只能分 10 片,没有扩展空间; - 类型要稳定:分片键类型一旦定成 BIGINT,就不要在代码里一会儿传
long一会儿传String,否则路由结果不一致(见坑 12)。
原则五:尽量让 JOIN / 事务发生在同一分片
如果 t_order 和 t_order_item 用同一个分片键且规则一致,那么"查订单 + 明细"就是单库 JOIN,还能用本地事务。这就是绑定表的意义。
4.2 常见分片键候选对比
| 候选分片键 | 优点 | 致命缺点 | 适合场景 |
|---|---|---|---|
| user_id | 天然均匀;用户维度查询全部单分片 | 按订单号查、商家查会全路由 | C 端订单、购物车、地址、消息 |
| order_id | 订单维度点查快 | "我的订单"列表必然全路由 | 按单号查询为主的后台系统 |
| merchant_id / shop_id | 商家维度查询友好 | 严重倾斜(大商家订单占比极高) | B 端 SaaS,需配合热点处理 |
| tenant_id | 多租户天然隔离 | 租户规模差异大 → 倾斜;单租户超大时无法再拆 | SaaS(中小租户) |
| create_time | 天然按时间归档,冷热分明 | 当前分片是绝对热点;跨月查询全路由 | 日志、流水、监控数据 |
| order_no(字符串) | 业务单号查询友好 | 字符串 hash 成本高;分布依赖单号生成规则 | 一般不推荐单独用 |
| 地区 / geo | 数据合规隔离 | 地区业务量差异大 | 有合规要求的全球化业务 |
4.3 各业务场景的分片键推荐(速查表)
| 业务 | 推荐分片键 | 备注 |
|---|---|---|
| 电商订单 | user_id + 订单号基因法 |
用户查自己订单是最高频 |
| 支付流水 | user_id 或 account_id |
同一账户交易要能同库聚合 |
| 用户中心 | user_id |
单分片点查,简单 |
| 商品 | shop_id 或 item_id |
读多写少,缓存挡前面,分片需求弱 |
| 库存 | sku_id |
扣库存要强一致,同分片内本地事务 |
| 物流/运单 | user_id + 运单号基因法 |
与订单同理 |
| 社交关注/粉丝 | user_id |
双向关系需冗余两份 |
| IM 消息 | conversation_id |
同一会话消息要同分片才能顺序拉取 |
| 操作日志/审计 | create_time(按月分表)+ 归档 |
天然冷热分离,写多读少 |
| 物联网时序数据 | device_id + 时间分表 |
也可直接上 TDengine / InfluxDB |
| SaaS 多租户 | tenant_id |
大租户单独拆库 |
4.4 多维查询怎么办:四种解法(面试必考)
核心矛盾 :按 user_id 分片后,用户查"我的订单"很快,但客服按 order_no 查、商家按 shop_id 查,全部全路由。
解法一:异构索引表(最常用,大厂标配)
思路:空间换时间。额外建一张索引表,用另一个维度做分片键,只存"维度 → 主键 + 分片键"的映射。
t_order(按 user_id 分片,8 个分片) ← 主表,全量字段
t_order_index_orderno(按 order_no 分片) ← 索引表,只有 (order_no, user_id, order_id)
t_order_index_shop(按 shop_id 分片) ← 索引表,只有 (shop_id, user_id, order_id)
查询流程(两次查询,但都是单分片):
java
// 1. 先查索引表(单分片),拿到分片键
OrderIndex idx = indexMapper.selectByOrderNo(orderNo);
// 2. 再带分片键查主表(单分片)
Order order = orderMapper.findByUserAndOrderId(idx.getUserId(), idx.getOrderId());
写入时保证主表和索引表一致(这是难点):
| 方案 | 一致性 | 复杂度 |
|---|---|---|
| 应用内双写(同一事务) | 强一致 | 简单,但代码耦合 |
| MQ 异步写索引表 | 最终一致(秒级) | 推荐,需处理幂等 + 补偿 |
| Canal 监听 binlog 构建索引 | 最终一致 | 解耦最彻底,运维成本高 |
面试追问:"索引表写失败了怎么办?" → ① MQ 重试 + 死信告警;② 定时任务对账(按时间窗口扫描主表补索引);③ 查询降级:索引表查不到时,允许有限度的全路由兜底(必须限流)。
解法二:基因法(面试加分项,无冗余表)
原理 :把 user_id 的"基因"(低 N 位)嵌入 order_id 末尾,使得:
order_id % 2^N == user_id % 2^N
这样用 order_id 查和用 user_id 查,会落到同一个分片,无需索引表。
推导过程(假设分片数 = 8 = 2^3,基因长度 N = 3 bit):
1. 取基因: gene = user_id & (2^3 - 1) = user_id & 7
2. 生成 ID: order_id = (雪花ID << 3) | gene
3. 分库分表: shard = order_id & 7 = gene = user_id & 7 ← 两者一致
代码实现:
java
/**
* 基因法分片 ID 生成器
* 分片数必须是 2 的幂,GENE_BITS = log2(分片数)
*/
public final class GeneIdGenerator {
private static final int SHARD_COUNT = 8; // 分片数
private static final int GENE_BITS = 3; // log2(8)
private static final long GENE_MASK = SHARD_COUNT - 1L; // 7
private final Snowflake snowflake;
/** 根据 userId 生成带基因的 orderId */
public long nextOrderId(long userId) {
long gene = userId & GENE_MASK; // 步骤1:取基因
long seq = snowflake.nextId(); // 步骤2:雪花 ID
return (seq << GENE_BITS) | gene; // 步骤3:基因嵌入低位
}
/** 由 userId 算分片 */
public int shardOfUser(long userId) { return (int) (userId & GENE_MASK); }
/** 由 orderId 算分片(结果与上面一致) */
public int shardOfOrder(long orderId) { return (int) (orderId & GENE_MASK); }
}
验证:
user_id = 1001 → 1001 & 7 = 1 → 分片 1
order_id = (雪花 << 3) | 1 → order_id & 7 = 1 → 分片 1 ✅
基因法的适用边界(必须知道,否则面试被追问就露馅):
| 优点 | 缺点 |
|---|---|
| 不需要额外表,无冗余、无一致性问题 | 分片数只能是 2 的幂,扩容必须翻倍(8→16) |
| 两个维度查询都走单分片 | 只能解决两个维度,第三个维度(商家)还得靠索引表 |
| 实现简单,纯计算 | order_id 可用位数少几位,且 ID 含业务含义 |
面试坑点:问"基因法有什么限制?"如果只答"好用"不答"分片数必须 2 的幂 + 只能解决两个维度",说明没真正落地过。
解法三:搜索引擎兜底(ES / Solr)
多维度复杂查询(模糊搜索、多维聚合、排序组合)交给 Elasticsearch:
MySQL(分片) --Canal 同步--> Elasticsearch(全量索引)
写 / 点查 复杂检索 / 聚合 / 深分页
注意:ES 是最终一致(秒级),不适合强一致场景;ES 的数据只能用于"查询",不能用于"扣款"这类写操作。
解法四:Hint 强制路由
当 SQL 里实在没法带分片键时,用代码显式指定分片:
java
try (HintManager hint = HintManager.getInstance()) {
hint.addDatabaseShardingValue("t_order", 1); // 指定库
hint.addTableShardingValue("t_order", 3); // 指定表
List<Order> list = orderMapper.selectByComplexCondition(condition);
}
// ⚠️ HintManager 基于 ThreadLocal,必须 close!否则线程复用会污染后续查询(见坑 11)
4.5 热点数据怎么处理
分片再均匀,也挡不住"一个大客户占 20% 流量"的现实。三种主流解法:
| 方案 | 做法 | 适用 |
|---|---|---|
| 加盐打散 | 分片键 = hash(tenant_id + "_" + (seq % 10)),大租户拆到 10 个分片 |
大租户写压力分散 |
| 热点单独建库 | 识别 Top N 大客户,单独部署独立库,配置特殊路由规则 | 大客户少、可控 |
| 二级分片 | 大客户的表再按 order_id 二次分片 |
复杂,一般不用 |
4.6 分片键选型自检清单(上线前逐条打勾)
- Top 20 高频 SQL 中,是否 ≥80% 带这个分片键?
- 用真实数据跑过倾斜检测,最大分片 / 平均分片 < 1.5?
- 分片键字段业务上永不变更?
- 分片键字段类型全局统一(都是 Long 或都是 String,不能混)?
- 主子表是否用同一分片键 + 同一规则(可配绑定表)?
- 不支持分片键的查询,是否已规划索引表 / ES / Hint 方案?
- 分片数是否为 2 的幂(为将来翻倍扩容留路)?
- 是否已建立全路由 SQL 的监控告警?
五、分片算法与分布式主键
5.1 内置分片算法对比
| 算法类型 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| MOD | 取模 sharding_value % count |
分布最均匀 | 扩容需迁移大量数据 |
| HASH_MOD | 先 hash 再取模,支持字符串 | 支持非数值分片键 | 扩容同上 |
| INLINE | Groovy 表达式,最灵活 | 一行搞定,可做复合逻辑 | 表达式复杂时难维护 |
| COSID_MOD | 基于 CosId 的分片取模 | 分片数变更友好 | 额外依赖 |
| VOLUME_RANGE | 按容量范围(0~1000万) | 直观 | 需预估容量,易倾斜 |
| BOUNDARY_RANGE | 按自定义边界点 | 边界可控 | 边界需人工维护 |
| AUTO_INTERVAL | 按时间自动分片 | 自动扩片 | 时间分片的冷热问题 |
| INTERVAL | 时间区间 + 后缀格式 | 按月分表神器 | 跨月查询全路由 |
| CLASS_BASED | 自定义 Java 类 | 完全可控 | 需写代码 + SPI 注册 |
5.2 常用配置示例
yaml
shardingAlgorithms:
# MOD:数值分片键取模
tbl-mod:
type: MOD
props:
sharding-count: 4 # t_order_0 ~ t_order_3
# HASH_MOD:字符串分片键
tbl-hash:
type: HASH_MOD
props:
sharding-count: 8
# INLINE:复合逻辑(大客户单独分片)
db-inline:
type: INLINE
props:
algorithm-expression: ds_${user_id in [1,2,3] ? 2 : user_id % 2}
# INTERVAL:按月分表
log-interval:
type: INTERVAL
props:
datetime-pattern: 'yyyy-MM-dd HH:mm:ss'
datetime-lower: '2024-01-01 00:00:00'
datetime-upper: '2026-12-31 23:59:59'
sharding-suffix-format: 'yyyyMM'
datetime-interval-amount: 1
datetime-interval-unit: 'MONTHS'
时间分表对应的表配置:
yaml
tables:
t_log:
actualDataNodes: ds_0.t_log_${202401..202612}
tableStrategy:
standard:
shardingColumn: create_time
shardingAlgorithmName: log-interval
5.3 自定义分片算法(复合分片 / 范围路由)
当你需要"分片键缺失时用另一个字段兜底"或"范围条件精确路由"时,用 CLASS_BASED:
java
/**
* 复合分片:优先用 user_id 精确路由,其次处理范围条件,兜底全路由
*/
public final class OrderComplexSharding implements ComplexKeysShardingAlgorithm<Long> {
private static final int SHARD_COUNT = 8;
@Override
public Collection<String> doSharding(Collection<String> availableTargetNames,
ComplexKeysShardingValue<Long> shardingValue) {
// 1) 等值条件:精确取模
Collection<Long> userIds =
shardingValue.getColumnNameAndShardingValuesMap().get("user_id");
if (userIds != null && !userIds.isEmpty()) {
return userIds.stream()
.map(id -> "t_order_" + (id % SHARD_COUNT))
.filter(availableTargetNames::contains)
.collect(Collectors.toSet());
}
// 2) 范围条件:这里只做示例,真实场景应计算最小分片集合
Range<Long> range = shardingValue.getColumnNameAndRangeValuesMap().get("user_id");
if (range != null) {
return availableTargetNames;
}
// 3) 兜底:全路由(生产应打点告警)
return availableTargetNames;
}
@Override public Properties getProps() { return new Properties(); }
@Override public void init(Properties props) { }
}
SPI 注册文件 META-INF/services/org.apache.shardingsphere.sharding.spi.ShardingAlgorithm:
com.example.sharding.OrderComplexSharding
配置引用:
yaml
shardingAlgorithms:
order-complex:
type: CLASS_BASED
props:
strategy: COMPLEX
algorithmClassName: com.example.sharding.OrderComplexSharding
5.4 分布式主键:为什么不能用自增 ID
三个致命问题:
- 冲突 :
ds_0.t_order_0和ds_1.t_order_0各自自增,主键必然重复; - 无法路由:插入时还不知道主键,就没法用主键做分片键(鸡生蛋问题);
- 可预测 :
/order/1、/order/2暴露业务量,有安全风险。
方案对比:
| 方案 | 有序 | 长度 | 优点 | 缺点 |
|---|---|---|---|---|
| Snowflake 雪花 | 趋势有序 | 8 字节 | 无中心、性能高、趋势递增(索引友好) | 时钟回拨会重复;依赖机器时钟 |
| UUID | ❌ 无序 | 36 字符串 | 简单,无中心 | 主键无序 → 频繁页分裂 → 写入暴跌;占空间 |
| 数据库号段(Leaf-segment) | 有序 | 8 字节 | 实现简单,ID 连续 | 依赖 DB 可用性;号段耗尽有尖刺 |
| Redis INCR | 有序 | --- | 简单 | 依赖 Redis 持久化;性能一般 |
为什么 UUID 做主键特别糟 :InnoDB 主键是聚簇索引,数据按主键顺序物理存储。UUID 无序 → 每次插入都要在 B+Tree 中间找位置 → 页分裂、碎片、Buffer Pool 命中率下降。实测同一张表,UUID 主键比自增主键的插入 TPS 低 30~50%,表体积大 1 倍以上。
折中:如果必须用 UUID,用 UUID v7 (时间有序)或 MySQL 8 的
UUID_TO_BIN(uuid(), 1)(交换时间位),并存为BINARY(16)。
Snowflake 时钟回拨及应对(面试高频追问):
场景:服务器时钟被 NTP 回拨 / 人工改时间 / 虚拟机挂起恢复
后果:生成与之前相同的 ID → 主键冲突 → 插入失败
应对:
① 启动时校验:上次生成时间戳 > 当前时间 → 拒绝启动并告警
② 小回拨(< 100ms):自旋等待时钟追上
③ 大回拨:抛异常 + 告警,或切换到备用 worker-id 段
④ 不要禁用 NTP,改用「渐进式校时」而非跳变
ShardingSphere 的 SNOWFLAKE 配置:
yaml
keyGenerators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 123
max-vibration-offset: 1
max-tolerate-time-difference-milliseconds: 10
生产建议 :worker-id 不能写死(多实例部署会重复)。用 ZK / Redis / K8s StatefulSet 序号动态分配,或改用美团 Leaf 这类独立发号服务。
六、什么场景该用,什么场景千万别用
6.1 ✅ 该上分库分表的六判据
满足任意两条以上,才值得动手:
| # | 判据 | 量化参考 |
|---|---|---|
| 1 | 单表数据量 | > 2000 万行 或 > 30GB(宽表阈值更低) |
| 2 | 数据增长速度 | 年增长超过现有容量,且没有有效归档手段 |
| 3 | 写压力 | 写 TPS 持续接近单库上限(如 > 3000 TPS) |
| 4 | 连接数 | 应用实例数 × 连接池大小 逼近 max_connections |
| 5 | 备份 / DDL 窗口 | 单表备份 > 2 小时,加字段 DDL 不可接受 |
| 6 | 业务隔离要求 | 多租户需要物理隔离资源(合规 / 大客户要求) |
典型该用的场景:电商订单、支付流水、物流运单(只增不减,C 端高频按用户查);用户中心(千万级,点查为主);IM 消息、评论、动态(写多,按会话/用户聚合);物联网/监控埋点;SaaS 多租户。
6.2 ❌ 千万别用分库分表的九种情况
这是面试反向挖坑的重灾区,也是最能体现你真做过项目的部分:
| # | 场景 | 为什么不该用 | 应该怎么做 |
|---|---|---|---|
| 1 | 单表数据量其实不大(< 500 万) | 复杂度远超收益 | 别分,先看索引 |
| 2 | 慢是因为 SQL 烂 / 没索引 | 分片后烂 SQL 变 N 份,更慢 | 优化 SQL + 索引 |
| 3 | 读多写少,只是读压力大 | 分库分表成本高 | 读写分离 + 缓存,性价比高 10 倍 |
| 4 | 强一致事务要求极高(金融核心账务) | 跨库 XA 性能差、复杂度爆炸 | 垂直拆分 + 单元化,或上分布式数据库 |
| 5 | 需要大量跨维度复杂查询(BI 报表) | 分片后全路由,性能灾难 | 同步到 OLAP(ClickHouse/Doris)或 ES |
| 6 | 需要频繁 JOIN 多张大表 | 跨库 JOIN 归并到内存,极易 OOM | 冗余字段 / 宽表 / 分析库 |
| 7 | 团队没有运维能力 | 分片后排障难度指数级上升 | 先分区表 / 归档;或直接买云上分布式数据库 |
| 8 | 业务逻辑频繁变化、还在验证期 | 分片键一旦定下很难改 | 先单体快速迭代,稳定后再拆 |
| 9 | 数据量增长可预期且有终点 | 拆了就回不去了 | 归档 + 分区表足够 |
6.3 分区表 vs 分库分表 vs NewSQL(面试高频对比题)
| 维度 | MySQL 分区表 | 分库分表(ShardingSphere) | NewSQL(TiDB / PolarDB-X / OceanBase) |
|---|---|---|---|
| 对应用透明 | ✅ 完全透明 | ❌ 需关注分片键 | ✅ 完全透明 |
| 跨分片查询 | ✅ MySQL 内部处理 | ⚠️ 中间件归并,能力有限 | ✅ 分布式执行引擎 |
| 分布式事务 | ✅ 本地事务 | ⚠️ 需 XA / Seata | ✅ 原生支持 |
| 单表容量上限 | ❌ 受单实例限制 | ✅ 水平扩展 | ✅ 水平扩展 |
| 连接数扩展 | ❌ 不解决 | ✅ 分库解决 | ✅ 解决 |
| 运维复杂度 | 低 | 高 | 中(需专业团队) |
| 全局唯一索引 | ✅ 原生支持 | ❌ 需自行保证 | ✅ 支持 |
| 改造成本 | 极低 | 高 | 中(迁移 + SQL 兼容测试) |
选型判断:
单表大,但总数据量 < 单机容量上限?
└─ 是 → 分区表 + 归档(成本最低)✅
└─ 否 → 团队有分布式库运维能力吗?
├─ 有 → NewSQL(TiDB / PolarDB-X)✅
└─ 没有 / 已有 Java 技术栈想渐进改造 → ShardingSphere-JDBC ✅
6.4 分库分表 vs 读写分离:别搞混
| 解决什么 | 不解决什么 | |
|---|---|---|
| 读写分离 | 读压力分摊到多个从库 | 写压力、单表容量、主从延迟 |
| 分库分表 | 单表容量 + 写压力 + 连接数 | 单条烂 SQL、主从延迟 |
两者可以叠加:ShardingSphere 同时支持 !SHARDING 和 !READWRITE_SPLITTING 规则。先做读写分离(成本低),不够再分片。
七、生产环境必踩的 20 个坑
每个坑按「现象 → 原因 → 解决」三段式写。这些基本都来自真实生产事故。
坑 1:不带分片键 → 全路由雪崩
现象:上线后 DB CPU 突然打满,慢查询日志里一条 SQL 出现了 8 次(8 个分片)。
原因 :WHERE 里没有分片键,ShardingSphere 只能把 SQL 广播到所有分片再内存归并。1 次查询变成 N 次,QPS 被放大 N 倍。
解决 : 1. 开启 sql-show: true,在测试环境跑一遍所有 SQL,导出路由日志找出全路由 SQL; 2. 在编译期或启动期用 SQL 拦截器拦截全路由 SQL; 3. 兜底:为全路由 SQL 设置独立的限流和超时,避免拖垮整个库。
坑 2:深分页 → 归并内存爆炸
现象 :LIMIT 1000000, 10 直接 OOM 或超时。
原因 :如 2.4 所述,跨分片分页必须把 offset + size 下推到每个分片,各分片各取 1000010 行,中间件内存归并排序。
解决(四种,按推荐度排序):
sql
-- 方案1(最推荐):游标 / 标签记录法,禁止跳页
SELECT * FROM t_order
WHERE user_id = ? AND order_id < #{lastOrderId}
ORDER BY order_id DESC LIMIT 10;
-- 带分片键 + 主键游标 → 单分片 + 走索引,毫秒级
-- 方案2:子查询先分页主键,再回表查详情
SELECT * FROM t_order
WHERE order_id IN (
SELECT order_id FROM t_order
WHERE user_id = ? ORDER BY create_time DESC LIMIT 1000000, 10
);
-- 方案3:二次查询法(先取各分片 id 范围,再精确定位)
-- 方案4:深分页交给 ES(search_after 更合适)
坑 3:跨库 JOIN 与子查询支持度有限
现象 :t_order JOIN t_order_item 报不支持,或结果集不对。
原因:跨分片 JOIN 需要中间件拉取两边数据到内存做 Hash Join;ShardingSphere 对复杂子查询(子查询里再带分片表 + 聚合)支持有限。
解决 : 1. 配置绑定表 :主子表同分片键同规则,JOIN 下推单分片(性能等同单库 JOIN); 2. 业务层拆两次查询 :先查主表拿 ID 列表,再 IN 查子表; 3. 冗余字段:把子表常用 2~3 个字段冗余到主表。
坑 4:唯一索引失效
现象 :两个分片各插入一条 order_no = 'X001',全局重复了。
原因 :MySQL 的 UNIQUE KEY 只在单张物理表内生效,跨分片完全无约束。
解决:
| 方案 | 一致性 | 说明 |
|---|---|---|
| 唯一键里带分片键 | 强 | UNIQUE(order_no, user_id),仅保证同一用户内唯一 |
| 全局唯一映射表 | 强 | 建按 order_no 分片的 unique_order_no 表,插入前置检查 |
| 分布式锁(Redis) | 强 | 插入前抢占 lock:orderno:X001 |
| 业务侧保证 ID 不重复 | 强 | 订单号由发号器生成,天然不重复 |
| 事后对账 | 最终 | 定时任务扫描检测并告警 |
坑 5:分片键不可 UPDATE
现象 :UPDATE t_order SET user_id = 200 WHERE order_id = 1 抛异常(分片值不允许变更)。
原因:更新分片键意味着数据要跨分片搬迁,ShardingSphere 不支持。
解决 :业务上禁止;确有需求(账号合并),走删除 + 重新插入(注意事务),并同步处理索引表。
坑 6:分布式事务
现象:跨分片更新出现部分成功、部分失败。
| 方案 | 一致性 | 性能 | 说明 |
|---|---|---|---|
| 避免跨分片事务(最佳) | 强 | 最优 | 通过分片键设计让同一事务写入落在同一分片 |
| XA(2PC) | 强 | 差(约本地事务 1/5) | ShardingSphere 内置 Atomikos/Narayana |
| Seata AT | 最终/准强 | 中 | 需 Seata Server + undo_log 表 |
| 本地消息表 / MQ 事务消息 | 最终 | 好 | 业务侵入大但最可控 |
| Saga / TCC | 最终 | 中 | 长事务场景 |
java
@Transactional
@ShardingSphereTransactionType(TransactionType.XA)
public void transfer() {
orderMapper.insert(...); // 落在 ds_0
accountMapper.update(...); // 落在 ds_1
}
面试金句 :"分布式事务的正确解法是尽量避免分布式事务------通过分片键设计把关联数据放同一分片。"
坑 7:自增主键冲突
现象 :插入报 Duplicate entry '1' for key 'PRIMARY'。
原因 :多张真实表各自 AUTO_INCREMENT。
解决:用 Snowflake(见 5.4)。存量自增表改造时,给每个分片设不同起始值和步长:
sql
-- 库级别:步长 4,各分片 offset 不同
SET GLOBAL auto_increment_increment = 4;
SET GLOBAL auto_increment_offset = 1; -- ds_0 用 1,ds_1 用 2 ...
坑 8:扩容数据迁移(最痛的一个)
现象 :从 4 个分片扩到 8 个,hash % 4 变 hash % 8,几乎所有数据都要重分布。
| 方案 | 停机 | 迁移量 | 复杂度 |
|---|---|---|---|
| 停机迁移 | 需要(小时级) | 全量 | 低 |
| 翻倍扩容(4→8) | 短 | 只迁移 50% | 中 |
| 双写迁移(推荐) | 无需停机 | 全量 + 增量 | 高 |
双写迁移标准流程(面试必背):
① 部署新分片集群(8 片),上线「双写」:所有写操作同时写旧(4片)和新(8片)
② 数据迁移:全量历史数据 dump 到新集群 + 实时增量(Canal 追 binlog)
③ 一致性校验:抽样比对 + 全量 checksum(行数、金额 SUM)
④ 双读切换:读新集群并对比新旧结果(灰度,出问题立刻回滚读旧)
⑤ 观察 1~2 周无异常 → 停旧写 → 下线旧集群
关键前提:分片数规划为 2 的幂,从 8 扩到 16 只需迁移一半数据。
坑 9:SQL 语法支持度(上线前必须实测)
ShardingSphere 不是 MySQL,它是解析器 + 改写器 。以下语法在高版本支持度不一,必须逐条实测:
| 语法 | 风险 |
|---|---|
INSERT INTO ... SELECT |
跨分片时支持有限 |
INSERT ... ON DUPLICATE KEY UPDATE |
需保证分片键不在更新列中 |
UNION / UNION ALL |
跨库归并支持有限 |
SELECT ... FOR UPDATE |
分布式锁语义需谨慎 |
| 子查询中再带聚合 / LIMIT | 改写复杂,易出错 |
| 存储过程、函数、触发器、视图 | 基本不支持或支持有限 |
CASE WHEN、复杂 HAVING |
部分场景改写失败 |
DISTINCT + ORDER BY 组合 |
归并需要列在 SELECT 中 |
LOAD DATA、REPLACE INTO |
支持有限 |
落地做法 : 1. 把项目里所有 SQL 抽出来,在分片环境跑一遍全量回归 ; 2. 开启 sql-show,人工抽查改写后的 SQL 是否符合预期; 3. 以官方文档 《SQL 支持度》 章节为准(按实际版本核对)。
坑 10:结果归并 OOM
现象:聚合/排序大结果集时中间件 OOM。
原因:内存归并(分组归并、聚合归并)会把所有分片结果加载进内存。
解决 : 1. max-connections-size-per-query 控制并发(默认 1 = 串行安全但慢,调大 = 并行但吃连接); 2. 必须加 LIMIT,禁止无限制聚合查询; 3. 大结果集走流式归并(纯排序场景 ShardingSphere 会用流式归并); 4. 报表类需求全部走 OLAP,不要打在主库。
坑 11:Hint 的 ThreadLocal 泄漏
现象:偶发查询路由到错误分片,难以复现。
原因 :HintManager 基于 ThreadLocal ,没 close(),线程归还线程池后 Hint 值残留污染下一个请求。
解决:
java
// ✅ 正确:try-with-resources
try (HintManager hint = HintManager.getInstance()) {
hint.addTableShardingValue("t_order", 1);
return mapper.query();
}
// ❌ 错误:泄漏
HintManager.getInstance().addTableShardingValue("t_order", 1);
return mapper.query();
坑 12:分片键类型不一致导致路由错误
现象:同样的订单,有时查得到有时查不到。
原因 :分片键是 BIGINT,但某处 MyBatis 传了 String("1001")。ShardingSphere 对字符串和数值的 hash/取模结果不同 → 路由到不同分片。
解决 :全局统一分片键类型(推荐 Long);实体类/Mapper/DTO 都用 Long;字符串分片键统一用 HASH_MOD 而非 INLINE。
坑 13:广播表的写入一致性
现象:字典表更新后,某些分片还是旧值。
原因 :广播表需要在所有分片执行写操作,跨库一致性依赖分布式事务。
解决:字典表更新属低频操作,更新后清缓存 + 校验各分片行数;强一致场景上 XA。
坑 14:绑定表没配 → 笛卡尔积爆炸
现象 :t_order JOIN t_order_item 本应 8 次查询,实际执行 64 次(8×8)。
原因 :没配 bindingTables,ShardingSphere 认为两表分片规则无关,做笛卡尔积路由。
解决:
yaml
bindingTables:
- t_order,t_order_item # 必须同一分片键 + 同一分片算法
坑 15:COUNT 总数性能
现象 :分页查询的 COUNT(*) 特别慢。
原因:跨分片 COUNT 要在所有分片执行后求和,无法避免。
解决 :① 缓存总数(Redis 计数异步更新);② 用近似值(ES 的 total.hits,或超 10000 显示 "10000+");③ 分页改造为"无限下拉",不要总数。
坑 16:ORDER BY / GROUP BY 字段不在 SELECT 中
现象:报错或排序结果不对。
原因 :归并阶段需要通过列名或列位置定位排序字段。
解决:把排序/分组字段显式写进 SELECT 列表。
sql
-- ❌ 可能出错
SELECT order_id FROM t_order ORDER BY create_time DESC;
-- ✅ 正确
SELECT order_id, create_time FROM t_order ORDER BY create_time DESC;
坑 17:连接池配置爆炸
现象 :MySQL 报 Too many connections。
原因 :ShardingSphere 为每个数据源 创建独立连接池。总连接数 = 应用实例数 × 分片库数 × 每库最大连接数。
例:20 个应用实例 × 2 个库 × 20 连接 = 800 个连接
解决 :① 每库 maximum-pool-size 调到 8~15(不要 50);② 控制应用实例数,或改 Proxy 模式;③ 监控连接数,设置告警阈值;④ max-connections-size-per-query 保持小值。
坑 18:时间分片的冷热不均
现象:按月分表后,当前月的表写入量和锁竞争是历史表的几十倍。
原因:所有写入都落在最新分片,历史分片闲置 → 资源浪费严重。
解决 :① 冷热分离,历史分片迁入归档库(低成本存储);② 混合分片,热数据按 user_id hash,冷数据按月归档;③ 监控只盯当前分片。
坑 19:读写分离 + 分片混合时的主从延迟
现象:刚下单成功,立刻查订单列表为空。
原因:写走主库,读走从库,主从延迟导致读不到最新数据。
解决:① 事务内自动路由主库;② Hint 强制主库;③ 关键业务(支付结果、订单创建)写后读强制主库。
java
try (HintManager hint = HintManager.getInstance()) {
hint.setWriteRouteOnly(); // 强制走主库
return mapper.selectById(id);
}
坑 20:DDL 与数据订正
现象:加字段只加了一个分片,其他分片没加 → 上线后部分请求报错。
原因 :ShardingSphere 不管理 DDL ,加字段/改索引需要在每个真实表上执行。
解决 :① 写脚本批量执行(务必遍历所有库所有表,含新增分片);② 用 pt-online-schema-change / gh-ost 做无损 DDL;③ 建立分片元数据一致性巡检任务;④ 数据订正脚本不能改分片键。
加分坑(常被忽略的 3 个)
- 坑 21:
IN列表过大 :WHERE user_id IN (...)有 5000 个值,路由计算耗时长、SQL 超max_allowed_packet→ 分批(每批 ≤ 500)。 - 坑 22:分片键上套函数 :
WHERE DATE(create_time) = ?、WHERE user_id + 0 = ?→ 解析不出分片值 → 全路由。禁止在分片键上套函数或运算。 - 坑 23:慢 SQL 定位困难 :分片后慢日志分散在 N 台机器 → 必须上统一可观测:APM(SkyWalking)+
sql-show+ 各库慢日志汇总。
八、面试必问 30 题 + 高频追问与坑点
组织方式:题目 → 考点 → 标准答法 → 追问/坑点。建议别背答案,背"答题结构"。
A. 基础概念(Q1~Q6)
Q1:为什么要分库分表?单表数据量到多少需要分?
- 考点:是否理解瓶颈本质,还是只会背"500 万"。
- 答法:三层------① 不是因为"行数多",而是 B+Tree 变高 + Buffer Pool 命中率下降;② 还有 DDL/备份窗口、写锁竞争、连接数打满三类瓶颈;③ 阈值:《Java 开发手册》500 万行或 2GB,实操 2000 万左右开始吃力,宽表更早。
- 追问 :"那 3 层 B+Tree 能存 2000 万,为什么还说慢?" → 真正的瓶颈是缓存命中率和 DDL 维护成本,不是单次查询的 IO 次数。
Q2:垂直拆分和水平拆分的区别?
- 答法 :垂直拆的是结构 (按字段/按业务),水平拆的是数据行(按规则)。垂直分库解耦业务、分散连接数;垂直分表让行变窄提升 Buffer 效率;水平分表解决单表索引体积;水平分库才真正扩展 CPU/IO/连接数。
- 坑点 :只说"垂直按业务、水平按数据"太单薄,必须说清各自解决什么瓶颈。
Q3:分库 和 分表 的区别?
- 答法 :只分表不分库,所有分片仍在同一实例,连接数、CPU、磁盘 IO 上限没变,只解决单表索引和锁竞争;分库才能水平扩展资源,但引入跨库 JOIN、分布式事务、跨库归并三大麻烦。
- 坑点:答"分表是表多,分库是库多" → 必挂。
Q4:ShardingSphere-JDBC 和 MyCat 的区别?
| 维度 | ShardingSphere-JDBC | MyCat |
|---|---|---|
| 部署 | 客户端 Jar | 独立中间件 |
| 性能 | 高(无网络跳转) | 多一跳,中间件易成瓶颈 |
| 语言 | 仅 Java | 任意 |
| 运维 | 无独立进程,升级需发版 | 独立运维,有单点风险 |
| 连接数 | 应用实例 × 分片数,会放大 | 由中间件统一管理 |
Q5:Sharding-JDBC 和 ShardingSphere 是什么关系?
- 答法 :Sharding-JDBC 是 3.x 时代的名字,2018 年进入 Apache 后改名 ShardingSphere,4.x 起配置格式大改,5.x 把配置收进
rules列表。ShardingSphere-JDBC是其中面向 Java 的客户端形态。
Q6:ShardingSphere 的 SQL 执行流程?
- 答法:解析 → 路由 → 改写 → 执行 → 归并,五步,每步能举一个例子(见 2.4)。
B. 设计 / 场景题(Q7~Q12)
Q7:分片键怎么选?
- 标准答法(五条):① 覆盖最高频查询维度;② 数据分布均匀无热点;③ 业务上不可变;④ 基数足够大、类型稳定;⑤ 让 JOIN/事务落在同一分片。
- 加分:说出"我会先统计 Top 20 慢 SQL 的 WHERE 字段词频再决定",以及"上线前用真实数据跑倾斜检测"。
Q8:订单表按 user_id 分片,商家要按 shop_id 查怎么办?
- 答法:四种------异构索引表(双写 / MQ / Canal)、基因法、ES 兜底、Hint 强制路由,并说明各自的一致性代价。
- 追问:"索引表写失败怎么办?" → MQ 重试 + 定时对账补偿 + 有限度的全路由兜底(限流)。
Q9:讲讲基因法?
- 答法 :把
user_id & (2^N - 1)的基因嵌入order_id低位,使order_id % 2^N == user_id % 2^N,两个维度都能单分片查询。 - 必答的限制:分片数必须是 2 的幂、扩容需翻倍、只能解决两个维度。
- 坑点:只讲原理不讲限制 = 背书。
Q10:分库分表后跨库 JOIN 怎么处理?
- 答法:① 绑定表(同分片键同规则,JOIN 下推单分片);② 业务层拆两次查询;③ 冗余字段 / 同步到 ES 或 OLAP。
- 追问:"多张大表 JOIN 呢?" → 直说不该这么设计,应走宽表或分析型存储。
Q11:分库分表后非分片键的等值查询怎么办?
- 答法:同 Q8。补充"能接受延迟上 ES;要求强一致用索引表"。
Q12:分库分表能解决慢查询吗?
- 答法 :不能。分库分表解决的是容量、连接数、写压力;单条 SQL 本身慢(没索引、回表多)在分片后会被放大 N 倍,必须先优化 SQL。
- 这是高频"送命题",很多候选人栽在这。
C. 分片算法与 ID(Q13~Q16)
Q13:常见分片算法有哪些?各有什么优缺点?
- 答法:MOD / HASH_MOD / INLINE / RANGE / INTERVAL / CLASS_BASED,并说出各自缺点(MOD 扩容迁移多、RANGE 易倾斜、INTERVAL 适合时间分表)。
Q14:为什么不能用自增 ID 做分布式主键?
- 答法:冲突、无法预先路由、可预测暴露业务量。
- 追问 :"UUID 可以吗?" → 不可以,UUID 无序导致聚簇索引频繁页分裂,写入性能和空间都显著劣化 (实测 TPS 低 30~50%)。要用就用 UUID v7 或
UUID_TO_BIN(uuid(),1)存BINARY(16)。
Q15:雪花算法的原理和坑?
- 原理:64 bit = 1 符号位 + 41 位时间戳 + 10 位 workerId + 12 位序列号,单节点每毫秒 4096 个 ID。
- 坑 :时钟回拨导致 ID 重复;worker-id 需动态分配不能写死。
- 应对:启动校验、小回拨自旋等待、大回拨告警/切 worker-id、NTP 渐进校时。
Q16:分片数为什么建议取 2 的幂?
- 答法 :① 可用位运算
& (n-1)替代取模;② 方便基因法;③ 翻倍扩容只需迁移 50% 数据(4→8→16)。
D. 查询难题(Q17~Q21)
Q17:分库分表后怎么分页?
- 答法 :单分片(带分片键)正常
LIMIT;跨分片深分页有性能灾难,解决:① 游标/标签法(WHERE id < lastId ORDER BY id DESC LIMIT n);② 子查询先分页主键;③ ES 承担;④ 产品侧禁止跳页。
Q18:跨分片的 COUNT / SUM / AVG 怎么归并?
- 答法 :COUNT/SUM 直接累加;AVG 改写成 SUM + COUNT 再相除 ;MAX/MIN 取最值;
COUNT(DISTINCT)需去重后计数,归并复杂、注意精度。
Q19:分库分表后唯一索引怎么保证?
- 答法:MySQL 唯一键只在单物理表生效;方案有唯一键带分片键、全局映射表、分布式锁、发号器保证、事后对账。
Q20:ORDER BY 的字段为什么要写在 SELECT 里?
- 答法:归并阶段需通过列名/列位置定位排序字段,不在 SELECT 中无法定位。
Q21:什么是绑定表和广播表?
- 绑定表:分片规则完全一致的主子表,配置后 JOIN 可下推单分片,避免笛卡尔积(从 8×8=64 次降到 8 次)。
- 广播表:所有分片存全量数据的小表(字典、配置、地区),JOIN 时可在任意分片本地完成;更新需在所有分片执行。
E. 事务、扩容与运维(Q22~Q26)
Q22:分库分表后分布式事务怎么做?
- 答法 :最佳是设计上避免跨分片事务;其次是 XA(强一致、性能差)、Seata AT、本地消息表/MQ 事务消息(最终一致)、TCC/Saga。
- 金句:"分布式事务最好的处理方式,是不产生分布式事务。"
Q23:XA 和 Seata AT 的区别?
| XA(2PC) | Seata AT | |
|---|---|---|
| 一致性 | 强一致 | 最终(有 undolog 回滚) |
| 性能 | 差(锁持有到提交) | 较好(本地事务先提交) |
| 侵入 | 无(ShardingSphere 内置) | 需 Seata Server + undo_log 表 |
| 适用 | 短事务、强一致 | 长事务、容忍最终一致 |
Q24:4 个分片扩到 8 个,怎么做到不停机?
- 答法:双写迁移五步------① 上线双写;② 全量迁移 + Canal 追增量;③ 一致性校验(行数 + checksum);④ 灰度双读对比,可回滚;⑤ 观察后停旧写、下线旧集群。
Q25:怎么把单库平滑迁移到分库分表?
- 答法 :同 Q24。补充:先做只读从库扩容 → 按分片键灰度切流量(先切 1% 用户)→ 逐步放大 → 全量,必须准备回滚开关。
Q26:分库分表后怎么做监控和排查?
- 答法 :①
sql-show打印真实 SQL 与路由结果;② APM(SkyWalking/ARMS)串联;③ 各分片慢日志汇总;④ 全路由 SQL 告警(最关键的指标);⑤ 分片数据量/倾斜度定期巡检。
F. 反向题与实战题(Q27~Q30)
Q27:什么情况下不应该分库分表?(高区分度题)
- 答法:背 6.2 的九种情况,尤其:数据量不大、慢 SQL 没优化、只是读压力大(该读写分离)、需要大量跨维度分析(该上 OLAP)、团队无运维能力。
Q28:分区表、分库分表、NewSQL 怎么选?
- 答法:总量可控且查询模式固定 → 分区表;需水平扩展且是 Java 技术栈想渐进改造 → ShardingSphere;长期大规模且有运维能力 → TiDB/PolarDB-X/OceanBase。
Q29:你们项目里分片键是什么?为什么?遇到过什么问题?
- 这是最能判断真假经验的题,要带数字和事故:
- "订单表用
user_id,因为 80% 查询是用户查自己的订单;分片数 8(2 的幂);订单号用基因法嵌入 user_id 低 3 位,客服按单号查也能单分片;商家维度走 Canal 同步到 ES。" - "踩过的坑:上线第一天有个后台导出接口没带分片键,全路由把 DB 打挂了,后来加了 SQL 审计拦截 + 导出走 ES。"
Q30:分库分表一定能提升性能吗?
- 答法 :不一定 。查询不带分片键时全路由性能反而下降 N 倍;瓶颈是单条 SQL 质量时分片也救不了。分片提升的是容量上限和并发承载能力,单查询延迟只在"带分片键"时才可能降低(索引更小)。
8.1 面试官最爱挖的 10 个坑点(答错即减分)
| # | 坑点 | 错误答法 | 正确姿态 |
|---|---|---|---|
| 1 | "单表 500 万就要分" | 当硬性阈值背 | 说清阈值来源 + 真正瓶颈是命中率/DDL/锁 |
| 2 | "分库分表提升查询性能" | 当性能优化 | 是容量与并发方案,单查询可能更慢 |
| 3 | "用 UUID 做主键" | 图省事 | 指出无序导致页分裂,给 Snowflake / Leaf |
| 4 | "分片键可以随时改" | 不知道限制 | 分片键不可 UPDATE,需业务设计保证 |
| 5 | "扩容就是改个取模数" | 想得太简单 | 讲双写迁移五步 + 2 的幂规划 |
| 6 | "跨库 JOIN 中间件会搞定" | 过度信任中间件 | 讲绑定表 / 拆查询 / 冗余 / ES |
| 7 | "唯一索引没问题" | 不知道失效 | 只在本表生效,需全局方案 |
| 8 | "分布式事务用 XA 就行" | 忽略性能 | 先说避免跨分片事务,再说 XA 代价 |
| 9 | "分片数随便定" | 不定 2 的幂 | 说清 2 的幂便于扩容与位运算 |
| 10 | "我们数据量大了所以分了" | 无决策过程 | 讲决策阶梯:索引→缓存→读写分离→垂直→归档→分片 |
8.2 反向提问:可以反问面试官的 5 个问题(加分)
- "咱们现在单表数据量、日增量和 QPS 大概是多少?"(数据驱动)
- "是否有历史数据归档机制?冷数据占比多少?"(成本意识)
- "分片后 DBA 的运维方式会怎么变?DDL 和数据订正流程是怎样的?"(落地经验)
- "非分片键的多维查询目前靠什么满足?ES 还是索引表?"(全局视角)
- "有没有考虑过直接上 TiDB/PolarDB-X?为什么选 ShardingSphere?"(选型能力)
九、一页纸决策清单
9.1 要不要分?------ 决策树
数据量 > 2000 万 / 写 TPS 打满 / 连接数打满 / 备份窗口不可接受?
│
├─ 否 → 不分。先做:索引优化 → 缓存 → 读写分离 → 归档
│
└─ 是 → 查询模式是否集中(80% 查询都带同一个字段)?
│
├─ 否(多维查询为主)→ 别分片,上 ES / ClickHouse / Doris
│
└─ 是 → 该字段是否不可变、分布是否均匀?
├─ 否 → 重新设计(加冗余映射字段)
└─ 是 → ✅ 可以分片
├─ 分片数取 2 的幂
├─ 主子表配绑定表,字典表配广播表
└─ 先分表,扛不住再分库
9.2 上线前检查清单
| 类别 | 检查项 |
|---|---|
| 分片键 | 覆盖 80% 查询、不可变、类型统一、倾斜度 < 1.5 |
| 分片数 | 2 的幂、预留 4~8 倍增长空间 |
| 主键 | Snowflake,worker-id 动态分配,有回拨防护 |
| SQL | 全量回归 + sql-show 抽查 + 无全路由 SQL |
| 索引 | 分片键必须进联合索引首位 |
| 连接池 | 每库 8~15,算清总连接数上限 |
| 事务 | 跨分片事务清单 + 方案(XA/Seata/消息) |
| 唯一性 | 全局唯一方案已落地 |
| 监控 | 全路由告警、慢 SQL 汇总、分片容量巡检 |
| 回滚 | 双写开关、灰度开关、回滚预案 |
9.3 一句话总结
分库分表的本质,是用"应用层的复杂度"和"一致性代价",换"数据库的水平扩展能力"。 它解决的是容量、连接数、写压力 三件事,不解决烂 SQL、多维分析、主从延迟 。 分片键是唯一的生死线:选对了,后面都是小修小补;选错了,后面全是救火。 真正的架构能力,不在于会不会配置 ShardingSphere,而在于知道什么时候不要用它。
附:最小可用配置速查
yaml
spring:
shardingsphere:
datasource:
names: ds_0,ds_1
ds_0: { ... }
ds_1: { ... }
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds_${0..1}.t_order_${0..3}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: db-mod
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: tb-mod
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
bindingTables: [t_order,t_order_item]
broadcastTables: [t_dict]
shardingAlgorithms:
db-mod: { type: MOD, props: { sharding-count: 2 } }
tb-mod: { type: MOD, props: { sharding-count: 4 } }
keyGenerators:
snowflake: { type: SNOWFLAKE, props: { worker-id: 1 } }
props:
sql-show: true
max-connections-size-per-query: 4
最后一句:如果你正在准备上线分库分表,把第七节的 20 个坑逐条对照一遍,能避开 90% 的事故。觉得有用的话欢迎收藏 + 关注,后续会继续写《ShardingSphere 源码解析:路由引擎是怎么工作的》和《分库分表后不停机扩容实战》。
本文基于 Apache ShardingSphere 5.x,实际版本特性请以官方文档为准。文中分片数量、阈值为工程经验值,落地前请结合自身数据验证。