これは、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
おそらくこんな画面が見えるはず。
登録してみる
Officeを登録しつつ、Itemのテキストフィールドに投稿すると各シャード先に書き込まれます。
やったぜ。
shard_one
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発番ロジックにも考慮が必要でしょう。
なお今回のサンプルアプリはこちらにアップロードしておきました。もしこの記事が気に入ったらスターをヨロシクオネガイシマス。
今回はテストコードを書いてなかったり最初からデータがある前提だったりと荒削りですが、最初のステップとしてこちらが参考になれば幸いです。