分布式id笔记

231 阅读8分钟

分布式id笔记

对分布式id的要求

  1. 全局唯一性:不可重复
  2. 趋势递增:为了DB的B+树结构
  3. 单调递增:下一个id一定大于上一个,为了实现事务
  4. 信息安全:id连续可能容易被爬取数据,订单号可能会被竞对知道销量
  5. 低延迟:TP999低
  6. 高QPS:支持高qps
  7. 可用性高:可用性要高

TP是一种服务测试指标,指的是在一段时间内,统计该方法每次调用所耗的时间,并将这些时间按照从小打到的顺序进行排序,并取出结果为:总次数*指标数=对应TP指标的值,在去除排序好的时间

一般的指标有TP50、TP90、TP99、TP999

常见的分布式id实现

UUID

uuid标准模型包含32个16进制数字,以链字号分为5段,形式为8-4-4-4-12的36个字符,示例550e8400-e29b-41d4-a716-446655440000

优点

性能极低,本地生成,没有网络消耗

缺点

  • 不易存储:uuid太长,16字节128位,通常以36长度字符串表示,很多场景不适用
  • 信息不安全:基于MAC地址生成的UUID可能会导致MAC地址暴露
  • DB问题:uuid作为主键或要添加索引时不适用

类snowflake方案

通过64个bit位来实现,这64个bit位被划分为几部分,以实现高并发下分布式id的生成

img

  • 最高位1bit不使用
  • 41bit作为时间戳,可以使用约69年的时间
  • 10bit作为workId,可根据需要划分,例如5bitIDC标识+5bit机器标识
  • 12bit序列号,可以表示2^12个id

优点

  • 毫秒数在高位,自增序列在低位,整个id都是趋势递增的
  • 不依赖数据库等三方的系统,生成id性能很高
  • 可根据自身业务分配bit位,灵活性高

缺点

强制依赖机器时钟,如果时钟回拨,会导致重复发号

Mongdb ObjectID

类似于snowflake方法,通过时间+机器码+pid+inc共12个字节,通过4+3+2+3的方式最终标识成一个24长度的十六进制字符

数据库生成

通过设置auto_increment_incrementauto_increment_offset来保证id自增

优点

  • 实现简单,成本小,有DBA维护
  • id单调递增

缺点

  • 强依赖DB,且DB可能存在主从问题
  • id发号性能瓶颈限制在单台MySQL的读写性能

MySQL的性能瓶颈有办法解决吗?

有,可以使用分布式+步长的方式。每台机器设置不同的初始值,且步长与机器数相等。假如有两台机器,那么我第一台机器的初始值为1,第二台机器的初始值为2,两台机器步长都为2,这样第一台机器生成的值为:1, 3, 5, 7, 9;而第二台机器生成的值为2, 4, 6, 8, 10;这样就实现了交叉生成的方法

这种方法也存在缺点:

  • 系统水平扩展比较困难, 加减机器调整步长实现起来较为复杂
  • id没有了单调递增的特性, 只是趋势递增, 这还好
  • 数据库压力大

美团Leaf

Leaf-segment数据库方案

基于数据库方案做了部分改变。原方案每次获取id都得读写一次数据库,这会增大数据库的压力。Leaf-segment使用proxy server批量获取,每次获取一个segment(由步长决定大小)号段的值,用完之后再去数据库获取新的号段,这样可以大大减轻数据库的压力。针对于不同的业务需求也会使用biz_tag进行业务上的区分。大致架构如下:

img

表设计如下:

+-------------+--------------+------+-----+-------------------+-----------------------------+
| Field       | Type         | Null | Key | Default           | Extra                       |
+-------------+--------------+------+-----+-------------------+-----------------------------+
| biz_tag     | varchar(128) | NO   | PRI |                   |                             |
| max_id      | bigint(20)   | NO   |     | 1                 |                             |
| step        | int(11)      | NO   |     | NULL              |                             |
| desc        | varchar(256) | YES  |     | NULL              |                             |
| update_time | timestamp    | NO   |     | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
+-------------+--------------+------+-----+-------------------+-----------------------------+
  • max_id表示该biz_tag目前所分配的id号段最大值
  • step表示每次分配的号段长度

该方案在性能扩展方面也有所改善,它使用了号段思想,将某个数量范围内的数据存在一起,而不是交叉存储,这样在扩容时只要把新的号段分配给新的机器即可,前提是不修改步长。

优点
  • 方便做线性扩展
  • id号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求
  • 容灾性高,Leaf服务内有号段缓存,DB宕机可以短时间保证服务可用
  • 可自定义max_id方便业务从原有服务迁移
缺点
  • id不够随机,有信息安全问题
  • TP999波动大,在缓存使用完后响应速度严重依赖MySQL更新IO速度
  • 强依赖DB
优化

对于TP999波动做出了如下优化:

使用双缓存机制,在号段被使用到10%时启用一步线程去获取下一号段并做好缓存,当当前号段被使用完成,将提前缓存好的下一号段缓存作为当前缓存,可以有效减少并提前预支所要付出的IO代价。两个缓存来回切换也可以增加DB宕机给开发者的反应时间。

img

对DB高可用做了如下优化:

采用一主两从方式部署DB且分机房部署,Master和Slave采用半同步方式同步数据,且做好监控等方案。

img

半同步: 保证如果源崩溃,他已提交的所有事物都已传输至少一个副本(不会等待所有同步都完成才视为同步完成)

Leaf-snowflake方案

对64bit位的使用还是按照基础snowflake进行拆分,不同的是10bit workId在Leaf服务规模大的情况下实现动态分配较为困难,所以Leaf-snowflake使用了zookeeper的持久顺序节点特性自动对snowflake节点配置workId

img

启动顺序如下:

  1. 启动Leaf-snowflake服务,连接zookeeper,在leaf_forever父节点下检查自己是否已经注册过
  2. 如果注册过直接取回自己的workId,启动服务
  3. 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号作为自己的workId号,启动服务
优化
弱依赖zookeeper

在本地文件系统上缓存一个workId文件,这样可以在zookeeper出问题需要重启时保证服务的正常启动,这样就实现了对zookeeper的弱依赖

解决时钟问题

snowflake强依赖系统时间,需要解决时钟回拨问题

img

简单来说就是:

  1. 获取Leaf节点的时间计算平均值与自身系统时间作比较来判断时间是否回拨
  2. 定时上报时间
  3. 还有一种方案是阻塞时间直到时钟追上当前时间,这种方案会阻塞需要考虑场景且需要看时钟回拨时间不能太长
  4. 建立完善的监控机制

百度UidGenerator

与常规的雪花算法不同,百度的雪花算法UidGenerator支持自定义时间戳、工作机器和序号等各部分的位数

img

  • Sign-1bit:固定的符号标识,表示生成唯一id为正数
  • delta second-28bit:当前时间,相对于时间基点2016-05-20的增量值,可配置
  • work node id-22bit:机器id,最多可支持约420w次机器启动
  • sequence-13bit:每秒下的并发序列,13bit最多可支持每秒8192个并发

内部通过两种方式实现:DefaultUidGenerator和CachedUidGenerator

DefaultUidGenerator

默认实现,与普通的雪花算法基本相同,不同的是百度的算法使用秒为单位

项目启动时,初始化代码如下

@Override
public void afterPropertiesSet() throws Exception {
    // initialize bits allocator
    bitsAllocator = new BitsAllocator(timeBits, workerBits, seqBits);
​
    // initialize worker id
    workerId = workerIdAssigner.assignWorkerId();
    if (workerId > bitsAllocator.getMaxWorkerId()) {
        throw new RuntimeException("Worker id " + workerId + " exceeds the max " + bitsAllocator.getMaxWorkerId());
    }
​
    LOGGER.info("Initialized bits(1, {}, {}, {}) for workerID:{}", timeBits, workerBits, seqBits, workerId);
}
​
@Transactional
public long assignWorkerId() {
    // build worker node entity
    WorkerNodeEntity workerNodeEntity = buildWorkerNode();
​
    // add worker node for new (ignore the same IP + PORT)
    workerNodeDAO.addWorkerNode(workerNodeEntity);
    LOGGER.info("Add worker node:" + workerNodeEntity);
​
    return workerNodeEntity.getId();
}

每个实例都会去向WORKER_NODE表中添加一条数据,并将id取出作为自己实例的workId,这里就区别于美团Leaf的zookeeper管理

获取方法如下:

@Override
public long getUID() throws UidGenerateException {
    try {
        return nextId();
    } catch (Exception e) {
        LOGGER.error("Generate unique id exception. ", e);
        throw new UidGenerateException(e);
    }
}
​
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);
}
​
public long allocate(long deltaSeconds, long workerId, long sequence) {
    return (deltaSeconds << timestampShift) | (workerId << workerIdShift) | sequence;
}

这种默认的实现方式很简单,是标准的雪花算法生成方式且从上面的方法可以看出,该策略在对于时钟回拨的情况做出的应对方法是抛出异常Clock moved backwards. Refusing for refusedSeconds seconds

CachedUidGenerator

缓存实现,使用RingBuffer环形数组作为缓存不再实时计算,RingBuffer的容量默认为sequence的大小且为2^N

img

  • Tail指针:指向当前最后一个可用uid的位置,表示Producer生产的最大序列号。Tail不能超过Cursor,即生产者不能覆盖未消费的slot
  • Cursor指针:指向下一个获取uid的位置,其一定小于Tail,表示Consumer消费到的最小序列号。Cursor不能超过Tail,即不能消费未生产的slot

项目启动时,初始化代码如下:

@Override
public void afterPropertiesSet() throws Exception {
    // initialize workerId & bitsAllocator
    super.afterPropertiesSet();
    
    // initialize RingBuffer & RingBufferPaddingExecutor
    this.initRingBuffer();
    LOGGER.info("Initialized RingBuffer successfully.");
}
​
private void initRingBuffer() {
    // initialize RingBuffer
    int bufferSize = ((int) bitsAllocator.getMaxSequence() + 1) << boostPower;
    this.ringBuffer = new RingBuffer(bufferSize, paddingFactor);
    LOGGER.info("Initialized ring buffer size:{}, paddingFactor:{}", bufferSize, paddingFactor);
​
    // initialize RingBufferPaddingExecutor
    boolean usingSchedule = (scheduleInterval != null);
    this.bufferPaddingExecutor = new BufferPaddingExecutor(ringBuffer, this::nextIdsForOneSecond, usingSchedule);
    if (usingSchedule) {
        bufferPaddingExecutor.setScheduleInterval(scheduleInterval);
    }
    
    LOGGER.info("Initialized BufferPaddingExecutor. Using schdule:{}, interval:{}", usingSchedule, scheduleInterval);
    
    // set rejected put/take handle policy
    this.ringBuffer.setBufferPaddingExecutor(bufferPaddingExecutor);
    if (rejectedPutBufferHandler != null) {
        this.ringBuffer.setRejectedPutHandler(rejectedPutBufferHandler);
    }
    if (rejectedTakeBufferHandler != null) {
        this.ringBuffer.setRejectedTakeHandler(rejectedTakeBufferHandler);
    }
    
    // fill in all slots of the RingBuffer
    bufferPaddingExecutor.paddingBuffer();
    
    // start buffer padding threads
    bufferPaddingExecutor.start();
}

启动时采用RingBuffer缓存已经生成的uid,并行化uid的生产和消费,同时对CacheLine进行补齐,避免了为共享问题。同时采用未来时间解决了sequence天然存在的并发限制。

关于为共享参考此文章:www.cnblogs.com/cyfonly/p/5…

获取uid代码非常简单,就是直接获取RingBuffer中已经缓存好的uid,代码如下:

@Override
public long getUID() {
    try {
        return ringBuffer.take();
    } catch (Exception e) {
        LOGGER.error("Generate unique id exception. ", e);
        throw new UidGenerateException(e);
    }
}
​
public long take() {
    // spin get next available cursor
    long currentCursor = cursor.get();
    long nextCursor = cursor.updateAndGet(old -> old == tail.get() ? old : old + 1);
​
    // check for safety consideration, it never occurs
    Assert.isTrue(nextCursor >= currentCursor, "Curosr can't move back");
​
    // trigger padding in an async-mode if reach the threshold
    long currentTail = tail.get();
    if (currentTail - nextCursor < paddingThreshold) {
        LOGGER.info("Reach the padding threshold:{}. tail:{}, cursor:{}, rest:{}", paddingThreshold, currentTail,
                nextCursor, currentTail - nextCursor);
        bufferPaddingExecutor.asyncPadding();
    }
​
    // cursor catch the tail, means that there is no more available UID to take
    if (nextCursor == currentCursor) {
        rejectedTakeHandler.rejectTakeBuffer(this);
    }
​
    // 1. check next slot flag is CAN_TAKE_FLAG
    int nextCursorIndex = calSlotIndex(nextCursor);
    Assert.isTrue(flags[nextCursorIndex].get() == CAN_TAKE_FLAG, "Curosr not in can take status");
​
    // 2. get UID from next slot
    // 3. set next slot flag as CAN_PUT_FLAG.
    long uid = slots[nextCursorIndex];
    flags[nextCursorIndex].set(CAN_PUT_FLAG);
​
    // Note that: Step 2,3 can not swap. If we set flag before get value of slot, the producer may overwrite the
    // slot with a new UID, and this may cause the consumer take the UID twice after walk a round the ring
    return uid;
}