2020-05-15

STIについて

最近STIが用いられている箇所を触ることがあったのですが、STIについては軽くしか学んだことがなく、適切な実装が行えたのかどうかいまだに判断がつかないので、今更ながら学習しなおしてみる
議論も含めてこの記事が参考になったので、この記事に書かれていることを中心に理解を深めていく

STIとは

  • 単一テーブル継承と呼ばれるもので、継承階層に所属するクラス群を、1つのテーブルを使って永続化する手法のこと
  • Rails独自のものではなく、ORマッピングのための汎用的な手法の1つ
  • 継承関係を持つクラスの属性をRDBにどのように永続化するか?というのが関心の対象であり、振る舞いについては関心の対象外である
  • STIは「継承」の実装であり、ポリモーフィックは「関連」の実装なので、比較対象としては適していない

STIを具体的に

以下のようなクラス階層があったとする

1

みんなRailsのSTIを誤解してないか!?より引用

  • 最上位スーパークラス Customer は全ての顧客に共通する属性を持つ(id/code/name)
  • Person などのサブクラスは、それぞれのクラス固有の属性を持つ
  • これらのクラス群のレコードが保存されるテーブルは、 customers テーブルであり、 customers テーブルの type カラムによって、どのサブクラスのレコードなのかを判定することができる↓
id type code name real_name corporate_number capital
1 Person C-001 山田商店 山田 太郎 null null
2 Corporation C-002 品川区役所 null 6000020131091 null
3 Company C-003 フリー株式会社 null 7010401100770 62億円
  • Railsにおいては Customer.all で全体が、 Person.alltypePerson のものだけを取得できる

上記記事に書かれているメリット/デメリットについて1つ1つチェックしていく

メリット

  • 必要なテーブルは1つのみで、サブクラスが増えてもテーブル数は増えない
    • ここでは customers 1つだけでOK
    • サブクラスが増えても type に増えたサブクラスのクラス名が入るレコードが増えるようになるだけ
  • 論理的なレコード全体を取得するのにジョイン不要
    • ここでいう「論理的なレコード全体」とは「そのサブクラスに必要な全ての属性」のことだと思っている
      • Person なら id, code, name, real_name が「論理的なレコード全体」
      • STIではテーブルにそれぞれのサブクラスに必要な属性が(必要・不要に関わらず)全て存在しているので、単にレコードを取得すれば全体が取得できる
    • STIを使わずにそれぞれのクラスに対応するテーブルを作成した場合、全体を取得する場合は外部キーなどを使用したJOINを行う必要がある
  • レコードのサブクラスの変更が容易
    • type をupdateするだけでOK
  • Railsでネイティブサポートされているのでコーディングが容易

デメリット

  • 特定のサブクラスに固有の属性に対してNOT NULL制約を適用できない
    • 例えば real_namePerson 固有の属性だが、もしここにNOT NULL制約を付与してしまうと、 Person 以外のレコードを保存する際、入れる必要がないのに何らかの値を入れることになってしまうため、どの固有の属性にもNOT NULL制約を追加することができない
  • 特定のサブクラスのみを参照すべき他テーブルの外部キー制約が、誤ったサブクラスを参照することを防げない
    • 1つのテーブルに複数のサブクラスのレコードが保持されているため、他テーブルに customer_id のような外部キー制約が存在した場合に、全てのサブクラスが参照される可能性が排除できない
  • テーブルのカラム数が多くなりやすい
    • サブクラスが増え、そのサブクラス固有の属性が増えれば増えるほど、カラムの数は増える
  • 一部のサブクラスでしか使われない列の値はNULLばかりとなり、見た目がスパースになる

ここまでSTIを眺めてみて

テーブル増やさないで済んだりJOINしなくて済んだりするので、リソースやSQLの速度面でもメリットは大きそうな印象
デメリットである、サブクラス固有のカラムにNOT NULL制約を指定できない点などはやはり気になるところではあるが、記事にあるように

「このカラムは○○の条件が満たされるときは必ず非NULLでなければならない」というのはよくある話しです。 例えば「ステータスが『完了』の場合は『完了日時』にタイムスタンプがセットされていなければならない」など。

この場合はDBには制約を追加することはできないのでアプリ側で保証する(RailsならValidation)しかないが、確かにこのようなパターンはよく見る
この辺りはチームやアプリの仕様での決めの問題だろうか

コメントの議論部分

コメント部分で色々な議論が行われているのを眺めた感想など

  • type カラムに文字列が入るのはあまりよくない
    • 「空間効率がよくない」というのは意味がわからなかったが、「カーディナリティが低いのでインデックス向きでない」というのは納得した
      • type カラムに保存されるデータの種類 = サブクラスの数なので、せいぜい〜10とかそんなものだと思うが、レコードの数に対してこの 10 というのは非常に少ない = カーディナリティが低い
      • DBのインデックスは基本的にカーディナリティが高い箇所に貼るのがよいとされているので、これは確かに向いていない
    • 「列方向の冗長・重複は、column-oriented DBMSでない(列方向にブロック圧縮がかけられない)一般のRDBMSに関していえば非常に大きなペナルティになります」
      • 詳しくはわからないんだけど、これは覚えておかなければならなそう(DBをもっと勉強しなければ)
      • ちなみにPostgreSQLはver9.4以降は行指向(row-oriented)のDBなので注意が必要(wiki)
    • 「RailsのSTIではサブクラスでリレーションを呼ぶと必ず type = 'Customer' が入るので、すべてのインデックスで常に含める必要があるが、typeカラムはカーディナリティが低い(この例だと4種類しかないのでテーブルを4分割しかできない = オプティマイザがフルスキャンを選択することも多い)のでかなり無駄」
      • 例えば Person.first とクエリを発行した場合、SQLは SELECT "customers".* FROM "customers" WHERE "customers"."type" = $1" ASC LIMIT $2 [["type", "Person"], ["LIMIT", 1]] みたいなものが発行されるので、typeによるインデックスは必要になる
      • が、↑のカーディナリティの件があるので、レコード数次第ではパフォーマンスが致命的かもしれない
    • 「テーブル本体はNULLでスパースだけれどもインデックスだけは大きく、テーブル本体の3倍以上のスペースをインデックスが消費しているというようなことも珍しくない」
      • あまり意味のないデータのために、貴重なリソースを大量消費するのはこれまたコスト面で相当厳しそう
  • サードパーティgemとの相性
    • これ怖いなー

全体的な感想

STIはメリットもあるが、デメリットもそれなりにありそう
コードを綺麗に保てるのは大きなメリットだが、DBのパフォーマンスは無視できないので、使用するときはよく検討した方がいいかもしれない
type に文字列が入るのがよくないのはポリモーフィック関連(〇〇_type)でも同じなので覚えておきたいところ

enumは比較検討する価値はありそうなんだけど、enumってことは customer.person? みたいにして条件を分岐させるってことだよな?
ということはやはりテーブルは1つだけになるので、STIの時のデメリットも一部そのまま残るのでは...?と思ったんだけどどうなんでしょ?
レコード数が膨大になることがわかっているのであれば、オブジェクト指向的なメリットは多少目を瞑り、最初からテーブルを分割しておくのも悪くないのかもしれない

選択肢は多いに越したことはないので、しっかりメリット・デメリット把握して然るべき時に使えるようにしておこう
設計ってむずかしいなー

参考