分布式系统中生成唯一ID在后台开发是经常遇到的架构设计,当然方案有很多,比如通过redis或者数据库实现自增。但是如果依赖redis或者数据库,会导致单点问题,在架构上反而需要考虑点更多,那怎么解决呢?
首先分布式唯一ID需要支持如下:
- 全局唯一:必须保证生成的ID是全局性唯一的;
- 有序性:生成的ID是有序,方便追溯和排序操作;
- 可用性:需要保证高并发下的可用性,除了对ID号码自身的要求,业务还对ID生成系统的可用性要求极高;
- 自主性:分布式环境下不依赖中心认证即可自行生成ID;
- 安全性:不暴露系统和业务的信息;
方案:
- 单机生成方式
- 第三方模块生成方式
- 号段生成方式
单机生成方式
1、UUID
UUID(Universally Unique Identifier,即通用唯一标识码)算法的目的是生成某种形式的全局唯一ID来标识系统中的任一元素,尤其是在分布式环境下,UUID可以不依赖中心认证即可自动生成全局唯一ID。UUID的标准形式为32个十六进制数组成的字符串,且分割为五个部分,例如:执行:cat /proc/sys/kernel/random/uuid,输出:70048d49-6ef3-4ba6-84c4-1e6e37ec2f4a。
缺点:
- 生成是随机的,无法做到顺序生成;
- 性能虽然高,但是输出的格式不一定符合业务要求,无法比较大小;
2、Snowflake
snowflake(雪花算法)是一个开源的分布式ID生成算法,结果是一个long型的ID。snowflake算法将64bit划分为多段,分开来标识机器、时间等信息,其中格式如下:
0 |00000...0000|000...0000|000000000000|
1bit| 41bit时间戳 |10bit机器号|12bit序列递增|
- 1bit保留位:方便扩展;
- 41bit时间戳:可以标识毫秒时间戳(最长支持69年),结合递增bit使用,可以保证有序,不过我觉得如果qps没有超过4000,使用秒时间戳也可以;
- 10bit机器号:可以支持1024个机器ID,用于标识不同的机器;
- 12bit序列递增:支持4096个序列递增,可以支持同一台机器同一毫秒内生成4096个ID;
snowflake算法优势是支持递增,可以根据自己的算法改造使用bit位,不过存在如下缺点:
- 强依赖时间同步,如果某台机器的时钟出现回拨,递增就不准确;
- ID不能完全支持全局递增,需要依赖定义的机器号;
第三方模块生成方式
通过mysql,redis,zk或者ticket server实现架构如下:
架构
1、Mysql
前面提到依赖mysql也可以实现序列号,mysql的auto_increment可以保证全局唯一,不过需要依赖数据库,性能上会有影响。
当然提升性能的方式就是将mysql设置主从模式,但是只是为了序列号生成,部署多个mysql实例确实有些浪费。
2、Redis
redis同样可以实现递增,而且可以保证原子,比如通过incr或者incrby,虽然性能比mysql要好很多,我测试下来4c8g情况下可以支持10W+qps,不过存在单点维护问题。
3、Zookeeper
利用zookeeper的znode也可以生成序列号,可以生成32位和64位的数据版本号,客户端可以使用这个版本号来作为唯一的序列号,不过zk的性能比较差,在高并发场景下基本不建议采用。
4、Ticket Server
Ticket Server类似单独的票据服务,可以通过自己的逻辑生成唯一序列号,比如实现上可以使用原子递增,或者根据各个业务的特性进行适配。
不过要实现完整的容灾体系下可持久的服务工作量是不小的,对于没有太多特殊需求的场景,更建议依赖redis或者mysql。
号段生成方式
1、大厂方案:美团Leaf-segment和Leaf-snowflake方案
1.1 Leaf-segment
具体技术介绍:https://tech.meituan.com/2017/04/21/mt-leaf.html。Leaf-segment主要解决思路是:对直接用数据库自增ID充当分布式ID的一种优化,减少对数据库的访问频率,每次获取不是获取一个ID,而是获取一个号段,同时获取号段以后,将数据持久化到数据库中,这样可以解决分布式的抢占或者持久化问题,即使DB出现问题,也可以通过Master-Slave来解决。
Leaf-segment架构
1.2 Leaf-snowflake
Leaf-snowflake继续使用snowflake方案,主要解决了时钟不同步的问题,其中中间10bit机器号定义为WorkerID,Leaf-snowflake是按照下面几个步骤启动的:
- 启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点);
- 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务;
- 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务;
- 检查服务启动是否写过ZooKeeper leaf_forever节点,并进行如下处理:
若写过,则用自身系统时间与leaf_forever/节点记录时间做比较,若小于{self}时间则认为机器时间发生了大步长回拨,服务启动失败并报警;
若未写过,证明是新服务节点,直接创建持久节点leaf_forever/${self}并写入自身系统时间,接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确,具体做法是取leaf_temporary下的所有临时节点(所有运行中的Leaf-snowflake节点)的服务IP:Port,然后通过RPC请求得到所有节点的系统时间,计算sum(time)/nodeSize;
若abs( 系统时间-sum(time)/nodeSize ) < 阈值,认为当前系统时间准确,正常启动服务,同时写临时节点leaf_temporary/${self} 维持租约;
否则认为本机系统时间发生大步长偏移,启动失败并报警;
每隔一段时间(3s)上报自身系统时间写入leaf_forever/${self};
Leaf-snowflake架构
2、大厂方案:滴滴Tinyid和百度UidGenerator
2.1 滴滴Tinyid
开源方案:https://github.com/didi/tinyid。Tinyid和美团的Leaf-segment方案类似,从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如:(1,1000]代表1000个ID,业务服务将号段在本地生成1~1000的自增ID并加载到内存。Tinyid会将可用号段加载到内存中,并在内存中生成ID,可用号段在首次获取ID时加载,如当前号段使用达到一定比例时,系统会异步的去加载下一个可用号段,以此保证内存中始终有可用号段,以便在发号服务宕机后一段时间内还有可用ID。
Tinyid架构
2.2 百度UidGenerator
百度UidGenerator是基于snowflake方案改造,旨在解决时钟回拨,workerid不够等问题。
百度UidGenerator
- 1bit保留位:方便扩展;
- 28bit时间戳:指当前时间与epoch时间的时间差,单位为秒,比如2024-01-01 00:00:00上线,那时间就是当前时间戳-1704038400;
- 22bit节点id:22bit可以支持4194304台机器,同时这个数据是持久化到DB,保持每次新增或者重启都会自增;
- 13bit序列递增:支持每秒生成8192个自增序列号(未调整boostPower情况下);
- UidGenerator优化点还包括:
RingBuffer:UidGenerator不再在每次取ID时都实时计算分布式ID,而是利用RingBuffer数据结构预先生成若干个分布式ID并保存,引入boostPower可以控制每秒生成ID的上限能力;
时间递增:UidGenerator的时间类型是AtomicLong,且通过incrementAndGet()方法获取下一次的时间,从而脱离了对服务器时间的依赖,也就不会有时钟回拨的问题;
3、大厂方案:容灾
微信内部也是通过独立的seqsvr提供序列号生成,主要面对场景是微信中的消息版本,其中特性和挑战如下。
(1)两个特性:
- 递增的32位整型;
- 每个用户都有自己的32位sequence空间;
(2)面临两个挑战:
- 1、分布式场景;
- 2、每秒千万级别的QPS;
- 3、每个Uin都需要存储max-seqid,存储量大,也带来容灾问题;
如何解决分布式场景下的问题?
提供两层:StoreSvr和AllocSvr,分别是存储层和缓存中间层,分层后就能利用堆机器就可以解决问题;
图片
每秒千万级别的QPS?
实现方案和美团Leaf-segment类似,每次提供一批seqid,这样从千万级别的qps就变成千级别的qps,不过不保证序列号是连续的,但是能保证是递增的。
每个Uin都需要存储max-seqid,存储量大?
每个用户需要加载一个max_seq(32bit),如果uin是2^32个,则需要存储数据大小为16GB,这样系统启动时候加载就会很慢,微信如何解决?通过区分Set,同一批共享同一个max_seqid,这样就减少加载的数据量。
容灾如何实现?
seqsvr服务虽然简单,解决了上述高性能的问题,但是要保证高可靠性还是非常难,我查了一下内部资料和infoQ一样,实现架构可以参考:https://www.infoq.cn/article/wechat-serial-number-generator-architecture。seqsvr最核心的点是什么呢?每个 uin 的sequence申请要递增不回退,但是约束条件是:任意时刻任意 uin 有且仅有一台 AllocSvr 提供服务,就可以比较容易地实现sequence递增不回退的要求。
(1)容灾1.0
容灾1.0
- 一主一备:每个Set都是一主一备两台 AllocSvr ,主机出故障时,仲裁服务切换主备,原来的主机下线变成备机,原备机变成主机后加载 uid 号段提供服务;
- 引入仲裁服务:探测 AllocSvr 的状态,决定每个 uin 走到哪一台 AllocSvr ,同时解决备机切换的问题;
但是上述面临问题:
- 主从容灾扩缩容麻烦;
- 单个Set的资源不一定能支撑高并发请求;
(2)容灾2.0
通过提供 Client 路由表方式解决访问 AllocSvr 切换的问题,执行步骤如下:
- Client 根据本地共享内存缓存的路由表,选择对应的 AllocSvr,如果路由表不存在,随机选择一台 AllocSvr;
- 对选中的 AllocSvr 发起请求,请求带上本地路由表的版本号;
- AllocSvr 收到请求,除了处理 sequence 逻辑外,判断 Client 带上版本号是否最新,如果是旧版则在响应包中附上最新的路由表;
- Client 收到响应包,除了处理 sequence 逻辑外,判断响应包是否带有新路由表,如果有,更新本地路由表,并决策是否返回第 1 步重试;
容灾2.0
总结
以上就是一些场景下生成分布式唯一ID的方案选择,分布式唯一ID的架构虽然简单,但是如果要实现高性能高可用,还是需要根据业务场景来考虑。所以说简单的事情要做好并非易事,但是在这些年的工作中总是会有很多人为了追求效率,总想找到捷径而放弃架构的基本演进路径方法论....
参考
(1)https://learning-guide.gitbook.io/system-design-interview/chapter-07-design-a-unique-id-generator-in-distributed-systems(2)https://tech.meituan.com/2017/04/21/mt-leaf.html(3)https://www.infoq.cn/article/wechat-serial-number-generator-architecture