技术干货实战(2)- 聊一聊分布式系统全局唯一ID的几种实现方式

作者: 修罗debug
版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。



现如今可谓是微服务、分布式、IoT(物联网)横行的时代,作为一名开发者始终还是要保持一定的危机意识,特别是在日常的项目开发中,若是有机会接触到一些关于微服务、分布式下的应用场景,应当硬着头皮、排除万难,主动应承下来 上去大干一场;这期间不管结果如何,积累下来的经验将会让自己受益匪浅;而本文要介绍的“分布式全局唯一ID”便是一种典型的分布式应用场景!!!

话不多说,咱们直接进入正题~~~

说起这个全局唯一ID,你可能会第一时间想到“数据库的自增主键”、“UUID”、“雪花算法”等等,更有甚者,还能说出一些大厂开源的组件,比如滴滴的IDWorker、美团的Leaf等等,没错,这些确实是可以实现全局唯一ID的方案,你能想到这些点,那涉猎其实还是挺广的;


而对于“全局唯一ID/编号/编码”的应用场景,在现实生活中还是比较多的,比如电商平台中“订单系统”的订单编号,“进销存系统”中的商品编号、订单编号,“支付”过程中订单流水号等等;接下来debug将会总结性的介绍下目前市面上比较流行的“全局唯一ID”的几种实现方式,并针对分布式场景下的几种实现方式进行代码实战


话不多说,直接进入正题,先贴张思维导图吧,总结性地概括下目前网上比较流行的几种方式(当然啦,图片来源于互联网哈,因为debug懒得去制作了!)


结合上图几种方式,debug再概括性的介绍下吧:

一、数据库的自增主键

简介:这一点相信写过代码的小伙伴都晓得,主要利用主键IDauo_increment特性,每进来一条数据时数据库自动为其生成当前最大的ID并作为该条记录的主键;

优点:简单、便捷;

缺点:只能限于单机,严重依赖于DB,仅可限于DB相关的业务,可用性还是有点差;


二、批量预生成ID

简介:DB只存储当前最大的ID值,每次需要ID时,则按照顺序批量生成N个有序的ID列表,并将最大的ID + N

优点:相对于第一种方式性能还是提高了不少;

缺点:只能限于单机,还是仍然得依赖于DB,可用性还是有点差;而且批量生成的ID可能断层(比如服务挂了然后重启就可能跳过部分ID,如果服务有多个,将难以保证其有序性)


三、UUID的方式

简介:通用唯一识别码,这个估计众所周知啦,不作过多的介绍了!

优点:简单,直接 UUID.randomUUID().toString() 即可搞定;

缺点:比较长、占用空间大;无序且不利于索引,在实际项目中不建议使用;特别是在插入数据库时如果用UUID生成的ID作为主键的话,很可能会引起B+树的不断重平衡;


四、基于时间戳

简介:比如按照规则:yyyyMMdd + N位随机数 或者 yyyyMMddHHmmss + N位随机数

优点:可行,而且生成的ID编号前半段有序,有一定的业务意义;

缺点:当并发产生的数据量比较大时,那N位随机数会出现重复的可能(虽然可以通过各种方式去重,比如RedisSet,但代价还是相当高的,因为得不断的 while判断是否重复


五、SnowFlake算法

简介:Twitter开源的一种分布式ID生成算法,结果是一个Long型的64位的ID;其核心思想是将64位划分为各个段,其中0号位不用,连续41位表示时间戳,连续10位表示工作机器ID,最后12位则表示毫秒级别的序列号,如下图所示:


优点:可以说是分布式场景下生成全局唯一ID的一种经典算法吧,采用Java生成,对于咱们Java的小伙伴来说可以说是相当接地气的了;

缺点:目前倒没发现有啥缺陷,如果硬要说有,那就是“时钟回播”的问题了,但其实没啥事的话别乱重置系统时钟或者乱调系统时钟则一般是没啥问题的!如果还说它仍然有缺点的话,那就是它的算法实现逻辑,即nextId()方法里面的代码还真的挺复杂,一堆位运算 理解起来确实比较消耗脑细胞(除此之外,那就是它最终生成的ID长度有点长啦)!


六、原子操作类AtomicXX

简介:JUC包下经典的原子操作类,可以基于它生成自增、有序且全局唯一的编号

优点:底层采用CASCompare And Swap)机制实现,并发场景下可以保证“自增”代码逻辑的安全性;

缺点:依赖于JDK,只适合单机环境


七、RedisINCRBY操作

简介:熟悉这个命令的应该都知道它是啥意思,不知道的 自己打开redis-cli执行下该命令就可以了!

优点:可行,分布式场景下是适用的;

缺点:基本上没想到有啥缺陷,如果要挑刺的话,那就是依赖于中间件服务,如果Redis挂掉,那基本上该ID生成服务就不可用了(其实,这有点杠的嫌疑哈,年轻人 不要搞内斗哈 ~ 你不会做Redis集群部署保证其高可用吗?)


八、基于ZooKeeper的节点版本号生成ID

简介:这个大家可能有点陌生,其实就是利用ZooKeeper底层树形节点ZNode(类似于Windows的文件目录数)的有序性,循环不断生成其对应的版本号或者节点本身的数据

优点:可行,分布式场景下是适用的;

缺点:基本上没想到有啥缺陷,跟第七点类似吧,需要保证ZK服务的高可用即可

 

啰里啰嗦介绍了这么多,接下来咱们还是得进入代码实战,其中的场景可以暂设定为:生成全局唯一的、数据格式为:yyyyMMddHHmmss + N位的数值码(N=4或者N=6比较常见),其中要求最终生成的码全局唯一、有序且最好有一定的业务意义,那废话少说,咱们直接开干吧!

  

、基于原子操作类AtomicXX

1)需求分析:对于前半部分 yyyyMMddHHmmss 这个还是比较简单的,基于SimpleDateFormat即可解决(但要注意它本身并非线程安全),而至于后面的 N位数值码,在这里可以用 AtomicLong 进行实现,假设 N=6,则其初始值可以设定为100000,也就是说在一段时间内可以生成999999个编码,在秒级并发场景下这应该足够了;

 要注意的是,当系统运行到一段时间后,如达到999999时需要将其重置回100000,或者也可以通过不定时上线、重启亦可以达到效果

2为了测试生成的ID/编码是否全局唯一,我们建了一个简单的数据库表qr_code,其DDL如下所示,后续可以通过group bysql语句统计相同的code出现的次数:   

CREATE TABLE `qr_code` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`code` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '编码',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

紧接着,创建一控制器类QrCodeController,并在其中创建相应的请求方法,用于JMeter压力测试,如下代码所示:

@Autowired
private QrCodeMapper qrCodeMapper;

//随机的后6位商品编号,毫秒级上限为999999,应该是满足的 (只要间隔一定的频率重新发布/重//启应用时,则当前计数器将重置为 100000)
private static AtomicLong atomicLong=new AtomicLong(100000);

@PostMapping("generate/code/v2")
public BaseResponse generateCodeV2(){
BaseResponse response=new BaseResponse(StatusCode.Success);
try {
//方式一:原子操作数(单体应用系统架构)
qrCodeMapper.insertSelective(new QrCode(generateCodeInV1()));
}catch (Exception e){
response=new BaseResponse(StatusCode.Fail.getCode(),e.getMessage());
}
return response;
}
//全局唯一编码 - 正常情况 - 单体应用系统架构下可用 “原子操作数” 控制并发(加上本身就有//计数功能)
private String generateCodeInV1(){
SimpleDateFormat format=new SimpleDateFormat("yyyyMMddHHmmss");
return format.format(new Date()) + atomicLong.getAndIncrement();
}

之后,将项目运行起来并打开JMeter建立一测试计划,直接设定1秒内并发线程数为10000,如下图所示:


完了之后,查看数据库表,先看下总数会发现一共10000条数据完美进入DB,与此同时执行下下面的SQL查看下是否有相同的code出现2次或者2次以上的,如下图所示:   

SELECT
`code`,
COUNT(id) AS total
FROM
qr_code
GROUP BY
`code`
HAVING total > 1


OK,此种实现方式基本上就没啥问题了,但有点要注意的话,这种方式依赖于JDK,只适用于单体应用系统架构,如果是传统的企业级应用系统需要生成全局唯一的ID/编号,那这种方式应该没啥问题!

、基于SnowFlake算法

 SnowFlake算法就不作过多介绍了,完整的介绍可以到开源网站github上进行阅览:https://github.com/souyunku/SnowFlake ,其核心思想在于“分段”,并基于高效的位操作加以实现,感兴趣的小伙伴可以去研究研究它的源码,在此debug就简单介绍介绍吧:

0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000

64位,第一位为未使用,接下来的41位为毫秒级时间(41位的长度可以使用69),然后是5datacenterId5workerId(10位的长度最多支持部署1024个节点) ,最后12位是毫秒内的计数(12位的计数顺序可以支持每个节点每毫秒产生4096ID序号);一共加起来刚好64位,为一个Long(转换成字符串长度为18)

SnowFlake生成的ID整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由datacenterworkerId作区分),并且效率较高,据说:snowflake每秒能够产生26万个ID;下面简单进入实战吧(借助Hutool工具即可):

@Autowired
private QrCodeMapper qrCodeMapper;

//随机的后6位商品编号,毫秒级上限为999999,应该是满足的 (只要间隔一定的频率重新发布/重启应用时,则当前计数器将重置为 100000)
private static AtomicLong atomicLong=new AtomicLong(100000);

@PostMapping("generate/code/v2")
public BaseResponse generateCodeV2(){
BaseResponse response=new BaseResponse(StatusCode.Success);
try {
//方式二:雪花算法(单体/分布式)
qrCodeMapper.insertSelective(new QrCode(generateCodeInV2()));
}catch (Exception e){
response=new BaseResponse(StatusCode.Fail.getCode(),e.getMessage());
}
return response;
}

private static final Snowflake snowflake=new Snowflake(5,5);

//全局唯一编码 - 雪花算法
private String generateCodeInV2(){
//为了凑够20位
return snowflake.nextIdStr()+RandomStringUtils.randomNumeric(1);
}

直接压测一番,然后看结果吧:



RedisINCRBY操作

我们仍然假设 N=6,即可以将其初始值设定为99999,然后通过INCRBY命令对应的操作不断进行 +1 操作;此种方式主要是利用Redis的命令具有原子操作的特性(单线程,但支持并发),因此在分布式高并发的场景下这一方式是顶得住的;

只不过仍然需要设定一个检测机制,判断是否已经达到了  999999 ,如果是,则需要将其重置回  99999 (由于前半段的数据格式精确到毫秒ms,因此可能会出现的差错也是毫秒级别的出错概率);

如下代码所示:

@Autowired
private RedisTemplate redisTemplate;

private static final String RedisKeyCode="sb:technology:code:v1";

private static final Long LimitMaxCode=1000L;

private static final Long InitKeyCode=99L;

//每次项目重启都可以将其重置为初始值
@PostConstruct
public void init(){
redisTemplate.delete(RedisKeyCode);

redisTemplate.opsForValue().set(RedisKeyCode,InitKeyCode);
}

//全局唯一编码 - Redis:要使用 Redis的 INCRBY命令,需要设置缓存中key的序列化机制为://StringRedisSerializer;
//不然会出现:ERR value is not an integer or out of range
private String generateCodeInV3(){
SimpleDateFormat format=new SimpleDateFormat("yyyyMMddHHmmss");
Long currCode=redisTemplate.opsForValue().increment(RedisKeyCode,1L);
//编码上限/阈值检测机制
if (Objects.equals(LimitMaxCode,currCode)){
redisTemplate.opsForValue().set(RedisKeyCode,InitKeyCode);
currCode=redisTemplate.opsForValue().increment(RedisKeyCode,1L);
}
return format.format(new Date()) + currCode;
}

为了压测方便,debug将其中的N=3,即初始值为99,从100开始计数,最大编码上限为1000(不能取到),这样的话,JMeter压测时QPS设置10000时就会有很多次达到 1000,触发重置机制,如下图所示为QPS=10000时的压测结果:






至此,也完成了此种方式的代码实战,感兴趣的小伙伴可以撸一撸!!!   

、基于ZooKeeper的节点版本号生成ID

这种方式的话得需要大致知晓ZooKeeper底层的系统架构和数据的存储结构,其数据存储结构可以简单地理解为“类Windows操作系统的文件目录结构树”,即多节点ZNode树形结构,节点与节点之间串成一路径Path,以此用于区分、标识存储的唯一数据;

在这里debug是基于zk本身节点的版本号来构成全局唯一ID、编码的,话不多说,直接上代码吧:   

//zookeeper生成全局唯一标志符的方式
private static final String ID_NODE = "/QRCodeV2";

//zk客户端实例
@Autowired
private CuratorFramework client;

//全局唯一编码 - zookeeper
private String generateCodeInV4() throws Exception{
if (null == client.checkExists().forPath(ID_NODE)) {
//PERSISTENT(0, false, false) 持久型节点; PERSISTENT_SEQUENTIAL(2, false, true) 持久顺序型节点;
//EPHEMERAL(1, true, false) 临时型节点;EPHEMERAL_SEQUENTIAL(3, true, true) 临时顺序型节点;
client.create().withMode(CreateMode.PERSISTENT).forPath(ID_NODE, new byte[0]);
}

//根据节点的版本号-从0开始递增的,因此位数也是不断在变化的(只要path不变)
Stat stat = client.setData().forPath(ID_NODE,new byte[0]);
SimpleDateFormat format=new SimpleDateFormat("yyyyMMddHHmmss");

return format.format(new Date()) + stat.getVersion();
}

其中,要注意的是ZooKeeper客户端实例CuratorFramework相关属性的自定义注入与配置,如下所示:

@Configuration
public class ZooKeeperConfig {
private static final String ZK_ADDRESS = "127.0.0.1:2181";

@Bean
public CuratorFramework curatorFramework(){
//如果获取链接失败,则重试3次,每次间隔2s
RetryPolicy policy=new RetryNTimes(3,2000);
//获取链接到zk服务的客户端实例
CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
.connectString(ZK_ADDRESS).sessionTimeoutMs(5000).connectionTimeoutMs(10000)
.retryPolicy(policy).build();
//启动客户端
curatorFramework.start();
return curatorFramework;
}
}

需要在本地127.0.0.1这里将zookeeper服务开起来,如果是windows环境下的,可以来这里下载:https://www.fightjava.com/web/index/resource/10 ,双击bin目录里面的zkServer.cmd 即可开心的耍起来!!!

如下所示为最终的压测结果:



总结:

1代码下载:关注“程序员实战基地”微信公众号,回复“分布式id”,即可获取代码下载链接   

2至此,我们已经介绍完了N种“全局唯一ID/编码”实现方式的介绍以及在分布式场景下的代码实战实现;具体要选择哪一种,还是那句老话:视具体的业务场景、服务器配置以及技术人员的技术掌握程度进行抉择;在本文debug有提供了一种单体下的实现方式、也提供了多种分布式场景下的实现方式(当然啦,既然是分布式,也就适用于单体的场景啦),话不多说,诸位年轻人还是亲自上去撸一撸吧!


我是debug,一个相信技术改变生活、技术成就梦想 的攻城狮;如果本文对你有帮助,请关注公众号,并动动手指收藏、点赞、以及转发哦!!!   

关注一下Debug的技术微信公众号,最新的技术文章、课程以及技术专栏将会第一时间在公众号发布哦