17 KiB
tags, create time
| tags | create time | ||||||
|---|---|---|---|---|---|---|---|
|
2026-05-16 00:00 |
分库分表
概述
当单库单表的百万级数据无法满足性能需求时,分库分表(Sharding)成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题。
[!QUESTION] 你真的需要分库分表吗?
InnoDB 在单表千万级数据下依然表现优秀。一条合适的联合索引 + 读写分离就能解决 80% 的性能瓶颈。分片引入的复杂度远超收益——跨分片 JOIN、分布式事务、数据倾斜、在线迁移……每一个都是生产环境的深夜警报。建议仅在以下情况才考虑分片:
- 单表已突破 5000 万行且增长趋势明显
- QPS 已接近单机 MySQL 的物理极限(约 1~2 万写入/秒)
- SSD 存储成本显著上升,无法继续横向扩展
分片策略总览
flowchart TD
subgraph "水平分片 Horizontal"
HS["按用户哈希<br/>user_id % N"]
HR["按范围 Range<br/>created_at 月份"]
HL["按列表 List<br/>country IN ('CN','US')"]
end
subgraph "垂直分片 Vertical"
V1["按业务域拆分<br/>订单库 / 用户库 / 商品库"]
V2["冷热数据分离<br/>热表 SSD / 冷表 HDD"]
end
style HS fill:#00D866,color:#fff
style V1 fill:#00B6BC,color:#fff
| 维度 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Hash 分片 | 均匀写入分散压力 | 数据分布均匀 | 范围查询需遍历所有分片 |
| Range 分片 | 时间序列、归档场景 | 天然支持范围查询 | 热点数据倾斜(双 11) |
| List 分片 | 租户隔离、地域隔离 | 边界清晰、易于运维 | 新增分区需预规划 |
| 垂直拆分 | 多业务混合负载 | 业务解耦、故障隔离 | 跨库 JOIN 变复杂 |
[!TIP] 最佳实践:先垂直再水平 如果不同业务的表耦合在同一库里,先做垂直拆分(按业务域拆成多个库),再对单个大表做水平拆分。两步都做能最大化收益。
水平分表实战
Hash 分片
-- 假设拆分 orders 表为 4 张子表:orders_0 ~ orders_3
-- 路由规则:order_id % 4 = 表后缀
-- order_id=1001 → orders_1
-- order_id=1004 → orders_0
// 通过 order_id 计算目标分片
func GetShardTableName(orderID int64, shardCount int) string {
return fmt.Sprintf("orders_%d", orderID%int64(shardCount))
}
⚠️ Hash 分片的坑:
order_id本身由数据库自增生成——如果你还没上分布式 ID,order_id不能用作分片键(因为它是单库内递增的)。此时应改用user_id(雪花算法生成的分布式 ID)。
Range 分片
-- 按月分表(适合审计日志、交易流水等时间序列数据)
-- logs_202601, logs_202602, ..., logs_202612
-- 优势:天然支持范围查询 WHERE created_at BETWEEN ...
-- 劣势:最近月份写入压力大,历史月份只有读取
-- 利用 range 分片的特性做数据归档
-- 每月 1 号自动将上月数据迁移到 archive 库
ALTER TABLE logs_202601 RENAME TO archive.logs_202601_old;
CREATE TABLE logs_202602 LIKE logs_202601;
List 分片
-- 按 tenant_id 列表拆分(SaaS 多租户场景)
-- tenant_A 的所有表在一个库,tenant_B 在另一个库
-- 优势:租户级别的数据隔离,方便合规审查
-- tenant_A: db_tenant_0.tenant_A_users, db_tenant_0.tenant_A_orders
-- tenant_B: db_tenant_1.tenant_B_users, db_tenant_1.tenant_B_orders
联合分片
-- 双维度分片:shard_id = (user_id % 16) * 4 + (month % 4)
-- 第一层 user_id 哈希:分散写入压力,同一用户的记录集中
-- 第二层 month 取模:便于按时间范围和归档
-- 共 16 × 4 = 64 张子表
分片键选型指南
分片键是选择最关键的决策——一旦选定,后期几乎无法更换。
flowchart TD
Start["选定分片键"] --> Q1{"查询是否<br/>总是包含此字段?"}
Q1 -->|是| Q2{"数据量是否<br/>分布均匀?"}
Q1 -->|否| FAIL1["❌ 范围查询需扫描全部分片"]
Q2 -->|是| RECOMMEND["✅ 推荐作为分片键"]
Q2 -->|否| REBALANCE["⚠️ 考虑 List 或 Range 分片"]
RECOMMEND --> Q3{"是否有业务<br/>级联删除?"}
Q3 -->|无| FINAL["🏆 user_id 是最常见的分片键"]
Q3 -->|有| WARNING["⚠️ 级联操作会触发多分片写"]
FAIL1 --> Q4{"是否按时间查询为主?"}
Q4 -->|是| TIME["✅ Range 按时间分片"]
Q4 -->|否| TRYDIFF["🔄 换一个能覆盖主查询的字段"]
[!EXAMPLE] 经典案例:电商订单的分片键选择
- 错误选择:
order_id—— 自增 ID,数据集中在最后一个分片- 正确选择:
user_id—— 分布式 ID,天然分散;且 90% 的订单查询都带WHERE user_id = ?- 折中方案:
(user_id, order_id)联合主键,user_id作分片键,order_id作二级索引
验证分片键的三个问题
在决定分片键之前,用这三个问题自检:
| # | 问题 | 如果答案是否定的 |
|---|---|---|
| 1 | 核心查询路径是否都能带上这个字段? | 会出现大量跨分片扫描 |
| 2 | 数据分布是否足够随机化? | 需要加盐(salt)打散 |
| 3 | 未来 1~2 年的业务增长是否会改变查询模式? | 预留可调整空间 |
[!NOTE] 数据倾斜检测
-- 监控每个分片的数据量差异 SELECT table_name, table_rows FROM information_schema.tables WHERE table_schema = 'mydb' AND table_name LIKE 'orders_%' ORDER BY table_rows DESC; -- 如果最大分片行数 > 最小分片的 3 倍 → 存在倾斜
分布式 ID 与分片的关系
分库分表后,全局唯一 ID 是第一道门槛。每个分片的自增 ID 只在本地有意义,合并时必然冲突。
[!IMPORTANT] 主键策略必须适配分片
如果你的主键是 AUTO_INCREMENT,每个分库各自从 1 开始计数——一旦需要合并数据或做跨库查询,ID 冲突不可避免。这就是为什么要做分片就必须先上分布式 ID。
详细的 ID 生成方案请参考:hhs/MySQL/09-主键策略对比(Snowflake、ULID、UUID_TO_BIN 等方案的深度对比)。
快速选型参考:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 已有 Snowflake Worker | 直接用现有 WorkerID | 保持一致性,无需额外组件 |
| 已有独立 ID 服务 | 调用 gRPC/HTTP ID 接口 | 集中管控,可回拨告警 |
| 轻量级项目 | MySQL sequence 表 | 简单可靠,但高并发下有瓶颈 |
跨分片查询
这是分库分表最大的痛点。
不可行方案
-- ❌ JOIN 跨分片:MySQL 原生不支持分布式 JOIN
-- 一个表在 sharded_db.orders_0..3,另一个在 user_db.users
-- ❌ UNION ALL 拼全部分片(除非你知道具体分片号)
SELECT * FROM orders_0 WHERE user_id = 100
UNION ALL SELECT * FROM orders_1 WHERE user_id = 100
UNION ALL SELECT * FROM orders_2 WHERE user_id = 100
UNION ALL SELECT * FROM orders_3 WHERE user_id = 100;
可行方案:应用层 JOIN
// Step 1: 获取用户信息
userInfo, _ := userService.GetUser(userID)
// Step 2: 计算订单所在分片
shards := []string{fmt.Sprintf("orders_%d", userID%4)}
// Step 3: 并发查询各分片
type OrderResult struct {
Orders []Order
Err error
}
ch := make(chan OrderResult, len(shards))
for _, shard := range shards {
go func(s string) {
orders := queryOrders(s, userID)
ch <- OrderResult{Orders: orders}
}(shard)
}
var allOrders []Order
for range shards {
result := <-ch
allOrders = append(allOrders, result.Orders...)
}
sort.Slice(allOrders, func(i, j int) bool {
return allOrders[i].CreatedAt.After(allOrders[j].CreatedAt)
})
flowchart LR
Client["客户端请求"] --> GW["应用网关<br/>计算目标分片"]
GW -->|"concurrent"| S1["Query orders_0"]
GW -->|"concurrent"| S2["Query orders_1"]
GW -->|"concurrent"| S3["Query orders_2"]
GW -->|"concurrent"| S4["Query orders_3"]
S1 & S2 & S3 & S4 --> Merge["合并排序<br/>应用层聚合"]
Merge --> Resp["返回最终结果"]
style Merge fill:#FF9F43,color:#000
跨分片分页(难点中的难点)
-- ❌ LIMIT 深分页在各分片各自生效,合并后分页结果不对
-- 分片 0: SELECT * FROM orders_0 ORDER BY id LIMIT 0, 20 → 拿到前 20
-- 分片 1: SELECT * FROM orders_1 ORDER BY id LIMIT 0, 20 → 拿到前 20
-- 合并取前 20?→ 错了!实际全局可能有 80 条比这些更早的记录
-- ✅ 正确做法:游标分页 + 应用层归并
-- 每个分片取更大窗口:ORDER BY created_at LIMIT 0, (N+depth)
-- 应用层全量合并后再截取
// 游标分页 + 多路归并排序
// 类似 Git rebase 的多分支合并逻辑
type PageRequest struct {
UserID int64
Limit int // 期望返回数量
AfterTs int64 // 游标:上次最后一条的时间戳
}
func QueryShardedOrders(req PageRequest) ([]Order, error) {
shards := []string{fmt.Sprintf("orders_%d", req.UserID%4)}
// Step 1: 各分片拉取 (Limit + Depth) 条数据
type RawPage struct {
Shard string
Orders []Order
}
var allPages []RawPage
for _, shard := range shards {
q := fmt.Sprintf(
"SELECT * FROM %s WHERE user_id = %d AND created_at < %d ORDER BY created_at DESC LIMIT %d",
shard, req.UserID, req.AfterTs, req.Limit*2, // 扩大 2 倍缓冲
)
rows, _ := db.Query(shardDBConn(shard), q)
orders := scanOrders(rows)
allPages = append(allPages, RawPage{shard, orders})
}
// Step 2: 多路归并(k-way merge)
merged := mergeSort(allPages, req.Limit)
return merged, nil
}
// k-way merge:类似归并排序的 merge 步骤
func mergeSort(pages []RawPage, limit int) []Order {
all := make([]Order, 0, len(pages)*limit)
for _, p := range pages {
all = append(all, p.Orders...)
}
sort.Slice(all, func(i, j int) bool {
return all[i].CreatedAt.After(all[j].CreatedAt)
})
if len(all) > limit {
return all[:limit]
}
return all
}
[!QUESTION] 为什么不在数据库层做多分片聚合? 因为每个分片上的
LIMIT只在自己分片范围内有效。想象你有 4 个桶,每个桶倒出前 20 颗珠子,合起来 80 颗里取前 20——但如果你要的是「全局第 1000~1020 颗」,每个桶自己取前 20 完全不够。解决方案是加大每片采集窗口,然后在应用层做全局排序截断。 代价是内存和延迟增加,但这是分布式系统的固有 Trade-off。
跨分片聚合(COUNT / SUM / AVG)
-- ❌ COUNT(*) 在单个分片运行,汇总时直接相加即可
-- ✅ 聚合类查询:SUM(xxx) → 各分片算完再 SUM
-- 但复杂聚合(GROUP BY + JOIN)会很贵
-- 方案:预计算 + 定时汇总表
-- cron 每小时执行一次:
INSERT INTO summary_daily (date, total_orders, total_amount)
SELECT DATE(created_at), COUNT(*), SUM(amount)
FROM orders_0 GROUP BY DATE(created_at)
UNION ALL
SELECT DATE(created_at), COUNT(*), SUM(amount)
FROM orders_1 GROUP BY DATE(created_at);
中间件方案
| 中间件 | 开发者 | 部署模式 | 核心特点 |
|---|---|---|---|
| Vitess | YouTube / Google | Proxy | 透明分片、在线 Re-sharding、K8s 原生 |
| ShardingSphere | Apache | Proxy / JDBC | 功能最全、生态广、Java 生态为主 |
| MyCat | 开源社区 | Proxy | 轻量简单、上手快 |
| Cobar | Alibaba | Proxy | 已停止维护,被 ShardingSphere 取代 |
Vitess 架构
flowchart TB
subgraph App["应用层"]
GO["Go / Java App"]
end
subgraph VT["Vitess Layer"]
VTGate["vtgate<br/>SQL 路由 + 连接池"]
VSchemas["vschema<br/>分片策略定义"]
end
subgraph Shards["MySQL Instances"]
S1["Shard-0<br/>mysqld-3000 ~ 3002"]
S2["Shard-1<br/>mysqld-3003 ~ 3005"]
S3["Shard-2<br/>mysqld-3006 ~ 3008"]
end
GO --> VTGate
VTGate --> VSchemas
VSchemas -->|"keyspace/table routing"| S1
VSchemas -->|"keyspace/table routing"| S2
VSchemas -->|"keyspace/table routing"| S3
style VTGate fill:#00B6BC,color:#fff
style VSchemas fill:#C44569,color:#fff
[!NOTE] Vitess 的核心优势
- 透明路由:应用感知不到分片,SQL 无需改动
- 在线 Re-sharding:数据迁移过程不影响线上服务
- Global Search:通过 secondary index 支持全分片搜索
- Kubernetes Native:原生支持 K8s 部署与弹性伸缩
数据迁移策略
从零停机迁移角度,这是生产环境最关键的一环。
双写迁移法(推荐)
flowchart TD
A["旧库 single_db"] --> B["新集群 sharded_db_0..N"]
subgraph "Phase 1: 存量数据搬迁"
P1["离线导出旧表<br/>mysqldump"] --> P2["分批导入新集群<br/>每批 ~100 万行"]
end
subgraph "Phase 2: 双写过渡"
APP["应用代码"] -->|"读旧+写旧"| P1
APP -->|"写新(异步)"| B
NOTE1["新旧数据一致<br/>允许短暂不一致"]
end
subgraph "Phase 3: 切读"
P3["数据校验工具<br/>row count / checksum"] --> P4["切换读路由到新集群"]
end
subgraph "Phase 4: 清理"
P5["停止旧库写入"] --> P6["观察 1~2 周无异常<br/>下线旧库"]
end
P1 -.->|阶段顺序| P2 -.->|阶段顺序| P3 -.->|阶段顺序| P4 -.->|阶段顺序| P5 -.->|阶段顺序| P6
// 双写伪代码:写入新集群时走异步通道
func CreateOrder(order *Order) error {
// 同步写旧库(保证兼容)
if err := writeOldDB(order); err != nil {
return err
}
// 异步写新分片(不阻塞主流程)
go func() {
shard := GetShardTableName(order.ID, 4)
writeNewDB(shard, order)
}()
return nil
}
渐进式迁移(适合超大表)
┌──────────────────────────────────────────────────┐
│ 渐进式迁移:按分片逐步切换 │
│ │
│ 初始: 全部流量 → 老库 │
│ Phase 1: hash(user_id) % 2 == 0 → 新分片 0,1 │
│ hash(user_id) % 2 == 1 → 老库 │
│ Phase 2: hash(user_id) % 4 = 0,1 → 新分片 0,1 │
│ hash(user_id) % 4 = 2,3 → 新分片 2,3 │
│ Final: 全部流量 → 新集群 │
│ │
│ 优势:每次切换只影响部分用户,风险可控 │
│ 劣势:需要在应用层维护两套路由规则 │
└──────────────────────────────────────────────────┘
[!WARNING] 迁移 checklist
- DDL 变更兼容性:旧版代码能否读取新表结构?(永远先加字段、后删字段)
- 数据校验:用
pt-table-checksum比对新旧数据一致性- 回滚预案:切到新集群后发现问题,能在 5 分钟内切回
- 索引重建:新分片是否需要不同的索引策略?
- 监控接入:分片后的慢查询监控面板是否就绪?
分片的挑战与反模式
| 问题 | 描述 | 应对策略 |
|---|---|---|
| 数据倾斜 | 某些分片数据量远大于其他 | 检查 hash 分布,必要时换分片键或加 salt |
| 跨分片事务 | InnoDB 不支持跨库事务 | 用 Saga / TCC / 消息队列补偿 |
| Rebalance | 新增节点后数据如何迁移 | 渐进式迁移,Vitess 内置 Scatter-Gather |
| 复杂聚合 | COUNT / SUM 跨分片很贵 | 预计算 + 定时汇总到汇总表 |
| 分页困难 | LIMIT 深分页在各分片各自生效 | 游标分页 + 应用层合并(见上方详解) |
| 全局唯一约束 | UNIQUE KEY 在多分片下失效 | 改为业务层面去重,或用 Redis SET |
[!WARNING] 别过早分片
再次强调:90% 的项目永远不需要分库分表。InnoDB 单表千万级性能依然优秀。只有在满足前述三个条件时才考虑分片。如果团队规模小、迭代快,花两周优化 SQL 和索引的收益远高于花一个月做分片架构。
关联笔记
- hhs/MySQL/09-主键策略对比 — 分布式 ID 生成方案(Snowflake / ULID / UUID)
- hhs/GORM/13-多数据库支持 — GORM 对多库连接的支持
- hhs/MySQL/35-Go 连接池配置 — 多分片场景下的连接池隔离
- hhs/MySQL/38-监控指标 — 分片集群的监控指标体系