七月 22, 2024
摘要:在本教程中,您将学习如何在 PostgreSQL 中使用 UUID 作为主键。
目录
UUID 通常用作数据库表的主键。它们易于生成,易于在分布式系统之间共享,并保证唯一性。
考虑到 UUID 的大小,它是否是一个正确的选择是值得怀疑的,但通常也不是由我们来决定的。
本教程重点不在于“UUID 是否是正确的键值格式”,而是如何在 PostgreSQL 中有效地将 UUID 用作主键。
PostgreSQL 中用于 UUID 的数据类型
UUID 可以看作是一个字符串,将它们存储为字符串可能很诱人。Postgres 中有用于存储字符串的灵活数据类型:text
,它通常用作存储 UUID 值的主键。
它是一种正确的数据类型吗?当然不是。
Postgres 中有一个专用于 UUID 的数据类型:uuid
。UUID 是 128 位数据类型,因此存储单个值需要 16 个字节。text
数据类型会有 1 或 4 个字节的开销,外加存储实际字符串的空间。
这些差异在小型表中并不那么重要,但是一旦开始存储数十万或数百万行,就会成为一个问题。
我们来做一个实验,看看在实践中有什么区别。有两个表只有一列:作为主键的一个id
。第一个表使用text
,第二个表使用uuid
:
create table bank_transfer(
id text primary key
);
create table bank_transfer_uuid(
id uuid primary key
);
这里没有指定主键索引的类型,因此 Postgres 会使用默认的 B 树。
然后,使用 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 |
+------------------+-----------------------+----------+----------+
使用 text
的表大小要大 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 树索引最适合有序值,例如自动递增或按时间排序的列。
UUID - 即使看起来总是相似 - 也有多种变体。Java 的 UUID.randomUUID()
- 返回 UUID v4 - 这是一个伪随机值。对我们来说,更有用的是 UUID v7 - 它会产生按时间排序的值。这意味着每次生成新的 UUID v7 时,它的值都会更大。这使得它非常适合 B 树索引。
要在 Java 中使用 UUID v7,我们需要一个第三方库,例如 java-uuid-generator:
<dependency>
<groupId>com.fasterxml.uuid</groupId>
<artifactId>java-uuid-generator</artifactId>
<version>5.0.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();
}));
}
结果看起来有点随机,尤其是在比较用常规 text
列和 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 倍。
总结
正如开头提到的 - 由于 UUID 的长度 - 即使进行了所有这些优化,它也不是主键的最佳类型。
但是,如果您必须或出于某种原因想要使用 UUID,请考虑下上面提到的优化。另请记住,此类优化对于大型数据集会有所不同。如果您只要存储数百或几千行,并且访问量较低,则可能不会看到应用程序性能有任何差异。但是,如果您有可能拥有大型数据集或较大的访问量 - 最好从一开始就这样做,因为更改主键可能会成为一个相当大的挑战。