dely engineering blog

レシピ動画サービス「kurashiru」を運営するdelyのテックブログ

Rails6の複数データベースの仕組みと実装時にハマったところ

こんにちは、開発部の高橋です。

本記事はdely Advent Calendar 2019の14日目の記事です。

昨日はミカサ(acke_red)さんの「デザイン負債を返済する - クラシルのデザインの展望2020」という記事でした。

dely.design

目次

はじめに

10月の半ば辺りにRails6の複数機能を利用し、master/slave構成に対応した新規アプリケーションを本番リリースしました。

Rails5まではこのような対応する場合は他のgemを利用する必要がありましたが、これらのgemはActiveRecordの内部をオーバーライドしていたりするため、Railsのバージョンを上げた際に壊れるみたいなことはあるあるなのではないかと思います。

今回は新規アプリケーションということもあり、またRailsもちょうど6が出たタイミングだったため、gemを使うのではなくRailsの複数データの機能を利用してmaster/slave構成に対応することにしました。

基本的にRailsガイドに大抵の設定・実装項目は書いてあるのでそれを読みつつ実装することで組み込み自体はスムーズに行うことができました。

railsguides.jp

ただその一方で、一重にRails6の複数データベースといっても実態としては単にreader/writerへ外にもいくつかの機能が合わさっており、どの機能がどこに作用するかという部分がイマイチ明確ではなく混乱した部分もありました。

今回は自分なりに調べた複数データベースの仕組みや、導入した際にハマった部分を知見として共有できればと思っています。

複数データベースの仕組み

複数データベースに関連するActiveRecordの全体像

複数データベースを理解するにあたり、コネクション周りの全体像がいまいちよくわからなかったので全体図を作ってみました。

複数データベースの機能がActiveRecordのどの辺りに作用しているかという観点でまとめています。

紐付けは各要素同士の参照を表しています。

注: Rails 6.0.1時点でのものです。

f:id:jity:20191212174554p:plain

上記画像をもとにRails6の複数データベースの機能を大別すると以下のようになります。

  1. master/slave構成
    • コネクション自動振り分け機能(DatabaseSelector)
  2. 複数のデータベース利用

なおRails6.0ではシャーディングの機能はなく、シャーディングをしたい場合は依然としてoctopusのようなgemを利用するなど別途対応する必要があります。

今後機能入れる予定ではあるらしく、シャーディングを入れる準備段階の実装のPRなども上がっているようでした。

github.com

1. master/slave構成

上記の画像の①の部分を振り分ける機能に当たります。

writing/readingというロールに対して、ActiveRecord::ConnectionAdapters::ConnectionHandlerのインスタンスがそれぞれに作成されます。

またそれぞれのconnection_handlerの間にprevent_writesという参照がありますが、これはRDBへの書き込みをRails側で抑制する機能です。

実行スレッドに対して値が設定されます。

rails/connection_pool.rb at v6.0.1 · rails/rails · GitHub

つまり、状態としては以下の4通りがありえることになります。

  1. 向き先がWriter・書き込み可能
  2. 向き先がWriter・書き込み不可
  3. 向き先がReader・書き込み可能
  4. 向き先がReader・書き込み不可

通常は1と4の状態が利用され、2は書き込み直後の読み取り時などに利用されることになります。

3はその状態にはできますが、意味はありません。

利用方法

利用方法は以下のようにdatabase.ymlの各envの直下にwriter/reader名を記述し、ApplicationRecordにてconnects_toメソッドでreading/writingのロールをそれぞれのDBにマッピングして使います。

production:
  primary:
    <<: *default
    host: <%= ENV["DB_HOST"] %>
  primary_replica:
    <<: *default
    host: <%= ENV["DB_REPLICA_HOST"] %>
    replica: true

replica: trueにすると、ActiveRecordのConnectionAdapterに情報が渡され、そのコネクションを経由した書き込みクエリは実行できないようになります。

例えばMySQLでは

BEGIN,COMMIT,EXPLAIN,SELECT,SET,SHOW,RELEASE,SAVEPOINT,ROLLBACK,DESCRIBE,DESC,WITH以外が書き込みクエリに該当します。

rails/database_statements.rb at v6.0.1 · rails/rails · GitHub

モデルでの定義は以下のように抽象クラスに定義します。

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: { writing: :primary, reading: :primary_replica }
end

これでApplicationRecordのロード時にActiveRecord::Base.connection_handlersにwriting/readingのconnection_handlerが作成されます。

# - config.eager_load = false
# - bin/rails console実行直後(pry)

[Ruby:2.6.5][Rails:6.0.1] pry(main)> ActiveRecord::Base.connection_handlers.transform_values(&:class)
=> {:writing=>ActiveRecord::ConnectionAdapters::ConnectionHandler}
[Ruby:2.6.5][Rails:6.0.1] pry(main)> ApplicationRecord.connection_handlers.transform_values(&:class)
=> {:writing=>ActiveRecord::ConnectionAdapters::ConnectionHandler, :reading=>ActiveRecord::ConnectionAdapters::ConnectionHandler}
[Ruby:2.6.5][Rails:6.0.1] pry(main)> ActiveRecord::Base.connection_handlers.transform_values(&:class)
=> {:writing=>ActiveRecord::ConnectionAdapters::ConnectionHandler, :reading=>ActiveRecord::ConnectionAdapters::ConnectionHandler}

呼び出し方は以下のようになります。

ActiveRecord::Base.connected_to(role: :reading) do
  # 読み取り処理
end

ActiveRecord::Base.connected_to(role: :writing) do
  # 書き込み処理
end

接続しているコネクションもreadingロールとwritingロールで異なります

# default_connection_handlerはwritingロール
[Ruby:2.6.5][Rails:6.0.1] pry(main)> ActiveRecord::Base.connected_to(role: :reading) { ActiveRecord::Base.connection_pool.equal? ActiveRecord::Base.default_connection_handler.retrieve_connection_pool('primary') }
=> false

DatabaseSelectorの利用方法

上記で呼び出し処理を書きましたが、これを逐一実装の中で手書きするのは骨が折れますし、ヒューマンエラーも起きがちになりそうです。

そこでRailsはRackミドルウェアとしてDatabaseSelectorという仕組みを用意してくれています。

これは以下のような特性を持ちます。

  • HTTPリクエストがGET/HEADの場合はreadingロールを使う
  • GET/HEAD以外の場合はwritingロールを使う
    • writingへ向いてから一定時間内(デフォルト2秒)のリクエストに対しては、writingロールを使う
      • この間、書き込みは不可(prevent_writes == true)
      • この際、リクエスト元の判別はデフォルトでsession_store(cookie)を利用する
      • writingロールへの処理の最後に、session[:last_write]に現在時刻のタイムスタンプを挿入します

利用方法は以下のようにconfig/application.rbなどに設定します。

config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session

自動切り替えの仕組みやリクエスト判別の仕組みは自前で実装することも可能で、その際は上に設定する自作クラスに変更すればOKです。

2. 複数のデータベースの利用

前掲の画像の②の部分の機能に当たります。

こちらはmaster/slave切り替え機能とは異なり、別のデータベースを利用するための仕組みとなります。 例えば、foo_productionというメインのDBとbar_productionという別のDBを併用することができます。

内部的にはConnectionHandlerの先のConnectionPoolを切り替える仕組みになっています。 同一スレッド内でprevent_writesをtrueにした場合、primaryとAnimalBaseの両方に影響があります。

[Ruby:2.6.5][Rails:6.0.1] pry(main)> ActiveRecord::Base.connected_to(role: :writing, prevent_writes: true) do
[Ruby:2.6.5][Rails:6.0.1] pry(main)>   Foo.first
[Ruby:2.6.5][Rails:6.0.1] pry(main)>   Animal.create!
[Ruby:2.6.5][Rails:6.0.1] pry(main)> end
  Foo Load (2.0ms)  SELECT `foos`.* FROM `foos` ORDER BY `foos`.`id` ASC LIMIT 1
ActiveRecord::ReadOnlyError: Write query attempted while in readonly mode: INSERT INTO `animals` (`created_at`, `updated_at`) VALUES ('2019-12-11 12:31:37.696328', '2019-12-11 12:31:37.696328')

利用方法

こちらもdatabase.ymlにDB名を設定し、モデルでマッピングします。(Railsガイドと合わせて名前はanimalsとします)

production:
  animals:
    <<: *default
    host: <%= ENV["ANIMAL_DB_HOST"] %>
    migrations_paths: db/animals_migrate
  animals:
    <<: *default
    host: <%= ENV["ANIMAL_DB_REPLICA_HOST"] %>
    replica: true

migrations_pathsでmigrationファイルの置き場を分けることができます。

class AnimalBase < ApplicationRecord
  self.abstract_class = true

  connects_to database: { writing: :animals, reading: :animals_replica }
end

class Animal < AnimalBase
end

このように実装すると、紐づくConnectionPoolが異なるようになります。

[Ruby:2.6.5][Rails:6.0.1] pry(main)> Foo.connection_specification_name
=> "primary"
[Ruby:2.6.5][Rails:6.0.1] pry(main)> Animal.connection_specification_name
=> "AnimalBase"

[Ruby:2.6.5][Rails:6.0.1] pry(main)> ApplicationRecord.connection_pool.equal? Foo.connection_pool
=> true
[Ruby:2.6.5][Rails:6.0.1] pry(main)> ApplicationRecord.connection_pool.equal? Animal.connection_pool
=> false

またdatabase.ymlに追加するとdbコマンドにもanimals用のものが追加されます。

bin/rails -T | grep db:
rails db:create                          # Creates the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:create:all to create all databases in the config). Without RAILS_ENV or when RAILS_ENV is development, it defaults to creating the development and test databases
rails db:create:animals                  # Create animals database for current environment
rails db:create:primary                  # Create primary database for current environment
rails db:drop                            # Drops the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:drop:all to drop all databases in the config). Without RAILS_ENV or when RAILS_ENV is development, it defaults to dropping the development and test databases
rails db:drop:animals                    # Drop animals database for current environment
rails db:drop:primary                    # Drop primary database for current environment
rails db:environment:set                 # Set the environment value for the database
rails db:fixtures:load                   # Loads fixtures into the current environment's database
rails db:migrate                         # Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)
rails db:migrate:animals                 # Migrate animals database for current environment
rails db:migrate:primary                 # Migrate primary database for current environment
rails db:migrate:status                  # Display status of migrations
rails db:migrate:status:animals          # Display status of migrations for animals database
rails db:migrate:status:primary          # Display status of migrations for primary database
rails db:prepare                         # Runs setup if database does not exist, or runs migrations if it does
rails db:rollback                        # Rolls the schema back to the previous version (specify steps w/ STEP=n)
rails db:schema:cache:clear              # Clears a db/schema_cache.yml file
rails db:schema:cache:dump               # Creates a db/schema_cache.yml file
rails db:schema:dump                     # Creates a db/schema.rb file that is portable against any DB supported by Active Record
rails db:schema:load                     # Loads a schema.rb file into the database
rails db:seed                            # Loads the seed data from db/seeds.rb
rails db:seed:replant                    # Truncates tables of each database for current environment and loads the seeds
rails db:setup                           # Creates the database, loads the schema, and initializes with the seed data (use db:reset to also drop the database first)
rails db:structure:dump                  # Dumps the database structure to db/structure.sql
rails db:structure:load                  # Recreates the databases from the structure.sql file
rails db:version                         # Retrieves the current schema version number

利用する際は通常はconnects_toが設定されてあるモデル(上記の場合はAnimalクラス)をいつもどおり使います。

[Ruby:2.6.5][Rails:6.0.1] pry(main)> Animal.create
   (0.8ms)  BEGIN
  Animal Create (1.9ms)  INSERT INTO `animals` (`created_at`, `updated_at`) VALUES ('2019-12-11 13:20:03.478803', '2019-12-11 13:20:03.478803')
   (4.0ms)  COMMIT
=> #<Animal:0x00007ff4b817f890 id: 1, created_at: Wed, 11 Dec 2019 13:20:03 JST +09:00, updated_at: Wed, 11 Dec 2019 13:20:03 JST +09:00>
[Ruby:2.6.5][Rails:6.0.1] pry(main)> Animal.first
  Animal Load (2.9ms)  SELECT `animals`.* FROM `animals` ORDER BY `animals`.`id` ASC LIMIT 1
=> #<Animal:0x00007ff4b72f5b58 id: 1, created_at: Wed, 11 Dec 2019 13:20:03 JST +09:00, updated_at: Wed, 11 Dec 2019 13:20:03 JST +09:00>
root@localhost (13:20:44) [animal_development]> select * from animals;
+----+----------------------------+----------------------------+
| id | created_at                 | updated_at                 |
+----+----------------------------+----------------------------+
|  1 | 2019-12-11 13:20:03.478803 | 2019-12-11 13:20:03.478803 |
+----+----------------------------+----------------------------+
1 row in set (0.00 sec)

また一応conncted_toメソッドの引数としてdatabase引数を渡せるため、それ経由でActiveRecord::Base経由からもアクセスできます。(後述しますが非推奨です)

[Ruby:2.6.5][Rails:6.0.1] pry(main)> ActiveRecord::Base.connection.execute('select * from animals')
   (2.3ms)  select * from animals
ActiveRecord::StatementInvalid: Mysql2::Error: Table 'foo_development.animals' doesn't exist
[Ruby:2.6.5][Rails:6.0.1] pry(main)> ActiveRecord::Base.connected_to(database: :animals) do
[Ruby:2.6.5][Rails:6.0.1] pry(main)>   ActiveRecord::Base.connection.execute('select * from animals').to_a
[Ruby:2.6.5][Rails:6.0.1] pry(main)> end
   (4.9ms)  SET NAMES utf8mb4 COLLATE utf8mb4_general_ci,  @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'),  @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
   (1.8ms)  select * from animals
=> [[1, 2019-12-11 13:20:03 +0900, 2019-12-11 13:20:03 +0900]]

ただし、このアクセス方法にはいくつか問題があります。

一つは、内部でestablish_connectionが呼ばれてConnectionPoolの再生成処理が走ることです。これによりパフォーマンス劣化などの問題が懸念されます。

また、上記のブロックを抜けても自動でprimaryに接続が戻らないという問題もあります。

[Ruby:2.6.5][Rails:6.0.1] pry(main)> ActiveRecord::Base.connected_to(database: :animals) do
[Ruby:2.6.5][Rails:6.0.1] pry(main)>   ActiveRecord::Base.connection.execute('select * from animals').to_a
[Ruby:2.6.5][Rails:6.0.1] pry(main)> end
   (4.9ms)  SET NAMES utf8mb4 COLLATE utf8mb4_general_ci,  @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'),  @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
   (1.8ms)  select * from animals
=> [[1, 2019-12-11 13:20:03 +0900, 2019-12-11 13:20:03 +0900]]
[Ruby:2.6.5][Rails:6.0.1] pry(main)> Foo.first
  Foo Load (2.5ms)  SELECT `foos`.* FROM `foos` ORDER BY `foos`.`id` ASC LIMIT 1
ActiveRecord::StatementInvalid: Mysql2::Error: Table 'animal_development.foos' doesn't exist

そのため、自分でprimaryへ戻る処理を書く必要があります。

[Ruby:2.6.5][Rails:6.0.1] pry(main)> ActiveRecord::Base.connected_to(database: :animals) do
[Ruby:2.6.5][Rails:6.0.1] pry(main)>   ActiveRecord::Base.connection.execute('select * from animals').to_a
[Ruby:2.6.5][Rails:6.0.1] pry(main)> ensure
[Ruby:2.6.5][Rails:6.0.1] pry(main)>   config_hash = ActiveRecord::Base.resolve_config_for_connection(:primary)
[Ruby:2.6.5][Rails:6.0.1] pry(main)>   ActiveRecord::Base.establish_connection(config_hash)
[Ruby:2.6.5][Rails:6.0.1] pry(main)> end
   (1.9ms)  SET NAMES utf8mb4 COLLATE utf8mb4_general_ci,  @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'),  @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
   (1.4ms)  select * from animals
=> [[1, 2019-12-11 13:20:03 +0900, 2019-12-11 13:20:03 +0900]]
[Ruby:2.6.5][Rails:6.0.1] pry(main)> Foo.first
  Foo Load (1.5ms)  SELECT `foos`.* FROM `foos` ORDER BY `foos`.`id` ASC LIMIT 1
=> #<Foo:0x00007ff4c8476d68 id: 1, created_at: Wed, 11 Dec 2019 13:35:49 JST +09:00, updated_at: Wed, 11 Dec 2019 13:35:49 JST +09:00>

一応できるってだけで、基本的には事前にconnectes_toで設定していたモデルから参照するのが良さそうという所感です。

アプリケーションでの実際の実装

今回自分が担当した新規アプリケーションではmaster/slave機能のみを利用し、またDatabaseSelectorによる自動振り分け機能も利用しています。

基本的にDatabaseSelectorに乗っかる形で問題なく稼働できていますが、

  • GETリクエストで作成・更新したいケース
  • 更新処理はないが、POSTリクエストで大量にリクエストをさばきたいケース

という2つの例外ケースがアプリケーションの要件上一部存在してしまっています。

これらを解決するために、現状は以下のようなようなメソッドをコントローラに追加しています。

def with_reader(&block)
  ActiveRecord::Base.connected_to(role: :reading, &block)
end

def with_writer(&block)
  ActiveRecord::Base.connected_to(role: :writing, &block)
end

これらをaround_actionなどを利用して必要な箇所で呼び出すことによってDatabaseSelectorでまかない切れないケースに対応しています。

開発時にハマった箇所

POSTのあとのGETでの更新処理で競合が発生

アプリケーションの仕様として、POSTリクエストが走ったあとで、GETリクエストでデータベースに更新がかかるケースがあったのですが、その際に状況によってエラーが出たり出なかったりするという現象が起きていました。

これはDatabaseSelectorでPOSTリクエストのあとに2秒の間、Rackミドルウェア上でprevent_writes = trueがセットされており、またRailsアプリケーション側でそれをfalseにする処理を挟んでいなかったためにエラーが出たり出なかったりしていました。

つまり、POSTのあと2秒未満のGET更新の場合はエラーが発生し、2秒以上経過した後にGETリクエスト経由での更新処理の場合にエラーはでず、時間要因で結果が変わっているという状況でした。

これに関してはissueにて議論がなされていました。

github.com

またその結果としてv6.0.1ではconnected_toメソッドの引数にprevent_writesが追加されています

Call `while_preventing_writes` from `connected_to` by eileencodes · Pull Request #37065 · rails/rails · GitHub

ただし、今回の場合はバージョンアップまだ行えてなかったため、コントローラーの処理でwriterに向ける際には以下のようにprevent_writesにfalseを入れる処理を追加しました。

def with_writer(&block)
  ActiveRecord::Base.connected_to(role: :writing) do
    ActiveRecord::Base.connection_handler.while_preventing_writes(false, &block)
  end
end

Rails6.0.1では以下のように書けます。(prevent_writesオプションがデフォルトでfalseなので)

def with_writer(&block)
  ActiveRecord::Base.connected_to(role: :writing, &block)
end

readingロールに対して更新していることがテストで気付きにくい

以下のissueでも議論されていました。

github.com

Railsにはuse_transactional_testsというオプションがあり、これをtrueにしているとDBへの更新処理はCOMMITされず各example後にROLLBACKされます。

これによってテスト後に毎回DBをTRUNCATEする必要がなく、テストのパフォーマンスも向上するため、できるだけtrueにしたまま開発したいという気持ちがあります。

このオプションをONにすると内部的にはreadingロールのコネクションプールがwritingロールのコネクションプールにすり替わるようになります。

これによりCOMMITせずともwritingロールで更新を行ったデータをreadingロールでも読み取ることができるようになります。

rails/test_fixtures.rb at v6.0.1 · rails/rails · GitHub

その一方で、コネクションへreplicaフラグが渡されなくなるため、テスト中にreadingロールへの更新処理を行っていてもActiveRecord::ReadOnlyErrorが発生しなくなります。

そのため、readingロールへ更新処理を行っていることが自動テストでは検知できませんでした。

今回の自分のプロジェクトでは規模的には小さかったのもあり、実機検証するタイミングで検知するという方針をとって開発を進めました。

一応use_transactional_testsを必要なテストに挿入して対応することもできそうではありますが、それがどこに必要なのかを判断する基準は各開発者の意識に依存するため実運用は難しそうな印象です...

まとめ

詰まった箇所なども書きましたが、自分の担当していたアプリケーションでは概ね良好につかえていた印象です。

これから複数データベースを使おうと思ってる方の参考になれば幸いです。

最後に

明日は開発部SREの井上さんの記事です!お楽しみに。

qiita.com

adventar.org

また、delyではRailsエンジニアを絶賛募集中なので興味のある方は是非是非。

speakerdeck.com

www.wantedly.com

CXOとVPoEへのインタビュー記事もあります。

wevox.io