系统分库分表策略
系统分库分表策略
1. 核心思想
为了应对高并发和海量数据存储的挑战,我们引入了数据库分库分表机制。通过将数据水平拆分到不同的数据库和表中,可以有效分散系统负载,提高查询效率和系统的可扩展性。本项目采用 ShardingSphere
作为分库分表的中间件,根据不同业务服务的特点,设计了定制化的分片策略。
2. 用户服务分库分表策略
用户服务是系统的核心,其分库分表策略需要同时兼顾用户ID查询和通过手机号/邮箱登录的场景。为此,我们采用了 “主表分片 + 辅助表路由” 的方案。
2.1. ShardingSphere 配置 (shardingsphere-user.yaml
)
rules:
# 分库分表规则
- !SHARDING
tables:
# 用户主表: 按 id 取模分片
d_user:
actualDataNodes: ds_${0..1}.d_user_${0..1}
databaseStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: databaseUserModModel
tableStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: tableUserModModel
# 用户手机号辅助表: 按 mobile 哈希取模分片
d_user_mobile:
actualDataNodes: ds_${0..1}.d_user_mobile_${0..1}
databaseStrategy:
standard:
shardingColumn: mobile
shardingAlgorithmName: databaseUserMobileHashModModel
tableStrategy:
standard:
shardingColumn: mobile
shardingAlgorithmName: tableUserMobileHashMod
# 购票人表: 按 user_id 取模分片,与用户表保持数据亲和性
d_ticket_user:
actualDataNodes: ds_${0..1}.d_ticket_user_${0..1}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: databaseTicketUserModModel
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: tableTicketUserModModel
# 具体的算法定义
shardingAlgorithms:
# d_user 和 d_ticket_user 使用 MOD 算法
databaseUserModModel:
type: MOD
props:
sharding-count: 2
tableUserModModel:
type: MOD
props:
sharding-count: 2
databaseTicketUserModModel:
type: MOD
props:
sharding-count: 2
tableTicketUserModModel:
type: MOD
props:
sharding-count: 2
# d_user_mobile 使用 HASH_MOD 算法
databaseUserMobileHashModModel:
type: HASH_MOD
props:
sharding-count: 2
tableUserMobileHashMod:
type: HASH_MOD
props:
sharding-count: 2
2.2. 策略详解
- 核心表分片:
d_user
(用户主表): 使用id
作为分片键,并采用MOD
(取模) 算法。这保证了基于用户ID的查询能够被精确定位到单个库和表,性能极高。d_ticket_user
(购票人表): 使用user_id
作为分片键和MOD
算法。这确保了同一用户及其所有购票人的数据被存储在同一分片上,便于进行关联查询,实现了数据亲和性。
- 辅助表路由方案:
- 问题: 如果直接使用
mobile
或email
查询以id
分片的d_user
表,ShardingSphere无法定位数据,将触发 “全路由”(扫描所有分片),导致性能灾难。 - 解决方案: 创建
d_user_mobile
和d_user_email
辅助表。- 这两张表分别使用
mobile
和email
作为分片键,并采用HASH_MOD
算法。HASH_MOD
能将字符串类型的分片键(如手机号)均匀地散列到各个分片,避免数据倾斜。 - 工作流程: 登录时,先根据
mobile
精准查询d_user_mobile
表得到user_id
,再用user_id
精准查询d_user
表,从而避免了全路由。
- 这两张表分别使用
- 代价: 这种方案以增加一次查询和额外的存储为代价,换取了核心登录业务的高性能和系统的可扩展性。
- 问题: 如果直接使用
3. 节目服务分库分表策略
节目服务的数据具有明显的聚合性,即场次、座位等信息都围绕“节目”这一核心实体。策略核心是保证与同一节目相关的数据存储在一起。
3.1. ShardingSphere 配置 (shardingsphere-program.yaml
)
rules:
- !SHARDING
tables:
# 节目主表: 按 id 分片
d_program:
actualDataNodes: ds_${0..1}.d_program_${0..1}
databaseStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: databaseProgramModModel
tableStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: tableProgramModModel
# 节目相关表: 按 program_id 分片
d_seat:
actualDataNodes: ds_${0..1}.d_seat_${0..1}
databaseStrategy:
standard:
shardingColumn: program_id
shardingAlgorithmName: databaseSeatModModel
tableStrategy:
standard:
shardingColumn: program_id
shardingAlgorithmName: tableSeatModModel
# 广播表: 在所有库中都有一份完整数据
broadcastTables:
- d_program_category
shardingAlgorithms:
databaseProgramModModel:
type: MOD
props:
sharding-count: 2
# ... 其他表的算法定义类似
3.2. 策略详解
- 关联分片:
d_program
表使用其主键id
分片,而其所有附属表(如d_program_show_time
,d_seat
等)都使用外键program_id
作为分片键。所有表都使用相同的MOD
算法。这确保了查询某一节目及其所有相关信息时,所有SQL操作都会被路由到同一个物理分片,避免了跨库JOIN
,性能极佳。 - 广播表:
d_program_category
(节目分类表) 数据量小、变动少且被频繁引用。将其设为广播表,会在每个物理库中都冗余一份全量数据。这样,任何分片在需要关联分类信息时,都可以在本地库完成,极大地提升了查询效率。
4. 订单服务分库分表策略 (分片基因法)
订单服务需要同时支持通过 order_number
(订单号) 和 user_id
(用户ID) 进行高效查询。为解决此问题,我们采用了创新的 “分片基因法”,无需引入辅助表。
4.1. ShardingSphere 配置 (shardingsphere-order.yaml
)
rules:
- !SHARDING
tables:
# 订单表: 采用复合分片键和自定义算法
d_order:
actualDataNodes: ds_${0..1}.d_order_${0..3}
databaseStrategy:
complex:
shardingColumns: order_number,user_id
shardingAlgorithmName: databaseOrderComplexGeneArithmetic
tableStrategy:
complex:
shardingColumns: order_number,user_id
shardingAlgorithmName: tableOrderComplexGeneArithmetic
# 绑定表: d_order 和 d_order_ticket_user 使用完全相同的分片逻辑
bindingTables:
- d_order,d_order_ticket_user
shardingAlgorithms:
# 分库算法: 自定义CLASS_BASED类型
databaseOrderComplexGeneArithmetic:
type: CLASS_BASED
props:
sharding-count: 2
table-sharding-count: 4
strategy: complex
algorithmClassName: com.codelong.shardingsphere.DatabaseOrderComplexGeneArithmetic
# 分表算法: 自定义CLASS_BASED类型
tableOrderComplexGeneArithmetic:
type: CLASS_BASED
props:
sharding-count: 4
strategy: complex
algorithmClassName: com.codelong.shardingsphere.TableOrderComplexGeneArithmetic
4.2. 算法详解
核心思想: 在生成 order_number
时,将 user_id
对分表数取模的结果(即“基因”)嵌入到 order_number
的低位。这样 order_number
就携带了 user_id
的分片信息。
4.2.1. 分表算法 (TableOrderComplexGeneArithmetic.java
)
public class TableOrderComplexGeneArithmetic implements ComplexKeysShardingAlgorithm<Long> {
private int shardingCount; // 分表数量, e.g., 4
@Override
public void init(Properties props) {
shardingCount = Integer.parseInt(props.getProperty("sharding-count"));
}
@Override
public Collection<String> doSharding(Collection<String> allActualSplitTableNames, ComplexKeysShardingValue<Long> complexKeysShardingValue) {
Map<String, Collection<Long>> columnNameAndShardingValuesMap = complexKeysShardingValue.getColumnNameAndShardingValuesMap();
Collection<Long> orderNumberValues = columnNameAndShardingValuesMap.get("order_number");
Collection<Long> userIdValues = columnNameAndShardingValuesMap.get("user_id");
Long shardingValue = null;
if (CollectionUtil.isNotEmpty(orderNumberValues)) {
shardingValue = orderNumberValues.stream().findFirst().get();
} else if (CollectionUtil.isNotEmpty(userIdValues)) {
shardingValue = userIdValues.stream().findFirst().get();
}
if (Objects.nonNull(shardingValue)) {
// (shardingCount - 1) & shardingValue 是 value % shardingCount 的高效写法
// 由于基因法的设计,order_number 和 user_id 计算出的结果是相同的
String tableName = complexKeysShardingValue.getLogicTableName() + "_" + ((shardingCount - 1) & shardingValue);
return Collections.singletonList(tableName);
}
// 如果没有分片键,则全路由
return allActualSplitTableNames;
}
}
逻辑剖析:
- 算法检查查询条件中是提供了
order_number
还是user_id
。 - 无论使用哪个值,都对其进行取模运算
value % 4
来确定分表索引。 - 因为
order_number
中已嵌入了user_id
的分片信息,所以order_number % 4
的结果与user_id % 4
恒等,从而总能定位到正确的物理表。
4.2.2. 分库算法 (DatabaseOrderComplexGeneArithmetic.java
)
public class DatabaseOrderComplexGeneArithmetic implements ComplexKeysShardingAlgorithm<Long> {
private int databaseCount; // 分库数量, e.g., 2
private int tableShardingCount; // 分表数量, e.g., 4
// ... init a an doSharding methods are similar to table sharding ...
// The core logic is in calculateDatabaseIndex
public long calculateDatabaseIndex(Integer databaseCount, Long splicingKey, Integer tableCount) {
// 1. 将分片键 (order_number 或 user_id) 转为二进制
String splicingKeyBinary = Long.toBinaryString(splicingKey);
// 2. 计算基因长度 (分表数需要多少个二进制位来表示)
// e.g., 4个表需要 log2(4) = 2位
long replacementLength = (long)(Math.log(tableCount) / Math.log(2));
// 3. 从二进制字符串的末尾截取基因
// e.g., 截取最后2位
String geneBinaryStr = splicingKeyBinary.substring(splicingKeyBinary.length() - (int) replacementLength);
if (StringUtil.isNotEmpty(geneBinaryStr)) {
// 4. 对基因字符串进行高质量哈希,使其分布更均匀
int h;
int geneOptimizeHashCode = (h = geneBinaryStr.hashCode()) ^ (h >>> 16);
// 5. 对分库数取模,得到分库索引
return (databaseCount - 1) & geneOptimizeHashCode;
}
throw new FrameException(BaseCode.NOT_FOUND_GENE);
}
}
逻辑剖析:
- 提取基因: 算法从分片键的二进制表示中,提取出代表分表信息的“基因”(即末尾的几位)。
- 哈希基因: 对提取出的基因字符串进行哈希处理。这里借鉴了
HashMap
的思想,通过^ (h >>> 16)
操作(高16位与低16位异或),让哈希值分布更均匀,避免数据倾斜。 - 定位分库: 将哈希后的值对分库数取模,最终确定数据所在的物理库。这个方法保证了同一个用户的订单大概率会落入同一个库中,同时通过订单号也能直接定位。
5. 支付服务分库分表策略
支付服务的业务逻辑相对直接,核心是围绕唯一的外部订单号进行操作。
5.1. ShardingSphere 配置 (shardingsphere-pay.yaml
)
rules:
- !SHARDING
tables:
d_pay_bill:
actualDataNodes: ds_${0..1}.d_pay_bill_${0..1}
databaseStrategy:
standard:
shardingColumn: out_order_no
shardingAlgorithmName: databasePayHashModModel
tableStrategy:
standard:
shardingColumn: out_order_no
shardingAlgorithmName: tablePayHashModModel
shardingAlgorithms:
databasePayHashModModel:
type: HASH_MOD
props:
sharding-count: 2
tablePayHashModModel:
type: HASH_MOD
props:
sharding-count: 2
5.2. 策略详解
d_pay_bill
(支付流水表) 和d_refund_bill
(退款流水表) 都使用out_order_no
(外部订单号) 作为分片键。HASH_MOD
算法: 由于订单号通常是字符串且不具备连续性,使用HASH_MOD
算法可以将其哈希后均匀地分布到各个分片,是处理此类字符串键的理想选择。该策略简单、高效,完全满足支付业务的需求。
深度解析:分片基因法
1. 问题的根源:无法兼得的查询维度
在设计订单这类核心系统时,一个经典的数据库挑战浮出水面:系统必须同时高效地支持两种关键查询。
- 按
order_number
查询:这是单点查询,要求毫秒级响应,常见于支付回调、客服查找等场景。 - 按
user_id
查询:这是列表查询,用于“我的订单”页面,要求快速返回一个用户的所有订单。
传统的单一分片键策略在这里会“失灵”。
- 若以
user_id
分片:按order_number
查询时,系统不知道订单在哪,只能扫描所有分片(即“全路由”),引发性能灾难。 - 若以
order_number
分片:按user_id
查询时,同样会触发“全路由”。
“分片基因法”提供了一种不依赖额外辅助表,却能优雅解决此问题的创新方案。
2. 核心思想:让ID“开口说话”
分片基因法的哲学思想是:与其通过外部映射查找路由信息,不如让ID本身就携带路由信息。
我们可以将此过程类比为生物遗传学:
- “基因” (Gene):我们将
user_id
经过特定算法计算出的 分表位置信息,视为一段“基因”。 - “注入” (Injection):在订单创建、生成
order_number
的那一刻,我们通过精巧的位运算,将这段“基因”无缝地嵌入到order_number
的二进制结构中。 - “表达” (Expression):如此一来,
order_number
就不再是一个无意义的数字,而是一个“活”的ID。只要拿到它,分片算法就能从中“读取”出与它关联的user_id
的分片信息,从而实现精准路由。
3. 实现全景剖析
我们以 2个数据库分库、4个订单表分表 (d_order_0
至 d_order_3
) 的场景为例,完整地走一遍流程。
第 1 步:基因的注入(ID生成时)
这是整个魔法的起点,发生在订单服务生成 order_number
的瞬间。
获取用户ID: 假设用户
user_id = 1001
正在下单。计算分表基因: 基因的本质就是该用户数据应落在哪张分表上。
分表索引 (基因) = user_id % 分表总数
1001 % 4 = 1
。因此,该用户所有订单的“分表基因”就是1
。
构造订单号:
order_number
的生成算法必须确保将这个“基因”包含进去。一种典型的实现方式是利用位运算:order_number = (业务ID部分) << 基因位宽 | 分表基因
- 业务ID部分: 可以由
时间戳
、机器码
、自增序列
等组合而成,确保唯一性。 - 基因位宽: 指存储“基因”需要多少个二进制位。我们有4张表,
2^2 = 4
,所以需要 2位 来存储从0到3的基因。 <<
(左移): 将业务ID部分的二进制值向左移动2位,腾出最低2位给基因。|
(按位或): 将计算出的基因1
(二进制为01
) 填充到腾出的最低2位上。
经过这个过程,任何为用户
1001
生成的订单号,其二进制末尾两位必然是01
,这导致它对4取模的结果永远是1
。
第 2 步:基因的读取(查询路由时)
当携带分片键的SQL到达ShardingSphere时,我们自定义的复合分片算法 (ComplexKeysShardingAlgorithm
) 开始工作。
分表路由:简单而精准
分表逻辑非常直观,因为它直接利用了基因注入的结果。
- 当按
user_id = 1001
查询时:- 算法获取到值
1001
。 - 计算路由:
1001 % 4 = 1
。 - 决策: 路由到
d_order_1
。
- 算法获取到值
- 当按
order_number = ...01
(二进制) 查询时:- 算法获取到订单号。
- 计算路由:
order_number % 4 = 1
。 - 决策: 同样路由到
d_order_1
。
结论:通过ID注入的基因,分表逻辑被完美统一。
分库路由:基于基因的二次路由
分库的逻辑更为精妙,它不关心完整的ID,只关心ID中携带的“基因”,并以此为依据进行二次路由。
我们来剖析 DatabaseOrderComplexGeneArithmetic
算法的核心步骤:
- 提取基因:
long replacementLength = log2(tableCount);
- 计算基因的二进制位宽。
log2(4) = 2
。
- 计算基因的二进制位宽。
String geneBinaryStr = splicingKeyBinary.substring(splicingKeyBinary.length() - (int) replacementLength);
- 无论传入的是
user_id
还是order_number
,都将其转换为二进制字符串,并从末尾截取2位。由于基因注入机制,从两者中截取出的结果是等价的,都代表了分表索引。例如,都会得到字符串"01"
。
- 无论传入的是
- 优化基因分布:
int geneOptimizeHashCode = (h = geneBinaryStr.hashCode()) ^ (h >>> 16);
- 这是整个分库算法的精髓,是直接借鉴自Java
HashMap
的扰动函数。 - 目的: 防止数据倾斜。直接使用字符串的
hashCode()
可能会因为计算方式的局限性,导致不同的基因(如"01", "10")哈希后的结果不够离散,容易碰撞。 - 原理:
h >>> 16
将哈希值的高16位移到低16位,再与原哈希值进行^
(异或)运算。这使得高位的特征也参与到最终结果中,让哈希值分布得更均匀、更随机。
- 这是整个分库算法的精髓,是直接借鉴自Java
- 定位分库:
return (databaseCount - 1) & geneOptimizeHashCode;
- 使用高效的位运算
&
替代取模%
。当分库数databaseCount
是2的幂(如2, 4, 8)时,X & (N-1)
的结果与X % N
完全相同,但计算速度更快。 - 最终,通过这个高度离散化的哈希值来决定数据应落入哪个库。
- 使用高效的位运算
分库策略总结:分库决策完全依赖于从ID中提取出的“分表基因”。这确保了拥有相同基因(即落在同一张分表)的数据,大概率也会被路由到同一个物理库中,实现了用户维度的数据在库级别的聚合。
4. 权衡与取舍
优点
- 极致性能: 完美解决了多维查询下的全路由问题,无需任何额外的网络或磁盘I/O。
- 资源节约: 相比辅助表方案,节省了大量的存储空间和维护成本。
- 业务透明: 路由逻辑被封装在底层,对上层业务代码完全无感。
缺点
- 高耦合与低扩展性: 这是它最大的弊端。ID生成规则与分表数量(
tableCount
)被写死在了一起。如果未来需要将4张表扩容到8张,意味着“基因位宽”要从2位变成3位。所有历史订单号中的2位旧基因将全部失效,导致数据无法被正确路由。解决这个问题通常需要复杂且风险极高的数据迁移和重写。
最终结论:分片基因法是一种用牺牲水平扩展的灵活性,来换取极致查询性能和资源节约的高级分片策略。它极其适合那些业务模型稳定、分片规则一经确立便极少变更的核心业务场景,例如订单、流水等。系统分库分表策略综合说明