こんにちは、開発部の高橋です。
本記事はdely Advent Calendar 2019の14日目の記事です。
昨日はミカサ(acke_red)さんの「デザイン負債を返済する - クラシルのデザインの展望2020」という記事でした。
目次
はじめに
10月の半ば辺りにRails6の複数機能を利用し、master/slave構成に対応した新規アプリケーションを本番リリースしました。
Rails5まではこのような対応する場合は他のgemを利用する必要がありましたが、これらのgemはActiveRecordの内部をオーバーライドしていたりするため、Railsのバージョンを上げた際に壊れるみたいなことはあるあるなのではないかと思います。
今回は新規アプリケーションということもあり、またRailsもちょうど6が出たタイミングだったため、gemを使うのではなくRailsの複数データの機能を利用してmaster/slave構成に対応することにしました。
基本的にRailsガイドに大抵の設定・実装項目は書いてあるのでそれを読みつつ実装することで組み込み自体はスムーズに行うことができました。
ただその一方で、一重にRails6の複数データベースといっても実態としては単にreader/writerへ外にもいくつかの機能が合わさっており、どの機能がどこに作用するかという部分がイマイチ明確ではなく混乱した部分もありました。
今回は自分なりに調べた複数データベースの仕組みや、導入した際にハマった部分を知見として共有できればと思っています。
複数データベースの仕組み
複数データベースに関連するActiveRecordの全体像
複数データベースを理解するにあたり、コネクション周りの全体像がいまいちよくわからなかったので全体図を作ってみました。
複数データベースの機能がActiveRecordのどの辺りに作用しているかという観点でまとめています。
紐付けは各要素同士の参照を表しています。
注: Rails 6.0.1時点でのものです。
上記画像をもとにRails6の複数データベースの機能を大別すると以下のようになります。
- master/slave構成
- コネクション自動振り分け機能(DatabaseSelector)
- 複数のデータベース利用
なおRails6.0ではシャーディングの機能はなく、シャーディングをしたい場合は依然としてoctopusのようなgemを利用するなど別途対応する必要があります。
今後機能入れる予定ではあるらしく、シャーディングを入れる準備段階の実装のPRなども上がっているようでした。
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通りがありえることになります。
- 向き先がWriter・書き込み可能
- 向き先がWriter・書き込み不可
- 向き先がReader・書き込み可能
- 向き先が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]
に現在時刻のタイムスタンプを挿入します
- writingへ向いてから一定時間内(デフォルト2秒)のリクエストに対しては、writingロールを使う
利用方法は以下のように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にて議論がなされていました。
またその結果としてv6.0.1ではconnected_to
メソッドの引数にprevent_writes
が追加されています
ただし、今回の場合はバージョンアップまだ行えてなかったため、コントローラーの処理で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でも議論されていました。
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の井上さんの記事です!お楽しみに。
また、delyではRailsエンジニアを絶賛募集中なので興味のある方は是非是非。
CXOとVPoEへのインタビュー記事もあります。