雪花❄️算法ID重复-DB唯一键冲突-复现分析到解决-线上环境
1. 背景
-
在某天下午,群里突然出现告警,出现大量短信插入数据失败异常,最后定位到线上环境短信发送记录入库唯一键冲突问题。目前发现问题是由于不同的服务器上雪花算法机器码wordId相同导致。当发送短信功能在高并发请求下,大量请求在同一毫秒下并且workId在两台物理机Java服务上是一样的,就会出现ID重复情况, 下面是具体的设计:
- workId获取流程【workId存放在Redis的Hash结构里面,key是服务名+ip地址,value是map.size()大小,但是会出现同一个服务在不同机器的wordId相同, 正好碰上时间戳也是一致,并且sequence序列号也是一致的,虽然sequence递增过程加了sync锁,但是jvm层面的锁解决不了多台机器分布式的问题,最终导致雪花算法生成的ID出现一致问题】
-
现在版本的workerId是拿到最大workerId【map.size()】,再workerId递增。不知道哪个神仙设计的用Redis hash数据结构来获取workId,获取逻辑全是漏洞。
-
【正常流程】user有2个节点,当前map.size()=99,第一个节点启动map.size=100 第二个节点启动是101,这个流程是没问题的。
-
【异常流程】比如user 2个节点,当前map.size()=99,第一个节点启动map.size()=100,mall服务掉了一个节点redis的hash删除这个节点,现在map.size()=99, 当第二个user服务节点启动又是100,两个user的wordId都是100,生成雪花算法bit组成结构(timestamp+wordId+sequence)。
2. 雪花算法
- 64位 由第一位固定为1位0(正整数),时间戳占41位, 机器wordId占10位,sequence有12位(4096)
- 1+41+10+12=64位
/**
* Copyright (c) 2024
* All rights reserved
* Author: hakusai22@qq.com
*/
public class SnowflakeIdGenerator {
private static final long startTime = 1532016000000L;
private static final long workerIdBits = 10L;
private static final long maxWorkerId = 1023L;
private static final long sequenceBits = 12L;
private static final long workerIdMoveBits = 12L;
private static final long timestampMoveBits = 22L;
private static final long sequenceMask = 4095L;
private long workerId;
private static long sequence = 0L;
private static long lastTimestamp = -1L;
private static SnowflakeIdGenerator idWorker;
/**
* synchronized JVM锁 获取雪花ID ❄️.
*
* @return
*/
public synchronized long nextNormalId() {
long timestamp = this.currentTime();
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
} else {
if (lastTimestamp == timestamp) {
sequence = sequence + 1L & 4095L;
if (sequence == 0L) {
timestamp = this.blockTillNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
//64位 由第一位固定为1位0(正整数),时间戳占41位, 机器wordId占10位,sequence有12位(4096)
return timestamp - 1532016000000L << 22 | this.workerId << 12 | sequence;
}
}
}
3. (优化算法后的版本)初始化workId在本地服务中,Springboot容器加载完调用
- 查找workerId是否有空洞,有则填上(复用前面的值);没有则取当前最大数+1。
- 先拿到map.size()大小,使用一个set容器存储所有的value值,再拿到map中value最大的值,从最大值-1开始往前去set容器中查找不存在的值, 这个值就是workId,如果前面的值都用完了,再从最大的value递增+1生成新的workId。
Springboot 启动 初始化workId在本地服务中
- 启动的时候设置一个redis分布式锁🔒,超时时间是9s,这过程保证多个服务同时请求抢锁获取workId逻辑 不会出现workId一致的情况。为什么设置的是9s呢?9s内没有处理完锁被释放了,别的服务获取到锁 还需要处理第一个服务防止把后面的锁给误删了的逻辑(使用lua脚本删除redis中匹配value的key,可以避免由于方法执行时间过长而redis锁自动过期失效的时候误删其他线程的锁)。
/**
* Copyright (c) 2024
* All rights reserved
* Author: hakusai22@qq.com
*/
/**
* spring 容器加载完毕后调用
*/
@Component
@Slf4j
public class ApplicationInit implements ApplicationContextAware {
@Autowired
private RedisUtil redisUtil;
@Value("${spring.application.name}")
private String name;
/**
* 1.初始化SnowflakeIdGenerator
*/
@Override
public void setApplicationContext(
ApplicationContext applicationContext) throws BeansException {
String snowflakeId = "SnowflakeId";
try {
// 分布式锁🔒
redisUtil.lock(snowflakeId + "-key", 90000L, () -> {
Map<String, Object> map = redisUtil.hmget(snowflakeId);
long workId = 0;
if (map == null || map.size() == 0) {
map = new HashMap<>(16);
map.put(name + "-" + getIpAddress(), workId);
} else {
Object o = map.get(name + "-" + getIpAddress());
if (o != null) {
workId = Long.parseLong(o.toString());
} else {
workId = getNextWorkerId(map);
map.put(name + "-" + getIpAddress(), workId);
}
}
SnowflakeIdGenerator.init(workId);
redisUtil.hmset(snowflakeId, map);
return null;
});
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 查找workerId是否有空洞,有则填上;没有则取当前最大数+1
*
* @param map
* @return
*/
private static long getNextWorkerId(Map<String, Object> map) {
Long nextWorkerId = -1L;
Set<Long> currentWorkerIdSet = new HashSet<>(map.size());
for (Map.Entry<String, Object> entry : map.entrySet()) {
Long value = Long.parseLong(entry.getValue().toString());
nextWorkerId = nextWorkerId.compareTo(value) > 0 ? nextWorkerId : value;
currentWorkerIdSet.add(value);
}
long temp = nextWorkerId;
while (--temp >= 0) {
if (!currentWorkerIdSet.contains(temp)) {
return temp;
}
}
return ++nextWorkerId;
}
4. 总结
- 业务中出现雪花❄️ID重复主要是:
- workId算法生成设计有问题,优化生成workId算法就好了。
- 业务并发量高,同一个时间戳需要生成的ID调用很多,同一个服务synchronized锁只能解决本机重复问题,服务多节点部署就会出现问题,在workId相同情况下,添加redis分布式锁解决时间戳毫秒一致现象。
- 收获的话: 主要是排查问题的过程,通过日志和源代码进行分析,代码复习场景,文档总结描述,解决代码上的问题,还能学习一波雪花ID相关知识点。
5. 参考
转载自:https://juejin.cn/post/7376853374250123314