分布式ID策略

1. 分布式ID策略需要解决的问题

在数据库分库分表的情况下,若每个数据库的自增策略都为autoincreament逐一自增,会造成被拆分的表中出现记录id一样的情况

2. 更改分布式数据库自增策略

2.1 解决方案

如果此数据表在后续不继续拆分,我们可以通过修改不同数据库中的表的自增步长解决这个问题,步长即为分表的数量

2.2 优缺点
  • 优点

1.无需依赖除DB外其它资源

2.id始终单调递增,索引的维护代价小,通过主键进行查询的效率高

  • 缺点

1.生成id需要性能消耗

2.主从切换时可能造成id重复生成的情况

3.不灵活,如果后续要继续分表,则只能重新设置每个表的步长

3. UUID

3.1 UUID
  • UUID (Universally Unique Identifier),通用唯一识别码的缩写,是由一组32位数的16进制数字所构成。

  • 生成的UUID是由 8-4-4-4-12格式的数据组成,其中32个字符和4个连字符' - '。

3.2 基于时间的UUID
  • 这个一般是通过当前时间,随机数,和本地Mac地址来计算出来,可以通过 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()来使用或者其他包中工具。

3.3 DCE安全的UUID
  • DCE(Distributed Computing Environment)分布式计算环境安全的UUID和基于时间的UUID算法相同,但会把时间戳的前4位置换为POSIX(可移植性操作系统)的UID(用户id)或GID(组织id)

3.4 基于名字的UUID(MD5)
  • 基于名字的UUID通过计算字节数组MD5散列值得到。

byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);

public static UUID nameUUIDFromBytes(byte[] name) {
    MessageDigest md;
    try {
        md = MessageDigest.getInstance("MD5");
    } catch (NoSuchAlgorithmException nsae) {
        throw new InternalError("MD5 not supported", nsae);
    }
    byte[] md5Bytes = md.digest(name);
    md5Bytes[6]  &= 0x0f;  /* clear version        */
    md5Bytes[6]  |= 0x30;  /* set to version 3     */
    md5Bytes[8]  &= 0x3f;  /* clear variant        */
    md5Bytes[8]  |= 0x80;  /* set to IETF variant  */
    return new UUID(md5Bytes);
}
3.5 随机UUID
  • 根据随机数,或者伪随机数生成UUID。这种UUID产生重复的概率是可以计算出来的,但是重复的可能性可以忽略不计,因此该版本也是被经常使用的版本。JDK中使用的就是这个版本

UUID uuid = UUID.randomUUID();
3.6 基于名字的UUID(SHA1)
  • 和前面基于名字的UUID算法类似,只是散列值计算使用SHA1(Secure Hash Algorithm 1)算法

3.7 优缺点
  • 优点

生成方便,没有网络和io消耗

  • 缺点

1.不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示(32字符和4个连字符),很多场景不适用。

2.信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,暴露使用者的位置。

3.对MySQL索引不利:在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能。

4. 使用redis实现

4.1 解决方案
  • 通过redis 中INCR 和 INCRBY 这样的自增原子命令,在单机redis服务下,由于Redis自身的单线程的特点所以能保证生成的 ID 肯定是唯一有序的。

4.2 优缺点
  • 优点

1.只涉及内存操作,性能高

2.生成的数据是有序的,对排序业务有利

3.目前基本上所有系统都集成了redis,因此适用性较高

  • 缺点

单机redis环境下虽然能确保id唯一有效,但是在高并发的环境下,我们需要搭建redis集群处理id生成问题,因此又会出现像数据库分库分表时出现的id重复问题,此时也需要设置每台redis的id自增步长。

5. 雪花算法-Snowflake

5.1 介绍
  • 雪花算法是由Twitter开源的分布式ID生成算法,以划分命名空间的方式将 64-bit位分割成多个部分,每个部分代表不同的含义

1. 第1位占用1bit,其值始终是0,可看做是符号位不使用。

2. 第2位开始的41位是时间戳,41-bit位可表示2^41个数,代表毫秒数。

3. 中间的10-bit位可表示机器数,即2^10 = 1024台机器。

4. 最后12-bit位是自增序列,可表示2^12 = 4096个数

  • 相当于在一毫秒一台机器上可产生4096个有序的不重复的ID

  • Java版代码

    /**
     * 获得下一个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();
    }
5.2 优缺点
  • 优点

1.生成的ID是趋势递增,不依赖数据库等第三方系统

2.生成ID的性能非常高,而且可以根据自身业务特性分配bit位,非常灵活。

  • 缺点

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

如果恰巧回退前生成过一些ID,而时间回退后,生成的ID就有可能重复。

官方对于此并没有给出解决方案,而是简单的抛错处理,这样会造成在时间被追回之前的这段时间服务不可用。

6. 百度-UidGenerator

6.1 介绍
  • 在雪花算法上做了一些改进

  • UidGenerator 提供了两种生成唯一ID方式,分别是 DefaultUidGenerator 和 CachedUidGenerator,官方建议如果有性能考虑的话使用 CachedUidGenerator 方式实现。

  • 以划分命名空间的方式将 64-bit位分割成多个部分,只不过它的默认划分方式有别于雪花算法 snowflake。它默认是由 1-28-22-13 的格式进行划分。可自己调整各个字段占用的位数。

1.第1位仍然占用1bit,其值始终是0。

2.第2位开始的28位是时间戳,28-bit位可表示2^28个数,这里不再是以毫秒而是以秒为单位。

3.中间的 workId (数据中心+工作机器,可以其他组成方式)则由 22-bit位组成,可表示 2^22 = 4194304个工作ID。

4.最后由13-bit位构成自增序列,可表示2^13 = 8192个数。

  • 其中 workId (机器 id),机器每启动一次就会消耗一个workerid,最多可支持约420w次机器启动。在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略

ROP TABLE IF EXISTS WORKER_NODE;
CREATE TABLE WORKER_NODE
(
    ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',
    HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',
    PORT VARCHAR(64) NOT NULL COMMENT 'port',
    TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',
    LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',
    MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',
    CREATED TIMESTAMP NOT NULL COMMENT 'created time',
    PRIMARY KEY(ID)
)
 COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;
6.2 DefaultUidGenerator
  • DefaultUidGenerator 就是正常的根据时间戳和机器位还有序列号的生成方式,和雪花算法很相似,对于时钟回拨也只是抛异常处理

  • 仅有一些不同,如以秒为为单位而不再是毫秒

  • 实现代码

//实现策略
protected synchronized long nextId() {
    long currentSecond = getCurrentSecond();

    //时针回拨则抛出异常
    // Clock moved backwards, refuse to generate uid
    if (currentSecond < lastSecond) {
        long refusedSeconds = lastSecond - currentSecond;
        throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
    }

    // 同一秒内未达到最大序列号则自动递增序列号
    // At the same second, increase sequence
    if (currentSecond == lastSecond) {
        sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
        // 否则等待下一秒
        // Exceed the max sequence, we wait the next second to generate uid
        if (sequence == 0) {
            currentSecond = getNextSecond(lastSecond);
        }

    // At the different second, sequence restart from zero
    } else {
        sequence = 0L;
    }

    lastSecond = currentSecond;

    // Allocate bits for UID
    return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
}
  • 可通过 spring 划分的占用位数

//可配置每个部分的bit位数
<bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false">
    <property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/>

    <!-- Specified bits & epoch as your demand. No specified the default value will be used -->
    <property name="timeBits" value="29"/>
    <property name="workerBits" value="21"/>
    <property name="seqBits" value="13"/>
    <property name="epochStr" value="2016-09-20"/>
</bean>
6.3 CachedUidGenerator
  • 使用 RingBuffer 缓存生成的id。数组每个元素成为一个slot。

/** 常量配置 */
private static final int START_POINT = -1;
private static final long CAN_PUT_FLAG = 0L;
private static final long CAN_TAKE_FLAG = 1L;
public static final int DEFAULT_PADDING_PERCENT = 50;

/** RingBuffer 的 slot 的大小,每个 slot 持有一个 UID */
private final int bufferSize;
private final long indexMask;
/** 存 UID 的数组 */
private final long[] slots;
/** 存放 UID 状态的数组(是否可读或者可写,或是否可填充、是否可消费) */
private final PaddedAtomicLong[] flags;

/** Tail: 要产生的最后位置序列 */
private final AtomicLong tail = new PaddedAtomicLong(START_POINT);
/** Cursor: 要消耗的当前位置序列 */
private final AtomicLong cursor = new PaddedAtomicLong(START_POINT);

/** 触发填充缓冲区的阈值 */
private final int paddingThreshold; 

/** 放置 缓冲区的拒绝策略 拒绝方式为打印日志 */
private RejectedPutBufferHandler rejectedPutHandler = this::discardPutBuffer;
/** 获取 缓冲区的拒绝策略 拒绝方式为抛出异常并打印日志 */
private RejectedTakeBufferHandler rejectedTakeHandler = this::exceptionRejectedTakeBuffer; 

/** 填充缓冲区的执行者 */
private BufferPaddingExecutor bufferPaddingExecutor;
  • RingBuffer容量,默认为Snowflake算法中sequence最大值(2^13 = 8192),可通过 boostPower 配置进行扩容,以提高 RingBuffer 读写吞吐量。

<!-- RingBuffer size扩容参数, 可提高UID生成能力.即每秒产生ID数上限能力 --> 
<!-- 默认:3,原bufferSize=2^13, 扩容后bufferSize = 2^13 << 3 = 65536 -->
<property name="boostPower" value="3"/>
  • CachedUidGenerator采用了双RingBuffer,Uid-RingBuffer用于存储Uid、Flag-RingBuffer用于存储Uid状态(是否可填充、是否可消费)。

  1. Tail指针、Cursor指针用于环形数组上读写slot

Tail指针 表示Producer生产的最大序号(此序号从0开始,持续递增)。Tail不能超过Cursor,即生产者不能覆盖未消费的slot。当Tail已赶上curosr,此时可通过rejectedPutBufferHandler指定PutRejectPolicy

Cursor指针 表示Consumer消费到的最小序号(序号序列与Producer序列相同)。Cursor不能超过Tail,即不能消费未生产的slot。当Cursor已赶上tail,此时可通过rejectedTakeBufferHandler指定TakeRejectPolicy

  1. 由于数组元素在内存中是连续分配的,可最大程度利用CPU cache以提升性能。但同时会带来「伪共享」FalseSharing问题,为此在Tail、Cursor指针、Flag-RingBuffer中采用了CacheLine 补齐方式。

  1. FalseSharing伪共享问题

CPU缓存

CPU缓存一般分为三级,L1,L2,L3。其中L1和L2只能被一个内核单独使用,是内核私有缓存,而L3则是被单个插槽上面的所有CPU核共享的,三者有以下简单关系:

1.越靠近CPU的缓存就越小,速度也越快

2.小缓存的全部数据是大缓存的一部分

3.越常使用的数据放在离CPU内核更近的位置

再有就是主存也就是内存空间,它被所有插槽上面的CPU内核共享,拥有所有三级缓存的数据,同时也是最慢的

以上描述的大致结构图如下

当CPU进行运算的时候,会逐级寻找需要的数据,走得越远,耗时就越长

RFO(Request For Owner):

当两个CPU核心需要使用一条共享数据时,一般不会把数据放在三级缓存或者内存中,而是在自己的一级或者二级缓存中,这时候如果某CPU内核要对这条数据进行修改时,就会向其它所有持有该数据的内核发送一个RFO请求,宣布自己占有这条记录其它内核不能修改,此CPU内核改变这条数据之后,又会将数据同步到L3缓存中,而其它内核缓存中的原始数据全部失效,同步L3缓存中的新数据,这个过程会对极大地降低性能。

什么样的数据是共享数据--同一缓存行中的数据

缓存行:缓存系统中的存储单位是缓存行,通常为64字节

1.一个Java的 long 类型是8字节,因此在一个缓存行中可以存8个long类型的变量。

2.如果访问一个long 数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个

结合以上三块内容,我们便可以对以下图片进行简单讨论

图中XY两条数据位于同一缓存行,而两条数据都是会被频繁修改的数据,当Core1想要修改X时,会向Core2发送一次RFO请求,同理当Core2想要修改Y时,也会发送一次RFO请求。这种轮番争夺数据占有权的行为非常影响性能。而且如果在这种频繁修改的情况下,此时一个线程想要获取这条缓存行中的数据,可能出现Core1和Core 2中的数据都还未来得及更新而成为失效数据,导致只有L3中的数据是有效的,从前面的CPU缓存部分可知读L3中的数据性能相对来说是很低的,更坏的情况是如果Core1和Core2不在同一插槽,则最后只能从内存中读取数据,效率是最低的。

这就是伪共享问题

  1. 解决伪共享问题--让两条数据位于不同缓存行

代码示例

public class FalseSharing {
 public static void main(String[] args) throws InterruptedException {
     long timeSum = 0;
     //执行1000次,取平均时间
     for (int m = 0; m < 1000; m++) {
         CountDownLatch downLatch = new CountDownLatch(2);
         //数组中的两个元素改变1000000次
         long times = 100*10000L;

         //存放元素的数组
         VolatileLong[] volatileLongs = {new VolatileLong(), new VolatileLong()};

         //两个线程分别改变数组中两个元素的属性值并记录消耗时间
         long start = System.currentTimeMillis();
         new Thread(()-> {
             for (int i = 0; i < times; i++) {
                 volatileLongs[0].X.incrementAndGet();
             }
             downLatch.countDown();
         },"XThread").start();

         new Thread(()-> {
             for (int i = 0; i < times; i++) {
                 volatileLongs[1].X.incrementAndGet();
             }
             downLatch.countDown();

         },"YThread").start();

         downLatch.await();
         long end = System.currentTimeMillis();
         timeSum += (end - start);

     }
     System.out.println("总耗时为"+timeSum+"平均消耗时间为"+timeSum/1000+"ms");

 }

 //数组中元素类,分为填充六个long型属性和不填充两种情况
 static class VolatileLong{
     public volatile AtomicLong X = new AtomicLong(1);

     //填充缓存行属性
//        public long x1,x2,x3,x4,x5,x6;
 }

}

测试结果

  • Ringbuffer填充时机

  1. 初始化预填充 RingBuffer初始化时,预先填充满整个RingBuffer。

  1. 即时填充 Take消费时,即时检查剩余可用slot量(tail - cursor),如小于设定阈值,则补全空闲slots。阈值可通过paddingFactor来进行配置。

  1. 周期填充 通过Schedule线程,定时补全空闲slots。可通过scheduleInterval配置,以应用定时填充功能,并指定Schedule时间间隔。

7. 美团Leaf

7.1 Leaf-segment 数据库方案
  • 方案背景

上述数据库方案 每次获取ID时都要读写一次数据库,这种频繁IO会导致数据库压力大
  • Leaf-segment解决方式

从数据库中批量获取id, 每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。
  • 不同业务id自增序列不一样怎么办

不同的业务发号需求用 biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作, 只需要对biz_tag分库分表就行。
  • 数据库设计

CREATE TABLE `leaf_alloc` (
  `biz_tag` varchar(128)  NOT NULL DEFAULT '' COMMENT '业务key',
  `max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '当前已经分配了的最大id',
  `step` int(11) NOT NULL COMMENT '初始步长,也是动态调整的最小步长',
  `description` varchar(256)  DEFAULT NULL COMMENT '业务key的描述',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;
  • DB取号段过程优化

为了DB取号段的过程能够做到无阻塞,当号段消费到某个点时就异步的把下一个号段加载到内存中,而不需要等到号段用尽的时候才去更新号段

如下图所示

leaf采用双buffer模式,服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另起一个线程去更新下一个号段。当当前号段准备好了则切换到下个号段为当前号段继续下发id。这样就不会出现当前号段用尽时线程拿不到id而阻塞的问题

7.2 Leaf-snowflake方案
  • Leaf-snowflake方案完全沿用snowflake方案的bit位设计,对于workerID的分配引入了Zookeeper持久顺序节点的特性自动对snowflake节点配置 wokerIlD。避免了服务规模较大时,动手配置成本太高的问题。

  • Leaf-snowflake启动步骤

启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。
如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。
如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务
  • 实现架构如下图

8. 几种ID方案对比(结果可能有偏差)

数据库自增方案

UUID

Redis

雪花算法

百度CacheGenerator

美团leaf-segment

10*10000次

44962ms

584ms

8575ms

2013ms

14ms

207ms

100*10000次

>5min

857ms

65978ms

2197ms

138ms

314ms

1000*10000次

>50min

2957ms

>10min

4413ms

1425ms

891ms

5000*10000次

>500min

11747ms

>20min

14177ms

6865ms

2202ms

9. 总结

  • 单纯依赖DB的id分发方案效率最低且会暴露信息(一天有多少订单)

  • redis测试结果没有理想中的好,理论上应该是80000到100000ps

  • 百度在只有小几百万id生成的情况下性能要优于美团,但在大几百万甚至更大的id生成量的情况下性能要明显低于美团

10. 作者的话

基本上都是从网上学习收录过来的,加上了一点自己的想法和总结,欢迎批评指正。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值