🔧

Ruby on Rails でも Spanner を使いたい

2022/12/17に公開

はじめに

2021 年末、Cloud Spanner に対応した ActiveRecord Adapter がリリースされました。Cloud Spanner を使うことで従来の RDBMS と比べて運用を楽にできたり、可用性を高くできたり、簡単にスケーラブルにできたりする可能性があります。しかし MySQL や PostgreSQL などと比較すると Ruby on Rails でのアプリ開発には馴染みがないため Cloud Spanner との組合せはまだ少ないのが現状です。

本記事ではそんな現状を解消すべく、Cloud Spanner とは何か、Ruby on Rails での Cloud Spanner の基本的な使い方、 Cloud Spanner 特有のハマりどころとその回避策を説明します。

対象読者は、

  • Rails 開発者でデータベース運用のツラミを感じている人
  • Rails 開発者で Spanner に興味がある人
  • Rails 開発者で Google Cloud を使ってみたい人
  • Rails 開発者

です。

TL; DR

  • Cloud Spanner はいいぞ
  • Rails でも Cloud Spanner は活用できる
  • ただし特有のハマりどころがあるので注意

Cloud Spanner はいいぞ

まずは Cloud Spanner とはなにかを簡単に説明します。Spanner は Google が開発したデータベースで多くの Google サービスのバックエンドとして利用されています。NewSQL と呼ばれることもあり、リレーショナル DB の特徴 (スキーマ、SQL クエリ、ACID トランザクションなど) を持ちつつ水平スケールする分散データベースです。Spanner を Google Cloud のサービスとして提供しているものが Cloud Spanner です。

Cloud Spanner の特徴についてこちらの記事で簡潔に説明されていたので引用します。

https://zenn.dev/google_cloud_jp/articles/how-to-use-free-trial-spanner

Cloud Spanner の特徴をあげろと言われたら、僕はよくこの 3 つをあげています。

  • 運用が簡単(運用することがほぼ無い)
  • 可用性が高い(99.999% の可用性を実現)
  • 書き込みのスケールアウトができる

なんでこんな特徴が実現できているかというと、Cloud Spanenr は、ゾーンやリージョンをまたいだ同期レプリケーション と、 負荷状況にあわせた自動シャーディング、この 2 つの運用を 自動化 した分散データベースだからです。

この説明で十分伝わる部分もありますが、以下でより詳しくアプリ設計・開発者向けに Cloud Spanner の特徴について説明します。Cloud Spanner のことを既にご存知の方はこのセクションは読み飛ばしてください。

運用がとても楽

Cloud Spanner には従来の RDBMS と比較すると次のような特徴があります。

  • 高い可用性
  • パッチやホストメンテなどの計画メンテによるダウンタイムがない
  • ダウンタイムなしで性能の追加・削減が可能
  • 自動シャーディングと無制限のスケーリング
  • 運用はほぼサーバーレス [1]

特に、通常の運用でダウンタイムが発生しないという点は多くの開発・運用チームにとって大きな恩恵を受けられるポイントではないでしょうか。メンテのダウンタイムによるアラートで夜中に起こされたり、強制パッチのメンテ時間調整で疲弊したりすることがなくなります。

また、サービスの成長に手間なくダウンタイムなく追従できる点も安心です。小さく始めたサービスでも成長するとデータベースのプライマリ インスタンスのスケールアップやシャーディングが必要になり、ダウンタイムや大幅な設計変更を伴うケースがあります。このような場合でも Cloud Spanner ではノードを追加するだけで対応できて[2]ダウンタイムも発生しません。

構築がとても楽

これが Cloud Spanner インスタンスの作成画面です。

create instance

さて、Cloud SQL の MySQL インスタンス作成画面も見てみましょう。

create mysql

Cloud Spanner を使えばもう複雑な画面を前に悩む必要はありません。

もちろん、Cloud SQL はとてもいいサービスなので要件が合うときはぜひ安心して使ってください。この画面の長さは MySQL などオンプレ時代からある RDBMS のマネージド サービスに必要なものであり「Cloud SQL というサービスの複雑さ」とは少し違います。MySQL や PostgreSQL などの RDBMS も Cloud Spanner とはそれぞれ異なった良さがあります。Cloud Spanner、MySQL、PostgreSQL、常にどれが最も優れているということではないので必要に応じて使い分けてください。例えば、これまでと同じような Rails アプリの開発・運用がしたいというケースであれば Cloud Spanner ではなく Cloud SQL でこれまでと同じ RDBMS を選択する方が適していると言えます。

開発が思ったより普通

特殊なデータベースだから特殊な開発スキルが必要かというとそんなことはありません。Cloud Spanner も MySQL や PostgreSQL と同じような RDBMS として利用できます。細かい使い勝手が違うことはありますが他の RDBMS 同士の差分と比べて学習量が特段大きいわけではありません。

将来的に安心できるスキーマを設計するためには Cloud Spanner のベストプラクティスに従う必要がありますが、整備されたドキュメントを一通り読めば問題ないでしょう。他の RDBMS で正しく設計・開発ができる開発者であれば、慣れていない RDBMS を使う程度の苦労で Cloud Spanner を使うことができます。

でも、お高いんじゃない?

Cloud Spanner といえば高いというイメージがありますよね。Cloud Spanner はノード単位の課金で、以前は 1 ノードが最小サイズだったため最低利用料金が高額でした。しかし、Processing Units という 1 ノードをより細かく分割したような単位でインスタンスをデプロイできるようになり最低利用料金も下がりました。

例えば、本番環境向けの Cloud SQL for MySQL と Cloud Spanner の最小構成[3]Google Cloud 料金計算ツール で比較してみると次のようになります。インスタンス サイズ以外の条件としては、東京リージョン、高可用性あり、SSD ディスク 100GB、バックアップ 300GB です。

  • Cloud SQL for MySQL 203.62 ドル/月
  • Cloud Spanner 154.41 ドル/月

この構成で性能を比較すると一般的には Cloud SQL の方が高性能となりますが[4]、Cloud Spanner の方が安くスモール スタートできるという事実は意外ではないでしょうか。このように、単純に Cloud Spanner の方が高いという結果にはなりません。ただし性能を追加していくとコストも増加しくため、適切なパフォーマンス テストを実施した上で運用コストや構築の容易さ、スケーラビリティ、サーバーレス プロダクトとの相性の良さ等を見て総合的に比較する必要があります。

注意点

メリットだけでなく注意点もあります。

1 つ目は、Cloud Spanner 性能を最大限発揮するためには Cloud Spanner の知識が必要になるということです。例えば、Cloud Spanner では主キーに連番を使うと自動シャーディングで上手くスケールしないケースがあります[5]。従来の RDBMS ではスケールアップで対応できる可能性がありますが、Cloud Spanner の場合は自動シャーディングによるスケールアウトで対応しなければいけません。[6]

2 つ目は、開発用インスタンスの必要性です。Cloud Spanner は OSS ではないためローカルマシンで動作しません。エミュレータはありますがデータの永続化できず本番環境との差分もいくつかあります。そのためテストには十分ですが開発用途としては不十分であり、本番用とは別に開発用のインスタンスが必要になることも多いです。最小インスタンスでも 10 個のデータベースを作成できるので、開発用にインスタンスを 1 つ作成するような形がいいでしょう。初期段階の検証や開発には無料のトライアル インスタンスも利用できます。

3 つ目は、ActiveRecord Spanner Adapter の成熟度です。まだリリースして 1 年であり成熟しているとは言えません。世に出ている情報もまだ少ないですし様々な壁にぶつかる可能性があります。現段階では問題があれば自力でなんとかしてやるぜ、ぐらいの気概を持って使った方がいいかもしれません。

結局、どんなアプリに向いてるの?

まとめとして、Cloud Spanner は次のうちいずれかの要件があるアプリケーションには特に適しているでしょう。

  • シャーディングが必要
  • 書き込みのスケーラビリティが必要
  • リージョン障害にも耐えるような高い可用性が必要
  • メンテナンスによるダウンタイムを許容できない
  • 運用負荷をできるだけ削減して開発にリソースを集中させたい
  • スモール スタートしたいが安定性や高可用性、スケーラビリティも必要

基本的な使い方

前提

本記事ではそれぞれ次のバージョンで動作確認等を行っています。

サンプルコード

本記事で動作確認を行ったコードはこちらにあります。

https://github.com/nownabe/example-google-cloud-ruby/tree/main/rails-on-spanner

Cloud Spanner インスタンス作成

rails new する前に Cloud Spanner インスタンスを作成しておきます。Web UI でも gcloud コマンドでも作成できます。ここでは gcloud コマンドで作成する方法を紹介します[7]

gcloud spanner instances create rails-on-spanner \
  --config regional-asia-northeast1 \
  --description "Rails app development" \
  --processing-units 100

このコマンドで最小のインスタンスが東京リージョンに作成されます。

Application Default Credentials の設定

Google Cloud の各種クライアント ライブラリは Application Default Credentials という仕組みで認証します。いくつか設定方法はありますが、ローカル開発では gcloud コマンドで簡単に設定できます。

gcloud auth application-default login

初期設定

最初に rails new して Cloud Spanner を使い始めるまでを説明します。

まずはいつもどおり rails new しましょう。

rails _7.0.4_ new rails-on-spanner --skip-bundle

ディレクトリを移動します。

cd rails-on-spanner

Gemfile を編集します。

Gemfile
 # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
 gem "sprockets-rails"

-# Use sqlite3 as the database for Active Record
-gem "sqlite3", "~> 1.4"
+gem "activerecord-spanner-adapter", "~> 1.2.2"

 # Use the Puma web server [https://github.com/puma/puma]
 gem "puma", "~> 5.0"

設定した Gem をインストールします。

bundle install

config/database.yml を編集します。

config/database.yml
default: &default
  adapter: spanner
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  project: <%= ENV.fetch("SPANNER_PROJECT_ID", "rails-on-spanner") %>
  instance: <%= ENV.fetch("SPANNER_INSTANCE_ID", "rails-on-spanner") %>
  database: <%= ENV.fetch("SPANNER_DATABASE_ID", "rails-on-spanner") %>

development:
  <<: *default

test:
  <<: *default
  emulator_host: "localhost:9010"

production:
  <<: *default

テストではエミュレータを利用するように設定しています。

データベースを作成します。まだエミュレータを設定していないのでテスト用データベースの作成はスキップします。

rails db:create SKIP_TEST_DATABASE=true

開発用サーバーを起動します。

rails server

これで http://localhost:3000 にアクセスできるようになります。

モデル作成

標準の方法でモデル作成が可能です。試しに Post というモデルを作成してみます。

rails generate model Post text:string

次のような普通のマイグレーションファイルが生成されます。

class CreatePosts < ActiveRecord::Migration[7.0]
  def change
    create_table :posts do |t|
      t.string :text

      t.timestamps
    end
  end
end

マイグレーションも通常のコマンドで実行します。

rails db:migrate

コンソールでモデルを使ってみます。

$ rails console
Loading development environment (Rails 7.0.4)
>> post = Post.new(text: "Hello, Spanner!")
>> post.save
>> Post.all
=>
[#<Post:0x00007fa3d6753948
  id: 1512774127824833883,
  text: "Hello, Spanner!",
  created_at: Fri, 02 Dec 2022 06:38:31.642071966 UTC +00:00,
  updated_at: Fri, 02 Dec 2022 06:38:31.642071966 UTC +00:00>]

ここで気になる点として id がランダムな数値になっています。Cloud Spanner Adapter はデフォルトで主キーに INT64 型の UUID を使用します[8]。これは Cloud Spanner の性能を引き出すためのベストプラクティスのひとつです。

spanner-cli によるクエリ実行

MySQL や PostgreSQL を使った開発では mysql コマンドや psql コマンドを使って直接 SQL クエリを実行することがよくあります。Cloud Spanner では Web UI からクエリを実行して結果を得ることもできますが spanner-cli を使うとローカルから簡単に接続できます。

インストールには Go が必要なのでまずは Go をインストールしてください。様々なインストール方法がありますが Homebrew でインストールできます。

brew install go

spanner-cli を使うためには $GOPATH/bin にパスを通す必要があります。

echo 'export PATH="$GOPATH/bin:$PATH"' >> ~/.bash_profile

go install で spanner-cli をインストールします。

go install github.com/cloudspannerecosystem/spanner-cli@latest

インストールできたら次のようにプロジェクト、インスタンス、データベースを指定して接続します。

# config/database.yml と同じものを指定する
$ spanner-cli \
  -p $SPANNER_PROJECT_ID \
  -i $SPANNER_INSTANCE_ID \
  -d $SPANNER_DATABASE_ID

Connected.
spanner> select * from posts;
+---------------------+-----------------+--------------------------------+--------------------------------+
| id                  | text            | created_at                     | updated_at                     |
+---------------------+-----------------+--------------------------------+--------------------------------+
| 1512774127824833883 | Hello, Spanner! | 2022-12-02T06:38:31.642071966Z | 2022-12-02T06:38:31.642071966Z |
+---------------------+-----------------+--------------------------------+--------------------------------+
1 rows in set (7.77 msecs)

spanner> show tables;
+----------------------------+
| Tables_in_rails-on-spanner |
+----------------------------+
| ar_internal_metadata       |
| posts                      |
| schema_migrations          |
+----------------------------+
3 rows in set (0.03 sec)

spanner> show create table posts;
+-------+----------------------------------+
| Table | Create Table                     |
+-------+----------------------------------+
| posts | CREATE TABLE posts (             |
|       |   id INT64 NOT NULL,             |
|       |   text STRING(MAX),              |
|       |   created_at TIMESTAMP NOT NULL, |
|       |   updated_at TIMESTAMP NOT NULL, |
|       | ) PRIMARY KEY(id)                |
+-------+----------------------------------+
1 rows in set (0.48 sec)

spanner>

アソシエーション

標準の方法でアソシエーションを扱えます。Post モデルに紐づく Comment モデルを作成してみます。

rails g model Comment post:references text:string

次のようなマイグレーション ファイルが作成されます。

class CreateComments < ActiveRecord::Migration[7.0]
  def change
    create_table :comments do |t|
      t.references :post, null: false, foreign_key: true
      t.string :text

      t.timestamps
    end
  end
end

各モデルはこのようにします。

# app/models/post.rb
class Post < ApplicationRecord
  has_many :comments
end

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :post
end

コンソールから使ってみます。

post = Post.new(text: "I'm post")
post.save
comment1 = Comment.new(text: "1st comment", post: post)
comment1.save
comment2 = Comment.new(text: "2nd comment", post: post)
comment2.save

post.comments
=>
[#<Comment:0x00007fe742a44e90
  id: 554336591488924812,
  post_id: 1930108953802636175,
  text: "2nd comment",
  created_at: Fri, 02 Dec 2022 14:22:29.074713484 UTC +00:00,
  updated_at: Fri, 02 Dec 2022 14:22:29.074713484 UTC +00:00>,
 #<Comment:0x00007fe742a445d0
  id: 1283981736145185058,
  post_id: 1930108953802636175,
  text: "1st comment",
  created_at: Fri, 02 Dec 2022 14:22:11.155543363 UTC +00:00,
  updated_at: Fri, 02 Dec 2022 14:22:11.155543363 UTC +00:00>]

comment1.post
=> #<Post:0x00007fe7429ab920 id: 1930108953802636175, text: "I'm post", created_at: Fri, 02 Dec 2022 14:20:15.862502705 UTC +00:00, updated_at: Fri, 02 Dec 2022 14:20:15.862502705 UTC +00:00>

インターリーブの利用

Cloud Spanner にはインターリーブという外部キーに似た機能があります。どちらも親子関係を表現できる機能ですが[9]、インターリーブでは分散されたノードにおいて親と子が物理的に同じ場所に配置されるため多くの親子関係で外部キーよりパフォーマンスが向上します。

インターリーブすると子テーブルは複合主キーとなるので composite_primary_keys という Rubygem が必要になります。

Gemfile
gem "composite_primary_keys", "~> 14.0.4"

例えば、インターリーブを使って親子関係を持つ SingerAlbumTrack という 3 つのモデルのスキーマ定義は次のようになります。

create_table :singers, id: false do |t|
  # 明示的に主キーに名前をつけます。
  # インターリーブするすべてのテーブルの主キーが `id` になることを避けるためです。
  t.primary_key :singer_id

  t.string :name
  t.timestamps
end

create_table :albums, id: false do |t|
  # albums テーブルを singers テーブルを親テーブルとしてインターリーブします。
  t.interleave_in :singers

  # 親テーブルの主キーを指定します。
  t.parent_key :singer_id

  # 明示的に主キーに名前をつけます。
  t.primary_key :album_id

  t.string :title
  t.timestamps
end

create_table :tracks, id: false do |t|
  # tracks テーブルを albums テーブルを親テーブルとしてインターリーブします。
  # :cascade オプションをつけると親レコードを削除したとき子レコードも削除されます。
  t.interleave_in :albums, :cascade

  # 親テーブルの主キーを指定します。
  t.parent_key :singer_id
  t.parent_key :album_id

  # 明示的に主キーに名前をつけます。
  t.primary_key :track_id

  t.string :title
  t.timestamps
end

モデルにも少し特殊な記述が必要になります。各モデルは次のようになります。

class Singer < ApplicationRecord
  has_many :albums, foreign_key: :singer_id

  # tracks テーブルも singer_id 列があるので直接 has_many できる
  has_many :tracks, foreign_key: :singer_id
end

class Album < ApplicationRecord
  # 複合主キーを指定する
  self.primary_keys = [:singer_id, :album_id]

  belongs_to :singer, foreign_key: :singer_id
  has_many :tracks, foreign_key: [:singer_id, :album_id]
end

class Track < ApplicationRecord
  self.primary_keys = [:singer_id, :album_id, :track_id]

  belongs_to :album, foreign_key: [:singer_id, :album_id]
  belongs_to :singer, foreign_key: :singer_id

  def initialize(attributes = nil)
    super
    self.singer ||= album&.singer
  end

  def album=(value)
    super
    self.singer = value&.singer
  end
end

使い方は通常と同じです。

singer = Singer.create!(name: "ずっと真夜中でいいのに")
album1 = Album.create!(singer: singer, title: "潜潜話")
track1_1 = Track.create!(album: album1, title: "秒針を噛む")
track1_2 = Track.create!(album: album1, title: "脳裏上のクラッカー")
album2 = Album.create!(singer: singer, title: "ぐされ")
track2_1 = Track.create!(album: album2, title: "お勉強しといてよ")
track2_2 = Track.create!(album: album2, title: "暗く黒く")

singer.albums.pluck(:title)
=> ["ぐされ", "潜潜話"]

singer.tracks.pluck(:title)
=> ["お勉強しといてよ", "暗く黒く", "脳裏上のクラッカー", "秒針を噛む"]

track2_2.album.title
=> "ぐされ"

track2_2.singer.name
=> "ずっと真夜中でいいのに"

album2.delete

singer.tracks.pluck(:title)
=> ["脳裏上のクラッカー", "秒針を噛む"]

配列型の利用

PostgreSQL とおなじように Cloud Spanner でも配列型のカラムを使えます。

マイグレーションは次のように書きます。

create_table :books do |t|
  t.string :title, null: false
  t.string :tags, array: true, null: false, default: []
  t.string :ratings, array: true, null: false, default: []

  t.timestamps
end

次のように使います。

Book.create(title: "たのしい Ruby", tags: ["programming", "ruby"], ratings: [4, 5])
Book.where("'ruby' IN UNNEST(tags)").pluck(:title)
=> ["たのしい Ruby"]
Book.where("ARRAY_LENGTH(ratings) > 1").pluck(:title)
=> ["たのしい Ruby"]

トランザクションの利用

トランザクションは一般的な方法で利用できます。

ActiveRecord::Base.transaction do
  Post.create(text: "in transaction")
  raise ActiveRecord::Rollback
end

Post.where(text: "in transaction").size
=> 0

この使い方の場合トランザクションは Read/Write トランザクションになります。Cloud Spanner には Read Only トランザクションもあり、ある時点での整合性のあるデータをロックせず読み取ることができます。

ActiveRecord::Base.transaction(isolation: :read_only) do
  Post.where(text: "I'm post").size
end
  SQL (7.3ms)  BEGIN read_only
  Post Count (14.2ms)  SELECT COUNT(*) FROM `posts` WHERE `posts`.`text` = @p1
  SQL (0.0ms)  COMMIT
=> 1

また、過去のタイムスタンプのデータ読み取りも可能です。

post = Post.create(text: "20 seconds ago")
sleep 15
Post.update(post.id, text: "5 seconds ago")
sleep 5
ActiveRecord::Base.transaction(isolation: { timestamp: Time.now - 10.seconds }) do
  Post.find(post.id).text
end
=> "20 seconds ago"

ステイルネスを指定すると、指定した古さを許容するデータ読み取りができます。ステイルネスを利用することでパフォーマンスの向上が期待できます。例えば、次のコードでは過去 30 秒前より新しいデータを読み取ります。

ActiveRecord::Base.transaction(isolation: { staleness: 30.seconds }) do
  Post.where(text: "I'm post")
end

エミュレータの設定

初期設定のパートでスキップしてしまいましたが、Cloud Spanner にはエミュレータが存在します。開発用途でも使えますが、特にローカルでのテストや CI に向いています。

本記事では Docker Compose での使い方を紹介します。docker-compose.yaml にサービスを次のように追加して docker compose up すればエミュレータが起動します。

docker-compose.yaml
services:
  spanner:
    image: gcr.io/cloud-spanner-emulator/emulator
    ports:
      - 9010:9010

起動は簡単ですが使い方に少しクセがあります。まず、Rails アプリからエミュレータに接続する場合は config/database.ymlemulator_host を設定します。このとき、プロジェクト、インスタンス、データベースの指定も必要ですが、実際に存在するものではなく適当な名前でも大丈夫です。

config/database.yml
test:
  adapter: spanner
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  project: hoge
  instance: hoge
  database: hoge
  emulator_host: "localhost:9010"

環境変数 SPANNER_EMULATOR_HOST を設定した場合は config/database.ymlemulator_host を指定しなくてもエミュレーターを利用するようになります。これは、多くの Cloud Spanner のクライアント ライブラリで共通の振る舞いです。

# エミュレーターを使ってサーバーを起動する
SPANNER_EMULATOR_HOST=localhost:9010 rails server

# エミュレーターを使ってコンソールを起動する
SPANNER_EMULATOR_HOST=localhost:9010 rails console

spanner-cli ではこの方法を使います。

SPANNER_EMULATOR_HOST=localhost:9010 spanner-cli -p rails-on-spanner -i rails-on-spanner -d rails-on-spanner

環境変数の挙動を知らないと変にハマってしまったりするので開発中は注意してください。データベースの挙動がおかしいときは SPANNER_EMULATOR_HOST を確認してみるといいでしょう。

エミュレータに接続する前にインスタンスとデータベースを作成する必要があります。データベースは db:create で作成できるので db:create の際にインスタンスを作成するようにしておけば楽になります。

lib/cloud_spanner_admin.rb
require "google/cloud/spanner"
require "google/cloud/spanner/admin/instance"

class CloudSpannerAdmin
  DEFAULT_REGION = "asia-northeast1"

  def initialize(db_config)
    @project_id = db_config[:project]
    @instance_id = db_config[:instance]
    @emulator_host = db_config[:emulator_host]
  end

  def ensure_instance!
    return if instance_exists?

    instance_admin.create_instance(
      parent: project_path,
      instance_id: @instance_id,
      instance: {
        name: instance_path,
        config: instance_config_path,
        display_name: @instance_id,
        processing_units: 100
      }
    ).wait_until_done!
  end

  private

  def instance_admin
    @instance_admin ||= Google::Cloud::Spanner::Admin::Instance.instance_admin(
      project_id: @project_id,
      emulator_host: @emulator_host
    )
  end

  def instance_config_path
    @instance_config_path ||= instance_admin.instance_config_path(
      project: @project_id,
      instance_config: "regional-#{DEFAULT_REGION}"
    )
  end

  def instance_exists?
    instance_admin.list_instances(parent: project_path)
                  .any? { |instance| instance.name == instance_path }
  end

  def instance_path
    @instance_path ||= instance_admin.instance_path(
      project: @project_id,
      instance: @instance_id
    )
  end

  def project_path
    @project_path ||= instance_admin.project_path(project: @project_id)
  end
end
lib/tasks/spanner.rake
require "cloud_spanner_admin"

namespace :spanner do
  namespace :instance do
    task create: :environment do
      config = ActiveRecord::Base.configurations
                                 .find_db_config(Rails.env)
                                 .configuration_hash
      admin = CloudSpannerAdmin.new(config)
      admin.ensure_instance!
    end
  end
end

Rake::Task["db:create"].enhance(["spanner:instance:create"])

ハマりどころ

ここまで Rails 開発で基本的な使い方を紹介しました。ここからは Cloud Spanner 特有のハマりどころを紹介します。

use_transactional_tests が使えない

Minitest だと use_transactional_tests、RSpec だと use_transactional_fixtures が使えません。これは Cloud Spanner が SAVEPOINT 文をサポートしていないためです。同じ理由で transaction(requires_new: true) などによるネストされたトランザクションも使えません。

テストにおけるデータの削除を実現する他の方法としては DatabaseCleaner があります。

Gemfile
group :test do
  gem "database_cleaner-spanner"
end
spec/rails_helper.rb
RSpec.configure do |config|
  config.use_transactional_fixtures = false

  config.around(:each) do |example|
    DatabaseCleaner[:spanner].cleaning do
      example.run
    end
  end
end

ただし、こちらの方法では並列テストで期待する動作にならない可能性があります。並列テストを使うときは、ワーカー数だけデータベースを用意するなどの工夫が必要になります。

travel_to でエラーになる

travel_to で過去時間を設定したとき Google::Cloud::DeadlineExceededError が発生することがあります。これは、Cloud Spanner へのリクエストのタイムスタンプが過去に設定されるためリクエストがタイムアウトとみなされることが理由です。

これに関してはタイムアウト設定を変更することで回避できます。

# test/test_helper.rb とかに入れておくといいかも

Google::Cloud::Spanner::V1::Spanner::Client.configure do |c|
  timeout_sec = 10 * 365 * 24 * 60 * 60 # ten years

  c.rpcs.create_session.timeout = timeout_sec
  c.rpcs.batch_create_sessions.timeout = timeout_sec
  c.rpcs.get_session.timeout = timeout_sec
  c.rpcs.list_sessions.timeout = timeout_sec
  c.rpcs.delete_session.timeout = timeout_sec
  c.rpcs.execute_sql.timeout = timeout_sec
  c.rpcs.execute_streaming_sql.timeout = timeout_sec
  c.rpcs.read.timeout = timeout_sec
  c.rpcs.streaming_read.timeout = timeout_sec
  c.rpcs.begin_transaction.timeout = timeout_sec
  c.rpcs.commit.timeout = timeout_sec
  c.rpcs.rollback.timeout = timeout_sec
  c.rpcs.partition_query.timeout = timeout_sec
  c.rpcs.partition_read.timeout = timeout_sec
end

upsert とミューテーション

Cloud Spanner は upsert 用の構文[10]をサポートしていません。Cloud Spanner で upsert を実現するためには SQL ではなくミューテーション という方法を使う必要があります[11]。そのため ActiveRecord Cloud Spanner Adapter では upsert/upsert_all メソッドをミューテーションで実装しています。

注意点として、Cloud Spanner はひとつのトランザクション内で SQL とミューテーションによるデータ更新を同時に扱うことができず、SQL を発行するようなトランザクション内で upsert/upsert_all メソッドを実行するとエラーになります。トランザクション内で upsert するためにはミューテーションを使うための特別なトランザクションを利用します。このトランザクションの中では SQL のかわりにミューテーションを発行します。

ActiveRecord::Base.transaction(isolation: :buffered_mutations) do
  post = Post.new(text: "mutation")
  post.save

  # upsert できる
end
  SQL (0.1ms)  BEGIN buffered_mutations
  SQL (239.3ms)  COMMIT
=> true

ActiveRecord::Base.transaction do
  post = Post.new(text: "DML")
  post.save

  # upsert できない
end
  SQL (0.1ms)  BEGIN
  Post Create (32.1ms)  INSERT INTO `posts` (`text`, `created_at`, `updated_at`, `id`) VALUES (@p1, @p2, @p3, @p4)
  SQL (12.1ms)  COMMIT
=> true

データベースまわりでトラブルシュートが必要になったとき、その処理が SQL で実行されているか、ミューテーションで実行されているかを意識することも必要になってくるでしょう。

テーブルコメントがない

Cloud Spanner にはテーブルやカラムにコメントをつける機能がありません。マイグレーション ファイルでコメントをつけていても無視されるので注意してください。

既知の不具合

ここまで読んで気づいた方もいるかもしれませんが既知の不具合がいくつかあります。Rails 6 では問題ないが Rails 7 ではエラーになるという不具合もあります。

既知のものは修正に向けて活動を始めているので時間が経てば解決するはずです。未知の不具合を発見した場合はぜひ Issue で報告してください。また、多くの開発で支障をきたしそうな不具合に関しては本記事にパッチを記載しているのでモンキーパッチで耐え忍ぶことが可能です。

本記事に記載している不具合は 2022-12-17 時点で、 activerecord 7.0.4activerecord-spanner-adapter 1.2.2 で確認しています。

db:schema:load がエラーになる

現行バージョンでは db:schema:load がエラーになります。db:schema:load は新しい開発環境の構築時であったり、テストの内部で使われます。そのため通常の方法でテストできないなどの問題があります。

db:schema:load を使わないようにするかパッチを当てるかで対応してください。パッチは以下のように関連するタスクの直前にあたるようにすると便利です。また、既にマイグレーションを実行して db/schema.rb が作成されている場合、このパッチをあててから再度マイグレーションを実行し直す必要があります。

lib/tasks/spanner.rake
namespace :spanner do
  namespace :patch do
    task :schema_dump do
      module SchemaDumperPatch
        private

        def column_spec_for_primary_key(column)
          spec = super
          spec.except!(:limit) if default_primary_key?(column)
          spec
        end
      end

      require "active_record/schema_dumper"
      require "active_record/connection_adapters/abstract/schema_dumper"
      require "active_record/connection_adapters/spanner/schema_dumper"
      ActiveRecord::ConnectionAdapters::Spanner::SchemaDumper.prepend(SchemaDumperPatch)
    end

    task :schema_load do
      module SchemaStatementPatch
        def assume_migrated_upto_version(version)
          version = version.to_i
          sm_table = quote_table_name(schema_migration.table_name)

          migrated = migration_context.get_all_versions
          versions = migration_context.migrations.map(&:version)

          unless migrated.include?(version)
            execute "INSERT INTO #{sm_table} (version) VALUES (#{quote(version.to_s)})"
          end

          inserting = (versions - migrated).select { |v| v < version }
          if inserting.any?
            if (duplicate = inserting.detect { |v| inserting.count(v) > 1 })
              raise "Duplicate migration #{duplicate}. Please renumber your migrations to resolve the conflict."
            end
            execute insert_versions_sql(inserting)
          end
        end

        def insert_versions_sql(versions)
          sm_table = quote_table_name(schema_migration.table_name)

          if versions.is_a?(Array)
            sql = +"INSERT INTO #{sm_table} (version) VALUES\n"
            sql << versions.reverse.map { |v| "(#{quote(v.to_s)})" }.join(",\n")
            sql << ';'
            sql
          else
            "INSERT INTO #{sm_table} (version) VALUES (#{quote(versions.to_s)});"
          end
        end
      end

      require "active_record/connection_adapters/abstract/schema_statements"
      ActiveRecord::ConnectionAdapters::SchemaStatements.prepend(SchemaStatementPatch)
    end
  end
end

Rake::Task["db:schema:dump"].enhance(["spanner:patch:schema_dump"])
Rake::Task["db:schema:load"].enhance(["spanner:patch:schema_load"])
Rake::Task["db:test:load_schema"].enhance(["spanner:patch:schema_load"])

Fixtures が使えない

Rails のデフォルトでは Minitest が使われていて Fixtures が有効になっていますが、そのままだとエラーになります。

Fixtures を無効化するか、パッチを当ててください。

test/test_helper.rb
module FixturesPatch
  def insert_fixtures_set(fixture_set, tables_to_delete = [])
    fixture_inserts = build_fixture_statements(fixture_set)
    table_deletes = build_truncate_statements(tables_to_delete)
    statements = table_deletes + fixture_inserts

    with_multi_statements do
      disable_referential_integrity do
        transaction(requires_new: true) do
          execute_batch(statements, "Fixtures Load")
        end
      end
    end
  end
end

module FixturesPatchSpanner
  def build_truncate_statement(table_name)
    "DELETE FROM #{quote_table_name(table_name)} WHERE TRUE"
  end

  def build_fixture_statements(*args)
    super.flatten.compact
  end
end

ActiveRecord::ConnectionAdapters::DatabaseStatements.pretend(FixturesPatch)
ActiveRecord::ConnectionAdapters::Spanner::DatabaseStatements.pretend(FixturesPatchSpanner)

インターリーブを使ったモデルの Fixture は少し複雑になるので FactoryBot などを使った方がいいかもしれません。

インターリーブの子テーブルの保存がエラーになる

Rails 7 で ActiveRecord の partial_inserts が無効になっている場合、インターリーブされた子テーブルの保存がエラーになります。Rails 7 のデフォルトは無効化されているため特に設定していない場合はエラーになります。

partial_inserts を有効にするか、パッチを当てるかで対応してください。

partial_inserts を有効にする場合はRails アプリ全体に設定するか各モデルで設定できます。

# アプリ全体で設定する場合
# config/application.rb

module MyApp
  class Application < Rails::Application
    #...

    config.active_record.partial_inserts = true
  end
end

# モデルごとに設定する場合
# app/models/album.rb

class Album < ApplicationRecord
  self.partial_inserts = true

  # ...
end

partial_inserts を有効化しない場合は ApplicationRecordActiveRecord::Base._set_composite_primary_key_values をオーバーライドすることで対応できます。

app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  class << self
    def _set_composite_primary_key_values(primary_keys, values)
      primary_key_value = []
      primary_key.each do |col|
        value = values[col]

        if value&.value.nil? && prefetch_primary_key?
          value = ActiveModel::Attribute.from_database col, next_sequence_value, ActiveModel::Type::BigInteger.new
          values[col] = value
        end
        if value.is_a? ActiveModel::Attribute
          value = value.value
        end
        primary_key_value.append value
      end
      primary_key_value
    end
  end
end

Generated Column は未対応

MySQL や PostgreSQL と同じように Cloud Spanner でも Generated Column を扱えます。しかし、現在 ActiveRecord Cloud Spanner Adapter では対応していません。

upsert がエラーになる

Rails 7 では upsert/upsert_all がエラーになります。

upsert を使わないように実装するか、ActiveRecord を使わず Cloud Spanner SDK の upsertを直接使ってください。

おわりに

本記事では Cloud Spanner を Ruby on Rails で使うための基本的な使い方を説明しました。ActiveRecord から使うときにハマりポイントはありますが、通常のデータベースとして利用でき、クラウドらしい様々な恩恵を受けることができます。ぜひ一度試してみてください。

もし使ってみて、バグ等を発見したらぜひ Issue で報告してください。Issue 作成のハードルが高いという場合は本記事のコメントで教えてください。

最後に、まずは Cloud Spanner を触ってみたいという方はこちらの記事で無料トライアル インスタンスの使い方がわかりやすく紹介されているのでぜひ試してみてください。

https://zenn.dev/google_cloud_jp/articles/how-to-use-free-trial-spanner

脚注
  1. インスタンスという概念があるし、性能を追加するためにはノードを増やす必要があるのでサーバーレスではないんですが、運用の手間としてはサーバーレスなデータベースに近いです。 ↩︎

  2. スキーマがしっかりと設計されている必要はあります。 ↩︎

  3. Cloud SQL にはより小さいインスタンスもありますが開発用途であり SLA の適用外です。インスタンスの設定について  |  Cloud SQL for MySQL  |  Google Cloud ↩︎

  4. 細かい構成・設定やユースケースにもよりますが、ざっくりと数倍〜数十倍のスループットの差が出ると考えてください。あくまでも超ざっくりとした目安なので実際に使用する際には開発するアプリケーションのユースケースにあわせてパフォーマンス テストを行ってください。 ↩︎

  5. ActiveRecord Cloud Spanner Adapter では標準で対応されています。主キーの選択についてはこちらのドキュメントを参考にしてください。スキーマについて  |  Cloud Spanner  |  Google Cloud ↩︎

  6. ドキュメントが日本語でもしっかり整備されているので一読すれば安心できるでしょう。 スキーマ設計  |  Cloud Spanner  |  Google Cloud ↩︎

  7. 事前に gcloud のインストールと初期化が必要です。クイックスタート: Google Cloud CLI をインストールする  |  Google Cloud CLI のドキュメント ↩︎

  8. UUID はよく見る文字列ではなく INT64 型になっています。ActiveRecord Cloud Spanner Adapter では元の UUID の先頭 4 bit は常に一定となるため捨てていて、厳密な UUID ではありません。 ↩︎

  9. インターリーブと外部キーの違いについてはこちらにまとまっています。 ↩︎

  10. ON DUPLICATEON CONFLICT ↩︎

  11. DML とミューテーションの比較  |  Cloud Spanner  |  Google Cloud ↩︎

GitHubで編集を提案
Google Cloud Japan

Discussion