本文参考了廖雪峰老师的文章:分布式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());
}
运行之后我们可以看到结果
我们可以看到HashSet的size数量并不等于50000。
但是为什么HashSet是线程不安全的呢?
我们点进源码
可以发现,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快要立不住了。