暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

再提性能优化,可以再挖一下PostgreSQL中的UUID类型

数据库杂记 2024-07-18
751

前言

提到UUID这种类型,真是让广大开发人员和DBA人员,又爱又恨。是的,没说错,又爱又恨。爱:它很简单,可以在客户端直接生成,唯一性有保障,同时也能有效的防止外围用户进行相应值的猜测, 避免信息泄漏。

恨的地方在哪儿:性能上有些拉垮。尤其是存储空间上,默认的UUIDv4,因为产生的值随机分布比较强,导致存储的物理顺序并不有序,使用索引的时候,无疑要浪费很多存储空间,对应在该列上的一些查询操作,也讨不到好。

数据量少的时候,一切都还可以容忍。数据量大起来,可能就会招致很多人的反对。毕竟它跟Sequence在性能以及存储上,差距还是相当大。

UUID通常用作数据库表的主键。它们易于生成,易于在分布式系统之间共享,并保证唯一性。

考虑到UUID的大小,这是否是一个正确的选择是值得怀疑的,但通常不是由我们来决定的(言下之意,可能有时候是业务以及遗留系统决定的,身不由己)。

于是,本文不打算讨论:UUID是否适合用作数据库的主键。这里打算讨论的是:在使用UUID作为主键的情况下,如何让它的性能尽量最优

内容介绍

PostgreSQL中的UUID数据类型

UUID可以被看作是一个字符串,这样存储起来可能很诱人。PostgreSQL有一个灵活的数据类型来存储字符串:文本,它经常被用作存储UUID值的主键。

它是正确的数据类型吗? 绝对不会。

Postgres有一个专用的uuid数据类型:uuid (自PG12就开始支持了)。UUID是128位数据类型,因此存储单个值需要16字节。文本数据类型有1或4字节的开销,外加存储实际字符串的开销。

这些差异在小表中并不那么重要,但一旦开始存储数十万或数百万行,就会成为一个大问题。

这里做了一个实验,看看在实践中有什么不同。有两个表只有一列——一个id作为主键。第一个表使用文本,第二个表使用uuid:

create table bank_transfer(
    id text primary key
);

create table bank_transfer_uuid(
    id uuid primary key
);

我没有指定主键索引的类型,所以Postgres使用默认的B-tree。

然后我从Spring的JdbcTemplate中使用batchUpdate向每个表插入10 000 000行:

jdbcTemplate.batchUpdate("insert into bank_transfer (id) values (?)",
        new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                ps.setString(1, UUID.randomUUID().toString());
            }

            @Override
            public int getBatchSize() {
                return 10_000_000;
            }
});

jdbcTemplate.batchUpdate("insert into bank_transfer_uuid (id) values (?)",
        new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                ps.setObject(1, UUID.randomUUID());
            }

            @Override
            public int getBatchSize() {
                return 10_000_000;
            }
        });

我运行下边的查询来查找表大小和索引大小:

select 
    relname as "table"
    indexrelname as "index",
    pg_size_pretty(pg_relation_size(relid)) "table size",
    pg_size_pretty(pg_relation_size(indexrelid)) "index size"
from 
    pg_stat_all_indexes
where 
    relname not like 'pg%';

+------------------+-----------------------+----------+----------+
|table             |index                  |table size|index size|
+------------------+-----------------------+----------+----------+
|
bank_transfer_uuid|bank_transfer_uuid_pkey|422 MB    |394 MB    |
|bank_transfer     |bank_transfer_pkey     |651 MB    |730 MB    |
+------------------+-----------------------+----------+----------+

使用文本的表要大54%,索引要大85%。这也反映在Postgres用于存储这些表和索引的页面数量上:

select relname, relpages from pg_class where relname like 'bank_transfer%';

+-----------------------+--------+
|relname                |relpages|
+-----------------------+--------+
|
bank_transfer          |83334   |
|bank_transfer_pkey     |85498   |
|
bank_transfer_uuid     |54055   |
|bank_transfer_uuid_pkey|50463   |
+-----------------------+--------+

更大的表、索引和更多的表意味着Postgres必须执行插入新行和提取行的工作——特别是当索引的大小大于可用的RAM内存时,Postgres必须从磁盘加载索引数据。

UUID和B-树索引

随机UUID不适合b树索引,而b树索引是主键唯一可用的索引类型。

B-tree索引对于有序值(如自动递增列或时间排序列)效果最好。

UUID——尽管看起来总是相似的——有多种变体。Java的UUID. randomuuid()—返回UUID v4—这是一个伪随机值。对我们来说,更有趣的是UUID v7——它产生按时间排序的值。这意味着每次生成新的UUID v7时,它的值都会变大。这使得它很适合b树索引

要在Java中使用UUID v7,我们需要一个第三方库,如Java - UUID -generator: (参考:https://github.com/cowtowncoder/java-uuid-generator)

<dependency>
  <groupId>com.fasterxml.uuid</groupId>
  <artifactId>java-uuid-generator</artifactId>
  <version>5.1.0</version>
</dependency>

然后我们可以生成UUID v7:

UUID uuid = Generators.timeBasedEpochGenerator().generate();

理论上,这将提高执行INSERT语句的性能。

UUID v7是如何提升INSERT的性能的

我创建了另一个表,与bank_transfer_uuid完全相同,但它将只存储使用上面提到的库生成的UUID v7:

create table bank_transfer_uuid_v7(
   id uuid primary key
);

然后我运行10轮,向每个表插入10000行,并测量它需要多长时间:

for (int i = 1; i <= 10; i++) {
    measure(() -> IntStream.rangeClosed(010000).forEach(it -> {
        jdbcClient.sql("insert into bank_transfer (id) values (:id)")
                .param("id", UUID.randomUUID().toString())
                .update();
    }));

    measure(() -> IntStream.rangeClosed(010000).forEach(it -> {
        jdbcClient.sql("insert into bank_transfer_uuid (id) values (:id)")
                .param("id", UUID.randomUUID())
                .update();
    }));

    measure(() -> IntStream.rangeClosed(010000).forEach(it -> {
        jdbcClient.sql("insert into bank_transfer_uuid_v7 (id) values (:id)")
                .param("id", Generators.timeBasedEpochGenerator().generate())
                .update();
    }));
}

结果看起来有点随机,特别是在比较具有常规文本列和uuid v4的表的时间时:

+-------+-------+---------+
| text  | uuid  | uuid v7 |
+-------+-------+---------+
| 7428  | 8584  | 3398    |
| 5611  | 4966  | 3654    |
| 13849 | 10398 | 3771    |
| 6585  | 7624  | 3679    |
| 6131  | 5142  | 3861    |
| 6199  | 10336 | 3722    |
| 6764  | 6039  | 3644    |
| 9053  | 5515  | 3621    |
| 6134  | 5367  | 3706    |
| 11058 | 5551  | 3850    |
+-------+-------+---------+

但是我们可以清楚地看到,插入UUID v7比插入常规UUID v4快2倍。

TEXT类型与UUIDv7(client值)混用

其实在真实环境当中,由于要支持多种数据库,有些设计或实现人员,就把UUID类型的字段,给弄成了VARCHAR(36)或者TEXT类型了。即算碰到这种情况,我们仍然有优化的余地。通过在客户端生成UUID v7形式的值,保证后端存储的值仍然是基本有序,那么索引空间和索引效率也会大大提高。只是数据部分的存储有点受委屈了。有一说一。

就本文强调的而言:

Server端:uuid列类型,配合客户端生成UUID v7形式的值,基本可以达到完美组合。而PG本身的UUID v7(server 端), 估计很快也会有。如文后提到:will be likely supported natively in PostgreSQL 17 :-)

小结

正如开头所提到的(由于UUID长度的原因),即使进行了所有这些优化,它也不是主键的最佳类型。如果您有选择,请查看Vlad Mihalcea维护的TSID (https://github.com/vladmihalcea/hypersistence-tsid)。

但是,如果您必须或出于某种原因想要使用UUID,请考虑我提到的优化。还要记住,这种优化对于大型数据集来说是不同的。如果存储数百甚至数千行,并且流量很低,那么应用程序性能可能不会有任何差异。但是,如果您有可能拥有大型数据集或大流量,那么最好从一开始就这样做,因为更改主键可能是一个相当大的挑战。

UUID这个类型或者有关它的效率以及存储,这个话题将来还会有不断的演化与深入。

欢迎感兴趣的PGer们在文后留言。

参考和进一步阅读:

我是【Sean】,  欢迎大家长按关注并加星公众号:数据库杂记。有好资源相送,同时为你提供及时更新。已关注的朋友,发送0、1到7,都有好资源相送。

往期导读: 
1. 小福利: ULID---另一种半有序高效全局ID出现了
2. 小心使用UUID, PostgreSQL中的UUID的弊端及解决方案
3. 一个有关UUID字段相关类型的有趣案例 
4. PostgreSQL SQL的基础使用及技巧
5. PostgreSQL开发技术基础:过程与函数

文章转载自数据库杂记,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

文章被以下合辑收录

评论