こんにちは、開発部の高橋です。 本記事は dely Advent Calendar 2019 の14日目の記事です。 昨日はミカサ(acke_red)さんの「デザイン負債を返済する - クラシルのデザインの展望2020」という記事でした。 note.com 目次 目次 はじめに 複数データベースの仕組み 複数データベースに関連するActiveRecordの全体像 1. master/slave構成 利用方法 DatabaseSelectorの利用方法 2. 複数のデータベースの利用 利用方法 アプリケーションでの実際の実装 開発時にハマった箇所 POSTのあとのGETでの更新処理で競合が発生 readingロールに対して更新していることがテストで気付きにくい まとめ 最後に はじめに 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時点でのものです。 上記画像をもとにRails6の複数データベースの機能を大別すると以下のようになります。 master/slave構成 コネクション自動振り分け機能(DatabaseSelector) 複数のデータベース利用 なお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通りがありえることになります。 向き先が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] に現在時刻のタイムスタンプを挿入します 利用方法は以下のように 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