Subscribed unsubscribe Subscribe Subscribe

プライマリキーを使った1:1関連でカラム数の多いテーブルを分割する

おそらく多くのソーシャル系アプリにあてはまるRailsのプチ・デザインパターン的な話。

ぼくが今やっているEast Meet Eastには、ユーザごとに数多くのプロフィール属性があります。名前、性別、生年月日、郵便番号、職業などなど、カラム数にしてざっと25個。これを、全部ひとつのusersテーブルに詰め込むのは、コードの見通しという観点からも性能の観点からも、あまりよろしくありません。

なぜならば、ユーザ関連の情報を扱う局面としては主に

  • メールアドレスとパスワードなどを使ってログインする(アカウント情報)
  • プロフィール情報で条件を指定してユーザを検索・推薦する(プロフィール情報)

という2つの独立性の高いユースケースにわかれるため、ログイン処理をやってるときにはプロフィール情報はいらないし、プロフィールを検索してるときにはメールアドレスやパスワードをロードするのは無駄です。また、開発やデバッグ時に関心のない情報が大量についてくるのは純粋に邪魔です。

そこで、このテーブルをusersとprofilesという2つのテーブルに分割することを考えます。これを普通にhas_oneでやると

class User < ActiveRecord::Base
  has_one :profile
end

class Profile < ActiveRecord::Base
  belongs_to :user
end

としておいて、profilesテーブルにuser_idカラムを作って参照してやるのがrails的には普通だと思います。

しかし!

よく考えてみると、usersとprofilesは完全に1:1の関連であり、あたかも同一のテーブルをたまたまカラムのグループで2つに分割しただけの存在です。だとすると、プライマリキーさえ一致していれば、わざわざuser_idという外部キーを作ってやる必要はないはずです。

そこで、以下のようなパターンを使います。

class User < ActiveRecord::Base
  has_one :profile, foreign_key: 'id'
end

class Profile < ActiveRecord::Base
  belongs_to :user, foreign_key: 'id'
end

これだけで、usersとprofilesのプライマリキーが完全に一致するようになり、自分のidを使って相手を探しに行くようになります。余計な外部キーがいらなくなるので、インデックスも不要です。あらゆる点で無駄がなく、すごくスッキリしますね。

また、UserとProfileの両方にまたがる検索条件をつけての探索も、ActiveRecordのパワフルなスコーピングを使えば、まったく問題ありません。以下のようにjoinsmergeメソッドを組み合わせたスコープを定義してやることで、User側に定義したスコープをProfile側からも使えるようになります。

class Profile < ActiveRecord::Base
  scope :active, -> { joins(:user).merge(User.active) }
end

Profile.active

# SELECT `profiles`.* FROM `profiles`
# INNER JOIN `users` ON `users`.`id` = `profiles`.`id`
# WHERE `users`.`status` = 'active'

この考え方を応用すると、1:1でのテーブル分割が割と気軽にできるようになり、可能性が広がってきます。

たとえば、Rails 3.2から導入されたActiveRecord::Storeという、text型のカラムにyamljsonなどの塊を突っ込んでKVS的に使うという機能があるのですが、usersテーブルに直接このカラムを追加すると、User.findするたびにこのカラムの中身まで全部読むことになり、非常に無駄です。さらには、そのyamljsonの深い部分で変更があったかどうかをトラッキングする方法がないため、レコードの更新処理があるたびにyamljsonの全体を再ダンプして比較する検出処理が走ります。これは、textのサイズが大きくなってくると無視できないコストです。

ActiveRecord::Storeは、そもそもカラムに昇格させるほどではない「あまり重要ではないが参考のためにとっておく」程度の情報をまとめて突っ込んでおくための入れ物なので、普段は必要ないことのほうが多いでしょう。そうすると、このカラムだけを分離した別テーブルにすることも考えられます。

class User < ActiveRecord::Base
  has_one :user_store, foreign_key: 'id'
end

class UserStore < ActiveRecord::Base
  belongs_to :user, foreign_key: 'id'
  store :data
end

さらには、DeNAソーシャルゲームのためのMySQL入門その2で挙げられているテクニックですが、ソーシャルゲームなど更新頻度の高い用途の場合、更新パターンに応じたテーブル分割というのも有効です。参照中心のアカウント情報(メールアドレス、パスワード、トークンなど)とは別に、ユーザがログイン中にはひっきりなしに更新されるようなゲームデータ(ヒットポイント、経験値など)は、usersテーブルからは切り離して1行あたり数十バイト程度の小さい単独のテーブルにしておくと、同じページサイズにのる行数が増えるため、(適切なflush設定をしてあれば)更新処理をまとめて実行できる可能性が上がり、より少ないIOPSで大量の処理をさばけるようになります。

というわけで、has_oneforeign_key: 'id'を使う、というのは、非常に応用範囲の広い考え方なのでオススメです。

追記

いま一緒に仕事してる@onoさんに指摘されたのですが、上記のコード例は、常にUserとProfileを常に同時に挿入・削除する場合にのみ、両テーブルでauto incrementが連動するので有効です。途中からprofilesテーブルを追加するようなケースや、profiles側を削除→挿入することがあるようなケースでも問題なく動くようにするには、before_createで明示的に親からidのコピーを行っておきます。

class Profile < ActiveRecord::Base
  belongs_to :user, foreign_key: 'id'

  before_create do
    self.id = user.id
  end
end

もし不安なら、プライマリキーを使った1:1関連のテーブル分割で自動採番をしないようにする - かみぽわーるのように、idカラムのAUTO_INCREMENTを外しておくと、idが空のまま挿入しようとしたときに例外を投げてくれます。