65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 C# 中的 base 36 的简短标识符

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (60投票s)

2012 年 9 月 5 日

CPOL

11分钟阅读

viewsIcon

90023

downloadIcon

1839

如何为通用标识符生成更短、更具可读性、唯一的数值。

更新,2015-06-29 - 项目现已在 GitHubNuGet 上。代码已发展为支持不同的组件长度、跨进程线程安全和更好的性能。请参阅 GitHub 存储库以获取源代码和测试用例。在此处获取 NuGet 包

更新,2012-09-10 - 增加了 SQL Server 性能信息

更新,2012-09-07 - 代码已更新,以解决一些问题并添加更多测试例程,我还在文章中添加了一些更强有力的解释。

介绍 

如果这个
D657-BHF6-ZI6E-Z3A2 在你看来比这个标识符更好
9A6484a2-eb08-4aea-980a-a9694bf279dc,那么请继续阅读。

本文源于在大型分布式应用中使用多个数据存储分配标识符时遇到的诸多烦恼。我对标识符有一份简短但并非不合理的需求清单,不幸的是,大多数现有 ID 方案都未能满足其中一项或多项要求

  1. 足够唯一 
  2. 完全解耦 
  3. 高效索引
  4. 易用/人类可读
  5. 不会拖慢性能

关于代码

解决方案摘要解决方案包括 ID 生成器的类库以及一个控制台测试工具。下载并运行控制台应用程序,查看您的 PC 一秒钟内可以生成多少个 ID 的演示;我的主笔记本电脑可以生成超过 8.5 万个。其中有一些粗略且基本的内置碰撞和时钟问题防护措施;文章末尾的一个部分对此进行了一些解释。

要生成一个新 ID 而不知道具体发生什么,只需引用类库并调用静态方法 IdGenerator.NewBase36(),或者 IdGenerator.NewBase36(delimiter) 以将其显示为四个由分隔符分隔的块。你可以在几乎任何平台,跨数据存储内部使用它,而无需担心冲突或可排序性(ID 以升序序列开始)。 

背景  

上述标准的好处是显而易见的。唯一性是任何标识符的先决条件,但“足够”的定义各不相同。有时事物只需要在其关系表或实体类型内唯一,就像自增的 SQL 标识符一样。有时它们需要在更广泛的上下文中唯一,甚至可能是通用的(如 GUID),这样它们就可以标识像二进制文件和接口版本这样普遍共享的事物。“足够”的定义在这里是在分布式平台(如社交网络、搜索引擎或业务线 SaaS 平台)的上下文中使用的。一个递增的整数无法实现这一点,但 GUID 可以(尽管它不是顺序的或易于索引的)。

解耦确保系统的任何组件都不会因无法生成 ID 而失败;依赖另一个服务器或服务会引入一个故障点,而这在使用 GUID 时不会出现。

高效索引只是为了确保 ID 不会减慢应用程序的读写速度。ID 应该是可移植的,独立于存储层,但许多存储提供商根据 ID 的排序顺序来安排物理记录布局。我们应该通过某种升序排序来尊重这一点。SQL 顺序 ID 可以实现这一点,但它们会创建服务器依赖性并增加一些其他困难。

易于管理人类可读是相辅相成的。GUID 难以管理;你有没有尝试向别人读出一个 GUID,或者手动输入它?32 个字符,破折号位置等难以预测的强制执行,对于通信目的来说是不合理的。然而,通过电话读取信用卡号码并不难,所以 16 个字符应该没问题。但是,大小写敏感和特殊字符就不行了。许多发达国家的人在阅读 URL 时仍然会说“反斜杠”,在我们作为一个物种克服这个问题之前,我肯定不会在标识符方案中引入大小写或非字母数字字符,就像某些 25 个字符的“短 GUID”实现所做的那样。

我相信“不会拖慢性能”无需解释。

解决方案概述  

所以我们正在寻找在许多方面都 GUID 的东西……在整个系统生命周期内相对唯一(但不一定是普遍唯一)。它也应该比 GUID 短,并且像整数或顺序 GUID 一样易于索引。它不能有服务器依赖性,并且必须不区分大小写,没有重要的特殊字符。  

这里的用例不需要不透明性或强安全性。GUID 是不可猜测的,但这些标识符可以完全猜测出来并且仍然运行良好(这里的解决方案只能部分猜测)。至于性能,生成开销不应接近任何实际使用场景,并且存储空间不应超过 GUID。固定长度也不会有什么坏处,因为一些数据存储可以更有效地持久化、索引和查找固定长度的值。  

关于唯一性的一点信息

“合理唯一”的定义会因人而异,但让我们考虑一些并非完全任意的使用场景。根据某个网页,Google 美国最近某个时期每天进行 4 亿次查询。另一个同样声誉卓著的网站称,2006 年约有 217 亿笔信用卡交易。这个生成器可以毫无问题地处理这些数量。至于寿命,我随意地说,115 年对于一个系统来说是极其漫长的寿命,所以如果我们可以长期满足需求而没有冲突,它应该能够毫无问题地处理任何企业应用程序。(尽管有规定,如果需要,可以将该方案再延长 4000 年。)

到目前为止都明白了吗?好的,那么……准备,开始! 

解决方案详情

以下是我正在使用的 16 字符方案的构成

字母数字要求需要使用 36 进制编码(0-9,A-Z),这也帮助我们将更多的唯一性塞入更少的视觉空间,而不是像十六进制 GUID 那样。存储空间将与 GUID 相同,为 128 位 / 16 字节。在 SQL Server 中,只需将其定义为 char(16)。这 16 个字符由我接下来将描述的四个不同部分组成;每个部分都经过 36 进制编码,然后在必要时左填充以确保正确的长度。

一个时间戳占据前 10 个位置;这使得 ID 具有升序模式。为了让 ID 从低值开始,我将时间戳计算为从一个起始日期/时间(硬编码且应在整个组织中通用)经过的间隔。分辨率是微秒(ticks / 10),并且锁定机制可防止 AppDomain 中的任何应用程序或线程在彼此相距 10 微秒内发出两个 ID。这大致消除了任何局部 ID 冲突的可能性。您可以选择以四组四个字符的形式表示这些 ID,用破折号或其他分隔符分隔。这对于人们来说很熟悉,就像信用卡号一样。 

一个服务器哈希占据接下来的 2 个位置。这是一个基于服务器主机名的 36 进制编码校验和。这降低了分布式应用程序中 ID 冲突的可能性。它还为 ID 分配增加了可追溯性,只要您对服务器校验和进行编目。由于有两个 36 进制字符,因此有 1296 种可能的值。生成器提供了覆盖此值的方法,如果您愿意的话。

一个版本标识符占据第 13 位。这被随意设置为 Z。由于 36 进制中的 10 个字符提供了超过 115 年的时间戳,因此每次时间戳用尽时(大约每 115.8 年),此字符都会递减。这将打破 ID 的顺序性质,但是如果您的应用程序运行时间足够长,以至于发生这种情况,您应该受到祝贺,我会请您喝一杯啤酒。

三个随机字符占据最后的位置。36^3 提供了 46656 种可能的组合。如果您的分布式应用程序足够大,以至于存在重复的服务器哈希,这将把 ID 冲突的几率降到最坏情况下 46656 分之 n,其中 n 是具有相同哈希并在同一微秒内生成 ID 的服务器数量。有了 10 微秒的延迟,即使几台服务器在紧密的循环中并发发出 ID,也不太可能在同一微秒内生成 ID。

测试

我对这个生成器进行了大量的暴力测试,以检查性能和避免碰撞。压力测试中的生成速度根据硬件的不同,在每秒大约 60,000 到 100,000 之间。碰撞需要两台服务器具有相同的 2 字符哈希,因此在我的测试中,我将哈希覆盖为四台不同的强劲 Intel 服务器上相同,每台服务器每秒平均生成略超过 10 万个 ID。在所有四台服务器上并发生成 ID 72 小时没有产生碰撞。 

为了检查 16 字符字符串作为 ID 的性能,我比较了一个包含 1,000,000 个字符串 ID 实例的 List<T> 与 Int64 和 Guid 的排序。排序速度比 Guid 排序略快;从随机起始顺序对列表进行排序花费了不到 3 秒。(然而,如果您想要良好的性能,使用 string.OrdinalCompare() 很重要;string 类的默认 .CompareTo() 实例方法会严重拖慢您的速度。)这些测试包含在控制台应用程序中。

在 SQL Server 中出现了更显著的性能差异。我创建了两个表进行比较,一个带有 char(16) Id 来存储我的顺序 36 进制字符串,另一个带有 uniqueidentifier Id。我创建了匹配的实体,并使用 SqlDataReader 将 1000 万条记录插入到每个表中。使用 char(16) 进行插入通常更快一些(我尝试了每次插入 1 到 10 万条记录的批处理),而单个选择使用 uniqueidentifier 略快几千分之一毫秒。然而,当执行任何聚合操作或基于创建时间进行排序时,顺序 char(16) 显着更快。例如,插入 1000 万条记录后,查找第 900 万条记录的时间戳(逻辑上,从(按 datetimecreated desc 排序的前 1 百万条记录)中选择 MIN(timestamp)),char(16) id 表快 700% 到 1000%。Guid 表上像这样复杂的分组和聚合查询通常运行数分钟。这有一个教训:您可能不应该将 Guid 作为在大型表上进行增量、按日期或时间分组批处理的聚集索引。

我通过将硬编码的起始日期更改为 500 多年前,验证了 115.8 年后(当 36 进制顺序前缀用尽时)保留字符递减的逻辑。还有一个 Parse() 方法可以验证 ID 并显示其组成部分。它还处理大小写问题并删除破折号(或任何其他非 36 进制字符)。如果您传入的字符串不符合 ID 语法,它将抛出异常。(我没有编写 TryParse()。)

控制台应用程序会显示您的机器在 1 秒内生成了多少个 ID,并显示第一个和最后一个生成的 ID。它还显示起始日期,演示 Parse() 方法,并使用模拟类对通用列表排序进行压力测试。无需任何准备;只需加载解决方案并按 F5。输出如下: 

如果您的 ID 开始于 500 多年前,输出将更像这样

 

保障措施和限制 

这里的生成器采取了合理的措施来防止冲突,但它们在极端情况下仍然可能理论上发生。因此,您应该确保您的服务器场中没有两台服务器共享相同的主机校验和,或者设计您的应用程序,使得 ID 冲突不会造成灾难性后果(通常来说,防范此类事情是个好主意)。

请记住,该方案使用 0 和 O,以及 I 和 1。贵组织的人员应养成对数字说“零”,对字母说“O”的习惯,并且您应尽可能使用能增强字符差异的字体显示 ID。

这些 ID 的序列取决于两个关键的故障点:a) 生成器类中硬编码的服务日期,以及 b) 系统时钟。理想情况下,无论如何都应该将所有服务器时钟与外部 NTP 服务同步,所以我没有花太多时间来防范这种可能性。日期是硬编码的,因为 a) 我讨厌将任何对应用程序逻辑至关重要的东西存储在配置文件中,以及 b) 该值应该在整个组织中保持一致,这使得确保这一点变得容易。

进一步调查

对大规模服务器场中的碰撞几率进行严格计算,或考虑时钟故障的预期,这将是很有趣的。我还希望看到在几个数据存储(尤其是 MS SQL Server)上进行插入和查找性能比较。如果时间允许,我将亲自进行这些尝试,但与此同时,我认为在没有它们的情况下发布文章和代码比让它搁置直到我能完成所有工作更好。

历史 

初稿 2012-09-04。
更新于 2012-09-07

© . All rights reserved.