参考:javaguide.cn、https://tech.meituan.com/2017/04/21/mt-leaf.html
本文重点:数据库号段模式、snowflake方案、Leaf服务
日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,ID 就是数据的唯一标识。
在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识,保证分布在各节点上的数据ID不冲突。
比如随着数据日渐增长,对数据分库分表后需要有一个唯一ID来标识一条数据或消息,数据库的自增ID显然不能满足需求,此时需要全局唯一ID。再比如不同业务的数据也需要唯一的ID做标识,如订单、骑手的id等。
基本要求:
全局唯一:不同业务的ID各不相同。
方便易用:拿来即用,快速接入。
高可用:生成分布式ID的服务要保证无限接近于100%的可用性。
高性能:分布式ID生成快、对本地资源消耗小。
信息安全:ID中不包含敏感信息或能够获取敏感信息的数据。在一些应用场景下,会需要ID无规则、不规则。
好的分布式ID还应保证:
有序递增(趋势递增/单调递增):在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。很多时候可能会直接通过ID来进行排序,提高排序效率,此时需要保证下一个ID大于上一个ID。
有具体的业务含义:通过ID就能确定是哪个业务。
独立部署:独立部署发号器服务,将生成ID的服务与业务服务解耦,简化业务服务,但是增加了网络消耗。
注:信息安全和单调递增需求是互斥的,无法使用同一个方案满足。
此外,一个ID生成系统应该达到如下几点指标:
平均延迟和TP999延迟都要尽可能低;(高性能)
可用性5个9;(高可用)
高QPS。
通过关系型数据库的自增主键来产生唯一的 ID。
以Mysql为例:利用给字段设置auto_increment_increment(步长)和auto_increment_offset(初始值)来保证ID自增,每次业务使用下列SQL读写MySQL得到ID号。
begin;
REPLACE INTO Tickets64 (stub) VALUES ('a'); #生成新的ID
SELECT LAST_INSERT_ID(); #读取上一个ID
commit;
1 创建一个数据库表ID_TABLE,将id字段设为主键,且自增;将stub字段设为唯一键,作为唯一索引字段,保证其唯一性。
2 通过replace into(替换)插入数据,由于id自增,只需插入stub字段。
使用replace into 插入数据与insert into不同之处在于,如果主键或者唯一索引字段出现重复数据错误而插入失败时,会先从表中删除重复数据,再插入。
优:实现起来比较简单,可利用现有数据库系统的功能实现;ID 单调递增。
缺:
强依赖DB,存在数据库单点问题,可用性差;配置主从复制可以尽可能的增加可用性,但是数据一致性在特殊情况下难以保证。主从切换时的不一致可能会导致重复发号。
ID发号性能瓶颈限制在单台MySQL的读写性能,因为每次获取 ID 都要访问一次数据库;
ID 没有具体业务含义,存在安全问题等。
对于MySQL性能问题,可用如下方案解决:
在分布式系统中我们可以多部署几台数据库,每台机器设置不同的初始值,且步长和机器数相等。
假设我们要部署N台机器,步长需设置为N,每台的初始值依次为0,1,2…N-1,每台机器发号之后都递增N,那么整个架构就变成了如下图所示:
缺点:
系统水平扩展方案复杂难以实现。
ID不具备单调递增特性,只具备趋势递增(取决于负载均衡算法)。
数据库压力还是很大,每次获取ID都得读写一次数据库,只能靠堆机器来提高性能。
是对于数据库主键自增模式的改进。
分析:数据库主键自增这种模式,每次获取 ID 都要读写一次数据库,ID 需求比较大的时候,数据库压力大,性能比较差。如果我们可以改为利用proxy server 批量获取,每次获取一个号段(segment)的值,然后存放在内存里面,需要用到的时候,直接从内存里面拿,性能会有明显提升。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。
原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step。
这就引出了基于数据库的号段模式来生成分布式 ID,也是目前比较主流的一种分布式ID生成方式。
设计新的数据库表结构。
以Mysql为例:
1.创建一个数据库表,包括id字段(非自增),current_max_id字段(当前被分配的ID号段的最大值),step字段(号段的长度),用于获取批量ID,获取的批量 id 为:current_max_id~current_max_id+step
。还包括version 字段(版本号),主要用于解决并发问题(乐观锁),biz_type字段主要用于表示业务类型。
2.通过 insert into
插入数据。
3.通过 SELECT
获取指定业务(biz_type)下的批量唯一ID。
SELECT `current_max_id`, `step`,`biz_type` FROM `sequence_id_generator` where `biz_type` = 101
4.号段用完时,先通过update更新current_max_id之后,再重新 SELECT 即可。
UPDATE sequence_id_generator SET current_max_id = current_max_id+100, version=version+1 WHERE version = 0 AND `biz_type` = 101
对比:相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小,性能更好。
优点 :ID 有序递增、存储消耗空间小。
缺点 :仍然存在数据库单点问题(可以使用主从模式来提高可用性)、ID 没有具体业务含义、存在安全问题。
通过 Redis 的 incr 命令即可实现对 id 原子顺序递增。
set sequence_id_biz_type 1
incr sequence_id_biz_type
为了提高可用性和并发,我们可以使用 Redis Cluster。
Redis作为内存数据库,性能优于Mysql。
优:ID 有序递增、存储消耗空间小、查询速度快、高可用。
缺点:ID 没有具体业务含义、存在安全问题。
MongoDB ObjectId 经常也会被拿来当做分布式 ID 的解决方案。
十二个字节“时间+机器码+pid+inc”,前四位是时间戳,后三位是自增段,这七位逐字节比较,确保严格递增序列。
如果机器时间戳不准确呢?例如当前时间突然变成了之前的某个时间,此时新增数据的id是什么样的?
优点 : 性能不错
缺点 : 需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID) 、有安全性问题(ID 生成有规律性)。
注:NoSQL 方案使用 Redis 多一些。
JDK实现:UUID.randomUUID()
版本默认为 4
参考:https://zh.wikipedia.org/wiki/%E9%80%9A%E7%94%A8%E5%94%AF%E4%B8%80%E8%AF%86%E5%88%AB%E7%A0%81
UUID的标准型式包含32个16进制数字,以连字号分为五段,形式为 8-4-4-4-12 的32个字符,相当于16个Byte。相当于UUID的每一位,由四个bit组成。**不同的版本对应的 UUID 的生成规则是不同的。**到目前为止业界一共有5种方式生成UUID。
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
四位数字 M表示 UUID 版本,数字 N的一至三个最高有效位表示 UUID 变体。
版本详情参考:维基百科-UUID
对于版本1,这些字段对应于“版本1”和“版本2”基于时间的 UUID 中的字段,但是相同的 “8-4-4-4-12” 表示适用于所有UUID。
对于版本4:
使用:
总结:
UUID 可以保证唯一性,因为其生成规则包括 MAC 地址、时间戳、名字空间(Namespace)、随机或伪随机数、时序等元素,计算机基于这些规则生成的 UUID 是肯定不会重复的。
虽然,UUID 可以做到全局唯一性,但是,我们一般很少会使用它。比如使用 UUID 作为 MySQL 数据库主键的时候就非常不合适:
数据库主键要尽量越短越好,而 UUID 的消耗的存储空间比较大(32 个16进制数,128 位)。
UUID 是无顺序的,InnoDB 引擎下,数据库主键的无序性会严重影响数据库性能,写入性能。
优点 :本地生成、没有网络消耗,速度比较快、简单易用。
缺点 :存储消耗空间大(32 个16进制位,128 位) 、 信息不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)、无序(非自增)、没有具体业务含义、需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)
Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成。核心是以划分命名空间来生成ID的一种算法,这种方案把64-bit分别划分成多段,分开来标示机器、时间、序列等。
理论上snowflake方案的QPS约为409.6w/s,这种分配方式可以保证在任何一个Center的任何一台机器在任意毫秒内生成的ID都是不同的,多达4096个/ms。
有很多基于 Snowflake 算法的开源实现比如美团 的 Leaf、百度的 UidGenerator,并且这些开源实现对原有的 Snowflake 算法进行了优化。
在实际项目中,我们一般也会对 Snowflake 算法进行改造,最常见的就是在 Snowflake 算法中加入业务类型信息生成分布式ID。
优点 :ID 有序递增;不依赖数据库等第三方系统,以服务方式部署,生成ID的性能非常高;比较灵活,可以根据业务特点对 Snowflake 算法进行改造,灵活分配bit位。
缺点 :强依赖机器时钟,如果机器上出现时钟回拨,会导致**发号重复(重复ID)**或者服务会处于不可用状态。
UidGenerator是Java实现的, 基于Snowflake算法的唯一ID生成器。
UidGenerator以组件形式工作在应用项目中, 支持自定义workerId位数和初始化策略, 从而适用于docker等虚拟化环境下实例自动重启、漂移等场景。 在实现上, UidGenerator通过借用未来时间来解决sequence天然存在的并发限制; 采用RingBuffer来缓存已生成的UID, 并行化UID的生产和消费, 同时对CacheLine补齐,避免了由RingBuffer带来的硬件级「伪共享」问题. 最终单机QPS可达600万。
依赖版本:Java8及以上版本, MySQL(内置WorkerID分配器, 启动阶段通过DB进行分配; 如自定义实现, 则DB非必选依赖)
提供了两种生成器: DefaultUidGenerator、CachedUidGenerator。如对UID生成性能有要求, 请使用CachedUidGenerator。
使用时需要修改Spring配置。
不管如何配置, CachedUidGenerator总能提供600万/s的稳定吞吐量( 吞吐量是指系统在单位时间内处理请求的数量)。
提供了两种模式:号段模式和snowflake模式。
Leaf 对原有的号段模式进行改进,比如它这里增加了双号段避免获取 DB 在获取号段的时候阻塞请求获取 ID 的线程。
Leaf还解决了雪花 ID 系统时钟回拨问题。不过,时钟问题的解决需要弱依赖于 Zookeeper 。
如果使用segment模式,需要建立DB表(与上文类似),并配置leaf.jdbc.url, leaf.jdbc.username, leaf.jdbc.password
如果使用snowflake模式,需在leaf.properties中配置leaf.snowflake.zk.address,配置leaf 服务监听的端口leaf.snowflake.port。
为了追求更高的性能,需要通过RPC Server来部署Leaf 服务,那仅需要引入leaf-core的包,把生成ID的API封装到指定的RPC框架中即可。
详细技术: Leaf——美团点评分布式ID生成系统
Leaf分别在号段模式和snowflake模式上做了相应的优化,实现了Leaf-segment和Leaf-snowflake方案。
一 Leaf-segment数据库方案:
重要字段说明:biz_tag用来区分业务,max_id表示
该biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度。
大致架构:
对于业务test_tag,第一台Leaf机器上缓存的号段1~1000用完时,会去加载另一个长度为step=1000的号段,此时,先更新max_Id=max_Id+step再获取号段到缓存中。Leaf机器更新号段时执行的sql语句:
Begin
UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx#数据库中的id是自增的
SELECT tag, max_id, step FROM table WHERE biz_tag=xxx#获取max_id-step ~ max_id号段的id
Commit
优点:
Leaf服务可以很方便的线性扩展,更新号段时执行的sql语句与机器的数量无关,性能完全能够支撑大多数业务场景。
ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。
容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。
可以自定义max_id的大小,非常方便业务从原有的ID方式上迁移过来。(?)
缺点:
ID号码不够随机,能够泄露发号数量的信息,不太安全。
TP999数据波动大,当号段使用完之后还是阻塞在更新数据库的I/O上,tg999数据会出现偶尔的尖刺。
DB宕机会造成整个系统不可用。
Leaf双号段优化:针对第二个缺点获取临界ID导致线程阻塞的问题做出的优化。
Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。
我们希望从DB取号段的过程能够做到无阻塞,不需要在从DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中,而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的TP999指标。
实现如下:
采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。
Leaf高可用容灾:对于第三个缺点**“DB可用性”问题**
DB采用一主两从的方式,同时分机房(IDC)部署。
同时,Leaf服务分IDC部署,内部的服务化框架是“MTthrift RPC”。服务调用的时候,根据负载均衡算法会优先调用同机房的Leaf服务。
二 Leaf-snowflake方案
针对上述第一个缺点:存在信息不安全问题。(原文中:Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,不适用于订单ID生成场景,比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的。)
Leaf-snowflake方案完全沿用snowflake方案的bit位设计,即是“1+41+10+12”的方式组装ID号,总共64个bit位。
优化:针对Leaf服务规模较大时的workerID获取问题。
使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。
Leaf-snowflake是按照下面几个步骤启动的:
启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有顺序子节点)。
如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。
如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。
优化:针对强依赖ZK的问题
除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,能保证服务能够正常启动。
优化:解决时钟回拨问题(只会发生在重启时吗?)
流程:
Leaf服务启动时,首先检查自己是否写过ZooKeeper leaf_forever节点。
如果已经写过,则用自身系统时间与leaf_forever下自身的持久节点中记录的时间做比较,若小于记录的时间,则认为出现了时钟回拨,启动失败并报警。
如果没写过,说明是新的服务节点,在leaf_forever下创建顺序持久节点,并写入自身系统时间,得到自己初始的workerID。然后综合对比其他Leaf节点的系统时间来判断自身系统时间是否准确。如果差值<阈值,认为当前系统时间准确,正常启动服务,同时在leaf_temporary下创建临时节点维持租约。如果差值>阈值,认为本机系统时间不准确,启动失败并报警。
注:判断自身系统时间是否准确的具体做法为取leaf_temporary下所有临时节点的ip:port,通过RPC请求得到所有节点的系统时间,求平均值。
启动成功后,需要每隔一段时间(3s)将自身系统时间上报leaf_forever下的持久节点。
Tinyid也是基于数据库号段模式的唯一 ID 生成器。
原理介绍:
1 基于数据库号段模式的简单架构方案:
通过 HTTP 请求向发号器服务申请唯一 ID。负载均衡 router 会把我们的请求送往其中的一台 tinyid-server。存入数据库中。
号段模式简单架构存在的问题:
数据库存在单点故障;切换号段时,获取唯一id速度较慢;http调用也存在网络开销。
2 Tinyid架构:
相比于基于数据库号段模式的简单架构方案,Tinyid 方案主要做了下面这些优化:
双号段缓存 :为了避免在获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 Tinyid 中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段(与Leaf思路相同)。
增加多 db 支持 :支持多个 DB,并且,每个 DB 都能生成唯一 ID,提高了可用性。
增加 tinyid-client :纯本地操作,无 HTTP 请求消耗,性能和可用性都有很大提升。
主要有两种:数据库号段模式,类Snowflake模式。
一些指标:
TP99:保证百分之九十九的网络请求都被响应的最低耗时。TP999同理。
例如一秒内有1w个请求,每个请求的耗时从低到高进行排序,那么序号为1w*99%=9999对应的耗时为TP99。
并发量:系统同时处理的request数量 。
吞吐量:是指系统在单位时间内处理请求的数量。吞吐量(QPS) = 并发数/响应时间