PostgreSQL中如何高效使用UUID主鍵?

banq發表於2024-04-23

UUID通常用作資料庫表主鍵。它們易於生成,易於在分散式系統之間共享並保證唯一性。

考慮到 UUID 的大小,這是否是一個正確的選擇值得懷疑,但通常這不是由我們決定的。

本文的重點不是“ UUID 是否是鍵的正確格式”,而是如何有效地使用UUID作為PostgreSQL的主鍵。

用於 UUID 的 Postgres 資料型別
UUID 可以看作是一個字串,因此很容易將其儲存為字串。Postgres 有一種用於儲存字串的靈活資料型別:文字,它經常被用作儲存 UUID 值的主鍵。

這是一種正確的資料型別嗎?肯定不是。

Postgres 有一種專門用於 UUID 的資料型別:uuid。UUID 是 128 位資料型別,因此儲存單個值需要 16 個位元組。

這些差異在小表中並不重要,但一旦開始儲存數十萬或數百萬行,就會成為一個問題。

我做了一個實驗,看看在實踐中有什麼區別。有兩個表只有一列--作為主鍵的 id。第一個表使用文字,第二個表使用 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(<font>"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(<font>"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 <font>"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 樹索引對有序值(如自動遞增列或時間排序列)效果最佳。

UUID 雖然看起來總是相似,但卻有多種變體。Java 的 UUID.randomUUID() 返回 UUID v4,這是一個偽隨機值。對我們來說,更有趣的是 UUID v7,它會產生按時間排序的值。這意味著每次生成新的 UUID v7 時,它的值就會越大。這使得它非常適合 B-Tree 索引。

要在 Java 中使用 UUID v7,我們需要一個第三方庫,如 java-uuid-generator:

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

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(<font>"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 倍。

概括<strong>
正如一開始提到的 - 由於 UUID 長度 - 即使進行了所有這些最佳化,它也不是主鍵的最佳型別。如果您有選擇,請檢視由Vlad Mihalcea維護的[url=https://github.com/vladmihalcea/hypersistence-tsid]TSID[/url]。

但如果您必須或出於某種原因想要使用 UUID,請考慮我提到的最佳化。另請記住,此類最佳化對於大型資料集會產生影響。如果您儲存數百甚至數千行,並且流量較低,您可能不會看到應用程式效能有任何差異。但是,如果您有可能擁有大型資料集或大流量 - 最好從一開始就這樣做,因為更改主鍵可能是一個相當大的挑戰。

 

相關文章