项目亮点之分布式唯一主键

分布式唯一主键

  • 全局唯一 :ID 的全局唯一性肯定是首先要满足的!
  • 高性能 : 分布式 ID 的生成速度要快,对本地资源消耗要小。
  • 高可用 :生成分布式 ID 的服务要保证可用性无限接近于 100%。
  • 方便易用 :拿来即用,使用方便,快速接入!
  • 安全 :ID 中不包含敏感信息。
  • 有序递增 :如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且,很多时候 ,我们还很有可能会直接通过 ID 来进行排序。
  • 有具体的业务含义 :生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。
  • 独立部署 :也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。不过,这样同样带来了网络调用消耗增加的问题。总的来说,如果需要用到分布式 ID 的场景比较多的话,独立部署的发号器服务还是很有必要的。

数据库自增ID

最常见的方式。利用数据库,全数据库唯一(数据库一般只有一个主库,可以保证唯一)。

1.创建一个数据库表。

CREATE TABLE `sequence_id` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `stub` char(10) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  UNIQUE KEY `stub` (`stub`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

stub 字段无意义,只是为了占位,便于我们插入或者修改数据。并且,给 stub 字段创建了唯一索引,保证其唯一性。

2.通过 replace into 来插入数据。

BEGIN;
REPLACE INTO sequence_id (stub) VALUES ('stub');
SELECT LAST_INSERT_ID();
COMMIT;

插入数据这里,我们没有使用 insert into 而是使用 replace into 来插入数据,具体步骤是这样的:

  1. 尝试把数据插入到表中。
  2. 如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。

优点

  • 实现起来比较简单
  • ID 有序递增
  • 存储消耗空间小

缺点

  • 支持的并发量不大
  • 存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)
  • ID 没有具体业务含义
  • 安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )
  • 每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢)

数据库集群

做双主模式集群,也就是两个Mysql实例都能单独的生产自增ID。

设置起始值自增步长

MySQL_1 配置:

set @@auto_increment_offset = 1;     -- 起始值
set @@auto_increment_increment = 2;  -- 步长

MySQL_2 配置:

set @@auto_increment_offset = 2;     -- 起始值
set @@auto_increment_increment = 2;  -- 步长

在这里插入图片描述

水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了ID生成特性,将自增步长按照机器数量来设置。

增加第三台MySQL实例需要人工修改一、二两台MySQL实例的起始值和步长,把第三台机器的ID起始生成位置设定在比现有最大自增ID的位置远一些,但必须在一、二两台MySQL实例ID还没有增长到第三台MySQL实例的起始ID值的时候,否则自增ID就要出现重复了,必要时可能还需要停机修改。

优点

  • 解决DB单点问题

缺点

  • 不利于后续扩容。
  • 实际上单个数据库自身压力还是大,依旧无法满足高并发场景。

数据库号段模式

1.创建一个数据库表。

CREATE TABLE `sequence_id_generator` (
  `id` int(10) NOT NULL,
  `current_max_id` bigint(20) NOT NULL COMMENT '当前最大id',
  `step` int(10) NOT NULL COMMENT '号段的长度',
  `version` int(20) NOT NULL COMMENT '版本号',
  `biz_type`    int(20) NOT NULL COMMENT '业务类型',
   PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

current_max_id 字段和step字段主要用于获取批量 ID,获取的批量 id 为: current_max_id ~ current_max_id+step

img

version 字段主要用于解决并发问题(乐观锁),biz_type 主要用于表示业务类型。

2.先插入一行数据。

INSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`)
VALUES
	(1, 0, 100, 0, 101);

3.通过 SELECT 获取指定业务下的批量唯一 ID

SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101

结果:

id	current_max_id	step	version	biz_type
1	0	100	0	101

4.不够用的话,更新之后重新 SELECT 即可。

UPDATE sequence_id_generator SET current_max_id = 0+100, version=version+1 WHERE version = 0  AND `biz_type` = 101
SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101

结果:

id	current_max_id	step	version	biz_type
1	100	100	1	101

相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。

另外,为了避免单点问题,你可以从使用主从模式来提高可用性。

优点

  • ID 有序递增
  • 存储消耗空间小

缺点

  • 存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)
  • ID 没有具体业务含义
  • 安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )

Redis

Redis也同样可以实现,原理就是利用redisincr命令实现ID的原子性自增。

大致思路如下:

时间+用redis的incr自增命令(每日从1开始),代码如下:

public class RedisCounterRepository {
    private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    public RedisCounterRepository(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    // 根据获取的自增数据,添加日期标识构造分布式全局唯一标识,changeNumPrefix是自己定义的随机前缀
    private String getNumFromRedis(String changeNumPrefix) {
        String dateStr = LocalDate.now().format(dateTimeFormatter);
        Long value = incrementNum(changeNumPrefix + dateStr);
        //不足4位补0,redis从1开始生成的,每天再次请0
        return dateStr + StringUtils.leftPad(String.valueOf(value), 4, '0');
    }
    // 从redis中获取自增数据(redis保证自增是原子操作)
    private long incrementNum(String key) {
        RedisConnectionFactory factory = redisTemplate.getConnectionFactory();
        if (null == factory) {
            log.error("Unable to connect to redis.");
            throw new UserException(AppStatus.INTERNAL_SERVER_ERROR);
        }
        RedisAtomicLong redisAtomicLong = new RedisAtomicLong(key, factory);
        long increment = redisAtomicLong.incrementAndGet();
        if (1 == increment) {
            // 如果数据是初次设置,需要设置超时时间
            redisAtomicLong.expire(1, TimeUnit.DAYS);
        }
        return increment;
    }
}

优点

  • 性能不错、每秒10万并发量
  • 生成的 ID 是有序递增的

缺点

  • 存在Redis单点问题(可以使用Redis集群解决,不过增加了复杂度)
  • ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )
  • 每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢)

MongoDB

MongoDB ObjectId 一共需要 12 个字节存储:

  • 0~3:时间戳
  • 3~6: 代表机器 ID
  • 7~8:机器进程 ID
  • 9~11 :自增值

优点

  • 性能不错
  • 生成的 ID 是有序递增的

缺点

  • 需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)
  • 有安全性问题(ID 生成有规律性)

UUID

UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。

JDK 就提供了现成的生成 UUID 的方法,一行代码就行了。

	String uuid = UUID.randomUUID().toString().replaceAll("-","");

我们这里重点关注一下这个 Version(版本),不同的版本对应的 UUID 的生成规则是不同的。

5 种不同的 Version(版本)值分别对应的含义(参考维基百科对于 UUID 的介绍):

  • 版本 1 : UUID 是根据时间和节点 ID(通常是 MAC 地址)生成;
  • 版本 2 : UUID 是根据标识符(通常是组或用户 ID)、时间和节点 ID 生成;
  • 版本 3、版本 5 : 版本 5 - 确定性 UUID 通过散列(hashing)名字空间(namespace)标识符和名称生成;
  • 版本 4 : UUID 使用随机性伪随机性生成。

JDK 中通过 UUIDrandomUUID() 方法生成的 UUID 的版本默认为 4。

优点

  • 生成速度比较快、简单易用

缺点

  • 存储消耗空间大(32 个字符串,128 位)
  • 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)
  • 无序(非自增)
  • 没有具体业务含义
  • 需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)

Snowflake

snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。生成的是一个64位的二进制正整数,然后转换成10进制的数。64位二进制数由如下部分组成:

160888009282358

其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。

优点:

  • 生成速度比较快
  • 生成的 ID 有序递增
  • 比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID)

缺点

  • 需要解决重复 ID 问题(依赖时间,当机器时间不对的情况下,可能导致会产生重复 ID)。

UidGenerator

UidGenerator 是百度开源的一款基于 Snowflake(雪花算法)的唯一 ID 生成器。

不过,UidGenerator 对 Snowflake(雪花算法)进行了改进,生成的唯一 ID 组成如下。

img

可以看出,和原始 Snowflake(雪花算法)生成的唯一 ID 的组成不太一样。并且,上面这些参数我们都可以自定义。

UidGenerator 官方文档中的介绍如下:

img

Leaf

Leaf 是美团开源的一个分布式 ID 解决方案 。这个项目的名字 Leaf(树叶) 起源于德国哲学家、数学家莱布尼茨的一句话: “There are no two identical leaves in the world”(世界上没有两片相同的树叶) 。这名字起得真心挺不错的,有点文艺青年那味了!

img

Leaf 提供了 号段模式Snowflake(雪花算法) 这两种模式来生成分布式 ID。并且,它支持双号段,还解决了雪花 ID 系统时钟回拨问题。不过,时钟问题的解决需要弱依赖于 Zookeeper 。

Leaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。

Leaf 对原有的号段模式进行改进,比如它这里增加了双号段避免获取 DB 在获取号段的时候阻塞请求获取 ID 的线程。简单来说,就是我一个号段还没用完之前,我自己就主动提前去获取下一个号段(图片来自于美团官方文章:《Leaf——美团点评分布式 ID 生成系统》)。

img

根据项目 README 介绍,在 4C8G VM 基础上,通过公司 RPC 方式调用,QPS 压测结果近 5w/s,TP999 1ms。

Tinyid

Tinyid 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。

数据库号段模式的原理我们在上面已经介绍过了。Tinyid 有哪些亮点呢?

为了搞清楚这个问题,我们先来看看基于数据库号段模式的简单架构方案。(图片来自于 Tinyid 的官方 wiki:《Tinyid 原理介绍》

up-4afc0e45c0c86ba5ad645d023dce11e53c2

在这种架构模式下,我们通过 HTTP 请求向发号器服务申请唯一 ID。负载均衡 router 会把我们的请求送往其中的一台 tinyid-server。

这种方案有什么问题呢?在我看来(Tinyid 官方 wiki 也有介绍到),主要由下面这 2 个问题:

  • 获取新号段的情况下,程序获取唯一 ID 的速度比较慢。
  • 需要保证 DB 高可用,这个是比较麻烦且耗费资源的。

除此之外,HTTP 调用也存在网络开销。

Tinyid 的原理比较简单,其架构如下图所示:

img

相比于基于数据库号段模式的简单架构方案,Tinyid 方案主要做了下面这些优化:

  • 双号段缓存 :为了避免在获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 Tinyid 中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段。
  • 增加多 db 支持 :支持多个 DB,并且,每个 DB 都能生成唯一 ID,提高了可用性。
  • 增加 tinyid-client :纯本地操作,无 HTTP 请求消耗,性能和可用性都有很大提升。

Java自定义

一般就是时间+机器码+jvm内部的自增数列组成,这样效率很高,也不依赖于第三方中间件(如redis),建议必须掌握。

我们的生成逻辑如下,采用的是机器码+毫秒数+自增序列

public class IdWorker {
    //当前机器对应的编码,在初始化的时候加载一次,所以要设置为static,表示属于整个类
    private static String machineId;
    static {
        InetAddress ia = null;
        String machineName = "";
        try {
            ia = InetAddress.getLocalHost();
            //机器有多个IP的,获取首个
            machineName += ia.getHostAddress();
            //一个机器可能部署多个程序,但路径肯定不一样,获取当前项目路径的地址
            machineName += System.getProperty("user.dir");
            //然后在数据库中存储一张表,字段是自增id和机器名,然后先查询,如果没有的话insert,就可以用自增id作为机器唯一标识了
            //此处先做个范例,设个默认值
            machineId = "2";
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    //生成自增id的相关属性
    private static AtomicInteger currentSequence = new AtomicInteger(100);
    public static String nextId() {
        StringBuffer sb = new StringBuffer(machineId);
        long currentTimes = System.currentTimeMillis();
        //原子增加并且返回值
        int result = currentSequence.getAndIncrement();
        //new AtomicInteger(100)表示从100开始自增,是为了笔墨1和21还有101这种位数不一样需要补零的情况
        //为了防止AtomicInteger一直增长导致位数变化,此处设置当数字大于某个值后将值设置为原点,也就是每毫秒不生成800个订单ID就没问题
        //上一步到这一步可能有并发问题,也就是同时有多个程序已经自增了,所以会出现901,902和903等情况
        if (result > 900){
            currentSequence.set(100);;
        }
        sb.append(machineId).append(currentTimes).append("-").append(result);
        return sb.toString();
    }
    public static void main(String[] args) {
    }
}

优点

  • 实现简单
  • ID 有序递增
  • 存储消耗空间小

缺点

  • 每个节点每毫秒生成数量不能超过800个,需要总量预判
  • 需要解决重复 ID 问题(依赖时间,当机器时间不对的情况下,可能导致会产生重复 ID)。

参考链接

posted @ 2022-07-26 22:38  Faetbwac  阅读(214)  评论(0编辑  收藏  举报