数据库中主键ID的生成方案


数据库表里通常都会有一个主键id,来作为这条数据的唯一标识。主键 一定要做到唯一,mysql数据库提供的自增主键可以作为主键ID,UUID全球唯一的特性也是一个方案,那究竟应该选哪种方案,每种方案的优劣又是什么?

基于UUID

UUID是由一组32个16进制数字所构成,以连字号分为五段,形式为8-4-4-4-12的32个字符,如:550e8400-e29b-41d4-a716-446655440000。故UUID理论上的总数为16^32 = 2^128,约等于3.4 x 10^38。也就是说若每纳秒产生1兆个UUID,要花100亿年才会将所有UUID用完。

UUID由以下几部分的组合:

  • 当前日期和时间,UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同。 时钟序列。
  • 全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。

优点:

  • JDK自带本地生成,无网络消耗。

  • 因为具备全球唯一的特性,所以对于数据库迁移这种情况不存在问题。

缺点:

  • 每次生成的ID都是无序的,innodb底层使用B+树每次插入的时候可能会造成页分裂,以及插入的页面不在缓存中造成大量的随机IO。
  • 一个十六进制数占4位,也就是半个字节,那么UUID就是16个字节。UUID长度过长,每一个二级索引的叶子结点会带着主键,浪费大量空间,耗费数据库性能。

适用场景:

  • 可以用来生成如token令牌一类的场景,足够没辨识度,而且无序可读,长度足够。
  • 可以用于无纯数字要求、无序自增、无可读性要求的场景。

基于数据库主键自增

自增主键在每张表中都会存在,即使没有定义也会自动生成。自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑。

不同的引擎对于自增值的保存策略不同。

  • MyISAM 引擎的自增值保存在数据文件中。
  • InnoDB 引擎的自增值,其实是保存在了内存里,并且到了 MySQL 8.0 版本后,才有了“自增值持久化”的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为 MySQL 重启前的值”,具体情况是:
  • 在 MySQL 5.7 及之前的版本,自增值保存在内存里,并没有持久化。每次重启后,第一次打开表的时候,都会去找自增值的最大值max(id),然后将 max(id)+1 作为这个表当前的自增值。 举例来说,如果一个表当前数据行里最大的 id 是 10,AUTO_INCREMENT=11。这时候,我们删除 id=10 的行,AUTO_INCREMENT 还是11。但如果马上重启实例,重启后这个表的 AUTO_INCREMENT 就会变成 10。 也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。
  • 在 MySQL 8.0 版本,将自增值的变更记录在了 redo log 中,重启的时候依靠 redolog 恢复重启之前的值。

在 MySQL 里面,如果字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候, 如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段;如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值。

根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设,某次要插入的值是 X,当前的自增值是 Y。如果 X<Y,那么这个表的自增值不变;如果 X≥Y,就需要把当前自增值修改为新的自增值。

新的自增值是从 auto_increment_offset(自增初始值) 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 id 的值,作为新的自增值。

当 auto_increment_offset 和 auto_increment_increment 都是 1 的时候,如果X≥Y,新的自增值就是“X+1”;否则,自增值不变。

但是在这两个参数都设置为 1 的时候,自增主键 id 却不能保证是连续的。这是因为唯一键冲突或者回滚导致插入语句失效的时候自增值是不会回退的。

另外,对于批量插入数据,Mysql 中有批量申请自增 id 的策略。第一次分配 1 个,用完后再分配 2 个,再用完后第三次分配 4 个,以此类推。比如批量插入 5 条数据,前两次申请的 id 用完后,第三次分配了 4 个,但实际只用了 2 个。这个时候,分配的 id 不会收回去,因此也会导致 自增主键 id 不连续。

因此自增主键不一定不连续有三个原因:

  • 插入数据失败后,自增值不回滚
  • 事务回滚后,自增值不回退
  • 批量插入数据时,会批量提供 ID,用不完不会回收

优点:

  • 实现简单,依靠数据库即可,成本小。

缺点:

  • 需要合并表的时候,会出现主键冲突。
  • 每次获取ID都得读写一次数据库,影响性能

适用场景:

  • 小规模的,数据访问量小的业务场景。
  • 无高并发场景,插入记录可控的场景。

基于类Snowflake算法

snowflake雪花算法是twitter公司内部分布式项目采用的ID生成算法,用于在不同的机器上生成唯一的ID的算法。该算法生成一个64bit的数字作为分布式ID,保证这个ID自增并且全局唯一。生成的64位ID结构如下:
在这里插入图片描述

  • 第一位为0不使用:第一位为0表示我们生成的分布式ID是一个正数
  • 时间戳41位:41 bit 可以标识 2 ^ 41 - 1 个毫秒值,换算成年就是表示 69 年的时间,,从1970年到2039年。
  • 工作机器ID10位:记录工作机器 id,代表的是这个服务最多可以部署在 2^10 台机器上,也就是 1024 台机器。10 bit 里 5 个 bit 代表机房 id,5 个 bit 代表机器 id。意思就是最多代表 2 ^ 5 个机房(32 个机房),每个机房里可以代表 2 ^ 5 个机器(32 台机器),这里可以随意拆分,比如拿出4位标识业务号,其他6位作为机器号。可以随意组合
  • 序列号12位:12 bit 可以代表的最大正整数是 2 ^ 12 - 1 = 4096,可以区分同一个毫秒内的 4096 个不同的 id。也就是同一毫秒内同一台机器所生成的最大ID数量为4096,如果到达这个值,程序通过自旋等待时间到达下一毫秒然后生成新的分布式ID。

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

优点:

  • 生成单调自增的唯一ID,在innodb的b+数表中顺序插入不会造成页的分裂,性能高。
  • 生成64位id,只占用8个字节节省存储空间。

缺点:

  • 每台数据库的本地时间都要设置相同,否则会导致全局不递增
  • 如果时钟回拨,会产生重复id。

解决雪花算法的时间回拨问题的几种思路:

  1. 多时钟
    从工作机器 ID 以及序列号中各取 2 位,用于 1 个 4 位的时钟 ID。每次发现时间回拨(即之前最后一次生成 ID 的时间戳小于等于当前时间戳)的时候,便将时钟 ID 加 1,类似序列号
  2. 采用“历史时间”
    在进程启动后,我们会将当前时间(实际处理采用了延迟 10ms 启动)作为该业务这台机器进程的时间戳中的起始时间字段。后续的自增是在序列号自增到最大值时候时间戳增 1,而序列号重新归为 0,算是将时间戳和序列号作为一个大值进行自增,只是初始化不同。

雪花算法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) + " 毫秒");
    }
}

猜你喜欢

转载自blog.csdn.net/weixin_44153131/article/details/129025408