分布式唯一ID生成器

265 阅读3分钟

本文参考了廖雪峰老师的文章:分布式ID,并做了注解解释。

概念

Twitter的Snowflake算法,它给每台机器分配一个唯一标识,然后通过时间戳+标识+自增实现全局唯一ID。

他的组成为41位的毫秒时间戳 + 10位唯一机器ID + 12位每秒偏移量,共64位。

理论上支持1毫秒内可以有1024个机器。每台机器可以生成4096唯一ID。

那么1毫秒内,就可以最多生成4,194,304个唯一ID。

1秒内可以生成4,194,304,00个ID。

Snowflake算法不是没有缺点,我们知道它由时间戳计算ID。那么服务器之间的时间必然需要完全同步。(虽然一般也没人会去动服务器时间)

当然对于Twitter那样子的社交媒体平台这样子的性能或许足够,但是一般的服务应用,已经性能过剩了。

所以我们可以尝试短一点的53位ID。

为什么是53位?

因为JS最大整形是53位,超出这个位数就会丢失精度。因此选择64位的ID需要我们额外转换成字符串类型。

代码

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.CountDownLatch;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * @author sou7h
 * @description Snowflake算法ID生成器
 * @date 2021年12月08日 10:34 下午
 */
public class IdUtils {
    // 53bitID由32bit秒级时间戳+16bit自增+5bit机器标识组成

  	/**
     * 固定的时间值
     */
    private static final long TIME_OFFSET = LocalDate.of(2000, 1, 1).atStartOfDay(ZoneId.of("Z")).toEpochSecond();


  	/**
     * 机器ID
     */
    private static final long SERVER_ID = getServerIdAsLong();

    /**
     * 最大偏移量 16位自增
     */
    private static final long MAX_NEXT = 0b11111_11111111_111L;


    /**
     * 上次生成时间
     */
    private static long lastSecond = 0;

    /**
     * 偏移量
     */
    private static long offset = 0;

    /**
     * @return long 唯一ID
     * @description 外部方法 生成唯一ID
     */
    public static long nextId() {
        return nextId(System.currentTimeMillis() / 1000); //单体项目秒级即可
    }

    /**
     * @param second: 当前时间戳
     * @return long 唯一ID
     * @description 内部方法 生成唯一ID的前置判断 多次调用加锁避免重复生成
     */
    private static synchronized long nextId(long second) {
        //如果当前时间比上次生成时间小 说明出现了时间回拨
        if (second < lastSecond) {
            System.out.println("出现时间回拨:" + second + ";上次时间:" + lastSecond);
            //赋值为上次生成时间 避免重复
            second = lastSecond;
        }
        //接下来如果不与上次时间相同,则把上次时间重新赋值,并且重置偏移量。
        if (lastSecond != second) {
            lastSecond = second;
            reset();
        }
        offset++;

        //与符号最大偏移量
        long next = offset & MAX_NEXT;
        if (next == 0) {
            System.out.println("达到当前时间最大的偏移量:" + second + ",offset:" + offset);
            return nextId(second + 1);
        }
        return generateId(second, offset, SERVER_ID);
    }


    /**
     * @return void
     * @description 重置偏移量
     */
    private static void reset() {
        offset = 0;
    }

    /**
     * @param second:   当前时间戳
     * @param offset:   偏移量
     * @param serverID: 机器ID
     * @return long 唯一ID
     * @description 生成ID  TimeStamp 时间戳  32位  每秒生成最多生成的ID数  16位 机器数  5位
     */
    private static long generateId(long second, long offset, long shardId) {
      	// <<符号为位运算 意思为左移  << 21 即为左移21位
        // 为什么是21位? 我们把每秒生成最多生成的ID数 16 + 机器数 5 相加就是 21位
        // 同理我们需要把offset放在相同的位置
        return ((second - TIME_OFFSET) << 21) | (offset << 5) | shardId;
    }

    /**
     * @return long 根据服务器hostname获取唯一ID
     * @description 根据主机hostname生成机器唯一标识符
     */
    private static long getServerIdAsLong() {
        try {
            String hostName = InetAddress.getLocalHost().getHostName();
            //正则匹配 判断是否是 host-1 host-2 host-3 这样的主机名字 就可以自动配置
            Matcher matcher = Pattern.compile("^.*\\D+([0-9]+)$").matcher(hostName);
            if (matcher.matches()) {
                long machineID = Long.parseLong(matcher.group(1));
                if (machineID >= 0 && machineID < 32) {
                    System.out.println("获取到的hostname为" + hostName + "唯一标识符为:" + machineID);
                    return machineID;
                }
            }
        } catch (UnknownHostException e) {
            System.out.println("获取不到hostname,设置机器码为0");
        }
        return 0;
    }
  
  }

在上面代码中,我们配置机器的唯一ID是通过配置这样子的host-1 host-2这样的hostname,我们也可以选择配置到yaml中,或者传入值配置。

上文的System.out.println都可以换成log.info或者log.warn记录。

问题

在廖雪峰老师的评论下看到有同学用HashSet验证是否生成正确唯一ID。

可以肯定同学用set,去重的特性计算,但是HashSet是线程不安全的。所以我们可以改用其他线程安全的set验证。

我们在IdUtils类中写一个main方法验证。

   public static void main(String[] args) throws UnknownHostException, InterruptedException {
        //第一种: HashSet 线程不安全验证
        Set<Long> ids = new HashSet<>(800);
     
        //第二种:使用Collections.synchronizedSet给HashSet上锁
        Set<Long> idSet = Collections.synchronizedSet(new HashSet<>(800));
     
        //第三种:CopyOnWriteArraySet
        Set<Long> cowSet = new CopyOnWriteArraySet<>();
     
        //第四种:利用ConcurrentHashMap
        ConcurrentHashMap<Long, Integer> map = new ConcurrentHashMap<>(800);

        //利用CountDownLatch同步所有线程的完成
        CountDownLatch countDownLatch = new CountDownLatch(1000);
        for (int i = 0; i < 1000; i++) {

            Thread thread = new Thread(() -> {
                for (int j = 0; j < 50; j++) {
                    long l = nextId();
                    map.put(l, map.getOrDefault(l, 0) + 1);
                    ids.add(l);
                    idSet.add(l);
                    cowSet.add(l);
                }
                countDownLatch.countDown();


            });
            thread.start();

        }
        countDownLatch.await();
        long count = countDownLatch.getCount();
        System.out.println("===线程完成数量==");
        System.out.println(5000 - count);

        System.out.println("======以下为HashSet的结果======");
        System.out.println(ids.size());


        System.out.println("====以下为synchronizedSet的结果===");
        System.out.println(idSet.size());
        
        System.out.println("====以下为CopyOnWrite的结果====");
        System.out.println(cowSet.size());

        System.out.println("=====以下为ConcurrentHashMap的结果======");
        ConcurrentHashMap.KeySetView<Long, Integer> longs = map.keySet();
        System.out.println(longs.size());
    }

运行之后我们可以看到结果

image-20211212144210907

我们可以看到HashSetsize数量并不等于50000。

但是为什么HashSet是线程不安全的呢?

我们点进源码

image-20211212195339467 image-20211212195231594

可以发现,HashSet的内部本质是利用了HashMap

我们知道HashMap插入值的操作大致分为3步

  • 计算index
  • 判断后插入值(之中有几种情况,是否存在key,是否为链表,是否为红黑树)
  • 判断扩容

接下来我们可以假象一种情况,有两个线程A和B。

线程A要插入a值,线程B要插入b值,而a和b值计算的index值相同。

当线程A先执行完计算index值,时间片用完了。

之后线程B直接执行完整个put动作。这是b值已经插入。

线程A继续执行,这是线程A并不知道该index值上已有b值插入,那么A的操作会把b值覆盖为a值。

这样就会导致HashMap的插入出现数据丢失的情况,也就是线程不安全的情况。

本文中的IdUtils会使用在我的开源项目中,欢迎关注。当然最近项目进展太缓慢,我的flag快要立不住了。