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が 0 | 16 進数の 1-7 が該当( 0 は含まない)。予約済みで、ネットワーク コンピューティング システム ( NCS ) の下位互換性があり、Nil UUID が含まれる |
上位 2 bit が 10 | 16 進数の 8-9 , A-B が該当。RFC9562 で定義されている標準の UUID 形式で、基本的にはこれに該当する |
上位 3 bit が 110 | 16 進数の C-D が該当。Microsoft Corporation の下位互換性 |
上位 3 bit が 111 | 16 進数の 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 演算によって生成するようにします。
- 先頭 48 bit のタイムスタンプ情報を生成する
- 後方 80 bit の乱数部分を生成し、bit 演算で部分的に version と variant の情報を持たせる
- をそれぞれ 16 進数に変換して繋ぎ、
%s%s-%s-%s-%s-%s%s%s
のフォーマットで表現する
- をそれぞれ 16 進数に変換して繋ぎ、
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 演算では
- 変更したい bit を 0 でフォーマットする → AND ( & ) 演算を使う
- フォーマットした 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 の算出も、この一連の流れが理解できれば理解しやすいと思われます。