近两年的技术面试,分布式系列问题是面试官经常会问到的一个高频方向。比如:分布式事务、分布式锁、分布式调度、分布式存储、分布式 ID、分布式集群等。
今天我们就来聊聊,这里面相对简单的分布式 ID,首先来说下,我们为什么需要分布式 ID?
当系统数据量过大,数据查询已经达到瓶颈,进行分库分表后,我们需要对分散在各个库表中的数据记录进行唯一标识,从而保证数据完整性以及避免数据冲突。而分布式 ID 恰好用来解决这个问题。
接下来,我们简单看看常见分布式 ID 的生成方案,包括它们的工作原理、优缺点,以及对网络依赖性的考量。最后我们用一个生产中的例子,来教大家怎么使用美团开源框架实现分布式 ID 生成。
1、UUID(通用唯一标识符)
实现原理
UUID(Universally Unique Identifier)的标准型式由 32 个十六进制数组成的字符串和 4 个“-”构成,整体长度为 36。通常基于时间戳、计算机硬件标识符、随机数等元素生成,在分布式系统中可以确保能生成全局唯一的 ID。
UUID 的生成实现方式非常简单,可以通过 java.util
包提供的类即可实现。
import java.util.UUID;
public class Test {
public static void main(String[] args) {
System.out.println("生成长度为36位UUID为:" + UUID.randomUUID());
}
}
//打印结果
//生成长度为36位UUID为:6ddb5acf-565b-4e6e-8da4-32cfcd02e186
优缺点
优点:ID 生成性能非常高:本地生成,没有网络消耗。
缺点:无序并且无单调递增,不适合做索引;ID 较长,占用更多存储空间,可能导致存储和索引效率低下。
网络依赖性:无网络依赖。
框架实现
暂无。业界没有使用该方案实现应用场景。
2、数据库单点自增序列
实现原理
选择一个数据库作为中央数据库,利用该库中一个表的自增主键机制生成递增序列值分布式 ID。
上图事务中的语句可以使用 id_table
表中在保持一条数据记录的情况下,主键 ID 持续递增。
优缺点
优点:简单可靠,单调递增,保证顺序性。
缺点:DB 单点存在宕机风险,可能成为系统的单点故障;无法扛住高并发场景,有性能瓶颈。
网络依赖性:高度依赖网络,所有 ID 生成请求每次都需要访问中央数据库。
框架实现
暂无。由于缺点引起的问题比较严重,业界没有使用该方案实现应用场景。
3、数据库集群下递增序列
实现原理
前边说了单点数据库方式不可取,害怕一个主节点挂掉没法用,换成集群模式。也就是两个 Mysql 实例都能单独的生产自增 ID,并且需要分别设置每台数据库的起始值和步长。
假设有 db1
、db2
和 db3
三个数据库,分别设置这三个库中表的自增起始值为 1
、2
、3
,然后自增步长为数据库实例数都是 3
,这样就可以实现集群模式的自增了。
这里的步长Step=auto_increment_increment,架构图如下:
从上图可以看出,由于使用的是数据库的集群架构,客户端一般都是通过代理来访问 DB,这时候代理就需要有负载均衡的能力:需要依次轮询访问每一个 DB 节点,通过步长生成下一个分布式 ID 返回。
优缺点
优点:解决 DB 单点故障问题。
缺点:不利于后续扩容,扩容需要重新设置所有节点的起始值和步长;而且依旧无法抗住高并发场景,数据库压力还是很大,每次获取 ID 都得读写一次数据库;并且有一个节点挂了,会造成 ID 不连续,没有了单调递增的特性,只能趋势递增。
网络依赖性:高度依赖网络,所有 ID 生成请求每次都需要访问集群数据库。
框架实现
暂无。由于缺点不适用大型互联网公司业务,业界没有使用该方案实现应用场景。
4、数据库号段模式
实现原理
数据库号段,在 “数据库单点自增序列” 方案上做的优化。这种方式是每个应用服务节点从中央数据库获取一批分布式 ID 段,然后在本地缓存;直到该段用完,则需要更新数据库中的初始值,再次获取新批次的分布式 ID,并重新缓存分布式 ID 到服务本地。
如果数据库用的是分片集群架构(分不同机房部署),如下图:
号段表结构如下:
CREATE TABLE `id_generator` (
`biz_tag` varchar(128) NOT NULL DEFAULT '',
`max_id` bigint(20) NOT NULL DEFAULT '1',
`step` int(11) NOT NULL,
`description` varchar(256) DEFAULT NULL,
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;
优缺点
优点:避免了每次生成 ID 都要访问数据库带来的压力,减少了对数据库的频繁访问,提高了性能。
缺点:仍然存在单点故障(可用集群模式解决);另外如果服务在用完其 ID 段之前下线或重启,可能导致分配的 ID 未被完全使用,可能造成 ID 浪费。
网络依赖性:对网络的依赖相对较低,只在申请新的 ID 段时需要访问数据库。
框架实现
美团Leaf-segment
Leaf,是美团技术团队实现的分布式 ID 生成方案,实现了数据库号段模式(Leaf-segment)和雪花算法模式(Leaf-snowflake),这里我们先说 Leaf-segment。
Leaf-server 采用了双 buffer,异步预分发的方式生成 ID,即可以在 DB 之上挂 N
个 Server
,每个 Server
启动时,都会去 DB 拿固定长度的 ID List
,然后把最大的 ID 持久化下来。当然为了保证 DB 高可用,Leaf也采用了一主两从并且分 IDC
机房部署的方式。实现原理如下所示:
更多Leaf-segment内容可查阅文档: [美团点评分布式ID生成系统](https://tech.meituan.com/2017/04/21/mt-leaf.html)。
这里我通过美团Leaf-segment框架封装了个 spring starter
启动器,可 git clone
下来到你本地,然后通过maven打包后引入该依赖到你工程项目中,就可以直接生成分布式ID使用了。
具体源码可访问我的github地址:[基于美团Leaf号段的spring启动器](https://github.com/honyma/leaf-spring-boot-starter)。
滴滴Tingid
Tinyid,是滴滴技术团队实现的分布式ID生成算法,基于上文介绍的号段模式实现,在此基础上支持数据库多主节点模式,还提供了tinyid-client客户端的接入方式。
Tinyid会将可用号段加载到内存中,并在内存中生成 ID,可用号段在首次获取 ID 时加载,如当前号段使用达到一定比例时,系统会异步去加载下一个可用号段,以此保证内存中始终有可用号段,以便在发号服务宕机后一段时间内还有可用 ID。实现原理如下所示:
更多Tinyid内容可查阅文档: [滴滴Tinyid原理](https://github.com/didi/tinyid)。
微信序列号生成方案
微信序列号跟用户 uin
绑定,具有以下性质:递增的 64 位整形,使用每个用户独立的 64 位 sequence
体系,其实现方式包含如下两个关键点:
1)步进式持久化:使用 cur_seq
和 max_seq
用来获取最新序列号,step
每次更新为 max_seq
的步长,每次请求时 cur_seq++
,如果 "cur_sql > max_seq",则 "max_seq += step",将 max_seq
持久化,每次重启时将 max_seq
赋值给 cur_seq
。同时 "max_seq + step" 用来保证序列号递增,以及减少持久化次数。
2)分号段共享存储:引入号段 section
的概念,uin
相邻的一段用户属于一个号段,多用户共用一个 max_seq
。该处理方式可以大幅减少持久化数据的空间占用,同时可以进一步地降低 IO 次数。
系统分成 AllocSvr
和 StoreSvr
,通过 NRW 策略保证数据可靠性。
更多详细内容可查阅文档: [微信序列号生成器架构设计及演变](https://www.infoq.cn/article/wechat-serial-number-generator-architecture)。
阿里Tddl-sequence
TDDL 大家应该很熟悉了,淘宝分布式数据层中间件。很好的为我们实现了分库分表、读写分离、动态数据源配置等功能。同时也提供了 SEQUENCE
的解决方案。
Tddl-sequence 的原理基于 DB 数据段算法,以客户端JAR包依赖方式引入与应用共存。在应用的DB中创建序列表,并利用应用的数据源,在本地生成序列。每次操作批量分配 id
,分配 id
的数量就是 sequence
的内步长,而原有 id
值就加上外部长值,后续的分配直接就在内存里拿。
另外数据迁移过程中,在新库中,为了保证跟原数据库主键非冲突,需要设置一个跃迁比较大的主键,防止出现两个库中的主键冲突,这是后续迁移中要注意的关键点之一。
由于TDDL是淘宝内部使用的中间件,没有开源,因此没有这个相关的官方文档可以查阅。
5、雪花算法(Twitter Snowflake)
实现原理
雪花算法(SnowFlake), 是 Twitter 公司开源的一种生成 64 位 long
类型 ID 的服务,基于时间戳、节点机器 ID 和序列号。该分布式 ID 由 4 个部分构成,见下图:
时间戳保证了生成 ID 的唯一性和顺序性,确保 ID 按时间顺序增长;工作机器 ID 保证了在多机环境下的唯一性。
优缺点
优点:生成的 ID 有时间顺序,趋势递增,生成速度快。
缺点:强依赖机器时钟,如果机器上时钟回拨,会导致 ID 冲突;并且可读性差。
网络依赖性:通常无需网络交互,除非在多机器不同节点中同步机器 ID。
框架实现
美团Leaf-snowflake
Leaf-snowflake 方案沿用 Snowflake
方案的 bit
位设计,即”1+41+10+12“的方式组装 ID 号(正数位(占 1 比特)+ 时间戳(占 41 比特)+ 机器 ID(占 5 比特)+ 机房 ID(占 5 比特)+ 自增值(占 12 比特))。改动点为:将 Snowflake
从本地jar包变成了独立服务,并通过引入了 Zookeeper
来维护 workerId
,并比较当前服务系统时间,来解决时钟回拨问题。
更多Leaf-snowflake内容可查阅文档: [美团点评分布式ID生成系统](https://tech.meituan.com/2017/04/21/mt-leaf.html)。
百度UidGenerator
UidGenerator 是由百度技术部开发,基于 Snowflake
算法实现的,与原始的 Snowflake
算法不同在于,支持自定义时间戳、工作机器ID和序列号等各部分的位数,而且还支持自定义 workId
的生成策略。
UidGenerator通过借用未来时间,来解决sequence天然存在的并发限制,采用RingBuffer来缓存已生成的UID,并行化UID的生产和消费,同时对CacheLine补齐,避免了由RingBuffer带来的硬件级“伪共享”问题,最终单机QPS可达600万。
UidGenerator 分布式ID生成规则,也是划分成不同的4个部分组合:
UidGenerator 需要与数据库配合使用,需要新增一个 WORKER_NODE
表。当应用启动时会向数据库表中去插入一条数据,插入成功后返回的自增ID就是该机器的 workId
数据由 host
、port
组成。
更多UidGenerator内容可查阅文档: [百度UidGenerator分布式ID原理](https://github.com/baidu/uid-generator)。
6、Redis 集群使用自增命令
实现原理
Redis 的所有命令操作都是单线程的,本身提供像 INCR
和 INCRBY
这样的自增原子命令,所以能保证生成的 ID 肯定是唯一有序的。比较适合使用 Redis 来生成每天从 0
开始的流水号。比如订单号=日期+当日自增长号。可以每天在 Redis 中生成一个当天日期对应的 Key
,使用 INCR
自增命令进行累加。
在分布式环境中,可以部署多个 Redis 实例。每个实例可以独立生成 ID,或者通过配置不同的起始值和步长来确保 ID 的全局唯一性。比如一个集群中有 5
台 Redis,先在每一台节点上创建对应的 key
,然后初始化每台 Redis 上 key
的值分别是 1
, 2
, 3
, 4
, 5
,对应步长是 5
。最后需客户端实现:依次轮询每一个节点上的 key
,通过 INCRBY
进行获取每一个节点的下一个分布式 ID。
由于是使用 Redis 集群架构,客户端需要自己实现负载均衡:依次轮询每一个节点上的 key
,拿到对应的 key
后再通过 INCRBY
获取下一个分布式 ID。
逻辑实现可以参考下面这段 python 代码示例:
from rediscluster import RedisCluster
# 假设你的Redis集群由以下节点组成
startup_nodes = [
{"host": "127.0.0.1", "port": "7000"},
{"host": "127.0.0.1", "port": "7001"},
{"host": "127.0.0.1", "port": "7002"}
]
# 连接到Redis集群
rc = RedisCluster(startup_nodes=startup_nodes, decode_responses=True)
# 设置不同键的初始值
initial_values = {
"key1": 10,
"key2": 20,
"key3": 30
}
# 设置不同键的步长
increment_steps = {
"key1": 5,
"key2": 10,
"key3": 15
}
# 设置初始值
for key, value in initial_values.items():
rc.set(key, value)
# 使用INCRBY命令增加每个键的值,根据键使用不同的步长
for key in initial_values.keys():
# 如果步长字典中没有对应的键,则使用默认值0
step = increment_steps.get(key, 0)
if step: # 确保步长不为0
new_value = rc.incrby(key, step)
print(f"New value of '{key}' with step {step}: {new_value}")
优缺点
优点:快速、简单且易于扩展;支持高并发环境,且性能优于数据库。
缺点:依赖于外部服务 Redis,需要管理和维护额外的基础设施;并且客户端需要实现负载均衡依次轮询每一个节点获取下一个分布式 ID。
网络依赖性:高度依赖网络,需要跟 Redis 交互。
框架实现
暂无。由于要额外维护 Redis 集群架构(一般哨兵模式用的比较多),业界没有使用该方案实现应用场景。
7、 利用 Zookeeper 生成唯一 ID
实现原理
通过分布式键生成服务(如 Zookeeper、etcd、MongoDB)在集群中生成唯一 ID。
如 Zookeeper 主要通过其 znode
数据版本来生成序列号,可以生成 32 位和 64 位的数据版本号,客户端可以使用这个版本号来作为唯一的序列号。而 MongoDB 通过在不同主机生成不同的 ObjectId
保证唯一,生成算法跟SnowFlake雪花算法相似,也是通过划分成多个部分,由时间戳、机器标识符、进程标识符和随机数组成。
下图是Zookeeper通过其 znode
数据版本生成序列号的架构图:
优缺点
优点:通过集群协调机制保证 ID 的唯一性和顺序性,适合分布式环境。
缺点:引入外部依赖和系统部署,增加了系统的复杂性。
网络依赖性:高度依赖网络,因为它们需要在多个节点之间协调 ID 的生成。
框架实现
暂无。很少会使用 Zookeeper 等分布式键生成服务来生成唯一 ID。主要是由于需要依赖 Zookeeper,并且是多步调用 API,如果在竞争较大的情况下,需要考虑使用分布式锁。因此,性能在高并发的分布式环境下,也不是理想的选择。
更多优质技术分享,请关注下面👇👇👇[老马Hony]公众号