之前在一家公司曾经和项目经理争论过到底是用mysql数据库自增主键做主键ID,还是用字符串做主键ID,然后引起每个部门每个大佬都有不同的意见,简直就是大混战,每一个人都不服其他人,最后导致有的项目用了字符串做主键ID,有的项目用mysql数据库自增主键,一个字乱。

在集群高并发情况下如何保证分布式全局唯一ID生成?

 

分布式ID生成规则硬性要求:

1、全局唯一:不能出现重复的ID号,既然是唯一标识,这是最基本的要求。

2、趋势递增:MySQL中InnoDB引擎使用的是聚集索引。多数RDBMS使用Btree的数据结构来存储索引数据,在主键的选择上尽量选择有序的主键保证写入性能。

3、单调递增:保证下一个ID号一定大于上一个。

4、保证安全:ID号需要无规则性,不能让别人根据ID号猜出我们的信息和业务数据量,增加恶意用户扒取数据的难度。

5、含时间戳。

 

分布式ID生成可用性要求:

1、高可用:发布一个获取分布式ID的请求,服务器就要保证99.999%的情况下给创建一个全局唯一的分布式ID。

2、低延迟:发布一个获取分布式ID的请求,要快,急速。

3、高QPS:假如并发一口气10万个创建分布式ID请求同时杀过来,服务器要顶得住并且成功创建10万个分布式ID。

 

生成主键方案有哪些:

1、UUID。

2、数据库自增主键。

3、基于Redis生成全局ID策略。

4、雪花算法,Twitter的分布式自增ID算法snowflake。

5、百度UidGenerator算法(基于雪花算法实现自定义时间戳)。

6、美团Leaf算法(依赖于数据库,ZK)。

 

1、UUID的优缺点:

优点:性能非常高,JDK自带本地生成,无网络消耗。

缺点:(1)只保证了唯一性,趋势递增。(2)无序,无法预测他的生成规则,不能生成递增有序的数字。(3)mysql官方推荐主键越短越好,UUID包含32个16位进制的字母数字,每一个都很长。(4)B+树索引的分裂。主键是包含索引的,mysql的索引是通过B+树来实现的,每一次新的UUID数据插入,为了查询优化,因为UUID是无序的,都会对索引底层的B+树进行修改。插入无序,不但会导致一些中间节点产生分裂,也会白白创造很多不饱和的节点,大大降低了数据库插入的性能。

 

2、数据库自增主键的优缺点:

优点:简单方便易用。

缺点:(1)要设置增长步长,系统水平扩展比较困难。(2)每次获取ID都得读写一次数据库,数据库压力大,非常影响性能,不符合分布式ID里低延迟和高QPS的规则。

 

3、基于Redis生成全局ID策略优缺点:

优点:满足分布式ID生成要求,并且已有成功落地案例。

缺点:(1)要设置增长步长,同时key一定要设置有效期。(2)为了一个分布式ID,要搞一个Redis集群,维护成本大。

 

4、雪花算法,Twitter的分布式自增ID算snowflake优缺点:

优点:(1)经测试snowflake每秒能生成26万个自增可排序的ID。(2)snowflake生成的ID结果是一个64bit大小的整数,为一个Long型 (转换成字符串后长度最多19)。(3)分布式系统内不会产生ID碰撞(datacenter和workerId作区分)并且效率高。(4)不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也非常高,可以根据自身业务分配bit位,非常灵活。

缺点:依赖机器时钟,如果机器时钟回拨,会导致id重复。由于是部署到分布式环境,每台机器上的时钟不可能完全同步,有时候出现不是全局递增的情况。(一般分布式ID只要求趋势递增,并不会严格要求递增,90%的需求只要求趋势递增,可以忽略这个缺点,或者按实际情况进行改进,如下代码demo)

 

雪花算法的几个核心组成:

 

主要分为 5 个部分:

是 1 个 bit:0,这个是无意义的。
是 41 个 bit:表示的是时间戳。
是 10 个 bit:表示的是机房 id,0000000000,因为我传进去的就是0。
是 12 个 bit:表示的序号,就是某个机房某台机器上这一毫秒内同时生成的 id 的序号,0000 0000 0000。

1 bit,是无意义的:

  因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0。

41 bit:表示的是时间戳,单位是毫秒。

  41 bit 可以表示的数字多达 2^41 - 1,也就是可以标识 2 ^ 41 - 1 个毫秒值,换算成年就是表示 69 年的时间,从1970年到2039年9月7日。

10 bit:记录工作机器 id,代表的是这个服务最多可以部署在 2^10 台机器上,也就是 1024 台机器。

  但是 10 bit 里 5 个 bit 代表机房 id,5 个 bit 代表机器 id。意思就是最多代表 2 ^ 5 个机房(32 个机房),每个机房里可以代表 2 ^ 5 个机器(32 台机器),这里可以随意拆分,比如拿出4位标识业务号,其他6位作为机器号。可以随意组合。

12 bit:这个是用来记录同一个毫秒内产生的不同 id。

  12 bit 可以代表的最大正整数是 2 ^ 12 - 1 = 4096,也就是说可以用这个 12 bit 代表的数字来区分同一个毫秒内的 4096 个不同的 id。也就是同一毫秒内同一台机器所生成的最大ID数量为4096

   简单来说,你的某个服务假设要生成一个全局唯一 id,那么就可以发送一个请求给部署了 SnowFlake 算法的系统,由这个 SnowFlake 算法系统来生成唯一 id。这个 SnowFlake 算法系统首先肯定是知道自己所在的机器号,(这里姑且讲10bit全部作为工作机器ID)接着 SnowFlake 算法系统接收到这个请求之后,首先就会用二进制位运算的方式生成一个 64 bit 的 long 型 id,64 个 bit 中的第一个 bit 是无意义的。接着用当前时间戳(单位到毫秒)占用41 个 bit,然后接着 10 个 bit 设置机器 id。最后再判断一下,当前这台机房的这台机器上这一毫秒内,这是第几个请求,给这次生成 id 的请求累加一个序号,作为最后的 12 个 bit。

 

雪花算法源码demo:

package com.example.demo;

import java.net.Inet4Address;
import java.net.UnknownHostException;
import java.util.Random;

/**
 * @Author: yzp
 * @Date: 2020-7-27 15:32
 * @description
 */
public class SnowflakeIdWorker {

    /** 时间部分所占长度 */
    private static final int TIME_LEN = 41;
    /** 数据中心id所占长度 */
    private static final int DATA_LEN = 5;
    /** 机器id所占长度 */
    private static final int WORK_LEN = 5;
    /** 毫秒内存序列所占长度 */
    private static final int SEQ_LEN = 12;

    /** 定义起始时间 2020-07-27*/
    private static final long START_TIME = 1595835560497L;
    /** 上次生成ID的时间戳 */
    private static long LAST_TIME_STAMP = -1L;
    /** 时间部分向左移动的位数 22 */
    private static final int TIME_LEFT_BIT = 64 - 1 - TIME_LEN;

    /** 自动获取数据中心id(可以手动定义0-31之间的数) */
    private static final long DATA_ID = getDataId();
    /** 自动机器id(可以手动定义0-31之间的数) */
    private static final long WORK_ID = getWorkId();
    /** 数据中心id最大值 31 */
    private static final int DATA_MAX_NUM = ~(-1 << DATA_LEN);
    /** 机器id最大值 31 */
    private static final int WORK_MAX_NUM = ~(-1 << WORK_LEN);
    /** 随机获取数据中心id的参数 32 */
    private static final int DATA_RANDOM = DATA_MAX_NUM + 1;
    /** 随机获取机器id的参数 32 */
    private static final int WORK_RANDOM = WORK_MAX_NUM + 1;
    /** 数据中心id左移位数 17 */
    private static final int DATA_LEFT_BIT = TIME_LEFT_BIT - DATA_LEN;
    /** 机器id左移位数 12 */
    private static final int WORK_LEFT_BIT = DATA_LEFT_BIT - WORK_LEN;

    /** 上一次毫秒内存序列值 */
    private static long LAST_SEQ = 0L;
    /** 毫秒内存列的最大值 4095 */
    private static final long SEQ_MAX_NUM = ~(-1 << SEQ_LEN);

    /**
     * 获取字符串S的字节数组,然后将数组的元素相加,对(max+1)取余
     * @param s 本地机器的hostName/hostAddress
     * @param max 机房/机器的id最大值
     * @return
     */
    private static int getHostId(String s, int max) {
        byte[] bytes = s.getBytes();
        int sums = 0;
        for (int b : bytes) {
            sums += b;
        }
        return sums % (max + 1);
    }

    /**
     * 根据 host address 取余, 发送异常就返回 0-31 之间的随机数
     * @return 机器ID
     */
    private static int getWorkId() {
        try {
            return getHostId(Inet4Address.getLocalHost().getHostAddress(), WORK_MAX_NUM);
        } catch (UnknownHostException e) {
            return new Random().nextInt(WORK_RANDOM);
        }
    }

    /**
     * 根据 host name 取余, 发送异常就返回 0-31 之间的随机数
     * @return 机房ID(数据中心ID)
     */
    private static int getDataId() {
        try{
            return getHostId(Inet4Address.getLocalHost().getHostName(), DATA_MAX_NUM);
        }catch(Exception e){
            return new Random().nextInt(DATA_RANDOM);
        }
    }

    /**
     * 获取下一不同毫秒的时间戳
     * @param lastMillis
     * @return 下一毫秒的时间戳
     */
    private static long nextMillis(long lastMillis) {
        long now = System.currentTimeMillis();
        while (now <= lastMillis) {
            now = System.currentTimeMillis();
        }
        return now;
    }

    /**
     * 核心算法,需要加锁保证并发安全
     * @return 返回唯一ID
     */
    public synchronized static long getUUID() {
        long now = System.currentTimeMillis();

        // 如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过,此时因抛出异常
        if (now < LAST_TIME_STAMP) {
            throw new RuntimeException(String.format("系统时间错误! %d 毫秒内拒绝生成雪花ID", START_TIME));
        }

        if (now == LAST_TIME_STAMP) {
            LAST_SEQ = (LAST_SEQ + 1) & SEQ_MAX_NUM;
            if (LAST_SEQ == 0) {
                now = nextMillis(LAST_TIME_STAMP);
            }
        } else {
            LAST_SEQ = 0;
        }

        // 上次生成ID的时间戳
        LAST_TIME_STAMP = now;

        return ((now - START_TIME) << TIME_LEFT_BIT | (DATA_ID << DATA_LEFT_BIT) | (WORK_ID << WORK_LEFT_BIT) | LAST_SEQ);
    }

    /**
     * 主函数测试
     * @param args
     */
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        int num = 300000;
        for (int i = 0; i < num; i++) {
            System.out.println(getUUID());
        }
        long end = System.currentTimeMillis();

        System.out.println("共生成 " + num + " 个ID,用时 " + (end - start) + " 毫秒");
    }
}

 

为什么推荐使用数字做主键,不推荐用uuid作为主键。

数字做主键int bigint以字节存储分别是4byte和8byte

uuid在mysql4.0以前以字节存储,4.0以后以字符存储, varchar(32) 字符集是utf-8 中文name字段长度小于255是32*3+1占位符  255以上是32*3+2 英文32+1 32+2

 

posted on 2020-07-26 16:04  袁子弹  阅读(10912)  评论(1编辑  收藏  举报