This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/37-分库分表.md
T
2026-05-17 00:06:11 +08:00

456 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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["按用户哈希<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 分片
```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{"查询是否<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] 数据倾斜检测
> ```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["应用网关<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
```
### 跨分片分页(难点中的难点)
```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<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 的核心优势
> 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["离线导出旧表<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
```
```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-监控指标]] — 分片集群的监控指标体系