yourmystar tech blog
著者: masyus 公開日:

UUID v7 のデータ構造を詳細に理解する

ユアマイスターではデータベースを Cloud SQL の MySQL から AlloyDB の PostgreSQL へデータ移行を進めておりますが、その過程でデータモデリングの見直しとそれに伴うテーブルスキーマの再設計をしています。今回解説する UUID v7 は PostgreSQL のプライマリーキーの設計で採用しましたが、どのようなデータ構造なのかを詳細に理解しきれていなかったため、まとめました。

UUID v7 とは

  • 時間ベースの UUID バージョンの1つ
    • 他の UUID でも時間ベースのものはある。UUID v1 など
  • ミリ秒単位までのタイムスタンプを提供
  • 時間でソート可能
    • 新しく生成されたUUIDが古いものよりも大きな値を持つことを意味し、生成時間に基づいて自然にソートできる

サンプル

018422b2-4843-7a62-935b-b4e65649de3e
  • 16 進数 32 桁をハイフンで区切った構成になっている

UUID v7 Field and Bit Layout

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           unix_ts_ms                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms           |  ver  |       rand_a          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                        rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                            rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • 16 進数 1 桁で取りうる値が 4 bit に相当し、4 × 32 = 128 bit のレイアウトになっている

unix_ts_ms

018422b2 - 4843 - 7a62 - 935b - b4e65649de3e

  • ミリ秒含むタイムスタンプ情報
  • 48 bit = 16 進数 12 桁

ver

018422b2 - 4843 - 7 a62 - 935b - b4e65649de3e

  • UUID の version 情報
    • UUID のバージョンがいくつであるかは、この情報を見ればすぐにわかる
    • UUID v7 なら 7 , v4 なら 4 となる
  • 4 bit = 16 進数 1 桁

rand_a

018422b2 - 4843 - 7 a62 - 935b - b4e65649de3e

  • 乱数
  • 12 bit = 16 進数 3 桁

var

018422b2 - 4843 - 7a62 - 9 35b - b4e65649de3e

  • variant の略語。UUID のレイアウトを決定する値で、type フィールドとも呼ばれる
  • 2 bit = 16 進数 1 桁部分
  • 2 bit は本来 16 進数の 0-3 しか対応しないが、実際にはそうでない。上の例では 16 進数の 9 になっている
    • 16 進数の 9 は 2 進数の 1001 になる
    • 実は後述する rand_b の乱数情報の一部を持ち合わせる

RFC4122 (廃止)およびRFC9562 (修正版)によると、variant は上位 bit が特定の値を持つ時に特定の意味をなすそうです。

条件内容
上位 1 bitが 016 進数の 1-7 が該当( 0 は含まない)。予約済みで、ネットワーク コンピューティング システム ( NCS ) の下位互換性があり、Nil UUID が含まれる
上位 2 bit が 1016 進数の 8-9 , A-B が該当。RFC9562 で定義されている標準の UUID 形式で、基本的にはこれに該当する
上位 3 bit が 11016 進数の C-D が該当。Microsoft Corporation の下位互換性
上位 3 bit が 11116 進数の E-F が該当。予約済み。将来のために確保

rand_b

018422b2 - 4843 - 7a62 - 935b - b4e65649de3e

  • 乱数
  • 62 bit = 16 進数 15.5 桁で表現されるように見えるが、前述の variant の都合で正確には 16 桁部分で表現される
  • var の 2 bit を含めたビット演算をしないと実現できない

実運用で知るべき UUID v7 の項目

基本的には

  • unix_ts_ms
  • ver

だけ知っていれば問題ないです。特に unix_ts_ms はタイムスタンプ → 年月日時分秒の変換方法を開発チーム内の共通知識として持っておくと良いと思います。以下は PHP のサンプルコードです。

<?php
/**
 * このプログラムは引数で与えられた UUIDv7 からタイムスタンプ部分をパースし、datetime で出力します
 *
 * @param string $argv[1] UUIDv7
 * @return string $date Y-m-d H:i:s
 *
 * example:
 *     php convert_uuidv7_to_datetime.php 018422b2-4843-7a62-935b-b4e65649de3e
 *     出力: 2022-10-29 07:43:40
 */

$uuid = $argv[1];

if (empty($uuid)) {
    echo "UUIDv7 を指定してください\n";
    exit;
}

// UUID v7 のハイフンを除いた先頭 12 文字を取得
$unixTsMs = substr(str_replace('-', '', $uuid), 0, 12);

// 16 進数 → 10 進数に変換して UNIX タイムスタンプを取得
$unixTsMs = hexdec($unixTsMs);

// ミリ秒が不要な場合、UNIX タイムスタンプから 1000 を割り小数点以下切り捨てた上で date() を通す
$date = date('Y-m-d H:i:s', intval($unixTsMs / 1000));

echo $date;

UUID v7 の算出方針

ここからは UUID v7 の算出方法をより詳細に知りたい方向けです。おおまかな算出方針は RFC9562 セクション6 に指定された内容に準じ、Field and Bit Layout に基づき bit 演算によって生成するようにします。

  1. 先頭 48 bit のタイムスタンプ情報を生成する
  2. 後方 80 bit の乱数部分を生成し、bit 演算で部分的に version と variant の情報を持たせる
      1. をそれぞれ 16 進数に変換して繋ぎ、%s%s-%s-%s-%s-%s%s%s のフォーマットで表現する

PHP による算出例

以下のコードは https://github.com/oittaa/uuid-php?tab=readme-ov-file#minimal-uuid-v7-implementation より拝借したものです。詳細に解説します。

<?php
function uuid7()
{
    // 同一プログラムで uuid7() を複数回呼び出された時に、前回呼び出し時のタイムスタンプ情報を参照できるようにする
    static $last_timestamp = 0;

    // microtime(true) を使っているが欲しい情報はミリ秒までなので、
    // 1000 をかけて intval() で小数点切り捨てることでミリ秒までのタイムスタンプを得る
    $unixts_ms = intval(microtime(true) * 1000);

    // 万が一同一ミリ秒で uuid7() が呼び出された時の対策として、
    // 後者の呼び出し時はタイムスタンプを +1 する
    if ($last_timestamp >= $unixts_ms) {
        $unixts_ms = $last_timestamp + 1;
    }
    $last_timestamp = $unixts_ms;

    // 暗号学的にセキュアなランダムな byte 列を生成( 10 byte = 80 bit = 16 進数 20 桁分)
    // 1 byte = 16 進数 2 桁分
    $data = random_bytes(10);

    // 先頭 1 byte にあたる $data[0] に対し、先頭 4 bit 部分に version 情報を持たせる
    $data[0] = chr((ord($data[0]) & 0x0f) | 0x70);

    // 先頭 3 byte にあたる $data[2] に対し、先頭 2 bit 部分に variant 情報を持たせる
    $data[2] = chr((ord($data[2]) & 0x3f) | 0x80);

    // UUID v7 フォーマットにする
    return vsprintf(
        '%s%s-%s-%s-%s-%s%s%s',
        str_split(
            str_pad(dechex($unixts_ms), 12, '0', \STR_PAD_LEFT) . bin2hex($data),
            4
        )
    );
}

echo uuid7();

bit 演算詳細

このプログラムで一番難しいのは

// 先頭 1 byte にあたる $data[0] に対し、先頭 4 bit 部分に version 情報を持たせる
$data[0] = chr((ord($data[0]) & 0x0f) | 0x70);

の bit 演算だと思いますので、詳細に解説します。ここでの bit 演算では

  1. 変更したい bit を 0 でフォーマットする → AND ( & ) 演算を使う
  2. フォーマットした bit を指定した値に変える → OR ( | ) 演算を使う

の 2 段階で実施します。まず、$data[0] の中身が生のバイナリ文字列のため、bit 演算可能な 10 進数に変換します。仮に

$data[0] = "\x12"; //  = 16 進数の 12

の場合、

$ascii = ord($data[0]); // 10 進数の 18

を得ます。

次に bit 演算です。bit 演算はデータとマスクで構成され、今回ですと

  • データ: ord($data[0])
  • マスク: 0x0f

となります。わかりやすくするために ord($data[0]) を 10 進数の 18 とし、それぞれのデータを 2 進数に変換してみます。0x0f は、0x より後方の値( 0f )は 16 進数で構成されます。0f は 10 進数の 15 です。18 と 15 を 2 進数に変換すると

2 進数
データ00010010
マスク00001111

となります。これを AND 演算します。AND 演算ではマスクで 0 になっている桁が 0 になり、1 になっている桁は変化しません。AND 演算結果は

2 進数
データ00010010
マスク00001111
AND 演算結果00000010

となります。つまりこれは上位 4 bit をフォーマットしたことになります。

次の OR 演算ではマスクで 1 になっている桁が 1 になり、0 になっている桁は変化しません。上で得られた演算結果と 0x70 の 2 進数との OR 演算結果は

2 進数
データ00000010
マスク01110000
OR 演算結果01110010

となります。結果的にこれは AND 演算を含め、上位 4 bit を指定した値(今回は v7 の 7 ( = 0111 ))に書き換えたことになります。最後に chr()ord() の逆変換を実施し、演算結果を $data に戻します。variant の算出も、この一連の流れが理解できれば理解しやすいと思われます。

ポストするはてなブックマークに追加シェアする