关于分布式唯一ID,snowflake的一些思考及改进(完美解决时钟回拨问题)

1.写唯一ID生成器的原由

在阅读工程源码的时候,发现有一个工具职责生成一个消息ID,方便进行全链路的查询,实现方式特别简单,核心源码不过两行,根据时间戳以及随机数生成一个ID,这种算法ID在分布式系统中重复的风险就很明显了。本来以为只是日志打印功能,根据于此在不同系统调用间关联业务日志而已,不过后来发现此ID需要入库,看到这里就觉得有些风险了,于是就想着怎么改造它。

String timeString = String.valueOf(System.currentTimeMillis());
return Long.parseLong(timeString.substring(timeString.length() - 8, timeString.length()))
                * RandomUtils.nextInt(1, 9);

2. Twitter snowflake

既然是分布式唯一ID,自然而然想到了Twitter的snowflake算法,在以前的做的部分业务中也用到过它来生成数据库主键ID,不过当时仅限于使用,以及将64 bit的long数字拆分成几个部分,以保证唯一,对具体实现没有深入研究。正好借着机会深入下。

snowflake拆分long的示意图

该ID生成方式,用41位时间戳保存当前时间的毫秒数(69年后一个轮回),十位机器码,最多可以供一项业务1024台实例,每个毫秒数,12位序列自增,每秒理论上单机环境可生产409.6万个ID,分享一个官方github的scala实现,twitter-archive/snowflake

3.关于snowflake的一些思考

1.监视器锁

此锁的目的是为了保证在多线程的情况下,只有一个线程进入方法体生成ID,保证并发情况下生成ID的唯一性,如果在竞争激烈情况下,自旋锁+ CAS原子变量的方式或许是更为合理的选择,可以达到优化部分性能的目的。

源码中的监视器锁

2.时间回退问题

时间校准,以及其他因素,可能导致服务器时间回退(时间向前快进不会有问题),如果恰巧回退前生成过一些ID,而时间回退后,生成的ID就有可能重复。官方对于此并没有给出解决方案,而是简单的抛错处理,这样会造成在时间被追回之前的这段时间服务不可用,显然我无法接受这一点。

官方的抛错处理

而对于此的思考是,既然snowflake理论情况下单机可实现每秒409.6万个ID的生成上限,实际上能想得到的业务都不太可能产生如此高的并发,那么就会存在在过去的一段时间内,有大量的时间戳“被浪费”,达不到该上限,可能在某一毫秒内只生成几个ID,如果发生了时间回退,这些“被浪费”的资源是不是就能利用起来,而不是抛错。


被浪费的时间戳

如果在内存中建立一个数组,这个数组设定固定长度,比如说200,这些数组中存储上一次该位置对应的毫秒数的messageId,那么就能在时间回退到追回时间这段时间内,再至多提供819200((2^12) *200)个messageId,如果发生时间回退,就只用在上一次messageId进行+1操作,直到系统时间被追回(此段结合后续源码进行解释)。

4.改进版的snowflake

1.机器码生成器 MachineIdService设计及其实现:

public interface MachineIdService {
    /**
     * 生成MachineId的方法
     *
     * @return machineId 机器码
     * @throws  MessageIdException 获取机器码可能因为外部因素失败
     */
    Long getMachineId() throws MessageIdException;
}

实现该接口确保一个集群中,每台实例生成不同的machineID,并且MachineID 不能超过(2^10) 1023,具体实现方式,可使用MySQL数据库,文件描述映射,Redis自增等方式,这里我使用了Redis自增的方式(所以在需要用到该ID生成器的地方需要依赖Redis),具体实现方式如下:

public class RedisMachineIdServiceImpl implements MachineIdService {

    private static final String MAX_ID = "MAX_ID";
    private static final String IP_MACHINE_ID_MAPPING = "IP_MACHINE_ID_MAPPING";

    private RedisTemplate<String, String> redisTemplate;


    private String redisKeyPrefix;

    //设置RedisTemplate实例
    public void setRedisTemplate(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // 设置redisKey前缀,如果多个业务使用同一个Redis集群,使用不同的Redis前缀进行区分
    public void setRedisKeyPrefix(String redisKeyPrefix) {
        this.redisKeyPrefix = redisKeyPrefix;
    }

    @Override
    public Long getMachineId() throws MessageIdException {
        String host;
        try {
            //获取本机IP地址
            host = InetAddress.getLocalHost().getHostAddress();
        } catch (UnknownHostException e) {
            throw new MessageIdException("Can not get the host!", e);
        }
        if (redisTemplate == null) {
            throw new MessageIdException("Can not get the redisTemplate instance!");
        }
        if (redisKeyPrefix == null) {
            throw new MessageIdException("The redis key prefix is null,please set redis key prefix first!");
        }
        HashOperations<String, String, Long> hashOperations = redisTemplate.opsForHash();
        //通过IP地址在Redis中的映射,找到本机的MachineId
        Long result = hashOperations.get(redisKeyPrefix + IP_MACHINE_ID_MAPPING, host);
        if (result != null) {
            return result;
        }
        //如果没有找到,说明需要对该实例进行新增MachineId,使用Redis的自增函数,生成一个新的MachineId
        Long incrementResult = redisTemplate.opsForValue().increment(redisKeyPrefix + MAX_ID, 1L);
        if (incrementResult == null) {
            throw new MessageIdException("Get the machine id failed,please check the redis environment!");
        }
        //将生成的MachineId放入Redis中,方便下次查找映射
        hashOperations.put(redisKeyPrefix + IP_MACHINE_ID_MAPPING, host, incrementResult);
        return incrementResult;
    }
}

2.MessageIdService设计以及实现

public interface MessageIdService {

    /**
     * 生成一个保证全局唯一的MessageId
     *
     * @return messageId
     */
    long genMessageId();

    /**
     * 初始化方法
     *
     * @throws MessageIdException
     */
    void init() throws MessageIdException;
}
public class MessageIdServiceImpl implements MessageIdService {

    private static final Logger LOGGER = LoggerFactory.getLogger(MessageIdServiceImpl.class);
    //最大的MachineId,1024个
    private static final long MAX_MACHINE_ID = 1023L;
    //AtomicLongArray 环的大小,可保存200毫秒内,每个毫秒数上一次的MessageId,时间回退的时候依赖与此
    private static final int CAPACITY = 200;
    // 时间戳在messageId中左移的位数
    private static final int TIMESTAMP_SHIFT_COUNT = 22;
    // 机器码在messageId中左移的位数
    private static final int MACHINE_ID_SHIFT_COUNT = 12;
    // 序列号的掩码 2^12 4096
    private static final long SEQUENCE_MASK = 4095L;

    //messageId ,开始的时间戳,start the world,世界初始之日
    private static long START_THE_WORLD_MILLIS;
    //机器码变量
    private long machineId;
    // messageId环,解决时间回退的关键,亦可在多线程情况下减少毫秒数切换的竞争
    private AtomicLongArray messageIdCycle = new AtomicLongArray(CAPACITY);
    //生成MachineIds的实例
    private MachineIdService machineIdService;

    static {
        try {
            //使用一个固定的时间作为start the world的初始值
            START_THE_WORLD_MILLIS = SimpleDateFormat.getDateTimeInstance().parse("2018-09-13 00:00:00").getTime();
        } catch (ParseException e) {
            throw new RuntimeException("init start the world millis failed", e);
        }
    }

    public void setMachineIdService(MachineIdService machineIdService) {
        this.machineIdService = machineIdService;
    }

    /**
     * init方法中通过machineIdService 获取本机的machineId
     * @throws MessageIdException
     */
    @Override
    public void init() throws MessageIdException {
        if (machineId == 0L) {
            machineId = machineIdService.getMachineId();
        }
        //获取的machineId 不能超过最大值
        if (machineId <= 0L || machineId > MAX_MACHINE_ID) {
            throw new MessageIdException("the machine id is out of range,it must between 1 and 1023");
        }
    }
    /**
     * 核心实现的代码
     */
    @Override
    public long genMessageId() {
        do {
            // 获取当前时间戳,此时间戳是当前时间减去start the world的毫秒数
            long timestamp = System.currentTimeMillis() - START_THE_WORLD_MILLIS;
            // 获取当前时间在messageIdCycle 中的下标,用于获取环中上一个MessageId
            int index = (int)(timestamp % CAPACITY);
            long messageIdInCycle = messageIdCycle.get(index);
            //通过在messageIdCycle 获取到的messageIdInCycle,计算上一个MessageId的时间戳
            long timestampInCycle = messageIdInCycle >> TIMESTAMP_SHIFT_COUNT;
            // 如果timestampInCycle 并没有设置时间戳,或时间戳小于当前时间,认为需要设置新的时间戳
            if (messageIdInCycle == 0 || timestampInCycle < timestamp) {
                long messageId = timestamp << TIMESTAMP_SHIFT_COUNT | machineId << MACHINE_ID_SHIFT_COUNT;
                // 使用CAS的方式保证在该条件下,messageId 不被重复
                if (messageIdCycle.compareAndSet(index, messageIdInCycle, messageId)) {
                    return messageId;
                }
                LOGGER.debug("messageId cycle CAS1 failed");
            }
            // 如果当前时间戳与messageIdCycle的时间戳相等,使用环中的序列号+1的方式,生成新的序列号
            // 如果发生了时间回退的情况,(即timestampInCycle > timestamp的情况)那么不能也更新messageIdCycle 的时间戳,使用Cycle中MessageId+1
            if (timestampInCycle >= timestamp) {
                long sequence = messageIdInCycle & SEQUENCE_MASK;
                if (sequence >= SEQUENCE_MASK) {
                    LOGGER.debug("over sequence mask :{}", sequence);
                    continue;
                }
                long messageId = messageIdInCycle + 1L;
                // 使用CAS的方式保证在该条件下,messageId 不被重复
                if (messageIdCycle.compareAndSet(index, messageIdInCycle, messageId)) {
                    return messageId;
                }
                LOGGER.debug("messageId cycle CAS2 failed");
            }
            // 整个生成过程中,采用的spinLock
        } while (true);
    }

}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容