深度思考:雪花算法snowflake分布式id生成原理详解

news2024/10/9 2:23:33

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

雪花算法snowflake是一种优秀的分布式ID生成方案,其优点突出:它能生成全局唯一且递增的ID,确保了数据的一致性和准确性;同时,该算法灵活性强,可自定义各部分bit位,满足不同业务场景的需求;此外,雪花算法生成ID的速度快,效率高,能有效应对高并发场景,是分布式系统中不可或缺的组件。

前言

分布式ID的基本特性

在分布式系统的复杂环境下,数据量的持续激增对数据库架构提出了新的挑战。

传统的垂直与水平分库分表策略虽然能有效应对数据存储和扩展的问题,但却引出了一个新的需求:需要一个全局唯一标识符(ID)来标定每条数据或消息队列(MQ)中的消息

在这种情况下,依赖数据库自身的ID自增功能显然已经无法满足需求,因此,必须设计出符合以下要求的分布式ID生成策略:

1、全局唯一性,所生成的ID必须在全局范围内是独一无二的,以确保数据的精准标识,避免任何可能的ID冲突。

2、趋势递增,鉴于多数关系型数据库管理系统(RDBMS)MySQL的InnoDB引擎使用B树结构来存储索引数据,并依赖聚集索引进行优化,最好择有序的ID以保证高效的写入性能,因此生成的ID应具备整体递增的趋势。

3、单调递增,在特定场景下,如分布式事务的版本控制、即时通讯的增量消息传递或数据排序等,需要确保每个新生成的ID都严格大于前一个ID,从而实现数据的线性顺序增长。

4、信息安全,针对某些敏感业务,如订单处理等,生成的分布式ID必须是无规则的,以防止外部通过解析ID获取到流量信息或其他商业机密,这就需要ID生成策略在保持唯一性和递增性的同时,还要具备足够的信息隐匿性。

分布式ID常见的算法

在分布ID生成的领域,目前市面上主要流行几种算法,这些算法也是众多开源项目进行优化和实现的基础:

1、UUID方式,UUID由于是在本地生成,因此具有极高的性能,然而,它生成的ID长度较长,达到16字节128位,通常需要使用字符串类型进行存储。

此外,UUID是无序的,这使得它在许多场景中并不适用,尤其是在作为MySQL数据库的主键和索引时。MySQL官方建议主键应尽可能短,而对于InnoDB引擎来说,索引的无序性可能会导致数据位置频繁变动,从而严重影响性能。

2、数据库自增ID方式,这种方法的缺点是每次获取ID都需要进行数据库IO操作,给数据库带来较大压力,且性能较低。如果数据库发生宕机,对依赖其服务的外部系统将是毁灭性的打击。

尽管可以通过部署数据库集群来提高可用性,但问题并未从根本上解决。

3、数据库号段算法,这是对数据库自增ID的一种优化方法。通过每次获取一个号段的值,可以大大减少与数据库的交互次数,从而显著降低数据库的压力。号段越长,性能越高。即使数据库发生宕机,只要号段未用完,系统仍能在短时间内继续提供服务。

美团的Leaf和滴滴的TinyId就是采用这种算法的典型例子。

4、雪花算法,Twitter开源的snowflake算法,以时间戳、机器标识和递增序列为基础生成ID。这种算法生成的ID基本呈趋势递增,且性能很高。然而,由于它强烈依赖于机器时钟,因此需要考虑时钟回拨问题。即当机器上的时间因校正而发生倒退时,可能会导致生成的ID重复。

目前,百度的uid-generator和美团的Leaf也都在使用或优化这种算法。

雪花算法snowflake

snowflake定义

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Snowflake算法的原理相对直观,它负责生成一个64位(long型)的全局唯一ID,这个ID的构成包括:1位无用的符号位、41位的时间戳、10位的机器ID以及12位的序列号,除了固定的1位符号位之外,其余的三个部分都可以根据实际需求进行调整:

1、41位时间戳:这部分能够表示的时间跨度为(1L<<41)/(1000L*3600*24*365),即大约69年。

2、10位机器ID:可以唯一标识最多1024台机器,如果需要对互联网数据中心(IDC)进行划分,可以将这10位拆分为两部分,例如各5位,这样,系统就能够表示最多32个IDC,且每个IDC下可以容纳32台机器。

3、12位自增序列号:用于在同一毫秒内生成多个ID,最多可以表示2^12个不同的ID,因此,理论上,Snowflake算法能够达到的每秒查询率(QPS)约为409.6万。

snowflake优缺点

优点:

  • 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。
  • 可以不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也非常高。
  • 可以根据自身业务特性分配bit位,非常灵活。

缺点:

  • 强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务处于不可用状态。

Java代码实现snowflake

1、不考虑时间戳回拨


/**
 * Twitter_Snowflake<br>
 * SnowFlake的结构如下(每部分用-分开):<br>
 * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
 * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>
 * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
 * 得到的值),这里的的开始时间截,一般是的id生成器开始使用的时间,由程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
 * 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>
 * 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br>
 * 加起来刚好64位,为一个Long型。<br>
 * SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
 */
public class SnowflakeIdWorkerV1 {

    // ==============================Fields===========================================
    /** 开始时间截 (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;

    //==============================Constructors=====================================
    /**
     * 构造函数
     * @param workerId 工作ID (0~31)
     * @param datacenterId 数据中心ID (0~31)
     */
    public SnowflakeIdWorkerV1(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;
    }

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

        //如果当前时间小于上一次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 = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }

    /**
     * 返回以毫秒为单位的当前时间
     * @return 当前时间(毫秒)
     */
    protected long timeGen() {
        return System.currentTimeMillis();
    }

    //==============================Test=============================================
    /** 测试 */
    public static void main(String[] args) {
        SnowflakeIdWorkerV1 idWorker = new SnowflakeIdWorkerV1(0, 0);
        for (int i = 0; i < 1000; i++) {
            long id = idWorker.nextId();
            System.out.println(Long.toBinaryString(id));
            System.out.println(id);
        }
    }
}

2、考虑时间戳回拨

/**
 * Twitter_Snowflake<br>
 * SnowFlake的结构如下(每部分用-分开):<br>
 * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
 * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>
 * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
 * 得到的值),这里的的开始时间截,一般是的id生成器开始使用的时间,由程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T =
 * (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
 * 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>
 * 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br>
 * 加起来刚好64位,为一个Long型。<br>
 * SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
 */
public class SnowflakeIdWorkerV2 {

  // ==============================Fields===========================================
  /** 开始时间截 (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;

  private long startTimestamp = -1L;

  // ==============================Constructors=====================================
  /**
   * 构造函数
   *
   * @param workerId 工作ID (0~31)
   * @param datacenterId 数据中心ID (0~31)
   */
  public SnowflakeIdWorkerV2(long workerId, long datacenterId, long startTimestamp) {
    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;
    this.startTimestamp = startTimestamp;
  }

  // ==============================Methods==========================================
  /**
   * 获得下一个ID (该方法是线程安全的)
   *
   * @return SnowflakeId
   */
  public synchronized long nextId() {
    long sequenceTmp = sequence;
    sequence = (sequence + 1) & sequenceMask;
    if (sequence == 0 && sequenceTmp >= 0) {
      // sequence自增到最大了,时间戳自增1
      startTimestamp += 1;
    }

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

  /**
   * 返回以毫秒为单位的当前时间
   *
   * @return 当前时间(毫秒)
   */
  protected static long timeGen() {
    return System.currentTimeMillis();
  }

  // ==============================Test=============================================
  /** 测试 */
  public static void main(String[] args) {
    SnowflakeIdWorkerV2 idWorker = new SnowflakeIdWorkerV2(0, 0, timeGen());
    for (int i = 0; i < 1000; i++) {
      long id = idWorker.nextId();
      System.out.println(Long.toBinaryString(id));
      System.out.println(id);
    }
  }
}

3、逻辑实现流程

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

组装生成id

生成id的过程,就是把每一种标识(时间、机器、序列号)移到对应位置,然后相加,如下:

long id = (deltaTime << TIMESTAMP_LEFT_SHIFT) | (this.dataCenterId << DATA_CENTER_ID_SHIFT) | (this.workerId << WORKER_ID_SHIFT) | this.sequence;
  • deltaTime向左移22位(IDC-bit+机器bit+序列号bit)。
  • dataCenterId向左移17位(机器bit+序列号bit)。
  • workerId向左移12位(序列号bit)。
  • sequence不用移,中间的|以运算规律就相当于+求和(1 | 1 = 1,1 | 0 = 1,0 | 1 = 1,0 | 0 = 0)

计算最大值的几种方式

代码中分别对每个标识的最大值做了计算:

//序列掩码,用于限定序列最大值为4095 ((2^12)-1) ,从0开始算就有4096个序列
private static final long SEQUENCE_MASK =  -1L ^ (-1L << SEQUENCE_BITS);

//最大支持机器节点数0~31,一共32个  (2^5)-1
private static final long MAX_WORKER_ID = -1L ^ (-1L << WORKER_ID_BITS);

//最大支持数据中心节点数0~31,一共32个   (2^5)-1
private static final long MAX_DATA_CENTER_ID = -1L ^ (-1L << DATA_CENTER_ID_BITS);

//最大时间戳 2199023255551   (2^41)-1
private static final long MAX_DELTA_TIMESTAMP = -1L ^ (-1L << TIME_STAMP_BITS);

上方的计算就是利用二进制的运算逻辑,拿-1L ^ (-1L << SEQUENCE_BITS)举例:

先看看从哪个方向开始计算:-1L ^ (-1L << 12)-1L和(-1L <<12)做^按位异或运算(1 ^ 1 = 0,1 ^ 0 = 1,0 ^ 1 = 1,0 ^ 0 = 0)

  • -1L的二进制为64个1:1111111111111111111111111111111111111111111111111111111111111111
  • -1L左移12位得到:1111111111111111111111111111111111111111111111111111 000000 000000
  • 最后11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 000000 000000^运算得到0000000000000000000000000000000000000000000000000000 111111 111111(前面有52个0),这就得到序列号的最大值(4095)了,也可以说是掩码。

反解析ID

(1)通过已经生成的ID解析出时间、机器和序列号:

public long getSequence(long id) {
    return id & ~(-1L << SEQUENCE_BITS);
}

public long getWorkerId(long id) {
    return id >> WORKER_ID_SHIFT & ~(-1L << WORKER_ID_BITS);
}

public long getDataCenterId(long id) {
    return id >> DATA_CENTER_ID_SHIFT & ~(-1L << DATA_CENTER_ID_BITS);
}

public long getGenerateDateTime(long id) {
    return (id >> TIMESTAMP_LEFT_SHIFT & ~(-1L << 41L)) + twepoch;
}

因为sequence本身就在低位,所以不需要移动,其他机器和时间都是需要将id向右移动,使得自己的有效位置在低位,至于和自己的最大值做&运算,是为了让不属于自己bit的位置无效,即都转为0。

例如:生成的id为1414362783486840832,转为二进制1001110100000110100110111010100111101010001000001000000000000,想解析出workerIdworkerId有效位为[13, 17],那就将id向右移12位,移到低位得到0000000000001001110100000110100110111010100111101010001000001workerId5-bit,那么除了低位5-bit,其他位置都是无效bit,转为0000000000000100111010000011010011011101010011110101000100000111111&运算得到1(左边都是0可以省掉)。

(2)不过还有一种解析的思路更易于理解,就是运用两次移位运算,把无效位置移除:

1bit-sign + 41bit-time + 5bit-IDC + 5bit-workerId + 12bit-sequence

/**
 * 通过移位解析出sequence,sequence有效位为[0,12]
 * 所以先向左移64-12,然后再像右移64-12,通过两次移位就可以把无效位移除了
 * @param id
 * @return
 */
public long getSequence2(long id) {
    return (id << (TOTAL_BITS - SEQUENCE_BITS)) >>> (TOTAL_BITS - SEQUENCE_BITS);
}
/**
 * 通过移位解析出workerId,workerId有效位为[13,17], 左右两边都有无效位
 * 先向左移 41+5+1,移除掉41bit-时间,5bit-IDC、1bit-sign,
 * 然后右移回去41+5+1+12,从而移除掉12bit-序列号
 * @param id
 * @return
 */
public long getWorkerId2(long id) {
    return (id << (TIME_STAMP_BITS + DATA_CENTER_ID_BITS + SIGN_BITS)) >>> (TIME_STAMP_BITS + DATA_CENTER_ID_BITS + SEQUENCE_BITS + SIGN_BITS);
}
/**
 * 通过移位解析出IDC_ID,dataCenterId有效位为[18,23],左边两边都有无效位
 * 先左移41+1,移除掉41bit-时间和1bit-sign
 * 然后右移回去41+1+5+12,移除掉右边的5bit-workerId和12bit-序列号
 * @param id
 * @return
 */
public long getDataCenterId2(long id) {
    return (id << (TIME_STAMP_BITS + SIGN_BITS)) >>> (TIME_STAMP_BITS + WORKER_ID_BITS + SEQUENCE_BITS + SIGN_BITS);
}
/**
 * 41bit-时间,左边1bit-sign为0,可以忽略,不用左移,所以只需要右移,并加上起始时间twepoch即可。
 * @param id
 * @return
 */
public long getGenerateDateTime2(long id) {
    return (id >>> (DATA_CENTER_ID_BITS + WORKER_ID_BITS + SEQUENCE_BITS)) + twepoch;
}

ID生成器使用方式

在分布式ID生成的策略中,通常采用两种方式:一是基于发号器的方案,二是本地生成方案。

1、发号器方案

该方案将雪花算法ID的生成逻辑封装成一个独立的服务,并部署在多个服务器上,外部系统通过请求发号器服务来获取ID,此方案的优势在于,无需部署大量的服务器,仅需确保服务器数量满足需求即可,默认的1024台已经足够。

但是,由于ID的获取涉及到远程请求,因此会受到网络波动的影响,其性能自然无法与直接从本地生成相比,同时,发号器服务的稳定性至关重要,一旦服务出现故障,将影响到许多依赖其的外部服务。为了确保发号器的高可用性,需要采取多实例部署、异地容灾等策略。

此外,发号器在设计时还可以考虑一次性发布一段时间内的ID供服务本地缓存,这样既能提升性能,减少对发号器的频繁请求,也能在一定程度上降低发号器故障所带来的风险。

2、本地生成方案

在此方案中,ID是在本地直接生成的,因此不存在网络延迟问题,性能极高。但是,为了确保生成的ID具有全局唯一性,需要依赖机器ID来进行区分。因此每台机器以及机器上部署的每个服务都需要分配不同的机器ID。

此外,服务重启后也需要重新分配新的机器ID,这种特性使得机器ID具有“用后即弃”的性质,为了满足对大量机器ID的需求,不得不减少时间戳和序列号的位数,这样做虽然会牺牲一部分ID的丰富性,但却是保证本地生成方案可行性的必要妥协。

运算符相关概念

运算符&

"&"表示按位与操作,它用于操作整数类型的位模式,并将每个操作数中的每一位进行与操作,它会将两个整数的二进制表示进行按位与运算,只有当两个数的相应位都为1时,结果的相应位才为1,否则为0。这种运算符通常用于低级位操作,如设置、清除或翻转二进制位。

int a = 60;  // 60 = 0011 1100  
int b = 13;  // 13 = 0000 1101  
int c = a & b;  // c will be 12 = 0000 1100

运算符|

"|"表示按位或操作,它用于操作整数类型的位模式,并将每个操作数中的每一位进行或操作,它会将两个整数的二进制表示进行按位或运算,只要两个数的相应位中有一个为1,结果的相应位就为1,否则为0。

int a = 60;  // 60 = 0011 1100  
int b = 13;  // 13 = 0000 1101  
int c = a | b;  // c will be 61 = 0011 1101

运算符^

运算符“^”表示按位异或(XOR)操作,它是一个二元运算符,用于对两个整数的二进制位进行异或运算,异或运算的规则是:如果两个相应的二进制位相同,则结果为0;如果两个相应的二进制位不同,则结果为1。

按位异或运算符“^”通常用于处理整数的位级操作,当对两个整数进行异或运算时,它们的每一个二进制位都会根据异或规则进行运算,生成一个新的整数作为结果。

除了整数类型,按位异或运算符也可以应用于布尔类型。在布尔上下文中,运算符“^”表示逻辑异或操作。当应用于两个布尔值时,如果两个值不同,则结果为true;如果两个值相同,则结果为false。

int a = 15; // 二进制表示为 0000 1111  
int b = 8;  // 二进制表示为 0000 1000  
int c = a ^ b; // 进行按位异或运算  
System.out.println("Result: " + c); // 输出结果为 7(二进制表示为 0000 0111)

在这个示例中,整数a和b的二进制表示分别为0000 1111和0000 1000,对它们进行按位异或运算后,得到的结果为0000 0111,即十进制数7。

运算符<<

运算符“<<”表示左移运算,也被称为位移左移运算符或左移位运算符。这个运算符用于将整数的二进制表示向左移动指定的位数。

当你对一个整数应用“<<”运算符并指定一个位移量时,该整数的二进制形式中的所有位都会向左移动指定的位数,在移动过程中,最左边的位会被丢弃,而右边空出的位则用0来填充。

左移运算的效果等同于将原始数值乘以2的位移量次方,这是因为每向左移动一位,就相当于将原始数值乘以2(在二进制中,左移一位等同于将数值翻倍)。例如,如果你有一个整数8(在二进制中表示为1000),将其左移两位,结果将是32(在二进制中表示为100000),这是因为8乘以2的2次方等于32。

int originalValue = 8;  
int shiftedValue = originalValue << 2; // 结果是32

时钟回拨问题及其解决方案探讨

在深入了解时钟回拨问题之前,首先需要探讨为什么机器的本地时钟会出现回拨现象。

由于各种潜在因素,机器的本地时钟可能会变得不准确。为了校准这些时间偏差,网络提供了NTP(网络时间协议)服务。然而,在进行时间校准时,时钟的跳跃或回拨问题可能会随之出现。

雪花算法由于其强烈依赖于机器时钟,因此难以避免受到时钟回拨的影响,这可能导致生成重复的ID。

在原标准实现中,当时钟回拨发生时,系统会直接抛出异常并短暂停止对外服务。然而,这种处理方式在实际生产环境中是不可接受的。因此,需要探索有效的解决方案来最小化时钟回拨带来的影响。以下是两种可能的解决思路:

1、摆脱对机器时钟的依赖:可以定义一个初始时间戳,并在需要时自增该时间戳,而不是跟随机器时钟的增加。那么,何时自增这个时间戳呢?一个合理的策略是,当序列号增加到最大值时,将时间戳加1。这种方法能够充分利用序列号,特别适用于流量较大的场景。然而,在流量较小的情况下,可能会出现时间断层或滞后的现象。

2、优化对机器时钟的依赖:如果仍然选择依赖机器时钟,那么对于小范围的时钟回拨(例如几十毫秒),可以选择等待时钟恢复正常。此外,如果流量不大,可以考虑缓存前几百毫秒或几秒的序列号。当时钟回拨发生时,可以从缓存中获取并自增序列号,从而有效地应对时钟回拨问题。这种方法在保持对机器时钟依赖的同时,降低了时钟回拨对系统的影响。

时间戳自增彻底解决时钟回拨问题

private long sequence = -1L;
private long startTimestamp = 1623947387000L;
private synchronized  long nextId2() {
    long sequenceTmp = sequence;
    sequence = (sequence + 1) & SEQUENCE_MASK;
    // sequence =0 有可能是初始+1=0,也可能是超过了最大值等于0
    // 所以把 初始+1=0排除掉
    if (sequence == 0 && sequenceTmp >= 0) {
        // sequence自增到最大了,时间戳自增1
        startTimestamp += 1;
    }
    // 生成id
    return allocate(startTimestamp - twepoch);
}

这段Java代码定义了一个用于生成唯一ID的方法nextId2(),该方法不依赖于机器时钟,从而彻底解决了时钟回拨问题。代码中包含两个关键变量:sequencestartTimestamp

sequence初始化为-1,这是为了避免浪费sequence+1=0这一序列号。在每次生成ID时,sequence会自增,并通过与SEQUENCE_MASK进行位与操作来确保其在有效范围内循环。当sequence自增到最大值并回绕到0时,如果之前的sequenceTmp(即sequence自增前的值)是非负的,那么说明不是初始情况下的自增,此时会将startTimestamp时间戳自增1,以确保生成的ID的唯一性。

startTimestamp是一个初始时间戳,可以在构造器中指定,也可以使用默认值。该时间戳用于与twepoch(一个预设的常量值,表示时间起点)相减,从而得到一个相对时间差,这个相对时间差与序列号结合使用,通过allocate()方法生成最终的唯一ID。

这种方法的优点在于,它完全摆脱了机器时钟的限制,因此不会受到时钟回拨问题的影响。

同时,由于时间戳的自增是由程序自己控制的,因此可以充分利用未来时间,预先生成一些ID并存储在缓存中。

这样,外部系统可以直接从缓存中获取ID,而无需等待ID的生成过程,从而大大提高了性能。

此外,该方法还确保了每一毫秒内可以生成多达4096个序列号(范围从0到4095),且没有浪费。

*然而,这种方法也存在一个明显的缺点,即生成的ID中的时间戳并不是真实的时间。*如果系统的流量较小,那么时间戳可能会滞后很多。

因此,如果对从ID中解析出来的时间戳有特定的利用需求(例如用于记录事件发生的时间),那么这个缺点可能会成为问题。但如果时间戳的利用意义不大,那么这个缺点也就可以忽略不计了。

缓存历史序列号缓解时钟回拨问题

// 记录近2S的毫秒数的sequence的缓存
private int LENGTH = 2000;
// sequence缓存
private long[] sequenceCycle = new long[LENGTH];

private synchronized long nextId() throws Exception {
    long timestamp = timeGen();
    int index = (int)(timestamp % LENGTH);
    // 1、出现时钟回拨问题,获取历史序列号自增
    if (timestamp < lastTimestamp) {
        long sequence = 0;
        do {
           if ((lastTimestamp - timestamp) > LENGTH) {
               // 可自定义异常、告警等,短暂不能对外提供,故障转移,将请求转发到正常机器。
               throw new UnsupportedOperationException("The timeback range is too large and exceeds 2000ms caches");
            }
            long preSequence = sequenceCycle[index];
            sequence = (preSequence + 1) & SEQUENCE_MASK;
            if (sequence == 0) {
                // 如果取出的历史序列号+1后已经达到超过最大值,
                // 则重新获取timestamp,重新拿其他位置的缓存
                timestamp = tilNextMillis(lastTimestamp);
                index = (int)(timestamp % LENGTH);
            } else {
            	// 更新缓存
                sequenceCycle[index] = this.sequence;            
                return allocate((timestamp - this.twepoch), sequence);
            }
        } while (timestamp < lastTimestamp);
        // 如果在获取缓存的过程中timestamp恢复正常了,就走正常流程
    }
    // 2、时间等于lastTimestamp,取当前的sequence + 1
    if (timestamp == lastTimestamp) {
        sequence = (sequence + 1) & SEQUENCE_MASK;
        // Exceed the max sequence, we wait the next second to generate id
        if (sequence == 0) {
            timestamp = tilNextMillis(lastTimestamp);
            index = (int)(timestamp % LENGTH);
        }
    } else {
        // 3、时间大于lastTimestamp没有发生回拨, sequence 从0开始
        this.sequence = 0L;
    }
    // 缓存sequence + 更新lastTimestamp
    sequenceCycle[index] = this.sequence;
    lastTimestamp = timestamp;
    // 生成id
    return allocate(timestamp - this.twepoch);
}

这里缓存了2000ms的序列号,如果发生时钟回拨,且回拨范围在2000ms内,就从缓存中取序列号自增,超过2000ms回拨,就抛异常,故障转移,将请求分配到正常机器。

  1. 若获取的历史sequence+1之后超过了最大值,则重新获取时间戳,重新获取缓存sequence。
  2. 极端情况下,获取很多次缓存sequence+1都超过了最大值,就会一直循环获取,这样可能会影响性能,所以实际生产中可以限定重新获取次数。
  3. 在这个重新获取的过程中,时钟可能恢复正常了,则此时也要退出循环,走正常流程。

等待时钟校正

private synchronized  long nextId3() {
    long timestamp = timeGen();
    // 1、出现时钟回拨问题,如果回拨幅度不大,等待时钟自己校正
    if (timestamp < lastTimestamp) {
        int sleepCntMax = 2;
        int sleepCnt = 0;
        do {
            long sleepTime = lastTimestamp - timestamp;
            if (sleepCnt > sleepCntMax) {
                // 可自定义异常类
                throw new UnsupportedOperationException(String.format("Clock moved backwards. Refusing for %d seconds", sleepTime));
            }
            if (sleepTime <= 500) {
                try {
                    Thread.sleep(sleepTime);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    sleepCnt++;
                    timestamp = tilNextMillis(lastTimestamp);
                }
            } else {
                // 可自定义异常类
                throw new UnsupportedOperationException(String.format("Clock moved backwards. Refusing for %d seconds", sleepTime));
            }
        } while (timestamp < lastTimestamp);
    }
    // 2、时间等于lastTimestamp,取当前的sequence + 1
    if (timestamp == lastTimestamp) {
        sequence = (sequence + 1) & SEQUENCE_MASK;
        // Exceed the max sequence, we wait the next second to generate id
        if (sequence == 0) {
            timestamp = tilNextMillis(lastTimestamp);
        }
    } else {
        // 3、时间大于lastTimestamp没有发生回拨, sequence 从0开始
        this.sequence = 0L;
    }
    lastTimestamp = timestamp;
    // 生成id
    return allocate(timestamp - this.twepoch);
}

等待时钟自己校正来解决时钟回拨问题,适用于回拨幅度小的场景。

比如回拨时长小于500ms,那就睡眠500ms,等时间恢复到正常,如果这个过程中又发生了时钟回拨,不可能一直等它校正,实际生产中可限定校正的次数,超过最大校正次数,那就抛异常吧,这属于极端情况。

小结

解决时钟回拨问题的方法还有很多,无非就是避免和缓解。

每种方式有各自的特点和适用场景,可以两两结合使用,比如时钟回拨幅度小,就休眠校正,回拨幅度大或者出现多次回拨,也不抛异常,获取缓存sequence对外提供服务,也可以当发生时钟回拨时,用备用机器id生成ID等。

要点总结

1、在分布式系统中生成全局唯一的ID至关重要,目前,业界广泛采用的方案包括数据库号段算法和雪花算法,这些算法不仅在大厂中得到了广泛应用,还催生了诸多开源项目,例如百度的uid-generator、美团的Leaf以及滴滴的TinyId等。

2、雪花算法以其简单高效而著称,其核心在于结合时间戳、机器ID和序列号来生成64位的唯一ID,该算法生成的ID整体上呈递增趋势,确保了全局唯一性,同时性能表现优异。雪花算法提供了高度的灵活性,允许根据实际需求自定义各个组成部分的bit位数,若需提升QPS(每秒查询率),可适当增加序列号所占的bit位数。

3、尽管雪花算法具有诸多优点,但它对机器时钟的强依赖性也带来了时钟回拨问题,为应对这一问题,开发者们提出了多种解决方案,主要分为避免和缓解两大类。*常见的方法包括使时间戳自增以摆脱对机器时钟的依赖、利用缓存来存储序列号,或等待时钟自行校正等。*每种方法都有其独特之处,只有正确利用它们的优点,才能最大限度地提升系统性能。

4、雪花算法ID生成器的使用方式灵活多样,其中远程发号器和本地直接生成ID是两种主要方式。*远程发号器需要确保高可用性,以满足分布式系统中的需求;而本地直接生成ID则省去了远程请求的开销,因此在性能上更具优势。*然而,本地生成方式也带来了机器ID管理的挑战,因为一旦某个机器ID被使用,它就不能再被其他机器重复使用。为了应对这一挑战,开发者们通常利用MySQL或ZooKeeper等工具来进行机器ID的管理和分配。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

END!
END!
END!

往期回顾

精品文章

深度思考:总结SOA、WSDL、SOAP、REST、UDDI之间的关系

Spring揭秘:@import注解应用场景及实现原理!

Java并发基础:原子类之AtomicMarkableReference全面解析!

Java并发基础:concurrent Flow API全面解析

Java并发基础:CopyOnWriteArraySet全面解析

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1554804.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

13-API风格(下):RPCAPI介绍

RPC在Go项目开发中用得也非常多&#xff0c;需要我们认真掌握。 RPC介绍 根据维基百科的定义&#xff0c;RPC&#xff08;Remote Procedure Call&#xff09;&#xff0c;即远程过程调用&#xff0c;是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机…

【面经八股】搜广推方向:面试记录(十一)

【面经&八股】搜广推方向:面试记录(十一) 文章目录 【面经&八股】搜广推方向:面试记录(十一)1. 自我介绍2. 实习经历问答4. 编程题5. 反问1. 自我介绍 。。。。。。 2. 实习经历问答 就是对自己实习事情要足够的清晰,不熟的不要写在简历上!!! 其中,有个 …

电脑分辨率怎么调,电脑分辨率怎么调整

随着电脑的普及以及网络的发展&#xff0c;我们现在在工作中都离不开对电脑的使用&#xff0c;今天小编教大家设置电脑分辨率&#xff0c;现在我们先了解这个分辨率是什么?通常电脑的显示分辨率就是屏幕分辨率&#xff0c;显示屏大小固定时&#xff0c;显示分辨率越高图像越清…

【Node.js】WebSockets

概述 WebSockets是一种在浏览器和服务器之间建立持久连接的协议&#xff0c;它允许服务器主动推送数据给客户端&#xff0c;并且在客户端和服务器之间实现双向通信。 建立连接&#xff1a;客户端通过在JavaScript代码中使用WebSocket对象来建立WebSockets连接。例如&#xff1…

KUKA机器人KR FORTEC-2 ultra重型机器人介绍

额定负载在480-800kg的KR FORTEC-2 ultra重型机器人专为快速精确处理高惯量的大型部件的工艺而设计&#xff0c;双支撑连接臂使性能更强&#xff0c;并可以在紧凑的空间内保持优异性能。在工业生产中&#xff0c;工件的转动惯量越大&#xff0c;对机器人的有效载荷和臂展要求越…

安全SCDN的威胁情报库对DDOS防护有什么好处

目前网络攻击事件频频发生&#xff0c;DDoS&#xff08;分布式拒绝服务&#xff09;攻击已成为各种企业&#xff08;小到区域性小公司大到各种跨国公司&#xff09;的主要威胁&#xff0c;DDoS 攻击可能会对企业造成重大损害和破坏&#xff0c;比如对目标公司的业务造成产生不利…

探索父进程和子进程

文章目录 通过系统调用查看进程PID父进程、子进程 通过系统调用创建进程-fork初识为什么fork给父进程返回子进程的PID&#xff0c;给子进程返回0fork函数如何做到返回两个值一个变量为什么同时会有两个返回值&#xff1f;bash总结 通过系统调用查看进程PID getpid()函数可以获…

win10配置CLion2022+ubuntu20.04远程部署

背景 在博文ubunut搭建aarch64 cuda交叉编译环境记录中&#xff0c;使用的ubuntu20.04虚拟机安装eclipse来交叉编译aarch64的程序&#xff0c;然后发送到jetson板子上执行。开发一段时间后发现eclipse IDE使用起来不太便捷&#xff0c;因此&#xff0c;考虑使用CLion IDE&…

接口自动化框架搭建(四):pytest的使用

1&#xff0c;使用说明 网上资料比较多&#xff0c;我这边就简单写下 1&#xff0c;目录结构 2&#xff0c;test_1.py创建两条测试用例 def test_1():print(test1)def test_2():print(test2)3&#xff0c;在pycharm中执行 4&#xff0c;执行结果&#xff1a; 2&#xff0…

新能源汽车充电桩常见类型及充电桩站场的智能监管方案

随着新能源汽车市场的迅猛发展&#xff0c;充电桩作为支持其运行的基础设施&#xff0c;也呈现出多样化的类型。这些充电桩不仅在外形和功能上存在差异&#xff0c;更在充电速度、充电方式以及使用场景等方面展现出独特的优势。 一、充电桩类型及区别 1、慢充桩&#xff08;交…

《算法笔记》系列----质数的判断(埃氏筛法)

目录 一、朴素算法 二、埃氏筛法 1、与朴素算法对比 2、算法介绍 3、例题即代码实现 一、朴素算法 从素数的定义中可以知道&#xff0c;一个整数n要被判断为素数&#xff0c;需要判断n是否能被2.3.n- 1中的一个整除。只2&#xff0c;3..n- 1都不能整除n&#xff0c;n才能…

Coursera自然语言处理专项课程03:Natural Language Processing with Sequence Models笔记 Week02

Natural Language Processing with Sequence Models Course Certificate 本文是https://www.coursera.org/learn/sequence-models-in-nlp 这门课程的学习笔记&#xff0c;如有侵权&#xff0c;请联系删除。 文章目录 Natural Language Processing with Sequence ModelsWeek 02…

springcloud基本使用(搭建eureka服务端)

创建springbootmaven项目 next next finish创建成功 删除项目下所有文件目录&#xff0c;只保留pox.xml文件 父项目中的依赖&#xff1a; springboot依赖&#xff1a; <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-s…

增强现实(AR)的开发工具

增强现实&#xff08;AR&#xff09;的开发工具涵盖了一系列的软件和平台&#xff0c;它们可以帮助开发者创造出能够将虚拟内容融入现实世界的应用程序。以下是一些在AR领域内广泛使用的开发工具。北京木奇移动技术有限公司&#xff0c;专业的软件外包开发公司&#xff0c;欢迎…

虚拟机 centos 安装后与主机 ip保持一致

1、安装时 网络模式 悬着自动检测 &#xff08;桥接&#xff09; 2、打开网络 这里如果没有打开 就去 编辑 该文件。把ONBOOTno 改为yes 后 vim /etc/sysconfig/network-scripts/ifcfg-ens160 刷新配置 systemctl restart network 再查看addr 与本机 192.168.31.xx 在同…

HN热帖|替换Redis的一场赛跑

3 月 21 日&#xff0c; Redis Ltd. 宣布了一项重大决定&#xff1a;Redis “内存数据存储”项目从 Redis 7.4 版本开始将以非自由的、源代码可用的许可证发布。这一消息并不受欢迎&#xff0c;但也并非完全意外。这次的变化的不同寻常之处是市面上已经有了多个 Redis 替代品可…

1.Netty介绍及NIO三大组件

Netty网络编程Netty的底层是NIO&#xff08;非阻塞IO&#xff09;&#xff0c;常用的多线程和线程池使用的是阻塞IO&#xff0c;其效率并不高。支持高并发&#xff0c;性能好高性能的服务端程序、客户端程序 NIO三大组件 一、Channel 读写数据的双向传输通道 常见的传输通道…

React Native框架开发APP,安装免费的图标库(react-native-vector-icons)并使用详解

一、安装图标库 要使用免费的图标库&#xff0c;你可以使用 React Native Vector Icons 库。 首先&#xff0c;确保你已经安装了 react-native-vector-icons&#xff1a; npm install --save react-native-vector-iconsnpm install --save-dev types/react-native-vector-ic…

深入理解数据结构第一弹——二叉树(1)——堆

前言&#xff1a; 在前面我们已经学习了数据结构的基础操作&#xff1a;顺序表和链表及其相关内容&#xff0c;今天我们来学一点有些难度的知识——数据结构中的二叉树&#xff0c;今天我们先来学习二叉树中堆的知识&#xff0c;这部分内容还是非常有意思的&#xff0c;下面我们…

Collection与数据结构 链表与LinkedList(二):链表精选OJ例题(上)

1. 删除链表中所有值为val结点 OJ链接 class Solution {public ListNode removeElements(ListNode head, int val) {if(head null){return head;}ListNode pre head;ListNode cur head.next;while(cur ! null){if(cur.val val){pre.next cur.next;}else{pre pre.next;…