🔢

GCP Cloud Spannerのデフォルト値を使う

2023/09/29に公開

はじめに

当初のSpannerにはデフォルトの値をセットする機能が存在しておらずアプリケーションでその前提の配慮が必要で地味に面倒でした。

いつからからデフォルト値が使えるようになっていたので使い方と簡単な検証結果をメモしておこうと思います。

基礎知識

SpannerではPKにUUIDv4を利用することが推奨されています。これはSplitへデータを分散させるにあたりPKの先頭付近の値を元に配置配置先を決定するというアルゴリズムがあるためです。

単調に増減する数値や先頭付近の値が偏る文字列(例えば名前やメールアドレス)をPKに採用すると特に書き込みのアクセスが単一のSplitに集中する、所謂ホットスポットが発生しスケーラビリティを十分に活かすことができません。

使い方

UUIDv4をセットする

従来ではいちいちアプリケーション側のロジックでUUIDを生成する必要がありました。

e.g.

rnd, err := uuid.NewRandom()
if err != nil {
	return err
}
u := User{
	UserID: rnd.String(),
	Name: "john doe",
}

しかし以下のようにスキーマをセットすればSpanner側で生成した値を使ってくれます。

CREATE TABLE Users (
    UserID STRING(36) NOT NULL DEFAULT (GENERATE_UUID()),
    Name STRING(MAX) NOT NULL,
) PRIMARY KEY (UserID);
spanner> INSERT INTO Users (Name) VALUES ('john doe');
Query OK, 1 rows affected (0.08 sec)

spanner> SELECT * FROM Users;
+--------------------------------------+----------+
| UserID                               | Name     |
+--------------------------------------+----------+
| 03709b4c-49e4-42d5-96c6-e3fe4fd46485 | john doe |
+--------------------------------------+----------+
1 rows in set (7.97 msecs)

Goから書き込む場合は若干工夫が必要で、従来のようにstructをそのまま放り込むとゼロ値(stringなら ""、int64なら 0)が書き込まれてしまうため、InsertMap() 等でカラムを部分的に指定した上でINSERTする必要があります。。

func insertUser(u *User) *spanner.Mutation {
	m := map[string]interface{}{
		"Name": u.Name,
	}
	return spanner.InsertMap(usersTableName, m)
}
u := User{
	Name: "john doe",
}
m := insertUser(&u)
mm = append(mm, m)

if _, err := cli.ReadWriteTransaction(ctx, func(ctx context.Context, rwt *spanner.ReadWriteTransaction) error {
	if err := rwt.BufferWrite(mm); err != nil {
		return err
	}
	return nil
}); err != nil {
	return err
}

INT64をセットする

マイグレーション元に起因する等、何らかの理由でPKに数値を採用したい場合は以下のようにして生成することができます。

CREATE SEQUENCE ItemsIDSequence OPTIONS (
    sequence_kind="bit_reversed_positive"
);

CREATE TABLE Items (
    ItemID INT64 NOT NULL DEFAULT (GET_NEXT_SEQUENCE_VALUE(SEQUENCE ItemsIDSequence)),
    Name STRING(MAX) NOT NULL
) PRIMARY KEY (ItemID);
spanner> INSERT INTO Items (Name) VALUES ('some item');
Query OK, 1 rows affected (0.07 sec)

spanner> SELECT * FROM Items;
+---------------------+-----------+
| ItemID              | Name      |
+---------------------+-----------+
| 9144559043375792128 | some item |
+---------------------+-----------+
1 rows in set (9.02 msecs)

ちなみに sequence_kind="bit_reversed_positive" はビットを反転(0/1の反転ではなく逆順にすること)させ生成元数値がシーケンシャルであってもランダム性を与えるためのオプションでこれを外すことはできません。恐らくSpannerにおいて純粋なシーケンシャルは有害でしかないという判断なのでしょう。

CREATE SEQUENCE ItemsIDSequence;
ERROR: (gcloud.spanner.databases.create) INVALID_ARGUMENT: Error parsing Spanner DDL statement: \n\n\n\n\n\nCREATE SEQUENCE ItemsIDSequence : CREATE SEQUENCE statements require option `sequence_kind` to be set

反転されたシーケンスのIDは BIT_REVERSE() を使うと生成元の整数に戻すことが可能で、逆を言えば既にID発行済みのテーブルをマイグレーションで取り込む場合、既存のID体系を残しつつSpannerに適した分散した値のIDをPKとすることが可能です。

その際は既存テーブルの状況に合わせて生成を開始する番号や除外範囲を指定するオプションを活用するとよいでしょう。

CREATE SEQUENCE ItemsIDSequence OPTIONS (
    sequence_kind="bit_reversed_positive",
    start_with_counter = 100000,
    skip_range_min = 1,
    skip_range_max = 65535
);

なお実際に生成された値を確認してみると大小関係は保証されるようですが単純にインクリメントされていくわけではないようです。

spanner> INSERT INTO Items (Name) VALUES ('i1');
Query OK, 1 rows affected (0.10 sec)

spanner> INSERT INTO Items (Name) VALUES ('i2');
Query OK, 1 rows affected (0.06 sec)

spanner> INSERT INTO Items (Name) VALUES ('i3');
Query OK, 1 rows affected (0.06 sec)

spanner> SELECT *, BIT_REVERSE(ItemID, true) AS NoReversedID FROM Items ORDER BY Name ASC;
+---------------------+------+--------------+
| ItemID              | Name | NoReversedID |
+---------------------+------+--------------+
| 4899916394579099648 | i1   | 17           |
| 1441151880758558720 | i2   | 20           |
| 8358680908399640576 | i3   | 23           |
+---------------------+------+--------------+
3 rows in set (3.84 msecs)

Unix time

タイムスタンプをUnix timeで保持している場合、これもデフォルトで生成することができるのでCreatedAt等のようなレコード作成日時を記録する際に便利かもしれません。

CREATE TABLE Users (
    UserID STRING(36) NOT NULL DEFAULT (GENERATE_UUID()),
    Name STRING(MAX) NOT NULL,
    CreatedAt INT64 NOT NULL DEFAULT (UNIX_SECONDS(CURRENT_TIMESTAMP())),
    UpdatedAt INT64 NOT NULL DEFAULT (UNIX_SECONDS(CURRENT_TIMESTAMP())),
) PRIMARY KEY (UserID);
spanner> INSERT INTO Users (Name) VALUES ('john doe');
Query OK, 1 rows affected (0.07 sec)

spanner> SELECT * FROM Users;
+--------------------------------------+----------+------------+------------+
| UserID                               | Name     | CreatedAt  | UpdatedAt  |
+--------------------------------------+----------+------------+------------+
| 054f969c-8247-4d24-97ad-fa464a9daa95 | john doe | 1695920628 | 1695920628 |
+--------------------------------------+----------+------------+------------+
1 rows in set (4.74 msecs)

なおSpanner側のコミットタイムスタンプに厳密に合わせられるオプションである allow_commit_timestamp = true は使用できません。

CREATE TABLE Users (
    UserID STRING(36) NOT NULL DEFAULT (GENERATE_UUID()),
    Name STRING(MAX) NOT NULL,
    CreatedAt TIMESTAMP NOT NULL DEFAULT (PENDING_COMMIT_TIMESTAMP()) OPTIONS ( allow_commit_timestamp = true ),
) PRIMARY KEY (UserID);
ERROR: (gcloud.spanner.databases.create) FAILED_PRECONDITION: Cannot use commit timestamp column `CreatedAt` as a column with default value.

終わりに

アプリケーション側で面倒を見る範囲が減り保守性が高くなるのでありがたい機能だと思います。万一従来の癖で値を入れてしまっても有害なことは無いはずなので然程リスクも無いでしょう。

個人的には既に稼働しているマイクロサービスの面倒を見ることが大半で、後から敢えて導入するモチベーションが湧かないのが残念なところです。新規に構築する機会があれば採用してみようと思います。

参考文献

Discussion