架构设计-分布式ID

一、 分布式ID基础

1.背景

1.不要用主键ID作为业务单号的唯一标识,因为一是数据同步麻烦,第二一旦业务数据扩张涉及到分库分表则数据维护麻烦,因为此时主键ID容易造成重复 。

2.对于有相似属性的业务ID如直播或者录播ID存储在业务表中的一个字段,一旦程序员哪天状态不好忘记区分类型,就很容易造成数据紊乱造成难以估量的损失。

1.为什么要引用分布式主键ID?

在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识。如在美团点评的金融、支付、餐饮、酒店、猫眼电影等产品的系统中,数据日渐增长,对数据分库分表后需要有一个唯一ID来标识一条数据或消息,数据库的自增ID显然不能满足需求;特别一点的如订单、骑手、优惠券也都需要有唯一ID做标识。此时一个能够生成全局唯一ID的系统是非常必要的
 

比如单机 MySQL 数据库,前期因为业务量不大,只是使用单个数据库存数据,后期发现业务量一下子就增长,单机 MySQL 已经不能满足于现在的数据量,单机 MySQL 已经没办法支撑了,这时候就需要进行分库分表。

在分库分表之后会有一个问题, 数据发布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了,那样就无法作为业务的唯一标识了。如下图主键 ID 重复:

在这里插入图片描述

2.引用分布式主键ID能解决什么问题?

分表之后,不同表生成全局唯一的Id是非常棘手的问题。因为同一个逻辑表内的不同,实际表之间的自增键是无法互相感知的, 这样会造成重复Id的生成。

比如如果涉及到查询多张表进行排序等,分布式主键ID性能将更高。

3.分布式设计重点

1.基础要求

1.全局唯一性:不能出现重复的ID号,既然是唯一标识,这是最基本的要求。

2.趋势递增:在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。

3.单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求。

4.信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则、不规则。

5.分布式id里面最好包含时间戳,这样就能够在开发中快速了解这个分布式id的生成时间

2.分布式id的高要求

可用性高:就是我用户发了一个获取分布式id的请求,那么你服务器就要保证99.999%的情况下给我创建一个分布式id

延迟低:就是我用户给你一个获取分布式id的请求,那么你服务器给我创建一个分布式id的速度就要快

高QPS:这个就是用户一下子有10万个创建分布式id请求同时过去了,那么你服务器要顶的住,你要一下子给我成功创建10万个分布式id


 

二、 分布式主键ID生成的几种策略

下面是常用的策略

在这里插入图片描述



 

1.UUID生成分布式主键

UUID是通用唯一识别码 (Universally Unique Identifier),在其他语言中也叫GUID,可以生成一个长度32位的全局唯一识别码。

代码示例

String uuid = UUID.randomUUID().toString()

结果示例:

046b6c7f-0b8a-43b9-b35d-6489e6daee91

实现原理:

1,当前日期和时间戳
2,时钟序列。 计数器
3,全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。

优点:代码简单,性能好(本地生成,没有网络消耗),保证唯一(相对而言,重复概率极低可以忽 略)

1.UUID的缺点

由于InnoDB采用的B+Tree索引特性,UUID生成的主键插入性能较差
在这里插入图片描述

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

这就涉及到 B+树索引的分裂:

在这里插入图片描述
众所周知,关系型数据库的索引大都是B+树的结构,拿ID字段来举例,索引树的每一个节点都存储着若干个ID。

如果我们的ID按递增的顺序来插入,比如陆续插入8,9,10,新的ID都只会插入到最后一个节点当中。当最后一个节点满了,会裂变出新的节点。这样的插入是性能比较高的插入,因为这样节点的分裂次数最少,而且充分利用了每一个节点的空间。

在这里插入图片描述
但是,如果我们的插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能。

2.数据库自增主键

假设名为table的表有如下结构:

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 字段创建了唯一索引,保证其唯一性。

每一次生成ID的时候,访问数据库,执行下面的语句:

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

REPLACE INTO 的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据。

这样一来,每次都可以得到一个递增的ID。

为了提高性能,在分布式系统中可以用DB proxy请求不同的分库,每个分库设置不同的初始值,步长和分库数量相等:

在这里插入图片描述

这样一来,DB1生成的ID是1,4,7,10,13…,DB2生成的ID是2,5,8,11,14…

1.缺点

在这里插入图片描述

3.号段模式

号段模式一般也是基于数据库自增实现分布式 ID 的一种方式,是当下分布式 ID 生成方式中比较流行的一种,其使用可以简单理解为每次从数据库中获取生成的 ID 号段范围,将范围数据获取到应用本地后,在范围内循递增生成一批 ID,然后将这批数据存入缓存。

每次应用需要获取 ID 时,这时就候就可以从缓存中读取 ID 数据,当缓存中的 ID 消耗到一定数目时候,这时再去从数据库中读取一个号段范围,再执行生成一批 ID 操作存入缓存,这是一个重复循环的过程,这样重复操作每次都只是从数据库中获取待生成的 ID 号段范围,而不是一次次获取数据库中生成的递增 ID,这样减少对数据库的访问次数,大大提高了 ID 的生成效率。

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

在使用号段模式时,我们通常会先建立一张表用于记录上述的 ID 号段范围,如下:

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;

每次从数据库中获取号段 ID 的范围时,都会执行更新语句,其中计算新号段范围最大值 max_id 的公式是 current_max_id+ step 组成,所以 SQL 中设置 current_max_id= current_max_id + step 来执行更新语句,更新数据库中这个范围最大值 current_max_id,然后再通过查询语句查询更新后 ID 最大值,再根据最大值 current_max_id与步长 step 计算出待生成的 ID 的范围,SQL 如下:

update `sequence_id_generator` set`current_max_id` = current_max_id+ step, `version` = version + 1 where `version` = #{执行更新的版本号} and `biz_type` = #{业务类型}

select`current_max_id`, `step`, `version` from`sequence_id_generator` where`biz_type` = #{业务类型}

实战

下面实现数据库号段模式生成 ID 过程描述:

例如,某个业务需要批量获取 ID,首先它往数据库 sequence_id_generator 中插入一条初始化值,设置 current_max_id = 0 和步长 step=100 及使用该 ID 的业务标识 biz_type=test 与版本 version=0,如下:

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

这时数据库中多了一条数据:

在这里插入图片描述
然后以 biz_type 作为筛选条件,从数据库 sequence_id_generator 中读取 current_max_id 与 step 的值:

max_id:0- step:100
通过这两个值可以知道号段范围为 (0,100),生成该批量 ID 存入到缓存中,那么这时候缓存大小为100。

每次都从缓存中取值,创建一个监听器用于监听缓存中 ID 消耗比例,设置阈值,判断如果取值超过的阈值后就进行数据库号段更新操作,比如,设置阈值为 50%,当缓存中存在 100 个 ID,监听器监听到业务应用已经消耗到 50 个,已经超过阈值,创建一个新的线程去执行更新 SQL 语句,让数据库中号段范围按照设置的 step 扩大,然后获取新的号段最大值,应用中再生成一批范围为 (101,200) 范围的 ID 存入缓存供应用使用。

整个过程是一个循环的过程,每到消耗到一定数据后就会生成新的一批。相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。

数据库号段模式生成 ID 的缺点:

存在数据库单点问题,可以使用数据库集群解决,不过增加了复杂度数据库宕机会造成整个系统不可用。ID 号码不够随机,可能够泄露发号数量的信息,不太安全

4.snowflake雪花算法

1.实现原理

JDBC接口来实现对于生成Id的访问,而将底层具体的Id生成实现分离出来。如Sharding-JDBC使用分布式ID。

它是生成一个64bit的整性数字 第一位符号位固定为0,41位时间戳,10位workId,12位序列号 位数可以有不同实现

2.优劣分析

优点:

1.每个毫秒值包含的ID值很多,不够可以变动位数来增加,性能佳(依赖workId的实现)。

2.时间戳值在高位,中间是固定的机器码,自增的序列在低位,整个ID是趋势递增的。
3.能够根据业务场景数据库节点布置灵活挑战bit位划分,灵活度高。

缺点:

1.强依赖于机器时钟,如果时钟回拨,会导致重复的ID生成,所以一般基于此的算法发现时钟回拨, 都会抛异常处 理,阻止ID生成,这可能导致服务不可用。

3.雪花算法的时间回拨问题解决方案

1. 问题描述

简单说就是时间被调整回到了之前的时间,由于雪花算法重度依赖机器的当前时间,所以一旦发生时间回拨,将有可能导致生成的 ID 可能与此前已经生成的某个 ID 重复(前提是刚好在同一毫秒生成 ID 时序列号也刚好一致),这就是雪花算法最经常讨论的问题——时间回拨

2. 现象引发
  • 网络时间校准
  • 人工设置
  • 出现负闰秒(关于闰秒的介绍会在后面讲到)
3. 常见的处理方式

1.直接抛出异常

在雪花算法原本的实现中,针对这种问题,算法本身只是返回错误,由应用另行决定处理逻辑,如果是在一个并发不高或者请求量不大的业务系统中,错误等待或者重试的策略问题不大,但是如果是在一个高并发的系统中,这种策略显得过于粗暴。当然重拾算法也要有一个阀值,当超过阀值后,可以降级使用redis解决方案。当使用redis解决方案后,可以用一个定时任务检查,使用雪花算法的生成的ID是否大于当前库里的最大值,如果大于库最大值,再切换回雪花算法方案。

3.2 延迟等待

将当前线程阻塞3ms,之后再获取时间,看时间是否比上一次请求的时间大,如果大了,说明恢复正常了,则不用管如果还小,说明真出问题了,则抛出异常,缺点仍然如3.1所描述

参考资料:

『SnowFlake』雪花算法的详解及时间回拨解决方案

5.Redis 实现分布式 ID

1.实现原理

Redis由于是单线程模型,所以对redis的读写操作都是线程安全的,所以可以用它来保证分布式场景下的分布式ID唯一。

Redis 中存在原子操作指令 INCR 或 INCRBY,执行后可用于创建初始化值或者在原有数字基础上增加指定数字,并返回执行 INCR 命令之后 key 的值,这样就可以很方便的创建有序递增的 ID。

1、INCR: 将 key 中储存的数字 +1,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。

2、INCRBY: 将 key 中储存的数字加上指定的增量值,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。

Redis 生成 ID 示例:

127.0.0.1:6379> set sequence_id_biz_type 1
OK
127.0.0.1:6379> incr sequence_id_biz_type
(integer) 2
127.0.0.1:6379> get sequence_id_biz_type
"2"


使用 Redis 单机生成 ID 存在性能瓶颈,无法满足高并发的业务需求,且一旦 Redis 崩溃或者服务器宕机,那么将导致整个基于它的服务不可用,这是业务中难以忍受的。

为了提高可用性和并发,我们可以使用 Redis Cluser。Redis Cluser 是 Redis 官方提供的 Redis 集群解决方案。

除了 Redis Cluser 之外,你也可以使用开源的 Redis 集群方案Codis (大规模集群比如上百个节点的时候比较推荐)。

除了高可用和并发之外,我们知道 Redis 基于内存,我们需要持久化数据,避免重启机器或者机器故障后数据丢失。Redis 支持两种不同的持久化方式:快照(snapshotting,RDB)、只追加文件(append-only file, AOF)。并且,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。关于 Redis 持久化就不多说了,这个不是重点。

2.优劣分析

1.Redis 生成分布式 ID 的缺点:

1、增加了程序的复杂度,和硬件资源

2、如果 Rdeis 宕机,则服务不可用

3、可能达到Rdeis的性能瓶颈,则需要部署多台,使用步长方式

4、持久化问题,若使用RDB持久化策略则可能在最后一次持久化之前发生宕机则恢复后可能发生ID重复问题,若使用AOF持久化策略则在恢复Redis时需要较长时间。针对这种ID重复问题,常见的处理方案是重新获取DB的最大值,然后放进缓存,再重新计算。
 

3.针对redis 不可用或者key失效的解决方案

当key如果失效了,则从DB数据库中取最大值,然后再放进到缓存中,重新计算。

一下为代码实现:

 /**
     * 自动生成栋舍编码
     *
     * @return
     */
    private String generateHouseCode(Long planId) {
        String cacheKey = INTRODUCTION_HOUSE_PREFIX + planId;
        Boolean hasKey = redisService.hasKey(cacheKey);
        if (!hasKey) {
            Long maxCode = queryMaxCode(planId);
            redisService.setCacheObject(cacheKey, maxCode);
        }
        Long code = redisService.incr(cacheKey);
        return HOUSE_PREFIX + code;
    }

当redis不可用,则从DB中取最大值,然后基于服务内存运算,但这样会引进新的缓存击穿(每次都要去请求DB)和分布式并发问题(多个进程可能同时读到一个DB最大值)而导致数据不唯一。

但实际情况redis都是会部署成高可用,如果redis集群挂了它的影响面是非常广的,需要立即进行修复,所以放心大胆的用吧。

参考资料

1.漫画:什么是SnowFlake算法?https://blog.csdn.net/bjweimengshu/article/details/80162731
2.为什么需要分布式 ID,在项目中该怎么做?https://blog.csdn.net/wuhuayangs/article/details/125203180

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值