Node.js で Cloud Spanner を使う

Cloud SpannerGoogle Cloud Platform(GCP) で提供されているデータベースです。
分散データベースによるスケーラビリティーと、ACID トランザクションによる強い整合性が両立されているのが特徴です。

また、SQL を使ったデータの抽出も可能で NoSQL を使う場合の整合性の問題と、SQL を使う場合のスケーラビリティの問題のどちらも解決が可能です。

私は名前を聞いたことがある程度だったのですが、先日「『ドラゴンボール レジェンズ』の舞台裏を支える Google Cloud」という記事を読み、Cloud Spanner に興味を持ちました。

そこで、普段から使っている Node.js と Node.js 用のクライアントライブラリである @google-cloud/spanner を利用して、Cloud Spanner を試してみることにしました。

いつものどおり、作成したプログラムは GitHub で公開していますので、合わせて参考にしてみてください。

なお、今回は Vagrant などは使わず、私の個人のマシンに環境を構築して開発と動作確認を行いました。

Name Version
OS Debian GNU/Linux 10(Buster / Testing)
Node.js 10.6.0
@google-cloud/spanner 1.5.0

参考にした情報

Cloud Spanner を動かして見る前に、名前しか知らなかったこのデータベースについての情報を集めました。
公式のドキュメントAPI のリファレンス以外で参考にしたのは、次のような情報です。

これらの情報によると、Cloud Spanner を利用する際には、次のような点に注意する必要があるようでした。

  • 連番や時間ベースの ID は使わず、ハッシュ関数に通したり UUID を利用する
  • JOIN が必要なテーブルは同じサーバーに配置するため、親子関係(インターリーブ)の指定を行う
  • クエリキャッシュを活用するため、クエリの作成に QueryBuilder を使用する

Max connections

Cloud Spanner を際に注意することが判りましたが、他にはないのでしょうか。
MySQL を使っていた頃に、よく同時接続数が問題になっていた経験があるため、その辺りは特に気になりました。
しかし Cloud Spanner のドキュメントには、同時接続数についての説明が無いように見えました。

これは、Google さんがうまい感じにやってくれているから必要ないのかな? と思いつつ、資料をみていると「REST を使用して Cloud Spanner を使ってみる」というページの「セッションを作成する」という項目に、次のような記載がありました。

データの追加、更新、削除、クエリを行うには、その前にセッションを作成する必要があります。
セッションは Cloud Spanner データベース サービスとの通信チャンネルを表します。
(Cloud Spanner クライアント ライブラリを使用している場合は直接セッションを使用せず、このクライアント ライブラリが代わりにセッションを管理します)

参考資料にもある「超実践 Cloud Spanner 設計講座」を見る限り、セッション数は「チャンネル数 x 100」で、デフォルトでは 4 x 100 = 400 のようです。
基本的にはデフォルトのままでも同時接続数が問題になることは余り無さそうです。
また、ノード毎に 10,000 セッションが上限ということで、必要に応じてチャンネルやノードを増やしていけば良さそうです。

Cloud Spanner を使ってみる

事前に準備が必要なこと

Clous Spanner の API にアクセスするにはサービスアカウントキーというものが必要になります。
サービスアカウントキーは Google Cloud Console の画面から作成が可能です。
Cloud Spanner Client Libraries のページなどを参考に、サービスアカウントキーを作成し、JSON ファイルをダウンロードしておいてください。

ダウンロードした JSON ファイルのパスを GOOGLE_APPLICATION_CREDENTIALS という環境変数に設定することで、API へアクセスできるようになります。

テーブルの作成

今回、テストで使用するテーブルは Google Cloud Console の画面から作成しました。
本来であれば API を使って Node.js からテーブルを作成したり Terraform などを利用した方がいいと思います。
しかし、今回は Cloud Spanner を使ってみるとが目的なので、その辺りは省いています。

データベースを作成する際に「データベーススキーマの定義」の項目で「テキストとして編集」の項目を ON にして、次のようなスキーマを登録しました。

CREATE TABLE members (
    member_id STRING(36),
    email STRING(255),
    password STRING(64)
) PRIMARY KEY(member_id);

CREATE TABLE profiles (
    member_id STRING(36),
    name STRING(64) NOT NULL,
    kana STRING(64),
    age INT64,
    birthday STRING(5),
    units Array<STRING(36)>
) PRIMARY KEY(member_id), INTERLEAVE IN PARENT members ON DELETE CASCADE;

CREATE TABLE units (
    unit_id STRING(36),
    name STRING(64)
) PRIMARY KEY(unit_id);

コメントが使えない

MySQL では #-- 以降がコメントとして扱われます。
また /* */ といったコメントの書き方もあります。
それに習ってコメントを書いていたのですが Google Cloud Console の画面に貼り付けたところ、エラーとなってしまいました。

また、普段は各カラムにも COMMENT "コメント" の形でコメントを残すようにしているのですが、こちらも同様にエラーとなりました。

親テーブルは複数設定することができない

最初、スキーマには次のような関連テーブルがありました。
しかし、このスキーマはエラーになってしまい、登録することができません。

CREATE TABLE member_unit (
    member_id STRING(36),
    unit_id STRING(36)
) PRIMARY KEY(member_id, unit_id),
  INTERLEAVE IN PARENT members ON DELETE CASCADE,
  INTERLEAVE IN PARENT units ON DELETE CASCADE;

調べてみたところ、1 つのテーブルが持てる親テーブルは、1 つだけのようでした。
テーブルを階層化することで解決できるようではありますが、関連テーブルが複数ある場合には、この方法が取れません。

インターリーブは FOREIGN KEY に近という説明もありましたが、思想も違うように思いますし、同じように使えるものでは無さそうです。
Cloud Spanner では、データ型として Array をサポートしているため、関連テーブルは作らずに、配列で情報をもつのが良さそうです。

CRUD のテスト

簡単な使い方の解説は「Node.js で Cloud Spanner を使ってみる」にあります。
@google-cloud/spanner のリポジトリに含まれている、サンプルのコードも参考になると思います。

また、@google-cloud/spanner の各メソッドに渡すパラメータや、オプションなどの詳細についての説明は Client API Reference にありあます。

データの取得

Cloud Spanner でデータを取得する方法は 2 つあります。
ひとつは SQL を使って取得する方法、もうひとつは、トランザクションやテーブルのクラスがもつ read というメソッドを呼び出す方法です。

SQL を使ってデータを取得するメリットは、テーブルの結合や、条件を指定しての抽出が可能なことです。
Table.read() メソッドを使う場合は、単一のテーブルからキーを指定して値を取得する KVS のような使い方になります。

SQL を使ってデータを取得する

次のようなコードで、SQL を使ってデータを取得できます。
サンプルのコードでは使っていませんが GROUP BY、ORDER BY などのクエリも、もちろん利用可能です。

const Spanner = require("@google-cloud/spanner");
const client = new Spanner({
    projectId: "PROJECT ID"
});
const instance = client.instance("INSTANCE ID");
const database = instance.database("DATABASE ID");

(async () => {
    const query = {
        sql: "SELECT * FROM members LEFT JOIN profiles ON members.member_id = profiles.member_id WHERE members.member_id = @member_id",
        params: {
            member_id: "MEMBER_ID"
        },
        json: true
    };

    const result = await database.run(query);
})();
Table.read() を使ってデータを取得する

Table.read() を使用してデータを取得する場合、すべてのカラムを取得したいときには columns に、すべてのカラム名を指定しないといけないことに注意してください。

「*」が利用できないため、スキーマが変更された場合、合わせて修正する必要もあります。

const Spanner = require("@google-cloud/spanner");
const client = new Spanner({
    projectId: "PROJECT ID"
});
const instance = client.instance("INSTANCE ID");
const database = instance.database("DATABASE ID");

(async () => {
    const member_table = database.table("profiles");

    const result = await member_table.read({
        keys: ["MEMBER_ID"],
        columns: [
            "member_id",
            "name",
            "kana",
            "age",
            "birthday",
            "units"
        ],
        json: true
    });
})();
UNNEST と CROSS JOIN を使用した配列の展開

profiles テーブルでは Array 型の units カラムに、複数の unit_id が保存されています。
このとき、プロフィールにユニット名を表示するために profiles テーブルと units テーブルを結合することを考えます。

NoSQL でやるのと同じく、別々に別取得してプログラムで結合するという方法も可能ですが、SQL を使って取得することもできます。
SQL を使って取得する場合は、次のように UNNEST と CROSS JOIN を使用します。

SELECT * FROM profiles CROSS JOIN UNNEST(units) AS unit LEFT JOIN units ON unit = unit_id;

データの登録

データの登録は SQL では行えないため、Table.insert() メソッドを利用します。
引数には登録したいデータを単体で渡すことも可能ですが、配列を渡すことで複数のデータを一度に登録することも可能です。

const Spanner = require("@google-cloud/spanner");
const client = new Spanner({
    projectId: "PROJECT ID"
});
const instance = client.instance("INSTANCE ID");
const database = instance.database("DATABASE ID");

(async () => {
    const unit_table = database.table("units");
    const data = {  unit_id: "UNIT_ID",
        name: "NAME"
    };

    await unit_table.insert([data]);
})();

データの更新

基本的にはデータの登録と変わりません。
データの更新には Table.insert() ではなく、Table.update() というメソッドを使います。
このメソッドにも配列でデータを渡すことができるため、複数の値を同時に更新することが可能です。

また、Table.update() メソッドに渡すデータには、テーブルのすべてのカラムの情報を含める必要はありません。
データに含まれていないカラムについては、削除されるのではなく、現在の情報が保持されます。

なお、Table.update() は同じ PRIMARY KEY をもつデータが上書きされ、データがない場合はエラーとなります。
データがないときには新規にデータを登録したいという場合には Table.upsert() が利用できます。

const Spanner = require("@google-cloud/spanner");
const client = new Spanner({
    projectId: "PROJECT ID"
});
const instance = client.instance("INSTANCE ID");
const database = instance.database("DATABASE ID");

(async () => {
    const unit_table = database.table("units");
    const data = {
        unit_id: "UNIT_ID",
        name: "NAME"
    };

    await unit_table.update([data]);
})();

データの削除

データの削除も SQL からは行えないため Table.deleteRows() メソッドを利用します。
テーブル自体を削除する Table.delete() というメソッドが存在することに注意してください。

名前のとおり、複数の情報を一度に削除することも可能です。
その場合は、削除したいデータの PRIMARY KEY を配列で渡します。

const Spanner = require("@google-cloud/spanner");
const client = new Spanner({
    projectId: "PROJECT ID"
});
const instance = client.instance("INSTANCE ID");
const database = instance.database("DATABASE ID");

(async () => {
    const unit_table = database.table("units");

    await unit_table.deleteRows(["UNIT_ID"]);
})();

トランザクション

Cloud Spanner には、読み込み専用のトランザクションと、読み書き可能なロック型のトランザクションの 2 種類がなります。
これらの違いや、トランザクションにについての解説は、公式のドキュメント にありますので、ここでは省略します。

しかし、注意点として次のような内容が挙げられているため、内容確認した上で利用することをお勧めします。

注: 読み取り専用トランザクションはどのレプリカでも実行できるため、可能であれば、トランザクションのすべての読み取りを読み取り専用トランザクションで実行することをおすすめします。
ロック型読み書きトランザクションは、次のセクションで説明するシナリオでのみ使用してください。

今回は、シナリオの 1 つである「1 つ以上の読み取りの結果に応じて 1 つ以上の書き込みを行う可能性がある場合」を想定して、ロック型読み書きトランザクションを利用してみました。

const Spanner = require("@google-cloud/spanner");
const client = new Spanner({
    projectId: "PROJECT ID"
});
const instance = client.instance("INSTANCE ID");
const database = instance.database("DATABASE ID");

(async () => {
    await new Promise((resolve) => {
        database.runTransaction(async (error: Error, transaction: any) => {
            const data = await transaction.read("profiles", {
                keys: ["UNIT_ID"],
                columns: [
                    "member_id",
                    "name",
                    "kana",
                    "age",
                    "birthday",
                    "units"
                ],
                json: true
            });

            data.age = data.age + 1;
            await transaction.update("profiles", data);
            await transaction.commit();

            resolve();
        });
    });
})();

もちろん、問題があったときには、ロールバックが可能です。

const Spanner = require("@google-cloud/spanner");
const client = new Spanner({
    projectId: "PROJECT ID"
});
const instance = client.instance("INSTANCE ID");
const database = instance.database("DATABASE ID");

database.runTransaction(async (error: Error, transaction: any) => {
    await transaction.rollback();
});

まとめ

今回、はじめて Cloud Spanner を使いましたが、抑える部分だけ抑えておけば他のデータベースと同じ様に利用できそうだなと思いました。
MySQL などの RDBMS と O/R マッパを使ってシステムを構築したことがあれば、導入の敷居は低そうです。
ただ、プライマリーキーに連番が使えなかったりするため、既存のデータベースを Cloud Spanner に移行するのは、少し大変かもしれません。

また、導入を検討する際に料金の高さが問題になるかもしれません。
Cloud Spanner を東京リージョン(asia-northeast1)で使う場合、2018 年 07 月 05 日の時点で、1 ノード毎に 1 時間あたり 1.17 USD かかります。 
ストレージの使用料や、ネットワークの使用料を別にしても、1 ノードで 1.17 x 24 x 30 = 842.4 USD になります。

そのため、どんな案件にでもお勧めできるというわけではなさそうです。
Cloud Spanner の提供するスケーラビリティと、強い整合性の両立という部分が必要ないのであれば、他の選択肢も検討した方がいいのかもしれません。


コメントの投稿