44.14 分库分表
一句话
分库分表是最后手段:它把「单机容量 / 写入瓶颈」换成「跨分片查询、分布式事务、扩容迁移」三笔长期债;只有当索引、归档、读写分离、缓存、分区表都救不了时才动它,而一旦动了,分片键的选择就决定了后面 90% 的麻烦。
先算门槛:五个信号,五件更便宜的事
| 信号 | 经验阈值 | 为什么是这个数 |
|---|---|---|
| 单表行数 | 2000 万行(行宽 1KB) | 1170 × 1170 × 16 ≈ 2190 万 是 3 层 B+ 树的上限;超过只是多一层,不是性能悬崖(见下) |
| 单库持续写入 | 5000 TPS(含二级索引与 sync_binlog=1) | 再高时 redo/binlog 的 fsync 成为瓶颈 |
| 单库数据量 | 2TB | 全备 + 恢复时间超过 RTO(见 36.1 RTO / RPO) |
| DDL 窗口 | 加索引 / 改表超过业务低峰窗口 | 大表 DDL 重写表 + 复制延迟 |
| 慢查询分布 | Top 10 全在同一张大表,加索引无效 | 说明是数据量问题,不是索引问题 |
读写分离只能扩读、写仍然压在唯一主库上(见 44.13 主从复制与读写分离),所以「写 TPS 撞墙」这个信号出现时,分片才是唯一出路。
2000 万行这个经验值与 B+ 树层数的推导见 44.8 索引原理。
2000 万行是「层数边界」,不是「性能悬崖」
这个数字被传成了一条铁律,但它的原始含义只是「3 层 B+ 树装满了」。按页 16KB、单页可用 14KB、页指针 12 字节、主键 int 算一遍:
| 项 | 计算 | 结果 |
|---|---|---|
| 单个索引页能放的指针数 | 14 × 1024 / 12 | 1194 |
| 单行 0.9KB 时单页放的行数 | 14 / 0.9 | 15 |
| 3 层树的理论容量 | 1194² × 15 | 约 2138 万行 |
| 4 层树的理论容量 | 1194³ × 15 | 约 255 亿行 |
| 单行 5KB 时 3 层树容量 | 1194² × 2 | 约 285 万行 |
(行宽 0.9KB 取自住范儿生产库「订单商品表」的实测均值,与压测用的 1KB 单行接近。)
从 3 层到 4 层,B+ 树只多了两个索引页的读取和一次页修改;上层索引页数量少、几乎常驻 Buffer Pool,所以实测差异是「多 1~2 次比较」,不是「性能断崖」。真正让大表变慢的是另外三件事:单行太宽导致每页放不下几行、局部性失效,以及 DDL 窗口。
所以「2000 万行」应该这样读:
| 判断 | 结论 |
|---|---|
| 单行 ~1KB、以点查为主、索引能覆盖 | 2000 万行以上完全可以继续用,不必分表 |
| 单行 5KB 以上 | 3 层容量掉到 285 万行,先「垂直分表」把大字段挪走,而不是水平分片 |
| 超 2000 万且频繁加索引/改表 | 分表的主要理由是 DDL 窗口,不是查询变慢 |
| 单库写入 > 5000 TPS 或单库 > 2TB | 这才是「必须分片」的信号(见上表) |
🔑 「表大就分」是把一个索引与表宽问题,升级成分布式系统的复杂度。分片的判据永远是「哪个资源先撞墙」,不是「行数到了多少」。
在动分片之前,按这个顺序把更便宜的手段用尽:
| 顺序 | 手段 | 能解决什么 | 成本 |
|---|---|---|---|
| 1 | 索引优化(复合索引、覆盖索引) | 90% 的「慢」 | 小时级 |
| 2 | 归档 / 冷热分离 | 单表行数降到 10% | 天级 |
| 3 | 读写分离 | 读吞吐 | 天级 |
| 4 | 缓存 | 读 QPS 与延迟 | 天级 |
| 5 | 分区表(PARTITION BY) | 单机内的 DDL 与归档 | 天级 |
| 6 | 分库分表 | 写吞吐 + 单库容量 | 月级,且不可逆 |
🔑 判据不是「表多大」,而是「哪个资源先撞墙」:读撞墙 → 缓存 + 从库;写撞墙或单库容量撞墙 → 才轮到分片。按「表大就分」动手,是把一个索引问题升级成分布式系统的复杂度。
四种拆法:先垂直,再水平
分片与分区在扩展性视角下的取舍见 37.3 分片与分区;本节从实现与运维角度展开。
| 拆法 | 做法 | 解决 | 代价 |
|---|---|---|---|
| 垂直分库 | 按业务域拆:订单库 / 用户库 / 商品库 | 单库连接数、IO、故障域 | 跨库 JOIN 变成服务调用;分布式事务 |
| 垂直分表 | 主表 + 扩展表(大字段、低频字段) | 单行过大、热数据被冷数据拖慢 | 两次查询;事务跨表 |
| 水平分库 | 同一张表按分片键拆到多个库 | 写吞吐、单库容量 | 跨库查询、分布式事务、扩容迁移 |
| 水平分表 | 同库内拆成多张同构表 | 单表容量、DDL 时长 | 路由、聚合、跨表分页 |
🔑 垂直拆分的收益常常被低估:按业务域把「订单」和「商品详情」拆到两个库,可能一次性解决 80% 的连接数与 IO 问题,而且几乎不用改 SQL。 先垂直、再水平,是成本最低的路径。
分片键:第一判据不是均匀,是「查询能带上它」
分片键的通用设计方法——基数、倾斜、热点——见 19.1 分区键设计;本节只讲它在「用户 / 订单 / 商家」这类双维度业务里的取舍。
四条判据,按优先级:
| 判据 | 说明 | 反例 |
|---|---|---|
| ① 覆盖率 | 80% 以上的查询能带分片键 | 按 order_id 分片,但 80% 查询是「我的订单」 |
| ② 基数与均匀 | 取值多、分布均匀 | 按「省份」分片,一线城市一个分片扛一半流量 |
| ③ 不可变 | 分片键一旦写入不能改 | 用「用户等级」做分片键,升级就要跨片搬数据 |
| ④ 事务局部性 | 一个事务涉及的数据落在同一片 | 订单与订单明细用不同的分片键 |
| 候选键 | 好处 | 致命问题 |
|---|---|---|
user_id | C 端查询单分片;用户维度天然隔离 | 商家后台、运营报表跨全部分片 |
order_id | 按订单查单分片 | 用户订单列表跨全部分片 |
merchant_id | 商家后台单分片 | C 端用户查询跨全部分片 |
created_at | 范围查询、归档友好 | 写入热点(当前时间片)、跨月查询扫多片 |
自增 id | 均匀 | 业务上不可控,扩容后新 ID 与旧数据分布不同 |
基因法解决「两个维度都要单分片」:
order_id = seq × n + (user_id % n) // 把分片信息编进订单号低位
分片 = order_id % n = user_id % n // 按订单号、按用户,落同一片
代价:订单号不再是纯自增(可读性差、不能直接看出时间);需要保证 n 是 2 的幂,或把「基因位」做成固定宽度(如 seq × 1024 + hash(user_id) % 1024)。
🔑 分片键选错的典型症状:均匀但查询不带它——单表慢查询变成了 8 个分片的慢查询,延迟不变、成本 ×8。
分片算法:按「扩容时搬多少」来选
| 算法 | 路由 | 扩容 8→16 需迁移 | 范围查询 | 适用 |
|---|---|---|---|---|
| 范围(range) | 按 ID / 时间区间 | 0(只加新范围) | ✅ 最好 | 日志、账单、可按时间归档 |
| 哈希取模 | hash(key) % n | 50% | ❌ 全分片 | 分片数固定、查询点查为主 |
| 一致性哈希 | 环上顺时针找节点 | 加 1 台约 1/(N+1) = 11%;翻倍仍约 50% | ❌ | 节点会增减 |
| 虚拟槽 | 16384 个槽映射到节点(Redis Cluster) | 以槽为单位搬迁,数量同一致性哈希 | ❌ | 需要在线扩缩容 |
| 基因法 | 分片信息编进业务 ID | 取决于取模方式 | ❌ | 双维度查询(订单 + 用户) |
节点增减时的最小迁移方案见 19.4 一致性哈希。
取模为什么是 50%:h % 16 与 h % 8 只有在 h % 16 ≥ 8 时才不同,恰好一半。8 片扩到 16 片要搬一半数据,1TB 就是 500GB。
⚠️ 注意「翻倍」是任何方案都躲不开的最坏节奏:一致性哈希在 8→16 时,8 个新节点合计要接手半个环,迁移量同样是 50%;它的优势体现在「加 1 台」(8→9 只搬约 11%)。而取模在 8→9 时要搬约 89%(
h%72里只有 8 个余数位置不变)。
💡 主流做法是预分 1024 个逻辑分片,再映射到少量物理库(如 1024 → 8 库)。扩容时按逻辑分片搬、改映射表,好处是迁移粒度可控、可在线灰度,而不是「少搬」:8→9 台搬 1/9,8→16 台仍要搬 50%。代价是路由多一层查表(可缓存)。
扩容:双写迁移五步,每步都要有回滚点
| 步骤 | 做什么 | 失败模式 | 回滚 |
|---|---|---|---|
| ① 双写 | 新库建好,写老库 + 写新库(异步或同步) | 新库写失败、数据漂移 | 关掉新库写,回到单写 |
| ② 全量迁移 | 按分片、限速搬历史数据 | 迁移把主库 IO 打满 | 暂停迁移任务(可续传) |
| ③ 增量追平 | 用 binlog / CDC 追迁移期间的变更 | binlog 被 purge,追不上 | 重新全量 |
| ④ 校验 | 行数 + 摘要 checksum + 抽样比对 | 静默丢行、字段截断 | 修复后重跑校验 |
| ⑤ 切读 → 停旧写 | 灰度 1% → 10% → 100%,稳定后停老库写 | 灰度流量发现遗漏的分片键 | 切回老库(保留 7 天) |
时间账:1TB 数据、限速 50MB/s → 5.6 小时;增量追平要求 binlog 保留期覆盖整个迁移窗口(通常 ≥ 3 天)。迁移期间不要做 DDL——两边表结构必须完全一致。
跨分片查询:四种解法,优先级从高到低
| 解法 | 做法 | 适用 | 代价 |
|---|---|---|---|
| ① 让查询带上分片键 | 改接口 / 改表设计(加冗余列) | 80% 的查询 | 改业务代码 |
| ② 异构索引表 | 按 merchant_id 再建一份索引(ES / 独立表),双写 | 多维度后台查询 | 双写一致性、存储 ×2 |
| ③ 冗余宽表 | 把要查的字段冗余进主表 | 少量字段 | 更新放大 |
| ④ 广播 + 内存归并 | 查所有分片再合并 | 低频、小结果集 | 延迟 = 最慢分片、内存放大 |
深分页:offset 在分片下是乘法
扫描行数 ≈ 分片数 × (offset + limit)
8 个分片、offset=200, limit=10 → 每片取 210 行、共 1680 行;offset=10000 时是 80,160 行,还要在内存里归并排序。
| 方案 | 扫描行数 | 限制 |
|---|---|---|
LIMIT offset, n | N × (offset + n) | offset 越大越慢,不可用于深分页 |
| 游标(seek) | N × n | 只能「下一页」,不能跳页 |
| 搜索引擎(ES) | 由倒排索引决定 | 数据同步延迟 |
⚠️ 聚合也有同样的坑:
COUNT/SUM可以各分片算完再归并,COUNT(DISTINCT)无法精确归并——只能用 HyperLogLog 近似(1.6 万寄存器误差约 0.8%),或者查明细表。
JOIN:分片后基本判死刑
跨分片 JOIN 要么退化成 N 次查询 + 应用层组装,要么把小表全量广播。正确做法:冗余字段、宽表、或把 JOIN 移到离线数仓。这也是「共享数据库反模式」在分片后的加强版。
全局唯一 ID:分片后主键必须「唯一且大致有序」
| 方案 | 结构 | 优点 | 问题 |
|---|---|---|---|
| 雪花(Snowflake) | 41 bit 毫秒 + 10 bit 机器 + 12 bit 序列 | 趋势递增、每节点 4096 ID/ms、无中心依赖 | 依赖时钟,回拨要处理(等待 / 借位 / 报错) |
| 号段(segment) | DB 一次发 1000 个,内存分配 | 不依赖时钟、性能高 | DB 是依赖;重启浪费一段;多实例要双 buffer 预取 |
| UUID | 128 bit 随机 | 本地生成、无依赖 | 无序 → B+ 树页分裂、写放大 2–3 倍;36 字节主键让所有二级索引膨胀 |
| Redis INCR | 单点自增 | 简单 | Redis 故障 / 持久化回退可能导致重号 |
| DB 自增步长 | N 库 auto_increment_offset + step=N | 简单 | 扩缩容要改步长,且老数据分布不跟着变 |
🔑 分片表的主键要「趋势递增」:雪花或号段二选一。UUID 可以做业务 ID(对外暴露),但不要做聚簇索引主键——随机写入会让 InnoDB 的页分裂与缓冲池命中率同时变差。
分布式事务:设计目标是「不跨片」
| 方案 | 一致性 | 性能 | 复杂度 | 适用 |
|---|---|---|---|---|
| 同一分片本地事务 | 强 | 最高 | 无 | 首选:分片键 = 事务边界 |
| 2PC / XA | 强 | 差(锁持有 = 2 RTT + 提交) | 中 | 极少用 |
| TCC | 最终一致 | 中 | 高(空回滚 / 幂等 / 悬挂) | 资金类 |
| Saga | 最终一致 | 高 | 中(要写补偿) | 长流程 |
| 本地消息表 | 最终一致 | 高 | 低 | 跨片异步场景 |
具体协议与选型见 24.9 事务模型选型。能靠分片键把事务收进一个分片,就不要引入分布式事务——这是分片设计里回报最高的一条约束。
中间件:客户端 SDK 还是代理
| 方案 | 代表 | 额外跳数 | 多语言 | 运维 |
|---|---|---|---|---|
| 客户端 SDK | ShardingSphere-JDBC | 0 | ❌(Java 为主) | 升级要动应用 |
| 代理 | ShardingSphere-Proxy、MyCat | 0.2–1ms | ✅ | 独立部署、统一治理 |
| 数据库原生 | Vitess(MySQL) | 0 | ✅(协议兼容) | 重,需 K8s |
| 自研 | — | 0 | 按需 | 长期维护成本高 |
⚠️ 代理层有个容易忽略的放大:应用 100 个连接 × 8 个分片 = 800 个物理连接,代理自身的连接池与后端 max_connections 要一起算;SQL 兼容性也要提前压测(跨分片 JOIN、子查询、
UNION、ORDER BY+LIMIT的归并语义)。
上线配置:ShardingSphere 分片规则
# config-sharding.yaml(ShardingSphere 5.x 结构;本机未部署 Proxy,以下配置未验证)
databaseName: order_db
dataSources:
ds_0: {url: "jdbc:mysql://10.0.1.21:3306/order_0", username: app, password: "***"}
ds_1: {url: "jdbc:mysql://10.0.1.22:3306/order_1", username: app, password: "***"}
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds_${0..1}.t_order_${0..15}
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: order_mod
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: user_mod
shardingAlgorithms:
order_mod: {type: MOD, props: {sharding-count: 16}}
user_mod: {type: MOD, props: {sharding-count: 2}}
两条路由规则指向不同的键:user_id 决定库(同一用户落同一库),order_id 决定表(订单均匀打散)。这就是“分片键第一判据是查询能带上它”的落地——如果查询只带 order_id,库路由就只能扫全部库。
上线验收三查:
-- ① 倾斜:各分片行数应接近;差 2 倍以上说明分片键选错或数据偏斜
SELECT MOD(user_id, 2) AS db_shard, COUNT(*) FROM t_order GROUP BY db_shard;
-- ② 跨片:不带分片键的查询会打全部分片,用代理/中间件的路由日志确认
SELECT COUNT(*) FROM t_order WHERE created_at > NOW() - INTERVAL 1 DAY;
-- ③ 双写一致性:迁移期每天对比新旧表的行数与关键聚合
SELECT COUNT(*), SUM(amount) FROM t_order;
分片键选得对不对,不用等 Proxy 上线就能先验。本机 1 万行 MySQL 26.7 实测(Proxy 后面同一条 SQL 语义相同):
$ mysql -e "SELECT MOD(user_id,2) AS db_shard, COUNT(*) AS rows_cnt FROM t_order GROUP BY db_shard ORDER BY db_shard;"
db_shard rows_cnt
0 5000
1 5000
跨片查询不是“不能用”,而是要明确知道它扫了几个分片;不知道就是等容量上来后才发现。
回滚开关:双写关闭 → 读回旧表;分片规则回退到单库配置后重载 Proxy(规则操作流程按你的版本执行,未在本机验证)。
可运行:路由、基因法、跨分片归并、扩容比例
1000 个用户 × 每人 10 单 = 1 万单,8 个分片,分片键 user_id,订单号用基因法编入分片信息:
package main
import (
"fmt"
"sort"
)
type order struct {
id int // 订单号(基因法:低位带分片信息)
userID int
merchant int
amount int
}
type shard struct {
id int
rows []order
}
func main() {
const n = 8 // 分片数
shards := make([]*shard, n)
for i := range shards {
shards[i] = &shard{id: i}
}
// ===== 写入:1000 个用户 × 每人 10 单 = 1 万单 =====
// 分片键是 user_id;order_id 用基因法把「分片信息」编进低位,
// 这样按订单号也能直接定位到同一个分片(order_id % n == user_id % n)。
seq := 0
for uid := 1; uid <= 1000; uid++ {
for j := 0; j < 10; j++ {
seq++
id := seq*n + uid%n
o := order{id: id, userID: uid, merchant: uid%3 + 1, amount: (seq*37)%1000 + 1}
shards[id%n].rows = append(shards[id%n].rows, o)
}
}
for _, s := range shards {
fmt.Printf("分片%d:%d 行\n", s.id, len(s.rows))
}
// ===== 路由 ①:按订单号查(单分片)=====
// 取一个真实存在的订单号:基因法保证 order_id % n == user_id % n
sample := shards[2].rows[0]
s := shards[sample.id%n]
var found *order
for i := range s.rows {
if s.rows[i].id == sample.id {
found = &s.rows[i]
break
}
}
fmt.Printf("① 按订单号 %d 查:直达分片%d(其余 7 个分片不访问),命中用户 %d\n",
sample.id, s.id, found.userID)
// ===== 路由 ②:按 user_id 查(单分片)=====
uid := sample.userID
s = shards[uid%n]
cnt := 0
for _, o := range s.rows {
if o.userID == uid {
cnt++
}
}
fmt.Printf("② 按用户 %d 查订单:直达分片%d,命中 %d 单(其余 7 个分片不访问)\n", uid, s.id, cnt)
// ===== 跨分片:按商家查 + 深分页 =====
const merchant, offset, limit = 2, 200, 10
page, total := queryByOffset(shards, merchant, offset, limit)
fmt.Printf("③ 商家 %d + offset=%d:每片取 offset+limit 行 → 共扫 %d 行,归并后返回 %d 行,首行金额 %d\n",
merchant, offset, total, len(page), page[0].amount)
cursorPage, totalCursor := queryByCursor(shards, merchant, 1<<30, limit)
fmt.Printf(" 同一查询改用游标(id < last):每片取 limit 行 → 共扫 %d 行,返回 %d 行,首行 id %d\n",
totalCursor, len(cursorPage), cursorPage[0].id)
// ===== 扩容:8 分片 -> 16 分片,有多少行要搬 =====
moved := 0
for _, sh := range shards {
for _, o := range sh.rows {
if o.id%n != o.id%(2*n) {
moved++
}
}
}
fmt.Printf("④ 扩容 8→16 分片:需迁移 %d/%d 行(%.0f%%)\n", moved, seq, 100*float64(moved)/float64(seq))
}
// queryByOffset 模拟「广播 + 内存归并」:每个分片都取前 offset+limit 条,
// 汇总后统一排序再切片。
func queryByOffset(shards []*shard, merchant, offset, limit int) (page []order, scanned int) {
var cand []order
for _, s := range shards {
var rows []order
for _, o := range s.rows {
if o.merchant == merchant {
rows = append(rows, o)
}
}
sort.Slice(rows, func(i, j int) bool { return rows[i].amount > rows[j].amount })
if len(rows) > offset+limit {
rows = rows[:offset+limit]
}
scanned += len(rows)
cand = append(cand, rows...)
}
sort.Slice(cand, func(i, j int) bool { return cand[i].amount > cand[j].amount })
if len(cand) > offset {
cand = cand[offset:]
} else {
cand = nil
}
if len(cand) > limit {
cand = cand[:limit]
}
return cand, scanned
}
// queryByCursor 模拟游标分页:每片只按条件取 limit 条,再归并。
func queryByCursor(shards []*shard, merchant, cursor, limit int) (page []order, scanned int) {
var cand []order
for _, s := range shards {
var rows []order
for _, o := range s.rows {
if o.merchant == merchant && o.id < cursor {
rows = append(rows, o)
}
}
sort.Slice(rows, func(i, j int) bool { return rows[i].id > rows[j].id })
if len(rows) > limit {
rows = rows[:limit]
}
scanned += len(rows)
cand = append(cand, rows...)
}
sort.Slice(cand, func(i, j int) bool { return cand[i].id > cand[j].id })
if len(cand) > limit {
cand = cand[:limit]
}
return cand, scanned
}
输出:
分片0:1250 行
分片1:1250 行
分片2:1250 行
分片3:1250 行
分片4:1250 行
分片5:1250 行
分片6:1250 行
分片7:1250 行
① 按订单号 90 查:直达分片2(其余 7 个分片不访问),命中用户 2
② 按用户 2 查订单:直达分片2,命中 10 单(其余 7 个分片不访问)
③ 商家 2 + offset=200:每片取 offset+limit 行 → 共扫 1680 行,归并后返回 10 行,首行金额 939
同一查询改用游标(id < last):每片取 limit 行 → 共扫 80 行,返回 10 行,首行 id 80000
④ 扩容 8→16 分片:需迁移 5000/10000 行(50%)
四条结论:
- 基因法让「按订单号」和「按用户」都只碰 1 个分片——这是双维度查询唯一不靠冗余表的解法;
- 按商家查是广播:商家维度与分片键无关,8 个分片全要访问——要么接受,要么上异构索引;
- 深分页是乘法:
offset=200就扫了 1680 行(8 × 210),游标只要 80 行(8 × 10),21 倍;offset 再大 10 倍,差距就是 210 倍; - 取模扩容搬一半:8→16 有 50% 的行要迁移,这就是「分片数一次定够 / 预分逻辑分片」的原因。
反模式
| 反模式 | 后果 |
|---|---|
按 created_at 分片做订单主表 | 写入全压当前时间片;用户查订单跨所有分片 |
| 分片键均匀但查询不带它 | 单表慢查询变成 N 个分片的慢查询,成本 ×N |
| 用 UUID 做主键 | 页分裂、写放大 2–3 倍、所有二级索引膨胀 |
| 分片后还在跨分片 JOIN | 退化成 N 次查询 + 应用层组装,延迟不可控 |
用 LIMIT offset, n 做深分页 | 扫描行数 = N × (offset+n),越翻越慢 |
| 每张表用不同的分片键 | 跨片事务、跨片 JOIN 全部出现 |
| 分片数一次定成 8,不留余量 | 扩容要搬 50% 数据,窗口不可控 |
| 分片后仍用单库自增 ID | 多库 ID 冲突,或步长调整导致分布不均 |
| 迁移不做校验、直接切读 | 静默丢行,几个月后对账才发现 |
| 迁移期间做 DDL | 两边结构不一致,增量追平失败 |
| 代理层不算连接数放大 | 应用 100 连接 × 8 分片 = 800 物理连接,打满数据库 |
| 小表也分片(配置表、字典表) | 复杂度上升,收益为零 |
自检
Q1 单表 5000 万行,慢查询 Top 10 都是「按 user_id 查订单列表」。同事要求立刻分库分表。你的判断和行动顺序?
▸ 答案:先别分。5000 万行 × 1KB ≈ 50GB,若 buffer pool 有 128GB,数据可以全放内存,分片的收益主要是「DDL 与备份窗口」而不是读性能。行动顺序:① 确认 (user_id, created_at) 复合索引是否存在、是否覆盖查询列(多数「按用户查列表」慢在这里);② 归档 12 个月前已完结订单到历史表,行数降到 10%–20%;③ 若读 QPS 仍高,加从库 / 缓存(37.4 读写分离);④ 只有当写入 TPS 持续 >5000 或单库 >2TB 导致备份恢复超 RTO 时,才按 user_id 水平分片。判据是「哪个资源撞墙」,不是「表有多少行」。
Q2 分片键选 created_at(按月分表)看起来「范围查询友好、扩容简单」,为什么大多数订单场景不能这么选?
▸ 答案:三个问题。① 写热点:所有新订单都落在当前月分片,其他分片闲着——分片本来是为了分摊写入,结果写全压一个片;② 查询放大:「我的订单」要扫所有月分片,用户越老、分片越多;③ 扩容不等于零成本:虽然不用搬历史数据,但新月份的分片要建索引、要预热,且跨月统计要归并。按月分表只适合「天然按时间访问、不按用户查」的数据:日志、账单归档、监控指标。订单表应按 user_id 分片,时间只做分片内的二级索引。
Q3 8 分片扩到 16 分片,同事说「一致性哈希只搬 1/8,为什么我们还搬了 50%」?怎么把迁移量降下来?
▸ 答案:因为你们用的是取模,h % 16 ≥ 8 的那一半全部换了位置,迁移量恰好 50%。同事把「加 1 台」的数字套到了「翻倍」上:一致性哈希只有在增加 1 个节点时才只搬 1/(N+1)(8→9 约 11%);节点数翻倍时,8 个新节点合计要接手半个环,迁移量同样是 50%。真正的降法不是换算法,而是换节奏与形态:① 一次只加 1–2 台(8→9 搬 11%,但会经历 8→9→10… 多轮迁移);② 用预分逻辑分片 + 灰度搬迁,把 50% 的搬迁拆成可暂停、可回滚的小批次,而不是让它变少;③ 按范围分片,新数据只进新分片、历史数据不动(代价是写热点)。没有「免费扩容」:要么平时多付路由复杂度,要么扩容时多付迁移时间。
Q4 8 个分片,offset=10000, limit=20 的深分页要扫描多少行?给出两种改法。
▸ 答案:8 × (10000 + 20) = 80,160 行,还要在内存里归并排序,P99 必然爆炸。改法一:游标分页(WHERE id < last_id ORDER BY id DESC LIMIT 20),每片只取 20 行、共 160 行,代价是不能跳页(只支持「下一页」),适合信息流 / 订单列表。改法二:搜索引擎(ES / 异构索引),由倒排索引直接定位,代价是数据同步延迟与一致性维护。产品上还可以限制最大可翻页数(如 100 页),把长尾需求导到「筛选条件」而不是「翻页」。
Q5 分片后为什么不能用 UUID 做主键?如果业务一定要全局唯一字符串 ID,怎么办?
见 44.24 发号器与全局 ID:分片之后主键怎么发、位宽怎么切。
▸ 答案:InnoDB 是聚簇索引,主键决定行的物理顺序。UUID 随机分布会让插入落在 B+ 树的任意页:页分裂频繁、缓冲池命中率下降、写放大 2–3 倍;而且 36 字节的主键会被每个二级索引复制一份,索引体积膨胀数倍。解法:① 主键用雪花 / 号段(趋势递增、8 字节),对外暴露的业务 ID 另用 UUID / 短链,两者用唯一索引关联;② 若必须用字符串主键,用有序的(如时间前缀 + 随机后缀),并在应用层保证单调;③ 用 BINARY(16) 存 UUID 并做时间有序 UUID(v7),比 CHAR(36) 省一半空间、恢复部分局部性。
关联
- 前置:19.1 分区键设计、19.4 一致性哈希、24.9 事务模型选型、37.4 读写分离
- 并列:44.13 主从复制与读写分离
- 下游:44.24 发号器与全局 ID(分片之后主键怎么来)、44.23 未读数系统