論理削除
ユーザなどのリソースエンティティのパージするわけではないデータ削除(a.k.a. 論理削除)をどう設計するか、は単純でありながら、イミュータブルデータモデルの基本形を学ぶ良い題材なので、順を追って説明する。 リソースの検討
扱いが異ならない場合を考えてみよう。
code: (mermaid)
classDiagram
direction LR
class ユーザ {
<<Resource>>
名前 : VARCHAR
メールアドレス : VARCHAR
}
アクティブか削除済みかが、ただのラベルに過ぎず、振る舞いも属性も何も変わらないならば、ユーザの属性としてユーザ区分を持っていれば良い。このパターンは考えにくいかもしれないが、既存のExcel業務をただWeb化するだけのシステムでは、これで良いこともある。
次に扱いが異なる場合だが、その違いがわかるようにサブタイプとして表現しておく。
code: (mermaid)
classDiagram
direction LR
class ユーザ {
<<Resource>>
}
class アクティブユーザ {
ユーザID : BIGINT
名前 : VARCHAR
メールアドレス : VARCHAR
}
class 削除ユーザ {
ユーザID : BIGINT
}
ユーザ <|-- アクティブユーザ
ユーザ <|-- 削除ユーザ
普通はアクティブユーザだけを対象とするユースケースがたくさんあるだろうし、削除ユーザは氏名などの個人情報が保存されていてはならない、など属性に違いがあることもある。
イベントの検討
削除のタイミングで業務的な記録を残す必要があるかを考える。
code: (mermaid)
classDiagram
class ユーザ {
<<NOT Good>>
ユーザID
削除日時
}
このために削除日時をリソースに持たせればいいや、という思考は業務イベントの見落としにつながる。ユーザの削除は自分で登録抹消する場合もあれば、管理者がBANすることもあるかもしれない。管理者がBANした場合は、その理由も記録しておかなければならないかもしれない。
また「なんか消すの怖いから」「消した後にクレーム入った時に戻せないと嫌だ」のような漠然とした要求は言語化してモデルに起こしておく。「削除状態からボタン一発で元に戻す機能が必要」みたいなハッキリした要求がない限りは、登録抹消イベントに削除時点での属性を持っていればたいてい大体事足りる。
code: (mermaid)
classDiagram
direction LR
class ユーザ {
<<Resource>>
ユーザID
名前
メールアドレス
}
class 登録抹消 {
<<Event>>
ユーザID
名前
メールアドレス
抹消日時
}
class BAN {
<<Event>>
ユーザID
名前
メールアドレス
BAN理由
BAN日時
}
ユーザ .. 登録抹消
ユーザ .. BAN
短絡的な「論理削除」のマーカーとして、削除フラグか削除日時かという議論があるが、イミュータブルデータモデルの観点からするとこれらは意味合いが異なる。削除日時をユーザエンティティが持つことは、イベントとリソースを区別せず、それらが混じり合った状態であることを意味する。したがって、イミュータブルデータモデルではリソースであるユーザエンティティに削除日時を持たせることはしない。
リソースのサブタイプの実装
サブタイプの実装方法を決める
1. シングルテーブル継承
2. 具象テーブル継承
3. クラステーブル継承
シングルテーブル継承
code: (mermaid)
classDiagram
class ユーザ {
ユーザID
名前
メールアドレス
ユーザ区分
}
1つのユーザテーブルにまとめるパターンである。
Pros
削除ユーザも含めて検索する場合に、1つのテーブルスキャンで済む
Cons
削除ユーザが増えてきたらSQL性能に悪影響を及ぼす可能性が高まる
ユーザ区分 != '削除済み' をWHERE句に必ず含めなければならない
メールアドレスのように「アクティブユーザで一意であること」のような制約があるとき、削除レコードも含むため、RDBMSのUNIQUE制約を付与できない
よくある削除フラグを用いた実装も、このタイプである。ここまでの検討を経て、Pros/Consを理解した上で削除フラグを実装するのは問題ないが、この形だけをパターンとして覚えるのは避けるべきである。
code: (mermaid)
classDiagram
class ユーザ {
ユーザID
削除フラグ : boolean
}
削除フラグを含むシングルテーブル継承パターンでの実装は、デメリットも多くクエリ時に皺寄せが生じる。「ユーザ」のようにクエリで頻繁に使われるテーブルでは避けるべきである。
具象テーブル継承
具象テーブル継承は、それぞれのサブタイプを独立したテーブルとして実装するパターンである。
code: (mermaid)
classDiagram
direction LR
class アクティブユーザ {
名前 : VARCHAR
メールアドレス : VARCHAR
}
class 削除ユーザ {
}
Pros
削除ユーザに個人情報を持たせない実装が自然にできる
削除ユーザが増えてもアクティブユーザの検索性能に影響しない
Cons
両方のユーザを横断的に検索する場合、UNIONが必要
「削除したら別のテーブルに移してアーカイブし、元のユーザテーブルから削除する」というアプローチは、この具象テーブル継承の一種である。
具象テーブル継承で実装すると、1つのイベントから複数のリソースサブタイプとの関連が作られることになる。つまり、注文テーブルのユーザIDに外部キー制約を付与できない。これを理由にシングルテーブル継承をすぐに選択してしまうことがあるが、それは短絡的である。シングルテーブル継承のデメリットをよくよく検討しよう。これだけの理由であれば、たいてい次のクラステーブル継承で実装した方が良い。
code: (mermaid)
classDiagram
direction LR
class アクティブユーザ {
名前 : VARCHAR
メールアドレス : VARCHAR
}
class 削除ユーザ {
}
class 注文 {
ユーザID : BIGINT
注文日時 : TIMESTAMP
}
注文 .. アクティブユーザ
注文 .. 削除ユーザ
そもそも、イベントと関連付くリソースは最新の状態で良いのかということを検討しなければならない。これは、「イベントとの関連」のセクションで議論する。
クラステーブル継承
クラステーブル継承は、共通部分を親テーブルに、サブタイプ固有の部分を子テーブルに分離するパターンである。
code: (mermaid)
classDiagram
class ユーザ {
ユーザ区分 : ENUM
}
class アクティブユーザ {
名前 : VARCHAR
メールアドレス : VARCHAR
}
class 削除ユーザ {
}
ユーザ -- アクティブユーザ
ユーザ -- 削除ユーザ
Pros
横断的な検索が比較的簡単(親テーブルだけを見ればよい)
将来的に新しいサブタイプを追加しやすい
Cons
O/Rマッパーを使っていると、実装が複雑になることがある
イベントとの関連
「ユーザIDが例えば注文イベントと結び付いていて、注文イベントは簡単に削除できないので、ユーザテーブルから削除できない」という問題もある。
注文が参照するのは、最新のユーザなのかどうかをまず検討する必要がある。
常に最新のリソース状態を参照する場合
注文のようなイベントでは考えにくいだが、イベントが参照するリソースが最新の状態で良い業務もある。仮に注文が常に最新のユーザ情報を参照する場合、単に注文がユーザIDを持てば良い。
シングルテーブル継承の場合:
code: シングルテーブル継承の場合(mermaid)
classDiagram
direction LR
class 注文 {
注文日時 : TIMESTAMP
}
class ユーザ {
名前 : VARCHAR
メールアドレス : VARCHAR
}
注文 "*" -- "1" ユーザ : 注文する
クラステーブル継承の場合:
code: (mermaid)
classDiagram
direction LR
class 注文 {
注文日時 : TIMESTAMP
}
class ユーザ {
}
class アクティブユーザ {
名前 : VARCHAR
メールアドレス : VARCHAR
}
class 削除ユーザ {
}
注文 "*" -- "1" ユーザ : 注文する
ユーザ -- アクティブユーザ
ユーザ -- 削除ユーザ
先に述べた通り、具象テーブル継承ではFK制約を外す必要がある。それも許容できない場合は、次のセクションで述べる世代管理方式を使うと良い。
code: (mermaid)
classDiagram
class ユーザ {
ユーザ区分 : ENUM
}
class アクティブユーザ {
名前 : VARCHAR
メールアドレス : VARCHAR
}
class 削除ユーザ {
}
ユーザ -- アクティブユーザ
ユーザ -- 削除ユーザ
イベント時点のリソース状態を保持する場合
注文時点のユーザを参照できなくてはならない場合は、次の2通りの対応が考えられる。
1. 注文エンティティにその時点でのユーザの属性をコピーして持たせる
2. ユーザエンティティを世代管理する
1. スナップショット方式
注文エンティティにその時点でのユーザの属性をコピーして持たせる。
code: (mermaid)
classDiagram
direction LR
class ユーザ {
名前 : VARCHAR
メールアドレス : VARCHAR
}
class 注文 {
名前 : VARCHAR
メールアドレス : VARCHAR
注文日時 : TIMESTAMP
}
注文 "*" -- "1" ユーザ : 注文時点の情報をコピー
Pros
注文時点のユーザ情報が完全に保存される
ユーザが削除されても注文情報に影響しない
実装がシンプル
Cons
属性をコピーして持つので、1人が多数の注文をするようなケースでは容量を圧迫する
イベントエンティティの属性が非常に多くなる
2. 世代管理方式
code: (mermaid)
classDiagram
direction LR
class ユーザ世代 {
ユーザID : BIGINT
名前 : VARCHAR
メールアドレス : VARCHAR
}
class アクティブユーザ {
}
class 注文 {
注文日時 : TIMESTAMP
}
注文 "*" --> "1" ユーザ世代 : "注文時点の世代を参照"
ユーザ世代 "1" --> "0..1" アクティブユーザ : "最新世代"
つまり、注文のようなイベントと関連付くのは世代を持ったテーブルであり、他のユースケースでユーザ情報を参照する場合は、アクティブユーザとユーザ世代をJOINして使う。
ユーザIDでユーザが特定されるクエリにおいては、性能上のデメリットも特に考えなくても良いだろう。
Pros
ユーザ情報の変更履歴を完全に追跡できる
注文時点での正確なユーザ情報を参照できる
データの重複を最小限に抑えられる
Cons
ユーザ情報の変更時に世代の管理が必要
ユーザを横断的に検索する場合には、ユーザ世代のテーブルスキャンやインデックスのRANGEスキャンが必要になるため、性能面に注意して設計しなければならない。
アクティブユーザテーブルに名前やメールアドレスの属性を重複して持たせると、「ユーザ世代」は履歴の意味合いを帯びる。しかし、イベントテーブルとリソースの履歴が関連付くのは意味論的にわかりにくくなるため、設計上はあくまでも世代と呼ぶことを推奨する。
デシジョンチャート
https://gyazo.com/cf0eab4d22166e258f50d9b52e15280f