🤖

UUIDとULIDを理解していない方は見た方がいい記事

2024/06/13に公開
2

はじめに

UUIDとULIDは、データベースや分散システムで広く使用される識別子です。
UUIDは、分散システムにおいて広く利用されており、バージョンごとに異なる生成方法と特性を持ちます。ULIDは、UUIDの欠点を補う形で登場した新しい識別子で、時系列ソートが可能であることが特徴です。本記事では、それぞれの識別子の特徴と、どのようなケースで利用すべきかについて掘り下げます。
あまり考慮せずにAuto increment(自動採番)型のプライマリキーを採用している場合は、当記事を見てもらえたら嬉しいです。

特徴 自動採番形式 UUID v4 UUID v7 ULID
データ型(MySQL) INT, BIGINT CHAR(36) CHAR(36) CHAR(26)
ソート ⭕️ ⭕️
データサイズ 4バイト(INTの場合) 16バイト 16バイト 16バイト
1, 2, 3, ... 550e8400-e29b-41d4-a716-446655440000 018e4c59-64e3-7baf-bc9b-3d0fd7f7ddf4 01AN4Z07BY79KA1307SR9X4MV3

Auto increment(自動採番)型を採用したくない場合

Auto Incrementは、データベースにおいて自動的に一意の識別子を生成するメカニズムです。通常、数値型の列が対象となり、新しいレコードが挿入されるたびにその列の値が自動的にインクリメントされます。典型的なIDですかね。
ここでは一意性の確保の話や、データ移行やバックアップのデメリットには言及せず、セキュリティとプライバシーの懸念にフォーカスして考えます。

予測可能性

Auto Increment型のIDは連番であるため、次に生成されるIDが容易に予測可能です。これにより、攻撃者がシステムの内部構造を推測し、不正アクセスを試みるリスクが高まります。

情報漏洩のリスク

連番のIDはデータベースの挿入順序を反映しているため、公開されることで企業の活動パターンやデータ生成の頻度が漏洩する可能性があります。
例)
競合他社は、公開されている連番のIDを分析することで、企業の新製品のリリース頻度を把握しました。新製品のIDが順番に増加していることから、リリースのタイミングや製品開発のペースを予測することができ、基に自社の戦略を調整しました。
その他にも支払いの管理に使われているIDが連番になっていることから、未公開のユーザー登録数や有料プランの加入数なども推測されてしまいました。

上記のようなリスクが考えられます。

UUID(Universally Unique Identifier)

UUIDは128ビットの識別子であり、複数のバージョンが存在します。それぞれのバージョンは異なる方法で識別子を生成します。いくつかのバージョンはIDの生成にMACアドレスを使っているプライバシーの問題や、MD5やSHA-1を使っていることによる衝突リスクから使われなくなりました。
(参考:
SHA1 が初めて衝突
MD5は簡単に衝突させられる)

そして現在一般的に使われているのがUUID v4であり、最近提案されたのがUUID v7になります。

UUID v4

ID生成方法
UUID v4は、ランダムに生成される128ビットの値を基にしています。このバージョンのUUIDは、全てのビットがランダムに設定されるため、生成が非常に簡単で、高い一意性を持ちます。また以下の決まった情報も組み込まれています。

  • バージョンビットを設定:128ビットのうち、特定の4ビット(バージョンフィールド)を0100に設定
  • バリアントビットを設定:次に、特定の2ビット(バリアントフィールド)を10に設定します。これにより、UUIDがRFC 4122に準拠していることを示す
import uuid

uuid_v4 = uuid.uuid4()
print(uuid_v4)

以下は上記で生成したUUID v4です。
どちらも第3セクションが4になっており、第4セクションの文字がバリアント1(最初のビットが10)になっています。(aと8の箇所)

dac78382-23f0-414b-ad31-9cbf3d872fab
5fca6ad9-149b-4392-8d28-6ba3acc96e08

UUID v7

次にUUID v7についてです。
基本的にUUID v4をソート可能ないように見直したと捉えてもらって良いのかなと思います。

IDの生成方法

  • タイムスタンプを取得:現在のタイムスタンプをミリ秒単位で取得し、48ビットのビット列に変換します。
  • ランダムビットの生成:残りの80ビットを暗号学的に安全な乱数で埋めます。
  • バージョンビットの設定:タイムスタンプの一部を使ってバージョンフィールドを0111に設定します。

参考:https://github.com/ahawker/ulid

from uuid6 import uuid7

uuid_v7 = uuid7()
print(uuid_v7)

第3セクションにバージョンが記載されていることが分かると思います。
64ビットがタイムスタンプになっているため、生成順にソートが可能というのが特徴です。

018ffc72-5231-7262-bcf6-1434e89e9e0c
018ffc80-7e8b-70a9-994b-097a03bb60d1

タイムスタンプで構成されているため、解釈して時間を表示することが可能です。
以下のコードは時間を抽出する例です。

from uuid6 import uuid7
import datetime


def extract_timestamp_from_uuid7(uuid):
    uuid_bytes = uuid.bytes
    # タイムスタンプ部分を抽出
    timestamp_ms = int.from_bytes(uuid_bytes[:6])
    # タイムスタンプをミリ秒単位のUnixタイムスタンプとして解釈
    timestamp_s = timestamp_ms / 1000.0
    # タイムスタンプをdatetimeオブジェクトに変換
    dt = datetime.datetime.fromtimestamp(timestamp_s)
    return dt

uuid = uuid7()
print(uuid)

timestamp = extract_timestamp_from_uuid7(uuid)
print(timestamp)

出力は以下になりました。
見ての通り再生時間が確認可能ですね。

018ffca2-c64b-7fef-9791-b4a3fdf06124
2024-06-09 10:54:37.131000

UUID v7の特徴として、最初の48ビットがタイムスタンプとしてエンコードされているため、UUIDから生成時刻を容易に特定することが可能です。
そのため、生成時刻が第三者に漏れてはいけない場合はv4を使う方が良いです。
という単純な問題でもないようです。これに関しては【#まとめのようなもの】の章で後述しております。

ULID(Universally Unique Lexicographically Sortable Identifier)

ULID(Universally Unique Lexicographically Sortable Identifier)は、UUIDの欠点を補う形で設計された一意識別子です。特に、時系列順にソート可能であることが大きな特徴です。

IDの生成方法

  • タイムスタンプの取得:現在のタイムスタンプをミリ秒単位で取得し、48ビットのビット列に変換します。
  • ランダム値の生成:残りの80ビットをランダムな値で埋めます。
  • エンコード:生成されたビット列をCrockford's Base32でエンコードします。

この生成方法によって、以下の特徴を持ちます

  • 時系列順にソートが可能で、データの管理や検索が容易。
  • 高い一意性と可読性を持ちます。
  • UUIDと比べて短く、URLなどで使用する際に便利。
import ulid

ulid_instance = ulid.new()
print(ulid_instance)

timestamp = ulid_instance.timestamp().datetime
print(timestamp)

上記の結果は以下です。
このようにdatetimeの取得も簡単に行えます。

01HZYC2028WMB3NJ16WCV9Z9E0
2024-06-09 11:27:38.056000+00:00

UUID v7とULIDは似た特徴を持つので、出力されるIDのフォーマットを見て、システムのニーズと照らし合わせてどちらを採用するか決めるのが良いかと思いました。

UUID、ULIDを採用したくない場合

Auto Incrementの課題からUUID、ULIDの採用を考えたとしても、前述したようにUUID、ULIDにも別の課題があります。再度まとめてみます。

  • UUID v4
    • タイムスタンプを含まないランダムな値で、ソート不可能なことによるパフォーマンスの低下
  • UUID v7 / ULID
    • 自動採番の数値と比較してパフォーマンスが悪い
    • 生成時間(タイムスタンプ)の漏洩
具体例で考えるために、数百万の商品を取り扱う大規模なECサイトを例にします

背景:
データベースには、商品の詳細、ユーザーの購入履歴、レビューなどが保存されています。日々多くのデータが追加され、クエリパフォーマンスが重要です。

課題:
パフォーマンス: 大量のデータが追加されるため、データベースのパフォーマンスが重要です。特に商品の検索やユーザーの購入履歴の取得が頻繁に行われます。
プライバシー: ユーザーの購入履歴やレビューのタイムスタンプが漏洩すると、ユーザーの行動パターンが特定されるリスクがあります。

UUID v4の問題:
ランダムな値であるため、データの挿入順序がバラバラになり、インデックスのフラグメンテーションが発生しやすく、クエリパフォーマンスが低下します。

UUID v7 / ULIDの問題:
挿入順序は維持されますが、文字列型のIDは数値型に比べて容量が大きく、インデックスのサイズも増大します。
タイムスタンプが含まれるため、データの生成時間が推測可能であり、ユーザープライバシーの観点からリスクがあります。

まとめのようなもの

MySQLでは、クラスタインデックスが用いられます。クラスタインデックスでは、キーの値が物理的に近い場所にレコードデータが配置されます。これによって連続したデータが物理的に近接していることで、キャッシュヒット率が向上し、パフォーマンスが向上します。
しかし、ランダムなUUIDをプライマリキーにすると、書き込み先がランダムになり、キャッシュヒット率が下がり、パフォーマンスが低下することが予想されます。

上記から考えるに、UUID v4はパフォーマンス懸念が大きいです。
そうなるとUUID v7もしくはULIDが候補として上がりますね。

しかしそれらに関しても数値型の自動採番に比べるとパフォーマンスは落ちますし、前述したようにタイムスタンプが見られるリスクがあります。

これらの問題を避けるため、プライマリキーには数値の自動採番を使用し、ユーザーに公開するキーとしては、別途ランダムな文字列(UUIDやカスタム乱数)を生成することで対処したら良いかと思います。

Discussion

Stew EucenStew Eucen

良記事をありがとうございます!

code の typo がありましたので、報告しまする。

UUID v7 のセクション内

- from uuid6 import uuid7
+ from uuid7 import uuid7

uuid_v7 = uuid7()
print(uuid_v7)
- from uuid6 import uuid7
+ from uuid7 import uuid7
import datetime

...