vault backup: 2026-05-21 22:11:56

This commit is contained in:
hhs
2026-05-21 22:11:56 +08:00
parent 9fc46eac24
commit c3779073e9
5 changed files with 700 additions and 263 deletions
+205 -97
View File
@@ -7,7 +7,9 @@ create time: 2026-05-16 00:00
## 概述
当单库单表的百万级数据无法满足性能需求时,分库分表(Sharding)成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题。
想象你开了一家超市,所有商品都放在一个仓库里。当商品只有几千种时,一个仓库绰绰有余;但当商品增长到几十万种,一个仓库就变得拥挤不堪——找货慢、入库堵、仓库还会塌。解决方案很简单:**建多个仓库,按规则分配商品**。这就是分库分表(Sharding)的核心思想。
当单库单表的数据量超出 MySQL 的舒适区时,分库分表成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题,每一个都是对团队工程能力的考验。
> [!QUESTION] 你真的需要分库分表吗?
>
@@ -36,12 +38,12 @@ flowchart TD
style V1 fill:#00B6BC,color:#fff
```
| 维度 | 适用场景 | 优点 | 缺点 |
|------|---------|------|------|
| **Hash 分片** | 均匀写入分散压力 | 数据分布均匀 | 范围查询需遍历所有分片 |
| **Range 分片** | 时间序列、归档场景 | 天然支持范围查询 | 热点数据倾斜(双 11) |
| **List 分片** | 租户隔离、地域隔离 | 边界清晰、易于运维 | 新增分区需预规划 |
| **垂直拆分** | 多业务混合负载 | 业务解耦、故障隔离 | 跨库 JOIN 变复杂 |
| 维度 | 类比 | 适用场景 | 优点 | 缺点 |
|------|------|---------|------|------|
| **Hash 分片** | 按姓名首字母分班 | 均匀写入、分散压力 | 数据分布均匀 | 范围查询需遍历所有分片 |
| **Range 分片** | 按年份归档文件 | 时间序列、归档场景 | 天然支持范围查询 | 热点数据倾斜(如双 11) |
| **List 分片** | 按部门分配办公区 | 租户隔离、地域隔离 | 边界清晰、易于运维 | 新增分区需提前规划 |
| **垂直拆分** | 超市按品类分区 | 多业务混合负载 | 业务解耦、故障隔离 | 跨库 JOIN 变复杂 |
> [!TIP] 最佳实践:先垂直再水平
> 如果不同业务的表耦合在同一库里,先做**垂直拆分**(按业务域拆成多个库),再对单个大表做**水平拆分**。两步都做能最大化收益。
@@ -68,42 +70,104 @@ func GetShardTableName(orderID int64, shardCount int) string {
### Range 分片
```sql
-- 按月分表(适合审计日志、交易流水等时间序列数据)
-- logs_202601, logs_202602, ..., logs_202612
-- 优势:天然支持范围查询 WHERE created_at BETWEEN ...
-- 劣势:最近月份写入压力大,历史月份只有读取
```
Range 分片的核心思想是**按连续区间划分**,最典型的场景就是按时间分表——每个月一张表,就像把文件按年份放进不同的文件夹。
```sql
-- 利用 range 分片的特性做数据归档
-- 每月 1 号自动将上月数据迁移到 archive 库
ALTER TABLE logs_202601 RENAME TO archive.logs_202601_old;
CREATE TABLE logs_202602 LIKE logs_202601;
-- 按月分表:审计日志、交易流水等时间序列数据
-- 表名: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
-- 按 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
-- 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 分片天然满足这一需求——不同租户的数据在不同物理库中,审计时只需检查对应库即可。
### 联合分片
```sql
-- 双维度分片:shard_id = (user_id % 16) * 4 + (month % 4)
-- 第一层 user_id 哈希:分散写入压力,同一用户的记录集中
-- 第二层 month 取模:便于按时间范围和归档
-- 共 16 × 4 = 64 张子表
单一维度的分片总有局限: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
@@ -151,7 +215,7 @@ flowchart TD
## 分布式 ID 与分片的关系
分库分表后,**全局唯一 ID** 是第一道门槛。每个分片的自增 ID 只在本地有意义,合并时必然冲突。
分库分表后,**全局唯一 ID** 是第一道门槛。想象有两个班级各自编号:A 班有 1 号、2 号、3 号……B 班也有 1 号、2 号、3 号……当两个班级合并参加运动会时,"1 号"到底是谁?这就是分库后自增 ID 冲突的问题。
> [!IMPORTANT] 主键策略必须适配分片
>
@@ -169,15 +233,18 @@ flowchart TD
## 跨分片查询
这是分库分表最大的痛点。
这是分库分表最大的痛点。分片前,一个 `JOIN` 就能搞定的查询,分片后可能需要遍历所有分片再手动合并。
### 不可行方案
```sql
-- ❌ JOIN 跨分片:MySQL 原生不支持分布式 JOIN
-- 一个表在 sharded_db.orders_0..3,另一个在 user_db.users
-- ❌ 跨分片 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 拼全部分片(除非你知道具体分片号)
-- ❌ 暴力 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
@@ -243,78 +310,99 @@ flowchart LR
```
```go
// 游标分页 + 多路归并排序
// 类似 Git rebase 的多分支合并逻辑
// 核心思路:游标分页 + 多路归并
// 第一步:每个分片多取一些数据(扩大缓冲区)
// 第二步:应用层合并所有结果,排序后截取需要的条数
type PageRequest struct {
UserID int64
Limit int // 期望返回数量
AfterTs int64 // 游标:上次最后一条的时间戳
Limit int // 期望返回条数,比如 20
AfterTs int64 // 游标:上一页最后一条的时间戳,首次请求传 0
}
func QueryShardedOrders(req PageRequest) ([]Order, error) {
shards := []string{fmt.Sprintf("orders_%d", req.UserID%4)}
// 1. 计算目标分片(这里只查 1 个分片,因为 user_id % 4 路由精确)
shard := fmt.Sprintf("orders_%d", req.UserID%4)
// Step 1: 各分片拉取 (Limit + Depth) 条数据
type RawPage struct {
Shard string
Orders []Order
// 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]
}
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
return orders, 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...)
// 当分片键不是查询条件时(比如按时间全局排序),需要扫描所有分片:
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(all, func(i, j int) bool {
return all[i].CreatedAt.After(all[j].CreatedAt)
// 全局排序 + 截取
sort.Slice(allOrders, func(i, j int) bool {
return allOrders[i].CreatedAt.After(allOrders[j].CreatedAt)
})
if len(all) > limit {
return all[:limit]
if len(allOrders) > req.Limit {
allOrders = allOrders[:req.Limit]
}
return all
return allOrders, nil
}
```
> [!QUESTION] 为什么不在数据库层做多分片聚合?
> 因为每个分片上的 `LIMIT` 只在自己分片范围内有效。想象你有 4 个桶,每个桶倒出前 20 颗珠子,合起来 80 颗里取前 20——但如果你要的是「全局第 1000~1020 颗」,每个桶自己取前 20 完全不够。**解决方案是加大每片采集窗口,然后在应用层做全局排序截断。** 代价是内存和延迟增加,但这是分布式系统的固有 Trade-off。
> 因为每个分片上的 `LIMIT` 只在自己分片范围内有效。举个具体例子:你有 4 个分片,想查全局第 100~120 条数据。如果每个分片执行 `LIMIT 100, 20`,得到的是"每个分片自己排第 100~120 条"——合起来 80 条数据,但它们和全局第 100~120 条完全不同。
>
> **正确做法**:从每个分片多取一些数据(扩大窗口),在应用层做全局排序后截取。代价是内存和延迟增加,但这是分布式系统的固有 Trade-off。
### 跨分片聚合(COUNT / SUM / AVG)
```sql
-- ❌ COUNT(*) 在单个分片运行,汇总时直接相加即可
-- ✅ 聚合类查询:SUM(xxx) → 各分片算完再 SUM
简单聚合(COUNT、SUM、AVG)的思路很直接:**每个分片各自算一遍,应用层再合并**。比如总订单数 = 分片 0 的 COUNT + 分片 1 的 COUNT + ……
-- 但复杂聚合(GROUP BY + JOIN)会很贵
-- 方案:预计算 + 定时汇总表
-- cron 每小时执行一次:
但 `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);
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 原生 |
@@ -363,6 +451,8 @@ flowchart TB
### 双写迁移法(推荐)
双写迁移的思路很直观:**新旧两套库同时写入,逐步切换读流量**。就像搬新家时,先把旧家具搬到新房子,同时新旧两个地址都收快递——等新家收拾好了,再把快递地址改成新家。
```mermaid
flowchart TD
A["旧库 single_db"] --> B["新集群 sharded_db_0..N"]
@@ -408,21 +498,37 @@ func CreateOrder(order *Order) error {
### 渐进式迁移(适合超大表)
渐进式迁移的核心思想是**分批切换**——先迁移一部分流量到新集群,验证无误后再迁移下一批,最终全部切完。就像搬办公室:先把一个部门搬过去,安顿好了再搬下一个,而不是一次性把整层楼的人都搬走。
```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
```
┌──────────────────────────────────────────────────┐
│ 渐进式迁移:按分片逐步切换 │
│ │
│ 初始: 全部流量 → 老库 │
│ 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: 全部流量 → 新集群 │
│ │
│ 优势:每次切换只影响部分用户,风险可控 │
│ 劣势:需要在应用层维护两套路由规则 │
└──────────────────────────────────────────────────┘
```
> [!TIP] 渐进式迁移的核心优势
> - **风险可控**:每次只影响部分用户,出问题时只需回滚那部分流量
> - **可灰度验证**:先选一小批内部用户试跑,确认无误后再扩大范围
> - **无需停机**:整个过程对用户透明,业务零感知
>
> 劣势:需要在应用层维护两套路由规则,增加了代码复杂度。
> [!WARNING] 迁移 checklist
>
@@ -434,14 +540,16 @@ func CreateOrder(order *Order) error {
## 分片的挑战与反模式
| 问题 | 描述 | 应对策略 |
|------|------|---------|
| **数据倾斜** | 某些分片数据量远大于其他 | 检查 hash 分布,必要时换分片键或加 salt |
| **跨分片事务** | InnoDB 不支持跨库事务 | 用 Saga / TCC / 消息队列补偿 |
| **Rebalance** | 新增节点后数据如何迁移 | 渐进式迁移,Vitess 内置 Scatter-Gather |
| **复杂聚合** | COUNT / SUM 跨分片很贵 | 预计算 + 定时汇总到汇总表 |
| **分页困难** | LIMIT 深分页在各分片各自生效 | 游标分页 + 应用层合并(见上方详解) |
| **全局唯一约束** | UNIQUE KEY 在多分片下失效 | 改为业务层面去重,或用 Redis SET |
分库分表不是银弹,它解决了一个问题,却引入了更多问题。下表列出了最常见的"坑"和应对策略:
| 问题 | 具体表现 | 应对策略 |
|------|---------|---------|
| **数据倾斜** | 某些分片数据量远大于其他(比如大卖家的订单集中在某个分片) | 检查 hash 分布,必要时换分片键或加 salt 打散 |
| **跨分片事务** | 一笔业务涉及多个分片,InnoDB 不支持跨库事务 | 用 Saga / TCC / 消息队列补偿(见 [[hhs/MySQL/08-工程实践/36-分布式事务]]) |
| **Rebalance** | 新增节点后,老数据如何迁移到新分片? | 渐进式迁移,Vitess 内置 Scatter-Gather |
| **复杂聚合** | COUNT / SUM 跨分片很贵,GROUP BY 更是噩梦 | 预计算 + 定时汇总到汇总表(见上方详解) |
| **分页困难** | LIMIT 深分页在各分片各自生效,合并后结果不对 | 游标分页 + 应用层合并(见上方详解) |
| **全局唯一约束** | UNIQUE KEY 在多分片下失效(两个分片可能各插入同一条) | 改为业务层面去重,或用 Redis SET |
> [!WARNING] 别过早分片
>