---
tags: [MySQL, 分库分表, Sharding, Vitess, ShardingSphere, Snowflake]
create time: 2026-05-16 00:00
---
# 分库分表
## 概述
当单库单表的百万级数据无法满足性能需求时,分库分表(Sharding)成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题。
> [!QUESTION] 你真的需要分库分表吗?
>
> InnoDB 在单表千万级数据下依然表现优秀。一条合适的联合索引 + 读写分离就能解决 80% 的性能瓶颈。分片引入的复杂度远超收益——跨分片 JOIN、分布式事务、数据倾斜、在线迁移……每一个都是生产环境的深夜警报。**建议仅在以下情况才考虑分片:**
>
> 1. 单表已突破 **5000 万行**且增长趋势明显
> 2. QPS 已接近单机 MySQL 的物理极限(约 1~2 万写入/秒)
> 3. SSD 存储成本显著上升,无法继续横向扩展
## 分片策略总览
```mermaid
flowchart TD
subgraph "水平分片 Horizontal"
HS["按用户哈希
user_id % N"]
HR["按范围 Range
created_at 月份"]
HL["按列表 List
country IN ('CN','US')"]
end
subgraph "垂直分片 Vertical"
V1["按业务域拆分
订单库 / 用户库 / 商品库"]
V2["冷热数据分离
热表 SSD / 冷表 HDD"]
end
style HS fill:#00D866,color:#fff
style V1 fill:#00B6BC,color:#fff
```
| 维度 | 适用场景 | 优点 | 缺点 |
|------|---------|------|------|
| **Hash 分片** | 均匀写入分散压力 | 数据分布均匀 | 范围查询需遍历所有分片 |
| **Range 分片** | 时间序列、归档场景 | 天然支持范围查询 | 热点数据倾斜(双 11) |
| **List 分片** | 租户隔离、地域隔离 | 边界清晰、易于运维 | 新增分区需预规划 |
| **垂直拆分** | 多业务混合负载 | 业务解耦、故障隔离 | 跨库 JOIN 变复杂 |
> [!TIP] 最佳实践:先垂直再水平
> 如果不同业务的表耦合在同一库里,先做**垂直拆分**(按业务域拆成多个库),再对单个大表做**水平拆分**。两步都做能最大化收益。
## 水平分表实战
### Hash 分片
```sql
-- 假设拆分 orders 表为 4 张子表:orders_0 ~ orders_3
-- 路由规则:order_id % 4 = 表后缀
-- order_id=1001 → orders_1
-- order_id=1004 → orders_0
```
```go
// 通过 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 分片
```sql
-- 按月分表(适合审计日志、交易流水等时间序列数据)
-- logs_202601, logs_202602, ..., logs_202612
-- 优势:天然支持范围查询 WHERE created_at BETWEEN ...
-- 劣势:最近月份写入压力大,历史月份只有读取
```
```sql
-- 利用 range 分片的特性做数据归档
-- 每月 1 号自动将上月数据迁移到 archive 库
ALTER TABLE logs_202601 RENAME TO archive.logs_202601_old;
CREATE TABLE logs_202602 LIKE logs_202601;
```
### List 分片
```sql
-- 按 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
```
### 联合分片
```sql
-- 双维度分片:shard_id = (user_id % 16) * 4 + (month % 4)
-- 第一层 user_id 哈希:分散写入压力,同一用户的记录集中
-- 第二层 month 取模:便于按时间范围和归档
-- 共 16 × 4 = 64 张子表
```
## 分片键选型指南
分片键是选择最关键的决策——**一旦选定,后期几乎无法更换**。
```mermaid
flowchart TD
Start["选定分片键"] --> Q1{"查询是否
总是包含此字段?"}
Q1 -->|是| Q2{"数据量是否
分布均匀?"}
Q1 -->|否| FAIL1["❌ 范围查询需扫描全部分片"]
Q2 -->|是| RECOMMEND["✅ 推荐作为分片键"]
Q2 -->|否| REBALANCE["⚠️ 考虑 List 或 Range 分片"]
RECOMMEND --> Q3{"是否有业务
级联删除?"}
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] 数据倾斜检测
> ```sql
> -- 监控每个分片的数据量差异
> 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 表 | 简单可靠,但高并发下有瓶颈 |
## 跨分片查询
这是分库分表最大的痛点。
### 不可行方案
```sql
-- ❌ 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
```go
// 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)
})
```
```mermaid
flowchart LR
Client["客户端请求"] --> GW["应用网关
计算目标分片"]
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["合并排序
应用层聚合"]
Merge --> Resp["返回最终结果"]
style Merge fill:#FF9F43,color:#000
```
### 跨分片分页(难点中的难点)
```sql
-- ❌ 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)
-- 应用层全量合并后再截取
```
```go
// 游标分页 + 多路归并排序
// 类似 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)
```sql
-- ❌ 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 架构
```mermaid
flowchart TB
subgraph App["应用层"]
GO["Go / Java App"]
end
subgraph VT["Vitess Layer"]
VTGate["vtgate
SQL 路由 + 连接池"]
VSchemas["vschema
分片策略定义"]
end
subgraph Shards["MySQL Instances"]
S1["Shard-0
mysqld-3000 ~ 3002"]
S2["Shard-1
mysqld-3003 ~ 3005"]
S3["Shard-2
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 的核心优势
> 1. **透明路由**:应用感知不到分片,SQL 无需改动
> 2. **在线 Re-sharding**:数据迁移过程不影响线上服务
> 3. **Global Search**:通过 secondary index 支持全分片搜索
> 4. **Kubernetes Native**:原生支持 K8s 部署与弹性伸缩
## 数据迁移策略
**从零停机迁移角度,这是生产环境最关键的一环。**
### 双写迁移法(推荐)
```mermaid
flowchart TD
A["旧库 single_db"] --> B["新集群 sharded_db_0..N"]
subgraph "Phase 1: 存量数据搬迁"
P1["离线导出旧表
mysqldump"] --> P2["分批导入新集群
每批 ~100 万行"]
end
subgraph "Phase 2: 双写过渡"
APP["应用代码"] -->|"读旧+写旧"| P1
APP -->|"写新(异步)"| B
NOTE1["新旧数据一致
允许短暂不一致"]
end
subgraph "Phase 3: 切读"
P3["数据校验工具
row count / checksum"] --> P4["切换读路由到新集群"]
end
subgraph "Phase 4: 清理"
P5["停止旧库写入"] --> P6["观察 1~2 周无异常
下线旧库"]
end
P1 -.->|阶段顺序| P2 -.->|阶段顺序| P3 -.->|阶段顺序| P4 -.->|阶段顺序| P5 -.->|阶段顺序| P6
```
```go
// 双写伪代码:写入新集群时走异步通道
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-监控指标]] — 分片集群的监控指标体系