RSS
菜单
全部文章快讯开发科技深度热点

第 23 章 分库分表与分区

内容摘要

当单表数据量突破千万、单机磁盘跑满,垂直扩展(加内存/加 CPU)已经到头,分库分表是必由之路。本篇系统讲解垂直拆分、水平拆分的思路,分片键的选择原则,常见分片算法,分区表与分库分表的区别,以及扩容、数据迁移的经典方案。学完本篇,你将能为一个千万级订单表设计合理的分片方案,并说出 ShardingSphere 等中间件的适用场景。本系列示例基于 MySQL 8.0 语法。

📖 本篇简介:当单表数据量突破千万、单机磁盘跑满,垂直扩展(加内存/加 CPU)已经到头,分库分表是必由之路。本篇系统讲解垂直拆分、水平拆分的思路,分片键的选择原则,常见分片算法,分区表与分库分表的区别,以及扩容、数据迁移的经典方案。学完本篇,你将能为一个千万级订单表设计合理的分片方案,并说出 ShardingSphere 等中间件的适用场景。本系列示例基于 MySQL 8.0 语法。

目录

  • 1 为什么要分库分表
  • 2 垂直拆分
  • 3 水平拆分与分片键
  • 4 常见分片算法
  • 5 分库分表中间件
  • 6 扩容与数据迁移
  • 7 分区表
  • 8 分库分表速查表
  • 9 常见坑与注意事项
  • 10 练习题与参考答案

1 为什么要分库分表

单表数据量到什么程度该拆?业界经验值:

| 数据量 | 表现 | 建议 |

|---|---|---|

| < 100 万行 | 查询很快 | 不用拆 |

| 100 万 ~ 1000 万 | 加索引尚可 | 优化 SQL、加索引 |

| 1000 万 ~ 5000 万 | 查询开始慢、DDL 慢 | 考虑读写分离、归档 |

| > 5000 万 / 单表 > 50GB | DDL 动辄几小时、备份慢 | 必须分库分表 |

分库分表本质上是"把一张大表拆成多张小表,分散到多台机器上",让每台机器的负载回到单库单表的健康水平。

1.1 垂直拆分 vs 水平拆分

分库分表两大方向:

  • 垂直拆分(Vertical):按业务/字段拆。要么把不同业务的表拆到不同库(订单库、用户库),要么把一张表的冷字段拆到另一张表;
  • 水平拆分(Horizontal):按行拆。把同一张表的数据按某个规则(如 user_id 取模)分散到 N 张结构完全相同的表 / 库。
垂直拆分:                       水平拆分:
┌──────────────┐                 ┌──────────────┐
│   订单库     │                 │  orders_0    │
│  orders表    │                 │  orders_1    │
├──────────────┤     vs          │  orders_2    │
│   用户库     │                 │  orders_3    │
│  users表     │                 │  ...         │
└──────────────┘                 └──────────────┘

实际项目里通常是"先垂直拆业务、再水平拆大表"组合使用。

2 垂直拆分

2.1 按业务拆库

互联网公司最早的拆分动作,就是按业务域把单库拆成多个库:

  • 早期:一个 shop 库里面有 users、orders、products、payments 等所有表;
  • 垂直拆分后:user_db、order_db、product_db、pay_db 分别独立。

好处:

1. 不同业务可以独立扩容、独立部署;

2. 一个业务出问题不会拖垮整库;

3. 团队分工清晰,订单团队只管 order_db。

代价:跨库 JOIN 不能直接用了,应用层要组装。

2.2 按字段拆表

有些表有"热字段 + 冷字段"的特性。比如商品表 products:

products 表(热字段):id, name, price, stock, status
products_detail 表(冷字段):id, description, detail_html, images, specs_json

热字段经常被列表查询用到,冷字段(描述、详情图)只有商品详情页才查。把冷字段拆到 products_detail 表,主表变小、Buffer Pool 命中率更高。

-- 建商品主表(热字段)
CREATE TABLE products (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    price DECIMAL(10,2),
    stock INT,
    status TINYINT
);

-- 建商品详情表(冷字段,按 id 一对一)
CREATE TABLE products_detail (
    product_id BIGINT PRIMARY KEY,
    description TEXT,
    detail_html MEDIUMTEXT,
    images JSON,
    specs JSON
);

3 水平拆分与分片键

3.1 什么是分片键

水平拆分时,决定"一行数据落到哪张表/哪个库"的字段叫分片键(Sharding Key)。比如按 user_id % 16 分 16 张表,分片键就是 user_id。

分片键选得好不好,直接决定了后续所有查询能不能命中单表。

3.2 分片键选择原则

| 原则 | 说明 |

|---|---|

| 高基数 | 值要分散,避免热点 |

| 高查询频率 | 业务最常作为 WHERE 条件的字段 |

| 不轻易变更 | 选了之后很难换,换了要全量迁移 |

| 尽量唯一 | 优先选 user_id / order_id 这类唯一键 |

反例:

  • 用 status 当分片键:所有"待支付"订单都挤在同一分片,热点;
  • 用 create_time 当分片键(按天):跨时间范围查询要扫所有分片;
  • 用没索引的字段当分片键:分片路由后还要再扫全表。

3.3 典型分片键选择

| 业务表 | 推荐分片键 | 理由 |

|---|---|---|

| 订单表 | user_id | 90% 查询是"我的订单" |

| 交易流水表 | user_id | 同上 |

| 日志表 | 按时间(月)+ 随机 | 只按时间归档,不分片键 |

| 消息表 | user_id | 用户维度查询 |

4 常见分片算法

4.1 取模(Mod)

最经典:shard = hash(user_id) % N。比如分 16 张表:

orders_0:   user_id % 16 == 0
orders_1:   user_id % 16 == 1
...
orders_15:  user_id % 16 == 15

优点:数据分布均匀、热点少。

缺点:扩容时 N 变了,几乎所有数据都要迁移(比如从 16 扩到 32,大部分 user_id 的取模结果变了)。

4.2 范围(Range)

按分片键的范围分段:

orders_0:   user_id ∈ [1, 1000000]
orders_1:   user_id ∈ [1000001, 2000000]
...

优点:扩容简单,新加一段就行。

缺点:容易热点(新数据都写最新那段),范围查询跨分片多。

4.3 一致性哈希(Consistent Hashing)

把节点和 key 都映射到一个 0~2^32 的环上,key 顺时针找到第一个节点。扩容时只影响相邻节点的数据,迁移量小。

# 一致性哈希极简示意(本机实跑)
class HashRing:
    def __init__(self, nodes):
        # 环:sorted({hash(node): node})
        self.ring = sorted({hash(n): n for n in nodes}.items())

    def get(self, key):
        h = hash(key)
        # 顺时针找第一个大于等于 h 的节点
        for pos, node in self.ring:
            if pos >= h:
                return node
        return self.ring[0][1]   # 兜回第一个节点

ring = HashRing(["db0", "db1", "db2", "db3"])
for uid in [1001, 1002, 1003, 1004, 1005]:
    print(f"user_id={uid} → {ring.get(uid)}")

实跑输出(Python 的 hash 每次进程随机化,节点少时分布可能偏,但同一个 key 多次路由结果稳定):

user_id=1001 → db3
user_id=1002 → db3
user_id=1003 → db3
user_id=1004 → db3
user_id=1005 → db3

生产环境的一致性哈希会给每个节点配多个"虚拟节点",解决节点少、分布不均的问题。虚拟节点数一般设为真实节点的 100~200 倍。

4.4 时间维度(按月/按天)

日志、流水这类表,按 YYYYMM 分表:orders_202601、orders_202602...

优点:归档简单(直接 DROP 老表);缺点:跨月查询要扫多表。

5 分库分表中间件

业务自己写代码路由分片太麻烦,业界有成熟的中间件:

| 中间件 | 类型 | 特点 |

|---|---|---|

| ShardingSphere(JDBC) | 客户端 jar | 轻量、和应用同进程,灵活 |

| ShardingSphere-Proxy | 代理 | 独立部署,对应用透明,像 MySQL |

| MyCat | 代理 | 老牌,国内用得多 |

| Vitess | 代理 | YouTube 开源,Go 写的 |

| 云厂商 DDS | 云 | 零运维,锁云 |

ShardingSphere 是 Apache 顶级项目,国内最主流。它的配置示例(yaml 节选):

# ShardingSphere 逻辑表 orders 配置
rules:
  - !SHARDING
    tables:
      orders:
        actualDataNodes: ds_${0..3}.orders_${0..15}   # 4 库 × 16 表
        tableStrategy:
          standard:
            shardingColumn: user_id                   # 分片键
            shardingAlgorithmName: order_inline
        keyGenerateStrategy:
          column: order_id
          keyGeneratorName: snowflake                 # 分布式 ID
    shardingAlgorithms:
      order_inline:
        type: INLINE
        props:
          algorithm-expression: orders_${user_id % 16}

应用侧只连 ShardingSphere,写 SELECT * FROM orders WHERE user_id=1001,中间件自动路由到 ds_2.orders_1。

5.1 分布式 ID

分库分表后,自增主键不能用了(每个库都从 1 开始会冲突)。常见方案:

| 方案 | 优点 | 缺点 |

|---|---|---|

| UUID | 简单 | 太长、无序、做主键性能差 |

| 号段模式 | 性能高 | 依赖 DB |

| 雪花算法(Snowflake) | 趋势递增、本地生成 | 依赖机器时钟 |

| Redis INCR | 简单 | 依赖 Redis |

Snowflake 是最常用的:64 位 long,由"时间戳 + 机器 ID + 序列号"组成,本地生成、全局唯一、趋势递增。

5.2 分布式事务

分库分表后,一个业务操作往往要写多个库。比如"下单"要写 order_db 的订单、扣 product_db 的库存、写 pay_db 的流水,本地事务管不到跨库。常见方案:

| 方案 | 原理 | 适用 |

|---|---|---|

| 2PC / XA | 两阶段提交,强一致 | 性能差,少用 |

| TCC | Try-Confirm-Cancel,业务层实现 | 金融核心 |

| Saga | 长事务拆多步,每步有补偿 | 互联网常见 |

| 本地消息表 | 本地事务写消息表,异步投递 | 最终一致最常用 |

本地消息表是互联网公司最常用的"最终一致"方案:订单库本地事务里同时写订单和消息表,后台任务轮询消息表、投递给 MQ,库存库消费 MQ 扣库存。即使中间某步失败,重试即可。

6 扩容与数据迁移

最痛的不是第一次分片,而是从 16 张表扩到 32 张表。

6.1 双写迁移法

经典扩容流程:

1. 老分片按 user_id % 16 路由;

2. 新分片按 user_id % 32 路由;

3. 应用同时写老表和新表(双写);

4. 跑历史数据迁移程序,把老表数据按新规则导到新表;

5. 校验一致后,读切到新表;

6. 再观察一段时间,停老表写入。

-- 迁移程序示意(伪 SQL):把老 orders_0 的数据按新规则分散到新表
INSERT INTO orders_0_new SELECT * FROM orders_0 WHERE user_id % 32 = 0;
INSERT INTO orders_1_new SELECT * FROM orders_0 WHERE user_id % 32 = 1;
-- ... 老表一行可能要拆到多张新表

6.2 停机迁移 vs 在线迁移

| 方式 | 停机迁移 | 在线双写迁移 |

|---|---|---|

| 业务影响 | 要停几小时 | 不停机 |

| 复杂度 | 简单 | 高 |

| 适用 | 小表、非核心业务 | 大表、核心业务 |

大表迁移必须在线双写,停机一小时业务损失巨大。

7 分区表

分区表(Partition)和分库分表容易混淆。它是逻辑上一张表、物理上多个文件,由 MySQL 自己管理,对应用透明。

7.1 分区 vs 分库分表

| 维度 | 分区表 | 分库分表 |

|---|---|---|

| 由谁管理 | MySQL 自动 | 应用/中间件路由 |

| 表结构 | 一张表 | 多张结构相同的表 |

| 跨节点 | 单机 | 多机 |

| 容量上限 | 单机磁盘 | 水平扩展 |

| 适用 | 单表过大但还能单机扛 | 必须水平扩展 |

7.2 RANGE 分区示例

按时间分区,日志类表最常用:

-- 按创建时间按月分区
CREATE TABLE orders (
    id BIGINT,
    user_id BIGINT,
    amount DECIMAL(10,2),
    create_time DATETIME,
    PRIMARY KEY (id, create_time)    -- 分区键必须在主键里
)
PARTITION BY RANGE (TO_DAYS(create_time)) (
    PARTITION p202601 VALUES LESS THAN (TO_DAYS('2026-02-01')),
    PARTITION p202602 VALUES LESS THAN (TO_DAYS('2026-03-01')),
    PARTITION p202603 VALUES LESS THAN (TO_DAYS('2026-04-01')),
    PARTITION pmax   VALUES LESS THAN MAXVALUE
);

查询带 WHERE create_time BETWEEN ... 时,MySQL 只扫相关分区(Partition Pruning),性能接近单表。

7.3 分区类型

| 类型 | 适用 |

|---|---|

| RANGE | 按范围(时间、金额) |

| LIST | 按枚举值(地区、品类) |

| HASH | 均匀分布 |

| KEY | 类似 HASH,用 MySQL 自带 hash |

7.4 广播表与绑定表

ShardingSphere 里还有两个常用概念:

  • 广播表(Broadcast Table):像字典表(省份、品类)这种数据量小、更新少的表,在每个分片库都存一份,JOIN 时不用跨库;
  • 绑定表(Binding Table):orders 和 order_item 都按 user_id 分片,同一个 user_id 的订单和订单明细落到同一个分片,JOIN 可以本地完成。

这两种设计能大幅减少跨片 JOIN,是分库分表后保持查询性能的关键。

8 分库分表速查表

| 概念 | 一句话解释 |

|---|---|

| 垂直拆分 | 按业务/字段拆库拆表 |

| 水平拆分 | 按行拆到多表多库 |

| 分片键 | 决定一行落在哪的字段 |

| 取模算法 | hash(key) % N |

| 范围算法 | 按 key 区间分段 |

| 一致性哈希 | 扩容迁移量小 |

| 分布式 ID | Snowflake / 号段 |

| 分区表 | MySQL 自动管理,单表多文件 |

| 双写迁移 | 扩容时同时写新旧表 |

| ShardingSphere | 主流分片中间件 |

关键决策速记:

| 场景 | 推荐方案 |

|---|---|

| 单表 < 1000 万 | 不分,优化 SQL |

| 单表 1000 万 ~ 5000 万 | 读写分离 + 归档 |

| 单表 > 5000 万 | 水平分表 |

| 日志/流水 | 按时间分区或分表 |

| 跨分片 JOIN 多 | 重新考虑分片键 |

9 常见坑与注意事项

坑 1:分片键选错,跨片查询多。

选了 create_time 当分片键,但业务最常查"我的订单"(按 user_id)。每次查都要扫所有分片,性能极差。选分片键前必须先看业务最常用的查询条件。

坑 2:跨片 JOIN。

分库分表后,orders 和 users 在不同库,JOIN 不能直接写。要应用层组装,或者把冗余字段(user_name)存到订单表。

坑 3:分布式事务。

一个业务操作要写两个库(订单库 + 库存库),本地事务管不到。要引入 TCC、Saga、或最终一致(消息队列)。

坑 4:扩容时停机几小时。

大表迁移没做双写,直接停机导数据。几小时停机对核心业务不可接受。必须在线双写。

坑 5:自增主键还想用。

分库分表后每个库自增 ID 会重复。必须用分布式 ID(Snowflake 等)。

坑 6:把分区表当分库分表用。

分区表还是单机,磁盘跑满了还是要拆。分区只解决"单表文件过大、DDL 慢"的问题,不解决水平扩展。

坑 7:分片数一开始就按极限设。

一开始就分 1024 张表,管理复杂、查询慢。一般按"未来 3 年数据量"估算,分 16~64 张起步。

坑 8:跨片分页排序。

SELECT * FROM orders ORDER BY create_time DESC LIMIT 10000, 20 在分片环境下,每个分片都要返回前 10020 条,由中间件合并排序再取 20 条。跨片深分页性能极差,业务上要避免,或者强制带分片键。

10 练习题与参考答案

练习 1:垂直拆分和水平拆分的本质区别是什么?

<details>

<summary>参考答案</summary>

垂直拆分是按业务/字段拆:把不同业务的表拆到不同库,或把一张表的冷字段拆到另一张表。水平拆分是按行拆:同一张表的数据按某个规则分散到 N 张结构相同的表/库。前者解决"业务耦合、单表字段过多",后者解决"单表数据量过大"。

</details>

练习 2:分片键选择要遵循哪几条原则?

<details>

<summary>参考答案</summary>

高基数(值分散、不热点)、高查询频率(业务最常 WHERE 的字段)、不轻易变更(选了难换)、尽量唯一(优先 user_id/order_id)。

</details>

练习 3:订单表 5000 万行,90% 查询是"我的订单"(按 user_id),该怎么分?

<details>

<summary>参考答案</summary>

按 user_id 取模分 16 张表(orders_0 ~ orders_15),分布式 ID 用 Snowflake。"我的订单"查询只路由到一张表,性能好。订单详情查询用 order_id,order_id 里嵌入 user_id 信息(雪花算法或设计时绑定),也能路由到对应分片。

</details>

练习 4:16 张表要扩到 32 张,怎么不停机完成?

<details>

<summary>参考答案</summary>

双写迁移:应用同时按新旧规则写;跑迁移程序把老表数据按新规则导到新表;校验一致后读切新表;观察一段时间停老写。

</details>

练习 5:分区表和分库分表有什么区别?什么时候用分区表?

<details>

<summary>参考答案</summary>

分区表是 MySQL 自动管理的单表多文件,对应用透明,不跨机器;分库分表是应用/中间件路由到多机多表。分区表适合"单表过大但还能单机扛、按时间归档"的场景(如日志);数据量必须水平扩展到多机时才用分库分表。

</details>

练习 6:什么是广播表和绑定表?为什么它们能减少跨片 JOIN?

<details>

<summary>参考答案</summary>

广播表是小字典表在每个分片库都存一份,JOIN 时本地就能完成;绑定表是两张表按相同分片键(如都按 user_id)分片,同一个 user_id 的数据落到同一分片,JOIN 也是本地完成。两者都避免了把数据拉到中间件合并。

</details>

下一篇预告:第 24 章 性能监控与参数调优

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 23 章 分库分表与分区》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。