为什么要分布式ID
分布式系统中我们会对一些数据量大的业务进行分拆,如:用户表,订单表。因为数据量巨大一张表无法承接,就会对其进行分库分表。
但一旦涉及到分库分表,就会引申出分布式系统中唯一主键ID的生成问题 不然会造成数据ID重复, 唯一ID的特性:
-
整个系统ID唯一
-
ID是数字类型,而且是趋势递增的
-
ID简短,查询效率快
什么是递增? 如:第一次生成的ID为12,下一次生成的ID是13,再下一次生成的ID是14。这个就是生成ID递增。
什么是趋势递增? 如:在一段时间内,生成的ID是递增的趋势。如:再一段时间内生成的ID在【0,1000】之间,过段时间生成的ID在【1000,2000】之间。但在【0-1000】区间内的时候,ID生成有可能第一次是12,第二次是10,第三次是14。
方法
1. UUID
算法的核心思想是结合机器的网卡、当地时间、一个随记数来生成UUID。
-
优点:本地生成,生成简单,性能好,没有高可用风险,
-
因为是全球唯一的ID,所以迁移数据容易
-
缺点:长度过长,存储空间大,且无序不可读,查询效率低
应用场景:
-
类似生成token令牌的场景
-
不适用一些要求有趋势递增的ID场景
2. 数据库自增ID
使用数据库的id自增策略,如 MySQL 的 auto_increment。并且可以使用两台数据库分别设置不同步长,生成不重复ID的策略来实现高可用。
-
优点:数据库生成的ID绝对有序,高可用实现方式简单
-
缺点:一旦把步长定好后,就无法扩容;而且单个数据库的压力大,数据库自身性能无法满足高并发
应用场景:
-
数据不需要扩容的场景
Redis自增
Redis的所有命令操作都是单线程的,本身提供像 incr 和 increby 这样的自增原子命令,所以能保证生成的 ID 肯定是唯一有序的。
-
优点:不依赖于数据库,且性能优于数据库;数字ID天然排序,对分页或者需要排序的结果很有帮助。
-
缺点:占用带宽,每次要向redis进行请求 一旦Redis挂了,整个系统不可用。
雪花算法
目前流行的分布式ID解决方案有两种:「号段模式」和「雪花算法」。
「号段模式」依赖于数据库,但是区别于数据库主键自增的模式。假设100为一个号段100,200,300,每取一次可以获得100个ID,性能显著提高。
「雪花算法」是由符号位+时间戳+工作机器id+序列号组成的,如图所示:
如上图的所示,Twitter 的 Snowflake 算法由下面几部分组成:
-
1位符号位:
由于 long 类型在 java 中带符号的,最高位为符号位,正数为 0,负数为 1,且实际系统中所使用的ID一般都是正数,所以最高位为 0。
-
41位时间戳(毫秒级):
需要注意的是此处的 41 位时间戳并非存储当前时间的时间戳,而是存储时间戳的差值(当前时间戳 - 起始时间戳),这里的起始时间戳一般是ID生成器开始使用的时间戳,由程序来指定,所以41位毫秒时间戳最多可以使用 (1 << 41) / (1000x60x60x24x365) = 69年
。
-
10位数据机器位:
包括5位数据标识位和5位机器标识位,这10位决定了分布式系统中最多可以部署 1 << 10 = 1024
s个节点。超过这个数量,生成的ID就有可能会冲突。
-
12位毫秒内的序列:
这 12 位计数支持每个节点每毫秒(同一台机器,同一时刻)最多生成 1 << 12 = 4096个ID
加起来刚好64位,为一个Long型。
在分布式场景中,服务器时钟回拨会经常遇到,一般存在10ms之间的回拨;小伙伴们就说这点10ms,很短可以不考虑吧。但此算法就是建立在毫秒级别的生成方案,一旦回拨,就很有可能存在重复ID。
缺点:
-
优点:
-
此方案每秒能够产生409.6万个ID,性能快
-
时间戳在高位,自增序列在低位,整个ID是趋势递增的,按照时间有序递增
-
灵活度高,可以根据业务需求,调整bit位的划分,满足不同的需求
-
依赖机器的时钟,如果服务器时钟回拨,会导致重复ID生成
雪花算法能存放多少数据?时间范围:2^41 / (3652460601000) = 69年 工作进程范围:2^10 = 1024 序列号范围:2^12 = 4096,表示1ms可以生成4096个ID。
分布式ID开源组件
百度UidGenerator是Java语言的;最近一次提交记录是两年前,基本无人维护;只支持雪花算法。
美团Leaf也是Java语言的;最近维护为2020年;支持号段模式和雪花算法。
美团leaf
3.1、改造数据库主键自增
上述我们介绍了利用数据库的自增主键的特性,可以实现分布式ID;这个ID比较简短明了,适合做userId,正好符合如何永不迁移数据和避免热点? 根据服务器指标分配数据量(揭秘篇)文章中的ID的需求。但这个方案有严重的问题:
-
一旦步长定下来,不容易扩容
-
数据库压力山大
小伙伴们看看怎么优化这个方案。先看数据库压力大,为什么压力大?是因为我们每次获取ID的时候,都要去数据库请求一次。那我们可以不可以不要每次去取?
思路我们可以请求数据库得到ID的时候,可设计成获得的ID是一个ID区间段。
img
我们看上图,有张ID规则表:
1、id表示为主键,无业务含义。
2、biz_tag为了表示业务,因为整体系统中会有很多业务需要生成ID,这样可以共用一张表维护
3、max_id表示现在整体系统中已经分配的最大ID
4、desc描述
5、update_time表示每次取的ID时间
我们再来看看整体流程:
1、【用户服务】在注册一个用户时,需要一个用户ID;会请求【生成ID服务(是独立的应用)】的接口
2、【生成ID服务】会去查询数据库,找到user_tag的id,现在的max_id为0,step=1000
3、【生成ID服务】把max_id和step返回给【用户服务】;并且把max_id更新为max_id = max_id + step,即更新为1000
4、【用户服务】获得max_id=0,step=1000;
5、 这个用户服务可以用ID=【max_id + 1,max_id+step】区间的ID,即为【1,1000】
6、【用户服务】会把这个区间保存到jvm中
7、【用户服务】需要用到ID的时候,在区间【1,1000】中依次获取id,可采用AtomicLong中的getAndIncrement方法。
8、如果把区间的值用完了,再去请求【生产ID服务】接口,获取到max_id为1000,即可以用【max_id + 1,max_id+step】区间的ID,即为【1001,2000】
这个方案就非常完美的解决了数据库自增的问题,而且可以自行定义max_id的起点,和step步长,非常方便扩容。
而且也解决了数据库压力的问题,因为在一段区间内,是在jvm内存中获取的,而不需要每次请求数据库。即使数据库宕机了,系统也不受影响,ID还能维持一段时间。
3.2、竞争问题
以上方案中,如果是多个用户服务,同时获取ID,同时去请求【ID服务】,在获取max_id的时候会存在并发问题。
如用户服务A,取到的max_id=1000 ;用户服务B取到的也是max_id=1000,那就出现了问题,Id重复了。那怎么解决?
其实方案很多,加分布式锁,保证同一时刻只有一个用户服务获取max_id。当然也可以用数据库自身的锁去解决。
img
利用事务方式加行锁,上面的语句,在没有执行完之前,是不允许第二个用户服务请求过来的,第二个请求只能阻塞。
3.3、突发阻塞问题
img
上图中,多个用户服务获取到了各自的ID区间,在高并发场景下,ID用的很快,如果3个用户服务在某一时刻都用完了,同时去请求【ID服务】。因为上面提到的竞争问题,所有只有一个用户服务去操作数据库,其他二个会被阻塞。
小伙伴就会问,有这么巧吗?同时ID用完。我们这里举的是3个用户服务,感觉概率不大;如果是100个用户服务呢?概率是不是一下子大了。
出现的现象就是一会儿突然系统耗时变长,一会儿好了,就是这个原因导致的,怎么去解决?
3.4、双buffer方案
在一般的系统设计中,双buffer会经常看到,怎么去解决上面的问题也可以采用双buffer方案。
img
在设计的时候,采用双buffer方案,上图的流程:
1、当前获取ID在buffer1中,每次获取ID在buffer1中获取
2、当buffer1中的Id已经使用到了100,也就是达到区间的10%
3、达到了10%,先判断buffer2中有没有去获取过,如果没有就立即发起请求获取ID线程,此线程把获取到的ID,设置到buffer2中。
4、如果buffer1用完了,会自动切换到buffer2
5、buffer2用到10%了,也会启动线程再次获取,设置到buffer1中
6、依次往返
双buffer的方案,小伙伴们有没有感觉很酷,这样就达到了业务场景用的ID,都是在jvm内存中获得的,从此不需要到数据库中获取了。允许数据库宕机时间更长了。
因为会有一个线程,会观察什么时候去自动获取。两个buffer之间自行切换使用。就解决了突发阻塞的问题。