分布式ID中的SnowFlake

技术28-08-2023

雪花算法这一在分布式架构中很常见的玩意,但一般也不需要怎么去深入了解,一方面一般个人项目用不到分布式之类的大型架构,另一方面,就算要用到,市面上很多ID生成器也帮我们完成了这项工作。不过出于学习,本文也简单来介绍一下它的实现和原理。

分布式ID的特点

对此的常见解决方案有UUID、SnowFlake、UidGenerator、Leaf。
我们今天主角便是SnowFlake。

起源

一般的雪花大约由10^19个水分子组成。在雪花形成过程中,会形成不同的结构分支,所以说大自然中不存在两片完全一样的雪花,每一片雪花都拥有自己漂亮独特的形状。
雪花算法表示生成的id如雪花般独一无二。snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。
其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。

具体介绍

他的原理分四部分:

2^41/1000*60*60*24*365 = 69

image.png

场景应用举例

我们通过对过滤器实现对所有请求自动生成雪花ID,从而方便线上定位问题。
因为雪花ID的特性,让我们可以追溯问题,定位错误。

如下,我们可以通过实现拦截器,生成雪花ID,附加到日志,这样后续定位问题将会非常方便。


/**
 * 请求日志过滤器,用于记录所有用户请求信息
 */
@Slf4j
@Component
public class RequestLogFilter extends OncePerRequestFilter {

    @Resource
    SnowflakeIdGenerator generator;

    private final Set<String> ignores = Set.of("/swagger-ui", "/v3/api-docs");

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if(this.isIgnoreUrl(request.getServletPath())) {
            filterChain.doFilter(request, response);
        } else {
            long startTime = System.currentTimeMillis();
            this.logRequestStart(request);
            ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
            filterChain.doFilter(request, wrapper);
            this.logRequestEnd(wrapper, startTime);
            wrapper.copyBodyToResponse();
        }
    }

    /**
     * 判定当前请求url是否不需要日志打印
     * @param url 路径
     * @return 是否忽略
     */
    private boolean isIgnoreUrl(String url){
        for (String ignore : ignores) {
            if(url.startsWith(ignore)) return true;
        }
        return false;
    }

    /**
     * 请求结束时的日志打印,包含处理耗时以及响应结果
     * @param wrapper 用于读取响应结果的包装类
     * @param startTime 起始时间
     */
    public void logRequestEnd(ContentCachingResponseWrapper wrapper, long startTime){
        long time = System.currentTimeMillis() - startTime;
        int status = wrapper.getStatus();
        String content = status != 200 ?
                status + " 错误" : new String(wrapper.getContentAsByteArray());
        log.info("请求处理耗时: {}ms | 响应结果: {}", time, content);
    }

    /**
     * 请求开始时的日志打印,包含请求全部信息,以及对应用户角色
     * @param request 请求
     */
    public void logRequestStart(HttpServletRequest request){
        long reqId = generator.nextId();
        MDC.put("reqId", String.valueOf(reqId));
        JSONObject object = new JSONObject();
        request.getParameterMap().forEach((k, v) -> object.put(k, v.length > 0 ? v[0] : null));
        Object id = request.getAttribute(Const.ATTR_USER_ID);
        if(id != null) {
            User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            log.info("请求URL: \"{}\" ({}) | 远程IP地址: {} │ 身份: {} (UID: {}) | 角色: {} | 请求参数列表: {}",
                    request.getServletPath(), request.getMethod(), request.getRemoteAddr(),
                    user.getUsername(), id, user.getAuthorities(), object);
        } else {
            log.info("请求URL: \"{}\" ({}) | 远程IP地址: {} │ 身份: 未验证 | 请求参数列表: {}",
                    request.getServletPath(), request.getMethod(), request.getRemoteAddr(), object);
        }
    }
}

雪花ID生成器实现

讲完雪花ID的应用,我们就来讲讲它的实现。
显然,它的原理非常简单,我们用代码自己实现一个雪花ID生成器。

package com.example.utils;

import org.springframework.stereotype.Component;

/**
 * 雪花算法ID生成器
 */
//该雪花ID生成器可以生成唯一的、有序的分布式ID,其中包含了时间戳、数据中心ID、工作节点ID和序列号等信息。
@Component
public class SnowflakeIdGenerator {
    private static final long START_TIMESTAMP = 1691087910202L; //起始时间戳,用于计算时间戳部分。

    private static final long DATA_CENTER_ID_BITS = 5L; //数据中心ID的位数。
    private static final long WORKER_ID_BITS = 5L; //工作节点ID的位数。
    private static final long SEQUENCE_BITS = 12L; //序列号的位数。

    private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS); //数据中心ID的最大值。
    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); //工作节点ID的最大值。
    private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); //序列号的最大值。

    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; //工作节点ID的位移量。
    private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; //数据中心ID的位移量。
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS; //时间戳的位移量。

    //数据中心ID、工作节点ID、上一次生成ID的时间戳和序列号等属性。
    private final long dataCenterId;
    private final long workerId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(){
        this(1, 1);
    }

    //构造函数中对数据中心ID和工作节点ID进行了合法性检查,并将它们赋值给对应的属性。
    private SnowflakeIdGenerator(long dataCenterId, long workerId) {
        if (dataCenterId > MAX_DATA_CENTER_ID || dataCenterId < 0) {
            throw new IllegalArgumentException("Data center ID can't be greater than " + MAX_DATA_CENTER_ID + " or less than 0");
        }
        if (workerId > MAX_WORKER_ID || workerId < 0) {
            throw new IllegalArgumentException("Worker ID can't be greater than " + MAX_WORKER_ID + " or less than 0");
        }
        this.dataCenterId = dataCenterId;
        this.workerId = workerId;
    }

    /**
     * 生成一个新的雪花算法ID加锁
     * @return 雪花ID
     */
    public synchronized long nextId() {
        long timestamp = getCurrentTimestamp();
        //首先获取当前的时间戳。如果时间戳小于上一次生成ID的时间戳,抛出异常,因为时间戳不应该后退。
        if (timestamp < lastTimestamp) {
            throw new IllegalStateException("Clock moved backwards. Refusing to generate ID.");
        }
        //如果时间戳与上一次生成ID的时间戳相同,递增序列号。如果序列号达到最大值,说明在同一毫秒内已经生成了足够多的ID,需要等待下一毫秒。
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {
                timestamp = getNextTimestamp(lastTimestamp);
            }
        } else { //如果时间戳与上一次生成ID的时间戳不同,重置序列号为0。
            sequence = 0L;
        }
        //更新上一次生成ID的时间戳为当前时间戳。
        lastTimestamp = timestamp;
        //根据时间戳、数据中心ID、工作节点ID和序列号,通过位运算生成最终的雪花ID。
        return ((timestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT) |
                (dataCenterId << DATA_CENTER_ID_SHIFT) |
                (workerId << WORKER_ID_SHIFT) |
                sequence;
    }

    //getCurrentTimestamp 方法用于获取当前的时间戳。
    private long getCurrentTimestamp() {
        return System.currentTimeMillis();
    }

    //getNextTimestamp 方法用于获取下一个时间戳,如果当前时间戳小于等于上一次生成ID的时间戳,就一直循环获取,直到获得一个更大的时间戳。
    private long getNextTimestamp(long lastTimestamp) {
        long timestamp = getCurrentTimestamp();
        while (timestamp <= lastTimestamp) {
            timestamp = getCurrentTimestamp();
        }
        return timestamp;
    }
}

代码注释都写得比较完善,其实就是把我们的原理和算法,用代码写出来。不过我们关注一下代码里面的一个细节。

为何要加锁

    /**
     * 生成一个新的雪花算法ID加锁
     * @return 雪花ID
     */
    public synchronized long nextId() {
        long timestamp = getCurrentTimestamp();
        //首先获取当前的时间戳。如果时间戳小于上一次生成ID的时间戳,抛出异常,因为时间戳不应该后退。
        if (timestamp < lastTimestamp) {
            throw new IllegalStateException("Clock moved backwards. Refusing to generate ID.");
        }
        //如果时间戳与上一次生成ID的时间戳相同,递增序列号。如果序列号达到最大值,说明在同一毫秒内已经生成了足够多的ID,需要等待下一毫秒。
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {
                timestamp = getNextTimestamp(lastTimestamp);
            }
        } else { //如果时间戳与上一次生成ID的时间戳不同,重置序列号为0。
            sequence = 0L;
        }
        //更新上一次生成ID的时间戳为当前时间戳。
        lastTimestamp = timestamp;
        //根据时间戳、数据中心ID、工作节点ID和序列号,通过位运算生成最终的雪花ID。
        return ((timestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT) |
                (dataCenterId << DATA_CENTER_ID_SHIFT) |
                (workerId << WORKER_ID_SHIFT) |
                sequence;
    }

在生成雪花算法ID时加锁的目的是为了确保线程安全性,避免并发情况下出现冲突或不一致的问题。
雪花算法生成ID的过程中,涉及到共享的状态变量,比如上一次生成ID的时间戳和序列号。如果多个线程同时调用nextId()方法,没有加锁的情况下,可能会导致以下问题:

通过在nextId()方法上添加synchronized关键字,实现了对方法级别的互斥访问,即同一时间只有一个线程能够执行该方法,从而保证了生成的雪花ID的唯一性和正确性。

一些细节讨论

算法的核心思想很明显,在实际的应用过程中,我们可以根据项目的实际情况,进行适当的修改。

调整比特位分布

很多公司在使用雪花算法时会根据自己的业务需求进行二次改造。
举个例子,假设你的公司的业务评估只需要运行10年,而不是默认的69年。然而,你的集群节点数量可能会超过1024个。在这种情况下,你可以对雪花算法进行调整。你可以将时间戳位数调整为39位,并将worker ID调整为12位。此外,你还可以根据业务需求或者机房划分等因素对worker ID进行拆分,比如根据业务拆分或者根据机房拆分等。。
通过调整时间戳和worker ID的位数,你可以根据具体需求来平衡雪花算法的时间范围和节点数量。这样可以更好地适应你的业务场景,并确保生成的ID满足要求。

workerid生成

方案有很多。比如可以通过jvm启动参数的方式传过来,应用启动的时候获取一个启动参数,保证每个节点启动的时候传入不同的启动参数即可。
启动参数一般是通过-D选项传入,示例:

-Dname=value
System.getProperty("name");

获取,或者通过 @value注解也能拿到。

容器化部署的上述缺陷

现在很多部署都是基于k8s的容器化部署,这种方案往往是基于同一个yaml文件一键部署多个容器。所以没法通过上面的方法每个节点传入不同的启动参数。这个问题可以通过在代码中根据一些规则计算workerid,比如根据节点的IP地址等。下面给出一个方案:

private static long makeWorkerId() {
        try {
            String hostAddress = Inet4Address.getLocalHost().getHostAddress();
            int[] ips = StringUtils.toCodePoints(hostAddress);
            int sums = 0;
            for (int ip: ips) {
                sums += ip;
            }
            return (sums % 1024);
        } catch (UnknownHostException e) {
            return RandomUtils.nextLong(0, 1024);
        }
    }

这里其实是获取了节点的IP地址,然后把ip地址中的每个字节的ascii码值相加然后对最大值取模。当然这种方法是有可能产生重复的id的。

时间回拨问题

在获取时间的时候,可能会出现时间回拨的问题,什么是时间回拨问题呢?就是服务器上的时间突然倒退到之前的时间。

  1. 人为原因,把系统环境的时间改了。
  2. 有时候不同的机器上需要同步时间,可能不同机器之间存在误差,那么可能会出现时间回拨问题。

对此百度的解决方案如下:(其他大厂也有自己的方案)

总结

没有最好的设计方案,只有合适和不合适的方案。

雪花算法依赖于时间的一致性,如果发生时间回拨,就可能导致生成的ID出现问题。为了解决这个问题,通常会采用拓展位的方式来增加时间戳的位数。通过增加时间戳位数,可以延长算法可用的时间范围。例如,将时间戳位数设置为42位可以使用139年的时间范围。然而,在实际应用中,很多公司在开始阶段更关注的是生存和发展,因此通常会选择使用较短的时间戳位数。
需要注意的是,雪花算法并不是一种完美的解决方案,它也有一些缺点。例如,在单机环境下生成的ID是递增的,但在多台机器上生成的ID只是大致呈递增趋势,并不能严格保证递增。这是因为多台机器之间的时钟可能存在差异,导致生成的ID不是严格按照时间顺序递增。然而,对于大多数应用场景而言,这种大致的递增趋势已经足够满足需求。
总而言之,雪花算法是一种常用的ID生成算法,通过时间戳和序列号的组合生成唯一的ID。通过拓展位可以增加时间戳的位数,延长算法可用的时间范围。然而,在实际应用中需要权衡时间戳位数和系统需求,同时也要注意雪花算法的局限性。

Author's photo

HuanXin-Chen

A tech enthusiast and avid sharer, this dream chaser firmly believes that great things will happen!

See other articles: