Database · #database#sharding#partitioning

数据库分库分表策略与实践

2025.02.26 9 min 3.7k
// 目录 · contents

引言

当单表数据量达到千万甚至亿级别时,即使有合理的索引,查询性能也会明显下降;当单库的连接数、QPS达到瓶颈时,单纯的读写分离也难以满足需求。这时候,分库分表(Sharding)就成为必经之路。本文将系统讲解分库分表的策略选择、技术实现和常见问题的解决方案。

为什么需要分库分表

graph TD
    A[业务增长] --> B{瓶颈类型}
    B -->|存储瓶颈<br/>单表数据过大| C[分表]
    B -->|连接/QPS瓶颈<br/>单库压力过大| D[分库]
    B -->|业务耦合<br/>不同业务共享库| E[垂直分库]

    C --> F[水平分表<br/>按行拆分]
    D --> G[水平分库<br/>数据分散到多个库]
    E --> H[按业务域拆分<br/>用户库/订单库/商品库]

什么时候考虑分库分表

指标 阈值(参考) 说明
单表行数 > 5000万 B+Tree层高增加,查询变慢
单表数据量 > 10GB 全表扫描和DDL操作耗时过长
单库QPS > 5000 连接数和CPU接近瓶颈
单库连接数 > 3000 上下文切换开销增大
单库磁盘 > 500GB 备份恢复时间过长

分库分表策略

垂直拆分

垂直分库

按业务域将不同的表拆分到不同的数据库。

graph TD
    subgraph 拆分前
        DB[单一数据库] --> T1[users表]
        DB --> T2[orders表]
        DB --> T3[products表]
        DB --> T4[payments表]
        DB --> T5[inventory表]
    end

    subgraph 拆分后
        DB1[用户库] --> T1a[users表]
        DB1 --> T1b[user_profiles表]
        DB2[订单库] --> T2a[orders表]
        DB2 --> T2b[order_items表]
        DB3[商品库] --> T3a[products表]
        DB3 --> T3b[categories表]
        DB4[支付库] --> T4a[payments表]
        DB5[库存库] --> T5a[inventory表]
    end

垂直分表

将一张宽表按列拆分为多张表,常用列和不常用列分开存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 拆分前: 一张宽表
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(200),
price DECIMAL(10,2),
status TINYINT,
-- 以上为频繁查询的字段
description TEXT,
detail_html MEDIUMTEXT,
spec_json JSON,
-- 以上为不常查询的大字段
created_at DATETIME,
updated_at DATETIME
);

-- 拆分后: 主表 + 详情表
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(200),
price DECIMAL(10,2),
status TINYINT,
created_at DATETIME,
updated_at DATETIME
);

CREATE TABLE product_details (
product_id BIGINT PRIMARY KEY,
description TEXT,
detail_html MEDIUMTEXT,
spec_json JSON
);

水平拆分

水平分表

将同一张表的数据按行拆分到多张表中(同一数据库内)。

graph TD
    subgraph "水平分表"
        A[orders表<br/>1亿行] --> B[orders_0<br/>0-2499万]
        A --> C[orders_1<br/>2500-4999万]
        A --> D[orders_2<br/>5000-7499万]
        A --> E[orders_3<br/>7500-9999万]
    end

水平分库分表

将数据分散到多个数据库的多张表中。

graph TD
    subgraph "水平分库分表"
        A[Application] --> R[路由层]
        R --> DB0[Database 0]
        R --> DB1[Database 1]
        R --> DB2[Database 2]
        R --> DB3[Database 3]

        DB0 --> T00[orders_0]
        DB0 --> T01[orders_1]
        DB1 --> T10[orders_2]
        DB1 --> T11[orders_3]
        DB2 --> T20[orders_4]
        DB2 --> T21[orders_5]
        DB3 --> T30[orders_6]
        DB3 --> T31[orders_7]
    end

分片键(Sharding Key)选择

分片键是分库分表中最关键的设计决策,直接决定了数据分布的均匀性和查询效率。

选择原则

flowchart TD
    A[选择分片键] --> B{最频繁的查询条件?}
    B --> C[确定候选字段]
    C --> D{数据分布是否均匀?}
    D -->|否| E[排除此字段]
    D -->|是| F{是否支持范围查询需求?}
    F -->|需要| G[考虑范围分片]
    F -->|不需要| H[Hash分片]
    G --> I{是否有热点问题?}
    I -->|有| J[组合分片键]
    I -->|无| K[确定分片键]
    H --> K
    J --> K

常见分片键方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 方案1: 按用户ID分片(最常见)
-- 适用: 查询以用户维度为主(查询我的订单、我的消息等)
-- 分片规则: user_id % 库数量 -> 确定库, user_id % 表数量 -> 确定表
-- 优点: 同一用户的数据在同一分片,避免跨库查询
-- 缺点: 大用户(大V、大商家)可能导致数据倾斜

-- 方案2: 按订单ID分片
-- 适用: 查询以订单维度为主
-- 订单ID中嵌入分片信息:
-- 订单号格式: 时间戳(14位) + 用户ID后4位 + 序列号(4位) + 分片号(2位)
-- 20250315143020 + 0001 + 0001 + 03

-- 方案3: 按时间范围分片
-- 适用: 日志、消息等时序数据
-- 按月分表: orders_202501, orders_202502, ...
-- 优点: 范围查询友好,冷数据可归档
-- 缺点: 热点集中在最新分片,负载不均

路由算法

Hash 取模

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 简单Hash取模
public class HashRouter {
private int dbCount; // 库数量
private int tableCount; // 每个库的表数量

public ShardResult route(long shardingKey) {
// 确定库
int dbIndex = (int) (shardingKey % dbCount);
// 确定表
int tableIndex = (int) ((shardingKey / dbCount) % tableCount);
return new ShardResult(dbIndex, tableIndex);
}
}

// 示例: 4个库,每个库8张表
// user_id = 12345
// dbIndex = 12345 % 4 = 1 -> db_1
// tableIndex = (12345/4) % 8 = 3086 % 8 = 6 -> orders_6
// 最终路由到: db_1.orders_6

一致性 Hash

graph TD
    subgraph "一致性Hash环"
        direction TB
        A["Node A<br/>hash: 0-90"]
        B["Node B<br/>hash: 91-180"]
        C["Node C<br/>hash: 181-270"]
        D["Node D<br/>hash: 271-360"]
    end

    E["key1 hash=45"] --> A
    F["key2 hash=150"] --> B
    G["key3 hash=250"] --> C
    H["key4 hash=300"] --> D

一致性Hash的优点是扩容时只需迁移部分数据,但实现较复杂。

范围路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 按时间范围路由
public class RangeRouter {
public String route(LocalDate orderDate) {
// 按月份路由到不同的表
return "orders_" + orderDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
}
}

// 按ID范围路由
public String routeById(long orderId) {
if (orderId < 10_000_000) return "orders_0";
else if (orderId < 20_000_000) return "orders_1";
else if (orderId < 30_000_000) return "orders_2";
else return "orders_3";
}

路由表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 使用路由表(配置表)记录分片映射关系
CREATE TABLE sharding_config (
id BIGINT PRIMARY KEY,
sharding_key_range_start BIGINT,
sharding_key_range_end BIGINT,
db_name VARCHAR(50),
table_name VARCHAR(50),
status TINYINT DEFAULT 1
);

-- 查询路由
SELECT db_name, table_name FROM sharding_config
WHERE sharding_key_range_start <= 12345
AND sharding_key_range_end > 12345;

分布式查询问题

跨分片查询

sequenceDiagram
    participant App as Application
    participant MW as Middleware
    participant DB0 as DB Shard 0
    participant DB1 as DB Shard 1
    participant DB2 as DB Shard 2

    App->>MW: SELECT * FROM orders<br/>WHERE status='paid'<br/>ORDER BY created_at DESC<br/>LIMIT 10

    Note over MW: 无法确定数据在哪个分片<br/>需要广播查询

    MW->>DB0: 相同查询
    MW->>DB1: 相同查询
    MW->>DB2: 相同查询

    DB0->>MW: 10条结果
    DB1->>MW: 10条结果
    DB2->>MW: 10条结果

    Note over MW: 合并30条结果<br/>重新排序<br/>取Top 10

    MW->>App: 最终10条结果

跨分片JOIN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 无法直接跨库JOIN,常见解决方案:

-- 方案1: 全局表(广播表)
-- 小表(如配置表、字典表)在每个分片都存一份
-- 可以在本地JOIN

-- 方案2: 绑定表(ER关系表)
-- 相关联的表使用相同的分片键,保证在同一分片
-- orders 和 order_items 都按 user_id 分片
-- 则同一用户的订单和订单项一定在同一分片

-- 方案3: 应用层JOIN
-- 先查询一个表,再根据结果查询另一个表
-- SELECT * FROM orders WHERE user_id = 1;
-- SELECT * FROM users WHERE id IN (1);
-- 在应用层组装结果

-- 方案4: 宽表冗余
-- 将JOIN需要的字段冗余到主表中
-- orders表中冗余 user_name, user_phone

分布式聚合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 跨分片的COUNT, SUM, AVG等聚合
-- 中间件需要对各分片结果进行二次计算

-- COUNT: 各分片COUNT结果相加
-- SUM: 各分片SUM结果相加
-- AVG: 需要 SUM(各分片SUM) / SUM(各分片COUNT)
-- MAX/MIN: 取各分片MAX/MIN的极值
-- DISTINCT: 各分片结果合并后去重(内存可能不够)

-- ORDER BY + LIMIT (分页问题)
-- 如果 LIMIT 100, 10 (跳过100条取10条)
-- 每个分片都需要查 LIMIT 110
-- 然后在中间件合并排序取10条
-- 深分页问题更加严重!

分布式分页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 分页查询是分库分表后最棘手的问题之一

-- 方案1: 禁止跳页,只支持"下一页"
-- 使用游标分页,携带上一页最后一条记录的排序字段值

-- 方案2: 二次查询法
-- 第一次: 每个分片查 LIMIT offset/N, size (N为分片数)
-- 根据结果确定全局范围
-- 第二次: 在确定的范围内精确查询

-- 方案3: 中间件内存排序(适用于浅分页)
-- offset+size较小时,所有分片取出后内存排序

-- 方案4: ES/搜索引擎辅助
-- 将需要搜索和分页的数据同步到ES
-- 通过ES完成复杂的搜索和分页

分布式ID生成

分库分表后,数据库自增ID无法保证全局唯一,需要分布式ID生成方案。

graph TD
    A[分布式ID方案] --> B[UUID]
    A --> C[数据库号段]
    A --> D[雪花算法 Snowflake]
    A --> E[Redis自增]

    B --> B1["优点: 简单,无依赖<br/>缺点: 无序,不适合做索引"]
    C --> C1["优点: 简单,有序<br/>缺点: 依赖数据库"]
    D --> D1["优点: 有序,高性能,不依赖外部<br/>缺点: 时钟回拨问题"]
    E --> E1["优点: 简单,有序<br/>缺点: 依赖Redis"]

雪花算法(Snowflake)

1
2
3
4
5
6
+-------------------------------------------------------------+
| 1 bit | 41 bits | 10 bits | 12 bits |
| 符号位 | 毫秒级时间戳 | 机器ID | 序列号 |
+-------------------------------------------------------------+
| 0 | 时间(69年可用) | 1024台机器 | 每毫秒4096个ID |
+-------------------------------------------------------------+
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class SnowflakeIdGenerator {
private final long epoch = 1704067200000L; // 2024-01-01
private final long workerIdBits = 10L;
private final long sequenceBits = 12L;

private final long maxWorkerId = ~(-1L << workerIdBits); // 1023
private final long sequenceMask = ~(-1L << sequenceBits); // 4095

private final long workerIdShift = sequenceBits; // 12
private final long timestampShift = sequenceBits + workerIdBits; // 22

private final long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;

public SnowflakeIdGenerator(long workerId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException("Worker ID out of range");
}
this.workerId = workerId;
}

public synchronized long nextId() {
long timestamp = System.currentTimeMillis();

if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards");
}

if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}

lastTimestamp = timestamp;

return ((timestamp - epoch) << timestampShift)
| (workerId << workerIdShift)
| sequence;
}

private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}

ShardingSphere 实战

Apache ShardingSphere是目前最流行的分库分表中间件之一。

ShardingSphere-JDBC 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# application.yml
spring:
shardingsphere:
datasource:
names: ds0,ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/db_0
username: root
password: root
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/db_1
username: root
password: root

rules:
sharding:
tables:
orders:
actual-data-nodes: ds$->{0..1}.orders_$->{0..3}
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: db-hash-mod
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: table-hash-mod
key-generate-strategy:
column: id
key-generator-name: snowflake

# 绑定表: 订单项与订单使用相同分片策略
order_items:
actual-data-nodes: ds$->{0..1}.order_items_$->{0..3}
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: db-hash-mod
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: table-hash-mod

# 绑定表声明
binding-tables:
- orders,order_items

# 广播表(全局表)
broadcast-tables:
- dict_status
- dict_category

sharding-algorithms:
db-hash-mod:
type: HASH_MOD
props:
sharding-count: 2
table-hash-mod:
type: HASH_MOD
props:
sharding-count: 4

key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 1

代码使用(对应用透明)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ShardingSphere-JDBC对应用层透明,代码无需修改
@Repository
public class OrderRepository {

@Autowired
private JdbcTemplate jdbcTemplate;

// 会自动路由到正确的库和表
public Order findByUserIdAndOrderId(long userId, long orderId) {
return jdbcTemplate.queryForObject(
"SELECT * FROM orders WHERE user_id = ? AND id = ?",
new Object[]{userId, orderId},
orderRowMapper
);
}

// 跨分片查询会自动广播并合并结果
public List<Order> findByStatus(String status) {
return jdbcTemplate.query(
"SELECT * FROM orders WHERE status = ? ORDER BY created_at DESC LIMIT 20",
new Object[]{status},
orderRowMapper
);
}
}

数据迁移方案

双写迁移方案

sequenceDiagram
    participant App as Application
    participant Old as 旧单库
    participant New as 新分片库
    participant Sync as 同步工具

    Note over App, Sync: Phase 1: 双写(新旧库同时写入)
    App->>Old: 写入
    App->>New: 写入(异步)
    Note over Sync: 校验数据一致性

    Note over App, Sync: Phase 2: 历史数据迁移
    Sync->>Old: 读取历史数据
    Sync->>New: 写入分片库
    Note over Sync: 增量同步 + 数据校验

    Note over App, Sync: Phase 3: 切读
    App->>New: 读取(新库)
    App->>Old: 读取(旧库作为兜底)
    Note over App: 对比验证读取结果

    Note over App, Sync: Phase 4: 停旧写
    App->>New: 读写都走新库
    Note over Old: 旧库保留观察期后下线

灰度迁移

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 通过Feature Flag控制流量切换
public Order getOrder(long userId, long orderId) {
if (shardingGrayConfig.isNewShardingEnabled(userId)) {
// 新分片库查询
return newShardingOrderDao.findByUserIdAndOrderId(userId, orderId);
} else {
// 旧库查询
return oldOrderDao.findByOrderId(orderId);
}
}

// 灰度规则: 按用户ID尾号逐步切换
// 第1周: userId % 100 < 1 (1%流量)
// 第2周: userId % 100 < 10 (10%流量)
// 第3周: userId % 100 < 50 (50%流量)
// 第4周: 全量切换

总结

分库分表是应对数据量和并发增长的有效手段,但也引入了显著的复杂性:

  • 优先考虑垂直拆分:按业务域拆分的复杂度远低于水平拆分
  • 分片键选择是核心决策:需要综合考虑查询模式、数据均匀度和扩展性
  • 路由算法中Hash取模最简单常用,但扩容时需要数据迁移
  • 跨分片查询是最大挑战:JOIN、聚合、分页都需要特殊处理
  • 分布式ID推荐Snowflake算法,性能高且ID有序
  • ShardingSphere等中间件可以大幅降低应用改造成本
  • 数据迁移建议采用双写+灰度的方式,确保平滑切换
  • 分库分表不是银弹,在决策前应先考虑:读写分离、缓存、归档历史数据、升级硬件等更简单的方案
作者 · authorzt
发布 · date2025-02-26
篇幅 · length3.7k 字 · 9 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论