在《数据库索引介绍》这篇文章里,我们谈到了数据库索引的种类、表示方式以及实际应用。
在这篇文章中,我们将实验比较哪个版本更适合用作聚集索引(Clustered Index)。UUID版本4与UUID版本6和版本7,然后我们将讨论这是为什么。
关于UUID (通用唯一识别码) 版4是什么?
UUID,全称为通用唯一识别码(Universal Unique Identifier的简称),是一个128位的标识符,表示为由破折号分隔的32个字母和数字字符组成的序列,格式为8–4–4–4–12。UUID版本4的结构,如“123e4567-e89b-12d3-a456–426655440000”,展示了其十六进制数值,每个字符的值范围是‘1’到‘f’。这种独特的标识符可以以字符串或16字节的形式存储在数据库中。不论是完全随机生成还是通过伪随机数生成器生成,UUID版本4都保持其独特性。
3.26*10¹⁶个UUID样本中几乎不会有重复情况
你可以参考文献#2找到详细的计算过程。
UUID 版本 7 是什么?类似于 UUID v4,UUID v7 是一个由 32 个字符组成的 128 位标识符,格式为 8-4-4-4-12。UUID v7 的独特之处在于它是一个时间有序的 UUID,最重要的 48 位编码了一个具有毫秒精度的 Unix 时间戳。符合 UUID 格式,其中 4 位用于表示 UUID 版本,2 位用于表示变体。其余的 74 位是随机生成的,保证了该标识符的独特性。
图1:UUID v7结构示意图
为何用 UUID 而不用顺序 ID 呢?我们来看看UUID和顺序ID在优缺点方面的对比。
-
优点:
- 低碰撞概率:由于其结构,UUID具有非常低的碰撞概率,允许服务器在插入记录前生成ID。
- 适合分布式系统:UUID非常适合分布式数据库和系统,可以在服务器上独立生成。
- 增强安全性:通过使记录匿名,增强数据库的安全性,防止用户(或恶意实体)推断记录创建的顺序。
- 缺点:
- 增加存储:UUID占用更多的存储空间(16字节),而传统ID(例如INT为4字节或BIGINT为8字节)则占用较少的存储空间。
- 手动数据输入困难:由于UUID的复杂性,手动数据输入变得困难。
- 查询性能降低:较大的UUID大小导致查询性能降低,因为每页存储的记录数减少,导致更多的I/O操作,从而降低整体性能。
- 索引和数据碎片化:UUID可能导致索引和数据碎片化,从而影响数据库效率。对此问题需要进一步讨论。
对于这个实验,我用到的技术有MySQL、Docker、Node和Go。
我创建了一个docker-compose文件来启动MySQL并配置环境,并将数据保存到一个卷中,实验后我会自动清理这个卷中的数据。
需要注意的是:MySQL因其主键默认采用聚集索引特性而被选中,而在PostgreSQL中则没有这样的特性
我将通过逐个插入100万条记录来测试插入性能(如果我们使用批量操作,数据库引擎会排序这些记录,这将破坏我们的实验)。我将使用node.js脚本和Go语言进行测试。我还将使用Go模拟多台服务器连接到一个数据库,并通过goroutines来插入200万条记录。运行7个线程,每个线程占用一个核心。将保留一个核心来运行Docker守护进程。
在我的实验中,我创建了一个模拟的聊天数据库,我只有一个表,名为“chat_messages”。这个表有五列:id、chat_id、sender_id、message 和 created_at。id、chat_id 和 sender_id 的类型会根据我插入的数据是整数还是 UUID v4 或者 UUID v7 在 INT 和 BINARY(16) 之间切换。
图2:我们将实验的表设计。
注释如下:- 我在应用层面上测量插入时间的时长。我知道这不是最准确的方法,也许可以使用触发器或存储过程来实现,但这种方法更快捷。我重复了实验,使用了不同的机器和程序(如node和go),结果相似。
- 值得注意的是,实验是在一台专用机器上进行的,以确保系统资源在实验过程中不会被其他任务占用。这种隔离确保了实验的可靠性,减少了外部因素对插入时间测量的干扰。
或
步骤如下:这里是实验的伪代码,从总体视图来看。
- 运行 docker-compose 文件。
- 连接到数据库。
- 创建 chat_messages 表(UUID v4)。
- (所有插入的总耗时)插入记录并获取时间。(UUID v4)
- 停止 docker 并删除数据卷。(非常重要,以避免影响 UUID v7 插入)
- 等待 1 秒。(让系统释放内存和交换空间)
- 创建 chat_messages 表(UUID v7)。
- (UUID v7)插入记录并获取时间。
- 停止 docker 并删除数据卷。
- 等待 1 秒。
- 创建 chat_messages 表(整数)。
- (整数)插入记录并获取时间。
- 停止 docker 并删除数据卷。
- 等待 1 秒。
如果你想,可以克隆这个代码库并在你的电脑上运行实验。
GitHub — RimonTawadrous 的 uuidv7-vs-uuidv4-sql 仓库 在 GitHub 上创建一个帐户可以帮助你为 RimonTawadrous/uuidv7-vs-uuidv4-sql 的开发做出贡献 结果:节点进程插入100万条记录:
UUIDV4: 24345338.406382076
UUIDV7: 23579840.35688359
INT: 23678315.194741927
UUID V4 / UUID V7 比: 1.0324640895745087
在这里,我们也可以看到UUID v4比UUID v7多耗时3%。
GoLang 进程插入100万条记录:
UUIDV4:2.6320770985406373e+07
UUIDV7:2.5592608053863425e+07
INT:2.5759889825318027e+07
UUID V4 与 UUID V7 的比例:1.0284520799916297
在这里,我们也能看到UUID v4版本比UUID v7版本要慢3%。
使用7个线程的Go程序插入500万条记录:
我有8个核心处理器,所以我试着让每个线程都运行在一个核心上。
UUID V4: 20634873.5100111 毫秒
UUID V7: 16750775.02223781 毫秒
INT: 164567295.36401817 毫秒
UUID V4 和 UUID V7 的比值: 1.2318757479947573
在这里我们可以看到,UUID v4 比 UUID v7 花费多约 23.1% 的时间,比整数多约 25.3% 的时间。(请注意,结果可能因运行环境的不同而有所变化)
为什么UUID v7版本比UUID v4版本更快一些?
索引位置:为了达成共同的理解,让我们重新审视一下之前文章中提到的聚集索引的概念。理解聚集索引的存储机制对于理解结果至关重要。
聚集索引是怎么存储的?
我们提到每个数据片段都存储在一页上。索引则以 b+ 树的形式存储在一页内。这意味着索引中的键是按顺序排列的。因此,在两个现有键之间插入一个新的键需要重新组织存储的索引。这个重新组织的过程可能涉及读取多页,插入一个新页并调整前后指针(而不是拆分页面)。
图3:在添加ID为8的记录之前
图4:添加ID为8的记录之后
请注意,15被添加到了根页面。图4 引入ID为8的记录后,包含10、15和20的页面被拆分了。值得一提的是,在这个过程中,数据页也被拆分了。
图5:叶级数据的糟糕分割
这么说来,你现在应该可以猜到无序的ID会怎样影响性能表现。
UUID v4之间不存在关联性,因为它们是完全随机生成的,导致索引局部性较差。因此,新生成的UUID v4的十六进制值可能会比较旧的UUID v4小。由于我们使用的是聚集索引,为了保持所需的顺序,新生成的UUID v4可能需要放在之前的UUID v4前面。
与UUID v7不同,因为它们是基于时间的,所以是内在排序的。生成的值几乎连续,并且总是插入到最新一页的末尾(只要所有服务器都同步)。这种特性有效地解决了索引局部性的问题。
缓冲池:如果你对此概念不熟悉,让我简单介绍一下。数据库引擎和其他运行在我们机器上的进程一样,并不具备无限的内存。它们向操作系统请求固定大小的内存。数据库引擎可以做很多事情,比如查询优化、解析记录、排序记录、连接记录等。但最重要的是,这块内存区域用来保存从存储读取的页面,并创建新的页面以插入新的记录,这部分内存划分叫做“缓冲池”。
它的用途不仅限于存储读取(选择)操作中的页面。它还用于插入、更新和删除记录。数据库引擎需要获取包含目标记录的页面,或即将插入的页面,然后进行插入、更新或删除。
图6:缓冲区
这跟我们的问题有什么关系?
UUID v4瓶颈的核心问题在于,ID非常随机,缓存池迅速被填满。每条记录可能位于不同的页面上,数据库引擎会去获取该记录。如果缓存池已满,它需要将一个或多个页面写回磁盘以腾出空间,而下一个记录可能不在刚刚写回的那个页面上,这个循环会反复进行。
然而,这种情况不会出现在使用UUID v7或序列整数时,因为这些记录的ID是按递增顺序分配的。因此,当达到页面限制时,新的记录将被添加到最后一页。数据库引擎会创建一个新的页面来存储这些记录,它可能会立即将旧页面写入磁盘,或者等待Buffer池填满后再写入预写日志(WAL)。
为为什么Serial比UUID快?这是一个非常容易回答的问题。如果你记得,数据库中的每条记录都是插入到一页中的,而页的大小默认是固定的,比如MySQL为16KB,PostgreSQL为8KB。在记录的情况下,如果ID是INT类型,其大小为271字节(比如INT, INT, INT, VARCHAR(255), TIMES…)。然而,如果ID是UUID类型,记录的大小则为307字节(16 + 16 + 16 + 255 + 4)。
这意味着在同一页内,INT ID类型的记录比UUID类型的多出6条(假设一页只包含一条记录,实际上可能更差,但我也不确定具体是多少)。含有INT ID记录的页面将容纳更多的记录,这意味着更少的IO操作,速度也会更快。
一个小注释是说,添加时间戳GUID的想法并不新鲜,但这个概念并不新颖。UUID v1 也做到了这一点(但它有一些特定的缺点)。例如,Instagram 的 ShardingID,Shopify 使用 ULID 而不是 UUID v4,MongoDB 的 ObjectId 也做了类似的工作。
希望你喜欢这篇文章内容,并且从中学到一些新的东西。如果你觉得有可以改进的地方,请告诉我一声。
将来的工作计划- 我想用 Rust 试一下。
- 获取每种索引类型的 B+ 树大小。
- 使用连接池来管理数据库连接,而不是使用单个连接。
共同学习,写下你的评论
评论加载中...
作者其他优质文章