阅读了美团的ID生成器架构设计,记个笔记,同时强烈推荐阅读原文;
业务系统对ID号的要求有:
3和4需求还是互斥的,无法使用同一个方案满足。
业务还对ID号生成系统的可用性要求极高,由此总结下一个ID生成系统应该做到如下几点:
UUID(Universally Unique Identifier)的标准型式包含32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的36个字符;
性能高,本地生成,没有网络消耗;
不易于存储;UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用;
信息不安全;基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置;
ID作为主键时在某些场景不适用;比如做DB主键的场景下,UUID就非常不适用;而且UUID的无序性会导致数据位置在MySQL索引中频繁变动;
是一种以划分命名空间(UUID也算,由于比较常见,所以单独分析)来生成ID的一种算法,这种方案把64-bit分别划分成多段,分开来标示机器、时间等;理论上snowflake方案的QPS约为409.6w/s,这种分配方式可以保证在任何一个IDC的任何一台机器在任意毫秒内生成的ID都是不同的。
第一bit不用,41-bit的时间可以表示(1L<<41)/(1000L360024*365)=69年的时间,10-bit机器可以分别表示1024台机器【如果我们对IDC划分有需求,还可以将10-bit分5-bit给IDC,分5-bit给工作机器,这样就可以表示32个IDC,每个IDC下可以有32台机器,可以根据自身需求定义】,12个自增序列号可以表示2^12个ID;
ID趋势递增,毫秒数在高位,自增序列在低位;
不依赖数据库等第三方系统,本地生成,稳定性高,性能也高;
分配灵活,根据自身业务特性分配bit位;
强依赖机器时钟,机器上的时钟回拨会导致ID重复或者服务不可用;
MongoDB的objectID,通过“时间+机器码+pid+inc”共12个字节,通过4+3+2+3的方式最终标识成一个24长度的十六进制字符。
以MySQL举例;利用给字段设置auto_increment_increment
和auto_increment_offset
来保证ID自增,每次业务使用下列SQL读写MySQL得到ID号
begin;
REPLACE INTO Tickets64 (stub) VALUES (‘a’);
SELECT LAST_INSERT_ID();
commit;
replace into 跟 insert 功能类似,不同点在于:replace into 首先尝试插入数据到表中, 1. 如果发现表中已经有此行数据(根据主键或者唯一索引判断)则先删除此行数据,然后插入新的数据。 2. 否则,直接插入新数据。
要注意的是:插入数据的表必须有主键或者是唯一索引!否则的话,replace into 会直接插入数据,这将导致表中出现重复的数据。
成本小,简单,通过MySQL就能实现;
ID号自增,在特殊场景下这也是优势;
强依赖DB,当DB异常时整个系统不可用;
DB主从切换时可能导致数据不一致,ID重复;
性能瓶颈在单台MySQL;
N台机器,每台机器的步长需设置为N,每台的初始值依次为0,1,2…N-1;
系统水平扩展很难;机器台数和步长确定后,扩容麻烦;
ID不能单调递增,只能趋势递增;
数据库压力依旧很大,每次获取ID都需要读写一次数据库;
或许可以通过预生成来减少数据库的压力;比如数据库预先生产千万级的数据到缓存中,业务先从缓存读取ID,缓存没有了再去数据库中读取;数据库中数据批量追加到缓存中;
Leaf分别在上述第二种和第三种方案上做了相应的优化,实现了Leaf-segment和Leaf-snowflake方案。
通过proxy server批量获取,每次获取一个segment(step决定大小)号段的值,用完后再去数据库中取;同时不同业务通过biz_tag字段区分,每个biz_tag的ID获取,互相隔离;
扩容通过biz_tag分库分表即可;
±------------±-------------±-----±----±------------------±----------------------------+
| Field | Type | Null | Key | Default | Extra |
±------------±-------------±-----±----±------------------±----------------------------+
| biz_tag | varchar(128) | NO | PRI | | |
| max_id | bigint(20) | NO | | 1 | |
| step | int(11) | NO | | NULL | |
| desc | varchar(256) | YES | | NULL | |
| update_time | timestamp | NO | | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
±------------±-------------±-----±----±------------------±----------------------------+
biz_tag用来区分业务,max_id表示biz_tag目前被分配的ID号段的最大值,step表示每次分配的号段长度;(比如step为1000,那么当1000取完了才去数据库中读取)
Begin
UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx
SELECT tag, max_id, step FROM table WHERE biz_tag=xxx
Commit
leaf服务扩容方便,性能能支撑大多数业务场景;
ID号码是趋势递增的8bytes的64位数字,用MySQL就能满足;
容灾性高,leaf内部有号段缓存,即使DB宕机段时间内服务仍然可用;
迁移性好,max_id可变,迁移方便;
ID号码不够随机,不太安全;
TP999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,所以TP999会出现尖刺;
DB宕机会导致系统不可用;
leaf取号段的时机是在号段消耗完才进行的;也就是当前号段的上界点的ID下发时间是下一号段的取回号段时间,过程中新的拉取ID请求需要等待(线程阻塞),特别是取DB的时候网络的抖动或者DB的慢查询会导致整个系统的响应时间变慢;
本架构设计在号段消耗完前,提前预拉取下一号段的数据;
内存中同时存在2个号段数据,号段通过异步线程滚动前进;
每个biz_tag都有消费进度监控,比如号段长度设置为高峰期QPS的600倍;
每次请求都会判断下一个号段的状态,从而更新下一个号段;
Leaf分别在上述第二种和第三种方案上做了相应的优化,实现了Leaf-segment和Leaf-snowflake方案。
snowflake方案适合订单类,不需要趋势递增的场景;
leaf-snowflake方案仍然使用snowflake方案的bit位设计,“1+42+10+12”;
通过zookeeper服务注册管理每个leaf服务,并且zk负责下发每个leaf服务的work id,作为标识启动后续ID生成程序;
在本机缓存一个worker ID文件,当zk出现问题时,通过读取文件实现对zk的弱依赖;
如果机器时钟发生了回拨,那么就会发生重复ID号;启动顺序如下:
参见上图整个启动流程图,服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:
若写过,则用自身系统时间与leaf_forever/ s e l f 节点记录时间做比较,若小于 l e a f f o r e v e r / {self}节点记录时间做比较,若小于leaf_forever/ self节点记录时间做比较,若小于leafforever/{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}。
由于强依赖时钟,对时间的要求敏感,需要关闭机器的NTP同步;
并且在时钟回拨时不提供服务或者返回Error_Code,或者摘除节点并报警;
// 当前时间小于上次的发号时间
if (timestamp < lastTimeStamp) {
// 发生时钟回拨
}
目前Leaf的性能在4C8G的机器上QPS能压测到近5万/s,TP999 1ms
leaf 美团的分布式ID生成器