基因法如何数据均匀分布
🎯 核心要点速览
本文解决的关键问题:
🔴 数据分布不均问题:原始算法分库和分表使用相同的基因位,导致约50%的物理表闲置
🔴 查询一致性问题:订单号查询和用户ID查询可能定位到不同的分片
🟢 虚拟分片支持:支持细粒度扩容,orderNumber 和 userId 完美一致
💡 解决方案:
✅ 基因位分离:分表用ID的最后N位,分库用右移后的M位(完全不重叠)
✅ 基因嵌入:订单号生成时嵌入用户ID的基因,保证100%查询一致
✅ 纯位运算:去除hashCode,使用右移和按位与,性能更优
✅ 虚拟分片扩展:嵌入更多userId位(10位),支持1024个虚拟分片的细粒度迁移
📊 核心改进点:
| 项目 | 改进前 | 优化后(自动支持虚拟分片) |
|---|---|---|
| 分库算法 | 使用hashCode导致不均 | 纯位运算 (dbCount-1) & (key >> tableGeneBits) |
| 订单号生成 | 只包含表基因 | 自动嵌入(基因+虚拟分片细分) |
| 嵌入位数 | 仅表基因 | 根据配置自动计算(2库×4表=10位) |
| 资源利用率 | 约50% | 100% |
| 查询一致性 | 不保证 | 100%一致(物理+虚拟) |
| 扩容粒度 | 整个物理分片 | 1/1024细粒度 |
| API复杂度 | - | 简洁(3个参数,自动计算) |
一、🔍 问题的发现
在实际的分库分表场景中,我们经常会遇到这样的需求:
- 📦 订单表需要按
订单编号(order_number)或用户ID(user_id)进行分库分表 - 👤 用户表需要按
用户ID(user_id)进行分库分表
在项目中的订单服务中会配置这样的分库分表规则:
# 2个库,每个库4个表
databaseUserModModel:
type: MOD # 取模
props:
sharding-count: 2 # 2个库
tableUserModModel:
type: MOD # 取模
props:
sharding-count: 4 # 4个表
这看起来很完美:2个库 × 4个表 = 8个物理表,可以有效分散数据。✨
1.1 📝 原始算法的实现
🔹 分表算法
// 通过 & 运算进行取模(当shardingCount是2的幂次方时,(n-1) & value 等价于 value % n)
actualTableNames.add(logicTableName + "_" + ((shardingCount - 1) & value));
🔹 分库算法
/**
* 计算给定表索引应分配到的数据库编号。
*
* @param databaseCount 数据库总数
* @param splicingKey 分片键
* @param tableCount 表总数
* @return 分配到的数据库编号
*/
public long calculateDatabaseIndex(Integer databaseCount, Long splicingKey, Integer tableCount) {
// 将分片键换成二进制字符串
String splicingKeyBinary = Long.toBinaryString(splicingKey);
//找到被替换基因的长度
long replacementLength = log2N(tableCount);
//截取获得基因
String geneBinaryStr = splicingKeyBinary.substring(splicingKeyBinary.length() - (int) replacementLength);
if (StringUtil.isNotEmpty(geneBinaryStr)) {
int h;
//对基因的hashCode进行优化
int geneOptimizeHashCode = (h = geneBinaryStr.hashCode()) ^ (h >>> 16);
//进行取模
return (databaseCount - 1) & geneOptimizeHashCode;
}
throw new DaMaiFrameException(BaseCode.NOT_FOUND_GENE);
}
1.2 ⚠️ 严重的问题:资源浪费
让我们用一个具体的例子来看看问题出在哪里:
⚙️ 配置:
2个库、4个表(总共8个物理表)
| 用户ID | 二进制表示 | 最后2位(分表用) | 分表结果 | 分库基因(截取最后2位) | 分库结果 | 最终位置 |
|---|---|---|---|---|---|---|
| 1 | 1 | 01 | 表1 | "01" → hashCode=1585 → & 1 | 库1 | 库1-表1 |
| 2 | 10 | 10 | 表2 | "10" → hashCode=1586 → & 1 | 库0 | 库0-表2 |
| 3 | 11 | 11 | 表3 | "11" → hashCode=1587 → & 1 | 库1 | 库1-表3 |
| 4 | 100 | 00 | 表0 | "00" → hashCode=1536 → & 1 | 库0 | 库0-表0 |
| 5 | 101 | 01 | 表1 | "01" → hashCode=1585 → & 1 | 库1 | 库1-表1 |
| 6 | 110 | 10 | 表2 | "10" → hashCode=1586 → & 1 | 库0 | 库0-表2 |
| 7 | 111 | 11 | 表3 | "11" → hashCode=1587 → & 1 | 库1 | 库1-表3 |
| 8 | 1000 | 00 | 表0 | "00" → hashCode=1536 → & 1 | 库0 | 库0-表0 |
| ... | ... | ... | ... | ... | ... | ... |
🔬 问题分析:
虽然表面上看8个ID分布到了不同位置,但经过大量测试会发现:
- ❌ 库0-表1 几乎不被使用
- ❌ 库1-表0 几乎不被使用
- ❌ 库1-表2 几乎不被使用
- ❌ 库0-表3 几乎不被使用
- 🚨 8个物理表中约有4个表很少使用,资源浪费严重!
测试1000个ID后的实际分布(原始算法):
库0-表0: 250条 ✓ 正常
库0-表1: 8条 ✗ 严重偏少
库0-表2: 242条 ✓ 正常
库0-表3: 0条 ✗ 完全未使用
库1-表0: 0条 ✗ 完全未使用
库1-表1: 242条 ✓ 正常
库1-表2: 8条 ✗ 严重偏少
库1-表3: 250条 ✓ 正常
1.3 🤔 问题出在哪里?
原始算法的致命缺陷在于:分库和分表使用了ID的同一个"基因位"。
- 分表:使用ID二进制的 最后2位(
value & 3,可表示0-3) - 分库:也从ID二进制的 最后2位 截取基因,再通过hashCode转换
虽然经过了hashCode处理,但由于:
- 相同的表基因(如"00")总是产生相同的hashCode
- hashCode的分布特性导致某些组合很少出现
因此分库和分表的结果仍然是强关联的,无法实现均匀独立分布。
二、💡 改进方案:基因位分离
2.1 🧬 核心思想
要解决这个问题,核心思想是:让分库和分表使用分片键的不同部分(不同的基因位)。✨
我们把分片键(订单编号或用户ID)看成一串二进制数字,就像DNA的基因序列一样:
分片键 = 13(十进制)= 1101(二进制)
例如:
- 订单编号 = 1234567890
- 用户ID = 1001
分解为:
┌────────┬────────┬────────┐
│ 高位区 │ 库基因 │ 表基因 │
└────────┴────────┴────────┘
↓ ↓
用来分库 用来分表
📌 说明:
- 订单表:使用订单编号(order_number)作为分片键
- 用户表:使用用户ID(user_id)作为分片键
- 算法对两种分片键都适用
🔑 关键改进:
- ✅ 分表:使用分片键的 低位bit(最右边的几位)
- ✅ 分库:使用分片键的 中高位bit(跳过表基因位后的几位)
- ✅ 两者使用的bit位完全不重叠,实现独立分布
2.2 🔐 核心:订单号生成(基因嵌入)
要实现订单号和用户ID查询的分库分表位置完全一致,关键在于:订单号必须包含用户ID的基因信息。🎯
2.2.1 💡 解决方案:基因嵌入
核心思想:在订单号的最后N位嵌入用户ID的基因(N = 表基因位数 + 库基因位数)