フューチャー技術ブログ

Go 1.27 リリース連載: uuid

The Go gopher was designed by Renee French.

Go 1.27 リリース連載 の 6 本目です。

はじめに

本記事では Go 1.27 で標準ライブラリに追加された uuid パッケージを扱います。

パッケージの概要は、すでに Go 1.27 で標準ライブラリに追加される UUID パッケージGo の標準ライブラリに uuid パッケージが入る で解説されています。
本記事は API の紹介に加え、google/uuid と比較した実測ベンチマークや移行などにも言及します。

アップデートの概要

リリースノート の記述はきわめて簡潔です。

The new uuid package generates and parses UUIDs.

パッケージ名は crypto/uuid ではなく uuid に落ち着きました。
生成できるのは UUIDv4 と UUIDv7 だけで、v1/v2/v3/v5/v6 の生成関数は入っていません。

主なディスカッションは次の Proposal と Change List を見ればわかります。

API の全体像はこれだけです。かなり小さくまとまっています。

package uuid

type UUID [16]byte

func New() UUID
func NewV4() UUID
func NewV7() UUID

func Nil() UUID
func Max() UUID

func Parse(s string) (UUID, error)
func MustParse(s string) UUID

func (u UUID) String() string
func (u UUID) Compare(v UUID) int
func (u UUID) MarshalText() ([]byte, error)
func (u UUID) AppendText(b []byte) ([]byte, error)
func (u *UUID) UnmarshalText(b []byte) error

そもそもの話

本題へ入る前に、UUID そのものと、標準ライブラリ入りに至った経緯を軽くおさらいします。

UUID とは

UUID(Universally Unique Identifier)は 128 ビットの識別子です。
中央集権的な採番機構を用意しなくても、複数のノードがそれぞれ勝手に生成して衝突しない、という点が最大の価値です。

Go 1.27 の uuid パッケージが準拠するのは RFC 9562 で、これは 2024 年に RFC 4122 を置き換えた最新の仕様です。
テキスト表現は 8-4-4-4-12 のハイフン区切り小文字 16 進数で、f81d4fae-7dec-11d0-a765-00a0c91e6bf6 のような形になります。

128 ビットのうち 6 ビットは、固定的な意味を持つフィールドに使われます。
4 ビットの version が生成アルゴリズムの種類を、2 ビットの variant がレイアウトの種類を表します。
テキスト表現で見ると、それぞれ 3 番目と 4 番目のグループの先頭の桁に現れます。

f81d4fae-7dec-11d0-a765-00a0c91e6bf6
^ ^
| +-- variant が入っている桁
+------- version が入っている桁

ここで 16 進数の 1 桁は 4 ビットである点に注意が必要です。
version は 4 ビットをまるごと使いますが、variant は先頭の 2 ビットしか使いません。
上の例の該当桁を 2 進数へ展開すると、次のようになります。

version の桁 "1"
1 -> 0001
^^^^ 4 ビットすべてが version 番号(0001 = 1 なので UUIDv1)

variant の桁 "a"
a -> 1010
^^ 先頭 2 ビットが variant(10 なので RFC 9562 のレイアウト)
^^ 残りの 2 ビットは UUID のデータの一部

実際に使われるバージョンを並べておきます。
このほかに、実装が中身を自由に決められる v8 が実験・ベンダー独自用途として定義されています。

バージョン 生成方法 時系列ソート Go 1.27 での生成
v1 時刻(100 ナノ秒単位)とノード ID(通常は MAC アドレス) 不可 非対応
v2 DCE Security。定義は RFC 9562 の範囲外 不可 非対応
v3 名前空間と名前の MD5 ハッシュ 不可 非対応
v4 122 ビットの乱数 不可 NewV4
v5 名前空間と名前の SHA-1 ハッシュ 不可 非対応
v6 v1 と同じ要素を時刻順に並べ替えたもの 非対応
v7 UNIX 時刻(ミリ秒)と乱数 NewV7

「時系列ソート」は、パースせずにバイト列や文字列のまま並べ替えても時刻順になる、という意味です。

v1 も時刻を持っていますが、タイムスタンプが上位・中位・下位に分割して配置されているため、そのまま並べても時刻順にはなりません。v6 はこれを並べ替えて解消したもので、RFC 9562 はこの 2 つだけをソート可能なバージョンとして位置付けています。

なぜいま標準ライブラリに入ったのか

UUID を標準ライブラリへ入れる提案は #23789 として 2018 年にすでに存在していましたが、「標準ライブラリに何が必要なのか情報が足りず、当面はサードパーティで十分」という結論で見送られていました。

その後 google/uuid をはじめとするライブラリが広く使われるようになりました。

標準ライブラリへの導入の決め手は、次の 2 点でした。

1 つは相互運用性です。ライブラリが乱立していると、UUID 型もそれぞれ別物になります。net/url が URL の共通表現を提供しているのと同じように、標準の uuid があれば、どのライブラリとも UUID をそのまま受け渡せます。

もう 1 つは、2018 年の時点で足りないとされた「標準ライブラリに何が必要か」という情報が、実データとして得られるようになった点です。これだけ広く使われた結果、どの API がどれだけ使われているかを調べられるようになりました。

google/uuid の利用箇所を 調査した結果 が次のものです。

function               usages   percentage   cumulative
New 14882 36.12 36.12
UUID.String 14549 35.31 71.43
NewString 3914 9.50 80.93
Parse 3280 7.96 88.89
NewRandom 1548 3.76 92.64
NewUUID 949 2.30 94.95
MustParse 440 1.07 96.01
NewV7 263 0.64 96.65
...
UUID.Version 29 0.07 99.26
UUID.Time 26 0.06 99.39

上位 4 つ(生成・文字列化・パース)で全体の約 89% を占め、バージョンや時刻を取り出す introspection 系はほとんど使われていません。同時は判断できなかった「何を入れるべきか」が明確になったわけです。

使ってみる

生成

新しい UUID をつくる関数は NewNewV4NewV7 の 3 つです。
アルゴリズムにこだわりがなければ New、明示したいときは NewV4NewV7 を呼びます。
これとは別に、RFC 9562 が定義する固定値を返す NilMax があります。

package main

import (
"fmt"
"uuid"
)

func main() {
fmt.Println("New: ", uuid.New())
fmt.Println("NewV4:", uuid.NewV4())
fmt.Println("NewV7:", uuid.NewV7())
fmt.Println("Nil: ", uuid.Nil())
fmt.Println("Max: ", uuid.Max())
}
$ go run ./gen
New: 5db4b40e-a8d5-4d10-8838-d7a351f436b2
NewV4: b77b0c2f-85f9-48cd-a996-6e8dccf6de6f
NewV7: 019fc17a-5977-79cd-ada3-7a04de777038
Nil: 00000000-0000-0000-0000-000000000000
Max: ffffffff-ffff-ffff-ffff-ffffffffffff

どれもエラーを返しません。

乱数の取得に crypto/rand.Read を直接使っており、この関数は Go 1.24 以降エラーを返さない仕様(失敗時はプロセスをクラッシュさせる)になっているためです。

google/uuid の NewRandom がエラーを返していたのは、この仕様変更より前のなごりです。

パース

Parse が受け付ける表記は 4 種類あります。

ハイフン区切りの標準形に加えて、波括弧付き、URN 形式、ハイフンなしの 32 文字が通ります。

16 進数の英字は大文字と小文字のどちらでもかまいません。

func main() {
for _, s := range []string{
"f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
"{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}",
"urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
"f81d4fae7dec11d0a76500a0c91e6bf6",
"F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6",
"f81d4fae-7dec-11d0-a765-00a0c91e6bf",
} {
u, err := uuid.Parse(s)
fmt.Printf("%-46s -> %v %v\n", s, u, err)
}

// 定数など確実に成功する入力には MustParse を使う
u := uuid.MustParse("f81d4fae-7dec-11d0-a765-00a0c91e6bf6")
fmt.Println("MustParse:", u)
}
$ go run ./parse
f81d4fae-7dec-11d0-a765-00a0c91e6bf6 -> f81d4fae-7dec-11d0-a765-00a0c91e6bf6 <nil>
{f81d4fae-7dec-11d0-a765-00a0c91e6bf6} -> f81d4fae-7dec-11d0-a765-00a0c91e6bf6 <nil>
urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 -> f81d4fae-7dec-11d0-a765-00a0c91e6bf6 <nil>
f81d4fae7dec11d0a76500a0c91e6bf6 -> f81d4fae-7dec-11d0-a765-00a0c91e6bf6 <nil>
F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6 -> f81d4fae-7dec-11d0-a765-00a0c91e6bf6 <nil>
f81d4fae-7dec-11d0-a765-00a0c91e6bf -> 00000000-0000-0000-0000-000000000000 invalid uuid
MustParse: f81d4fae-7dec-11d0-a765-00a0c91e6bf6

この緩さは意図的なものです。

google/uuid と satori/go.uuid が受け付ける表記をすべて受け入れることで、移行時に挙動が変わらないようにしています。厳密に検証したい場合は Parse へ渡す前に自分で長さをチェックする、というのが Proposal での 結論 でした。

エラーの内容は invalid uuid の 1 種類だけです。google/uuid のように長さ違いと文字違いでエラー型を分けるようなことはしていません。

比較とソート

UUID の実体は [16]byte であり、== で比較でき、map のキーとしてもそのまま使えます。

順序が必要な場面では Compare メソッドを使います。

func main() {
a := uuid.MustParse("f81d4fae-7dec-11d0-a765-00a0c91e6bf6")
b := uuid.MustParse("F81D4FAE7DEC11D0A76500A0C91E6BF6")
fmt.Println("a == b:", a == b)
fmt.Println("map key:", map[uuid.UUID]string{a: "hello"}[b])

ids := []uuid.UUID{uuid.Max(), uuid.Nil(), a}
slices.SortFunc(ids, uuid.UUID.Compare)
fmt.Println("sorted: ", ids)
}
$ go run ./cmp
a == b: true
map key: hello
sorted: [00000000-0000-0000-0000-000000000000 f81d4fae-7dec-11d0-a765-00a0c91e6bf6 ffffffff-ffff-ffff-ffff-ffffffffffff]

slices.SortFunc(ids, uuid.UUID.Compare) のようにメソッド式を渡せば、比較関数を書く必要もありません。

並び順は RFC 9562 が定めるビッグエンディアンのバイト順で、時刻順に意味を持つのは v6 と v7 だけである点には注意してください。

JSON でのやりとり

UUIDMarshalText / UnmarshalText を実装しているため、encoding/json では自動的に文字列として扱われます。

type User struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}

func main() {
u := User{ID: uuid.MustParse("d556ac56-0e8d-4a30-b7b0-647fe7a22aba"), Name: "Bob"}
b, _ := json.Marshal(u)
fmt.Println("marshal:", string(b))

var back User
_ = json.Unmarshal([]byte(`{"id":"{D556AC56-0E8D-4A30-B7B0-647FE7A22ABA}","name":"Bob"}`), &back)
fmt.Printf("unmarshal: %v 一致=%v\n", back.ID, back.ID == u.ID)
}
$ go run ./jsondemo
marshal: {"id":"d556ac56-0e8d-4a30-b7b0-647fe7a22aba","name":"Bob"}
unmarshal: d556ac56-0e8d-4a30-b7b0-647fe7a22aba 一致=true

注意したいのは、UnmarshalTextParse と同じ 4 種類の表記を受け付ける点です。

上の例のように、波括弧付きで大文字の JSON が何のエラーもなく通ります。

外部から受け取る JSON を厳密に検証したい場合は、len(s) == 36 のような長さチェックを自分で足す必要があります。

API の設計思想を Proposal から読む

Go 1.27 の uuid パッケージが驚くほど小さいのは、先ほどの利用実績をそのまま API 設計へ反映したためです。

その判断の過程は Proposal に残っています。#62026 には 300 件を超えるコメントが付き、Accept 時には実装者である neild 氏が 設計判断の根拠をまとめたコメント を残しました。

ここからいくつか拾ってみます。

なぜ crypto/uuid ではなく uuid なのか

当初の提案は crypto/uuid でした。crypto/rand の安全な乱数を使う点を名前で示す意図があったためです。

しかし RFC 9562 に暗号学的な用語がほとんど登場せず、暗号強度は「安全な乱数ソースを推奨する」という文脈でしか扱われません。

gofrs/uuid を保守している dylan-bourque 氏の この主張 が決め手となり、より短い uuid が選ばれました。

なぜ [16]byte なのか

不透明な構造体にする案もありましたが、既存パッケージのほぼすべてが [16]byte を採用しているため、同じ表現にしておけば単なる型変換で相互変換できるという利点が決め手になりました。

不正な UUID を表す [16]byte はどうするのかという問いには、RFC 9562 が有効性という概念を定義していない、つまり不正な UUID というものは存在しない、とプロポーザルでも回答されています。

現行のどのアルゴリズムでも生成されない 16 バイト値は存在しますが、それは不正であることを意味しません。

なぜ NewNewV4 の両方があるのか

現時点で両者の挙動は同一です。

それでも両方あるのは、呼び出し側の意図を表現できるからだと説明されています。NewV4 を呼ぶのは「UUIDv4 が欲しい」という宣言であり、New を呼ぶのは「UUID が欲しいが生成アルゴリズムにはこだわらない」という宣言です。将来より良いデフォルトが現れたとき、New の利用者だけを移行させられます。

なぜデフォルトが v7 ではなく v4 なのか

UUIDv7 は時刻順に並ぶため、B-tree インデックスへの大量挿入ではたしかに有利です。

それでも v4 がデフォルトに選ばれたのには、次の理由があります。

  • Cassandra、CockroachDB、Spanner のように水平シャーディングされたデータストアでは、時刻順に並ぶ ID は書き込み先が特定のノードへ偏り、ホットスポットになる
  • v7 には生成時刻が埋め込まれるため、ID を外部へ公開していると作成日時が読み取れる

v4 は単なる乱数なので性能やセキュリティ上の懸念がなく「最も安全なデフォルト」だと結論づけられています。

なぜ NewRandom ではなく NewV4 なのか

NewRandom のほうが説明的ではありますが UUIDv7 に対応する命名が困難です。

NewSequentialNewMonotonic という候補は、v6 と v7 の両方へ等しく当てはまってしまいます。v4 だけ説明的な名前にするのは不整合ですし、NewRandom という名前が将来にわたって一意な意味を保つ保証もありません。

結果としてバージョン番号で命名する方式が採用されました。

NilMax が変数から関数になった経緯

google/uuid は var Nil という公開変数として定義しており、互換性を重視するなら変数にすべきという意見と、書き換えられる公開変数は避けるべきという意見が対立しました。

Proposal レビュー側は当初「実害の証拠があれば関数にする」というスタンスでしたが、実際に google/uuid の変数が意図せず書き換えられて問題になった事例 が提示されたことで、最終的に関数へ倒れました。

なお uuid.UUID のゼロ値は Nil UUID そのものですので、実用上は id == uuid.UUID{} でも id == uuid.Nil() でも同じです。

採用されなかったもの

Go の互換性保証のもとでは、一度公開した API は取り下げられません。そのため「迷ったら入れない」方針が徹底されており、次のものが見送られました。

1 つめは v1/v2/v3/v5/v6 の生成です。生成関数の利用実績を 集計 すると v4 が 94.17%、v1 が 4.39%、v7 が 1.22% で、残りは合計 0.22% でした。v1 利用の大半は NewUUID という紛らわしい名前に手が伸びただけと推測されています。v3/v5 は MD5/SHA-1 という壊れたハッシュに依存しており、新規に決定的な UUID が必要なら、自由形式の v8 で現代的なハッシュを使うほうがよい、という整理です。

2 つめは Version()Time() といった introspection です。利用実績がほとんどなく、Google 社内のコードベースを調べても Version の利用箇所の大半は「バージョン 4 以外を理由もなく弾く」という誤用だった、と報告されています。

3 つめは UUIDv7 の生成時刻を指定する API です。時刻を取り出す手段を提供しないと決めた以上、設定だけできるのは不自然という理由です。「時刻漏洩を避けるためにランダムなオフセットを入れるべき」という議論もありましたが、一貫したオフセットは推測可能で、可変にすると v7 の性能上の利点が失われます。

4 つめは Generator 型による乱数ソースの差し替えです。インジェクションしたいなら func() uuid.UUID を渡せば十分、という結論になりました。

ベンチマーク

google/uuid v1.6.0 と比較したベンチマークを取りました。

bench/bench_test.go
package bench

import (
"runtime"
"testing"
"uuid"

guuid "github.com/google/uuid"
)

const s = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"

func BenchmarkNewV4(b *testing.B) {
for b.Loop() {
uuid.NewV4()
}
}

func BenchmarkGoogleNewV4(b *testing.B) {
for b.Loop() {
guuid.New()
}
}

func BenchmarkNewV4Parallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
var u uuid.UUID
for pb.Next() {
u = uuid.NewV4()
}
runtime.KeepAlive(u)
})
}

func BenchmarkNewV7(b *testing.B) {
for b.Loop() {
uuid.NewV7()
}
}

func BenchmarkGoogleNewV7(b *testing.B) {
for b.Loop() {
guuid.NewV7()
}
}

func BenchmarkNewV7Parallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
var u uuid.UUID
for pb.Next() {
u = uuid.NewV7()
}
runtime.KeepAlive(u)
})
}

func BenchmarkParse(b *testing.B) {
for b.Loop() {
uuid.Parse(s)
}
}

func BenchmarkGoogleParse(b *testing.B) {
for b.Loop() {
guuid.Parse(s)
}
}

func BenchmarkString(b *testing.B) {
u := uuid.MustParse(s)
for b.Loop() {
_ = u.String()
}
}

func BenchmarkGoogleString(b *testing.B) {
u := guuid.MustParse(s)
for b.Loop() {
_ = u.String()
}
}

環境は go1.27rc2 / linux/arm64 / 12 コアです。

$ go test -run=NONE -bench=. -benchmem ./bench
goos: linux
goarch: arm64
BenchmarkNewV4-12 13977332 84.19 ns/op 0 B/op 0 allocs/op
BenchmarkGoogleNewV4-12 13290716 89.40 ns/op 16 B/op 1 allocs/op
BenchmarkNewV4Parallel-12 141339780 8.456 ns/op 0 B/op 0 allocs/op
BenchmarkNewV7-12 12154754 98.62 ns/op 0 B/op 0 allocs/op
BenchmarkGoogleNewV7-12 7964306 149.1 ns/op 16 B/op 1 allocs/op
BenchmarkNewV7Parallel-12 5942059 204.8 ns/op 0 B/op 0 allocs/op
BenchmarkParse-12 48857696 24.34 ns/op 0 B/op 0 allocs/op
BenchmarkGoogleParse-12 63914497 19.05 ns/op 0 B/op 0 allocs/op
BenchmarkString-12 36801724 31.34 ns/op 48 B/op 1 allocs/op
BenchmarkGoogleString-12 41646963 28.32 ns/op 48 B/op 1 allocs/op

いくつかポイントを説明します。

まずは、生成側のアロケーションがゼロになっている点です。google/uuid は io.ReadFull(rander, uuid[:]) のようにインターフェース越しに配列を渡すため 16 バイトのヒープアロケーションが発生しますが、標準ライブラリは crypto/rand.Read を直接呼ぶのでエスケープしません。この差もあって、v7 では 1.5 倍の性能差がでています。

意外だったのは NewV7 を並列化したときの性能です。NewV4 は 12 コアで 84ns から 8.5ns へとほぼコア数分だけ速くなるのに、NewV7 は 99ns から 205ns へ悪化しました。NewV7 は単調増加を保証するためにパッケージレベルで採番を直列化しており、これがそのままボトルネックになります。とはいえ 1 秒あたり約 500 万件は生成できる計算ですので、実際にこれが問題になるのは相当に特殊なワークロードだけでしょう。

逆に ParseString は、わずかながら google/uuid のほうが速いという結果になりました。標準ライブラリ版は ParseUnmarshalText 経由で実装しているぶん、専用に最適化された実装には劣るのではないかと考えられます。差は数ナノ秒ですので、実用上は誤差の範囲です。

google/uuid からの移行

型が同じ [16]byte ですので、移行そのものは難しくありません。

主な対応関係を表にまとめました。

google/uuid 標準ライブラリ uuid 備考
uuid.New() uuid.New()
uuid.NewString() uuid.New().String()
uuid.NewRandom() uuid.NewV4() 標準はエラーを返さない
uuid.NewV7() uuid.NewV7() 標準はエラーを返さない
uuid.NewUUID() なし
uuid.NewMD5 / NewSHA1 なし
uuid.Parse() uuid.Parse()
uuid.ParseBytes() u.UnmarshalText(b)
uuid.MustParse() uuid.MustParse()
uuid.Validate() なし Parse のエラーで判定する
uuid.Nil uuid.Nil()
uuid.Max uuid.Max()
u.Version() なし 必要なら u[6]>>4 を自前で書く
u.Time() なし
u.MarshalBinary() u[:] 実体が配列なのでスライス化するだけ
u.Scan() / u.Value() なし database/sql 側が uuid.UUID を認識する

おわりに

300 件を超える議論の末に残ったのが 12 個の関数とメソッドだけ、というのは、いかにも Go らしい結論だと思います。

標準で使えるようになったのは非常にうれしいアップデートですね。

参考