大家好,我是G探险者。 项目里面有个日志流水号,是通过雪花算法实现的,客户在评审我们的代码时,说是在高并发的场景下可能会存在序列号重复的情况。
如下,是我的源代码实现逻辑
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; // 返回生成的序列号
}
}
}
潜在问题:高并发下序列号重复的原因
-
序列号重置机制问题
-
代码中定义了序列号的范围为 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 成功,导致生成重复的序列号。
-
-
CAS 操作的竞争条件
-
==
AtomicInteger使用了 CAS 来保证对count的原子性更新,但它并没有完全消除 并发更新的竞态条件==。即多个线程读取到相同的current值后,如果它们同时调用compareAndSet(current, next),其中某些线程的操作会成功,而其他线程则会失败(因为count的值已被更新),这并不能确保每个线程都能生成唯一的序列号。 -
例如:线程 A 和线程 B 同时读取
count的值,发现是 9000,然后都试图将count更新为 1000。因为compareAndSet的成功与否取决于count是否与current相等,而count在多线程并发情况下可能会变成不一致的状态,导致多个线程成功更新count,进而生成重复的序列号。
-
-
时间窗口
- 如果系统的请求频率非常高,并且调用频繁,多个线程可能会在极短的时间内(比如同一毫秒内)频繁访问
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。
差异:与雪花算法的本质区别
-
全局唯一性保障不足:
- 雪花算法通过机器 ID 和数据中心 ID 来保证 ID 在分布式环境中的全局唯一性。每台机器都有一个独立的机器 ID,这在多节点(分布式)环境下非常重要。
- 你的代码并没有涉及到机器 ID 或者数据中心 ID。它假设整个系统只有一个节点(或单机模式),这就意味着它并没有解决 分布式系统中的 ID 唯一性 问题。如果在多个服务或应用实例中同时使用这种生成方式,就可能会出现 ID 冲突。
-
时间戳粒度和范围:
- 雪花算法的时间戳部分是 41 位,能够支持较长的时间范围(大约 69 年),并且粒度是毫秒级的,这样能够确保长时间运行而不会产生重复 ID。
- 你的算法使用的是
System.currentTimeMillis(),其粒度也是毫秒级别,但它没有雪花算法那样精确的控制和范围。在同一毫秒内,由于你只使用了简单的序列号递增,可能会出现序列号冲突的情况,尤其是在高并发场景下。
-
重置机制:
- 雪花算法中的序列号是在每毫秒内递增,直到最大值为止。如果超出最大值,系统会等待下一毫秒,确保序列号不会重复。雪花算法本身设计时避免了序列号重置的问题。
- 你的代码在
count达到 9000 时会 重置为 1000,这就可能导致 在高并发场景下,多个线程生成相同的 ID,从而引发 ID 冲突。
-
ID 结构:
- 雪花算法使用了 64 位的 ID,包含了符号位、时间戳、机器 ID、数据中心 ID 和序列号,结构清晰且易于扩展。
- 你的代码生成的 ID 并没有采用类似雪花算法的结构化设计,而是直接拼接了
IP 地址、时间戳和序列号,这样的生成方式不够灵活,无法很好地适应分布式环境。
总结
我的代码确实借鉴了雪花算法的一些思想,尤其是时间戳和序列号递增的部分,但它并没有实现雪花算法的完整机制(如机器 ID、数据中心 ID、分布式保证等)。因此,它只能算是对雪花算法的一种简单模仿,适用于单机场景下的 ID 生成,但在 分布式系统 或 高并发 环境中,存在生成重复 ID 的风险。