--- 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-监控指标]] — 分片集群的监控指标体系