跳到主要内容

基因法如何数据均匀分布

🎯 核心要点速览

本文解决的关键问题:

🔴 数据分布不均问题:原始算法分库和分表使用相同的基因位,导致约50%的物理表闲置

🔴 查询一致性问题:订单号查询和用户ID查询可能定位到不同的分片

🟢 虚拟分片支持:支持细粒度扩容,orderNumberuserId 完美一致

💡 解决方案:

基因位分离:分表用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位)分库结果最终位置
1101表1"01" → hashCode=1585 → & 1库1库1-表1
21010表2"10" → hashCode=1586 → & 1库0库0-表2
31111表3"11" → hashCode=1587 → & 1库1库1-表3
410000表0"00" → hashCode=1536 → & 1库0库0-表0
510101表1"01" → hashCode=1585 → & 1库1库1-表1
611010表2"10" → hashCode=1586 → & 1库0库0-表2
711111表3"11" → hashCode=1587 → & 1库1库1-表3
8100000表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处理,但由于:

  1. 相同的表基因(如"00")总是产生相同的hashCode
  2. hashCode的分布特性导致某些组合很少出现

因此分库和分表的结果仍然是强关联的,无法实现均匀独立分布。

二、💡 改进方案:基因位分离

2.1 🧬 核心思想

要解决这个问题,核心思想是:让分库和分表使用分片键的不同部分(不同的基因位)。✨

我们把分片键(订单编号或用户ID)看成一串二进制数字,就像DNA的基因序列一样:

分片键 = 13(十进制)= 1101(二进制)

例如:
- 订单编号 = 1234567890
- 用户ID = 1001

分解为:
┌────────┬────────┬────────┐
│ 高位区 │ 库基因 │ 表基因 │
└────────┴────────┴────────┘
↓ ↓
用来分库 用来分表

📌 说明:

  • 订单表:使用订单编号(order_number)作为分片键
  • 用户表:使用用户ID(user_id)作为分片键
  • 算法对两种分片键都适用

🔑 关键改进:

  1. 分表:使用分片键的 低位bit(最右边的几位)
  2. 分库:使用分片键的 中高位bit(跳过表基因位后的几位)
  3. ✅ 两者使用的bit位完全不重叠,实现独立分布

2.2 🔐 核心:订单号生成(基因嵌入)

要实现订单号和用户ID查询的分库分表位置完全一致,关键在于:订单号必须包含用户ID的基因信息。🎯

2.2.1 💡 解决方案:基因嵌入

核心思想:在订单号的最后N位嵌入用户ID的基因(N = 表基因位数 + 库基因位数)