PostgreSQLのプライマリーキーはSERIALとUUIDのどっちが速いのか実験してみた


:::note warn
**注意**
この記事の実験は実際の運用を正確に反映していない恐れがあります（コメント欄の @hmatsu47 さんの投稿を参照）。
実務のアプリケーションでは異なる結果になる可能性もあるので、本記事の内容はあまり鵜呑みにせず参考程度に留めておいてください。

※「実務に近い環境で実験してみた」という投稿もお待ちしています！
:::

## はじめに
データベース（この記事ではPostgreSQLを対象とします）の主キーは1,2,3のような連番の整数値を主キーにするSERIALと、"00009236-b73c-4338-8ebd-e1f6c4f4fdd8"のようなランダムな文字列を主キーにするUUIDがあります。

それぞれメリットとデメリットがありますが、パフォーマンスについてはどうでしょうか？なんとなくSERIALの方がシンプルなぶん、速そうなイメージがありますが、実際はどうなのか調べてみました。

## 実行環境

- MacBook Pro 13-inch, M1, 2020
    - Memory 16GB
    - macOS Ventural 13.1
- PostgreSQL 14.5 (Homebrew)
- Ruby 3.2.0
- pg gem 1.4.5

## テーブル定義

SERIALを使うテーブルです。

```sql 
CREATE TABLE IF NOT EXISTS public.seq_table
(
    id integer NOT NULL DEFAULT nextval('seq_table_id_seq'::regclass),
    name character varying COLLATE pg_catalog."default",
    CONSTRAINT seq_table_pkey PRIMARY KEY (id)
)
```

UUIDを使うテーブルです。

```sql 
CREATE TABLE IF NOT EXISTS public.uuid_table
(
    id uuid NOT NULL DEFAULT gen_random_uuid(),
    name character varying COLLATE pg_catalog."default",
    CONSTRAINT uuid_table_pkey PRIMARY KEY (id)
)
```

## INSERTのパフォーマンス比較

50万件のデータを登録する時間を比較してみました。実験に使ったスクリプトは以下です。

```ruby 
require 'pg'
require 'benchmark'

db = 'uuid-sandbox'
host = 'localhost'
user ='your-name'
port = 5432

conn = PG::Connection.new(host: host, port: port, dbname: db, user: user)

num_iteration = 500_000

Benchmark.bm do |r|
  r.report "SERIAL" do
    num_iteration.times do
      conn.exec_params("INSERT INTO seq_table (name) VALUES ('hoge')")
    end
  end

  r.report "UUID" do
    num_iteration.times do
      conn.exec_params("INSERT INTO uuid_table (name) VALUES ('hoge')")
    end
  end
end
```

実行結果です。

```
           user     system      total        real
SERIAL 2.079472   3.254243   5.333715 ( 51.445103)
UUID   2.083389   3.253116   5.336505 ( 51.706449)
```

realが実際にかかった時間ですが、

- SERIAL = 51.4秒
- UUID = 51.7秒

ということで、UUIDがわずかに遅いものの、思ったほどの差はありませんでした（50万件で0.3秒程度なら、1件あたりの速度差はほぼ誤差の世界）。

## SELECTのパフォーマンス比較

上で作成した50万件のデータを全件読み出す処理を、100回実行したときの時間を比較してみました。実験に使ったスクリプトは以下です。

```ruby 
require 'pg'
require 'benchmark'

db = 'uuid-sandbox'
host = 'localhost'
user ='your-name'
port = 5432

conn = PG::Connection.new(host: host, port: port, dbname: db, user: user)

num_iteration = 100

# NOTE: 予め50万件分のデータをそれぞれ作成しておく

Benchmark.bm do |r|
  r.report "SERIAL" do
    num_iteration.times do
      seq_data = []
      conn.exec_params('SELECT id, name FROM seq_table ORDER BY id').each do |row|
        seq_data << [row['id'], row['name']]
      end
    end
  end

  r.report "UUID" do
    num_iteration.times do
      uuid_data = []
      conn.exec_params('SELECT id, name FROM uuid_table ORDER BY id').each do |row|
        uuid_data << [row['id'], row['name']]
      end
    end
  end
end
```

実行結果です。

```
            user     system      total        real
SERIAL 18.875210   1.901452  20.776662 ( 26.955823)
UUID   23.601780   2.482648  26.084428 ( 46.783059)
```

realの数字を見てみましょう。

- SERIAL = 26.9秒
- UUID = 46.7秒

こちらはSERIALに比べて、UUIDの方が約1.7倍時間がかかっていますね。文字列を返すぶん、UUIDの方がデータの受け渡しに時間がかかるんでしょうか？（ボトルネックの詳細は未調査。Rubyやpg gemの内部処理にボトルネックがある、可能性もゼロではない）

この時間差はあくまで「50万件分のデータを100回読み込んだら」という条件下なので、数十件程度の読み込みならほとんど違いがわからないと思いますが、「UUIDの方が多少遅い（かもしれない）」ということは頭の片隅に入れておいた方が良いかもしれません。

## 追記1：SELECTの実行計画を見てみる

https://twitter.com/onozaty/status/1622749604092723200

上のコメントを見て、「そうだ、実行計画があった！」と思ったので実行計画の結果を追記します。

### idとnameを取得する場合

やっぱりUUIDの方がcostが大きい。あとwidthも大きい（widthは「[取得される行の平均サイズ](https://postgresweb.com/post-4047)」とのこと）。

```
uuid-sandbox=# EXPLAIN SELECT id, name FROM seq_table ORDER BY id;
                                       QUERY PLAN                                        
-----------------------------------------------------------------------------------------
 Index Scan using seq_table_pkey on seq_table  (cost=0.42..15562.84 rows=490046 width=9)
(1 row)
```

```
uuid-sandbox=# EXPLAIN SELECT id, name FROM uuid_table ORDER BY id;
                                         QUERY PLAN                                         
--------------------------------------------------------------------------------------------
 Index Scan using uuid_table_pkey on uuid_table  (cost=0.42..29892.42 rows=500000 width=21)
(1 row)
```

EXPLAIN ANALYZEしてもやはりUUIDの方が遅い（Execution Timeが82.758ms vs 169.611ms）。

```
uuid-sandbox=# EXPLAIN ANALYZE SELECT id, name FROM seq_table ORDER BY id;
                                                               QUERY PLAN                                                                
-----------------------------------------------------------------------------------------------------------------------------------------
 Index Scan using seq_table_pkey on seq_table  (cost=0.42..15562.84 rows=490046 width=9) (actual time=0.063..62.753 rows=500000 loops=1)
 Planning Time: 0.161 ms
 Execution Time: 82.758 ms
(3 rows)
```

```
uuid-sandbox=# EXPLAIN ANALYZE SELECT id, name FROM uuid_table ORDER BY id;
                                                                 QUERY PLAN                                                                  
---------------------------------------------------------------------------------------------------------------------------------------------
 Index Scan using uuid_table_pkey on uuid_table  (cost=0.42..29892.42 rows=500000 width=21) (actual time=0.116..157.893 rows=500000 loops=1)
 Planning Time: 0.719 ms
 Execution Time: 169.611 ms
(3 rows)
```

### idのみを取得する場合

次に、idのみを取得するSQLでEXPLAINしてみました。Index ScanからIndex Only Scanに変わりますが、やはりUUIDの方が遅い（costが大きい）です。

```
uuid-sandbox=# EXPLAIN SELECT id FROM seq_table ORDER BY id;
                                          QUERY PLAN                                          
----------------------------------------------------------------------------------------------
 Index Only Scan using seq_table_pkey on seq_table  (cost=0.42..12871.18 rows=490046 width=4)
(1 row)
```

```
uuid-sandbox=# EXPLAIN SELECT id FROM uuid_table ORDER BY id;
                                           QUERY PLAN                                            
-------------------------------------------------------------------------------------------------
 Index Only Scan using uuid_table_pkey on uuid_table  (cost=0.42..17152.42 rows=500000 width=16)
(1 row)
```

ただし、EXPLAIN ANALYZEしたときのExecution Timeはほとんど変わりませんでした（何度か試したがどちらも65ms〜69ms程度だった）。

```
uuid-sandbox=# EXPLAIN ANALYZE SELECT id FROM seq_table ORDER BY id;
                                                                  QUERY PLAN                                                                  
----------------------------------------------------------------------------------------------------------------------------------------------
 Index Only Scan using seq_table_pkey on seq_table  (cost=0.42..12871.18 rows=490046 width=4) (actual time=0.038..43.454 rows=500000 loops=1)
   Heap Fetches: 13080
 Planning Time: 0.065 ms
 Execution Time: 65.106 ms
(4 rows)
```

```
uuid-sandbox=# EXPLAIN ANALYZE SELECT id FROM uuid_table ORDER BY id;
                                                                   QUERY PLAN                                                                    
-------------------------------------------------------------------------------------------------------------------------------------------------
 Index Only Scan using uuid_table_pkey on uuid_table  (cost=0.42..17152.42 rows=500000 width=16) (actual time=0.040..44.770 rows=500000 loops=1)
   Heap Fetches: 0
 Planning Time: 0.065 ms
 Execution Time: 65.917 ms
(4 rows)
```

idのみを取得する場合のEXPLAIN ANALYZEのExecution Timeがほぼ同じなのはいったいなぜなんでしょうか……？🤔
→この理由についてはコメント欄の @magicant さんの投稿が参考になりそうです。

## 追記2：order byを外してみた

コメント欄にて @magicant さんから「`ORDER BY id`を外してみたらどうなるか」という提案があったので試してみました。

EXPLAINのみ（ANALYZEなし）

```
uuid-sandbox=# EXPLAIN SELECT id, name FROM seq_table;
                           QUERY PLAN                            
-----------------------------------------------------------------
 Seq Scan on seq_table  (cost=0.00..7603.46 rows=490046 width=9)
(1 row)
```

```
uuid-sandbox=# EXPLAIN SELECT id, name FROM uuid_table;
                            QUERY PLAN                             
-------------------------------------------------------------------
 Seq Scan on uuid_table  (cost=0.00..8185.00 rows=500000 width=21)
(1 row)
```

EXPLAIN ANALYZEの場合

```
uuid-sandbox=# EXPLAIN ANALYZE SELECT id, name FROM seq_table;
                                                    QUERY PLAN                                                    
------------------------------------------------------------------------------------------------------------------
 Seq Scan on seq_table  (cost=0.00..7603.46 rows=490046 width=9) (actual time=1.169..199.587 rows=500000 loops=1)
 Planning Time: 0.068 ms
 Execution Time: 229.073 ms
(3 rows)
```

```
uuid-sandbox=# EXPLAIN ANALYZE SELECT id, name FROM uuid_table;
                                                     QUERY PLAN                                                     
--------------------------------------------------------------------------------------------------------------------
 Seq Scan on uuid_table  (cost=0.00..8185.00 rows=500000 width=21) (actual time=1.481..164.873 rows=500000 loops=1)
 Planning Time: 0.053 ms
 Execution Time: 192.362 ms
(3 rows)
```

この場合はどちらも同じ、というか場合によってはUUIDの方がちょっと速いようです（ただのSeq Scanですしね）。

## 追記3：CLUSTERを実行してから実行計画の変化を見てみた

コメント欄にて @hmatsu47 さんと @magicant さんから「CLUSTERを使ったら〜」という話があったので、僕も試してみました。
というか、恥ずかしながらCLUSTERって初めて知りました。

> CLUSTERは、index_nameで指定されたインデックスに基づき、table_nameで指定されたテーブルをクラスタ化するように、PostgreSQLに指示します。 このインデックスは前もってtable_name上に定義されていなければなりません。
>
> テーブルがクラスタ化されると、それぞれのテーブルはインデックス情報に基づいて物理的に並べ直されます。 クラスタ化は、1回限りの操作です。 クラスタ化後にテーブルが更新されても、その変更はクラスタ化されません。 つまり、新規に追加された行や更新された行は、インデックス順には保管されません。 
>
> https://www.postgresql.jp/document/14/html/sql-cluster.html

CLUSTERコマンドを実行してみました。

```
uuid-sandbox=# CLUSTER VERBOSE public.uuid_table USING uuid_table_pkey;
INFO:  clustering "public.uuid_table" using index scan on "uuid_table_pkey"
INFO:  "uuid_table": found 0 removable, 500000 nonremovable row versions in 3185 pages
DETAIL:  0 dead row versions cannot be removed yet.
CPU: user: 0.31 s, system: 0.12 s, elapsed: 0.87 s.
CLUSTER
```

続いて実行計画を見てみます。

EXPLAINのみ（ANALYZEなし）

```
uuid-sandbox=# EXPLAIN SELECT id, name FROM seq_table ORDER BY id;
                                       QUERY PLAN                                        
-----------------------------------------------------------------------------------------
 Index Scan using seq_table_pkey on seq_table  (cost=0.42..15562.84 rows=490046 width=9)
(1 row)
```

```
uuid-sandbox=# EXPLAIN SELECT id, name FROM uuid_table ORDER BY id;
                                         QUERY PLAN                                         
--------------------------------------------------------------------------------------------
 Index Scan using uuid_table_pkey on uuid_table  (cost=0.42..27952.42 rows=500000 width=21)
(1 row)
```

EXPLAIN ANALYZEの場合

```
uuid-sandbox=# EXPLAIN ANALYZE SELECT id, name FROM seq_table ORDER BY id;
                                                               QUERY PLAN                                                                
-----------------------------------------------------------------------------------------------------------------------------------------
 Index Scan using seq_table_pkey on seq_table  (cost=0.42..15562.84 rows=490046 width=9) (actual time=1.779..66.468 rows=500000 loops=1)
 Planning Time: 0.069 ms
 Execution Time: 84.290 ms
(3 rows)
```

```
uuid-sandbox=# EXPLAIN ANALYZE SELECT id, name FROM uuid_table ORDER BY id;
                                                                 QUERY PLAN                                                                 
--------------------------------------------------------------------------------------------------------------------------------------------
 Index Scan using uuid_table_pkey on uuid_table  (cost=0.42..27952.42 rows=500000 width=21) (actual time=0.906..75.100 rows=500000 loops=1)
 Planning Time: 0.067 ms
 Execution Time: 93.242 ms
(3 rows)
```

EXPLAINだけだとCLUSTERを実行する前とほとんど変わらないですが、EXPLAIN ANALYZEのExecution Timeを見ると、ほとんど大差ない速度差になりました。

## まとめ

というわけでこの実験のまとめです。

- INSERTにおいてはほとんど差がない
- SELECTはSERIALの方がちょっと速い / UUIDの方がちょっと遅い（かもしれない）
- 24時間365日大量のデータを読み書きするとか、パフォーマンスが何よりも命、といった要件がなければ、速度面においてはどちらを選んでも問題なさそう

この実験はRDBMSの種類やバージョンによって変わる可能性もありそうなので注意してください。

もし実験のやり方や結果の分析に問題がありそうなら、コメント欄等で教えてもらえると助かります！

## あわせて読みたい

パフォーマンス以外の面でSERIALがいいかUUIDがいいかについてはネット上にいろんな議論があるのでそちらを参考にしてみてください。

- [Auto\-generated primary keys: UUID, serial or identity column? \- CYBERTEC](https://www.cybertec-postgresql.com/en/uuid-serial-or-identity-columns-for-postgresql-auto-generated-primary-keys/)
- [postgresql \- UUID or SEQUENCE for primary key? \- Stack Overflow](https://stackoverflow.com/questions/33274291/uuid-or-sequence-for-primary-key)