全局id改如何设计才能高效且安全呢?

537 阅读8分钟

简介

上一文我们聊到了短网址服务
传送门如何设计一个短网址服务 )
❤️ 涉及到了很多点,其中的核心是全局id,那么今天我们就来聊一下全局id改如何设计才能够高效且安全

❤️ 以及每种方法的优缺点、带来的问题以及解决方案

❤️ 耐心看完就可以跟面试官对线吹水

干货满满,你准备好了吗🐒

正文

为什么要使用全局ID

含义

(下面开始废话学)
全局ID顾名思义,意为一个业务下一个永远不会重复的字符串或者数字,当然一般为数字,毕竟人家叫ID
那么有些人会有疑问了?使用mysql的自增或者java的 i++ 不就可以了嘛?
当然可以!!
使用mysql自增做全局id前提是没有分库分表
使用java的i++得保证你的服务不会重启且是单机服务(不存在的🤔)

实现方式

我们得用一个东西,来保证分布式、分库分表情况下也能保证唯一性且有序性?诶?这不巧了吗,首先想到了什么?redis呗就,还能咋的~~~,NONO,还是有很多实现方法的,下面我们一一探讨市面上几个常用的实现方式

UUID

直接上代码

System.out.println(UUID.randomUUID());

得到的结果:9796a02e-ec79-47ab-bd10-d5a2d27ce650
简单到只有一行代码就可以生成这一段数字+英文的神秘代码,但是这样子的字符串对业务来讲,有什么意义呢?是没有意义的,一般不会选择作为分布式全局ID来使用

关于UUID是什么,这里不就不炒冷饭了,不做多解释了

redis

我们可以使用redis的原子操作来实现全局id

incr/incrby
redis性能很高,如果单机有性能瓶颈,可以使用redis集群(推荐一下codis),多个集群可以使用步长(incrby 集群数量)来避免id重复,比如我们有3个集群

集群id序列
A1,4,7,10,13
B2,5,8,11,14
C3,6,9,12,15

面试官:这种方案如果redis宕机或者重启怎么办?

:对啊,redis基于内存,即便是有持久化机制RDB、AOF,当redis宕机或者重启的时候难免会丢失数据,天呐这么大的漏洞要怎么办啊?不如就此放弃?❌

假装思考🤔后...... : 由于redis是基于内存的,即便是有持久化机制RDB、AOF,当redis宕机或者重启的时候难免会丢失数据?如果全局id这个key丢失了,那么id就要从头开始或者生成重复的id,针对这种情况,我们可以使用单独的redis实例做全局id,AOF加强使用,保存redis操作的每一条记录,这样重启恢复时,不至于丢失数据,缺点就是数据恢复时间过长

面试官:还行吧我们继续聊~~

mysql

使用mysql来作为全局id一般🈶️两种方式

  1. 自增id
  2. id字段

自增id

使用单独一个表,利用id自增来达到全局id的效果

CREATE TABLE t_sequence_1 (
    id bigint(20) unsigned NOT NULL auto_increment, 
    value char(10) NOT NULL default '',
    PRIMARY KEY (id)
);

生成ID时,往里面写入一条数据并返回id即可

insert into t_sequence_1(value)value("xxx");

这么做虽然简单,但是有一个致命的地方,当服务qps很高时,mysql本身就是性能瓶颈,会导致这张表的数据量巨大,因此风险较大,不是很推荐

id字段

使用单独一张表,这张表只有一条记录,生成id时将id字段做加法操作并返回该字段

CREATE TABLE `t_sequence` (
  `name` varchar(50) CHARACTER SET utf8mb4 NOT NULL DEFAULT '',
  `id` bigint(30) NOT NULL DEFAULT '0',
  PRIMARY KEY (`name`)
) 

利用name字段做业务区分,比如当前业务是tingurl,先给一个初始值

insert t_sequence value ("tinyurl",1);

需要生成id时先把id字段查出来,再做加法操作,注意做好 并发控制 ⭐️⭐️⭐️⭐️⭐️

悲观锁实现:
step1. SELECT id from t_sequence where name = 'tinyurl' for update
step2. UPDATE t_sequenceset id = id + #{num} where name = 'tinyurl'
step3. 返回 id+num的值作为全局id供业务方使用,这里的num为步长,意味id间隔大小,可以为1,也可以>1,具体业务具体使用
因为对应业务只有一条数据,所以这里是用的是select for update 悲观锁来保证id安全。
当然也可以使用乐观锁来实现,但是业务上需要做自旋操作,得不偿失

乐观锁实现:
step1. SELECT id from t_sequence where name = 'tinyurl'
step2. UPDATE t_sequenceset id = id + #{num} where name = 'tinyurl' and id = #{id} (step1查询出来的id值),这里的num为步长,意味id间隔大小,可以为1,也可以>1,具体业务具体使用
step3. 若更新条数为1,则直接返回id+num,若更新条数为0,则说明更新冲突了,需要自旋一次,从step1开始走流程

面试官: 你讲的很好,并发控制也讲到了,但是有没有考虑过I/O开销?以及最大容量?

我(内心): 就等你问这个问题呢,我得假装考虑一下再回答你这个问题,这样才显得我足够优秀,嘻嘻嘻我真是小机灵鬼🧐

假装思考若干秒后:

针对I/O开销,我们可以实现一个id生成器,一条线程不停的生成id,保证唯一性和顺序性,然后将生成的id放入队列中(先进先出、保证顺序),工作线程要获取id时直接从队列头拿出来即可,这样是不就除去了工作线程I/O的过程,直接读内存,速度大大的提高。当然我们也可以设置一个规则,比如当队列长度小于某一个界限的时候再生成id。上代码(基于springboot的伪代码)

以下是id生成器,这种方法也适用于redis的id生成,同样也是短网址服务全局id生成的策略,10个节点,单节点每日最高峰270QPS,笔者还是很推荐这种方式的 ⭐️⭐️⭐️⭐️⭐️

//步长,可要可不要,用于id生成
private static final int STEP = 50;

//来一个固定长度的队列,存放预生成的id,容量微step*5
private static final ArrayBlockingQueue<Long> SEQUENCE_QUEUE = new ArrayBlockingQueue<>(STEP * 5);

//id生成器
@PostConstruct
public void init() {
   new Thread(() -> {
      while (true) {
         try {
            //当队列容量小于step/2时候才预生成id
            if (SEQUENCE_QUEUE.size() < (STEP / 2)) {
               genId();
               log.info("queueSsize(" + SEQUENCE_QUEUE.size() + ")");
            }
         } catch (Exception e) {
            ThreadUtil.sleep(50);
         }
         ThreadUtil.sleep(50);
      }
   }).start();
}

/**
 * 生成id
 */
private void genId() {
   //从数据库获取id
   int seqMax = sequenceRepository.getId(STEP);
   for (long i = seqMax - STEP + 1; i <= seqMax; i++) {
      try {
         SEQUENCE_QUEUE.put(i);
      } catch (InterruptedException e) {
         log.error("tinyurl_error: SEQUENCE_QUEUE put fail", e);
      }
   }
}


public int getId(int step) {
   //悲观锁获取id,防止生成id过程中有修改
   int id = sequenceMapper.getId();
   //更新id
   sequenceMapper.updateId(step);
   //返回id
   return id + step;
}
getId sql:
SELECT id from t_sequence where name = 'tinyurl' for update
updateId sql:UPDATE t_sequence set id = id + #{step} where name = 'tinyurl'

以下是获取id

/**
 * 从预生成的id队列头获取一个id
 * 
 * @return
 * @throws Exception
 */
public long newId() throws Exception {
   return SEQUENCE_QUEUE.take();
}


针对"id用完"这种情况,实际上就是生成的id已经超出了存储大小,比如long类型最大存储9223372036854775807,同java的long类型上限,当id达到这个值的时候,我们是可以感叹一下我们的业务是如此的火爆😎
当全局id达到这个上限时,我们就应该考虑一下是不是我们的全局id使用的不合理?(比如一个日志系统你要这样生成id,那是万万不可的),是不是考虑一下业务的水平拆分?这时候就不是系统层面能够解决的问题了,(除非把id换成decimal类型,这样业务方也需要改造,成本很大),应该要考虑一下业务拆分了。

面试官: 小伙子不错,我们进办公室♂细聊♂

mysql的号段模式

这种模式也算是用的比较多了,其原理就是从数据库批量的获取id,每次从db中读取一个号码段,1-5000 代表5000个id,生成1-5000个id加载到内存中,供业务方使用

CREATE TABLE t_step_id (
  id BIGINT(20) NOT NULL,
  type int(10) NOT NULL COMMENT '业务类型',
  max_id bigint(20) NOT NULL COMMENT '当前最大id',
  step int(20) NOT NULL COMMENT '号段步长',
  version int(20) NOT NULL COMMENT '乐观锁版本',
  PRIMARY KEY (`id`)
) 
insert int t_step_id value(1,1,5000,5000,1);
此时业务方需要使用id时,首先去内存中读取1-50005000个id,取出一个最小值比如,996,然后再把996移出内存
如果内存中没有值,则需要向mysql重新申请号码段,sql如下:

update t_step_id set max_id = #{max_id+step}, version = version + 1 where version = # {version} and type = 1

然后再把申请下来的5001-100005000个id放入内存,继续使用

为什么要使用乐观锁呢?因为支持多业务,防止并发而已啦~~

这种方式避免了多次访问数据库,降低了I/O次数,增加了效率,但是也有缺点的,一旦申请了号码段的节点重启了,那么就会丢失这一段的id,造成id空洞不连续,但是无伤大雅🤔。

雪花算法(SnowFlake,twitter的)

直接上原理图

image.png

  • 第一个bit位是标识部分,在java中由于long的最高位是符号位,正数是0,负数是1,一般生成的 ID为正数,所以固定为0。
  • 时间戳部分占41bit,这个是毫秒级的时间,一般实现上不会存储当前的时间戳,而是时间戳的差值(当前时间-固定的开始时间),这样可以使产生的ID从更小值开始;41位的时间戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年
  • 工作机器id占10bit,这里比较灵活,比如,可以使用前5位作为数据中心机房标识,后5位作为单机房机器标识,可以部署1024个节点。
  • 序列号部分占12bit,支持同一毫秒内同一个节点可以生成4096个ID

以下代码是抄的,勿喷🤣🤣🤣

package snowflake;


public class SnowflakeIdWorker {
    
    /**
     * 开始时间截 (2015-01-01),可以自定义
     */
    private final long twepoch = 1420041600000L;

    /**
     * 机器id所占的位数
     */
    private final long workerIdBits = 5L;

    /**
     * 数据标识id所占的位数
     */
    private final long datacenterIdBits = 5L;

    /**
     * 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数)
     */
    private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

    /**
     * 支持的最大数据标识id,结果是31
     */
    private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

    /**
     * 序列在id中占的位数
     */
    private final long sequenceBits = 12L;

    /**
     * 机器ID向左移12位
     */
    private final long workerIdShift = sequenceBits;

    /**
     * 数据标识id向左移17位(12+5)
     */
    private final long datacenterIdShift = sequenceBits + workerIdBits;

    /**
     * 时间截向左移22位(5+5+12)
     */
    private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

    /**
     * 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095)
     */
    private final long sequenceMask = -1L ^ (-1L << sequenceBits);

    /**
     * 工作机器ID(0~31)
     */
    private long workerId;

    /**
     * 数据中心ID(0~31)
     */
    private long datacenterId;

    /**
     * 毫秒内序列(0~4095)
     */
    private long sequence = 0L;

    /**
     * 上次生成ID的时间截
     */
    private long lastTimestamp = -1L;


    /**
     * 构造函数
     *
     * @param workerId     工作ID (0~31)
     * @param datacenterId 数据中心ID (0~31)
     */
    public SnowflakeIdWorker(long workerId, long datacenterId) {
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }


    /**
     * 获得下一个ID (该方法是线程安全的)
     *
     * @return SnowflakeId
     */
    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();

        //如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(
                    String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
        }

        //如果是同一时间生成的,则进行毫秒内序列
        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & sequenceMask;
            //毫秒内序列溢出
            if (sequence == 0) {
                //阻塞到下一个毫秒,获得新的时间戳
                timestamp = tilNextMillis(lastTimestamp);
            }
        }
        //时间戳改变,毫秒内序列重置
        else {
            sequence = 0L;
        }

        //上次生成ID的时间截
        lastTimestamp = timestamp;

        //移位并通过或运算拼到一起组成64位的ID
        return ((timestamp - twepoch) << timestampLeftShift) //
                | (datacenterId << datacenterIdShift) //
                | (workerId << workerIdShift) //
                | sequence;
    }

    /**
     * 阻塞到下一个毫秒,直到获得新的时间戳
     *
     * @param lastTimestamp 上次生成ID的时间截
     * @return 当前时间戳
     */
    protected long tilNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
    
    
    public static void main(String[] args) {
        //最好做单例模式,很重要
        SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
        for (int i = 0; i < 100; i++) {
            long id = idWorker.nextId();
            System.out.println(id);
        }
    }
}

这个算法效率是极高的,没有I/O的开销,也不依赖于任何中间件,但是有一个致命的缺点,就是依赖于服务器的时间,如果服务器的时间回退,就可能造成id重复

总结

以上就是几种常见的全局id解决方案,当然还有很多开源的解决方案比如

  • TinyID(滴滴的)
  • Uidgenerator (百度的)
  • Leaf(美团的) 大家有兴趣可以自行了解下,其实就是对上述几种方法的封装+优化,万变不离其宗

    原创文章,完全基于个人的理解码出来的文,如有不正确的地方希望各位积极指出,变得更好~~



下期预告⭐️⭐️⭐️⭐️⭐️ :如何设计一个高效且准确的es全量、增量服务,我会结合自己的亲身经历来探讨此问题,尽情期待🧐🤔

一个努力让自己变强的深漂Java程序员🐒