需求背景

在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识。
如在美团点评的金融、支付、餐饮、酒店、猫眼电影等产品的系统中,数据日渐增长,对数据分库分表后需要有一个唯一ID来标识一条数据或消息,数据库的自增ID显然不能满足需求。
特别一点的如订单、骑手、优惠券也都需要有唯一ID做标识。
此时一个能够生成全局唯一ID的系统是非常必要的。
概括下来,那业务系统对ID号的要求有哪些呢?
1.全局唯一性
不能出现重复的ID号,既然是唯一标识,这是最基本的要求。
2.趋势递增
在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS(关系数据库管理系统)使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。
3. 单调递增
保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求。
4. 信息安全
如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则不规则
这也是本文要讨论的核心问题:如何高效生成趋势有序的全局唯一ID。

上述123对应三类不同的场景,3和4需求还是互斥的,无法使用同一个方案满足。

UUID方法

不管是通过数据库,还是通过服务来生成ID,业务方Application都需要进行一次远程调用,比较耗时。
有没有一种本地生成ID的方法,即高性能,又时延低呢?
UUID(Universally Unique Identifier)的标准型式包含32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的36个字符,示例:550e8400-e29b-41d4-a716-446655440000,到目前为止业界一共有5种方式生成UUID,详情见IETF发布的UUID规范 A Universally Unique IDentifier (UUID) URN Namespace。
string ID =GenUUID();

  • 优点
    1. 本地生成ID,不需要进行远程调用,时延低,性能非常高
    2. 扩展性好,基本可以认为没有性能上限
  • 缺点
    1. 不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用(“转化为两个uint64整数存储”或者“折半存储”(折半后不能保证唯一性))。
    2. 信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
    3. ID作为主键时在特定的环境会存在一些问题,比如做DB主键的场景下,UUID就非常不适用:
      ① MySQL官方有明确的建议主键要尽量越短越好[4],36个字符长度的UUID不符合要求。
      ② 对MySQL索引不利:如果作为数据库主键,在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能。
    4. 无法保证趋势递增

取当前毫秒数

uuid是一个本地算法,生成性能高,但无法保证趋势递增,且作为字符串ID检索效率低,有没有一种能保证递增的本地算法呢?
取当前毫秒数是一种常见方案:
uint64 ID = GenTimeMS();
优点:
本地生成ID,不需要进行远程调用,时延低
生成的ID趋势递增
生成的ID是整数,建立索引后查询效率高
缺点:
如果并发量超过1000,会生成重复的ID
这个缺点要了命了,不能保证ID的唯一性。当然,使用微秒可以降低冲突概率,但每秒最多只能生成1000000个ID,再多的话就一定会冲突了,所以使用微秒并不从根本上解决问题。

SnowFlake 算法

  • 优点
    1. 保证分布式场景下生成的ID是唯一
    2. 生成的全局ID整体上是呈自增趋势的,也就是说整体是有序
    3. 高性能,能快速产生ID
    4. 只占64bit位空间,可以根据业务需求扩展在前缀或后缀拼接业务标志位转换为字符串
  • 缺点
    1. 由于“没有一个全局时钟”,每台服务器分配的ID是绝对递增的,但从全局看,生成的ID只是趋势递增的(有些服务器的时间早,有些服务器的时间晚)
    2. 强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。

生成规则算法:

  1. 1位,不用。二进制中,最高位为1的都是负数,但是我们生成的ID一般都使用整数,所以这个最高位固定为0
  2. 41位,用来纪录时间戳(毫秒),41位可以表示2的41次方-1个毫秒的值,转化成单位年则是69年
  3. 10位 用来纪录工作机器ID。可以部署在2的10次方 1024个节点。包括5位的数据中心ID,和5位的机器ID
  4. 12位 序列号,用来纪录同毫秒内产生的不同id.12位bit 可以表示的最大正整数是2的12次方减1 是4095 来表示同一个机器同一个时间戳毫秒内产生4096个序号

核心在于如何分配64的位数
举例:假设某公司ID生成器服务的需求如下:

  1. 单机高峰并发量小于1W,预计未来5年单机高峰小于10w
  2. 有两个机房,预计未来5年机房数量小于4个
  3. 每个机房机器小于100台
  4. 目前有5个业务线有ID生成需求,未来业务线小于10个

分析过程如下,

  1. 高位取 2018年9月22日(系统ID生成器在这个时间之后上线)到现在的毫秒数,假设系统要运行10年,需要103652436001000等于 320 的10的9次方,2的39次方大概刚好大于这个数,则预留39位给毫秒数
  2. 每秒的单机高峰并发小于10W,即平均每毫秒的单机并发量小于100,差不多预留7个bit给每秒内序列号
  3. 5年内机房数小雨4个,预留2个bit
  4. 每个机房小于100个机器,留7个bit给每个机房的服务器标识
  5. 业务线小于10个,预留四个bit给业务标识

这样设计的64bit标识,可以保证:

  • 每个业务线、每个机房、每个机器生成的ID都是不同的
  • 同一个机器,每个毫秒内生成的ID都是不同的
  • 同一个机器,同一个毫秒内,以序列号区区分保证生成的ID是不同的
  • 将毫秒数放在最高位,保证生成的ID是趋势递增的

改进的美团Leaf-snowflake优化方案

Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,不适用于订单ID生成场景,比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的。面对这一问题,我们提供了 Leaf-snowflake方案
在这里插入图片描述
Leaf-snowflake方案完全沿用snowflake方案的bit位设计,即是是“1+41+10+12”的方式组装ID号。

对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。
所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。
Leaf-snowflake是按照下面几个步骤启动的:

  1. 启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。

  2. 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。

  3. 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。
    在这里插入图片描述
    弱依赖ZooKeeper
    除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动,这样做到了对三方组件的弱依赖,一定程度上提高了SLA(对互联网公司来说就是网站服务可用性的一个保证,9越多代表全年服务可用时间越长服务更可靠,停机时间越短,反之亦然)
    解决时钟问题
    因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,需要解决时钟回退的问题。
    在这里插入图片描述
    参见上图整个启动流程图,服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:

  4. 若写过,则用自身系统时间与leaf_forever/ s e l f 节点记录时间做比较,若小于 l e a f f o r e v e r / {self}节点记录时间做比较,若小于leaf_forever/ self节点记录时间做比较,若小于leafforever/{self}时间则认为机器时间发生了大步长回拨,服务启动失败并报警。

  5. 若未写过,证明是新服务节点,直接创建持久节点leaf_forever/${self}并写入自身系统时间,接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确,具体做法是取leaf_temporary下的所有临时节点(所有运行中的Leaf-snowflake节点)的服务IP:Port,然后通过RPC请求得到所有节点的系统时间,计算sum(time)/nodeSize。

  6. 若abs( 系统时间-sum(time)/nodeSize ) < 阈值,认为当前系统时间准确,正常启动服务,同时写临时节点leaf_temporary/${self} 维持租约。

  7. 否则认为本机系统时间发生大步长偏移,启动失败并报警。

  8. 每隔一段时间(3s)上报自身系统时间写入leaf_forever/${self}。

由于强依赖时钟,对时间的要求比较敏感,在机器工作时NTP同步也会造成秒级别的回退,建议可以直接关闭NTP同步。要么在时钟回拨的时候直接不提供服务直接返回ERROR_CODE,等时钟追上即可。或者做一层重试,然后上报报警系统,更或者是发现有时钟回拨之后自动摘除本身节点并报警,如下:

//发生了回拨,此刻时间小于上次发号时间

 if (timestamp < lastTimestamp) {          
        long offset = lastTimestamp - timestamp;          
          if (offset <= 5) {             
               try {                  
                    //时间偏差大小小于5ms,则等待两倍时间
                    wait(offset << 1);
                    //wait
                    timestamp = timeGen();                
                        if (timestamp < lastTimestamp) {                     
                          //还是小于,抛异常并上报
                           throwClockBackwardsEx(timestamp);
                        }    
                } catch (InterruptedException e) {  
                   throw  e;
                }
            } else {                //throw
                throwClockBackwardsEx(timestamp);
            }
        } //分配ID

从上线情况来看,在2017年闰秒出现那一次出现过部分机器回拨,由于Leaf-snowflake的策略保证,成功避免了对业务造成的影响。

Mongdb objectID

MongoDB官方文档 ObjectID可以算作是和snowflake类似方法,通过“时间+机器码+pid+inc”共12个字节,通过4+3+2+3的方式最终标识成一个24长度的十六进制字符。

数据库生成

以MySQL举例,利用给字段设置auto_increment_incrementauto_increment_offset来保证ID自增,每次业务使用下列SQL读写MySQL得到ID号。

begin;
REPLACE INTO Tickets64 (stub) VALUES ('a');
SELECT LAST_INSERT_ID();
commit;

在这里插入图片描述
这种方案的优缺点如下:
优点:

  • 非常简单,利用现有数据库系统的功能实现,成本小,有DBA专业维护。
  • ID号单调自增,可以实现一些对ID有特殊要求的业务。
    缺点:
  • 强依赖DB,当DB异常时整个系统不可用,属于致命问题。配置主从复制可以尽可能的增加可用性,但是数据一致性在特殊情况下难以保证。主从切换时的不一致可能会导致重复发号。
  • ID发号性能瓶颈限制在单台MySQL的读写性能。
    对于MySQL性能问题,可用如下方案解决:在分布式系统中我们可以多部署几台机器,每台机器设置不同的初始值,且步长和机器数相等。比如有两台机器。设置步长step为2,TicketServer1的初始值为1(1,3,5,7,9,11…)、TicketServer2的初始值为2(2,4,6,8,10…)。这是Flickr团队在2010年撰文介绍的一种主键生成策略(Ticket Servers: Distributed Unique Primary Keys on the Cheap )。如下所示,为了实现上述方案分别设置两台机器对应的参数,TicketServer1从1开始发号,TicketServer2从2开始发号,两台机器每次发号之后都递增2。
TicketServer1:
auto-increment-increment = 1
auto-increment-offset = 2

TicketServer2:
auto-increment-increment = 2
auto-increment-offset = 2

假设我们要部署N台机器,步长需设置为N,每台的初始值依次为0,1,2…N-1那么整个架构就变成了如下图所示:
在这里插入图片描述

  • 系统水平扩展比较困难,比如定义好了步长和机器台数之后,如果要添加机器该怎么做?假设现在只有一台机器发号是1,2,3,4,5(步长是1),这个时候需要扩容机器一台。可以这样做:把第二台机器的初始值设置得比第一台超过很多,比如14(假设在扩容时间之内第一台不可能发到14),同时设置步长为2,那么这台机器下发的号码都是14以后的偶数。然后摘掉第一台,把ID值保留为奇数,比如7,然后修改第一台的步长为2。让它符合我们定义的号段标准,对于这个例子来说就是让第一台以后只能产生奇数。扩容方案看起来复杂吗?貌似还好,现在想象一下如果我们线上有100台机器,这个时候要扩容该怎么做?简直是噩梦。所以系统水平扩展方案复杂难以实现。
  • ID没有了单调递增的特性,只能趋势递增,这个缺点对于一般业务需求不是很重要,可以容忍。
  • 数据库压力还是很大,每次获取ID都得读写一次数据库,只能靠堆机器来提高性能。

综合对比上述几种方案,每种方案都不完全符合我们的要求。所以Leaf分别在上述第二种和第三种方案上做了相应的优化,实现了Leaf-segment和Leaf-snowflake方案。

Leaf-segment数据库方案

第一种Leaf-segment方案,在使用数据库的方案上,做了如下改变:

  • 原方案每次获取ID都得读写一次数据库,造成数据库压力大。
  • 改为利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。
  • 用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。
  • 各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响
  • 如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。

数据库表设计如下:

FieldTypeNullKeyDefaultExtra
biz_tagvarchar(128)NOPRI
max_idbigint(20)NO1
stepint(11)NONULL
descvarchar(256)YESNULL
update_timetimestampNOCURRENT_TIMESTAMPon update CURRENT_TIMESTAMP
重要字段说明:biz_tag用来区分业务,max_id表示该biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度。原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step,大致架构如下图所示:
在这里插入图片描述
  • Leaf Server 1:从DB加载号段[1,1000]。
  • Leaf Server 2:从DB加载号段[1001,2000]。
  • Leaf Server 3:从DB加载号段[2001,3000]。
    用户通过Round-robin的方式调用Leaf Server的各个服务,所以某一个Client获取到的ID序列可能是:1,1001,2001,2,1002,2002……也可能是:1,2,1001,2001,2002,2003,3,4……当某个Leaf Server号段用完之后,下一次请求就会从DB中加载新的号段,这样保证了每次加载的号段是递增的。

test_tag在第一台Leaf机器上是1~ 1000的号段,当这个号段用完时,会去加载另一个长度为step=1000的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是3001~4000。同时数据库对应的biz_tag这条数据的max_id会从3000被更新成4000,更新号段的SQL语句如下:

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号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。
  • 容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。
  • 可以自定义max_id的大小,非常方便业务从原有的ID方式上迁移过来。

缺点:

  • ID号码不够随机,能够泄露发号数量的信息,不太安全。
  • TP999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,tg999数据会出现偶尔的尖刺。
  • DB宕机会造成整个系统不可用。
    双buffer优化
    对于第二个缺点,Leaf-segment做了一些优化,简单的说就是:

Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。

为此,我们希望DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的TP999指标。详细实现如下图所示:
在这里插入图片描述
采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。

  • 每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。
  • 每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。

Leaf高可用容灾
对于第三点“DB可用性”问题,我们目前采用一主两从的方式,同时分机房部署,Master和Slave之间采用半同步方式[5]同步数据。同时使用公司Atlas数据库中间件(已开源,改名为DBProxy)做主从切换。当然这种方案在一些情况会退化成异步模式,甚至在非常极端情况下仍然会造成数据不一致的情况,但是出现的概率非常小。如果你的系统要保证100%的数据强一致,可以选择使用“类Paxos算法”实现的强一致MySQL方案,如MySQL 5.7前段时间刚刚GA的MySQL Group Replication。但是运维成本和精力都会相应的增加,根据实际情况选型即可。

在这里插入图片描述
同时Leaf服务分IDC部署,内部的服务化框架是“MTthrift RPC”。服务调用的时候,根据负载均衡算法会优先调用同机房的Leaf服务。在该IDC内Leaf服务不可用的时候才会选择其他机房的Leaf服务。同时服务治理平台OCTO还提供了针对服务的过载保护、一键截流、动态流量分配等对服务的保护措施。

Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,不适用于订单ID生成场景,比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的。面对这一问题,我们提供了 Leaf-snowflake方案。

使用数据库的 auto_increment 来生成全局唯一递增ID

优点

  • 简单,使用数据库已有的功能
  • 能够保证唯一性
  • 能够保证递增性
  • 步长固定

缺点

  • 可用性难以保证:数据库常见架构是一主多从+读写分离,生成自增ID是写请求,主库挂了就玩不转了
  • 扩展性差,性能有上限:因为写入是单点,数据库主库的写性能决定ID的生成性能上限,并且难以扩展
    改进方法:
    冗余主库,避免写入单点
    数据水平切分,保证各主库生成的ID不重复
    如上图所述,由1个写库变成3个写库,每个写库设置不同的auto_increment初始值,以及相同的增长步长,以保证每个数据库生成的ID是不同的(上图中库0生成0,3,6,9…,库1生成1,4,7,10,库2生成2,5,8,11…)
    改进后的架构保证了可用性,但缺点是:
    丧失了ID生成的“绝对递增性”:先访问库0生成0,3,再访问库1生成1,可能导致在非常短的时间内,ID生成不是绝对递增的(这个问题不大,目标是趋势递增,不是绝对递增)
    数据库的写压力依然很大,每次生成ID都要访问数据库
    为了解决上述两个问题,引出了第二个常见的方案
Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐