⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7474427694630436914 「G探险者」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

项目里面有个日志流水号,是通过雪花算法实现的,客户在评审我们的代码时,说是在高并发的场景下可能会存在序列号重复的情况。

如下,是我的源代码实现逻辑

public class TraceIdGenerator {

public static String getInetAddress() {
try {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
InetAddress address = null;
while (interfaces.hasMoreElements()) {
NetworkInterface ni = interfaces.nextElement();
Enumeration<InetAddress> addresses = ni.getInetAddresses();
while (addresses.hasMoreElements()) {
address = addresses.nextElement();
if (!address.isLoopbackAddress() && address.getHostAddress().indexOf(":") == -1) {
return address.getHostAddress();
}
}
}
return null;
} catch (Throwable t) {
return null;
}
}

private static String IP_16 = "ffffffff";
private static String P_ID_CACHE;
private static AtomicInteger count = new AtomicInteger(1000);

static {
try {
String ipAddress = getInetAddress();
if (ipAddress != null) {
IP_16 = getIP_16(ipAddress);
}
} catch (Throwable e) {
/*
* empty catch block
*/
}
}

/**
* This method can be a better way under JDK9, but in the current JDK version, it can only be implemented in this way.
* <p>
* In Mac OS , JDK6,JDK7,JDK8 ,it's OK
* In Linux OS,JDK6,JDK7,JDK8 ,it's OK
*
* @return Process ID
*/
public static String getPID() {
//check pid is cached

if (P_ID_CACHE != null) {
return P_ID_CACHE;
}
try {
String processName = java.lang.management.ManagementFactory.getRuntimeMXBean().getName();
if (StringUtils.isBlank(processName)) {
return StringUtils.EMPTY;
}

String[] processSplitName = processName.split("@");

if (processSplitName.length == 0) {
return StringUtils.EMPTY;
}

String pid = processSplitName[0];

if (StringUtils.isBlank(pid)) {
return StringUtils.EMPTY;
}
P_ID_CACHE = pid;
return pid;
} catch (Throwable e) {
//ignore
}
return StringUtils.EMPTY;
}

private static String getTraceId(String ip, long timestamp, int nextId) {
StringBuilder appender = new StringBuilder(30);
appender.append(ip).append(timestamp).append(nextId).append(getPID());
return appender.toString();
}

public static String generate() {
try {
return getTraceId(IP_16, System.currentTimeMillis(), getNextId());
} catch (Throwable e) {
return UUID.fastUUID().toString();
}
}

private static String getIP_16(String ip) {
String[] ips = ip.split("\\.");
StringBuilder sb = new StringBuilder();
for (String column : ips) {
String hex = Integer.toHexString(Integer.parseInt(column));
if (hex.length() == 1) {
sb.append('0').append(hex);
} else {
sb.append(hex);
}

}
return sb.toString();
}

private static int getNextId() {
for (; ; ) {
int current = count.get();
int next = (current > 9000) ? 1000 : current + 1;
if (count.compareAndSet(current, next)) {
return next;
}
}
}


public static void main(String[] args) {
String generate = generate();
System.out.println(generate);
}
}

其中getNextId() 方法利用 AtomicInteger 来生成序列号。看似使用了 CAS(比较并交换)机制来确保线程安全,但在高并发场景下仍然可能存在 序列号重复 的问题。下面我将详细分析这个问题的根本原因。

代码分析

private static int getNextId() {
for (;;) {
int current = count.get(); // 获取当前序列号
int next = (current > 9000) ? 1000 : current + 1; // 如果序列号超过9000,重置为1000
if (count.compareAndSet(current, next)) { // 如果当前值等于current,才会更新为next
return next; // 返回生成的序列号
}
}
}

潜在问题:高并发下序列号重复的原因

1.序列号重置机制问题

代码中定义了序列号的范围为 1000 到 9000,一旦当前的 count 超过 9000,它就会重置为 1000。问题的关键在于,如果多个线程在同一毫秒内高频率地调用该方法,并且当前的 count 达到了 9000,那么它们会尝试重置 count 为 1000,导致重复的序列号生成。

具体场景如下:假设当前 count 为 9000,多个线程几乎同时执行 getNextId() 方法,它们会都看到 count 为 9000,然后根据条件生成 next = 1000,然后通过 compareAndSet 更新 count。由于 compareAndSet 依赖于比较当前值 current 和 count,当多个线程几乎同时读取到 current = 9000 时,可能会成功更新 count 为 1000 多次,导致多个线程生成了相同的序列号 1000。

这种情况发生的概率随着并发量的增加而增加,尤其是在高并发环境下,线程调度不可控,可能会出现多个线程的 CAS 成功,导致生成重复的序列号。

2.CAS 操作的竞争条件

AtomicInteger 使用了 CAS 来保证对 count 的原子性更新,但它并没有完全消除 并发更新的竞态条件。即多个线程读取到相同的 current 值后,如果它们同时调用 compareAndSet(current, next),其中某些线程的操作会成功,而其他线程则会失败(因为 count 的值已被更新),这并不能确保每个线程都能生成唯一的序列号。

例如:线程 A 和线程 B 同时读取 count 的值,发现是 9000,然后都试图将 count 更新为 1000。因为 compareAndSet 的成功与否取决于 count 是否与 current 相等,而 count 在多线程并发情况下可能会变成不一致的状态,导致多个线程成功更新 count,进而生成重复的序列号。

3.时间窗口

如果系统的请求频率非常高,并且调用频繁,多个线程可能会在极短的时间内(比如同一毫秒内)频繁访问 getNextId(),即使 compareAndSet 有原子性保障,但由于重置逻辑非常简单,多个线程会同时重置序列号并生成相同的 1000。

改进方案:避免重复生成序列号

为了解决这一问题,我们可以通过以下几种方法来优化 getNextId():

1. 引入时间戳检查

在生成序列号时,可以通过检查当前的时间戳(System.currentTimeMillis())来区分不同的毫秒,从而避免在同一毫秒内重置序列号。

private static long lastTimestamp = -1L;
private static AtomicInteger count = new AtomicInteger(1000);

private static int getNextId() {
long currentTime = System.currentTimeMillis();

// 如果时间戳发生变化,则重置序列号
if (currentTime != lastTimestamp) {
synchronized (TraceIdGenerator.class) {
if (currentTime != lastTimestamp) {
count.set(1000);
lastTimestamp = currentTime;
}
}
}

for (;;) {
int current = count.get();
int next = (current > 9000) ? 1000 : current + 1;
if (count.compareAndSet(current, next)) {
return next;
}
}
}

通过 时间戳检查,每次进入新的一毫秒时,序列号会重置为 1000,这样能够确保同一毫秒内不会因为重置操作导致序列号重复。

2. 改用更大范围的序列号

如果是为了处理更高的并发,可以考虑扩展序列号的范围,例如使用更大的序列号区间(例如 0 到 100,000),以减少在同一毫秒内序列号用尽的概率。这样,即便在高并发场景下,也能有更多的序列号可用。

3. 时间窗口重试机制

如果在同一毫秒内序列号用尽,可以考虑引入一个等待机制,在同一毫秒内序列号用完时,线程会等待到下一个毫秒再继续生成 ID。

4. 使用雪花算法

如果业务需要保证全局唯一性和高并发性能,可以考虑使用雪花算法(Snowflake),它不仅能保证 ID 唯一性,还能够处理高并发和分布式场景,避免重复序列号的问题。

啥是雪花算法?

可以说我代码中的算法在某些方面模仿了雪花算法,但并不是一个完全的雪花算法实现。它借用了雪花算法的 时间戳 + 序列号递增 的概念,但在细节和全局唯一性的保障上,存在明显差异。以下是你代码和雪花算法的对比:

共同点:模仿雪花算法的元素

时间戳: 你代码中通过 System.currentTimeMillis() 获取当前时间戳,作为生成 ID 的一部分,这与雪花算法中的时间戳部分是相似的。

序列号递增: 每次生成一个 ID,序列号递增(例如,当 count 达到 9000 时重置为 1000),这也是雪花算法中常见的做法。雪花算法中的序列号部分是 12 位,能够在同一毫秒内生成大量唯一 ID。

差异:与雪花算法的本质区别

1.全局唯一性保障不足:

雪花算法通过机器 ID 和数据中心 ID 来保证 ID 在分布式环境中的全局唯一性。每台机器都有一个独立的机器 ID,这在多节点(分布式)环境下非常重要。

你的代码并没有涉及到机器 ID 或者数据中心 ID。它假设整个系统只有一个节点(或单机模式),这就意味着它并没有解决 分布式系统中的 ID 唯一性 问题。如果在多个服务或应用实例中同时使用这种生成方式,就可能会出现 ID 冲突。

2.时间戳粒度和范围:

雪花算法的时间戳部分是 41 位,能够支持较长的时间范围(大约 69 年),并且粒度是毫秒级的,这样能够确保长时间运行而不会产生重复 ID。

你的算法使用的是 System.currentTimeMillis(),其粒度也是毫秒级别,但它没有雪花算法那样精确的控制和范围。在同一毫秒内,由于你只使用了简单的序列号递增,可能会出现序列号冲突的情况,尤其是在高并发场景下。

3.重置机制:

雪花算法中的序列号是在每毫秒内递增,直到最大值为止。如果超出最大值,系统会等待下一毫秒,确保序列号不会重复。雪花算法本身设计时避免了序列号重置的问题。

你的代码在 count 达到 9000 时会 重置为 1000,这就可能导致 在高并发场景下,多个线程生成相同的 ID,从而引发 ID 冲突。

4.ID 结构:

雪花算法使用了 64 位的 ID,包含了符号位、时间戳、机器 ID、数据中心 ID 和序列号,结构清晰且易于扩展。

你的代码生成的 ID 并没有采用类似雪花算法的结构化设计,而是直接拼接了 IP 地址、时间戳 和序列号,这样的生成方式不够灵活,无法很好地适应分布式环境。

总结

我的代码确实借鉴了雪花算法的一些思想,尤其是时间戳和序列号递增的部分,但它并没有实现雪花算法的完整机制(如机器 ID、数据中心 ID、分布式保证等)。因此,它只能算是对雪花算法的一种简单模仿,适用于单机场景下的 ID 生成,但在 分布式系统 或 高并发 环境中,存在生成重复 ID 的风险。

文章目录
  1. 1. 代码分析
  2. 2. 潜在问题:高并发下序列号重复的原因
    1. 2.1. 1.序列号重置机制问题
    2. 2.2. 2.CAS 操作的竞争条件
    3. 2.3. 3.时间窗口
  3. 3. 改进方案:避免重复生成序列号
    1. 3.1. 1. 引入时间戳检查
    2. 3.2. 2. 改用更大范围的序列号
    3. 3.3. 3. 时间窗口重试机制
    4. 3.4. 4. 使用雪花算法
  4. 4. 啥是雪花算法?
    1. 4.1. 共同点:模仿雪花算法的元素
    2. 4.2. 差异:与雪花算法的本质区别
      1. 4.2.1. 1.全局唯一性保障不足:
      2. 4.2.2. 2.时间戳粒度和范围:
      3. 4.2.3. 3.重置机制:
      4. 4.2.4. 4.ID 结构:
    3. 4.3. 总结