Railsで実現するデータベース負荷分散とパフォーマンス向上

# はじめに
ある日、運用しているRailsアプリケーションでアクセスが予想外に増えたことでデータベースが悲鳴を上げ、サービスが不安定になってしまいました。
コスト面の観点からなるべくスケールはしたくなかったので、Railsの機能を使ってデータベースアクセスの負荷を分散しました。

この記事では、その体験を踏まえつつ、Railsでのデータベース負荷分散の基本的な概念や実装方法について、Rails6以前とRails6以降の違いを含めてまとめていきたいと思います。

# Railsでのデータベース負荷分散
Railsのデフォルト設定では、**1つのプライマリデータベース**(以下プライマリとします)に接続する仕組みになっていますが、そのままだとアクセスが増えたときにプライマリに負荷が集中し、レスポンスが遅くなる恐れがあります。

これを解決する方法として、プライマリと読み取り専用のレプリカを使った**プライマリ/レプリカ構成**を採用するのが有効です。
これによってデータベースへのアクセスを効率よく分散させることができ、以下のメリットが得られます。

- **パフォーマンスの向上**
処理速度が速くなり、レスポンスの遅延が減ることが期待できます。
- **可用性の向上**
もしプライマリに障害が発生しても、読み取り処理が止まりにくく、システム全体の安定性が担保されます。

# 具体的な導入手順
Rails6以前と6以降とでアプローチが変わってきます。

:::note info
Rails6以降ではActiveRecordがマルチデータベース構成をサポートしているため、標準機能を優先するのが一般的です。
一方で、Rails6以前のプロジェクトや、標準機能でカバーしづらい特殊な要件がある場合などは、switch_pointのようなGemの活用を検討すると良いかと思います。
:::

## Rails6以前：switch_pointによる負荷分散
switch_pointのGemを利用するといいでしょう。

### switch_pointとは
https://github.com/eagletmt/switch_point

処理によってプライマリとレプリカを切り替えられるRailsのGemで、これを使用することで、書き込み操作はプライマリに、読み取り操作はレプリカに振り分けることができます。

### 設定方法

1.Gemを追加します。
```ruby:Gemfile
# gem 'switch_point'
```

2.switch_pointの設定を追加します。
```ruby:config/switch_point.yml
SwitchPoint.configure do |config|
  config.define_switch_point :default,
    readonly: :"#{Rails.env}_read",
    writable: :"#{Rails.env}_write"
end
```
3.database.ymlに接続情報を設定します。
```ruby:database.yml
default: &default
  adapter: mysql2
  username: <%= ENV['DATABASE_USERNAME'] %>
  password: <%= ENV['DATABASE_PASSWORD'] %>
  host: <%= ENV['DATABASE_HOST'] %>
  database: <%= ENV['DATABASE_NAME'] %>
  encoding: utf8

# Development環境
development:
  <<: *default

development_read:
  <<: *default
  host: <%= ENV['DATABASE_READ_HOST_DEVELOPMENT'] %>

# Staging環境
staging:
  <<: *default
  host: <%= ENV['DATABASE_HOST_STAGING'] %>

staging_read:
  <<: *default
  host: <%= ENV['DATABASE_READ_HOST_STAGING'] %>

# Production環境
production:
  <<: *default

production_read:
  <<: *default
  host: <%= ENV['DATABASE_READ_HOST_PRODUCTION'] %>
```

設定としてはこれでOKで、
例えばモデルでswitch_pointを利用する場合は、以下のように記述します。

```ruby:User.rb
class User < ApplicationRecord
  use_switch_point :user
end

# 書き込み
User.use(:writable) do
  User.create!(name: "test_user")
end

# 読み取り
User.use(:readonly) do
  User.find_by(name: "test_user")
end
```

これで、書き込みはプライマリへ、読み取りはレプリカへ自動的に分散されます。

:::note warn
レプリカはプライマリとの同期遅延が発生する(レプリカでのデータ取得が数秒遅れたりする)可能性があるため、直前の書き込み結果を即座に取得するケースでは注意が必要です。
また、最新データが必要な場合も、レプリカでは情報が古くなることもあるためプライマリを利用する方が安全かもしれません。
:::

コントローラーで全体的にindexメソッドのみを読み取り専用にする場合は以下のような記述します。

```ruby:ApplicationController
class ApplicationController < ActionController::Base
  around_action :use_readonly_index

  private

  def use_readonly_index
    if action_name == 'index'
      # 読み取り専用DBで処理を実行
      ActiveRecord::Base.use(:readonly) do
        yield
      end
    else
      yield
    end
  end
end
```

indexアクション以外の条件分岐が不要な場合は
```ruby:ApplicationController
class ApplicationController < ActionController::Base
  around_action :use_readonly_index, only: :index

  private

  def use_readonly_index
    ActiveRecord::Base.use(:readonly) do
      yield
    end
  end
end
```
でもいいですね。

:::note warn
switch_pointで接続を切り替えた時に接続がプールに保持されることで意図しないプライマリやレプリカへの接続が残ってしまい、次のデータベース操作に影響を与える可能性があります。
その場合は、必要に応じてclear_active_connections!を呼び出して、使い終わった接続をプールから明示的にクリアすることで、意図しない接続が残らないようにすることができます。
:::
・ApplicationControllerでクリアする例

```ruby:ApplicationController
class ApplicationController < ActionController::Base
  around_action :use_readonly_index

  private

  def use_readonly_index
    if action_name == 'index'
      # 読み取り専用DBで処理を実行
      ActiveRecord::Base.use(:readonly) do
        yield
      end
    else
      yield
    end
  ensure
    # 処理後に使い終わった接続を明示的にクリア
    ActiveRecord::Base.clear_active_connections!
  end
end
```


## Rails6以降：ActiveRecordのマルチデータベース機能
Rails6以降では、ActiveRecordがマルチデータベース構成をデフォルトでサポートしています。
この機能を活用することで、外部Gemを利用せずにデータベース負荷分散が可能になります。

https://railsguides.jp/v8.0/active_record_multiple_databases.html


### 設定方法
- データベース設定
```ruby:config/database.yml
default: &default
  adapter: mysql2
  username: <%= ENV['DATABASE_USERNAME'] %>
  password: <%= ENV['DATABASE_PASSWORD'] %>
  encoding: utf8

# Development環境
development:
  primary:
    <<: *default
    host: <%= ENV['DATABASE_HOST_DEVELOPMENT'] %>
    database: <%= ENV['DATABASE_NAME_DEVELOPMENT'] %>
  replica:
    <<: *default
    host: <%= ENV['DATABASE_READ_HOST_DEVELOPMENT'] %>
    database: <%= ENV['DATABASE_NAME_DEVELOPMENT'] %>

# Staging環境
staging:
  primary:
    <<: *default
    host: <%= ENV['DATABASE_HOST_STAGING'] %>
    database: <%= ENV['DATABASE_NAME_STAGING'] %>
  replica:
    <<: *default
    host: <%= ENV['DATABASE_READ_HOST_STAGING'] %>
    database: <%= ENV['DATABASE_NAME_STAGING'] %>

# Production環境
production:
  primary:
    <<: *default
    host: <%= ENV['DATABASE_HOST_PRODUCTION'] %>
    database: <%= ENV['DATABASE_NAME_PRODUCTION'] %>
  replica:
    <<: *default
    host: <%= ENV['DATABASE_READ_HOST_PRODUCTION'] %>
    database: <%= ENV['DATABASE_NAME_PRODUCTION'] %>
```

ActiveRecordのconnected_toメソッドを使って接続を切り替えます。

```ruby:Article.rb
# 書き込み
Article.connected_to(role: :writing) do
  Article.create!(title: "New Article")
end

# 読み取り
Article.connected_to(role: :reading) do
  Article.find_by(title: "New Article")
end
```

# Amazon Auroraの注意点
https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/Aurora.Integrating.AutoScaling.html

Amazon Auroraを使っている場合ですが、ライター(プライマリ)インスタンスは1つで稼働していてスケールアウトができないため、どうしても負荷が集中しがちです。
ですが、リーダー(レプリカ)インスタンスはスケールアウトできるので、リーダーの数を増やしたりして読み取りの負荷をうまく分散できます。

Aurora×Railsでデータベースの負荷分散を考えるときは、ライターインスタンスにかかる負担を減らして、リーダーインスタンスをうまく使うのがポイントです。


# さいごに
データベース負荷分散は、Railsアプリケーションのパフォーマンスと安定性を向上させる上で非常に有効です。
特にアクセスが増加しているアプリケーションや、読み取り操作が多いシステムには効果的かと思います。

Railsで今後負荷分散を実施する場合のご参考になれば幸いです。


# 関連リンク
[Railsガイド: マルチデータベース構成](https://railsguides.jp/v8.0/active_record_multiple_databases.html)
[switch_pointのGem](https://github.com/eagletmt/switch_point)
[Auroraのオートスケール](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/Aurora.Integrating.AutoScaling.html)