分布式 ID 详解

前言

日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。

我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人、地址 ID 对应且仅对。

应简单来说,ID 就是数据的唯一标识。

1、什么是分布式 ID

分布式 ID 是分布式系统下的 ID。分布式 ID 不存在与现实生活中,属于计算机系统中的一个概念。

我简单举一个分库分表的例子。我司的一个项目,使用的是单机 MySQL 。但是,没想到的是,项目上线一个月之后,随着使用人数越来越多,整个系统的数据量将越来越大。单机 MySQL 已经没办法支撑了,需要进行分库分表(推荐 Sharding-JDBC)。

在分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?

这个时候就需要生成分布式 ID了。

2、分布式 ID 需要满足哪些要求

分布式 ID 作为分布式系统中必不可少的一环,很多地方都要用到分布式 ID。一个最基本的分布式 ID 需要满足下面这些要求:

  • 全局唯一 :ID 的全局唯一性肯定是首先要满足的。
  • 高性能 : 分布式 ID 的生成速度要快,对本地资源消耗要小。
  • 高可用 :生成分布式 ID 的服务要保证可用性无限接近于 100%。
  • 方便易用 :拿来即用,使用方便,快速接入。

除了这些之外,一个比较好的分布式 ID 还应保证:

  • 安全 :ID 中不包含敏感信息。
  • 有序递增 :如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且,很多时候 ,我们还很有可能会直接通过 ID 来进行排序。
  • 有具体的业务含义 :生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。
  • 独立部署 :也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。不过,这样同样带来了网络调用消耗增加的问题。总的来说,如果需要用到分布式 ID 的场景比较多的话,独立部署的发号器服务还是很有必要的。

3、分布式 ID 常见解决方案

3.1、UUID

UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。JDK 就提供了现成的生成 UUID 的方法,一行代码就行了。

String uuid = UUID.randomUUID().toString().replaceAll("-","");
System.out.println(uuid);// 9c58226555c248018be2032964de2de6

我们这里重点关注一下这个 Version(版本),不同的版本对应的 UUID 的生成规则是不同的。5 种不同的 Version(版本)值分别对应的含义:

  • 版本 1 : UUID 是根据时间和节点 ID(通常是 MAC 地址)生成。
  • 版本 2 : UUID 是根据标识符(通常是组或用户 ID)、时间和节点 ID 生成。
  • 版本 3、版本 5 : 版本 5 - 确定性 UUID 通过散列(hashing)名字空间(namespace)标识符和名称生成。
  • 版本 4 : UUID 使用随机性open in new window或伪随机性open in new window生成。

JDK 中通过 UUID 的 randomUUID() 方法生成的 UUID 的版本默认为 4。

UUID uuid = UUID.randomUUID();
int version = uuid.version();// 4

虽然,UUID 可以做到全局唯一性,但是,我们一般很少会使用它。

优点 :生成速度比较快,本地生成的,不依赖于网络。简单易用。

缺点 : 存储消耗空间大(32 个字符串,128 位) 、 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)、无序(非自增)、没有具体业务含义、需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID。

3.2、数据库主键自增

这种方式就比较简单直白了,就是通过关系型数据库的自增主键产生来唯一的 ID。

以 MySQL 举例,我们通过下面的方式即可。

  1. 创建一个数据库表。
CREATE TABLE `sequence_id` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `stub` char(10) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  UNIQUE KEY `stub` (`stub`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

stub 字段无意义,只是为了占位,便于我们插入或者修改数据。并且,给 stub 字段创建了唯一索引,保证其唯一性。

      2.通过 replace into 来插入数据。

BEGIN;
REPLACE INTO sequence_id (stub) VALUES ('stub');
SELECT LAST_INSERT_ID();
COMMIT;

插入数据这里,我们没有使用 insert into 而是使用 replace into 来插入数据,具体步骤是这样的:

1)第一步: 尝试把数据插入到表中。

2)第二步: 如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。

优点 :实现起来比较简单、ID 有序递增、存储消耗空间小。
缺点 : 支持的并发量不大、存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )、每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢)

水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了ID生成特性,将自增步长按照机器数量来设置。增加第三台MySQL实例需要人工修改一、二两台MySQL实例的起始值和步长,把第三台机器的ID起始生成位置设定在比现有最大自增ID的位置远一些,但必须在一、二两台MySQL实例ID还没有增长到第三台MySQL实例的起始ID值的时候,否则自增ID就要出现重复了,必要时可能还需要停机修改。

3.3、数据库号段模式

数据库主键自增这种模式,每次获取 ID 都要访问一次数据库,ID 需求比较大的时候,肯定是不行的。

如果我们可以批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就舒服了!这也就是我们说的 基于数据库的号段模式来生成分布式 ID。

数据库的号段模式也是目前比较主流的一种分布式 ID 生成方式。像滴滴开源的Tinyidopen in new window 就是基于这种方式来做的。不过,TinyId 使用了双号段缓存、增加多 db 支持等方式来进一步优化。

以 MySQL 举例,我们通过下面的方式即可。

  1. 创建一个数据库表。
CREATE TABLE `sequence_id_generator` (
  `id` int(10) NOT NULL,
  `current_max_id` bigint(20) NOT NULL COMMENT '当前最大id',
  `step` int(10) NOT NULL COMMENT '号段的长度',
  `version` int(20) NOT NULL COMMENT '版本号',
  `biz_type`    int(20) NOT NULL COMMENT '业务类型',
   PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

current_max_id 字段和step字段主要用于获取批量 ID,批量 id 为: current_max_id ~ current_max_id+step。

version 字段主要用于解决并发问题(乐观锁),biz_type 主要用于表示业务类型。

        2.先插入一行数据。

INSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`)
VALUES (1, 0, 100, 0, 101);

        3.通过 SELECT 获取指定业务下的批量唯一 ID

SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101

        4.不够用的话,更新之后重新 SELECT 即可

UPDATE sequence_id_generator SET current_max_id = 0+100, version=version+1 WHERE version = 0  AND `biz_type` = 101
SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101

相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。另外,为了避免单点问题,你可以从使用主从模式来提高可用性。

优点 :ID 有序递增、存储消耗空间小。
缺点 :存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )。

水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了ID生成特性,将自增步长按照机器数量来设置。增加第三台MySQL实例需要人工修改一、二两台MySQL实例的起始值和步长,把第三台机器的ID起始生成位置设定在比现有最大自增ID的位置远一些,但必须在一、二两台MySQL实例ID还没有增长到第三台MySQL实例的起始ID值的时候,否则自增ID就要出现重复了,必要时可能还需要停机修改。

3.4、Redis ID生成

Redis分布式ID实现主要是通过提供像 INCR 和 INCRBY 这样的自增原子命令,由于Redis单线程的特点,可以保证ID的唯一性和有序性。

这种实现方式,如果并发请求量上来后,就需要集群,不过集群后,又要和传统数据库一样,设置分段和步长。

时间+用redis的incr自增命令(每日从1开始),代码如下:

public class RedisCounterRepository {
    private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    public RedisCounterRepository(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    // 根据获取的自增数据,添加日期标识构造分布式全局唯一标识,changeNumPrefix是自己定义的随机前缀
    private String getNumFromRedis(String changeNumPrefix) {
        String dateStr = LocalDate.now().format(dateTimeFormatter);
        Long value = incrementNum(changeNumPrefix + dateStr);
        //不足4位补0,redis从1开始生成的,每天再次请0
        return dateStr + StringUtils.leftPad(String.valueOf(value), 4, '0');
    }
    // 从redis中获取自增数据(redis保证自增是原子操作)
    private long incrementNum(String key) {
        RedisConnectionFactory factory = redisTemplate.getConnectionFactory();
        if (null == factory) {
            log.error("Unable to connect to redis.");
            throw new UserException(AppStatus.INTERNAL_SERVER_ERROR);
        }
        RedisAtomicLong redisAtomicLong = new RedisAtomicLong(key, factory);
        long increment = redisAtomicLong.incrementAndGet();
        if (1 == increment) {
            // 如果数据是初次设置,需要设置超时时间
            redisAtomicLong.expire(1, TimeUnit.DAYS);
        }
        return increment;
    }
}

用redis实现需要注意一点,要考虑到redis持久化的问题。redis有两种持久化方式RDBAOF

  • RDB会定时打一个快照进行持久化,假如连续自增但redis没及时持久化,而这会Redis挂掉了,重启Redis后会出现ID重复的情况。
  • AOF会对每条写命令进行持久化,即使Redis挂掉了也不会出现ID重复的情况,但由于incr命令的特殊性,会导致Redis重启恢复的数据时间过长。

优点:性能不错、每秒10万并发量。生成的 ID 是有序递增的。

缺点:redis 宕机后不可用,RDB重启数据丢失会重复ID。自增,数据量易暴露。

3.5、Snowflake(雪花算法)

snowflake 中文的意思是雪花,所以常被称为雪花算法。SnowFlake 算法,是 Twitter 开源的分布式 id 生成算法。其核心思想就是:使用一个 64 bit 的 long 型的数字作为全局唯一 id。

设计原理

java中,每个数据类型存储所占的字节数不一样,雪花算法生成的数字定义为 long,所以就是8个字节,64位,范围为:-2的64次方 ~ 2的64次方减1,考虑到生成的唯一值用于数据库主键,所以理论值应该从正数开始,容量上也是能满足业务,所以第一位为0是正数,最终范围为:0~9223372036854775808(2的63次方减1)。

组成原理

  • 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0。
  • 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截) 得到的值,这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69。
  • 10位的数据机器位,可以部署在1024个节点,包括5位 datacenterId 和5位 workerId 。
  • 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号。加起来刚好64位,为一个Long型。

上面总体是64位,具体位数可自行配置,如想运行更久,需要增加时间戳位数;如想支持更多节点,可增加工作机器id位数;如想支持更高并发,增加序列号位数

优点

整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。

Java代码

package com.sitech.ep.appinfo.util;

import org.shade.apache.commons.lang3.RandomUtils;
import org.shade.apache.commons.lang3.StringUtils;
import org.shade.apache.commons.lang3.SystemUtils;

import java.net.Inet4Address;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;

/**
 * @Description:
 * @Author: yangjj_tc
 * @Date: 2022/12/28 15:04
 */
public class SnowflakeIdWorker {

    /**
     * 开始时间截 (2022-12-28)
     */
    private final long twepoch = 1672211070000L;

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

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

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

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

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

    /**
     * 机器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) 此方法是判断传入的机房号和机器号是否超过了最大值31或者小于0
     */
    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 = 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 要增1, 但要预防sequence超过 最大值4095,所以要 与 SEQUENCE_MASK 按位求与
            // 即如果此时sequence等于4095,加1后为4096,再和4095按位与后,结果为0
            sequence = (sequence + 1) & sequenceMask;
            // 毫秒内序列溢出
            if (sequence == 0) {
                // 阻塞到下一个毫秒,获得新的时间戳
                timestamp = tilNextMillis(lastTimestamp);
            }
        } // 时间戳改变,毫秒内序列重置
        else {
            sequence = 0L;
        }

        // 上次生成ID的时间截
        // 把当前时间赋值给 lastTime, 以便下一次判断是否处在同一个毫秒内
        lastTimestamp = timestamp;

        // 移位并通过或运算拼到一起组成64位的ID
        long id = ((timestamp - twepoch) << timestampLeftShift) // 时间戳减去默认时间 再左移22位 与运算
            | (datacenterId << datacenterIdShift) // 机房号 左移17位 与运算
            | (workerId << workerIdShift) // 机器号 左移12位 与运算
            | sequence; // 序列号无需左移 直接进行与运算
        return id;
    }

    /**
     * 阻塞到下一个毫秒,直到获得新的时间戳
     *
     * @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();
    }

    /**
     * @Description: 测试
     * @Author: yangjj_tc
     * @Date: 2022/12/30 10:22
     */
    public static void main(String[] args) {
        SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
        for (int i = 0; i < 1000; i++) {
            long id = idWorker.nextId();
            System.out.println(id);
        }
    }

    private static Long getWorkId() {
        try {
            String hostAddress = Inet4Address.getLocalHost().getHostAddress();
            int[] ints = StringUtils.toCodePoints(hostAddress);
            int sums = 0;
            for (int b : ints) {
                sums += b;
            }
            return (long)(sums % 32);
        } catch (UnknownHostException e) {
            return RandomUtils.nextLong(0, 31);
        }
    }

    private static Long getDataCenterId() {
        int[] ints = StringUtils.toCodePoints(SystemUtils.getHostName());
        int sums = 0;
        for (int i : ints) {
            sums += i;
        }
        return (long)(sums % 32);
    }
}

核心

  1. 线程安全
// 获得下一个ID
public synchronized long nextId() {}

生成ID的方法是加了synchronized 关键词,确保了线程安全,否则在并发情况下,生成的Id就有可能重复。

  2.同一毫秒生成多个Id时

根据雪花算法的组成,可以看出,如果同一台机器同一毫秒需要生成多个Id,因为毫秒的时间戳、数据机器位一样,则前52位一致,所以需要靠后12位的序列号来区分。lastTimestamp 记录了上一次生成Id的毫秒级的时间戳,timestamp 为当前生成Id时毫秒级的时间戳,如果同一毫秒生成多个id,要生成不同序列号,序列号 sequence 开始为0。

// 如果是同一时间生成的,则进行毫秒内序列
if (lastTimestamp == timestamp) {
    // sequence 要增1, 但要预防sequence超过 最大值4095,所以要 与 SEQUENCE_MASK 按位求与
    // 即如果此时sequence等于4095,加1后为4096,再和4095按位与后,结果为0
    sequence = (sequence + 1) & sequenceMask;
    // 毫秒内序列溢出
    if (sequence == 0) {
        // 阻塞到下一个毫秒,获得新的时间戳
        timestamp = tilNextMillis(lastTimestamp);
    }
} // 时间戳改变,毫秒内序列重置
else {
    sequence = 0L;
}

sequence 递增到 4095 要重新回到 0 ,则使用 tilNextMillis 方法阻塞到下一毫秒并赋值给 timestamp,不断获取当前时间和最近生成Id的时间戳进行判断,如果还在当前毫秒级别,则空转,直到下一毫秒。获取新的时间戳后,此时 sequence 回到0。

protected long tilNextMillis(long lastTimestamp) {
    long timestamp = timeGen();
    while (timestamp <= lastTimestamp) {
        timestamp = timeGen();
    }
    return timestamp;
}

3.移位并通过或运算拼到一起组成64位的ID

// 移位并通过或运算拼到一起组成64位的ID
long id = ((timestamp - twepoch) << timestampLeftShift) // 时间戳减去默认时间 再左移22位 与运算
    | (datacenterId << datacenterIdShift) // 机房号 左移17位 与运算
    | (workerId << workerIdShift) // 机器号 左移12位 与运算
    | sequence; // 序列号无需左移 直接进行与运算
return id;

优点: 雪花算法生成的ID是趋势递增,不依赖数据库等第三方系统,生成ID的效率非常高,稳定性好,可以根据自身业务特性分配bit位,比较灵活。

缺点: 每台机器的时钟不同,当时钟回拨可能会发生重复ID。当数据量大时,需要对ID取模分库分表,在跨毫秒时,序列号总是归0,会发生取模后分布不均衡。

如何解决时间回拨问题

时间回拨是指,当机器出现问题,时间可能回到之前,此时雪花算法生成的id可能与之前的id值相同,从而导致id重复。

  1. 系统抛出异常,运维来手动调整时间。
  2. 延迟等待,对于偶然性的时间回拨,也许是机器出现了一次小故障,频繁出现的概率并不大,所以对于这种情况没必要中断业务,可以采用阻塞线程5ms,再获取时间,对比看时间是否比上一次请求的时间大,如果大了,说明恢复正常了,则不用管;如果还小,说明真出问题了,则抛出异常,呼唤程序员处理。
  3. 备用机方式来解决,当前机器出现问题,迅速换一台机器,通过高可用解决。

3.6、Leaf(美团)

Leaf是美团基础研发平台推出的一个分布式ID生成服务,名字取自德国哲学家、数学家莱布尼茨的一句话:There are no two identical leaves in the world,Leaf具备高可靠、低延迟、全局唯一等特点。

There are no two identical leaves in the world

世界上没有两片完全相同的树叶。

​ — 莱布尼茨

目已经在Github上开源:GitHub - Meituan-Dianping/Leaf: Distributed ID Generate Service

Leaf特性:

  1. 全局唯一,绝对不会出现重复的ID,且ID整体趋势递增。
  2. 高可用,服务完全基于分布式架构,即使MySQL宕机,也能容忍一段时间的数据库不可用。
  3. 高并发低延时,在CentOS 4C8G的虚拟机上,远程调用QPS可达5W+,TP99在1ms内。
  4. 接入简单,直接通过公司RPC服务或者HTTP调用即可接入。

目前主流的分布式ID生成方式,大致都是基于数据库号段模式和雪花算法(snowflake),而美团(Leaf)刚好同时兼具了这两种方式,可以根据不同业务场景灵活切换。

3.6.1、Leaf-segment号段模式

数据库配置

号段模式依赖于数据库表,先创建表:

CREATE TABLE `leaf_alloc` (
  `biz_tag` varchar(128)  NOT NULL DEFAULT '',
  `max_id` bigint(20) NOT NULL DEFAULT '1',
  `step` int(11) NOT NULL,
  `description` varchar(256)  DEFAULT NULL,
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;
  • biz_tag:针对不同业务需求,用biz_tag字段来隔离,如果以后需要扩容时,只需对biz_tag分库分表即可
  • max_id:当前业务号段的最大值,用于计算下一个号段
  • step:步长,也就是每次获取ID的数量
  • description:对于业务的描述

数据库表设计如下:

+-------------+--------------+------+-----+-------------------+-----------------------------+
| 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 |
+-------------+--------------+------+-----+-------------------+-----------------------------+

初始化数据库表:

insert into leaf_alloc(biz_tag, max_id, step, description) values('leaf-segment-test', 1, 2000, 'Test leaf Segment Mode Get Id')

架构

重要字段说明:biz_tag用来区分业务,max_id表示该biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度。

原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step,大致架构如下图所示:

test_tag在第一台Leaf机器上是1-1000号段,当这个号段用完时,会去加载另一个长度为step=1000的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是3001-4000。同时数据库对应的biz_tag这条数据的max_id会从3000被更新成4000,更新号段的SQL语句如下:

Begin
UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx
SELECT tag, max_id, step FROM table WHERE biz_tag=xxx
Commit

导入并修改leaf项目

Leaf Server的配置都在leaf-server/src/main/resources/leaf.properties中

配置项含义默认值
leaf.nameleaf 服务名
leaf.segment.enable是否开启号段模式false
leaf.jdbc.urlmysql 库地址
leaf.jdbc.usernamemysql 用户名
leaf.jdbc.passwordmysql 密码
leaf.snowflake.enable是否开启snowflake模式false
leaf.snowflake.zk.addresssnowflake模式下的zk地址
leaf.snowflake.portsnowflake模式下的服务注册端口
leaf.name=com.sankuai.leaf.opensource.test
#号段模式
leaf.segment.enable=true
leaf.jdbc.url=jdbc:mysql://localhost:3306/aa?useUnicode=true&characterEncoding=utf8&useSSL=false
leaf.jdbc.username=root
leaf.jdbc.password=123456
#雪花算法
leaf.snowflake.enable=false
#leaf.snowflake.zk.address=
#leaf.snowflake.port=

如果使用号段模式,需要建立DB表后,配置url、username、password,如果不想使用该模式配置leaf.segment.enable=false即可。

leaf.segment和leaf.snowflake务必保证只有一个开启,由于使用的是segment(号段模式)所以开启此服务,然后由于mysql服务器是8.0.25版本,所以将pom中的mysql-connector以及druid对做了相应的版本修改,如果mysql版本是5.x.x版本的就无须任何修改。

Leaf-segment双buffer模式

leaf的号段模式在更新号段时是无阻塞的,当号段耗尽时再去DB中取下一个号段,如果此时网络发生抖动,或者DB发生慢查询,业务系统拿不到号段,就会导致整个系统的响应时间变慢。

所以Leaf在当前号段消费到某个点时,就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做很大程度上的降低了系统的风险。

Leaf采用了异步更新的策略,同时通过双Buffer的方式,保证无论何时DB出现问题,都能有一个Buffer的号段可以正常对外提供服务,只要DB在一个Buffer的下发的周期内恢复,就不会影响整个Leaf的可用性。

Leaf-segment采用双buffer的方式,它的服务内部有两个号段缓存区segment。当前号段已消耗10%时,还没能拿到下一个号段,则会另启一个更新线程去更新下一个号段。简而言之就是Leaf保证了总是会多缓存两个号段,即便哪一时刻数据库挂了,也会保证发号服务可以正常工作一段时间。

这个版本代码在线上稳定运行了半年左右,Leaf又遇到了新的问题:

  • 号段长度始终是固定的,假如Leaf本来能在DB不可用的情况下,维持10分钟正常工作,那么如果流量增加10倍就只能维持1分钟正常工作了。
  • 号段长度设置的过长,导致缓存中的号段迟迟消耗不完,进而导致更新DB的新号段与前一次下发的号段ID跨度过大。

Leaf segment监控

针对服务自身的监控,Leaf提供了Web层的内存数据映射界面,可以实时看到所有号段的下发状态。比如每个号段双buffer的使用情况,当前ID下发到了哪个位置等信息都可以在Web界面上查看。

访问:http://127.0.0.1:8080/cache

3.6.2、Leaf-snowflake雪花算法


Snowflake,Twitter开源的一种分布式ID生成算法。基于64位数实现,下图为Snowflake算法的ID构成图。

  • 第1位置为0。
  • 第2-42位是相对时间戳,通过当前时间戳减去一个固定的历史时间戳生成。
  • 第43-52位是机器号workerID,每个Server的机器ID不同。
  • 第53-64位是自增ID。

这样通过时间+机器号+自增ID的组合来实现了完全分布式的ID下发。

Leaf-snowflake是可以解决以下问题

  1. 解决雪花算法的问题

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

  1. 解决Leaf-segment的ID可计算问题

Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,不适用于订单ID生成场景,比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的。

Leaf-snowflake的启动过程

对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。Leaf-snowflake是按照下面几个步骤启动的:

  1. 启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。

  2. 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。

  3. 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。

弱依赖ZooKeeper

除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。这样做到了对三方组件的弱依赖。一定程度上提高了SLA。

时钟回退问题

因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,需要解决时钟回退的问题。

参见上图整个启动流程图,服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:

  1. 若写过,则用自身系统时间与leaf_forever/self节点记录时间做比较,若小于leafforever/{self}时间则认为机器时间发生了大步长回拨,服务启动失败并报警。
  2. 若未写过,证明是新服务节点,直接创建持久节点leaf_forever/${self}并写入自身系统时间,接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确。具体做法是取leaf_temporary下的所有临时节点(所有运行中的Leaf-snowflake节点)的服务IP:Port,然后通过RPC请求得到所有节点的系统时间,计算sum(time)/nodeSize。
  3. 若abs( 系统时间-sum(time)/nodeSize ) < 阈值,认为当前系统时间准确,正常启动服务,同时写临时节点leaf_temporary/${self} 维持租约。
  4. 否则认为本机系统时间发生大步长偏移,启动失败并报警。
  5. 每隔一段时间(3s)上报自身系统时间写入leaf_forever/${self}。

由于强依赖时钟,对时间的要求比较敏感,在机器工作时NTP同步也会造成秒级别的回退,建议可以直接关闭NTP同步。要么在时钟回拨的时候直接不提供服务直接返回ERROR_CODE,等时钟追上即可。或者做一层重试,然后上报报警系统,更或者是发现有时钟回拨之后自动摘除本身节点并报警,如下:

//发生了回拨,此刻时间小于上次发号时间
 if (timestamp < lastTimestamp) {
  			  
            long offset = lastTimestamp - timestamp;
            if (offset <= 5) {
                try {
                	//时间偏差大小小于5ms,则等待两倍时间
                    wait(offset << 1);//wait
                    timestamp = timeGen();
                    if (timestamp < lastTimestamp) {
                       //还是小于,抛异常并上报
                        throwClockBackwardsEx(timestamp);
                      }    
                } catch (InterruptedException e) {  
                   throw  e;
                }
            } else {
                //throw
                throwClockBackwardsEx(timestamp);
            }
        }
 //分配ID

从上线情况来看,在2017年闰秒出现那一次出现过部分机器回拨,由于Leaf-snowflake的策略保证,成功避免了对业务造成的影响。

启动Leaf-snowflake

启动Leaf-snowflake模式也比较简单,启动本地ZooKeeper,修改一下项目中的leaf.properties文件,关闭leaf.segment模式,启用leaf.snowflake模式即可。

leaf.name=com.sankuai.leaf.opensource.test
leaf.segment.enable=false
leaf.jdbc.url=jdbc:mysql://localhost:3306/aa?useUnicode=true&characterEncoding=utf8&useSSL=false
leaf.jdbc.username=root
leaf.jdbc.password=123456

leaf.snowflake.enable=true
# snowflake模式下的zk地址
leaf.snowflake.zk.address=127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183
# snowflake模式下的服务注册端口
leaf.snowflake.port=1212

访问:http://127.0.0.1:8080/api/snowflake/get/leaf-segment-test

可以看到:

3.6.3、Spring Boot 注解方式使用 leaf

引入依赖

<dependency><artifactId>leaf-boot-starter</artifactId>
    <groupId>com.sankuai.inf.leaf</groupId>
    <version>1.0.1-RELEASE</version>
</dependency>

如果依赖无法下载引入 可下载 Leaf-feature-spring-boot-starter 构建本地依赖后导入到项目

构建完成的本地仓库

导入 JAR 包

启动类加上注解 @EnableLeafServer

@SpringBootApplication
@EnableLeafServer
@MapperScan(basePackages = "com.example.canal.mybatis.mapper")

public class CanalApplication {
    public static void main(String[] args) {
        SpringApplication.run(CanalApplication.class,args);
    }
}

号段模式

package com.example.canal.mybatis.controller;

import com.sankuai.inf.leaf.common.Status;
import com.sankuai.inf.leaf.service.SegmentService;
import com.sankuai.inf.leaf.service.SnowflakeService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;

@RestController
@RequestMapping("/yang")
public class UserController {

    @Resource(name = "initLeafSegmentStarter")
    private SegmentService segmentService;

    // @Resource(name="initLeafSnowflakeStarter")
    // private SnowflakeService snowflakeService;

    @RequestMapping(value = "/snowflake", method = RequestMethod.POST)
    public void getSnowflakeId() {

        // 获取号段模式分布式ID
        com.sankuai.inf.leaf.common.Result r = segmentService.getId("leaf-segment-test");

        // 判断是否成功,成功返回具体的id,不成功返回错误提示
        if (r.getStatus() == Status.SUCCESS) {
            System.out.println(r.getId());
        }
    }
}

雪花算法

import com.sankuai.inf.leaf.common.Status;
import com.sankuai.inf.leaf.service.SegmentService;
import com.sankuai.inf.leaf.service.SnowflakeService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;

@RestController
@RequestMapping("/yang")
public class UserController {

//    @Resource(name = "initLeafSegmentStarter")
//    private SegmentService segmentService;

     @Resource(name="initLeafSnowflakeStarter")
     private SnowflakeService snowflakeService;

    @RequestMapping(value = "/snowflake", method = RequestMethod.POST)
    public void getSnowflakeId() {

        // 获取snowflake分布式ID
        // id 这个参数是没有意义的,只是为了和号段模式的接口统一,所以要传一个参数,自己随意定义一个
        com.sankuai.inf.leaf.common.Result r = snowflakeService.getId("id");

        // 判断是否成功,成功返回具体的id,不成功返回错误提示
        if (r.getStatus() == Status.SUCCESS) {
            System.out.println(r.getId());
        }
    }
}

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值