高并发情况下,雪花ID一秒400W个,以及分布式ID算法(详析)

背景

最近在研究雪花算法,在研究同时,想了一个问题,在高并发的情况下,一秒内,雪花算法能生成多少个ID。

闲话少说,开撸。

概述

而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移到Cassandra(由Facebook开发一套 开源分布式NoSQL数据库系统)因
为Cassandra没有顺序ID生成机制,所以开发了这样一套 全局唯一-ID生成服务。

Twitter的分布式雪花算法SnowFlake,经测试snowflake每秒能够产生26万个自增可排序的ID

  • twitter的SnowFlake 生成ID能够按照时间有序生成
  • SnowFlake算法生 成id的结果是一个64bit大小的整数, 为- 个Long型(转换成字符串后长度最多19)。
  • 分布式系统内不会产生ID碰撞(由datacenter数据中心和workerld机器id作区分)并且效率较高。.

分布式ID

1. 为什么需要分布式全局唯一ID以及分布式ID的业务需求?

在复杂的分布式系统中,往往需要对大量的数据和消息进行唯一标识。如在美团点评的金融、支付、餐饮、酒店等产品的系统中数据日渐增长,对数据分库分表后需要有一个唯一ID来标识一条数据或消息,此时一个能够生成全局唯一ID的系统是非常必要的。

2. ID生成规则部分硬性要求

  • 全局唯一:不能出现重复的ID号,既然是唯一标识,这是最基本的要求;
  • 趋势递增:在MySql的InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能;
  • 单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求;
  • 信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可,如果是订单号就更加危险,竞对可以直接知道我们一天的单量;
  • 含时间戳:这样就能够在开发中快速了解分布式ID的生成时间。

3. ID生成系统的可用性要求

  • 高可用:发一个获取分布式ID的请求,服务器就要保证99.999%的情况下给我创建一个唯一分布式ID;
  • 低延迟:发一个获取分布式ID的请求,服务器就要快,极速;
  • 高QPS:假如并发一口气10万个创建分布式ID请求同时发过来,服务器要顶得住且一下子成功创建10万个分布式ID。

分布式ID解决方案

1. UUID

是什么?UUID的标准形式:包含32个16进制数字,以连字号分为五段,形式为8-4-4-12的36个字符。

优点:性能非常高,本地生成,没有网络消耗。

缺点:入数据库性能差。

为什么无序的UUID会导致入库性能变差呢?

1)无序,无法预测它的生成顺序,不能生成递增有序的数字。首先分布式ID一般都会作为主键,但是MySql官方推荐主键要尽量越短越好,UUID每一个都很长,所以不是很推荐。

2)主键,ID作为主键时在特定的环境下会存在一些问题,比如做DB主键的场景下,UUID就非常不适用MySql,官方有明确的建议要尽量越短越好,36个字符长度的UUID不符合要求。

3)索引,B+树索引的分裂。既然分布式ID是主键,然后主键是包含索引的,然后MySql的索引是通过b+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的b+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键底层的b+树进行很大的修改,这一点很不好。插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能。

如果只是考虑唯一性,UUID是OK的。


2. 数据库自增主键

单机应用
在分布式里,数据库的自增ID机制的主要原理是:数据库自增ID和MySql数据库的replace into实现的。

replace into 跟 insert 功能类似,不同点在于:replace into首先尝试插入数据列表中,如果发现表中已经有此行数据(根据主键或唯一索引)判断是否存在,若有则先删除再插入,否则直接插入新数据。REPLACE INTO的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据。

CREATE TABLE t_test (
    id bigint(20) unsigned not null auto_increment primary key,
    stub char(1) not null default '',
    unique key stub (stub)
)

select * from t_test;
replace into t_test (stub) values('a');
select last_insert_id();

集群分布式
那数据库自增ID机制适合作分布式ID吗?答案是不太适合的。

  • 系统水平扩展比较困难,比如定义好了步长和机器台数之后,如果要添加机器该怎么做?假设现在只有一台机器发号是1到99999,步长是1,这时候需要扩容机器一台。可以这么做:把第二台机器的初始值设置得比第一台超过很多,貌似还好,现在想象一下如果我们线上有100台机器,这个时候要扩容该怎么做?简直是噩梦,所以系统水平扩展方案复杂难以实现。
  • 数据库压力还是很大,每次获取ID都得读写一次数据库,非常影响性能,不符合分布式ID里面的延迟低和要高QPS的规则(在高并发下,如果都去数据库里获取ID,那是非常影响性能的)。


3. 基于Redis生成全局ID策略

  • 单机应用,因为Redis是单线程的天生保证原子性,可以使用原子操作INCR和INCRBY来实现。
  • 集群分布式,可以使用Redis集群来获取更高的吞吐量,注意:在Redis集群情况下,同样和MySql一样需要设置不同的增长步长,同时key一定要设置有效期。

假如一个集群中有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

4. 雪花算法 - Snowflake

号段解析:

  • 符号位(1 bit):不使用,因为二进制中最高位是符号位,1表示负数,0表示整数,生成的ID一般都是正数,所以最高位固定为0;
  • 时间戳位(41 bit):用来记录时间戳,毫秒级。41位可以表示2^{41}-1个数字,如果只用来表示正整数(计算机中正数包含0),可以表示的数值范围是:0至2^{41}-1,减1是因为可表示的数值范围是从0开始算的,而不是1。也就是说41位可以表示2^{41}-1个毫秒的值,转化成单位年差不多是69年;
  • 工作进程位(10bit):工作机器ID,用来记录工作机器ID。可以部署 2^{10} = 1024个节点,包括5位datacenterId和5位workerId。5位(bit)可以表示的最大正整数是2^{5}-1 = 31,即可以用0、1、2、3 … 31这32个数字,来表示不同的datacenterId或workerId;
  • 序列号位(12bit):序列号,用来记录同毫秒内产生的不同ID。12位可以表示的最大正整数是2^{12}-1 = 4095,即可以用0、1、2、3 … 4094 这4095个数字,来表示同一机器同一时间截(毫秒)内产生的4095个ID序号
  • SnowFlake可以保证所有生成的ID按时间趋势递增,整个分布式系统内不会产生重复ID(因为有 datacenterId 和 workId来做区分)。

源码

import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.CountDownLatch;

/**
 * @description:
 * @author: Janson Lin
 * @time: 2021/4/14 17:02
 */
public class MySnowflake {

    /**
     * 起始的时间戳
     */
    private final static long twepoch = 1618391581881L;

    /**
     * 每一部分占用的位数
     */
    private final static long workerIdBits = 5L;
    private final static long datacenterIdBits = 5L;
    private final static long sequenceBits = 12L;

    /**
     * 每一部分的最大值
     */
    private final static long maxWorkerId = -1L ^ (-1L << workerIdBits);
    private final static long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
    private final static long maxSequence = -1L ^ (-1L << sequenceBits);

    /**
     * 每一部分向左的位移
     */
    private final static long workerIdShift = sequenceBits;
    private final static long datacenterIdShift = sequenceBits + workerIdBits;
    private final static long timestampShift = sequenceBits + workerIdBits + datacenterIdBits;

    private long datacenterId; // 数据中心ID
    private long workerId; // 机器ID
    private long sequence = 0L; // 序列号
    private long lastTimestamp = -1L; // 上一次时间戳

    public MySnowflake(long workerId, long datacenterId) {
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(
                    String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException(
                    String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    public synchronized long nextId() {
        long timestamp = timeGen();
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(String.format(
                    "Clock moved backwards.  Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
        }
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & maxSequence;
            if (sequence == 0L) {
                timestamp = tilNextMillis();
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = timestamp;

        return (timestamp - twepoch) << timestampShift // 时间戳部分
                | datacenterId << datacenterIdShift // 数据中心部分
                | workerId << workerIdShift // 机器标识部分
                | sequence; // 序列号部分
    }

    private long tilNextMillis() {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }

    private long timeGen() {
        return System.currentTimeMillis();
    }

    public static void main(String[] args) {
        MySnowflake mySnowflake = new MySnowflake(0,0);
        Map<Long, Long> map = new ConcurrentHashMap<>();
        Set<Long> set = new ConcurrentSkipListSet<>();
        //线程数量
        int CURRENT_NUMBER = 400;
        final CountDownLatch latch= new CountDownLatch(CURRENT_NUMBER);
        try {
            Long startTime = System.currentTimeMillis();
            for (int i = 0; i < CURRENT_NUMBER; i++) {
                new Thread(() -> {
                    for (int j = 0; j < 10000; j++) {
                        long id =mySnowflake.nextId();
                        set.add(id);
                    }
                    latch.countDown();
                }).start();
            }

            latch.await();//阻塞当前线程直到latch中数值为零才执行
            Long endTime = System.currentTimeMillis();
            System.out.println(set.size());
            System.out.println((double) (endTime - startTime)/1000);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

知识拓展:

雪花算法中的有些补码的运算操作,>>  , >>> 和 << ,知识点传送门

执行结果:(并发400W个ID,只需要1.093秒)

 

4000000
1.093

雪花算法优缺点

优点:

  • 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的;
  • 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的;
  • 可以根据自身业务特性分配bit位,非常灵活。

缺点:

  • 依赖机器时钟,如果机器时钟回拨,会导致重复ID生成;
  • 在单机上是递增的,但是由于设计到分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况(此缺点可以认为无所谓,一般分布式ID只要求趋势递增,并不会严格要求递增,90%的需求都只要求趋势递增)。

补充解决时钟回拨思路:因为机器的原因会发生时间回拨,我们的雪花算法是强依赖机器时间的,如果时间发生回拨,有可能会生成重复的ID,在我们上面的nextId中用当前时间和上一次的时间进行判断,如果当前时间小于上一次的时间那么肯定是发生了回拨,普通的算法会直接抛出异常。这里我们可以对其进行优化,一般分为两个情况:

  • 如果时间回拨时间较短,比如配置5ms以内,那么可以直接等待一定的时间,让机器时间追上来;
  • 如果时间的回拨时间较长,我们不能接受这么长的阻塞等待,那么又有两个策略,直接拒绝,抛出异常,打日志,或者通知RD时钟回滚。
  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在Java中,@RequestBody和@RequestParam是用于处理HTTP请求参数的注解。它们之间有一些区别,下面详细解析一下: 1. @RequestBody注解用于获取请求体中的参数,并将其绑定到方法的参数上。通常用于处理POST请求中的表单数据或者JSON数据。当使用@RequestBody注解时,Spring框架会自动将请求体中的数据转换为方法参数所需的对象。例如: ```java @PostMapping("/user") public void addUser(@RequestBody User user) { // 处理user对象 } ``` 在上面的例子中,@RequestBody注解将请求体中的数据转换为User对象,并将其绑定到addUser方法的参数上。 2. @RequestParam注解用于获取URL中的请求参数,并将其绑定到方法的参数上。通常用于处理GET请求或者POST请求中的URL参数。当使用@RequestParam注解时,Spring框架会自动从URL中提取指定名称的参数,并将其转换为方法参数所需的类型。例如: ```java @GetMapping("/user") public void getUser(@RequestParam("id") int userId) { // 处理userId参数 } ``` 在上面的例子中,@RequestParam注解将URL中名为"id"的参数提取出来,并将其转换为int类型,并将其绑定到getUser方法的参数userId上。 总结起来,@RequestBody注解用于获取请求体中的参数,适用于处理POST请求中的表单数据或者JSON数据。而@RequestParam注解用于获取URL中的请求参数,适用于处理GET请求或者POST请求中的URL参数。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值