Skip to content

技术精华-解锁分库分表新姿势:基因法完全解读 在分库分表时,经常会遇到查询的条件不含有分片键的情况,比如说用户表,生成的订单中是依靠userId来关联用户信息,而用户在登录时又可以使用手机号和邮箱来登录,这样只有userId一个分片键就搞不定了。

大麦网中的解决方案是在设计出 用户手机表 用手机号当做分片键 。以及用户邮箱表 用邮箱当做分片键。 当用户用手机或者邮箱登录后,分别从相应的用户手机表和用户邮箱表查询出userId,然后用userId去用户表查询信息。

目前在订单业务中也遇到类似的问题,我们需要既可以通过订单号查询出订单详细,也想通过userId查询该用户下的订单列表,这样就需要即通过order_number查询又要通过userId查询

估计小伙伴就会想了,还是使用附属表路由的方案呗,再设计出一个订单用户表,通过userId去订单用户表查询,得到order_number,然后再去订单表查询信息

首先说这种方案确实可以解决,但就是要额外维护表。而且对于订单这种量级很大的表来说,附属路由表的量级也会很大。

所以最好有另一种方案,可以不用再设计出一张表去维护它,这种方案就是我们要介绍的 基因法

基因法

我们先介绍一下特征:

191 % 32 = 31
  • 191的二进制: 10111111
  • 32的二进制:   100000  也就是2的5次方
  • 31的二进制:   11111

让我们观察下这个计算规律:

31的二进制 和 191的二进制最后5位相等,都是11111。如果随便拿一个数转成二进制,然后把二进制的后5位替换成这个11111,然后将这个替换后的值对32进行取模,那么得到的余数也是31,这个5位长度就是靠32的二进制的长度也就是求log2n对数的值

比如说 159,二进制是 10011111 ,对 32 取模,结果还是31

下面让我们把这个计算规律应用到订单号和用户id的业务中

public class TestMain {
    
    public static void main(String[] args) {
        //分片数量
        int shardingCount = 32;
        //订单编号
        long orderNumber = 2654324532L;
        //用户id
        long userId = 45346343212L;
        //求shardingCount的log2N对数
        int sequenceShift = log2N(shardingCount);
        long newOrderNumber = replaceBinaryBits(orderNumber, sequenceShift, (userId % shardingCount));
        System.out.println("替换后订单号取模结果:" + (newOrderNumber % shardingCount));
        System.out.println("用户id取模结果:" + (userId % shardingCount));
    }
    /**
     * 求log2(N)
     * */
    public static int log2N(int count) {
        return (int)(Math.log(count)/ Math.log(2));
    }
    public static long replaceBinaryBits(long num1, int numBits, long num2) {
        // 将两个数转换成二进制字符串
        String binaryStr1 = Long.toBinaryString(num1);
        // 计算num2对应的二进制长度,确保它填充到指定的numBits位
        String binaryStr2 = Long.toBinaryString(num2 % (1L << numBits));
        
        // 如果binaryStr2长度小于numBits,则在前面补零
        while (binaryStr2.length() < numBits) {
            binaryStr2 = "0" + binaryStr2;
        }
        
        // 计算需要保留的前面部分的长度
        int keepLength = binaryStr1.length() - numBits;
        
        // 如果需要替换的位数超过了num1的长度,则直接使用num2的二进制字符串
        if(keepLength < 0) {
            System.out.println("替换位数超过了第一个参数的位数,直接使用第三个参数的二进制: " + binaryStr2);
            // 直接将num2的二进制字符串转换为十进制并打印
            return Long.parseLong(binaryStr2, 2);
        }
        
        
        // 保留num1前面的部分和num2的二进制拼接
        String resultBinaryStr = binaryStr1.substring(0, keepLength) + binaryStr2;
        
        
        System.out.println("要替换的二进制后位数" + numBits);
        // 打印原始的二进制字符串
        System.out.println("num1 替换前的二进制: " + binaryStr1);
        // 打印最终的二进制字符串
        System.out.println("num1 替换后的二进制: " + resultBinaryStr);
        
        System.out.println("num2 的十进制: " + num2);
        System.out.println("num2 的二进制: " + binaryStr2);
        // 将结果二进制字符串转换回十进制数值并打印
        long resultDecimal = Long.parseLong(resultBinaryStr, 2);
        System.out.println("num1 对应的十进制数为: " + resultDecimal);
        return resultDecimal;
    }
}

结果:

要替换的二进制后位数5
num1 替换前的二进制: 10011110001101011100011100110100
num1 替换后的二进制: 10011110001101011100011100101100
num2 的十进制: 12
num2 的二进制: 01100
num1 对应的十进制数为: 2654324524
替换后订单号取模结果:12
用户id取模结果:12

流程总结下:

  • 假设 分片数量shardingCount = 32,订单号orderNumber = 2654324532L,用户iduserId = 45346343212L,分片数量32的log2n对数sequenceShift = 5 (使用5位二进制表示分片号)
  • orderNumber转为二进制,为10011110001101011100011100110100
  • userId % shardingCount的余数 = 12 转为二进制01100(补全为5位)
  • 10011110001101011100011100110100的后5位替换成01100,替换后为10011110001101011100011100101100
  • 将替换后的10011110001101011100011100101100转为十进制为2654324524
  • 计算2654324524 % 32 = 12

看到这相信大家已经明白基因法的原理了,下面来介绍如何具体的应用

现在分布式id的生成基本用的都是雪花算法的原理,我们再来看一下雪花算法的结构

雪花算法

  • 第一个部分:1个bit,无意义,固定为0。二进制中最高位是符号位,1表示负数,0表示正数。ID都是正整数,所以固定为0。
  • 第二个部分:41个bit,表示时间戳,精确到毫秒,2^41/(1000_60_60_24_365)=69,大概可以使用 69 年。时间戳带有自增属性。
  • 第三个部分:10个bit,表示10位的机器标识,最多支持2^10=1024个节点。此部分也可拆分成5位datacenterId和5位workerId,datacenterId表示机房ID,workerId表示机器ID。
  • 第四部分:12个bit,表示序列化,同一毫秒时间戳时,通过这个递增的序列号来区分。即对于同一台机器而言,同一毫秒时间戳下,可以生成 2^12=4096 个不重复 id。

假设存在一种情况,在超高的并发下,在同一毫秒,同一台机器,生成两个id,那么这两个id唯一的区别 就是序列号相差1,如果这时我们使用了基因法,分成32张表,也就是取把雪花算法二进制的后5位进行基因替换,那么这两个id不就重复了吗?

确实是会重复但我们要和业务结合起来思考,如果是在订单业务下,**订单id被替换的是用户id和分片数的取模值,**在这种业务下发生重复的概率是非常低的,也就是说需要用户在同一毫秒下创建了2个订单才能重复,这要是正常的用户肯定是不会重复的,除非是机器刷单或者恶意攻击这种情况

改造

下面我们来对雪花算法进行改造,结合基因法来测试一下重复的情况

public synchronized long getOrderNumber(long userId) {
    long timestamp = getBase();
    // 时间戳部分 | 数据中心部分 | 机器标识部分 | 序列号部分
    return ((timestamp - BASIS_TIME) << timestampLeftShift)
            | (datacenterId << datacenterIdShift)
            | (workerId << workerIdShift)
            | sequence << 4
            | userId % 16;
}
  • userId的取模为 16,对其去log2n的对数是4
  • 将序列号左移4位,也就是序列号的有效位是8位,最高支持到2的8次方 256,也就是说某个用户在毫秒内,同一机器下最多生成256个订单号不会重复

接下来下面我们来验证一下

循环256次

public void testOrderNumber(){
    List<Long> idList = new ArrayList<>();
    for (int i = 0; i < 256; i++) {
        long orderNumber = snowflakeIdGenerator.getOrderNumber(2222);
        idList.add(orderNumber);
    }
    int count = 0;
    Map<Long, List<Long>> collect = idList.stream().collect(Collectors.groupingBy(id -> id));
    for (final Entry<Long, List<Long>> entry : collect.entrySet()) {
        System.out.println("订单号:" + entry.getKey() + ",数量为:" + entry.getValue().size());
        if (entry.getValue().size() > 1) {
            count++;
        }
    }
    System.out.println("重复的orderNumber数量:" + count);
}

结果

....
订单号:1763164230291425342,数量为:1
订单号:1763164230291425854,数量为:1
重复的orderNumber数量:0

下面来做进一步的改造,可根据userId和要配置的分表数量来灵活的生成订单编号

com.damai.toolkit.SnowflakeIdGenerator#getOrderNumber(long, long)

/**
 * 获取订单编号
 *
 * @return orderNumber
 */
public synchronized long getOrderNumber(long userId,long tableCount) {
    long timestamp = getBase();
    long sequenceShift = log2N(tableCount);
    // 时间戳部分 | 数据中心部分 | 机器标识部分 | 序列号部分 | 用户id基因
    return ((timestamp - BASIS_TIME) << timestampLeftShift)
            | (datacenterId << datacenterIdShift)
            | (workerId << workerIdShift)
            | (sequence << sequenceShift)
            | (userId % tableCount);
}
  • 需要userIdtableCount分片数量作为参数传入
  • 根据tableCount计算出log2n的对数sequenceShift
  • 将序列号左移sequenceShift
  • 将序列号左移sequenceShift位后,剩下的位数替换成userId % tableCount的基因
  • 拼接好数据后将id返回,作为订单编号

通过上述可知,毫秒内能生成不重复的订单号和分片数量成相反关系,根据业务需求进行相关的调整即可,目前的订单业务是由通过订单编号和用户id查询,可以用基因法,但如果查询的条件变多了,比如查询某个节目类型下的订单列表,或者统计数量,那么用基因法就不适合了,还是要考虑使用附属路由表的方案,或者使用其他类型数据库,比如elasticsearch,而在节目服务服务的数据查询中,就使用到了elasticsearch,小伙伴也直接跳转到节目服务的相关业务介绍模块即可