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
2026-05-21 22:11:56 +08:00

564 lines
23 KiB
Markdown
Raw Permalink 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)的核心思想。
当单库单表的数据量超出 MySQL 的舒适区时,分库分表成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 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 分片
Range 分片的核心思想是**按连续区间划分**,最典型的场景就是按时间分表——每个月一张表,就像把文件按年份放进不同的文件夹。
```sql
-- 按月分表:审计日志、交易流水等时间序列数据
-- 表名:logs_202601, logs_202602, ..., logs_202612
-- 查询某月数据 → 只需访问一张表
SELECT * FROM logs_202603 WHERE level = 'ERROR' ORDER BY created_at DESC LIMIT 100;
-- 跨月范围查询 → 路由层自动合并多张表
SELECT * FROM logs_202603
UNION ALL
SELECT * FROM logs_202604
WHERE created_at BETWEEN '2026-03-01' AND '2026-04-30';
```
```go
// 路由层:根据时间范围计算需要查询哪些表
func GetLogTableNames(start, end time.Time) []string {
var tables []string
for t := start; !t.After(end); t = t.AddDate(0, 1, 0) {
tables = append(tables, fmt.Sprintf("logs_%s", t.Format("200601")))
}
return tables
}
```
> [!TIP] Range 分片天然适合做数据归档
> 历史数据可以直接迁移到低成本存储,操作极其简单:
> ```sql
> -- 将上月数据归档到 archive 库,腾出 SSD 空间
> ALTER TABLE logs_202601 RENAME TO archive.logs_202601_old;
> -- 为下月预创建空表
> CREATE TABLE logs_202602 LIKE logs_202601;
> ```
> [!QUESTION] Range 分片最大的风险是什么?
> 答:**数据倾斜**。如果你按月分表,双 11 当天的写入量可能是平时的 100 倍——所有的写入压力集中在一张表上。解决方案:对热点时段做二次 Hash 分片(见下方"联合分片")。
### List 分片
List 分片是**按枚举值分组**,最典型的场景是 SaaS 多租户——每个租户的数据天然隔离,互不干扰。就像办公楼按公司分配楼层,A 公司在 3 楼,B 公司在 5 楼。
```sql
-- SaaS 多租户场景:每个租户的数据在独立的库中
-- tenant_A: db_tenant_0.orders, db_tenant_0.users
-- tenant_B: db_tenant_1.orders, db_tenant_1.users
-- 查询时自动路由到对应库
-- 应用层根据 tenant_id 选择数据库连接
SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC;
-- → tenant_A 走 db_tenant_0,tenant_B 走 db_tenant_1
```
```go
// 路由层:根据租户 ID 选择目标数据库
func GetTenantDB(tenantID string) *sql.DB {
// 租户 → 库的映射关系(可存配置中心或 Redis)
dbMap := map[string]*sql.DB{
"tenant_A": dbTenant0,
"tenant_B": dbTenant1,
}
return dbMap[tenantID]
}
```
> [!NOTE] List 分片的额外好处:合规审计
> 某些行业(金融、医疗)要求租户数据物理隔离。List 分片天然满足这一需求——不同租户的数据在不同物理库中,审计时只需检查对应库即可。
### 联合分片
单一维度的分片总有局限:Hash 分片不支持范围查询,Range 分片会数据倾斜。**联合分片是两种策略的组合**——用两个(或更多)维度共同决定数据归属。
```go
// 联合分片公式:shard_id = (user_id % 16) * 4 + (month % 4)
// 第一层 user_id % 16 → 分散写入压力,同一用户的数据集中
// 第二层 month % 4 → 按时间归档,热点月份自动分散到 4 张表
// 共 16 × 4 = 64 张子表
func GetShardTableName(userID int64, t time.Time) string {
userShard := userID % 16 // 用户维度:16 个桶
timeShard := int(t.Month()-1) % 4 // 时间维度:4 个桶
shardID := userShard*4 + int64(timeShard)
return fmt.Sprintf("orders_%d", shardID)
}
// 举例:
// user_id=1001, 2026年3月 → orders_38 (1001%16=9, month=2, 9*4+2=38)
// user_id=1001, 2026年5月 → orders_36 (1001%16=9, month=4, 9*4+0=36)
// user_id=2002, 2026年3月 → orders_10 (2002%16=2, month=2, 2*4+2=10)
// 同一用户不同月份的数据落在相邻分片,方便按用户维度聚合
```
## 分片键选型指南
分片键(Sharding Key)是分库分表中最关键的决策——**一旦选定,后期几乎无法更换**。选错了分片键,就像把图书馆的书按"书名首字"分区,找"数据库"相关的书要跑遍 20 个书架。
分片键的核心要求只有一个:**让最常用的查询能精确定位到一个分片**,而不是扫描所有分片。
```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** 是第一道门槛。想象有两个班级各自编号:A 班有 1 号、2 号、3 号……B 班也有 1 号、2 号、3 号……当两个班级合并参加运动会时,"1 号"到底是谁?这就是分库后自增 ID 冲突的问题。
> [!IMPORTANT] 主键策略必须适配分片
>
> 如果你的主键是 AUTO_INCREMENT,每个分库各自从 1 开始计数——一旦需要合并数据或做跨库查询,ID 冲突不可避免。这就是为什么**要做分片就必须先上分布式 ID**。
>
> 详细的 ID 生成方案请参考:[[hhs/MySQL/05-表设计/22-主键策略对比]](Snowflake、ULID、UUID_TO_BIN 等方案的深度对比)。
快速选型参考:
| 场景 | 推荐方案 | 理由 |
|------|---------|------|
| 已有 Snowflake Worker | 直接用现有 WorkerID | 保持一致性,无需额外组件 |
| 已有独立 ID 服务 | 调用 gRPC/HTTP ID 接口 | 集中管控,可回拨告警 |
| 轻量级项目 | MySQL sequence 表 | 简单可靠,但高并发下有瓶颈 |
## 跨分片查询
这是分库分表最大的痛点。分片前,一个 `JOIN` 就能搞定的查询,分片后可能需要遍历所有分片再手动合并。
### 不可行方案
```sql
-- ❌ 跨分片 JOIN:MySQL 的 JOIN 只在同一实例内有效
-- orders 表在 sharded_db,users 表在 user_db,两个不同的实例
-- 这条 SQL 会直接报错:Table 'user_db.users' doesn't exist
SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.id;
-- ❌ 暴力 UNION ALL:除非你能精确计算分片号,否则必须扫描全部分片
-- 4 个分片还好,如果有 64 个分片,每次都扫全量就太浪费了
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
// 核心思路:游标分页 + 多路归并
// 第一步:每个分片多取一些数据(扩大缓冲区)
// 第二步:应用层合并所有结果,排序后截取需要的条数
type PageRequest struct {
UserID int64
Limit int // 期望返回条数,比如 20
AfterTs int64 // 游标:上一页最后一条的时间戳,首次请求传 0
}
func QueryShardedOrders(req PageRequest) ([]Order, error) {
// 1. 计算目标分片(这里只查 1 个分片,因为 user_id % 4 路由精确)
shard := fmt.Sprintf("orders_%d", req.UserID%4)
// 2. 扩大窗口:请求 20 条,但从每个分片取 40 条作为缓冲
// 为什么要多取?因为游标边界附近的数据可能分布在不同分片
bufferSize := req.Limit * 2
query := 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, bufferSize,
)
rows, _ := db.Query(getConn(shard), query)
orders := scanOrders(rows)
// 3. 截取需要的数量
if len(orders) > req.Limit {
orders = orders[:req.Limit]
}
return orders, nil
}
// 当分片键不是查询条件时(比如按时间全局排序),需要扫描所有分片:
func QueryAllShards(req PageRequest) ([]Order, error) {
var allOrders []Order
for i := 0; i < 4; i++ {
shard := fmt.Sprintf("orders_%d", i)
query := fmt.Sprintf(
"SELECT * FROM %s WHERE created_at < %d ORDER BY created_at DESC LIMIT %d",
shard, req.AfterTs, req.Limit*2,
)
rows, _ := db.Query(getConn(shard), query)
allOrders = append(allOrders, scanOrders(rows)...)
}
// 全局排序 + 截取
sort.Slice(allOrders, func(i, j int) bool {
return allOrders[i].CreatedAt.After(allOrders[j].CreatedAt)
})
if len(allOrders) > req.Limit {
allOrders = allOrders[:req.Limit]
}
return allOrders, nil
}
```
> [!QUESTION] 为什么不在数据库层做多分片聚合?
> 因为每个分片上的 `LIMIT` 只在自己分片范围内有效。举个具体例子:你有 4 个分片,想查全局第 100~120 条数据。如果每个分片执行 `LIMIT 100, 20`,得到的是"每个分片自己排第 100~120 条"——合起来 80 条数据,但它们和全局第 100~120 条完全不同。
>
> **正确做法**:从每个分片多取一些数据(扩大窗口),在应用层做全局排序后截取。代价是内存和延迟增加,但这是分布式系统的固有 Trade-off。
### 跨分片聚合(COUNT / SUM / AVG)
简单聚合(COUNT、SUM、AVG)的思路很直接:**每个分片各自算一遍,应用层再合并**。比如总订单数 = 分片 0 的 COUNT + 分片 1 的 COUNT + ……
但 `GROUP BY + 聚合` 就麻烦了——你需要合并各分片的分组结果,逻辑复杂且性能差。**更好的方案是预计算**:
```sql
-- 方案:定时汇总表(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)
UNION ALL
SELECT DATE(created_at), COUNT(*), SUM(amount)
FROM orders_2 GROUP BY DATE(created_at)
UNION ALL
SELECT DATE(created_at), COUNT(*), SUM(amount)
FROM orders_3 GROUP BY DATE(created_at);
-- 查询时直接读汇总表,响应速度从"秒级"降到"毫秒级"
SELECT date, SUM(total_orders), SUM(total_amount)
FROM summary_daily
WHERE date BETWEEN '2026-05-01' AND '2026-05-31'
GROUP BY date;
```
## 中间件方案
前面的代码示例都是手动计算分片路由——在生产环境中,我们更常用成熟的中间件来处理分片逻辑。中间件就像一个"智能路由器":应用发一条普通 SQL,中间件自动帮你拆分、路由、合并结果。
| 中间件 | 开发者 | 部署模式 | 核心特点 |
|--------|-------|---------|---------|
| **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
}
```
### 渐进式迁移(适合超大表)
渐进式迁移的核心思想是**分批切换**——先迁移一部分流量到新集群,验证无误后再迁移下一批,最终全部切完。就像搬办公室:先把一个部门搬过去,安顿好了再搬下一个,而不是一次性把整层楼的人都搬走。
```mermaid
flowchart LR
subgraph Init["初始状态"]
AllOld["全部流量"] --> OldDB["老库"]
end
subgraph P1["Phase 1: 50% 流量"]
P1Even["hash(user_id) % 2 == 0"] --> NewS01["新分片 0,1"]
P1Odd["hash(user_id) % 2 == 1"] --> OldDB2["老库"]
end
subgraph P2["Phase 2: 100% 流量"]
P2A["hash(user_id) % 4 == 0,1"] --> NewS01B["新分片 0,1"]
P2B["hash(user_id) % 4 == 2,3"] --> NewS23["新分片 2,3"]
end
Init -->|"Phase 1: 先切一半"| P1
P1 -->|"Phase 2: 再切剩余"| P2
style P1 fill:#FFEAA7,color:#000
style P2 fill:#00D866,color:#fff
```
> [!TIP] 渐进式迁移的核心优势
> - **风险可控**:每次只影响部分用户,出问题时只需回滚那部分流量
> - **可灰度验证**:先选一小批内部用户试跑,确认无误后再扩大范围
> - **无需停机**:整个过程对用户透明,业务零感知
>
> 劣势:需要在应用层维护两套路由规则,增加了代码复杂度。
> [!WARNING] 迁移 checklist
>
> - [ ] DDL 变更兼容性:旧版代码能否读取新表结构?(永远先加字段、后删字段)
> - [ ] 数据校验:用 `pt-table-checksum` 比对新旧数据一致性
> - [ ] 回滚预案:切到新集群后发现问题,能在 5 分钟内切回
> - [ ] 索引重建:新分片是否需要不同的索引策略?
> - [ ] 监控接入:分片后的慢查询监控面板是否就绪?
## 分片的挑战与反模式
分库分表不是银弹,它解决了一个问题,却引入了更多问题。下表列出了最常见的"坑"和应对策略:
| 问题 | 具体表现 | 应对策略 |
|------|---------|---------|
| **数据倾斜** | 某些分片数据量远大于其他(比如大卖家的订单集中在某个分片) | 检查 hash 分布,必要时换分片键或加 salt 打散 |
| **跨分片事务** | 一笔业务涉及多个分片,InnoDB 不支持跨库事务 | 用 Saga / TCC / 消息队列补偿(见 [[hhs/MySQL/08-工程实践/36-分布式事务]]) |
| **Rebalance** | 新增节点后,老数据如何迁移到新分片? | 渐进式迁移,Vitess 内置 Scatter-Gather |
| **复杂聚合** | COUNT / SUM 跨分片很贵,GROUP BY 更是噩梦 | 预计算 + 定时汇总到汇总表(见上方详解) |
| **分页困难** | LIMIT 深分页在各分片各自生效,合并后结果不对 | 游标分页 + 应用层合并(见上方详解) |
| **全局唯一约束** | UNIQUE KEY 在多分片下失效(两个分片可能各插入同一条) | 改为业务层面去重,或用 Redis SET |
> [!WARNING] 别过早分片
>
> 再次强调:**90% 的项目永远不需要分库分表**。InnoDB 单表千万级性能依然优秀。只有在满足前述三个条件时才考虑分片。如果团队规模小、迭代快,花两周优化 SQL 和索引的收益远高于花一个月做分片架构。
## 关联笔记
- [[hhs/MySQL/05-表设计/22-主键策略对比]] — 分布式 ID 生成方案(Snowflake / ULID / UUID)
- [[hhs/GORM/13-多数据库支持]] — GORM 对多库连接的支持
- [[hhs/MySQL/08-工程实践/35-Go 连接池配置]] — 多分片场景下的连接池隔离
- [[hhs/MySQL/08-工程实践/38-监控指标]] — 分片集群的监控指标体系