はじめに
度々SNS上でエンジニア界隈でいろんなトピックが話題に上がりますが、ここ最近は論理削除の是非が話題です。
これまでブログにまとめたことがないトピックだったので、いろんな参考資料やブログをもとに、論理削除の是非について考えてみようと思います。
こういったトピックはほとんどすべてのアプリケーションで問題になるため、対象となる人口がそもそも多かったり、その是非が人によって分かれるテーマだからだと思います。こうすればいい、というプラクティス的な話ではなく、システム固有のドメインによって適切に判断しよう、という着地になリがちなテーマほどよく話題になる気がします。
削除の実装アプローチ 物理削除と論理削除
削除を実装するにあたって、2つのアプローチがあります。今回の記事はその2つの手法への考察なので、まずはそれぞれの実装パターンを押さえておきます。
1.1 物理削除(ハードデリート)
項目 | 内容 |
---|---|
定義 | データベースから 行を完全に削除 (DELETE ) し、ストレージ上に残さない方法 |
典型的な実装 | DELETE FROM users WHERE id = 42; |
メリット | - クエリが単純で高速 - テーブル肥大化を防げる - 外部キー ON DELETE CASCADE が活きる |
デメリット | - 誤削除は即消失(バックアップ復元が必要) - 削除履歴が別途必要(監査要件向けにログ or 監査テーブル) |
1.2 論理削除(ソフトデリート)
項目 | 内容 |
---|---|
定義 | データ自体は残しつつ “削除済み” フラグや deleted_at で非アクティブ扱い する方法 |
典型的な実装 | sql<br/>ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL;<br/>UPDATE users SET deleted_at = NOW() WHERE id = 42; |
メリット | - 誤削除してもフラグを戻すだけで復元可能 - “ごみ箱” UI を手軽に実装 - 削除イベントのタイムスタンプが手に入る |
デメリット | - 全クエリで除外条件が必要 - テーブルが肥大化しパフォーマンス低下 - 制約・ユニークキーと衝突しがち(本稿で詳述) |
論理削除はアンチパターンか?
実際の実装では、論理削除が選ばれがちであり論理削除を選択したことによる問題が発生します。
選ばれる理由としては、下記のようなものがあります。
- 誤削除への恐怖: データを物理的に消すと復旧できないので怖い
- 監査要件: いつ誰が消したかを把握したい
- 依存レコードの多さ: 複雑に貼られた外部キーがある状態での
DELETE CASCADE
を回避したい
一見正しそうに見えますが、プロダクトの長いライフサイクルを考えると様々な問題が発生します。
主な問題点
常にWHERE句が必要になる
通常のレコードの利用においても常に削除フラグを考慮する必要があり、パフォーマンスの悪化が発生します。また、一箇所でも漏れてしまうと重大な情報漏えいとなるリスクがあります。
例えば、SELECT文でユーザーテーブルからメールアドレスを取得することを考えても、WHEREが抜けると退会ユーザーにメールを配信してしまう、といったことになります。
デフォルトスコープ地獄
ORMツールの一部では、デフォルトスコープと呼ばれる、すべての通常クエリに自動で付与される条件を設定できるものがあります。論理削除の実現のためにこの機能が利用されることがあります。
便利な反面、集計の抜けやJOINの欠落、インデックス設計の際の考慮漏れなど様々な問題を含みます。
Railsのdefault_scope
や、LaravelのEloquentにおけるSoftDeletes
トレイトなどがこれに当たります。
論理削除において、これらの方法はデータベース設計上の問題解決の手段であり、アプリケーションとしてのドメインを適切に表現できていないという点でも問題だと考えています。
隠れデータとストレージ負担
論理削除を削除フラグによって実現する場合、削除済みのレコードが残り続けるため、無駄なデータに対してもインデックスが作成されます。
整合性
親テーブルが論理削除されても子テーブルは参照を維持します。
データの整合性としてフラグをすべての子テーブルに伝播させるか、外部キー制約を外す力が働きます。
子テーブルの参照の維持がメリットになるケースもあると思いますが、その場合は削除レコードを専用の別テーブルなどに移すといいかもしれません。
ユニーク制約・主キー重複の落とし穴
論理削除済みメールアドレスを再利用したいケースでも問題になります。
UNIQUEに加えてis_deletedを複合キーにするなどしないといけません。
ドメイン言語の欠如
そもそも、本当に「削除」するというドメイン知識なのでしょうか?
論理削除が必要な場合、それは「退会」や「非表示」、「アーカイブ」といったドメイン知識ではないでしょうか。
「論理削除」という単語はDB設計上の用語であり、ドメイン上のいわゆるユビキタス言語にはなり得ないはずです。
代替アプローチ
状態管理(State)パターンへの切り替え
このパターンでは、カラムによって状態を表現するのではなく、「役割ごとにテーブルを変える」という設計です。
下記のブログで、そーだいさんは「テーブルに状態を持たせず状態ごとに分ける」ことを提案しています。
ユーザ情報を保存する時のテーブル設計 - そーだいなるらくがき帳
履歴テーブルへの移動
アクティブ行のみを本番テーブルに残し、非アクティブ行は*_archive
へ移動
これにより、単に消すのが怖いといった心理的ハードルを下げつつ読み取り性能を確保できます。
物理削除+ログ+イベントソーシング
イベントソーシングパターンを採用する場合は、レコード自体は消しつつ、「削除する」というイベントを別テーブルに書くことになるため、監査や復元はログリプレイで担保することができます。
ドメイン上のモデリングから考えるアンチパターンの回避
ここまで、基本的な論理削除における問題とその解決手段について簡単に触れました。実際の設計において私が意識するべきだと考える点についてまとめます。
- t_wadaさんのスライドSQLアンチパターン 幻の第26章「とりあえず削除フラグ」 | PDF | Databases | Computer Software and Applications
- DELETE_FLAG を付ける前に確認したいこと。 #RDB - Qiita
から、削除フラグの問題への対処方法を考えてみます。
問題は、仕様をきちんと把握すると、「最適な設計は DELETE_FLAG ではない」という場合が有って、その場合は、その最適な設計を探すのが正しいだろうという話です。単純に考え至って無い可能性が隠れている。
DELETE_FLAG がしれっと入ったテーブル設計を見たところから話は始まります。 まず意図を聞く。 Q 「この DELETE_FLAG 列はどういう目的で必要なのですか?」 A 「データ上は無い事にしたいけど、実際のデータは消したくないからです」(??) だいたいこんな説明が返ってきます。
私の体感でも記事の通りだと感じていて、そもそもアプリケーションを作る上では論理削除が必須であるケースというのはかなり少ないです。
“とりあえず”削除フラグを導入することがアンチパターンである、というのがt_wadaさんの主張だと思っています。
たとえば、ユーザーの退会は「退会済み」であるユーザーという状態を表現していて、これは「削除」ではないです。「削除」という単語自体、DBレコードというドメインの外側の知識ではないでしょうか。
また、以下のkawasimaさんのscrapboxも参考になります。
まず、削除対象のリソースについて考えます。記事の例ではユーザーですね。
- ユーザーがアクティブなユーザーと削除されたユーザーで扱いが異なるかを考える
- あまりないかもしれないが、異ならない場合、単にユーザー区分でいいはず
- 扱いが異なる場合は、その違いがわかるようにサブタイプとして表現しておく
とあります。
そーだいさんの記事の状態ごとにテーブルを分けるパターンと似ていますね。違う点は、リソーステーブルに対するサブタイプとするか、テーブル自体を移動するかという点です。
どちらのパターンでも、アクティブなレコードに対する課題や削除時の要件については満たしていそうです。
共通して言えることは、「論理削除」という操作はあくまで実装パターンであって、要求をきちんと深堀りして「データを無効化したい」のか、「物理削除の代わり」なのかを区別している点だと思います。
「削除」という用語は適切にドメインを表現したモデルなのか、という話とデータベース設計として、クエリのパフォーマンスやキーの制約に関する話なのかは分けて考えるようにしましょう。
まとめ
論理削除は「今すぐ安心」でも、長期運用では テーブル肥大・クエリ複雑化・状態設計の硬直化 というツケを払うことになります。データのライフサイクル をまず言語化し、「本当に“削除”という一方向の遷移なのか」「監査は別レイヤで担保できないか」を検討しましょう。