【译】UUID 很受欢迎,但对性能不利——让我们讨论一下

前言

如果你对 UUID 和 MySQL 进行快速的网络搜索,你会得到相当多的结果。这里只是几个例子:

  • Storing UUID and Generated Columns
  • Storing UUID Values in MySQL
  • Illustrating Primary Key models in InnoDB and their impact on disk usage
  • MySQL UUID Smackdown: UUID vs. INT for Primary Key
  • GUID/UUID Performance Breakthrough
  • To UUID or not to UUID?

那么,像这样一个覆盖面广的话题还需要关注吗? 显然——是需要的。 尽管大多数文章都是警告人们不要使用 UUID,但它们却仍然很受欢迎。 这种流行来自这样一个事实,UUID 可以很容易地由远程设备生成,并且发生冲突的概率非常低。 在这篇文章中,我的目标是总结其他人已经写过的东西,并希望能带来一些新的想法。

什么是 UUID?

UUID 是 Universally Unique IDentifier 缩写,在 RFC 4122 中定义。它是一个 128 位的数字,通常以十六进制编写,并由破折号分成五组。典型的 UUID 值如下所示:

yves@laptop:~$ uuidgen 
83fda883-86d9-4913-9729-91f20973fa52
yves@laptop:~$ uuidgen 
83fda883-86d9-4913-9729-91f20973fa52

官方有 5 种类型的 UUID 值,版本 1 到 5,但最常见的是:基于时间的(版本 1 或版本 2)和纯随机的(版本 3)。基于时间的 UUID 将自 1970 年 1 月 1 日以来的 10ns 数编码为 7.5 字节(60 位),以“time-low”-“time-mid”-“time-hi”的方式进行拆分。缺少的 4 位是用作 time-hi 字段前缀的版本号。这产生了前 3 组的 64 位。最后 2 组是时钟序列,每次修改时钟时递增的值和主机唯一标识符。大多数时候,主机的主网络接口的 MAC 地址被用作唯一标识符。

使用基于时间的 UUID 值时需要考虑以下几点:

  • 可以从生成值的前 3 个字段确定大致时间
  • 连续的 UUID 值之间有很多重复的字段
  • 第一个字段,“time-low”,每 429 秒翻转一次
  • MySQL UUID 函数产生 V1 版本值

下面是一个使用 “uuidgen” Unix 工具生成基于时间的值的示例:

yves@laptop:~$ for i in $(seq 1 500); do echo "$(date +%s): $(uuidgen -t)"; sleep 1; done
1573656803: 572e4122-0625-11ea-9f44-8c16456798f1
1573656804: 57c8019a-0625-11ea-9f44-8c16456798f1
1573656805: 586202b8-0625-11ea-9f44-8c16456798f1
...
1573657085: ff86e090-0625-11ea-9f44-8c16456798f1
1573657086: 0020a216-0626-11ea-9f44-8c16456798f1
...
1573657232: 56b943b2-0626-11ea-9f44-8c16456798f1
1573657233: 57534782-0626-11ea-9f44-8c16456798f1
1573657234: 57ed593a-0626-11ea-9f44-8c16456798f1
...

第一个字段翻转(在 t=1573657086),第二个字段递增。第一个字段再次看到相似值大约需要 429 秒。第三个字段大约每年只更改一次。最后一个字段在给定主机上是静态的,MAC 地址在我的笔记本电脑上使用:

yves@laptop:~$ ifconfig | grep ether | grep 8c
        ether 8c:16:45:67:98:f1  txqueuelen 1000  (Ethernet)

另一个常见的 UUID 版本是 4,纯随机版本。默认情况下,Unix “uuidgen” 工具生成 UUID 版本 4 值:

yves@laptop:~$ for i in $(seq 1 3); do uuidgen; done
6102ef39-c3f4-4977-80d4-742d15eefe66
14d6e343-028d-48a3-9ec6-77f1b703dc8f
ac9c7139-34a1-48cf-86cf-a2c823689a91

唯一的“重复”值是第 3 个字段开头的版本“4”。所有其他 124 位都是随机的。

UUID 值有什么问题?

为了了解使用 UUID 值作为主键的影响,重要的是要查看 InnoDB 如何组织数据。InnoDB 将表的行存储在主键的 b 树中。在数据库术语中,我们称之为聚集索引。聚集索引按主键自动对行进行排序。

当您插入具有随机主键值的新行时,InnoDB 必须找到该行所属的页面,如果它不存在,则将其加载到缓冲池中,插入该行,然后最终将页面刷新回磁盘。对于纯随机值和大表,所有 b-tree 叶页都容易接收新行,没有热页。插入主键顺序之外的行会导致页面拆分,从而导致填充因子低。对于比缓冲池大得多的表,插入很可能需要从磁盘读取表页。缓冲池中插入新行的页面将是脏的。该页面在需要刷新到磁盘之前接收第二行的几率非常低。大多数情况下,每次插入都会导致两次 IOP——一次读取和一次写入。第一个主要影响是对 IOP 的速率,它是可扩展性的主要限制因素。

因此,获得良好性能的唯一方法是使用具有低延迟和高耐用性的存储。这就是第二个主要的性能影响。对于聚集索引,二级索引使用主键值作为指针。对于聚集索引,二级索引使用主键值作为指针。主键的 b 树的叶子存储行,二级索引的 b 树的叶子存储主键值。

让我们假设一个包含 1B 行的表,其中 UUID 值作为主键和五个二级索引。如果您阅读上一段,您就会知道每行的主键值存储六次。这意味着总共 6B char(36) 值代表 216 GB。这只是冰山一角,因为表通常具有指向其他表的外键,无论是否显式。当架构基于 UUID 值时,所有这些列和支持它们的索引都是 char(36)。我最近分析了一个基于 UUID 的模式,发现大约 70% 的存储用于这些值。

好像这还不够,使用 UUID 值还有第三个重要影响。 CPU 一次最多比较整数值 8 个字节,但 UUID 值是按字符比较的。数据库很少受 CPU 限制,但尽管如此,这会增加查询的延迟。如果您不相信,请查看整数与字符串之间的性能比较:

mysql> select benchmark(100000000,2=3);
+--------------------------+
| benchmark(100000000,2=3) |
+--------------------------+
|                        0 |
+--------------------------+
1 row in set (0.96 sec)

mysql> select benchmark(100000000,'df878007-80da-11e9-93dd-00163e000002'='df878007-80da-11e9-93dd-00163e000003');
+----------------------------------------------------------------------------------------------------+
| benchmark(100000000,'df878007-80da-11e9-93dd-00163e000002'='df878007-80da-11e9-93dd-00163e000003') |
+----------------------------------------------------------------------------------------------------+
|                                                                                                  0 |
+----------------------------------------------------------------------------------------------------+
1 row in set (27.67 sec)

当然,上面的例子是最坏的情况,但它至少给出了问题的范围。比较整数大约快 28 倍。即使差异在 char 值中迅速出现,它仍然慢了大约 2.5 倍:

mysql> select benchmark(100000000,'df878007-80da-11e9-93dd-00163e000002'='ef878007-80da-11e9-93dd-00163e000003');
+----------------------------------------------------------------------------------------------------+
| benchmark(100000000,'df878007-80da-11e9-93dd-00163e000002'='ef878007-80da-11e9-93dd-00163e000003') |
+----------------------------------------------------------------------------------------------------+
|                                                                                                  0 |
+----------------------------------------------------------------------------------------------------+
1 row in set (2.45 sec)

让我们探索一些解决方案来解决这些问题。

值的大小

UUID、散列和令牌值的默认表示通常是十六进制表示法。对于基数,可能值的数量,每个字节只有 16 个,它并不高效。使用 base64 甚至直接使用二进制等其他表示形式怎么样?我们节省了多少?性能如何受到影响?

让我们从 base64 表示法开始。每个字节的基数是 64,因此在 base64 中需要 3 个字节来表示 2 个字节的实际值。一个 UUID 值由 16 个字节的数据组成,如果我们除以 3,则余数为 1。为此,base64 编码在末尾添加了“=”:

mysql> select to_base64(unhex(replace(uuid(),'-','')));
+------------------------------------------+
| to_base64(unhex(replace(uuid(),'-',''))) |
+------------------------------------------+
| clJ4xvczEeml1FJUAJ7+Fg==                 |
+------------------------------------------+
1 row in set (0.00 sec)

如果编码实体的长度已知,例如 UUID,我们可以删除“==”,因为它只是自重。因此,以 base64 编码的 UUID 的长度为 22。

下一个逻辑步骤是以二进制格式直接存储该值。这是最优化的格式,但在 mysql 客户端中显示值不太方便。

那么,尺寸如何影响性能?为了说明影响,我将随机 UUID 值插入到具有以下定义的表中……

CREATE TABLE `data_uuid` (
  `id` char(36) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

... 对于默认的十六进制表示。对于 base64,“id”列定义为 char(22),而 binary(16) 用于二进制示例。
数据库服务器的缓冲池大小为 128M,其 IOPs 限制为 500。插入通过单个线程完成。

使用不同 UUID 值表示的表的插入率

在所有情况下,插入率一开始都是 CPU 限制的,但一旦表大于缓冲池,插入就会迅速变成 IO 限制。这是意料之中的,不应该让任何人感到惊讶。对 UUID 值使用较小的表示形式只会允许更多行放入缓冲池中,但从长远来看,它并不能真正提高性能,因为随机插入顺序占主导地位。如果您使用随机 UUID 值作为主键,那么您的性能会受到您可以承受的内存量的限制。

选项 1:使用伪随机顺序的保存 IOPs

正如我们所见,最重要的问题是值的随机性。新行可能会出现在任何表叶页中。因此,除非将整个表加载到缓冲池中,否则这意味着读取 IOP,最终意味着写入 IOP。我的同事 David Ducos 为这个问题提供了一个很好的解决方案,但一些客户不希望允许从 UUID 值中提取信息,例如生成时间戳。

如果我们稍微减少值的随机性,以使几个字节的前缀在一段时间内保持不变,那会怎样? 在时间间隔内,整个表中只有一小部分,对应于前缀的基数,需要在内存中保存读取的 IOP。 这也会增加页面在刷新到磁盘之前接收第二次写入的可能性,从而减少写入负载。 让我们考虑以下 UUID 生成函数:

drop function if exists f_new_uuid; 
delimiter ;;
CREATE DEFINER=`root`@`%` FUNCTION `f_new_uuid`() RETURNS char(36)
    NOT DETERMINISTIC
BEGIN
    DECLARE cNewUUID char(36);
    DECLARE cMd5Val char(32);


    set cMd5Val = md5(concat(rand(),now(6)));
    set cNewUUID = concat(left(md5(concat(year(now()),week(now()))),4),left(cMd5Val,4),'-',
        mid(cMd5Val,5,4),'-4',mid(cMd5Val,9,3),'-',mid(cMd5Val,13,4),'-',mid(cMd5Val,17,12));

    RETURN cNewUUID;
END;;
delimiter ;

UUID 值的前四个字符来自当前年份和周数串联的 MD5 哈希。当然,这个值在一周内是静态的。剩余的 UUID 值来自一个随机值的 MD5 和当前时间,精度为 1us。第三个字段以“4”为前缀,表示它是版本 4 UUID 类型。有 65536 个可能的前缀,所以在一周内,内存中只需要 1/65536 的表行来避免插入时的读取 IOP。这更容易管理,一个 1TB 的表在缓冲池中只需要大约 16MB 来支持插入。

选项 2:将 UUID 映射到整数

即使您使用存储为二进制的伪有序 UUID 值,它仍然是一种非常大的数据类型,会增加数据集的大小。 记住主键值被 InnoDB 用作二级索引中的指针。 如果我们将模式的所有 UUID 值存储在映射表中会怎样? 映射表将定义为:

CREATE TABLE `uuid_to_id` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `uuid` char(36) NOT NULL,
  `uuid_hash` int(10) unsigned GENERATED ALWAYS AS (crc32(`uuid`)) STORED NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_hash` (`uuid_hash`)
) ENGINE=InnoDB AUTO_INCREMENT=2590857 DEFAULT CHARSET=latin1;

重要的是要注意 uuid_to_id 表不强制 uuid 的唯一性。 idx_hash 索引有点像布隆过滤器。当没有匹配的哈希值时,我们将确定 UUID 值不存在于表中,但是当存在匹配的哈希值时,我们必须使用存储的 UUID 值进行验证。为了帮助我们,让我们创建一个 SQL 函数:

DELIMITER ;;
CREATE DEFINER=`root`@`%` FUNCTION `f_uuid_to_id`(pUUID char(36)) RETURNS int(10) unsigned
    DETERMINISTIC
BEGIN
        DECLARE iID int unsigned;
        DECLARE iOUT int unsigned;

        select get_lock('uuid_lock',10) INTO iOUT;

        SELECT id INTO iID
        FROM uuid_to_id WHERE uuid_hash = crc32(pUUID) and uuid = pUUID;

        IF iID IS NOT NULL THEN
            select release_lock('uuid_lock') INTO iOUT;
            SIGNAL SQLSTATE '23000'
                SET MESSAGE_TEXT = 'Duplicate entry', MYSQL_ERRNO = 1062;
        ELSE
            insert into uuid_to_id (uuid) values (pUUID);
            select release_lock('uuid_lock') INTO iOUT;
            set iID = last_insert_id();
        END IF;

        RETURN iID;
END ;;
DELIMITER ;

该函数检查传递的 UUID 值是否存在于 uuid_to_id 表中,如果存在则返回匹配的 id 值,否则插入 UUID 值并返回 last_insert_id。 为了防止同时提交相同的 UUID 值,我添加了一个数据库锁。 数据库锁限制了解决方案的可扩展性。 如果您的应用程序无法在很短的时间内提交两次请求,则可以取消锁定。 我还有另一个版本的函数,没有锁调用,并使用一个小的重复数据删除表,其中最近的行仅保留几秒钟。 有兴趣可以看我的 github。

替代方法的结果

现在,让我们看看使用这些替代方法的插入率。

使用 UUID 值作为主键插入表,替代解决方案

伪序结果很好。这里我修改了算法,将 UUID 前缀保持不变一分钟而不是一周,以便更好地适应测试环境。即使伪顺序解决方案表现良好,请记住它仍然会使架构膨胀,并且总体性能提升可能不会那么大。

尽管需要额外的 DML 才插入率较小,但映射到整数值这种方式将架构与 UUID 值分离。 表现在使用整数作为主键。 这种映射几乎消除了使用 UUID 值的所有可伸缩性问题。 尽管如此,即使在 CPU 和 IOPS 有限的小型 VM 上,UUID 映射技术也会产生近 4000 次插入/秒。 结合上下文,这意味着每小时 14M 行、每天 345M 行和每年 126B 行。 这样的速度可能符合大多数要求。 唯一的增长限制因素是哈希索引的大小。 当哈希索引太大而无法放入缓冲池时,性能将开始下降。

UUID 值以外的其他选项?

当然,还有其他方式可以生成唯一 ID。 MySQL 函数 UUID_SHORT() 使用的方法就很有趣。像智能手机这样的远程设备可以使用 UTC 时间而不是服务器正常运行时间。这是一个建议:

(Seconds since January 1st 1970) << 32
+ (lower 2 bytes of the wifi MAC address) << 16
+ 16_bits_unsigned_int++;

16 位计数器应初始化为随机值并允许翻转。 两个设备产生相同 ID 的可能性非常小。 它必须大约在同一时间发生,两个设备必须具有相同的 MAC 低字节,并且它们的 16 位计数器必须具有相同的增量。

笔记

与这篇文章相关的所有数据都可以在我的 github 中找到。

【原文地址】:UUIDs are Popular, but Bad for Performance — Let’s Discuss

你可能感兴趣的:(【译】UUID 很受欢迎,但对性能不利——让我们讨论一下)