STIについて
最近STIが用いられている箇所を触ることがあったのですが、STIについては軽くしか学んだことがなく、適切な実装が行えたのかどうかいまだに判断がつかないので、今更ながら学習しなおしてみる
議論も含めてこの記事が参考になったので、この記事に書かれていることを中心に理解を深めていく
STIとは
- 単一テーブル継承と呼ばれるもので、継承階層に所属するクラス群を、1つのテーブルを使って永続化する手法のこと
- Rails独自のものではなく、ORマッピングのための汎用的な手法の1つ
- 継承関係を持つクラスの属性をRDBにどのように永続化するか?というのが関心の対象であり、振る舞いについては関心の対象外である
- STIは「継承」の実装であり、ポリモーフィックは「関連」の実装なので、比較対象としては適していない
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.all
でtype
がPerson
のものだけを取得できる
上記記事に書かれているメリット/デメリットについて1つ1つチェックしていく
メリット
- 必要なテーブルは1つのみで、サブクラスが増えてもテーブル数は増えない
- ここでは
customers
1つだけでOK - サブクラスが増えても
type
に増えたサブクラスのクラス名が入るレコードが増えるようになるだけ
- ここでは
- 論理的なレコード全体を取得するのにジョイン不要
- ここでいう「論理的なレコード全体」とは「そのサブクラスに必要な全ての属性」のことだと思っている
Person
ならid, code, name, real_name
が「論理的なレコード全体」- STIではテーブルにそれぞれのサブクラスに必要な属性が(必要・不要に関わらず)全て存在しているので、単にレコードを取得すれば全体が取得できる
- STIを使わずにそれぞれのクラスに対応するテーブルを作成した場合、全体を取得する場合は外部キーなどを使用したJOINを行う必要がある
- 記事にある「Class Table Inheritance / クラステーブル継承」がまさにそれで、以下のようなテーブル構成なので、
customer_id
によるJOINを行わなければ「論理的なレコード全体」は取得できない - みんなRailsのSTIを誤解してないか!?より引用
- 記事にある「Class Table Inheritance / クラステーブル継承」がまさにそれで、以下のようなテーブル構成なので、
- ここでいう「論理的なレコード全体」とは「そのサブクラスに必要な全ての属性」のことだと思っている
- レコードのサブクラスの変更が容易
type
をupdateするだけでOK
- Railsでネイティブサポートされているのでコーディングが容易
- 公式ガイドにも解説があるのでRails wayに乗れる
デメリット
- 特定のサブクラスに固有の属性に対してNOT NULL制約を適用できない
- 例えば
real_name
はPerson
固有の属性だが、もしここにNOT NULL制約を付与してしまうと、Person
以外のレコードを保存する際、入れる必要がないのに何らかの値を入れることになってしまうため、どの固有の属性にもNOT NULL制約を追加することができない
- 例えば
- 特定のサブクラスのみを参照すべき他テーブルの外部キー制約が、誤ったサブクラスを参照することを防げない
- 1つのテーブルに複数のサブクラスのレコードが保持されているため、他テーブルに
customer_id
のような外部キー制約が存在した場合に、全てのサブクラスが参照される可能性が排除できない
- 1つのテーブルに複数のサブクラスのレコードが保持されているため、他テーブルに
- テーブルのカラム数が多くなりやすい
- サブクラスが増え、そのサブクラス固有の属性が増えれば増えるほど、カラムの数は増える
- 一部のサブクラスでしか使われない列の値はNULLばかりとなり、見た目がスパースになる
- 上記テーブルの場合は
Person
以外で使われていないreal_name
など(Person
以外ではnull
となる) - ちなみに「スパース(sparse)」は「すかすか」という意味らしい
- 「全体のデータは大規模だが、意味のある情報はごく一部しかない」というようなものが、スパース構造を持つデータだ なるほど
- 上記テーブルの場合は
ここまで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の時のデメリットも一部そのまま残るのでは...?と思ったんだけどどうなんでしょ?
レコード数が膨大になることがわかっているのであれば、オブジェクト指向的なメリットは多少目を瞑り、最初からテーブルを分割しておくのも悪くないのかもしれない
選択肢は多いに越したことはないので、しっかりメリット・デメリット把握して然るべき時に使えるようにしておこう
設計ってむずかしいなー
参考
- https://qiita.com/yebihara/items/9ecb838893ad99be0561 とても勉強になった
- https://qiita.com/na9amura/items/8b9d16e15699104cc49d STIとenum組み合わせるとハマる場合もある
- https://qiita.com/suusan2go/items/6da23826523744a74ba3 enumのカラムをSTIと組み合わせている人