分布式全局ID生成方案

目录

1、背景

2、特性需求

3、分布式ID的生成方案

3.1 数据库自增ID

3.2 批量生成ID

3.3 UUID

3.4 取当前毫秒数

3.5 Redis生成ID

3.6 snowflake算法(雪花算法)

3.7 美团Leaf算法


1、背景

分布式架构下,唯一序列号生成是我们在设计一个系统,尤其是数据库使用分库分表的时候常常会遇见的问题。当分成若干sharding表后,如何能够快速拿到一个唯一序列号,是经常遇到的问题。

在互联网的业务系统中,涉及到各种各样的ID,如在支付系统中就会有支付ID、退款ID等。那一般生成ID都有哪些解决方案呢?特别是在复杂的分布式系统业务场景中,我们应该采用哪种适合自己的解决方案是十分重要的

2、特性需求

保证生成的ID全局唯一
今后数据在多个Shards之间迁移不会受到ID生成方式的限制
生成的ID中最好能带上时间信息, 例如 ID 的前 k 位是 Timestamp, 这样能够直接通过对ID的前k位的排序来对数据按时间排序
生成的ID比特位不要过大,最好不大于64 bits
生成 ID 的速度有要求. 例如, 在一个高吞吐量的场景中, 需要每秒生成几万个 ID (Twitter最新的峰值到达了143,199Tweets/s, 也就是10万+/秒)
整个服务最好没有单点
趋势递增:在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。
单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求。
信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则、不规则。

3、分布式ID的生成方案

3.1 数据库自增ID

使用数据库的id自增策略,如 MySQL 的 auto_increment。并且可以使用两台数据库服务分别设置不同步长,生成不重复ID的策略来实现高可用和高性能。

3.1.1 优缺点

优点:

(1)简单,使用数据库已有的功能

(2)能够保证唯一性

(3)数据库生成的id能够保证递增性

(4)步长固定

缺点:

(1)可用性难以保证:数据库常见架构是一主多从+读写分离,生成自增ID是写请求,主库挂了就玩不转了

(2)扩展性差,性能有上限:因为写入是单点,数据库主库的写性能决定ID的生成性能上限,并且难以扩展

3.1.2 改进方法

(1)增加主库,避免写入单点

(2)数据水平切分,保证各主库生成的ID不重复

如上图所述,由1个写库变成3个写库,每个写库设置不同的auto_increment初始值,以及相同的增长步长,以保证每个数据库生成的ID是不同的(上图中库0生成0,3,6,9…,库1生成1,4,7,10,库2生成2,5,8,11…)

3.1.3 实现SQL脚本

/*
* global-id-01
*/
drop table if exists `t_user`;
CREATE TABLE `t_user` (
`id`  int(11) NOT NULL AUTO_INCREMENT,
`name`  varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL ,
`age`  int(11) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB 
DEFAULT CHARACTER SET=utf8 COLLATE=utf8_general_ci
ROW_FORMAT=COMPACT
;
​
SET @@auto_increment_offset = 1; /*初始值*/
set @@auto_increment_increment=3; /*步长*/
​
/*插入多条数据测试*/
INSERT INTO t_user(name,age) VALUES("laohu",18);
INSERT INTO t_user(name,age) VALUES("laoli",28);
INSERT INTO t_user(name,age) VALUES("laowang",36);
​
SELECT * FROM t_user;
​
/*
* global-id-02
*/
drop table if exists `t_user`;
CREATE TABLE `t_user` (
`id`  int(11) NOT NULL AUTO_INCREMENT,
`name`  varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL ,
`age`  int(11) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB 
DEFAULT CHARACTER SET=utf8 COLLATE=utf8_general_ci
ROW_FORMAT=COMPACT
;
​
SET @@auto_increment_offset = 2; /*初始值*/
set @@auto_increment_increment=3; /*步长*/
​
/*插入多条数据测试*/
INSERT INTO t_user(name,age) VALUES("laohu",18);
INSERT INTO t_user(name,age) VALUES("laoli",28);
INSERT INTO t_user(name,age) VALUES("laowang",36);
​
SELECT * FROM t_user;
​
/*
* global-id-03
*/
drop table if exists `t_user`;
CREATE TABLE `t_user` (
`id`  int(11) NOT NULL AUTO_INCREMENT,
`name`  varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL ,
`age`  int(11) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB 
DEFAULT CHARACTER SET=utf8 COLLATE=utf8_general_ci
ROW_FORMAT=COMPACT
;
​
SET @@auto_increment_offset = 3; /*初始值*/
set @@auto_increment_increment=3; /*步长*/
​
/*插入多条数据测试*/
INSERT INTO t_user(name,age) VALUES("laohu",18);
INSERT INTO t_user(name,age) VALUES("laoli",28);
INSERT INTO t_user(name,age) VALUES("laowang",36);
​
SELECT * FROM t_user;

生成结果分别为:

改进后的架构保证了可用性,但缺点是:

(1)丧失了ID生成的“绝对递增性”:先访问库0生成0,3,再访问库1生成1,可能导致在非常短的时间内,ID生成不是绝对递增的(这个问题不大,我们的目标是趋势递增,不是绝对递增)

(2)数据库的写压力依然很大,每次生成ID都要调用数据库ID生成自增序列进程

3.2 批量生成ID

3.2.1 实现方案

一次按需批量生成多个ID,每次生成都需要调用数据库ID生成自增序列进程,将数据库修改为最大的ID值,并在内存中记录当前值及最大值。分布式系统之所以难,很重要的原因之一是“没有一个全局时钟,难以保证绝对的时序”,要想保证绝对的时序,还是只能使用单点服务,用本地时钟保证“绝对时序”。数据库写压力大,是因为每次生成ID都调用数据库ID生成自增序列进程,可以使用批量的方式降低数据库写压力

如上图所述,数据库使用双master保证可用性,数据库中只存储当前ID的最大值,例如0。ID生成服务假设每次批量拉取6个ID,服务访问数据库,将当前ID的最大值修改为5,这样应用访问ID生成服务索要ID,ID生成服务不需要每次访问数据库,就能依次派发0,1,2,3,4,5这些ID了,当ID发完后,再将ID的最大值修改为11,就能再次派发6,7,8,9,10,11这些ID了,于是数据库的压力就降低到原来的1/6了。

3.2.2 SQL脚本

/*
* 该方案需要保证各表id自增1,但是插入数据时必须自带ID,而不能使用自增ID,避免id错乱
* global-id-01,global-id-02,global-id-02创建表如下
*/
drop table if exists `t_user`;
CREATE TABLE `t_user` (
`id`  int(11) NOT NULL  AUTO_INCREMENT,
`name`  varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL ,
`age`  int(11) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=utf8 COLLATE=utf8_general_ci
ROW_FORMAT=COMPACT
;
​

3.2.3 模拟代码

数据源

# 数据源1
spring.datasource.druid.w1.jdbcUrl=jdbc:mysql://192.168.223.128:3306/global-id-01
spring.datasource.druid.w1.username=root
spring.datasource.druid.w1.password=root
spring.datasource.druid.w1.driver-class-name=com.mysql.jdbc.Driver
​
# 数据源2
spring.datasource.druid.w2.jdbcUrl=jdbc:mysql://192.168.223.128:3306/global-id-02
spring.datasource.druid.w2.username=root
spring.datasource.druid.w2.password=root
spring.datasource.druid.w2.driver-class-name=com.mysql.jdbc.Driver
​
​

实例化数据源

package com.ydt.globalidtest;
​
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
​
import javax.sql.DataSource;
​
@SpringBootApplication
@Configuration
public class GlobalIdTestApplication {
​
    public static void main(String[] args) {
        SpringApplication.run(GlobalIdTestApplication.class, args);
    }
​
    @Bean(name = "dataSource1")
    @ConfigurationProperties(prefix="spring.datasource.druid.w1")
    public DataSource dataSource1() {
        return DataSourceBuilder.create().build();
    }
​
    @Bean(name = "dataSource2")
    @ConfigurationProperties(prefix="spring.datasource.druid.w2")
    public DataSource dataSource2() {
        return DataSourceBuilder.create().build();
    }
​
    @Bean(name = "jdbcTempalte1")
    public JdbcTemplate jdbcTemplate1(
            @Qualifier("dataSource1") DataSource dataSource){
        return new JdbcTemplate(dataSource);
    }
​
    @Bean(name = "jdbcTempalte2")
    public JdbcTemplate jdbcTemplate2(
            @Qualifier("dataSource2") DataSource dataSource){
        return new JdbcTemplate(dataSource);
    }
​
}
​

模拟测试代码

package com.ydt.globalidtest;
​
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
​
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
import java.util.Set;
​
@SpringBootTest
public class GlobalIdTestApplicationTests {
​
    @Resource(name = "jdbcTempalte1")
    private JdbcTemplate jdbcTemplate1;
​
    @Resource(name = "jdbcTempalte2")
    private JdbcTemplate jdbcTemplate2;
​
    @Test
    public void test() {
        int i = 0;
        String querySql = "SELECT auto_increment FROM information_schema.tables where table_schema='global-id-01' and table_name='t_user'";
        List<Map<String, Object>> maps = jdbcTemplate1.queryForList(querySql);
        for (Map<String, Object> map : maps) {
            Set<Map.Entry<String, Object>> entries = map.entrySet();
            for (Map.Entry<String, Object> entry : entries) {
               //该处实际生产中请放置到内存中
                BigInteger value = (BigInteger) entry.getValue();
                i=value.intValue();
                System.out.println(entry.getValue());
            }
        }
        //重新设置下一个自增ID节点
        jdbcTemplate1.execute("ALTER TABLE t_user auto_increment=" + (i+10));
        //实际生产中该处应该是数据库集群同步,互为主备
        jdbcTemplate2.execute("ALTER TABLE t_user auto_increment=" + (i+10));
    }
​
}
​

优点

(1)保证了ID生成的绝对递增有序

(2)大大的降低了数据库的压力,ID生成可以做到每秒生成几万几十万个

缺点

(1)服务仍然是单点

(2)如果服务挂了,服务重启起来之后,继续生成ID可能会不连续,中间出现空洞(服务内存是保存着0,1,2,3,4,5,数据库中max-id是5,分配到3时,服务重启了,下次会从6开始分配,4和5就成了空洞,不过这个问题也不大)

(3)虽然每秒可以生成几万几十万个ID,但毕竟还是有性能上限,无法进行水平扩展

改进方法

单点服务的常用高可用优化方案是“备用服务”,也叫“影子服务”,所以我们能用以下方法优化上述缺点(1):

如上图,对外提供的服务是主服务,有一个影子服务时刻处于备用状态,当主服务挂了的时候影子服务顶上。这个切换的过程对调用方是透明的,可以自动完成,常用的技术是keepalived虚拟IP,具体就不在这里展开。

3.3 UUID

算法的核心思想是结合机器的网卡、当地时间、一个随记数来生成UUID。

UUID为本地生成ID的方法,即高性能,又时延低。uuid是一种常见的方案:

UUID.randomUUID();#Java API
select uuid();#数据库

优点

(1)本地生成ID,不需要进行远程调用,时延低,没有高可用风险

(2)扩展性好,基本可以认为没有性能上限

缺点

(1)无法保证趋势递增

(2)uuid过长,往往用字符串表示,作为主键建立索引查询效率低

(3)不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用

3.4 取当前毫秒数

uuid是一个本地算法,生成性能高,但无法保证趋势递增,且作为字符串ID检索效率低,有没有一种能保证递增的本地算法呢?

取当前毫秒数是一种常见方案:uint64 ID = GenTimeMS();

优点

(1)本地生成ID,不需要进行远程调用,时延低

(2)生成的ID趋势递增

(3)生成的ID是整数,建立索引后查询效率高

缺点

(1)如果1毫秒内并发量超过1000,会生成重复的ID,不能保证ID的唯一性。当然,使用微秒可以降低冲突概率,但每微秒最多只能生成1000000个ID,再多的话就一定会冲突了,所以使用微秒并不从根本上解决问题

@Test
    public void testMil() throws InterruptedException {
        List<Long> list = new ArrayList<>();
        for (int i = 0; i < 1000000; i++) {
            Thread thread = new Thread() {
                @Override
                public void run() {
                    long nextLong = System.currentTimeMillis();
                    /*Long cutime = System.currentTimeMillis() * 1000; // 微秒
                    Long nanoTime = System.nanoTime(); // 纳秒
                    long nextLong =  cutime + (nanoTime - nanoTime / 1000000 * 1000000) / 1000;*/
                    if(list.contains(nextLong)){
                        System.out.println("重复了:" + nextLong);
                    }else{
                        list.add(nextLong);
                    }
                }
            };
            Thread.sleep(1);//可以休眠一毫秒再启动线程,但是凭啥呢?
            thread.start();
        }
        while (true);
    }

3.5 Redis生成ID

Redis的所有命令操作都是单线程的,本身提供像 incr 和 increby 这样的自增原子命令,所以能保证生成的 ID 肯定是唯一有序的。

  • 优点:不依赖于数据库,灵活方便,且性能优于数据库;数字ID天然排序,对分页或者需要排序的结果很有帮助。

  • 缺点:如果系统中没有Redis,还需要引入新的组件,增加系统复杂度;需要编码和配置的工作量比较大。

考虑到单节点的性能瓶颈,可以使用 Redis 集群来获取更高的吞吐量。假如一个集群中有5台 Redis。可以初始化每台 Redis 的值分别是1, 2, 3, 4, 5,然后步长都是 5。各个 Redis 生成的 ID 为:

A:1, 6, 11, 16, 21
B:2, 7, 12, 17, 22
C:3, 8, 13, 18, 23
D:4, 9, 14, 19, 24
E:5, 10, 15, 20, 25
​

步长和初始值一定需要事先确定,未来很难做修改。使用 Redis内置集群模式可以避免单点故障的问题。

另外,比较适合使用 Redis 来生成每天从0开始的流水号。比如订单号 = 日期 + 当日自增长号。可以每天在 Redis 中生成一个 Key ,使用 INCR 进行累加。

 @Test
    public void testRedis(){
        DateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
        String format = dateFormat.format(new Date());
        List<String> list = new ArrayList<>();
        stringRedisTemplate.boundValueOps(format).set("0");
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {
            Thread thread = new Thread() {
                @Override
                public void run() {
                    Long increment = stringRedisTemplate.opsForValue().increment(format);
                    String id = format+increment;
                    if(list.contains(id)){
                        System.out.println("重复了:" + id);
                    }else{
                        list.add(id);
                    }
​
                }
            };
            thread.start();
        }
        while (true) {
            String s = stringRedisTemplate.boundValueOps(format).get();
            if(s != null && s.equals("100000")){
                long endTime = System.currentTimeMillis();
                System.out.println("总耗时:" + (endTime-startTime));
                break;
            }
        }
    }
#配置单机和集群
#spring.redis.host=ydt1
spring.redis.cluster.nodes=ydt1:7001,ydt1:7002,ydt1:7003,ydt1:7004,ydt1:7005,ydt1:7006

3.6 snowflake算法(雪花算法)

3.6.1 雪花算法思想

自然界没有任何两朵雪花是完全一样的,雪花算法得到的全局ID也不会有相同的,这个就是雪花算法称呼由来!

SnowFlake算法生成id的结果是一个64bit大小的整数,它的结构如下图:

● 1位,不用。二进制中最高位为1的都是负数,但是我们生成的id一般都使用整数,所以这个最高位固定是0

● 41位,用来记录时间戳(毫秒)。 ​ ○ 41位可以表示$2^{41}-1$个数字, ​ ○ 如果只用来表示正整数(计算机中正数包含0),可以表示的数值范围是:0 至 $2^{41}-1$,减1是因为可表示的数值范围是从0开始算的,而不是1。 ​ ○ 也就是说41位可以表示$2^{41}-1$个毫秒的值,转化成单位年则是$(2^{41}-1) / (1000 * 60 * 60 * 24 * 365) = 69$年

10位,用来记录工作机器id。 ​ ○ 可以部署在$2^{10} = 1024$个节点,包括 5位datacenterId 和 5位workerId ​ ○ 5位(bit)可以表示的最大正整数是$2^{5}-1 = 31$,即可以用0、1、2、3、....31这32个数字,来表示不同的datecenterId或workerId

12位,序列号,用来记录同毫秒内产生的不同id。 ​ ○ 12位(bit)可以表示的最大正整数是$2^{12}-1 = 4095$,即可以用0、1、2、3、....4094这4095个数字,来表示同一机器同一时间截(毫秒)内产生的4095个ID序号

由于在Java中64bit的整数是long类型,所以在Java中SnowFlake算法生成的id就是long来存储的。

SnowFlake可以保证: ● 所有生成的id按时间趋势递增 ● 整个分布式系统内不会产生重复id(因为有datacenterId和workerId来做区分)

3.6.2 雪花算法实现工具类

package com.ydt.globalidtest;
​
import java.net.Inet4Address;
import java.net.UnknownHostException;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/**
 * Twitter_Snowflake<br>
 * SnowFlake的结构如下(每部分用-分开):<br>
 * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
 * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>
 * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
 * 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
 * 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>
 * 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br>
 * 加起来刚好64位,为一个Long型。<br>
 * SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
 */
public class SnowflakeUtils {
​
    /** 时间部分所占长度 */
    private static final int TIME_LEN = 41;
    /** 数据中心id所占长度 */
    private static final int DATA_LEN = 5;
    /** 机器id所占长度 */
    private static final int WORK_LEN = 5;
    /** 毫秒内序列所占长度 */
    private static final int SEQ_LEN = 12;
​
    /** 定义起始时间 2015-01-01 00:00:00 小于当前时间即可*/
    private static final long START_TIME = 1420041600000L;
    /** 上次生成ID的时间截 */
    private static long LAST_TIME_STAMP = -1L;
    /** 时间部分向左移动的位数 22 */
    private static final int TIME_LEFT_BIT = 64 - 1 - TIME_LEN;
​
    /** 自动获取数据中心id(可以手动定义 0-31之间的数) */
    private static final long DATA_ID = getDataId();
    /** 自动机器id(可以手动定义 0-31之间的数) */
    private static final long WORK_ID = getWorkId();
    /** 数据中心id最大值 31 */
    private static final int DATA_MAX_NUM = ~(-1 << DATA_LEN);
    /** 机器id最大值 31 */
    private static final int WORK_MAX_NUM = ~(-1 << WORK_LEN);
    /** 随机获取数据中心id的参数 32 */
    private static final int DATA_RANDOM = DATA_MAX_NUM + 1;
    /** 随机获取机器id的参数 32 */
    private static final int WORK_RANDOM = WORK_MAX_NUM + 1;
    /** 数据中心id左移位数 17 */
    private static final int DATA_LEFT_BIT = TIME_LEFT_BIT - DATA_LEN;
    /** 机器id左移位数 12 */
    private static final int WORK_LEFT_BIT = DATA_LEFT_BIT - WORK_LEN;
​
    /** 上一次的毫秒内序列值 */
    private static long LAST_SEQ = 0L;
    /** 毫秒内序列的最大值 4095 */
    private static final long SEQ_MAX_NUM = ~(-1 << SEQ_LEN);
​
​
    public synchronized static long genId(){
        long now = System.currentTimeMillis();
​
        //如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过,这个时候应当抛出异常
        if (now < LAST_TIME_STAMP) {
            throw new RuntimeException(String.format("系统时间错误! %d 毫秒内拒绝生成雪花ID!", START_TIME - now));
        }
​
        //等于当前时间,毫秒内生成id序列加1
        if (now == LAST_TIME_STAMP) {
            LAST_SEQ = (LAST_SEQ + 1) & SEQ_MAX_NUM;
            if (LAST_SEQ == 0){//如果毫秒内第一次生成,获取当前时间戳
                now = nextMillis(LAST_TIME_STAMP);
            }
        } else {
            LAST_SEQ = 0;
        }
​
        //上次生成ID的时间截
        LAST_TIME_STAMP = now;
​
        return ((now - START_TIME) << TIME_LEFT_BIT) | (DATA_ID << DATA_LEFT_BIT) | (WORK_ID << WORK_LEFT_BIT) | LAST_SEQ;
    }
​
​
    /**
     * 获取下一不同毫秒的时间戳,不能与最后的时间戳一样
     */
    public static long nextMillis(long lastMillis) {
        long now = System.currentTimeMillis();
        while (now <= lastMillis) {
            now = System.currentTimeMillis();
        }
        return now;
    }
​
    /**
     * 获取字符串s的字节数组,然后将数组的元素相加,对(max+1)取余
     */
    private static int getHostId(String s, int max){
        byte[] bytes = s.getBytes();
        int sums = 0;
        for(int b : bytes){
            sums += b;
        }
        return sums % (max+1);
    }
​
    /**
     * 根据 host address 取余,发生异常就获取 0到31之间的随机数
     */
    public static int getWorkId(){
        try {
            return getHostId(Inet4Address.getLocalHost().getHostAddress(), WORK_MAX_NUM);
        } catch (UnknownHostException e) {
            return new Random().nextInt(WORK_RANDOM);
        }
    }
​
    /**
     * 根据 host name 取余,发生异常就获取 0到31之间的随机数
     */
    public static int getDataId() {
        try {
            return getHostId(Inet4Address.getLocalHost().getHostName(), DATA_MAX_NUM);
        } catch (UnknownHostException e) {
            return new Random().nextInt(DATA_RANDOM);
        }
    }
​
    /**
     * 400W个全局ID大概只需要1秒
     * @param args
     */
    public static void main(String[] args) {
        Set ids = new HashSet();
        long start = System.currentTimeMillis();
        for (int i = 0; i < 3000000; i++) {
            ids.add(genId());
        }
        long end = System.currentTimeMillis();
        System.out.println("共生成id[" + ids.size() + "]个,花费时间[" + (end - start) + "]毫秒");
    }
}
​
​
​

其实看编译后的class文件更过瘾:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
​
package com.ydt.globalidtest;
​
import java.net.Inet4Address;
import java.net.UnknownHostException;
import java.util.HashSet;
import java.util.Random;
​
public class SnowflakeUtils {
    private static final int TIME_LEN = 41;
    private static final int DATA_LEN = 5;
    private static final int WORK_LEN = 5;
    private static final int SEQ_LEN = 12;
    private static final long START_TIME = 1420041600000L;
    private static long LAST_TIME_STAMP = -1L;
    private static final int TIME_LEFT_BIT = 22;
    private static final long DATA_ID = (long)getDataId();
    private static final long WORK_ID = (long)getWorkId();
    private static final int DATA_MAX_NUM = 31;
    private static final int WORK_MAX_NUM = 31;
    private static final int DATA_RANDOM = 32;
    private static final int WORK_RANDOM = 32;
    private static final int DATA_LEFT_BIT = 17;
    private static final int WORK_LEFT_BIT = 12;
    private static long LAST_SEQ = 0L;
    private static final long SEQ_MAX_NUM = 4095L;
​
    public SnowflakeUtils() {
    }
​
    public static synchronized long genId() {
        long now = System.currentTimeMillis();
        if (now < LAST_TIME_STAMP) {
            throw new RuntimeException(String.format("系统时间错误! %d 毫秒内拒绝生成雪花ID!", 1420041600000L - now));
        } else {
            if (now == LAST_TIME_STAMP) {
                LAST_SEQ = LAST_SEQ + 1L & 4095L;
                if (LAST_SEQ == 0L) {
                    now = nextMillis(LAST_TIME_STAMP);
                }
            } else {
                LAST_SEQ = 0L;
            }
​
            LAST_TIME_STAMP = now;
            return now - 1420041600000L << 22 | DATA_ID << 17 | WORK_ID << 12 | LAST_SEQ;
        }
    }
​
    public static long nextMillis(long lastMillis) {
        long now;
        for(now = System.currentTimeMillis(); now <= lastMillis; now = System.currentTimeMillis()) {
        }
​
        return now;
    }
​
    private static int getHostId(String s, int max) {
        byte[] bytes = s.getBytes();
        int sums = 0;
        byte[] var4 = bytes;
        int var5 = bytes.length;
​
        for(int var6 = 0; var6 < var5; ++var6) {
            int b = var4[var6];
            sums += b;
        }
​
        return sums % (max + 1);
    }
​
    public static int getWorkId() {
        try {
            return getHostId(Inet4Address.getLocalHost().getHostAddress(), 31);
        } catch (UnknownHostException var1) {
            return (new Random()).nextInt(32);
        }
    }
​
    public static int getDataId() {
        try {
            return getHostId(Inet4Address.getLocalHost().getHostName(), 31);
        } catch (UnknownHostException var1) {
            return (new Random()).nextInt(32);
        }
    }
​
    public static void main(String[] args) {
        Set ids = new HashSet();
        long start = System.currentTimeMillis();
        for (int i = 0; i < 3000000; i++) {
            genId();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费时间[" + (end - start) + "]毫秒");
    }
}
​

3.6.3 改进思想

其实雪花算法就是把id按位打散,然后再分成上面这几块,用位来表示状态,这其实就是一种思想。 所以咱们实际在用的时候,也不必非得按照上面这种分割,只需保证总位数在64位即可

如果你的业务不需要69年这么长,或者需要更长时间 用42位存储时间戳,(1L << 42) / (1000L * 60 * 60 * 24 * 365) = 139年 用41位存储时间戳,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年 用40位存储时间戳,(1L << 40) / (1000L * 60 * 60 * 24 * 365) = 34年 用39位存储时间戳,(1L << 39) / (1000L * 60 * 60 * 24 * 365) = 17年 用38位存储时间戳,(1L << 38) / (1000L * 60 * 60 * 24 * 365) = 8年 用37位存储时间戳,(1L << 37) / (1000L * 60 * 60 * 24 * 365) = 4年

如果你的机器没有那么1024个这么多,或者比1024还多 用7位存储机器id,(1L << 7) = 128 用8位存储机器id,(1L << 8) = 256 用9位存储机器id,(1L << 9) = 512 用10位存储机器id,(1L << 10) = 1024 用11位存储机器id,(1L << 11) = 2048 用12位存储机器id,(1L << 12) = 4096 用13位存储机器id,(1L << 13) = 8192

如果你的业务服务,每毫秒最多也不会4096个id要生成,或者比这个还多 用8位存储随机序列,(1L << 8) = 256 用9位存储随机序列,(1L << 9) = 512 用10位存储随机序列,(1L << 10) = 1024 用11位存储随机序列,(1L << 11) = 2048 用12位存储随机序列,(1L << 12) = 4096 用13位存储随机序列,(1L << 13) = 8192 用14位存储随机序列,(1L << 14) = 16384 用15位存储随机序列,(1L << 15) = 32768 注意,随机序列建议不要太大,一般业务,每毫秒要是能产生这么多id,建议在机器id上增加位

如果你的业务量很小,比如一般情况下每毫秒生成不到1个id,此时可以将随机序列设置成随机开始自增 比如从0到48随机开始自增,算是一种优化建议

如果你有多个业务,也可以拿出来几位来表示业务,比如用最后4位,支持16种业务的区分

如果你的业务特别复杂,可以考虑128位存储,不过这样的话,也可以考虑使用uuid了,但uuid无序,这个有序

如果你的业务很简单,甚至可以考虑32位存储,时间戳改成秒为单位…

总结: 合理的根据自己的实际情况去设计每个唯一条件的组合,雪花算法只是提供了一种相对合理的方式。雪花算法这种用位来表示状态的,我们还可以用在其他方面,比如数据库存储,可以用更小的空间去表示不同的状态位包括各种底层的比如序列化,也是有用到拆解位,充分利用存储

3.7 美团Leaf算法

3.7.1 算法介绍

Leaf是美团推出的一个分布式ID生成服务,名字取自德国哲学家、数学家莱布尼茨的一句话:“There are no two identical leaves in the world.”(“世界上没有两片相同的树叶”)

Leaf的优势:高可靠低延迟全局唯一等特点

目前主流的分布式ID生成方式,大致都是基于数据库号段模式雪花算法(snowflake),而美团(Leaf)刚好同时兼具了这两种方式,可以根据不同业务场景灵活切换。

技术网站:Leaf——美团点评分布式ID生成系统 - 美团技术团队

3.7.2 Leaf-segment号段模式

3.7.2.1 模式介绍

Leaf-segment号段模式是对直接用数据库自增ID充当分布式ID的一种优化,减少对数据库的频率操作。相当于从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如 (1,1000] 代表1000个ID,业务服务将号段在本地生成1~1000的自增ID并加载到内存.。

号段耗尽之后再去数据库获取新的号段,可以大大的减轻数据库的压力。对max_id字段做一次update操作,update max_id= max_id + step,update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]。

3.7.2.2 环境搭建

leaf算法提供了源码,也提供了可执行程序,我们这里使用源码的形式部署服务:

1)、下载源码包,地址:https://github.com/Meituan-Dianping/Leaf

2)解压之后,直接导入idea

3)配置文件leaf.properties

​
#为true时表示开启号段模式
leaf.segment.enable=true
#数据库url
leaf.jdbc.url=jdbc:mysql://192.168.223.128:3306/meituan-leaf
 #数据库账号
leaf.jdbc.username=root
 #数据库密码
leaf.jdbc.password=root

4)、导入号段表脚本,插入一条号段测试数据(2000步长,从1开始),当然,你也可以插入多条号段数据,只不过到时候取ID的时候输入不同的号段名称即可

DROP TABLE IF EXISTS `leaf_alloc` ;
CREATE TABLE `leaf_alloc` (
  `biz_tag` varchar(128)  NOT NULL DEFAULT '', 
  `max_id` bigint(20) NOT NULL DEFAULT '1',
  `step` int(11) NOT NULL,
  `description` varchar(256)  DEFAULT NULL,
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;
​
insert into leaf_alloc(biz_tag, max_id, step, description) values('leaf-segment-test', 1, 10, 'Test leaf Segment Mode Get Id')

5)、执行LeafServerApplication启动类

leaf是基于Http请求的发号服务, LeafController 中只有两个方法,一个号段接口,一个snowflake接口,key就是数据库中预先插入的业务biz_tag

package com.sankuai.inf.leaf.server.controller;
​
import com.sankuai.inf.leaf.common.Result;
import com.sankuai.inf.leaf.common.Status;
import com.sankuai.inf.leaf.server.exception.LeafServerException;
import com.sankuai.inf.leaf.server.exception.NoKeyException;
import com.sankuai.inf.leaf.server.service.SegmentService;
import com.sankuai.inf.leaf.server.service.SnowflakeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
@RestController
public class LeafController {
    private Logger logger = LoggerFactory.getLogger(LeafController.class);
​
    @Autowired
    private SegmentService segmentService;
    @Autowired
    private SnowflakeService snowflakeService;
​
    //号段接口
    @RequestMapping(value = "/api/segment/get/{key}")
    public String getSegmentId(@PathVariable("key") String key) {
        return get(key, segmentService.getId(key));
    }
    //snowflake接口
    @RequestMapping(value = "/api/snowflake/get/{key}")
    public String getSnowflakeId(@PathVariable("key") String key) {
        return get(key, snowflakeService.getId(key));
    }
​
    private String get(@PathVariable("key") String key, Result id) {
        Result result;
        if (key == null || key.isEmpty()) {
            throw new NoKeyException();
        }
        result = id;
        if (result.getStatus().equals(Status.EXCEPTION)) {
            throw new LeafServerException(result.toString());
        }
        return String.valueOf(result.getId());
    }
}
​

6)、关掉数据库,你会发现可以生成两个步长的ID,这个就涉及到leaf-segment的一个特性了:Leaf采用双buffer的方式,它的服务内部有两个号段缓存区segment。当前号段已消耗10%时,还没能拿到下一个号段,则会另启一个更新线程去更新下一个号段。

简而言之就是Leaf保证了总是会多缓存两个号段,即便哪一时刻数据库挂了,也会保证发号服务可以正常工作一段时间。

通常推荐号段(segment)长度设置为服务高峰期发号QPS(每秒查询率)的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。

3.7.2.3 优缺点

优点:

  • Leaf服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景。

  • 容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。

缺点:

  • ID号码不够随机,能够泄露发号数量的信息,不太安全。(追求趋势递增,尽量不要严格递增)

  • DB宕机会造成整个系统不可用(用到数据库的都有可能)。

  • 当id用完了,要读取数据库时,这个时候大量请求来了,都会堵塞。(双buffer优化)

3.7.3 Leaf-snowflake雪花模式

3.7.3.1 模式介绍

Leaf-snowflake基本上就是沿用了snowflake的设计,ID组成结构:正数位(占1比特)+ 时间戳(占41比特)+ 机器ID(占5比特)+ 机房ID(占5比特)+ 自增值(占12比特),总共64比特组成的一个Long类型。

Leaf-snowflake不同于原始snowflake算法地方,主要是在workId的生成上,Leaf-snowflake依靠Zookeeper生成workId,也就是上边的机器ID(占5比特)+ 机房ID(占5比特)。Leaf中workId是基于ZooKeeper的顺序Id来生成的,每个应用在使用Leaf-snowflake时,启动时都会都在Zookeeper中生成一个顺序Id,相当于一台机器对应一个顺序节点,也就是一个workId。

Leaf-snowflake启动服务的过程大致如下:

  • 启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。

  • 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。

  • 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。

Leaf-snowflake对Zookeeper是一种弱依赖关系,除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。一旦ZooKeeper出现问题,恰好机器出现故障需重启时,依然能够保证服务正常启动。

3.7.3.2 环境搭建

1)、配置文件

leaf.snowflake.enable=true #开启雪花模式
leaf.snowflake.zk.address=192.168.223.128 #配置zk地址
leaf.snowflake.port=2181 #配置zk端口

2)、启动zookeeper,启动LeafServerApplication类

3)优缺点

优点:

  • ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。

缺点:

  • 依赖ZooKeeper,存在服务不可用风险(实在不知道有啥缺点了)

3.7.4 Leaf监控

针对服务自身的监控,Leaf提供了Web层的内存数据映射界面,可以实时看到所有号段的下发状态。比如每个号段双buffer的使用情况,当前ID下发到了哪个位置等信息都可以在Web界面上查看。

请求地址:http://localhost:8080/cache

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值