分布式全局唯一id——Snowflake算法

1 问题引出

     MySQL自增属性AUTO_INCREMENT可以保证单表id唯一,但是单表可能成为性能瓶颈,且扩展性较差。

2 几种生成方法比较

2.1 UUID

性能差、太长、占用空间大、不能反解析出信息、无序性导致B+树索引在写的时候有过多随机写操作。

2.2 基于DB表实现

分库分表,每个表从不同的数字开始自增、使用相同的步长。

假设分3张表:

初始值步长生成的id
表103

0、3、6、9、...

表213

1、4、7、10、...

表323

2、5、8、11、...

缺点:把可提供id的服务节点数(表的个数)固定住了

3 Snowflake算法

全局唯一ID结构:

总长度64位,从低到高位依次划分为:

1)0~11位(共12bit)表示序列号,最大值2^12=4096,意味着在一个时间单位(例如1毫秒)内最多可以生成4096个ID;

2)12~21位(共10bit)表示机器id,最大值2^10=1024,意味着支持的最大集群规模为1024台机器。

3)22~62位(共41bit)表示时间戳,最大值2^41=2 199 023 255 552(单位:ms),意味着在这么多时间内我们可以肆意妄为地制造ID。是多久呢?一年按365天算,2^41 / 1000 / 3600 / 24 / 365 ≈ 69.7(年)。系统运行之前我们设置一个起始时间,例如“2019-2-21 00:00:00”,然后从此时开始算,差不多能用到2088年。

4) 63位(共1bit)最高位是符号位,不使用,设置为固定值“0”。

3.1 生成分布式全局唯一ID

/**
 * 全局唯一id生成器
 * global unique id generator
 */
public interface IdGenerator<T> {
    T generateId();
}
/**
 * 基于workId的全局唯一Id生成器
 */
public abstract class BaseWorkIdIdGenerator<T> implements IdGenerator<T> {

    @Resource(name = "dbWorkIdResolver")
    private WorkIdResolver workIdResolver;

    protected Long getWorkId() {
        return workIdResolver.resolveWorkId();
    }

}
import org.springframework.stereotype.Service;

import java.util.Calendar;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 *
 * <p>
 * 产生long类型的唯一id,基于Twitter的snow flake算法实现,单台机器每毫秒支持2^12=4096个id
 *
 * <p>
 * 第1位为0,符号位。第2-42位表示毫秒数,共41位,当前时间毫秒-2018年02月21日的毫秒数。第43-52位表示workId,即机器id,共10位,能支持1024台机器。第53-64位表示序列号,共12位
 */
@Service("commonIdGenerator")
public class CommonIdGenerator extends BaseWorkIdIdGenerator<Long> {

    public static final long START_TIME_MILLIS;

    private static final long SEQUENCE_BITS = 12L; // 12位序列号

    private static final long WORKER_ID_BITS = 10L; // 10位workId号

    private static final long SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1;

    private static final long WORKER_ID_LEFT_SHIFT_BITS = SEQUENCE_BITS;

    private static final long TIMESTAMP_LEFT_SHIFT_BITS = WORKER_ID_LEFT_SHIFT_BITS + WORKER_ID_BITS;

    private long sequence;

    private long lastTime;

    private Lock lock = new ReentrantLock();

    static {
        Calendar calendar = Calendar.getInstance();
        calendar.set(2018, Calendar.FEBRUARY, 21);
        calendar.set(Calendar.HOUR_OF_DAY, 0);
        calendar.set(Calendar.MINUTE, 0);
        calendar.set(Calendar.SECOND, 0);
        calendar.set(Calendar.MILLISECOND, 0);
        START_TIME_MILLIS = calendar.getTimeInMillis(); // 从2019.02.21开始
    }

    @Override
    public Long generateId() {
        // time
        long currentTime;
        // seq
        long seq;

        // 此处加锁也可以使用synchronized关键字,用来在多线程并发执行时保护lastTime、sequence这两个变量
        lock.lock();
        try {
            currentTime = System.currentTimeMillis();

            //时钟被回拨,直接拒绝服务
            if (currentTime < lastTime) {
                throw new IllegalStateException("Clock go back, refused generator guid service.");
            }

            if (currentTime == lastTime) {
                //如果1ms内单台机器的4096个序号用完了,等待下一毫秒
                if (0L == (sequence = ++sequence & SEQUENCE_MASK)) {
                    lastTime = waitUntilNextMillis(currentTime);
                }
            } else {
                lastTime = currentTime;
                sequence = 0;
            }
            currentTime = lastTime;
            seq = sequence;
        }finally {
            lock.unlock();
        }

        return ((currentTime - START_TIME_MILLIS) << TIMESTAMP_LEFT_SHIFT_BITS)
                | (getWorkId() << WORKER_ID_LEFT_SHIFT_BITS) | seq;
    }

    private long waitUntilNextMillis(final long fromMills) {
        long nextMills = System.currentTimeMillis();
        while (nextMills <= fromMills) {
            nextMills = System.currentTimeMillis();
        }
        return nextMills;
    }
}

代码说明:

1.到了java自带的锁,而不是分布式锁,因为只要保证在单机上线程安全就行了,不同机器由于使用了机器id来区分,所以不同机器不会生成相同的全局id。

2.时钟回拨问题:当前时间小于上一次发号结束时间,可能是时钟回拨了,这里直接抛异常拒绝服务,不然可能会产生重复id,因为回到了历史时间点。也可以设置一个等待,等到时钟时间大于上次发号时间再继续执行,不过这中间会等待多久不可确定,取决于回拨了多久。(注意下,生成ID的算法强依赖于机器时间,要是先回拨时间、再重启下应用,就没法判断是否回拨了,这样就有可能生成重复id,所以说禁止任何情况下回拨时钟,是基本要求。)

3.如何判断在此1ms内4096个号已经发完了:

0L == (sequence = ++sequence & SEQUENCE_MASK)

 SEENCE_MASK值是2^12 - 1 = 4095,转换成二进制:111 111 111 111

最初,currentTime取当前时间,long类型的lastTime值为0,if (currentTime == lastTime)这个条件判断结果是false,所以会走lastTime = currentTime;sequence = 0;将lastTime赋值为当前时间、sequence设为0并返回。

从下次开始,lastTime有值了,就会走if分支,然后里面会校验4096个号码有没有发完(1~4095,0已经发过了)。

(++sequence) 从1开始,1 & SEQUENCE_MASK=1

  000 000 000 001

& 111 111 111 111

---------------------------

000 000 000 001 (1)

一直到4095:

  111 111 111 111

& 111 111 111 111

---------------------------

011 111 111 111 (4095)

到4096的时候又会回到0:

  1 000 000 000 000

&   111 111 111 111

---------------------------

  0 000 000 000 000 (0)

如此一轮下来正好是4096个号(0-4095)。

4.最后,把二进制ID转成十进制数字返回:((currentTime - START_TIME_MILLIS) << TIMESTAMP_LEFT_SHIFT_BITS)| (getWorkId() << WORKER_ID_LEFT_SHIFT_BITS) | seq

假设指定初始时间START_TIME_MILLIS为2019-02-21 00:00:00,转换成毫秒就是:1550678400000,假设现在currentTime是2019-02-22 13:34:23,则对应的毫秒是:1550813663000,减去初始值:1550813663000-1550678400000=135263000,转换成二进制:1000000011111111001100011000,然后左移12+10=22位,得到时间戳:

1000000011111111001100011000 0 000 000 000 000 000 000 000

getWorkId() 假设当前机器编号是1,转成二进制:1,再左移12位:

0 000 000 001 000 000 000 000

seq 假设当前生成的序号是1024,转成二进制:

010 000 000 000

最后对时间戳、机器编号、序号三者进行逻辑“或”运算:

  1000000011111111001100011000 0 000 000 000 000 000 000 000

|                              0 000 000 001 000 000 000 000

|                                            010 000 000 000

----------------------------------------------------------

  1000000011111111001100011000 0 000 000 001 010 000 000 000

(转成十进制:567334141957120)

如此,便得到了全局唯一ID:567334141957120。

3.2 获取机器ID

(数据库可参考另一篇博客:Mybatis逆向工程_一次编写 到处调试的博客-CSDN博客

表设计:

CREATE TABLE `key_value_data` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `data_key` varchar(64) NOT NULL COMMENT 'key',
  `data_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '类型',
  `data_value` mediumtext,
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0失效 1有效',
  `db_create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `db_update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_data_key_data_type` (`data_key`,`data_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='K-V表'
package service.guid;

/**
 *
 * 本机workId持有者
 */
public interface WorkIdResolver {

    long resolveWorkId();
}
@Service("dbWorkIdResolver")
public class DbWorkIdResolver implements WorkIdResolver {
    private volatile Long workId;

    private static final String KEY = "GUID_WORK_ID";

    @Autowired
    private KeyValuePOMapperExt keyValuePOMapper;

    @Resource
    private WorkIdService workIdService;

    /**
     * 返回0 ~ 1023之间的workId,最大支持1024台机器
     *
     * @return
     */
    @Override
    public long resolveWorkId() {
        return workId;
    }

    @PostConstruct
    private void init() {
        workId = workIdService.generateWorkId();
    }
}
@Service
public class WorkIdService {
    private static final String KEY = "GUID_WORK_ID";

    private static final long WORKER_ID_MAX_VALUE = 1L << 10;

    /**
     * 默认最近使用的workId是0
     */
    private static final Long DEFAULT_LAST_WORK_ID = 0L;

    @Autowired
    private KeyValuePOMapperExt keyValuePOMapper;

    @Transactional
    public Long generateWorkId() {
        String ipAddr = NetwokUtils.getLocalhost();
        KeyValuePOExample keyValuePOExample = new KeyValuePOExample(); keyValuePOExample.createCriteria().andKeyEqualTo(KEY).andBizTypeEqualTo(KeyValueBizTypeEnum.DEFAULT);
        List<KeyValuePO> keyValuePOList = keyValuePOMapper.selectByExample(keyValuePOExample);

        if (CollectionUtils.isEmpty(keyValuePOList)) {
            // 数据库表里没数据,直接插入
            WorkIdData workIdData = new WorkIdData();
            Map<String, Long> workIdsMap = new HashMap<>(1);
            workIdsMap.put(ipAddr, DEFAULT_LAST_WORK_ID);
            workIdData.setWorkIdsMap(workIdsMap);
            workIdData.setLastWorkId(DEFAULT_LAST_WORK_ID);

            KeyValuePO insertPO = new KeyValuePO();
            insertPO.setKey(KEY);
            insertPO.setBizType(KeyValueBizTypeEnum.DEFAULT);
            insertPO.setValue(JSON.toJSONString(workIdData));
            keyValuePOMapper.insertSelective(insertPO);
            return DEFAULT_LAST_WORK_ID;
        }

        // 数据库表里有数据,先取出来,然后看看有没有保存当前IP地址
        KeyValuePO keyValuePO = keyValuePOList.get(0);
        WorkIdData workIdData =  JSON.parseObject(keyValuePO.getValue(), WorkIdData.class);
        Map<String, Long> workIdsMap = workIdData.getWorkIdsMap();

        if (workIdsMap != null && workIdsMap.containsKey(ipAddr)) {
            // 已经保存了当前ip,直接返回对应的workId
            return workIdsMap.get(ipAddr);
        }

        // 没有保存当前ip,把ip插进去
        if (workIdsMap == null) {
            workIdsMap = new HashMap<>();
        }
        long newLastId = workIdData.getLastWorkId() + 1;
        checkWorkerId(workIdData.getLastWorkId());
        workIdData.setLastWorkId(newLastId);
        workIdsMap.put(ipAddr, newLastId);
        KeyValuePO updatePO = new KeyValuePO();
        updatePO.setValue(JSON.toJSONString(workIdData));
        keyValuePOExample.getOredCriteria().get(0).andDbUpdateTimeEqualTo(keyValuePO.getDbUpdateTime());
        keyValuePOMapper.updateByExampleSelective(updatePO, keyValuePOExample);
        return newLastId;
    }

    /**
     * 校验机器编码是否超过1024
     */
    private void checkWorkerId(long workId) {
        if (workId >= 0L && workId < WORKER_ID_MAX_VALUE) {
            return;
        }

        throw new RuntimeException("workerId is overflow, attempt " + workId + " but max is " + WORKER_ID_MAX_VALUE);
    }
}

4 反解析全局唯一ID

把生成的GUID解析出来:

先定义下GUID结构:

/**
 * @Description 全局唯一id数据结构
 * @Author lilong
 * @Date 2019-02-21 14:44
 */
public class GuidBO {
    /**
     * 生成id的时间戳
     */
    private Timestamp lockTime;

    /**
     * 机器id
     */
    private Long workId;

    /**
     * 机器ip地址
     */
    private String workIpAddr;

    /**
     * 生成的序列号
     */
    private Long sequence;

   // getter/setter
}

反解析GUID:

@Service("commonIdGenerator")
public class CommonIdGenerator extends BaseWorkIdIdGenerator<Long> {
    public static final long START_TIME_MILLIS;
    private static final long SEQUENCE_BITS = 12L; // 12位序列号
    private static final long WORKER_ID_BITS = 10L; // 10位workId号
    private static final long SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1;
    private static final long WORK_ID_MASK = (1 << WORKER_ID_BITS) - 1; // 10位workId掩码
    private static final long WORKER_ID_LEFT_SHIFT_BITS = SEQUENCE_BITS;
    private static final long TIMESTAMP_LEFT_SHIFT_BITS = WORKER_ID_LEFT_SHIFT_BITS + WORKER_ID_BITS;

    static {
        Calendar calendar = Calendar.getInstance();
        calendar.set(2017, Calendar.APRIL, 1);
        calendar.set(Calendar.HOUR_OF_DAY, 0);
        calendar.set(Calendar.MINUTE, 0);
        calendar.set(Calendar.SECOND, 0);
        calendar.set(Calendar.MILLISECOND, 0);
        START_TIME_MILLIS = calendar.getTimeInMillis(); // 从2017.04.01开始
    }

    @Override
    public Long generateId() {
        ...
    }

    @Override
    public GuidBO parseGUID(Long id) {
        GuidBO guidBO = new GuidBO();

        //1.时间戳
        long generateTimeLong = (id >> TIMESTAMP_LEFT_SHIFT_BITS) + START_TIME_MILLIS;
        guidBO.setLockTime(new Timestamp(generateTimeLong));

        //2.机器号
        Long workId = (id >> SEQUENCE_BITS) & WORK_ID_MASK;
        guidBO.setWorkId(workId);

        //3.机器ip
        guidBO.setWorkIpAddr(parseWorkerIp(workId));

        //4.序列号
        guidBO.setSequence(id & SEQUENCE_MASK);

        return guidBO;
    }
}

5 测试

public class CommonIdGeneratorTest extends BaseTest {
    @Resource
    private CommonIdGenerator commonIdGenerator;

    @Test
    public void testGenerateId() {
        long guid = commonIdGenerator.generateId();
        System.out.println("############## guid:" + guid);

        GuidBO guidBO = commonIdGenerator.parseGUID(guid);
        System.out.println("############## guidBO:" + JSON.toJSONString(guidBO));
    }
}

6 机器编码耗尽问题

        “3.2 获取机器ID”小节将机器ip与编号绑定起来,希望每台机器有固定的编号,但是这样引出了新的问题:当集群机器的ip地址频繁变动时,会导致编号持续增长,由于算法最大支持1024台机器,因此必须设置最大编号1023(0~1023共1024个唯一的机器编码),随着编码的递增,迟早会超过该值。
        其实并不需要把机器ip与编码一一映射起来,只要保证在同一时刻集群中所有机器的编码都不同即可。具体做法是,在数据库中保存当前集群的最大编码,每次机器启动时,读取该编码并做“+1”操作,“+1”后的值作为该机器的编码,并更新到数据库中,同时设置编码上限阈值,当超过该阈值时,编码重置为0,如此循环往复。例如本次算法支持最多1024台机器,那么可以设置上限阈值为1023,即可支持编号0~1023共1024个唯一的机器编码。

        优点:循环使用,编码不会无限膨胀,不会超过限制;

        缺点:不能从ID反解析出生成ID的机器ip(不过这也无关紧要,一般不需要反解析ID)

示例代码:

package com.alibaba.fc.max.front.integration.backend;

@Service("dbWorkIdResolver")
public class DbWorkIdResolver implements WorkIdResolver {

    private static final String KEY                 = "GUID_WORK_ID_";

    private static final long   WORKER_ID_MAX_VALUE = 1024L;

    private volatile Long       workId;

    private CountDownLatch      countDownLatch      = new CountDownLatch(1);

    @Resource(name = "keyValueDataDao")
    private KeyValueDataDao     keyValueDataDao;

    /**
     * 返回0 ~ 1023之间的workId,最大支持1024台机器
     *
     * @return
     */
    @Override
    public long resolveWorkId() {
        if (null == workId) {
            try {
                countDownLatch.await();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return workId;
    }

    @PostConstruct
    private void init() {
        try {
            /*
             * 应用每次启动,都获取递增的workId,达到最大的1024后,重置为从0开始获取
             */
            KeyValueDataPO keyValueData = keyValueDataDao.getKeyValueData(KEY,
                KeyValueDataTypeEnum.DEFAULT.intValue());

            if (null != keyValueData) {
                long maxId = Long.parseLong(keyValueData.getDataValue());
                maxId += 1;
                if (maxId >= WORKER_ID_MAX_VALUE) {
                    maxId = 0L;
                }
                
                if (keyValueDataDao.updateKeyValueDataWithOldValue(KEY, String.valueOf(maxId),
                    KeyValueDataTypeEnum.DEFAULT.intValue(), keyValueData.getDbUpdateTime(),
                    keyValueData.getDataValue())) {
                    workId = maxId;
                } else {
                    log.error("update database error, data={} ",
                        FastJsonUtil.toJSONString(keyValueData.getDbUpdateTime()));
                }

            } else {
                if (keyValueDataDao.insertDataValue(KEY, KeyValueDataTypeEnum.DEFAULT.intValue(),
                    "0")) {
                    workId = 0L;
                }
            }

            if (null == workId) {
                // 抛异常,spring 初始化失败
                throw new IllegalStateException("init workId fail");
            }
        } finally {
            countDownLatch.countDown();
        }
    }
}

 7 其他

1.最大峰值型 vs. 最小粒度型

通过调短时间戳长度和调长序列号长度,可以解决峰值压力大的情况。

最大峰值型能够承受更大的峰值压力,但是粗略有序的粒度有点大;最小粒度型有较细致的粒度,但是没毫秒能承受的峰值较小。

2.美团Leaf开源:https://github.com/Meituan-Dianping/Leaf

介绍:Leaf:美团分布式ID生成服务开源 - 美团技术团队

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值