VTRyo Blog

一歩ずつ前に進むブログ

ローカル開発環境の Rails 7.x × MySQLで水平シャーディングをやってみる

これは、Money Forward Engineering 1 Advent Calendar 2022 20日目の記事です。

今回はローカルでRails7.xとMySQLでHorizontal Shardingを試したので、かんたんに試せる例を書いておこうと思います。

前提

動作確認環境

  • Rails 7.0.4
  • Ruby 3.0.2p107
  • MySQL Ver 8.0.30

対象読者

rails newしたことがある前提になります。

  • Railsでの水平シャーディングに興味がある人
  • ローカルでさっと確認してみたい人

準備

rails newして、Itemと事業者を登録するだけのかんたんなAppを例にします。Itemを登録する先を、事業者id別で振り分けることにします。シャーディングの挙動を確認するだけのAppなので、実装面での細かいことは気にしないでおきましょう。

概要

水平シャーディングには、Multiple Databases with Active Record — Ruby on Rails Guidesを利用します。基本的にはこのガイドに沿う形なので、細かい部分はこちらを読むことを推奨します。

  • Database
database 用途
shard_app_primary_development マスターDBの役割。事業者情報、ID発番、シャード管理の役割がメイン
shard_app_shard_one_development Item保存の役割がメイン
shard_app_shard_two_development Item保存の役割がメイン
  • Table
Table column 用途 備考
offices id:integer, name:string 事業者情報
items id:integer, name:string, office_id:integer アイテム情報 オートインクリメント無効化
id_tables id:integer, table_name:string ID管理 オートインクリメント無効化
shard_manages id:integer, office_id:integer, shard_name:string シャード管理 いわゆるLookupテーブル
  • フロー

2つのシャーディング用データベースを用意し、それぞれに対して書き込みが行われるようにします。 シャーディングロジックはランダムではなく、Lookupテーブルとして作成したshard_managesテーブルに基づいて判定させます。

つくる

初期設定

Rails app

● rails newする
● dabase.ymlに接続先データベースを定義する
● データベースをつくる

まずは作業ディレクトリを作っておきます。

$ mkdir shard_app && cd shard_app
$ bundle init

必要なgemを有効にしておきます。

gem 'rails', '~> 7.0.4'
gem 'mysql2'

installしてappを作ります。

$ bundle install
$ rails new .
  • database.ymlでデータベース設定を用意します
# database.yml
default: &default
  adapter: mysql2
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000
  username: root
  password:
  host: localhost

development:
  primary:
    <<: *default
    database: shard_app_primary_development
  shard_one:
    <<: *default
    database: shard_app_shard_one_development
  shard_two:
    <<: *default
    database: shard_app_shard_two_development

なお、一番上に記述したprimaryのデータベースがデフォルトで使用されます。名前は何でもよさそうです。

If a primary configuration is provided, it will be used as the "default" configuration. If there is no configuration named "primary", Rails will use the first configuration as default for each environment. The default configurations will use the default Rails filenames. For example, primary configurations will use schema.rb for the schema file, whereas all the other entries will use [CONFIGURATION_NAMESPACE]_schema.rb for the filename.

Multiple Databases with Active Record — Ruby on Rails Guides

  • modelを用意します
$ rails g model Office name:string
$ rails g model Item name:string office_id:integer
$ rails g model IdTable table_name:string
$ rails g model ShardManage office_id:integer shard_name:string
  • createとmigrationします

idをオートインクリメントしたくないテーブルがあるので、migrationファイルで修正をしておきます。

# xxxxxxxxxx_create_id_tables.rb
class CreateIdTables < ActiveRecord::Migration[7.0]
  def change
    create_table :id_tables, id: false do |t| # id: false
      t.column :id, 'BIGINT PRIMARY KEY' # BIGINTにしておく
      t.string :table_name

      t.timestamps
end
# xxxxxxxxxx_create_items.rb
class CreateItems < ActiveRecord::Migration[7.0]
  def change
    create_table :items, id: false do |t|
      t.column :id, 'BIGINT PRIMARY KEY'
      t.string :name
      t.integer :office_id

      t.timestamps
    end
  end
end

あとはcreateとmigrateします。

$ rails db:create
$ rails db:migrate

補足: 個別にmigrationしたいときは?

なお、データベース個別にmigrateすることも可能です。その場合の指定方法はdatabase.ymlに定義した名前をコロンでつなぎます。

rails db:migrate:primary

VERSIONを指定することもできます。

$ rails db:migrate:primary VERSION=20221213051733
== 20221213051733 CreateIdTables: migrating ===================================
-- create_table(:id_tables)
   -> 0.0300s
== 20221213051733 CreateIdTables: migrated (0.0301s) ==========================

接続関連

● application_record.rbにshard keyを定義する
● どのoffice_idをどのシャードに振り分けるかのデータを用意する

  • application_record.rbに接続先情報を記述する

database.ymlに定義したprimaryとなるデータベースをdefaultのshard keyと定義します。ほかも同様に、shard_one,shard_twoとします。これはアプリケーション内部で使うkeyです。

# application_record.rb
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to shards: {
    default: { writing: :primary },
    shard_one: { writing: :shard_one },
    shard_two: { writing: :shard_two }
  }
end
  • primaryのデータベースに対してデータを予め用意

今回は最初から振り分け用のデータを用意します。rails consoleに入ります。

$ rails c

shard_app_shard_one_development, shard_app_shard_two_developmentへの振り分けデータを作成します。

office_idが1であればshard_oneへ。 office_idが2であればshard_twoへ書き込んでもらうとしましょう。

# それぞれrails consoleで実行してデータを作っておく
 ActiveRecord::Base.connected_to(shard: :default) do
   ShardManage.create!(office_id: 1, shard_name: 'shard_one')
end

 ActiveRecord::Base.connected_to(shard: :default) do
   ShardManage.create!(office_id: 2, shard_name: 'shard_two')
end

実行結果の例

irb(main):045:1* ActiveRecord::Base.connected_to(shard: :default) do
irb(main):046:1*   ShardManage.create!(office_id: 2, shard_name: 'shard_two')
irb(main):047:0> end
  TRANSACTION (0.1ms)  BEGIN
  ShardManage Create (0.2ms)  INSERT INTO `shard_manages` (`office_id`, `shard_name`, `created_at`, `updated_at`) VALUES (2, 'shard_two', '2022-12-13 08:17:49.682531', '2022-12-13 08:17:49.682531')
  TRANSACTION (0.5ms)  COMMIT
=>
#<ShardManage:0x00007f8066f62c58
 id: 2,
 office_id: 2,
 shard_name: "shard_two",
 created_at: Tue, 13 Dec 2022 08:17:49.682531000 UTC +00:00,
 updated_at: Tue, 13 Dec 2022 08:17:49.682531000 UTC +00:00>

select * from shard_manages;の結果の例

+----+-----------+------------+----------------------------+----------------------------+
| id | office_id | shard_name | created_at                 | updated_at                 |
+----+-----------+------------+----------------------------+----------------------------+
| 1  | 1         | shard_one  | 2022-12-13 08:15:30.735507 | 2022-12-13 08:15:30.735507 |
| 2  | 2         | shard_two  | 2022-12-13 08:17:49.682531 | 2022-12-13 08:17:49.682531 |
+----+-----------+------------+----------------------------+----------------------------+
  • id_tablesにもサンプルデータを作成しておきます
irb(main):014:1* ActiveRecord::Base.connected_to(shard: :default) do
irb(main):015:1*   IdTable.create!({id: 1111, table_name: "items"})
irb(main):016:0> end
^C
irb(main):014:0> IdTable.all
  IdTable Load (0.3ms)  SELECT `id_tables`.* FROM `id_tables`
=>
[#<IdTable:0x00007f80361dc9b0
  id: 1111,
  table_name: "items",
  created_at: Tue, 13 Dec 2022 08:01:24.112382000 UTC +00:00,
  updated_at: Tue, 13 Dec 2022 08:01:24.112382000 UTC +00:00>]

これで、どれをどこへ書き込ませるかの準備ができました。

データを登録できるようにする

itemとofficeを登録できるようにしていきます。

$ rails g controller offices new create index
$ rails g controller items new create index
  • view
<!-- view/offices/new..erb -->
<h1>Office New</h1>

<%= form_for(@office) do |o| %>
  <div>Input Office Name <%= o.text_field :name %></div>
  <%= o.submit "投稿" %>
<% end %>
<!-- view/offices/index..erb -->
<h1>Offices</h1>
  <ul>
    <%= @offices.each do |office| %>
    <li><%= office.name %></li>
    <% end %>
  </ul>
<!-- view/items/new..erb -->
<h1>Item New</h1>

<%= form_for(@item) do |i| %>
  <div>Input Item Name <%= i.text_field :name %></div>
  <div>Input Office_id <%= i.text_field :office_id %></div>
  <%= i.submit "投稿" %>
<% end %>
<!-- view/items/index..erb -->
<h1>Items</h1>
  <ul>
    <%= @items.each do |item| %>
    <li><%= item.name %></li>
    <% end %>
  </ul>
  • controller
# offices_controller.rb
class OfficesController < ApplicationController
  def new
    @office = Office.new
  end

  def create
    @office = Office.create(office_params)
    redirect_to offices_path
  end

  def index
    @offices = Office.all
  end

  def destroy
  end

  private

  def office_params
    params.require(:office).permit(:name)
  end
end

items_controllerの詳細については後述します。

# items_controller.rb
class ItemsController < ApplicationController
  def new
    @item = Item.new
  end

  def create
    create_item_id

    ActiveRecord::Base.connected_to(role: :writing, shard: shard_key) do
      @item = Item.create({
                            id: item_id,
                            name: item_params[:name],
                            office_id: item_params[:office_id]
                          })
    end

    redirect_to items_path
  end

  def index
    # 振り分けられてるのかみたいのでshard_oneにしておく
    ActiveRecord::Base.connected_to(shard: :shard_one) do 
      @items = Item.all
    end
  end

  private


  def create_item_id
    latest_id = IdTable.last.id + 1
    ActiveRecord::Base.connected_to(shard: :default) do
      IdTable.create!(id: latest_id, table_name: "items")
    end
  end

  def item_id
    ActiveRecord::Base.connected_to(shard: :default) do
      IdTable.where(table_name: 'items').last.id
    end
  end

  def shard_key
    ActiveRecord::Base.connected_to(shard: :default) do
      shard_key = ShardManage.find_by(office_id: item_params[:office_id]).shard_name
      shard_key.to_sym
    end
  end
  
  def item_params
    params.require(:item).permit(:name, :office_id)
  end

end
  • routes.rb

ここは重要ではないので、ざっくりresourcesで定義しておきます。

# routes.rb
Rails.application.routes.draw do
  resources :offices
  resources :items
end
  • rails serverで確認する
$ rails s

おそらくこんな画面が見えるはず。

Item New
Office New

登録してみる

Officeを登録しつつ、Itemのテキストフィールドに投稿すると各シャード先に書き込まれます。

やったぜ。

shard_one

shard_one

shard_two

shard_two

id生成とシャード先の決定

items_controllerで実施していた内容を解説します。

# items_controller.rb
class ItemsController < ApplicationController
# 一部省略
def create
    create_item_id

    ActiveRecord::Base.connected_to(role: :writing, shard: shard_key) do
      @item = Item.create({
                            id: item_id,
                            name: item_params[:name],
                            office_id: item_params[:office_id]
                          })
    end

    redirect_to items_path
  end

  private

  def create_item_id
    latest_id = IdTable.last.id + 1
    ActiveRecord::Base.connected_to(shard: :default) do
      IdTable.create!(id: latest_id, table_name: "items")
    end
  end

  def item_id
    ActiveRecord::Base.connected_to(shard: :default) do
      IdTable.where(table_name: 'items').last.id
    end
  end

  def shard_key
    ActiveRecord::Base.connected_to(shard: :default) do
      shard_key = ShardManage.find_by(office_id: item_params[:office_id]).shard_name
      shard_key.to_sym
    end
  end

create実行時に、item_idを発行します。 latest_idで最後のレコードから+1してますが、とりあえずの動作検証なのでレコードがある前提です。 table_nameカラムは、どのテーブルに関する記録かを保存するために追加してます。この場合、itemsテーブルに書き込むのでitemsです。

ActiveRecord::Base.connected_to(shard: :default) doは書かなくてもdefaultを見ますが、分かりづらいので書いています。

shard_keyでは、最初に追加したshard_managesテーブルからシャードの名前を取ってきて、最終的にSymbolでreturnさせています。

# exmaple
irb(main):097:0> ShardManage.find_by(office_id: 1).shard_name
  ShardManage Load (0.4ms)  SELECT `shard_manages`.* FROM `shard_manages` WHERE `shard_manages`.`office_id` = 1 LIMIT 1
=> "shard_one"
irb(main):098:0> ShardManage.find_by(office_id: 2).shard_name
  ShardManage Load (0.4ms)  SELECT `shard_manages`.* FROM `shard_manages` WHERE `shard_manages`.`office_id` = 2 LIMIT 1
=> "shard_two"

こんな感じで、発番したidを差し込みつつ各シャード先に書き込みに行かせるのでした。

まとめ

ローカルでさくっと水平シャーディングの検証をしてみたかったのでやってみました。 実際のアプリケーションで導入するとなると、joinを始めとした課題があったりid発番ロジックにも考慮が必要でしょう。

なお今回のサンプルアプリはこちらにアップロードしておきました。もしこの記事が気に入ったらスターをヨロシクオネガイシマス。

今回はテストコードを書いてなかったり最初からデータがある前提だったりと荒削りですが、最初のステップとしてこちらが参考になれば幸いです。

その他

unsplash.com