这样使用雪花算法被客户喷了!

1,815 阅读8分钟

大家好,我是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;  // 返回生成的序列号
        }
    }
}

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

  1. 序列号重置机制问题

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

    • 具体场景如下:假设当前 count 为 9000,多个线程几乎同时执行 getNextId() 方法,它们会都看到 count 为 9000,然后根据条件生成 next = 1000,然后通过 compareAndSet 更新 count。由于 compareAndSet 依赖于比较当前值 currentcount,当多个线程几乎同时读取到 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 唯一性,还能够处理高并发和分布式场景,避免重复序列号的问题。

啥是雪花算法?

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

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

  1. 时间戳:你代码中通过 System.currentTimeMillis() 获取当前时间戳,作为生成 ID 的一部分,这与雪花算法中的时间戳部分是相似的。
  2. 序列号递增:每次生成一个 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 的风险。