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/08-工程实践/37-分库分表.md
T
2026-05-21 12:20:51 +08:00

17 KiB
Raw Blame History

tags, create time
tags create time
MySQL
分库分表
Sharding
Vitess
ShardingSphere
Snowflake
2026-05-16 00:00

分库分表

概述

当单库单表的百万级数据无法满足性能需求时,分库分表(Sharding)成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题。

[!QUESTION] 你真的需要分库分表吗?

InnoDB 在单表千万级数据下依然表现优秀。一条合适的联合索引 + 读写分离就能解决 80% 的性能瓶颈。分片引入的复杂度远超收益——跨分片 JOIN、分布式事务、数据倾斜、在线迁移……每一个都是生产环境的深夜警报。建议仅在以下情况才考虑分片:

  1. 单表已突破 5000 万行且增长趋势明显
  2. QPS 已接近单机 MySQL 的物理极限(约 1~2 万写入/秒)
  3. 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/05-表设计/22-主键策略对比(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 的核心优势

  1. 透明路由:应用感知不到分片,SQL 无需改动
  2. 在线 Re-sharding:数据迁移过程不影响线上服务
  3. Global Search:通过 secondary index 支持全分片搜索
  4. 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 和索引的收益远高于花一个月做分片架构。

关联笔记