🛠️

SpannerのPros & Cons

2022/06/25に公開

概要

Spannerをプロジェクトで使用して数年。
備忘録がてら、改めてPros & Consを書く。

Pros

レプリ遅延や水平分割を考慮したスケールを気にしなくて良い

Spannerの最大のメリットと言えばコレ。
正直、エンジニアによるメンテのコストはめちゃくちゃ減ってると思う。

従来のRDBMSの場合、シャーディングを自前で作る必要がある。

しかし、Spannerは不要。データサイズや負荷に応じて、物理的にデータを分割して配置してくれる。
(これをスプリットと呼ぶ)

監視が楽

Spannerでは基本的にCPU使用率を主軸に監視すれば大丈夫。
CPUが上がってきたらノードを増やせばOK。

しかも、ノード数を増やしても

  • ダウンタイムなし
  • レイテンシ悪化なし
  • CPU使用率上昇なし

という、正に鬼に金棒の状態である。

実はそんなに料金が高くはない

Spannerと聞くと「料金高い」というイメージを持たれがちだが、1ノード未満の設定(100ユニット)が出来るように一般提供(GA)された。

Cloud Spanner でインスタンスのきめ細かなサイズ設定: 月額 $40 から本番環境ワークロードを実行可能に

従来は1ノードがミニマムだったので、それと比べると

ユニット数 料金(大体の料金)
1000 $858.00
100 $89.31

差は歴然である。

エミュレータがあるのでローカルでの開発も楽ちん

gcr.io/cloud-spanner-emulator/emulator というDockerイメージが提供されているので、これをdocker-compose等で立ち上げればローカルで開発が出来る。

また、

アプリケーションが起動すると、クライアント ライブラリは自動的に SPANNER_EMULATOR_HOST をチェックし、エミュレータが実行されている場合は接続します。

Cloud Spanner エミュレータの使用

と記載がある通り、SPANNER_EMULATOR_HOSTという環境変数を指定してサーバーを立ち上げれば勝手にエミュレータに接続してくれる。

(勿論GitHub Actionsのservicesに入れれば、CIでテスト等も可能。)

レコードにTTLを付けることが出来る

履歴系のデータで要件的に「3日以上のものはUIで表示しない」とかの時に威力を発揮する。

構文としては

CREATE TABLE UserHistory (
  UserID STRING(MAX) NOT NULL,
  CreatedAt TIMESTAMP,
) PRIMARY KEY (UserID)
, ROW DELETION POLICY (OLDER_THAN(CreatedAt, INTERVAL 30 DAY));

のように、テーブル作成時につけられたり

ALTER TABLE UserHistory ADD ROW DELETION POLICY (OLDER_THAN(CreatedAt, INTERVAL 30 DAY));

のように、ALTER文として後から追加も出来る。

勿論、削除も変更も可能。

ALTER TABLE UserHistory DROP ROW DELETION POLICY;
ALTER TABLE UserHistory REPLACE ROW DELETION POLICY (OLDER_THAN(CreatedAt, INTERVAL 10 DAY));

長期運用でデータがどんどん増えていって、負荷少ないけど容量の都合でノード下げられないとかあるので結構重宝する。

Cons

スプリットを跨いだ検索が弱い

例えば、

CREATE TABLE User (
  UserID STRING(MAX) NOT NULL,
) PRIMARY KEY(UserID);

CREATE TABLE UserItem (
  UserID STRING(MAX) NOT NULL,
  ItemID STRING(MAX) NOT NULL,
) PRIMARY KEY(UserID, ItemID),
  INTERLEAVE IN PARENT User ON DELETE CASCADE;

というテーブルがあり、Userが親、UserItemが子だとする。

これを1個のUserIDでUserItemを100件取得するのは、同じsplitから取ってくる為そこまで負荷がかからない。

これを100個のUserIDでUserItemを1件ずつ取得だと、異なるsplitから取得してくるから負荷が一気に増える。

同じレコードの同時更新に弱い

Spannerクライアントには、トランザクションが失敗した際に自動的にリトライを行ってしまう。

これが100人同時にアクセスきた場合、複数のトランザクションでリトライが連発してSpannerだけでなく、APIサーバーに悪影響も与えてしまう。

・複数ユーザーが更新するデータは別のデータストアを検討する

・Redis等でトランザクション実行前にロックを取り、コミット後にロックを解放する

など、「そもそもリトライを発生しないようにする」対策が必要になる。

各Quotaの上限に気を付けないと、予期せぬ所でエラーになる

有名なところだと、ミューテーション上限だろうか。

1コミットあたり、20000という上限がある。

数え方は

Insert: テーブルのカラム数 + テーブルのINDEX数
Update: 更新対象のカラム数 + 更新対象のカラムを含むINDEX数×2
Delete: 1 + テーブルのINDEX数

こんな感じである。

ミューテーションは運用が長く続いて、更新するレコードが増えていった時に明るみに出やすい。

更新が必要なレコードのみをミューテーションに載せるか、もしくは

type User struct {
  UserID string
}

type Users []*User

func (l Users) Split(splitSize int32) []Users {
	length := int32(len(l))
	splits := make([]Users, 0, length/splitSize+1)
	for i := int32(0); i < length; i += splitSize {
		end := i + splitSize
		if length < end {
			end = length
		}
		splits = append(splits, l[i:end])
	}
	return splits
}

func Update(users Users) error {
  splits := users.Split(100)
  for _, users := range splits {
            _, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
	        for _, user := users {
		   stmt := spanner.Statement{}
                   _, err := txn.Update(ctx, stmt)
                   if err != nil {
                        return err
                   }
		}

                return err
        })
  }
  
  return nil
}

みたいに要件を満たせるのであれば、分割でトランザクションを発行するのが良し。

まとめ

Consはあるけども、Prosの恩恵が大きいので導入する価値はアリ。

Discussion