Postgres と MySQL における id, created_at, updated_at に関するベストプラクティス
読者対象
- ある程度データベースに関する知識を持っている,経験年数 1 年以上のバックエンドエンジニア
- 特定のプログラミング言語に依存する部分は含めないため,すべての SQL 使用者を対象とする
また,ゼロからの丁寧な説明というよりは,リファレンス感覚で使える記事という形にまとめる。
RDBMS の対象バージョン
- PostgreSQL: 9.4 以降
- MySQL: 8.0.28 以降
id
(データ型と INSERT 時のデフォルト埋め)
導入
一般的に採用されやすいプライマリキー用の値として,以下を考える。
-
連番整数
- MySQL では
AUTO_INCREMENT
, Postgres ではIDENTITY
やSERIAL
と呼ばれるもの
- MySQL では
- UUID v1: ハードウェアごとにユニークな単調増加値
- UUID v4: ランダム値
- UUID v7(ドラフト): 単調増加であるタイムスタンプとランダム値の複合
それぞれ,以下のような特徴を持つ。
連番整数 | UUID v1 | UUID v4 | UUID v7 | |
---|---|---|---|---|
スケーラビリティ | 集権的 | 分散可能 | 分散可能 | 分散可能 |
推測可能性 | 容易に推測可能 | 推測可能 | 不可 |
ほぼ推測不可 (暗号学的には安全でない) |
時系列ソート 単調増加性 |
アトミックな順序保証 |
フィールドをスワップすれば順序保証可能 (更にハードウェア単位ではアトミック) |
不可 | ミリ秒精度で順序保証 |
衝突可能性 | 0% |
0% (MAC アドレスが衝突しない限り) |
ほぼ 0% |
ほぼ 0% (ミリ秒オーダーの速さで連続発行する場合はやや衝突確率が上がる) |
状態への依存 | ステートフル | ステートフル | ステートレス | ステートレス |
また,(UUID バージョンによらず) UUID 形式の値を取り扱う場合,各 RDBMS によって相性がある。
MySQL | Postgres | |
---|---|---|
専用データ型 | なし ( CHAR(36) か BINARY(16) で代替) |
あり ( UUID ) |
単調増加でない主キーの性能劣化 | 致命的な問題あり | 極めて大量のデータでは若干の性能低下が起こる可能性がある |
値を生成する標準関数 |
UUID() (UUID v1) |
gen_random_uuid() (UUID v4) |
選考基準
まず実装細部には触れずに,選考基準だけを考える。
Postgres
Postgres は,ネイティブで UUID 型をサポートしている。これは,以下のような特性があることを意味する。
- 内部的には 16 バイトのバイナリ形式で格納されるため,最も空間効率が良い。
- 表層的には
xxxxxxxx-xxxx-xxxx-Nxxx-xxxxxxxxxxxx
の 16 進数表記に見えるため,プログラムから取り扱いやすい。
また, gen_random_uuid()
という UUID v4 の生成関数も標準で提供されているため,デフォルト値としての自動生成も必要に応じてできる。 Postgres においては,アトミックな順序保証が欲しかったり,インデックスサイズを小さくする必要があったり,MySQL の項にて後述する「クラスターインデックス」を意図的に Postgres で使ったりしない場合は, UUID v4 が最有力な選択肢となるだろう。
基本は created_at
updated_at
を併用してソートすべきであるが, id
単体での時系列ソートが欲しいときは, UUID v7 や UUID v1 を使うことになる。この場合は, UUID v7 の使用を推奨する。 UUID v1 は MAC アドレス依存があるが, Docker 環境の中にいる場合は取得できる値が一意であることが保証されにくいためである。
また時系列ソート不可能な UUID v4 を主キーに使うことに関して, MySQL の項で後述しているような致命的な影響は無いものの,数千億・数兆単位のレコードでは, Postgres でも理論上わずかに性能への影響が出てくる可能性がある。このクラスのデータ量を扱うことが事前に分かっている場合は, UUID v7 がより安全な選択にはなるだろう。
MySQL
UUID: データ型の検討
MySQL は,ネイティブで UUID 型をサポートしていない。そのため, CHAR(36)
か BINARY(16)
を選択することになる。
-
CHAR(36)
-
xxxxxxxx-xxxx-xxxx-Nxxx-xxxxxxxxxxxx
の 16 進数表記の文字列としてみた場合, 36 バイト必要になる。表層的には取り扱いやすいが,そのままCHAR
として格納すると空間効率が非常に悪い。
-
-
BINARY(16)
- Postgres と同じ効率の格納ができるが, SELECT してきた結果がバイナリデータとなるため,プログラムからそのままでは扱いにくい。加工に一工夫必要となる。
どちらを選択しても,一長一短でそれなりのデメリットが目立つ。但し,生成カラムを活用してバイナリを取り扱いやすくする方法はあるので,それは後述する。
UUID: バージョンの検討
MySQL においては, Postgres と異なり,すべてのテーブルにおいてデフォルトで クラスターインデックス というフォーマットが強制される。これが原因となって,単調増加でない値が連続インサートされたときに大きく性能が劣化することが知られている。そのため, MySQL で主キーとして UUID を利用する場合,最もよく使われる完全にランダムな UUID v4 を使うことが不利に働く場合がある。
これらを踏まえると,最も安牌な選択肢は連番整数となる。次点で UUID v7, UUID v1 となる。 UUID v1 は Postgres の項でも紹介したように, Docker との親和性の悪さの問題があるので注意。
テーブル定義例
次に,実装例としてのテーブル定義を見ていこう。
Postgres
IDENTITY
SERIAL
を使う場合は省略し, UUID v4 と UUID v7 の例を紹介する。
UUID v4
Postgres は基本これ。
CREATE TABLE users(
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
);
UUID v4 の特徴としてプログラム側で事前に生成しておけるメリットはあるが,省略した場合のデフォルト値が設定されていたほうが使い勝手は良い。書いておくに越したことはない。
UUID v7
単調増加・時系列ソート可能であってほしい場合はこちら。
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE OR REPLACE FUNCTION uuid_generate_v7() RETURNS UUID AS
$$
DECLARE
ver_rand_var bytea = e'\\000\\000\\000';
unix_time_ms bytea;
rand_bytes bytea;
BEGIN
unix_time_ms = substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3);
rand_bytes = gen_random_bytes(3);
ver_rand_var = set_byte(ver_rand_var, 0, (b'0111'||get_byte(rand_bytes, 0)::bit(4))::bit(8)::int);
ver_rand_var = set_byte(ver_rand_var, 1, get_byte(rand_bytes, 1));
ver_rand_var = set_byte(ver_rand_var, 2, (b'10'||get_byte(rand_bytes, 2)::bit(6))::bit(8)::int);
return substring((unix_time_ms || ver_rand_var || gen_random_bytes(7))::text from 3)::uuid;
END
$$ LANGUAGE plpgsql;
CREATE TABLE users(
id UUID PRIMARY KEY DEFAULT uuid_generate_v7()
);
ユーザ定義関数に関しては, こちらの Gist の実装を利用したところ,動作することを確認した。コメント欄を見る限り,ベンチマーク的にも大きな問題は無さそうとのこと。こちらに関しては可読性が少し悪いため,常にプログラム側で生成する前提で,デフォルト値は無しにしても良い。
UUID v1
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users(
id UUID PRIMARY KEY DEFAULT uuid_generate_v1()
);
上記のように, uuid-ossp
というモジュールを読み込むことで,ドラフトである v7 を除いて UUID v1, v3, v4, v5 すべてに対応できるようになる。
MySQL
AUTO_INCREMENT
を使う場合は省略し, UUID v1 の例を紹介する。 UUID v7 の例を示したかったが, Postgres のような参考実装をまだ見つけられていない。(情報提供求む)
UUID v1
MySQL 8.0.13 からは任意の式がデフォルト値として利用できるようになったため,当該バージョンである場合はインラインで書くことができる。
-- 16 進数文字列
CREATE TABLE users(
id CHAR(36) PRIMARY KEY DEFAULT (BIN_TO_UUID(UUID_TO_BIN(UUID(), 1))) CHARACTER SET ascii
);
-- バイナリ
CREATE TABLE users(
id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID(), 1)),
hex CHAR(36) AS (BIN_TO_UUID(id)) VIRTUAL NOT NULL
);
- 共通
-
UUID_TO_BIN()
の swap_flag を有効にすることで,タイムスタンプ部のLOW-MID-HIGH
をHIGH-MID-LOW
に置き換えている(「秒・分・時」のようなフォーマットを「時・分・秒」に置き換えているイメージに近い)。こうすることによって,時系列ソートできるようにデータが加工されている。
-
-
CHAR(36)
- 文字セットを局所的に
ascii
にすることにより,デフォルトに設定されているであろうutf8mb4
よりも消費バイト数を小さくしている。
- 文字セットを局所的に
-
BINARY(16)
-
計算結果が保存されない(
STORED
ではなくVIRTUAL
である)生成カラム を定義しておくと, SELECT でデータ取得したときのみ計算コストが発生するため,今回の用途に適している。
-
計算結果が保存されない(
コラム: ULID と UUID v7
UUID v7 が提唱されるよりも前に,時系列ソート可能なランダム値の実装がいくつか考えられてきたが,その 1 つに ULID がある。エンコードされている状態の見た目は大きく UUID と異なるが,バイナリフォーマットを覗いてみるとその実態は UUID v7 に極めて近い。
UUID v7
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 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- ミリ秒精度タイムスタンプ 48 ビット
- 乱数 74 ビット
- バージョン 4 ビット
- バリアント 2 ビット
ULID
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 32_bit_uint_time_high |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 16_bit_uint_time_low | 16_bit_uint_random |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 32_bit_uint_random |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 32_bit_uint_random |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- ミリ秒精度タイムスタンプ 48 ビット
- 乱数 80 ビット
この特徴のため, ULID は UUID フォーマットに変換し, UUID v7 っぽいものとして使用することができる。mpyw/uuid-ulid-converter のように,フォーマットを相互変換するライブラリを作ることも可能である。
created_at
updated_at
(データ型と INSERT 時のデフォルト埋め)
導入
作成日時を記録する,日付時刻系のデータ型として一般的なものを考える。
DATETIME (MySQL) |
TIMESTAMP (MySQL) |
TIMESTAMP (Postgres) |
TIMESTAMPTZ (Postgres) |
|
---|---|---|---|---|
日付時刻文字列 INSERT での タイムゾーン表現 +0900 など |
無視 | 考慮 | 無視 | 考慮 |
SELECT でクライアント時刻を考慮して 日付時刻文字列表示 |
無視 | 考慮 | 無視 | 考慮 |
表現可能な区間 | 西暦 1000 年 〜 西暦 9999 年 | 西暦 1970 年 〜 西暦 2038 年 | 紀元前 4713 年 〜 西暦 294276 年 | 紀元前 4713 年 〜 西暦 294276 年 |
精度 | デフォルトでは秒 (オプション指定でマイクロ秒まで可) |
デフォルトでは秒 (オプション指定でマイクロ秒まで可) |
マイクロ秒 | マイクロ秒 |
選考基準とテーブル定義例
Postgres
基本的に, タイムゾーン付きのタイムスタンプである TIMESTAMPTZ
1択と考えて良い。
CREATE TABLE users(
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -- トランザクション中は開始時刻に固定される
);
CREATE TABLE users(
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() -- 常に現在時刻を取る
);
-- TIMESTAMPTZ を省略せずに丁寧に書く場合
CREATE TABLE users(
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
MySQL
Postgres と同様に,タイムゾーンを考慮してくれる TIMESTAMP
を使いたいが, 2038 年で頭打ちになるデータ型を 2022 年の今使うのはかなり悩むところ。よって,
-
DATETIME
またはDATETIME(3)
(ミリ秒精度) またはDATETIME(6)
(マイクロ秒精度)
としてタイムゾーンは捨てた上で日付で格納するのが苦肉の策。または, MySQL 8.0.28 からはタイムスタンプと日付の変換関数が西暦 3000 年までのデータを取り扱えるようになったため,
-
BIGINT UNSIGNED
またはDECIMAL(65, 3)
(ミリ秒精度) またはDECIMAL(65, 6)
(マイクロ秒精度)
としてタイムスタンプを数値で格納するのも 1 つの戦略である(後者は UTC 基準であることを明確にしたい狙いが大きい)。後者の場合,先に UUID バイナリに関して説明した通り, VIRTUAL
な生成カラムを組み合わせてもよい。
-- DATETIME(マイクロ秒精度)
CREATE TABLE users(
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
);
-- タイムスタンプ変換した DECIMAL(マイクロ秒精度)
CREATE TABLE users(
created_at DECIMAL(65, 6) NOT NULL DEFAULT (UNIX_TIMESTAMP(CURRENT_TIMESTAMP(6))),
tz_created_at DATETIME(6) AS (FROM_UNIXTIME(created_at)) VIRTUAL NOT NULL
);
updated_at
(UPDATE 時のデフォルト埋め)
導入
データ型としては created_at
と全く同じだが,「行が更新されたときに埋める」を実現するための方法が, MySQL と Postgres で大きく異なる。
選考基準とテーブル定義例
MySQL
MySQL は, ON UPDATE CURRENT_TIMESTAMP
という便利なシンタックスを備えており,これにより更新された場合のみ自動で埋めることが簡単にできる。
CREATE TABLE users(
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
);
但し, DEFAULT
と違って任意の式を記述することはできないので, CURRENT_TIMESTAMP
を代入可能な TIMESTAMP
DATETIME
以外の場合はトリガーで対処するしかない。 MySQL は Postgres と異なり,トリガーの再利用が不可能なので,極めて冗長な記述が必要になってしまう。 BIGINT
や DECIMAL
を採用した場合は,自動更新は諦めたほうが賢明だろう。
Postgres
実は今回の記事でメイントピックとしたかった内容がこれ。 Postgres に ON UPDATE CURRENT_TIMESTAMP
という機能は存在しないので,どんな場合でもトリガーを書かなければならない。トリガーの再利用ができる点はマシだと思いたいところ。
updated_at
の値を明示的に更新するクエリが流れてきたときは,ON UPDATE CURRENT_TIMESTAMP
の内容は適用されない。
と MySQL の項で述べたが,これを再現するためにはトリガーが 3 つも必要になってしまう。
CREATE FUNCTION refresh_updated_at_step1() RETURNS trigger AS
$$
BEGIN
IF NEW.updated_at = OLD.updated_at THEN
NEW.updated_at := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION refresh_updated_at_step2() RETURNS trigger AS
$$
BEGIN
IF NEW.updated_at IS NULL THEN
NEW.updated_at := OLD.updated_at;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION refresh_updated_at_step3() RETURNS trigger AS
$$
BEGIN
IF NEW.updated_at IS NULL THEN
NEW.updated_at := CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE users(
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER refresh_users_updated_at_step1
BEFORE UPDATE ON users FOR EACH ROW
EXECUTE PROCEDURE refresh_updated_at_step1();
CREATE TRIGGER refresh_users_updated_at_step2
BEFORE UPDATE OF updated_at ON users FOR EACH ROW
EXECUTE PROCEDURE refresh_updated_at_step2();
CREATE TRIGGER refresh_users_updated_at_step3
BEFORE UPDATE ON users FOR EACH ROW
EXECUTE PROCEDURE refresh_updated_at_step3();
一見無駄なことをしているように見えるが,これにより
- (A) UPDATE 文で
updated_at
が省略された - (B) UPDATE 文で
updated_at
に現在と同じ値が渡された
が明確に区別できるようになっている。 refresh_users_updated_at_step2
が BEFORE UPDATE OF updated_at
により, (B) の場合にしか実行されないのがポイント。
UPDATE 文で updated_at が省略された
-
refresh_updated_at_step1
-
NEW.updated_at = OLD.updated_at
は真であるため,NEW.updated_at := NULL
が実行される
-
-
refresh_updated_at_step3
-
NEW.updated_at IS NULL
は真であるため,NEW.updated_at := CURRENT_TIMESTAMP
が実行される
-
UPDATE 文で updated_at に現在と同じ値が渡された
-
refresh_updated_at_step1
-
NEW.updated_at = OLD.updated_at
は真であるため,NEW.updated_at := NULL
が実行される
-
-
refresh_updated_at_step2
-
NEW.updated_at IS NULL
は真であるため,NEW.updated_at := OLD.updated_at
が実行される
-
-
refresh_updated_at_step3
-
NEW.updated_at IS NULL
は偽であるため,NEW.updated_at
はOLD.updated_at
のままである
-
UPDATE 文で updated_at に現在と異なる値が渡された
-
refresh_updated_at_step1
-
NEW.updated_at = OLD.updated_at
は偽であるため,NEW.updated_at
は渡された値のままである
-
-
refresh_updated_at_step2
-
NEW.updated_at IS NULL
は偽であるため,NEW.updated_at
は渡された値のままである
-
-
refresh_updated_at_step3
-
NEW.updated_at IS NULL
は偽であるため,NEW.updated_at
は渡された値のままである
-
これらの結果により, MySQL の ON UPDATE CURRENT_TIMESTAMP
の挙動を正しく再現できていることが分かる。
まとめ
-
id
- Postgres なら
UUID
ネイティブ型の恩恵を受けつつ UUID v4 を使うと良い。 - MySQL は主キーに使う値は単調増加でなければならないので,もし UUID を使いたい場合は
CHAR(36)
かBINARY(16)
で UUID v7 を使う。VIRTUAL
な生成カラムを併用するとベター。中央集権で簡易的に済ませたければAUTO_INCREMENT
でもアリ。 - UUID v1 は Docker と相性が悪いので,クライアント側でも採番するなら基本避けたほうが無難。
- Postgres なら
-
created_at
updated_at
- Postgres なら
TIMESTAMPTZ
1択。 - MySQL は基本は
DATETIME
,場合によっては数値型で明示的に UTC 基準のタイムスタンプとして対応するのもアリ。後者の場合はVIRTUAL
の生成カラムを併用するとベター。いずれの場合も,精度をマイクロ秒まで引き上げておくと良い。
- Postgres なら
-
updated_at
の自動更新- MySQL は
ON UPDATE CURRENT_TIMESTAMP
で OK。 - Postgres は頑張ってトリガーを 3 つ書く。
- MySQL は
付録: そのまま使えるモデルケース
簡単に済ませたい
Postgres
CREATE TABLE users(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -- 更新はプログラム側で
);
MySQL
CREATE TABLE users(
id BIGINT PRIMARY KEY UNSIGNED AUTO_INCREMENT,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
);
正しさを求めたい
Postgres
関数定義は省略しています。
CREATE TABLE users(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER refresh_users_updated_at_step1
BEFORE UPDATE ON users FOR EACH ROW
EXECUTE PROCEDURE refresh_updated_at_step1();
CREATE TRIGGER refresh_users_updated_at_step2
BEFORE UPDATE OF updated_at ON users FOR EACH ROW
EXECUTE PROCEDURE refresh_updated_at_step2();
CREATE TRIGGER refresh_users_updated_at_step3
BEFORE UPDATE ON users FOR EACH ROW
EXECUTE PROCEDURE refresh_updated_at_step3();
MySQL
MySQL は完全に模範解答と言えるものを用意するのが極めて困難です。もう一度よく全体を読んで,トレードオフとしてどこを捨てるかを検討した上で割り切った設計を行ってください。
Discussion
PostgreSQLの話ですが、CURRENT_TIMESTAMPはトランザクション内では複数回の参照で同じ値(トランザクション開始時刻)を戻すため、ソートには適さない場合があると思います。
初めて知りました!なかなかびっくりする仕様ですね…
後ほど追記しておきます
clock_timestamp()
を推奨する書き方に変更しておきました!違いが分かるようにどちらも書いておきます!
生成カラムを使ったテクニックについて少し追記しました
ブコメで「強制更新を前提とすればトリガー1個で済む」との指摘がありまして,それはその通りだとは思いました。業務的にソートが必要なら別途フィールドを用意しろもその通り。ただ実体験としては
updated_at
が付け焼刃的に使われることがそれなりにあるので,リカバリはきいたほうが安心あとそうですね, 使っているフレームワークによっては
created_at
updated_at
は ORM の機能を使ってアプリケーション側からコントロールという構築になる場合もあるので(例: Laravel),こういう場合は
created_at
updated_at
が明示的にコントロールできないと困りますね衝突確率から見た UUID v4 vs UUID v7
常に UUID v4 のほうが有利です。 最低でも 8900年ぐらいサービス運営し続けてその間データが蓄積され続けないと, UUID v7 のほうが有利にはならないようですね…