🗄️

RDBの主キー、UUID使った方がいいの?(DDD, CleanArchitecture対応)

2024/05/21に公開
4

結論

  1. お手軽モノリスならAutoIncrementが効率的だしこれでいいよ
  2. アプリケーション側で主キーを生成したい場合はLUIDを作る必要があるよ。GUIDで大は小を兼ねよう
  3. 主キーでGUIDを使うならULIDよりもUUIDv7がおすすめだよ
  4. ただし分散されているエンジンによってはUUIDv4の方が効率的になる場合もあるよ
  5. 主キーは原則公開しない方がいいよ
  6. UUIDv7やULIDはユニーク性を持ったInstant(timestamp)としても使えるよ
  7. 分散されたシステムでは厳密な時系列性を担保することはできないよ、あきらめてロックをかけつつ連番を一か所で生成しよう

RDBのPrimary Key(主キー)とは?

MySQL、PostgresQLなどのRDBでは各レコードを識別するために一意な値を必要とします。これをPrimary Key(主キー)と呼びます。別のカラムにUNIQUEなIndexを張ることもできますが、これも主キーが何かを記録しており、主キーを基に実際のデータを引きます(要出典)

主キーには被らなければどんな値でも設定していいの?

カラムの型を変えることでプライマリキーに様々な値を設定することは可能ですが、リレーショナルデータベースの肝であり一番アクセスが多い部分になるので、設計や見積もりを疎かにするとパフォーマンスのボトルネックとなってしまいます。
また、ナチュラルキー(ユーザーが設定する名前やidなど既に存在する値)を設定してしまうとその値を安易に変えることができなくなり、システムが柔軟性を失うことになります。主キーであること以外に意味を持たないキー(サロゲートキー)を設定することが重要です。

どういう値が効率的なの?

データ長は短い方がいいでしょう。可変長の型も向いていません。intやbinaryは処理が高速で表現が揺れることもないので向いています。固定長文字列(VARCHARなど)はエンコーディングによって内部のバイナリ表現が変わってしまう場合があるため注意が必要です。

型さえ合っていればどんな値を設定してもいいの?

データベースのエンジンが内部的にはどのようにデータを保管しアクセスするのかを考えて設計をすると効率的な採番方法が存在することがわかります。

MySQL(InnoDB)などの一般的なデータベースエンジンでは生成順に採番されている方が効率的になることが多いです。
https://techblog.raccoon.ne.jp/archives/1627262796.html

一方、分散型のRDB(Spannerなど)では一様な分布になるように採番した方が効率的な場合もあります。

https://cloud.google.com/spanner/docs/schema-design?hl=ja#primary-key-prevent-hotspots

採用するRDBの仕様を調べ、適切な採番方法を選択しましょう。

AutoIncrementでよくない?

MySQLやPostgresQLを使っていて連番の効率がいい場合、整数型のAutoIncrementは主キーの選択肢として適切といえるでしょう。データベースの側で採番ができるのでアプリケーションは投入したいデータに集中することができます。長年この形式をサポートしているフレームワークでは意識せずともこちらを採用していて問題になることも少ないでしょう。

しかし、この方法は挿入と更新の操作が冪等ではなくなるというデメリットがあります。DDDやClean Architectureなど、ドメインのロジックとIOなどのインフラアクセスを分離する開発手法ではデータはイミュータブルに、永続化は冪等であるように、と設計することが望ましいです。この時にAutoIncrementなどを使いRDBに主キー採番を任せた場合、ビジネスロジック内では生成ができず、永続化のためのインフラ層が採番するための状態を持ち、複雑になります。このため、DDDやClean Architectureでは、アプリケーションやシステム全体を通してユニークな採番ができるLUID(Local Unique IDentifier)を採用し、アプリケーション側でこれを生成することで主キー採番の責務をDBという実装からビジネスロジック内に移動させることが適切です。

LUIDってどうすればいいの?

何かしら採番のルールを決めて(サーバー名+タイムスタンプ+ランダム文字列etc)システム全体を通してユニーク性が保証されていれば問題ないです。ただ、独自の文字列形式のLUIDを発行する場合、そのIDのDBや外部サービスとの互換性や、データベースに格納する際のエンコード方法、特に文字列をそのまま保存する場合は後述する注意点を意識する必要があります。

たいていの場合、LUIDは独自に設計せず、GUID(Globaly Unique IDentifier)として標準化がすでに行われている規格を利用することが望ましいでしょう。

GUIDってどんなのがあるの?

代表的なGUIDには

  • UUID (RFC 4122)
    • こんなやつ
      • 550e8400-e29b-41d4-a716-446655440000
    • v1, v2, v3, v5
      • 割愛。時系列データやデバイスの情報を含んだりするが値の空間的には一様に分布するように採番される
    • v4
      • 各バージョンで一番ランダムビット数が多く(122bit)、ほぼ完全にランダムに採番される。UUIDといえばこれ
    • v6, v7, v8 (時系列ソートができる新しい規格, RFC 9562 でついに標準化されました!2024/05)
      • v6
        • タイムスタンプを並び替えて一様分布を達成したv1を元に並べ替えなおすようなもの
      • v7
        • 48bitのタイムスタンプと74bitの乱数からなる。今回の記事で一番取り上げたい!おすすめ!
      • v8
        • 他のバージョンが使えないときのフォールバックみたいな規格。いろいろ自由だけど時系列順に並ぶようにはしてね、みたいな感じ どうやらドラフトから標準になるにあたって時系列順という制約がなくなり、バージョンの6bit以外完全に自由に設定できるという仕様になったそうです。ただあくまでも他のバージョンが使えない時のフォールバック、という点は変わらないそう(@lyiase さん、ご指摘ありがとうございます!)
  • snowflake (https://github.com/twitter-archive/snowflake/tree/snowflake-2010)
    • こんなやつ
      • 1792657442480324608
    • 時系列ソート可能
    • twitterが作った64bitのGUID
    • long longな数値として表現可能(javascriptだと途切れちゃったりするらしい)
  • ULID (https://github.com/ulid/spec)
    • こんなやつ
      • 01ARZ3NDEKTSV4RRFFQ69G5FAV
    • 時系列ソート可能
    • UUIDと同じ128bit
    • UUIDv7はこれを参考に作られてる
    • 48bitのタイムスタンプと80bitの乱数
    • 同じミリ秒で採番されたときに時系列が担保されるようにインクリメントしてね、というのが規定されている
    • base32というエンコード方式を使っていて文字数が少ない
  • nanoid (https://github.com/ai/nanoid)
    • こんなやつ
      • V1StGXR8_Z5jdHi6B-myT
    • 126bitの乱数
    • 使用する文字列を自分で決められる
    • 乱数の生成方法にたくさん工夫がされている
    • ライブラリのサイズが小さい
    • 結構使われているらしい
  • CUID2 (https://github.com/paralleldrive/cuid2)
    • こんなやつ
      • tz4a98xxat96iws9zmbrgj3a
    • どちらかというと乱数の生成方法+エンコーディングみたいな仕様に見える
    • 長さを決められる

GUIDのどれがおすすめ?

結論から言うと主キーとしてGUIDを採用する場合にはUUIDが優れた候補と言えると思われます。IETFによって標準化されており、サポートする言語やライブラリ、サービスやインフラが他のIDと比べ桁違いに多いです。UUIDv7の標準化により時系列ソート可能なIDにも対応したことで隙がなくなりました。

現在はサードパーティーによる先行的な実装が主ですが(https://pypi.org/project/uuid7) 、言語の標準ライブラリにも搭載されるようになると思われます(https://discuss.python.org/t/add-uuid7-in-uuid-module-in-standard-library/44390/6)
PostgresQLではuuid型(https://www.postgresql.jp/docs/9.4/datatype-uuid.html) が存在し、効率的に格納、検索することが可能です。

一様分布が効率的なDB(Spanner)などではUUIDv4、それ以外ではUUIDv7を使うのが望ましいと思われます。

他のGUIDの場合はどのようにデータを格納するのかをアプリケーション側で考えないといけないため処理が増えてしまいます。(バイナリにエンコードして格納し取得時にデコードする、VARCHARやcharとして保存=サイズが増えテキストエンコーディングやコレーションを考慮する必要が出てくる)

主キーって外部に公開していいの?

主キーは原則外部に公開することは望ましくありません。AutoIncrementな主キーを公開すると他のレコードを推測できてしまうケース(俺のurlが/user/1ってことは/user/4も存在するかもしれない)が考えられますし、UUIDv7のようにタイムスタンプが含まれるIDを公開するとIDを生成した時間を逆算できてしまいます。

ではUUIDv4のようにランダムな値であれば主キーを公開してもいいのか?これも不都合が生まれます。
例えば外部に公開したいレコードと公開したくないレコードがある場合、主キーと共有してしまうと、そのレコードが公開可能であるかというのをビジネスロジックで判断する必要が出てきます。外部公開するIDを別に生成し、このIDを基に主キーおよび紐づくレコードを検索するようにすると実装がシンプルになります。

また内部的にはUUIDを使いたいけど、URLに含めるには長いんだよなーという場合も主キーには互換性の高いUUID, 外部公開用IDにはより短いnanoidを使用する、など柔軟な設計ができるようになります。

主キーはランダムなIDだけど時系列順ソート可能かつUNIQUEな値も欲しいよう

UUIDv7やULIDは構造上timestampとして使うこともでき、分散されたシステムでcreated_atのようなタイムスタンプを置き換えるuniqueな値として取り扱うことができます。ソート用かつ正規データとしてUUIDv7用のカラムを用意し、非正規化データとしてUUIDv7から計算したtimestamptzをカラムに持つことで、分散されたシステムでも(ほとんど)時系列順にUNIQUEに並ぶテーブルを実現することができます。(トランザクションを記録し、安定的にソートできることが求められるシステムで特に有効です)

この方法はRDB以外のランダム採番DB(FireStore)などでも有効です。

じゃあ全部GUIDでいいの?

分散されたシステムでGUIDを生成した場合、時系列ソートが可能なものであっても、ミリ秒単位で衝突が起こり時系列が正しくなくなる場合や、時刻が同期されておらず時系列が正しくなくなる場合があります。このように(ほとんど)時系列順、というデータが許容できない要件(金融や入札などシビアな領域など)では、採番専用のインフラを用意し、すべてのノードが同じものを利用するというような設計が必要になります。AutoIncrementな値はデータベースの中で手軽に生成でき、完全な時系列が保証されるのでこのような場合の採用は検討されるべきでしょう。

ただしこの場合も、できればIDを生成する部分を切り出して時系列順を担保し、作成更新の動作自体は冪等にするなど工夫が考えられます。

まとめ

  • UUIDv7が分散採番の鉄板になりそうだよ
  • 主キーは外部公開しないで別のIDを採番してね
  • 厳密性を求める場合はAutoIncrementを使ってもいいね

参考となるURLやツイート

https://techblog.raccoon.ne.jp/archives/1627262796.html
https://yoskhdia.hatenablog.com/entry/2018/01/05/124633
https://x.com/LiosK/status/1789092355330761082

Discussion

gorngorn

サロゲートキーがいいか、ナチュラルキーがいいかはよく宗教戦争になるけど、私はそこまでどれとは考えていません。システムの規模、想定する利用期間、参加・利用する人のスキルレベルなどいろいろなところで変わってきますから。

yu-ichiro(s3i7h)yu-ichiro(s3i7h)

主キーにナチュラルキーを採用することの弊害はたくさん列挙できますが、サロゲートキーを一つ追加することのデメリットというのは認知負荷が増える程度しかないと思うので論争になるという認識はありませんでした!ナチュラルキーを主キーに採用する方が優れているという論説に心当たりがあればお伺いしたいです!(ことRDBにおいてナチュラルキーを補助的なカラムではなく主キーに置くメリットというのが率直に思いつかないです!)

LyiaseLyiase

UUIDv8の説明に時系列の話が乗ってますが、恐らく提案仕様の段階の話ではないでしょうか?
RFC9562ではこの仕様がなくなっており、UUIDv8は全て自由に決めることが出来きて、サンプルとして時系列で作る方法が載ってるのみです。
https://datatracker.ietf.org/doc/html/rfc9562#name-example-of-a-uuidv8-value-t

yu-ichiro(s3i7h)yu-ichiro(s3i7h)

確認しました。確かに全てのフォールバックとなっているように読めますね!これは見落としていました。ドラフト段階から追っていたので標準になったとのニュースにテンションが上がり変更点に気づかず、、ご指摘ありがとうございます。修正させていただきます!