プライマリキーにUUID v7/ULIDを使うか問題について

MySQLでUUID(v4)をプライマリキーにしてはいけない理由

データベースの設計でプライマリキーをどうするかは、最初に悩むところではないかと思います。特にコンシューマー向けのWebサービスやマルチテナントのB2Bサービスを開発するときなどは特に気をつかいますよね。

何も考えずにAuto increment(自動採番)型のプライマリキーを付けて、「/users/1」「/users/2」「/users/3」みたいなID付きのURLでアクセスさせてしまうと、次のような問題が生じることがあります。

  • URLに使われているIDの大きさや増加数から、システムの規模感や利用状況が分かってしまう。
  • IDを機械的に変えて総当たり的に情報を取得できてしまう。

また、分散したシステムでサブシステムごとに衝突しないIDを割り当てたいことがあるかもしれません。そんなときにUUIDをプライマリキーにしちゃえばいいんじゃねと考えるわけですが、MySQLでは特にこれはバッドプラクティスとされています。

詳しい理由は上の記事に譲りますが、かんたんに説明すると、MySQL(InnoDB)のクラスタインデックスでは、キーの値を比較した順序が近いほど物理的に近接した場所にレコードのデータが配置されるため、Auto incrementの場合は、連続して投入されたデータはディスク上の近い場所にあり、キャッシュの恩恵が受けられます。UUIDの場合は書き込み先がランダムになるので、キャッシュヒットの割合が下がり、パフォーマンスが低下します。要するに、プライマリキーは連番じゃないとだめということです。

同様の機構に、Oracleの索引構成表があります。

cncpt272.gif

その対策として、上の記事ではUUID(v7)やULIDの利用を紹介しています。ULIDは先頭48bitsがタイムスタンプ、それに続く80bitsのランダム文字列という構成になっています。先頭がタイムスタンプなので、クラスタインデックス上でも投入順に隣接して格納されるというわけです。

01AN4Z07BY 79KA1307SR9X4MV3 |----------| |----------------| Timestamp Randomness 48bits 80bits

ちなみにPostgreSQLでは、プライマリキーのインデックスとは別の領域にレコードデータが保存されるため、そういった制約はありません。標準でUUIDの型も用意されています。

じゃあULIDならプライマリキーにしていいのか

ここからが本題なのですが、わたしはUUID v7やULIDであっても、プライマリキーにするべきではないと考えます。

理由は大きく3つあります。

文字列は数値より容量を使うし、数値より速くソートできない

UUIDは128ビットの値を16進数にしてハイフンを入れた36文字で表します。ULIDは128ビットの値をCrockford's Base32という形式でエンコードした26文字で表します。もしかするとUUIDをプライマリキーにしたい人はプライマリキーの型をvarchar(36)にしたいと思うかもしれません。格納するのに必要なサイズはcharsetがutf8mb4の場合、144バイトです。数値ならintで4バイト、bigintでも8バイトで済みます。わたしはvarchar(36)の外部キーを持ちたいとは思いません。遅そうだし、コレーションも気になります。賢い人はUUIDを数値として格納したらいいと思うでしょう。今のところMySQLには128ビットの数値型はないのでBINARY型を使うみたいです。

主キーが長くなると、セカンダリインデックスで使用される領域も多くなるため、主キーは短い方が利点があります。

https://dev.mysql.com/doc/refman/8.0/ja/innodb-index-types.html

そもそもタイムスタンプが類推できたら意味ないじゃんね

生成されたタイムスタンプくらいは類推できてもいいやと思うならそれまでですが、IDから何かしら情報が漏れるのは嫌だというところから始まっているので、せっかくランダムなIDにした意味がないじゃん?と思ってしまいます。

たとえUUID/ULIDであっても、内部キーをユーザーにさらすべきではない

UUIDにしてもAuto Incrementにしても、ここで言っているキーはいわゆるサロゲート(代理)キーなわけです。なぜサロゲートキーを使うかというと、文脈的に意味のあるキー(例えば社員番号やメールアドレス)をプライマリキーにしてしまうと、次のような問題が起こるかもしれないからです。

  1. 社員番号を変更するとその社員ページのURLが変わってしまう。
  2. 社員番号が未来永劫重複しないことを保証しなければいけなくなる。
  3. 同じ社員番号を使い回すことができなくなる。

サロゲートキーを使うことで、システム的な一意性と、文脈的な一意性とを分離することができます。社員番号などのカラムはユニーク制約を付けて運用することで、文脈的な一意性を担保できます。

ここで、プライマリキーをURLなどで外部にさらしてしまったら、そのキーはユーザーにとって文脈的に意味のあるキーだといえます。ユーザーはURLをブックマークするかもしれないし、誰かにシェアするかもしれません。プライマリキーを外部にさらしてしまうと、システムの移行や統合で内部的にプライマリキーの形式を変えたくなったときに対応が困難になります。

じゃあどうすればいいのか

プライマリキーにはAuto incrementの値を使って、ユーザーに公開するキーとして別途ランダムな文字列を生成します。UUIDでもいいし、自分で乱数を生成して使ってもいいです。自分で生成する場合は弱い疑似乱数を使わないようにしてください。

RubyだとSecureRandom.random_bytesを使うことになります。このメソッドからはRandom.urandomが呼び出されます。他の言語やライブラリでも、通常は/dev/urandomから乱数を取得することになるはずです。

Linuxの/dev/urandomは、システムから品質の高い乱数が得られればそれを使い、エントロピーが枯渇していれば疑似乱数を生成します。疑似乱数を使う場合でも、シードは過去のエントロピーが使われるため、暗号的に安全な擬似乱数だと期待できます。

公開キーの形式

ユーザーに公開するためのキーなので、なるべく短いのが望ましいです。仮にUUID v4と同じ128ビットのランダム値を用いる場合、文字列の長さは以下のようになります。

使用できる文字

文字列長

UUID

16進数値(4bits)とハイフン

36文字

Base32

0-9, abcdefghjkmnpqrstvwxyz(5bits)

26文字

Base64

0-9, a-z, A-Z, +/(6bits)

22文字

Runbookでは大文字小文字の区別のないBase32を採用することとしました。

おわりに

RunbookはMySQLを使用しているため、UUIDをプライマリキーにするという選択肢は最初から除外しました。PostgreSQLなんかだとまた事情は違うかもしれませんが、その場合でもプライマリキーはユーザーに公開すべきではないと考えています。うちはこうやってやってるよとか、ご意見などありましたら@ryokdyまでお願いします。