数据库架构演进与分库分表详解
数据库架构演进与分库分表详解
目录
- 数据库架构演进概览
- 读写分离架构
- 垂直拆分(Vertical Sharding)
- 水平拆分(Horizontal Sharding)
- 拆分后分布式事务问题与解决方案
- 分库分表中间件与框架
- 分库后多表查询操作
- 总结与选型建议
一、数据库架构演进概览
1.1 为什么需要演进
随着业务规模增长,单一数据库逐渐成为瓶颈,典型表现:
┌──────────────────────────────────────────────────────────────┐
│ 单库瓶颈分析 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. 读瓶颈 │
│ - 查询并发数过高,数据库连接池耗尽 │
│ - 复杂查询(聚合、多表 JOIN)拖垮性能 │
│ - 热点数据反复读取,缓存命中率低时压力巨大 │
│ │
│ 2. 写瓶颈 │
│ - 写入并发过高,锁竞争激烈 │
│ - 单表数据量过大(亿级),索引维护成本高,DML 变慢 │
│ - 单机磁盘 IO 瓶颈,无法满足写入吞吐 │
│ │
│ 3. 存储瓶颈 │
│ - 单表数据量过大(千万级),查询性能急剧下降 │
│ - 单库磁盘空间不足 │
│ │
│ 4. 可用性瓶颈 │
│ - 单点故障,数据库宕机则整个业务不可用 │
│ - 备份恢复耗时长,RTO/RPO 不达标 │
│ │
└──────────────────────────────────────────────────────────────┘
1.2 演进路径
单库单表
│
│ 读压力大
▼
读写分离(主从复制)
│
│ 单表数据量大 / 业务耦合
▼
垂直拆分(按业务拆库)
│
│ 单表数据量过大 / 写入瓶颈
▼
水平拆分(分库分表)
│
│ 更高可用性要求
▼
多活架构 / 单元化
二、读写分离架构
2.1 原理
读写分离的核心思想是:利用数据库主从复制,将读操作和写操作分离到不同的数据库实例上。
┌─────────────────┐
│ 应用层 │
│ ┌───────────┐ │
│ │ 路由规则 │ │
│ │ 写→主库 │ │
│ │ 读→从库 │ │
│ └───────────┘ │
└───┬───────┬─────┘
│ │
写操作 │ │ 读操作
┌───────────┘ └────────────┐
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ Master │ 主从复制 │ Slave 集群 │
│ (主库) │─────────────►│ ┌────┐┌────┐┌────┐│
│ │ binlog │ │从库1││从库2││从库3││
│ 写 + 读 │◄──── X ─────│ └────┘└────┘└────┘│
└──────────────┘ (禁止写) └──────────────────┘
主从复制流程:
1. Master 将数据变更写入 binlog(二进制日志)
2. Slave 的 I/O 线程从 Master 拉取 binlog,写入 relay log(中继日志)
3. Slave 的 SQL 线程读取 relay log,重放 SQL 到从库
4. 通过以上三步,从库数据与主库保持同步(存在延迟)
2.2 实现方式
方式一:代码层路由
// 使用 Spring 的 AbstractRoutingDataSource 实现动态数据源
@Component
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 通过 ThreadLocal 获取当前操作的数据源类型
String dataSourceType = DataSourceContextHolder.getDataSourceType();
if (dataSourceType == null) {
return "master"; // 默认主库
}
return dataSourceType;
}
}
// 定义切面,自动切换数据源
@Aspect
@Component
public class DataSourceAspect {
@Around("@annotation(transactional)")
public Object around(ProceedingJoinPoint pjp, Transactional transactional) {
try {
// 读操作 → 从库
DataSourceContextHolder.setDataSourceType("slave");
return pjp.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
@Around("execution(* com.example.service.*.insert*(..)) || " +
"execution(* com.example.service.*.update*(..)) || " +
"execution(* com.example.service.*.delete*(..))")
public Object aroundWrite(ProceedingJoinPoint pjp) throws Throwable {
try {
// 写操作 → 主库
DataSourceContextHolder.setDataSourceType("master");
return pjp.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
}
方式二:中间件代理(推荐)
┌──────────┐
│ 应用 │
└────┬─────┘
│
▼
┌──────────────┐
│ Proxy 代理 │ ← 如 ShardingSphere-Proxy、Mycat、ProxySQL
│ (读写分离) │
└────┬────┬────┘
│ │
▼ ▼
┌──────┐ ┌──────┐
│Master│ │Slave │
└──────┘ └──────┘
ShardingSphere 配置示例:
# ShardingSphere-JDBC 读写分离配置
spring:
shardingsphere:
datasource:
names: master, slave0, slave1
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.1.1:3306/mydb
username: root
password: root
slave0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.1.2:3306/mydb
username: root
password: root
slave1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.1.3:3306/mydb
username: root
password: root
rules:
readwrite-splitting:
data-sources:
mydb:
type: Static
props:
write-data-source-name: master
read-data-source-names: slave0, slave1
load-balancer-name: round_robin
load-balancers:
round_robin:
type: ROUND_ROBIN
2.3 主从延迟问题
读写分离最大的挑战是主从复制延迟,即写操作在主库提交后,从库还没同步到最新数据,读操作可能读到旧数据。
时间线:
T0: 用户写入数据到 Master
T1: Master 写入 binlog
T2: Slave I/O 线程拉取 binlog 到 relay log
T3: Slave SQL 线程重放 relay log
T4: 从库数据更新完成
主从延迟 = T4 - T0(通常几十毫秒到几秒)
解决方案:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 强制走主库 | 写操作后立即读,强制走主库 | 简单但有损性能 |
| 缓存标记 | 写操作后设置缓存标记(如 Redis),读时先检查标记,命中则走主库 | 灵活但增加缓存依赖 |
| 半同步复制 | Master 等待至少一个 Slave 确认收到 binlog 后才返回成功 | 减少延迟但不消除,影响写入性能 |
| 并行复制 | MySQL 5.7+ 支持基于组提交的并行复制,提升从库重放速度 | 减少延迟,但不能完全消除 |
| GTID + 位点追踪 | 记录写操作后的 GTID,读时等待从库追上该 GTID | 精确但实现复杂 |
| 业务容忍 | 对延迟不敏感的业务(如列表查询)接受最终一致性 | 无额外成本,但不是所有场景适用 |
2.4 优缺点
优点:
| 优点 | 说明 |
|---|---|
| 分摊读压力 | 读操作分散到多个从库,主库只负责写,性能大幅提升 |
| 高可用 | 主库故障时可快速切换到从库,提升可用性 |
| 扩展简单 | 添加从库容易,无需修改业务代码(中间件方案) |
| 成本低 | 相比分库分表,读写分离实现简单,成本低 |
缺点:
| 缺点 | 说明 |
|---|---|
| 主从延迟 | 存在数据不一致窗口,对实时性要求高的场景不友好 |
| 写瓶颈仍在 | 主库仍然是单点写入,写性能没有提升 |
| 存储瓶颈仍在 | 所有数据仍在主库,单表数据量问题未解决 |
| 维护成本 | 需要维护主从复制状态,处理复制中断、数据不一致等 |
三、垂直拆分(Vertical Sharding)
3.1 原理
垂直拆分(Vertical Sharding)是将一个数据库中的不同业务表拆分到不同的数据库中,或将一个大表中的列按业务拆分到不同表中。
拆分前:所有表在一个库
┌──────────────────────────────────────────────┐
│ 单库 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 用户表 │ │ 订单表 │ │ 商品表 │ │
│ │ user │ │ order │ │ product │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 支付表 │ │ 物流表 │ │ 评论表 │ │
│ │ payment │ │ logistics│ │ comment │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────┘
拆分后:按业务模块分库
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 用户库 │ │ 订单库 │ │ 商品库 │
│ user │ │ order │ │ product │
│ address │ │ order_item│ │ category │
│ account │ │ payment │ │ sku │
└──────────┘ └──────────┘ └──────────┘
也有第二种垂直拆分:按列拆分(垂直分表)
拆分前:用户表包含所有字段
┌──────────────────────────────────────┐
│ user 表 │
│ id, name, password, phone, email, │
│ avatar, bio, birthday, address, │
│ register_time, last_login_time, │
│ account_balance, points, vip_level │
│ ... (几十个字段) │
└──────────────────────────────────────┘
拆分后:按访问频率拆分
┌────────────────────┐ ┌────────────────────┐
│ user_base │ │ user_extend │
│ (常用字段) │ │ (不常用字段) │
├────────────────────┤ ├────────────────────┤
│ id (PK) │ │ id (PK, FK) │
│ name │ │ bio │
│ password │ │ birthday │
│ phone │ │ address │
│ email │ │ register_time │
│ vip_level │ │ last_login_time │
└────────────────────┘ └────────────────────┘
3.2 为什么这样做
| 原因 | 详细说明 |
|---|---|
| 业务解耦 | 不同业务模块独立发展,互不影响。用户模块的改动不会影响订单模块 |
| 独立扩容 | 每个业务库可以独立扩容,按需分配资源 |
| 故障隔离 | 某个业务库故障不影响其他业务,降低故障爆炸半径 |
| 团队自治 | 不同团队可以独立维护自己的数据库,减少协调成本 |
| 减少表行数和数据量 | 垂直分表后,核心表行数不变但每行数据量变小,IO 效率更高 |
| 减少锁竞争 | 不同业务的表在不同库,互不锁等待 |
3.3 实现方式
1. 确定拆分维度
拆分原则:
1. 业务内聚:同一个业务域的表放在同一个库
2. 关联紧密的表不拆分:如 order 和 order_item 应在同一库
3. 核心表独立:高频访问的核心表优先独立成库
4. 数据量均衡:各库数据量尽量均衡
2. 数据源配置
# 多数据源配置
spring:
datasource:
user:
url: jdbc:mysql://192.168.1.1:3306/user_db
username: root
password: root
order:
url: jdbc:mysql://192.168.1.2:3306/order_db
username: root
password: root
product:
url: jdbc:mysql://192.168.1.3:3306/product_db
username: root
password: root
3. 代码层路由
// 方式一:使用分包策略
// 每个业务模块有独立的 Mapper 包,关联不同的数据源
// 用户模块
@MapperScan(basePackages = "com.example.mapper.user",
sqlSessionTemplateRef = "userSqlSessionTemplate")
public class UserDataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.user")
public DataSource userDataSource() {
return DataSourceBuilder.create().build();
}
}
// 订单模块
@MapperScan(basePackages = "com.example.mapper.order",
sqlSessionTemplateRef = "orderSqlSessionTemplate")
public class OrderDataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.order")
public DataSource orderDataSource() {
return DataSourceBuilder.create().build();
}
}
// 方式二:使用 ShardingSphere 分库配置
spring:
shardingsphere:
datasource:
names: user_db, order_db, product_db
user_db:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://localhost:3306/user_db
...
order_db:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://localhost:3306/order_db
...
product_db:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://localhost:3306/product_db
...
rules:
sharding:
tables:
user:
actual-data-nodes: user_db.user
order:
actual-data-nodes: order_db.order
product:
actual-data-nodes: product_db.product
3.4 优缺点
优点:
| 优点 | 说明 |
|---|---|
| 业务清晰 | 每个数据库对应一个业务域,结构清晰,易于维护 |
| 独立扩容 | 哪个业务库撑不住了就扩哪个,资源利用率高 |
| 故障隔离 | 用户库故障不影响订单库,故障范围可控 |
| 实现简单 | 只需按表名路由到不同数据源,不涉及分片算法 |
缺点:
| 缺点 | 说明 |
|---|---|
| 无法解决单表数据量问题 | 表的数据量并没有减少,单表亿级数据仍然慢 |
| 跨库 JOIN 困难 | 用户表和订单表在不同库,无法直接 SQL JOIN |
| 分布式事务 | 跨库操作需要分布式事务保证一致性 |
| 拆分粒度难把握 | 拆得太细,跨库操作多;拆得太粗,各库压力不均 |
| 数据迁移复杂 | 业务增长后可能需要从垂直拆分升级到水平拆分,二次迁移 |
四、水平拆分(Horizontal Sharding)
4.1 原理
水平拆分(Horizontal Sharding)是将同一张表的数据按某种规则分散到多个数据库的多张结构相同的表中,每个分片只存储一部分数据。
拆分前:一张大表
┌─────────────────────────────────┐
│ order 表 │
│ 1 亿行数据,查询缓慢 │
│ ┌──────────────────────────┐ │
│ │ id=1 ~ id=10000000 │ │
│ │ id=10000001 ~ id=20000000│ │
│ │ ... │ │
│ │ id=90000001 ~ id=100000000│ │
│ └──────────────────────────┘ │
└─────────────────────────────────┘
拆分后:按 user_id 取模分散到 4 个库
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ db_0 │ │ db_1 │ │ db_2 │ │ db_3 │
│ order_0 │ │ order_0 │ │ order_0 │ │ order_0 │
│ order_1 │ │ order_1 │ │ order_1 │ │ order_1 │
│ │ │ │ │ │ │ │
│ user_id % 4 │ │ user_id % 4 │ │ user_id % 4 │ │ user_id % 4 │
│ = 0 │ │ = 1 │ │ = 2 │ │ = 3 │
│ │ │ │ │ │ │ │
│ 每表 ~2500万 │ │ 每表 ~2500万 │ │ 每表 ~2500万 │ │ 每表 ~2500万 │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
4.2 分片策略
策略一:取模分片(Mod Sharding)
算法:shard_key % shard_count
优点:数据分布均匀,实现简单
缺点:扩容时需要大量数据迁移(如从 4 库扩到 5 库,几乎所有数据都要重新路由)
示例:
user_id = 1001 → 1001 % 4 = 1 → db_1
user_id = 1002 → 1002 % 4 = 2 → db_2
user_id = 1003 → 1003 % 4 = 3 → db_3
user_id = 1004 → 1004 % 4 = 0 → db_0
策略二:范围分片(Range Sharding)
算法:按 shard_key 的范围区间分配到不同分片
优点:扩容简单(只需新增分片,调整范围即可)
缺点:数据分布可能不均匀(热点数据集中在某个分片)
示例:
order_id 1 ~ 10000000 → db_0
order_id 10000001 ~ 20000000 → db_1
order_id 20000001 ~ 30000000 → db_2
order_id 30000001 ~ 40000000 → db_3
策略三:一致性哈希分片(Consistent Hash Sharding)
算法:将分片节点映射到哈希环上,数据 key 也映射到环上,
顺时针找到第一个分片节点
┌─────────────────────────────┐
│ 哈希环 (0~2^32-1) │
│ │
│ db_0 │
│ / \ │
│ / \ │
│ / \ │
│ db_3 db_1 │
│ \ / │
│ \ / │
│ \ / │
│ db_2 │
│ │
└─────────────────────────────┘
优点:扩容时只需迁移环上相邻节点的部分数据,迁移量小
缺点:实现复杂,数据分布可能不均匀(需引入虚拟节点解决)
策略四:复合分片(Composite Sharding)
算法:使用多个字段组合进行分片
示例:基因法
- 将 user_id 的后几位拼接到 order_id 中
- 根据 user_id 分片的订单,查询时只需 user_id 就能定位分片
- 同时支持 order_id 查询(解析 order_id 中的 user_id 后缀)
order_id = 雪花ID + user_id 后 N 位
4.3 分片键(Sharding Key)的选择
分片键是水平拆分中最重要的设计决策,直接影响后续所有查询的效率。
| 选择原则 | 说明 |
|---|---|
| 高区分度 | 数据分布均匀,避免热点分片 |
| 查询频率高 | 大部分查询都带有这个字段,能路由到单分片 |
| 业务稳定 | 字段值不会频繁变更 |
| 允许冗余 | 可以多字段冗余存储,支持多维度查询路由 |
好的分片键: user_id(多租户场景)、order_id(订单场景)
不好的分片键: status(区分度低)、gender(只有两个值)
4.4 实现方式
ShardingSphere-JDBC 配置示例
spring:
shardingsphere:
datasource:
names: ds0, ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.1.1: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://192.168.1.2:3306/db_1
username: root
password: root
rules:
sharding:
# 分片算法
sharding-algorithms:
database-inline:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 2} # 按 user_id 取模分库
order-table-inline:
type: INLINE
props:
algorithm-expression: order_$->{user_id % 4} # 按 user_id 取模分表
order-id-mod:
type: MOD
props:
sharding-count: 2 # 按 order_id 取模分库
# 分片键绑定
binding-tables:
- order, order_item # 绑定表:order 和 order_item 按相同规则分片,避免跨库 JOIN
# 广播表(每个库都有全量数据)
broadcast-tables:
- t_config # 配置表在每个分片库中都有完整副本
# 分片规则
tables:
order:
actual-data-nodes: ds$->{0..1}.order_$->{0..3} # 2 库 × 4 表 = 8 个分片
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: database-inline
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: order-table-inline
order_item:
actual-data-nodes: ds$->{0..1}.order_item_$->{0..3}
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: database-inline
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: order-table-inline
props:
sql-show: true # 打印 SQL,方便调试
自定义分片算法
// 一致性哈希分片算法
public class ConsistentHashShardingAlgorithm implements StandardShardingAlgorithm<Long> {
private final TreeMap<Long, String> ring = new TreeMap<>();
private static final int VIRTUAL_NODES = 150; // 虚拟节点数
@Override
public void init(Properties props) {
// 初始化哈希环
List<String> dataSources = Arrays.asList("ds0", "ds1", "ds2", "ds3");
for (String ds : dataSources) {
for (int i = 0; i < VIRTUAL_NODES; i++) {
long hash = hash(ds + "-VN" + i);
ring.put(hash, ds);
}
}
}
@Override
public String doSharding(Collection<String> availableTargetNames,
PreciseShardingValue<Long> shardingValue) {
Long userId = shardingValue.getValue();
long hash = hash(String.valueOf(userId));
// 顺时针找到第一个虚拟节点
Map.Entry<Long, String> entry = ring.ceilingEntry(hash);
if (entry == null) {
entry = ring.firstEntry(); // 回到环的起点
}
return entry.getValue();
}
private long hash(String key) {
// 使用 MurmurHash 或 MD5 等哈希算法
return Hashing.murmur3_32().hashString(key, StandardCharsets.UTF_8).asLong() & 0x7FFFFFFF;
}
}
4.5 优缺点
优点:
| 优点 | 说明 |
|---|---|
| 解决单表数据量问题 | 数据分散到多个库/表,每表数据量可控,查询性能不随总量增长而下降 |
| 提升写入性能 | 写入分散到多个库,并发写入能力线性提升 |
| 独立扩容 | 分片可以分布在不同的物理机上,存储和计算资源可水平扩展 |
| 高可用 | 单个分片故障只影响部分数据,整体服务可用性高 |
缺点:
| 缺点 | 详细说明 |
|---|---|
| 跨分片查询复杂 | 不带分片键的查询需要扫描所有分片,性能差 |
| 跨分片 JOIN 困难 | 关联数据分散在不同分片,无法直接 SQL JOIN |
| 分布式事务 | 跨分片操作需要分布式事务,增加复杂度 |
| 分片扩容困难 | 取模分片扩容需大量数据迁移,范围分片可能数据不均 |
| 全局唯一 ID | 自增主键在分片后不再全局唯一,需要使用雪花算法等方案 |
| SQL 功能受限 | ORDER BY、GROUP BY、DISTINCT 等需要跨分片合并结果 |
| 运维复杂 | 数据备份、恢复、迁移等运维操作变得复杂 |
五、拆分后分布式事务问题与解决方案
此章与《分布式事务详解》文档关联,此处重点讲解分库分表场景下的分布式事务问题。
5.1 分库分表带来的事务问题
场景:电商下单,订单表在 db_order,库存表在 db_inventory,账户表在 db_account
BEGIN TRANSACTION;
-- 操作 db_order
INSERT INTO order (user_id, amount) VALUES (1001, 100);
-- 操作 db_inventory
UPDATE inventory SET stock = stock - 1 WHERE product_id = 2001;
-- 操作 db_account
UPDATE account SET balance = balance - 100 WHERE user_id = 1001;
COMMIT;
问题:这三个操作分布在三个不同的数据库,单机数据库事务无法跨库保证原子性!
5.2 解决方案总览
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 低 | 低 | 传统企业应用 |
| TCC | 最终一致 | 高 | 高 | 金融/支付 |
| Saga | 最终一致 | 高 | 中 | 长事务 |
| AT 模式(Seata) | 最终一致 | 高 | 低 | 通用互联网业务 |
| 可靠消息 | 最终一致 | 极高 | 中 | 异步解耦场景 |
| 本地消息表 | 最终一致 | 高 | 中 | 无事务消息 MQ 的场景 |
5.3 分库分表场景下的实践建议
分库分表 + 分布式事务的常见组合:
1. ShardingSphere + Seata AT 模式
- 最推荐的组合,配置简单,对业务零侵入
- ShardingSphere 处理分片路由,Seata 处理分布式事务
2. 分库后跨库操作尽量少
- 设计时就考虑数据亲和性,把关联频繁的表放在同一分片
- 如:order 和 order_item 通过 binding-tables 配置在同一分片
3. 优先使用最终一致性方案
- 分库分表的场景几乎都是高并发互联网场景
- 远程求最终一致性优先于强一致性
4. 消息队列 + 幂等
- 跨库操作通过 MQ 异步通知,消费端幂等处理
- 如:订单创建后发消息通知库存服务扣库存
各方案的详细原理、补偿机制、优缺点请参见《分布式事务详解》文档。
六、分库分表中间件与框架
6.1 主流方案对比
| 中间件 | 类型 | 架构模式 | 成熟度 | 社区活跃度 | 推荐指数 |
|---|---|---|---|---|---|
| ShardingSphere-JDBC | SDK | 客户端分片 | 高 | 极高 | ★★★★★ |
| ShardingSphere-Proxy | Proxy | 代理分片 | 高 | 极高 | ★★★★☆ |
| Mycat | Proxy | 代理分片 | 中 | 中 | ★★★☆☆ |
| Vitess | Proxy | 代理分片 | 高 | 高 | ★★★★☆ |
| DRDS | Proxy | 代理分片(阿里云) | 高 | 商业 | ★★★★☆ |
| TDSQL | Proxy | 代理分片(腾讯云) | 高 | 商业 | ★★★★☆ |
6.2 ShardingSphere(最推荐)
Apache ShardingSphere 是目前最流行的开源分库分表解决方案,生态完善。
架构对比
客户端模式(ShardingSphere-JDBC):
┌─────────────────────────────────┐
│ 应用进程 │
│ ┌───────────────────────────┐ │
│ │ ShardingSphere-JDBC │ │
│ │ (作为 JDBC 驱动层) │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │分片 │ │读写 │ │加密 │ │ │
│ │ │路由 │ │分离 │ │脱敏 │ │ │
│ │ └─────┘ └─────┘ └─────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────┐ ┌────┐ ┌────┐
│DB0 │ │DB1 │ │DB2 │
└────┘ └────┘ └────┘
优点:性能高(无额外网络跳转)、部署简单
缺点:语言绑定(仅 Java)、应用侧维护分片配置
代理模式(ShardingSphere-Proxy):
┌──────────┐ ┌──────────┐
│ 应用(Java)│ │ 应用(Go) │
└────┬─────┘ └────┬─────┘
│ │
└──────┬──────┘
▼
┌───────────────────────┐
│ ShardingSphere-Proxy │ ← 独立部署的代理服务
│ (MySQL 协议兼容) │
└───────┬───────┬───────┘
│ │
▼ ▼
┌────┐ ┌────┐
│DB0 │ │DB1 │
└────┘ └────┘
优点:多语言支持、配置集中管理、对应用透明
缺点:多一层网络跳转、需要独立部署运维
核心功能
| 功能 | 说明 |
|---|---|
| 数据分片 | 支持水平分库分表,多种分片算法 |
| 读写分离 | 自动路由读写操作 |
| 分布式事务 | 集成 Seata,支持 AT/TCC/Saga/XA |
| 数据加密 | 透明数据加密脱敏 |
| 影子库 | 全链路压测 |
| 分布式治理 | 配置中心、注册中心集成 |
快速接入
<!-- pom.xml -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.4.0</version>
</dependency>
# 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/db0
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/db1
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds$->{0..1}.t_order_$->{0..1}
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: db-inline
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: tbl-inline
sharding-algorithms:
db-inline:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 2}
tbl-inline:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 2}
业务代码完全不用改,正常使用 MyBatis/JPA 即可:
@Mapper
public interface OrderMapper {
// 正常写 SQL,ShardingSphere 自动路由
@Insert("INSERT INTO t_order (user_id, order_id, amount) VALUES (#{userId}, #{orderId}, #{amount})")
int insert(Order order);
@Select("SELECT * FROM t_order WHERE user_id = #{userId} AND order_id = #{orderId}")
Order selectByUserIdAndOrderId(@Param("userId") Long userId, @Param("orderId") Long orderId);
}
6.3 Mycat
Mycat 是较早的开源数据库中间件,以 Proxy 模式运行。
优点:
- 完全兼容 MySQL 协议,对应用透明
- 支持多语言客户端
- 社区版免费
缺点:
- 社区活跃度下降,更新缓慢
- 文档不够完善
- 性能相比 ShardingSphere-Proxy 有差距
- 分布式事务支持较弱
6.5 云厂商方案
| 方案 | 厂商 | 特点 |
|---|---|---|
| DRDS | 阿里云 | 从 TDDL 演进而来,兼容 MySQL 协议,支持分布式事务、全局二级索引 |
| TDSQL | 腾讯云 | 支持分布式事务,兼容 MySQL,自动水平拆分 |
| Amazon Aurora | AWS | 存算分离架构,自动扩容,但非分片方案 |
| CockroachDB | 开源 | 原生分布式 SQL 数据库,自动分片,强一致 |
6.6 接入难度评估
| 方案 | 代码改动量 | 运维复杂度 | 学习成本 | 总难度 |
|---|---|---|---|---|
| ShardingSphere-JDBC | 低(仅配置) | 低 | 中 | ★★☆☆☆ |
| ShardingSphere-Proxy | 零(应用无感) | 中 | 中 | ★★☆☆☆ |
| Mycat | 零(应用无感) | 中 | 中 | ★★★☆☆ |
| Vitess | 零(应用无感) | 高 | 高 | ★★★★☆ |
| 云厂商方案 | 零 | 低(云托管) | 中 | ★★☆☆☆ |
结论:对于大多数 Java 项目,ShardingSphere-JDBC 是最简单、最推荐的选择。
七、分库后多表查询操作
7.1 核心挑战
分库分表后,以下操作变得困难:
挑战 1:跨分片 JOIN
订单表在 db0,用户表在 db1,无法直接 JOIN
挑战 2:不带分片键的查询
根据商品名称查订单,需要扫描所有分片
挑战 3:跨分片排序和分页
ORDER BY create_time DESC LIMIT 10 需要全局排序
挑战 4:跨分片聚合
COUNT、SUM、AVG 需要跨分片聚合
挑战 5:全局唯一 ID
分片后自增主键不再全局唯一
7.2 全局唯一 ID 生成
雪花算法(Snowflake)
雪花算法 ID 结构(64 位):
┌─┬───────────────────────────────────────────────────────────────┐
│0│ 41 位时间戳(毫秒) │ 10 位机器ID │ 12 位序列号 │
│ │ 可用 69 年 │ 1024 台机器 │ 每毫秒 4096 │
└─┴───────────────────────────────────────────────────────────────┘
优点:趋势递增、高性能、不依赖数据库
缺点:依赖机器时钟,时钟回拨时可能重复
// 使用 Hutool 工具类
long id = IdUtil.getSnowflake(1, 1).nextId();
// MyBatis-Plus 自动生成雪花 ID
@Data
@TableName("t_order")
public class Order {
@TableId(type = IdType.ASSIGN_ID) // 雪花算法
private Long id;
private Long userId;
private BigDecimal amount;
}
其他方案
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 数据库自增 | 每个分片使用不同步长(如 db0 自增步长=4 起始=1,db1 步长=4 起始=2) | 简单但扩展性差,迁移困难 |
| Redis 自增 | INCR 命令生成唯一 ID | 依赖 Redis 高可用 |
| 号段模式 | 从数据库批量获取 ID 号段,应用内分配 | 如美团 Leaf Segments 模式 |
| 雪花算法 + 号段 | 如美团 Leaf,Snowflake + Segments 双模式 | 工业级方案,值得参考 |
7.3 不带分片键的查询
方案一:全分片扫描(Broadcast)
原理:把查询发送到所有分片,在内存中合并结果
SELECT * FROM t_order WHERE status = 'PAID' ORDER BY create_time DESC LIMIT 10;
执行流程:
1. 将 SQL 发送到 ds0, ds1 两个分片
2. 每个分片返回 status='PAID' 的 ORDER BY create_time DESC LIMIT 10 的结果
3. 在内存中合并 2×10=20 条结果,重新排序,取前 10 条
ShardingSphere 支持: 自动识别不带分片键的查询,执行全分片路由
// 不带分片键的查询,ShardingSphere 自动广播到所有分片
@Select("SELECT * FROM t_order WHERE status = #{status}")
List<Order> selectByStatus(@Param("status") String status);
缺点: 分片越多性能越差,不适合大数据量场景
方案二:ES 与数据库异构(推荐)
架构:MySQL 做存储,Elasticsearch 做查询
┌──────────┐ binlog 同步 ┌──────────┐
│ MySQL │──────────────►│ Canal │
│ (分片存储) │ │ (数据同步) │
└──────────┘ └─────┬────┘
│
▼
┌──────────┐
│ ES 集群 │
│ (全文检索) │
└─────┬────┘
│
▼
┌──────────┐
│ 应用查询 │
│ ES 查 ID │
│ MySQL 查详情│
└──────────┘
查询流程:
1. 用户输入商品名称搜索订单
2. 应用在 ES 中搜索 → 获得 order_id 列表
3. 根据 order_id(分片键)到 MySQL 对应分片查询详情
方案三:全局二级索引
原理:在分片规则之外,额外维护一个全局索引表
CREATE TABLE global_index_order_status (
status VARCHAR(20),
order_id BIGINT,
user_id BIGINT, -- 分片键
PRIMARY KEY (status, order_id)
);
查询流程:
1. SELECT order_id, user_id FROM global_index_order_status WHERE status = 'PAID'
2. 根据 user_id 确定分片
3. 到对应分片查询订单详情
注意: 全局索引表本身也面临分片压力,需要谨慎设计。
7.4 跨分片 JOIN
方案一:绑定表(Binding Tables)
原理:将关联紧密的表按相同规则分片,确保关联数据在同一分片
# ShardingSphere 配置
spring:
shardingsphere:
rules:
sharding:
binding-tables:
- t_order, t_order_item # 绑定表,按相同分片规则分片
效果:
user_id=1001 的订单和订单明细都在同一分片
→ 可以在同一分片内直接 JOIN
这是最推荐的方案,在分片设计时就考虑好数据亲和性。
方案二:多次查询(应用层合并)
// 场景:订单表按 user_id 分片,商品表按 product_id 分片
// 需要查询订单及其商品信息
public OrderDetailVO getOrderWithProduct(Long orderId, Long userId) {
// 1. 根据分片键 user_id 查询订单(单分片查询)
Order order = orderMapper.selectById(orderId, userId);
// 2. 从订单中获取 product_id
Long productId = order.getProductId();
// 3. 根据 product_id 查询商品(可能又是一个分片查询)
Product product = productMapper.selectById(productId);
// 4. 应用层组装结果
return OrderDetailVO.builder()
.order(order)
.product(product)
.build();
}
方案三:聚合宽表
原理:将需要 JOIN 的数据提前聚合到一张宽表中
CREATE TABLE order_wide (
order_id BIGINT,
user_id BIGINT,
product_id BIGINT,
product_name VARCHAR(200), -- 冗余商品名
product_price DECIMAL(10,2), -- 冗余商品价格
user_name VARCHAR(100), -- 冗余用户名
user_phone VARCHAR(20), -- 冗余手机号
order_status VARCHAR(20),
create_time DATETIME,
PRIMARY KEY (order_id)
) SHARDING BY user_id;
优点:查询时只需单表查询,无需 JOIN
缺点:数据冗余,需要维护一致性
7.5 跨分片排序与分页
问题
SELECT * FROM t_order ORDER BY create_time DESC LIMIT 10 OFFSET 1000;
如果分片为 ds0 和 ds1:
- 每个分片返回 ORDER BY create_time DESC LIMIT 1010 的结果
- 内存中合并 2×1010=2020 条数据,重新排序
- 偏移量越大,每个分片需要返回的数据越多,性能越差
解决方案
游标分页(推荐):
-- 不使用 OFFSET,使用游标(WHERE create_time < last_time)
SELECT * FROM t_order
WHERE user_id = #{userId}
AND create_time < #{lastCreateTime}
ORDER BY create_time DESC
LIMIT 10;
二次查询法(ShardingSphere 支持):
原理:
1. 第一轮:每个分片查询 LIMIT offset+limit,但只返回排序字段和主键
2. 合并排序后,确定目标分页的主键范围
3. 第二轮:根据主键精确查询完整数据
ShardingSphere 配置:
props:
sql-show: true
# 分页优化由 ShardingSphere 自动处理
业务改造:
// 1. 禁止跳页,只允许上一页/下一页
// 2. 限制最大翻页深度(如最多翻到第 100 页)
// 3. 在列表页使用 ES 搜索替代 SQL 分页查询
7.6 跨分片聚合
-- 问题:COUNT、SUM、AVG 等聚合函数
SELECT COUNT(*), SUM(amount) FROM t_order WHERE status = 'PAID';
-- ShardingSphere 处理方式:
-- 1. 每个分片各自执行聚合
-- 2. 在内存中合并结果
-- COUNT: SUM(每个分片的 COUNT)
-- SUM: SUM(每个分片的 SUM)
-- AVG: SUM(每个分片的 SUM) / SUM(每个分片的 COUNT)
-- MAX: MAX(每个分片的 MAX)
-- MIN: MIN(每个分片的 MIN)
注意: COUNT(DISTINCT col) 无法简单合并,因为同一个值可能出现在多个分片。ShardingSphere 会执行 SELECT DISTINCT col FROM ... 跨分片去重合并。
7.7 查询操作总结
| 查询类型 | 推荐方案 | 难度 |
|---|---|---|
| 带分片键的单表查询 | 直接查询,框架自动路由 | ★☆☆☆☆ |
| 带分片键的关联查询 | 绑定表(binding-tables) | ★☆☆☆☆ |
| 不带分片键的查询 | ES 异构 + 二次查询 | ★★★☆☆ |
| 跨分片 JOIN | 宽表 + 多次查询 | ★★★☆☆ |
| 跨分片分页 | 游标分页,禁止跳页 | ★★☆☆☆ |
| 跨分片聚合 | 使用 ShardingSphere 自动合并 | ★★☆☆☆ |
| 全局二级索引 | 自建索引表 + 二次查询 | ★★★★☆ |
八、总结与选型建议
8.1 演进路径总结
阶段 1: 单库单表
│ 问题:读写压力大、单表数据量过大
│
▼
阶段 2: 读写分离
│ 解决:读瓶颈
│ 遗留:写瓶颈、存储瓶颈、单表过大
│
▼
阶段 3: 垂直拆分
│ 解决:业务解耦、独立扩容
│ 遗留:单表数据量问题、跨库 JOIN
│
▼
阶段 4: 水平拆分
│ 解决:单表数据量、写瓶颈
│ 遗留:跨分片查询、分布式事务、运维复杂
│
▼
阶段 5: 完善治理
解决:使用中间件简化分片管理
引入 ES 解决查询问题
分布式事务保证一致性
数据异构提升查询性能
8.2 什么时候该做
| 场景 | 阈值参考 | 推荐方案 |
|---|---|---|
| 读 QPS 过高 | 单库 QPS > 2000 | 读写分离 |
| 单表数据量过大 | 单表 > 500 万行 | 水平分表 |
| 单库数据量过大 | 单库 > 500GB | 垂直拆分 + 水平拆分 |
| 写入压力大 | 单库写入 TPS > 1000 | 水平拆分 |
| 业务耦合严重 | 多个业务模块共享一个库 | 垂直拆分 |
| 高可用要求 | 单点故障不可接受 | 主从 + 多活 |
8.3 最终建议
- 不要过早优化:单表千万级以内,MySQL + 索引 + 缓存通常够用,不要为了分库分表而分库分表
- 从读写分离开始:大多数场景下,读写分离就能解决 80% 的问题
- 优先垂直拆分:比水平拆分简单,业务收益大
- 水平拆分是最后手段:复杂度高,需要充分评估
- 选对分片键:分片键的设计决定了整个分片方案的成败,花 80% 的时间在分片键设计上
- 使用 ShardingSphere-JDBC:对于 Java 项目,这是最简单、最成熟的选择
- 配合 ES 做查询:分库分表后,复杂查询应交给 ES,MySQL 只做基于分片键的精确查询
- 分布式事务用 Seata:最佳实践是 ShardingSphere + Seata 组合
文档版本: v1.0
更新日期: 2026 年 7 月
更多推荐
所有评论(0)