Overview
如今,分布式系统已成为常态。有时需要在此类分布式系统中生成唯一 ID。其中一些独特的 ID 要求可能是
-
公司有订单管理系统,每个订单都需要有一个唯一的ID
-
系统中存储的每个用户都需要有一个唯一的ID
-
如果我们以 Instagram 为例,那么可能会有数十亿条来自用户的状态更新或帖子。每个帖子都需要不同的唯一 ID
-
.........
Types of Unique IDs
此外,当涉及到唯一 ID 生成器时,可以生成三种类型的 ID
- Unique ID ( Loosely Sortable)
- Unique ID (Not Loosely Sortable) or Random Unique ID
- Sequence Number
让我们先看看每个的定义,然后我们将详细研究每个唯一 ID
Unique ID ( Loosely Sortable)
这些 ID 是松散的时间分类,可以以分散的方式生成。正如我们将在本文中看到的,有几种生成方法。这些 id 中含有一些时间成分,通常是纪元时间。
例如 UUID(128 位)、MongoDB 对象 ID(96 位)和 64 位 ID。由于这些 ID 可以按时间排序,因此应用程序可以用它来组织数据。
UUID 示例(128 位)
815b15597e8a4a4d4302d73e4682f4fc
442bc58166b6ab626ceed57f51982474
442bc58166b6ab626ceed57f51982474
Unique IDs ( Not time sortable) or Random UUID
这些通常是分类 ID,一般是 6 或 7 或 8 个字符长度的 ID。例如,有一个用于生成短 URL 的短字符串。这些短 URL 可用于 bitly.com 甚至 Youtube 等网站。Youtube 将这些短 URL 用于视频分享。因此,这些 ID 本身并没有排序的用例需求。而且这些 ID 也可能很大,没有与排序相关的固有信息。
例如,当某人在支付网关上进行支付时,系统会提供一个参考 ID 或交易 ID。这些 ID 的用例是完全随机的,因此不可能猜出任何参考 ID 或交易 ID。
可能还有更多这样的需求,我们需要在其中生成数十亿条记录。
7 个字符 ID 示例
ETL2W7q
aTyUW89
bSZVK7I
Sequence Number
顾名思义,这是一个自动递增的数字。在分布式系统中生成序列号时,需要了解系统中其他工作人员的情况。因此,它需要一个共享状态。在分布式系统中以高度可扩展的方式生成序列号是非常困难的,因为它需要一些中央管理。
序列号的示例是 MYSQL 自动递增号
erlang
1
2
3
.
.
n
Description and Design
首先,我们先看一下唯一 ID(可松散排序)、唯一 ID(不可松散排序)或随机唯一标识符、序列号的描述和设计。
Unique ID ( Loosely Sortable)
唯一 ID 生成系统的一些要求可能是:
- 生成的唯一 ID 应该很短
- 生成的 ID 应该是可按时间排序的
- 应该支持以一种可用的方式每秒生成 10000 个 ID
我们的系统中可以生成的一些唯一 ID 是
- UUID 或 GUID,长度为 128 位
- 96 位 UUID,例如 MongoDB 对象 ID
- 64 位长度的UUID
- 56 位长度的UUID
UUID or GUID
UUID 代表通用唯一标识符。它是一个 16 字节或 128 位的 ID,保证是唯一的。
Advantages of UUID or GUID
- 生成这些 ID 不需要特殊的单独系统
- 这些 ID 的生成速度很快
- 这些 ID 是可按时间排序的
Disadvantages
- GUID 或 UUID 的主要缺点是它的大小
- 可能会发生碰撞,但几率很小
96 bit GUID
Mongo DB 对象是 12 字节或 96 位,每个 ID 包括
- 4 字节时间戳值 (timestamp value),它是自纪元时间以来的秒数
- 5 字节随机值
- 一个 3 字节增量计数器
在 Mongo DB 世界中,每个分区的对象 ID 都是唯一的。这意味着由两个不同分区生成的两个不同对象 ID 可能是相同的。
Advantages
- 生成的ID可按时间排序
Disadvantages
- 大小仍然很大,这可能会使索引变大。
- 在需要 ID 小于 64 位的情况下,不能使用这些 ID
64 bit IDs
可以生成按时间排序的 64 位ID,由于它是 64 位的,所以它的大小也明显较小。
对于 64 位 IDs,我们有两种方法可以考虑
Snowflake by Twitter 出自推特的雪花算法❄️
Twitter 创建了一个雪花算法用于生成 64 位唯一 ID,它使用三个标准来确定唯一 ID
- 当前时间戳(以毫秒为单位)--- 41 位,根据纪元系统,可以推算出 69 年中任意时间段对应的时间戳
- 生成唯一 ID 的实例的序列号----它占用 10 bit,因此最多允许使用的实例号为 2^10 = 1024。
- 该实例中生成唯一 ID 的线程的序列号----占用 12 位,因此每个实例最大允许使用的线程序列号为 2^12 = 4096。
- 1 位保留,以备将来使用。
让我们看看这个系统的可扩展性。最多可以有 1024 个实例,因此不存在单点故障。
有了 1024 个实例,系统就有了更多的节点来处理流量,因此系统吞吐量也可以扩展。
Disadvantages
- 它需要zookeeper来管理机器的映射
- 需要多个雪花服务器,这增加了复杂性和管理难度
Instagram ID generator 出自 Instagram 的 id 生成算法
该算法也会生成一个 64 位 ID,每个 ID 包括
- 41 位用于时间戳(以毫秒为单位)。如果我们有一个自定义纪元,它可以给我们 69 年
- 13 位代表逻辑分片 ID (逻辑分区 ID)
- 10 位,表示每个分片的序列号。
因此每个分片每毫秒基本上可以生成 1024 个 ID。
分片系统有数千个逻辑分片,映射到少数物理机。由于它使用逻辑分片,因此我们可以从包含许多逻辑分片的较少物理服务器开始。一旦您的分片变得更大,我们就可以添加更多物理服务器并将这些分片移动到新的物理服务器。只需将分片移动到新的物理服务器即可,不需要对数据进行重新分类(re-bucketing 分桶)。
Instagram ID 生成器不需要 Twitter snowflake 中的 zookeeper。它使用 Postgress Schema 功能进行管理
What is Postgress Architecture
Postgress DB 由多个模式组成,每个模式由多个表组成,表名只能在一个模式中唯一。因此,在 Instagram 的情况中,每个逻辑分片都映射到一个模式,模式中的表用于生成 ID。
Unique IDs ( Not time sortable) or Random UUID
此类 ID 的示例
- 6 or 7 或 8 数字长度,分别为 36、42 和 48 位长度(经 base64 编码)
- 16 个字符长度的ID
- 32 个字符长度的ID
6 or 7 or 8 length digit base 64 encoded digit
由于它是 Base 64 编码的,因此每个数字都是 6 位:
- 因此,如果唯一 ID 的大小为 6,则总体大小将为 6*6=36 位。
- 因此,如果唯一 ID 的大小为 7,则总体大小将为 7*6=42 位。
- 如果唯一 ID 的大小为 8,则总体大小将为 8*6=48 位。
使用此类 ID 的一些用例是
- URL 简化系统(url shortener system),其中所需的 URL 需要尽可能短
- 粘贴仓类型系统(Paste Bin type Systen)再次要求生成的粘贴仓必须短。
- Youtube 需要这些 ID 才能为视频生成简短的 URL
Advantages
- 体积非常小,适合共享
Disadvantages
- 这些生成的 ID 没有按时间排序
16 or 32 bit Random IDs
此类 ID 的字符范围可能超过 100 个 ASCII 字符。由于它使用较大的字符集,因此这些 ID 的生成非常简单,并且冲突的可能性也较小。
16 位随机 ID 示例
bash
PO2bc58166b6ab62
E#5B15597e8a$a4$
32 位随机 ID 示例
ruby
PO2bc58166b6ab626ceed57f5198247$
E#5B15597e8a$a4$YU02d73e4682f4FC
Sequence Number
这些 ID 可以通过一个数据库生成,该数据库可以为我们提供自动递增的数字。由于有一个数据库正在生成唯一的 ID,因此可以保证它是唯一的
按顺序排列的唯一 ID,例如,数据库会生成一个唯一的递增 ID
Advantages
- 可以生成较短的增量 ID
- 它很容易进行时间排序
Disadvantages
- 唯一生成的 ID 可能是任意长度
- 系统不可扩展,生成唯一 ID 的实例是单点故障
为了提高可扩展性并防止单点故障,我们可以增加实例的数量,每个实例都会以不同的方式增加:
- 例如,如果我们有两个实例,那么其中一个会生成偶数,另一个会生成奇数
- 如果我们有三个实例,那么第一个实例将生成 3 的倍数的 ID,第二个实例将生成 3 加 1 的倍数,第三个实例将生成 3 加 2 的倍数
即使增加了实例数量,仍然存在一些缺点
- 它不再是序列号
- 两个不同实例生成的唯一 ID 将不再按时间排序。假设有两个实例,其中一个实例可能生成 1001 或 1003 等 ID。另一个实例可能同时生成 502,506 等 ID。显然,通过查看两个 ID 很难辨别哪个先出现。
如果流量很大,我们必须添加更多实例怎么办?同样,假设流量减少了,我们必须减少一些实例。
增加和减少实例可能涉及每个实例的 ID 生成逻辑的变化,并且管理此类事情既复杂又困难。
High-Level Design of Time Sortable Unique IDs
系统的一些非功能性要求是它应该具有高度的可扩展性和可用性。
API Details
我们的系统只需要一个 API。该 API 将用于生成一堆唯一的 ID,所以本质上它将返回一个唯一 ID 的数组。
我们将生成一个包含以下字段的 ID:
- Time Part
- Machine Number
- Process Or Thread Number
- Local Counter
这是每个字段的描述
时间部分 -- 表示唯一 ID 的时间部分。将时间分量添加到唯一 ID 使其可按时间排序。
机器编号 -- 这是机器、实例或容器的唯一编号。
线程编号 -- 分配给每个线程的唯一编号
本地计数器 -- 这是线程在一毫秒内可以生成的唯一 ID 的数量
我们将拥有 64 位ID,而且使用 64 位 ID,我们最多能够生成 2^64 个 ID。
Time Component
时间部分占用 64 位中的位数将取决于我们应用程序的生命周期。时间戳将是从 EPOCH 时间开始的毫秒数。还要注意,纪元时间戳从 1970 年 1 月 1 日开始。但是对于我们的应用程序,可以有一个自定义的时间戳,比如从 2000 年 1 月 1 日开始,或者对应于您的应用程序开始的任何其他日期。
假设我们的应用程序的生命周期是 50 年,我们的唯一 ID 将包含毫秒部分。所以 50 年的毫秒数是
50365 246060 = 1577000000000 milliseconds
存储这么大的数字所需的位数是 41 位,这是因为
2^40 < 1577000000000 < 2^41
下面的表格显示了时间戳的多少位定义了多少年。
Number of Bits | Max Binary Number | Number of milliseconds | Number of years |
---|---|---|---|
40 Bits | 1111111111111111111111111111111111111111 | 1099511627979 | 34.8 years |
41 Bits | 11111111111111111111111111111111111111111 | 2199023255755 | 69.7 years |
42 | 111111111111111111111111111111111111111111 | 4398046511307 | 139.4 years |
让我们看看每个部分需要多少位:
- 时间部分 -- 41 位
- 机器部分 -- 10 位,最大 2^10 = 1024 个实例或机器或容器
- 线程部分 -- 3 位,每个实例、机器或容器最多 2^3 = 8 个线程
- 本地计数器 - 10 位,每个容器每毫秒生成 2^10 = 1024 个唯一 IDs
让我们检查一下我们的服务能力
- 最大实例 --> 2^10 = 1024
- 每个实例的线程数 --> 2^3 = 8
- 可能的最大线程数 --> 2^10*2^3 = 2^13 = 8192
- 每毫秒每个线程的唯一 ID = 2^10 = 1024
- 每毫秒可能的唯一 ID 数 = 2^13*2^10 = 2^23 = 8,388,608 = 大约每毫秒 800 万个
- 每秒可能的唯一 ID 数量 = 2^23*1000 = 8,388,608,000 = 每秒 80 亿个唯一 ID
因此,从理论上讲,该系统每秒可以生成 80 亿个唯一的ID,而且最多允许生成 69.7 年。
我们还可以拥有一个生成56位ID的系统,让我们看看每个部分需要多少位:
- 时间分量 -- 41 位
- 机器组件 -- 8 位 -- 最大 2^8 = 256 个实例或机器或容器
- 线程组件 -- 2 位。最大 2^2 = 每个实例、机器或容器 4 个线程
- 本地计数器 - 4 位,每个容器每毫秒最多 2^4 个唯一 IDs
服务能力
- 最大实例 -- 2^10 = 1024
- 每个实例的线程数 -- 2^2 = 4
- 可能的最大线程数 -- 2^10*2^2 = 2^12 = 4096
- 每个线程每毫秒的唯一 ID(本地计数器)= 2^4 = 16
- 每毫秒可能的唯一 ID 数 = 2^12*2^4 = 2^16 = 65,536 = 大约每毫秒 65 K
- 每秒可能的唯一 ID 数 = 2^16*1000 = 65,536,000 = 每秒 6500 万个唯一 ID
因此,从理论上讲,该系统每秒可以生成 6500 万个唯一的 ID,可连续生成 69.7 年。
我们可以选择一个很长的时间戳,例如10000年甚至更长吗?
选择非常长的时间戳将需要更多的时间戳位数,这将限制工作线程或线程或本地计数器的数量,这反过来又会限制每秒生成多个唯一 ID 的能力。
如何为系统中的每台机器或每个实例分配唯一的编号?
这里我们可以使用zookeeper。每当任何新实例、机器或容器出现时,它都可以向 Zookeeper 注册并从 Zookeeper 获取唯一的编号。
High-Level Design
- 将有 zookeeper 服务
- 将有一个负载均衡器,其后面将有多个实例
- 每次出现实例时,它都会向 zookeeper 注册。当它注册时,它会从 zookeeper 那里获得一个唯一的编号。这个唯一的数字从1到1024变化,因为我们只有 10 位用于实例号。
- 实例的自动伸缩将基于 CPU 负载情况完成。将有一个管理程序,集群中的每个节点都将向其发送其CPU。集群中的每个节点都会将其 CPU 负载信息定期发送到一个管理器,管理器再根据这些节点的 CPU 负载信息,决定从集群中添加和删除实例或计算机。如果我们正在使用 AWS,那么设置自动缩放是很简单的。
下面是相同的高级关系图
High-Level Design of Unique IDs ( Not time sortable) or Random UUID
为此,我们将仅查看 6、7 和 8 字符长度随机 UUID 的设计。生成 16 长度或 32 长度的随机 UUID 非常简单,因为不会发生冲突。
下面是系统中用于生成 6,7, 和 8 个字符长度的随机 UUID 的高级组件
- 密钥生成服务 -- 该服务负责生成短密钥
- 上游服务 -- 这是将所有生成的短密钥用于业务目的的业务服务
- 密钥恢复服务 -- 该服务将恢复过期的密钥并将其放回数据库中以供将来使用
- Kafka/SNS+队列/SQS系统
- 数据库
- 缓存
因此,密钥生成服务或 KGS 服务将负责生成短密钥。首先,让我们看看每个密钥的长度应该是多少。长度的可能选项有 6、7、8。只能使用 base64 URL 安全字符来生成密钥。以下是 URL 安全字符
- Lower case Alphabets -- "a-z"
- Uppercase Alphabets -- "A-Z"
- Digits -- "0-9"
- Underscore -- "_"
- Dash -- "-"
由于只能使用 URL 安全字符,因此
对于 6 - 我们有 64^6= 687 亿种选择 对于 7 -- 我们有 64^7 = ~35000 亿种选择 对于 8 -- 我们有 64^8= 万亿种选择
现在我们可以假设 687 亿个条目就足够了,因此我们可以使用 6 个字符作为密钥。现在的问题是如何在数据库中维护这些内容,如果我们在数据库中存储 680 亿个条目,那么条目可能过多并且浪费资源。
一种选择是在数据库中存储一系列密钥。我们的范围可以是 64,其中我们只存储前五个字符。这五个字符将充当所有 64 个键的前缀,所有 64 个键都可以从此前缀生成。假设我们有以下前缀
adcA2
然后可以由此生成以下 64 个密钥
- adcA2[a-z] -- 26 keys
- adcA2[A-Z] -- 26 keys
- adcA2[0-9] -- 10 keys
- adcA2[-_] -- 2 keys
我们可以将这些范围存储在数据库中。因此,对于 6 个字符,数据库中总共有 64^5 个条目。
密钥服务将仅按范围和批量将密钥返回给 Tiny URL 服务。然后,上游服务将使用此前缀生成 64 个密钥并服务 64 个不同的创建小型 URL 请求。
这是优化,因为上游服务仅在用尽所有 64 个密钥时才需要调用密钥生成服务。因此,上游服务将调用一次密钥生成服务来生成 64 个短 URL。
现在让我们看看 KGS 服务的要点
- 数据库架构
- 使用哪个数据库
- 如何解决并发问题
- 如何恢复 key_prefix
- 如果 key 范围耗尽会发生什么
- 如果短网址永不过期怎么办
- KGS服务不是单点故障吗?
Database Schema
只有一个表来存储键的范围,即前缀。下面是表中的字段
- key_prefix
- key_length---现在它总是6,如果我们在任何场景中需要7个长度键,这些字段都存在
- used---如果这是真的,那么键前缀当前正在使用。如果为 false,则可以自由使用
- created
- updated
Which Database to Use
我们没有任何 ACID 要求,因此可以使用 NoSQL 数据库。此外,我们可能还有非常大的数据需要保存,因此 NoSQL 可能更适合。该系统将是一个重写入和重读取的系统(a write-heavy as well as a read-heavy system)。所以我们这里可以使用 Cassandra 数据库。
我们可以对数据库的容量进行估计,并据此决定我们想要拥有的分片数量,每个分片也将被正确复制。
在这里,我们还可以进行另一个优化来改善延迟。我们可以在缓存中重新填充空闲键范围,KGS 服务可以直接从那里挑选,而不是每次都去数据库。
How to resolve concurrency issues
很可能两个请求将相同的前缀或范围视为空闲。由于有多个服务器同时从密钥数据库读取数据,我们可能会遇到这样的情况:两个或更多服务器从密钥数据库中读取相同的密钥。
有两种方法可以解决我们刚才提到的并发问题
- 两个或多个服务器读取相同的密钥,但只有一个服务器能够标记数据库中使用的 key_prefix。并发处于 DB 级别,每一行在更新之前都被锁定,我们可以在这里利用这一点。无论是否更新了任何记录,数据库都会返回到服务器。如果记录没有更新,那么服务器可以获取新的密钥。如果记录被更新,那么该服务器就得到了正确的密钥。
- 另一种选择是使用在一个事务中执行查找和更新的事务。每次查找和更新都会返回一个唯一的 key_prefix。这可能不是推荐的选项,因为它会给数据库带来负载。
What happens if the key ranges are getting exhausted
这将是一个意想不到的情况。将有一个后台工作人员来检查键范围是否耗尽。如果是,那么它可以生成 7 个长度键的范围。但它如何知道关键范围是否已耗尽。为了保持粗略计数,可能有另一个表来存储已使用密钥的用户计数。
- 每当 KGS 将任何范围分配给上游服务时,它都会发布一条消息,该消息将由同步工作线程选取,该消息会将已用密钥的计数减少 1。
- 类似地,只要一个范围是空闲的,我们就可以增加这个计数器。
Is not KGS service a single point of failure?
为了防止这种情况,我们将正确复制密钥数据库。 服务本身将有多个应用程序服务器,我们还将设置适当的服务自动缩放,我们还可以进行灾备管理。
下面是整个系统的概要图
Conclusion
这是关于唯一 ID 生成的全部内容。