
前言
提到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(0, 10000).forEach(it -> {
jdbcClient.sql("insert into bank_transfer (id) values (:id)")
.param("id", UUID.randomUUID().toString())
.update();
}));
measure(() -> IntStream.rangeClosed(0, 10000).forEach(it -> {
jdbcClient.sql("insert into bank_transfer_uuid (id) values (:id)")
.param("id", UUID.randomUUID())
.update();
}));
measure(() -> IntStream.rangeClosed(0, 10000).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们在文后留言。
参考和进一步阅读:
UUID v7 will likely be supported natively in Postgres 17:
https://commitfest.postgresql.org/47/4388/UUID Version 7 format:
https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7UUIDs are Popular, but Bad for Performance:
https://www.percona.com/blog/uuids-are-popular-but-bad-for-performance-lets-discuss/https://vladmihalcea.com/uuid-database-primary-key/
https://maciejwalkowiak.com/blog/postgres-uuid-primary-key/ 这个是原文
一个有关UUID字段相关类型的有趣案例 这个谈到的是SQLServer中的一个使用案例
我是【Sean】, 欢迎大家长按关注并加星公众号:数据库杂记。有好资源相送,同时为你提供及时更新。已关注的朋友,发送0、1到7,都有好资源相送。

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




