水底

ScalaとかC#とかk8sとか

論理削除について再考、そしてイベントソーシングへ

TL;DR;

殆の場合において論理削除は不要。素直にRDBの機能を使うかイベントソーシングパターンを導入する。

なんで今更論理削除の話を?

散々議論されていて皆さん飽き飽きしている話題だと思いますが、今一度個人的にまとめたかったからです。 実際にできる/できないではなく、テーブル設計的にあるべき姿かどうか・実装運用時にどうなるかに重きを置きます。何度か論理削除を利用したプロダクトに関わった経験に基づく個人的な感想です。PostgreSQLベースです。

そもそも論理削除とは

RDBのテーブルに「削除済み」を意味するカラムを追加することで、DELETEを発行せずに論理的に削除を表現する手法です。良くある実装には、Booleanのフラグで表現するものと、削除タイムスタンプ (削除されていない場合はNULL) で表現するものがあります。これに加えて、論理削除されたレコードが見えないようにしたViewを組み合わせることが多いです。

論理削除が意味するもの

論理削除を単に「レコードを消したくないけど消したことにしたいためのフラグ」と言うことができますが、見方を変えると「RDB上にRDBのDELETEをエミュレートしている」とも言えるでしょう。これをRDBの利用方法として健全と捉えるかどうかが論理削除を考える一つ目のポイントとなります。

論理削除を使う理由から考え直す

ほとんど この記事 にまとめられていますが、少し解釈が異なったりするので再度整理したいと思います。

「システムからはレコードが削除されたことにしたいが、実際にレコードは削除せず残しておきたい」といった曖昧な理由で利用されることがあるのではないでしょうか。もしこれ以上の理由がでてこないのであれば、まず要件を確認して設計をやり直すべきに思います。とはいえ、これでは元も子もないので追加で以下の2つの点について考えていきます。 1つ目は「論理削除したレコードが参照されることはあるか」です。DELETEでなく論理削除をしている時点で何かしら後から参照したいという思いはあるのだと思います。ありそうな理由としては、万が一オペミスやアプリのロジックミス等で削除されるべきでないデータが削除されてしまった場合に調査・復旧するため参照できるようにしておきたい、辺りでしょうか。これに関しては素直にDELETEで削除し、PITRのような機能で復旧できるようにしておけば問題ないはずで、SQLのレイヤまで復旧を前提としたロジックを持ち込まない方が何かと良いでしょう。 2つ目は「論理削除したデータが更新されることはあるか」です。ここで言う更新としては、一部フィールドの更新や論理削除フラグの更新 (つまり、削除されたはずのデータが復活!?) があります。いずれの場合でも、そもそも削除ではないでしょう。あくまで利用不可、若しくはそれに類する状態に変化した、と捉え、状態フラグを利用すべきです。

論理削除を使う理由として残されているのは「ビジネス要件等で、アーカイブとして参照できるようにしておく必要がある」でしょうか。この場合は都度PITRといったことはあまり現実的ではありませんし、論理削除的なアプローチが適しているでしょう。ただ、RDBにはトリガー機能があります。DELETEトリガーでアーカイブ用のテーブルにレコードをコピーするほうが明確に役割ごとにデータを持つことができるため、より適切そうです。唯一気をつける必要がある点として、リレーションが挙げられます。削除対象のレコードのみを単純にコピーするのか、それとも削除時点のリレーションを辿って全て保存するのかといったことを要件に合わせて適切に実装する必要があります。とはいえ、論理削除の場合でも同等の問題が発生するためトリガーを避ける理由にはなりません。

論理削除フラグの副作用から考え直す

論理削除を採用する方針になったとしましょう。テーブルにBooleanのフラグか削除タイムスタンプを追加することになります。一見とてもシンプルに見えますが、かなりの罠が潜んでいます。

罠1: ユニーク制約が難しくなる

論理削除を扱わないテーブルであればユニーク制約をつけることは至極簡単なはずですが、論理削除フラグが増えるだけで話は大きく異なります。

Booleanフラグを使った簡単な (?) 場合

本来ユニーク制約に利用したいカラム (e.g. id) と論理削除フラグを複合させる形で制約を付ければ良さそうに見えます。が、同じidでCREATEされ、さらに論理削除が行われる可能性がサービスのロジックにある場合はどうでしょうか。サロゲートキーを使っているのであれば大抵問題ないはず (1) ですが、ナチュラルキーを取るような場合 (2) は制約が破綻する可能性が高いと見えます。簡単な例のはずでしたが、論理削除導入により少なくとも1つ考えることが増えてしまいました。

(0) 論理削除を使わない場合

name
foo

UNIQUE(name)

name='foo'を削除

name

↓同じname='foo'のレコードを追加

name
foo

当然何も問題なし

(1) 論理削除で、シーケンスのサロゲートキーでユニーク制約を保ちたい場合

id (seq) name deleted
1 foo false

UNIQUE(id, deleted)

id=1を論理削除

id (seq) name deleted
1 foo true

↓同じname='foo'のレコードを追加 (idはシーケンスなので重複せず)

id (seq) name deleted
1 foo true
2 foo false

id=2を論理削除

id (seq) name deleted
1 foo true
2 foo true

問題なし

(2) 論理削除で、ナチュラルキーでユニーク制約を保ちたい場合

name deleted
foo false

UNIQUE(name, deleted)

name='foo'を論理削除

name deleted
foo true

↓同じname='foo'のレコードを追加

name deleted
foo true
foo false

↓後から追加された方のname='foo'を論理削除

制約違反で (論理) 削除できない!?

削除タイムスタンプを使った場合

…とはいえこれは論理削除フラグをタイムスタンプにすることで回避できます。

name deleted_at
foo NULL

UNIQUE(name, deleted)

name='foo'を論理削除

name deleted_at
foo 2019-03-01T10:00:00Z

↓同じname='foo'のレコードを追加

name deleted_at
foo 2019-03-01T10:00:00Z
foo NULL

↓後から追加された方のname='foo'を論理削除

name deleted_at
foo 2019-03-01T10:00:00Z
foo 2019-03-01T10:00:10Z

タイムスタンプが重複しない限り問題なし

全てが解決したように見えますか?すぐに気づく人も多いと思いますが、deleted_atをNullableにする必要があるため、deleted_at=NULLのレコードに対してユニークを保てません (3)。どちらかというとSQLの仕様の問題でもあるのですが、name='foo'のユニークを保つには (4) のように複数のユニークインデックスを張る必要があります。うまく機能はしますが、不用に複雑になってしまうことがわかるかと思います。

(3)

name deleted_at
foo NULL

UNIQUE(name, deleted)

name='foo'を追加

name deleted_at
foo NULL
foo NULL

単純なユニーク制約だけでは恐らく想定していない状態になり得る

(4)

CREATE UNIQUE INDEX unique_idx_1 ON hoge_table (name) WHERE name IS NOT NULL;
CREATE UNIQUE INDEX unique_idx_2 ON hoge_table (name, deleted_at);

罠2: リレーションを保つのが難しくなる

ユニーク制約だけでなく外部キーの張られたデータの扱いも大変になります。通常、削除時の簡単な挙動であればON DELETEで簡単に実装することができましたが、論理削除の場合は別途管理する必要がでてきます。例えばON DELETE CASCADEを論理削除で実現する場合、外部キー作成だけでなくUPDATE OFのトリガー、はたまたSQLを発行するアプリ側のロジックで実現する必要がでてきます。「削除」を「UPDATE」を使って表現するというのも、本来DBモデルの定義で解決すべきことをアプリ側のロジックに追加するというのも、あまり健全とは言えないと思います。他にも、ON DELETE RESTRICTを論理削除で実現する場合も、同様にUPDATE OFのトリガーかアプリ側から参照しているレコードがないか確認する必要があります。いずれも実現は可能ですが、本来あるべき姿から乖離・複雑化してしまいます。

罠3: 余計な操作・レコードが溢れる

論理削除を利用するテーブルをSQLでアクセスする場合は、常に論理削除フラグが付き纏います。多くの場合は論理削除フラグを考慮したViewに対してSELECTしたり、ORMを利用することで論理削除フラグを隠蔽した状態でRDBを操作することができます。良さそうですね。しかしながら、常にその環境を利用できるとは限りません。ORMの論理削除への対応が不完全で一部だけ生SQLを書くことになった場合・そもそもORMを利用していないプロジェクトの場合・アプリ外から手動でSQLを叩いて緊急対応しなければならなくなった場合、このような場合にも常に落ち着いて論理削除フラグを考慮したSQLを組み立てる必要がでてきます。 また、デバッグ等でクエリログを見るときの見通しが悪くなることも考えられます。

テーブル設計・RDBのあり方から考え直す

RDBはどうあるべきでしょうか。RDBにはCreate・Read・Update・Deleteの機能があり、それらを組み合わせて使われることが想定されています。ならば削除も提供されている機能を素直に使った方が良いのではないのでしょうか。「削除」のために「更新」を使うのは何かがおかしいと思うのです。また、RDBでは様々な機能が提供されており、例えば、万が一のときにデータを復旧させられるようにしたければバックアップやWALからのPITRがありますし、アーカイブとして残したいのであればDELETEトリガーでアーカイブ用テーブルにレコードをコピーすることもできます。複雑性はできる限り持ち込まない、持ち込んだとしてもスコープは狭く (e.g. アーカイブ機能はトリガーを使うことでDB側で隠蔽し、アプリ側には見せない) すべきということを考えると、論理削除は適切ではなさそうです。

残された論理削除を検討する可能性

ここまではほとんど論理削除を否定するようなものばかりでしたが、少なからず論理削除を使ったほうがよいのでは?と思うケースもあります。アーカイブ目的であることが前提です。1つ目はORMが論理削除に完全対応している場合です。開発者視点から完全に隠蔽されうまく動作するのであれば、わざわざ自前でトリガーを作成するまでもなくORMに乗っかってしまうのもありでしょう。ただし罠3で挙げたように、ORM経由以外からのアクセス時の複雑性はなくならない点には注意する必要があります。2つ目はOracleのIn-Database Archivingのように、DB側が対応している場合です。DB側がちゃんとサポートしてくれているなら使わない手はないでしょう (Oracleを利用したことがないので、もしIn-Database Archivingが不完全な機能の場合は話が変わります)。

ゴール

ここでは2つのゴールへと分岐します。1つはここまでで考えてきた昔ながらの素朴な設計、もう1つはイベントソーシングです。

1つ目のゴール、昔ながらの素朴な設計

私の考える1つ目のゴールは基本的に論理削除を利用しないことです。理由はここまでで述べた通りです。素直にCreate・Read・Update・DeleteとRDBの提供している機能をそのままの意味でフル活用して行くのが良いでしょう。

もう1つのゴール、イベントソーシング

論理削除は幾度も議論されてきています。例えば以下のエントリもそうです。

これらの議論が行われたのは2015年です。この時点で、論理削除の議論でありながら、イベントソーシングの元となるアイディアが見られます (「イベントソーシング」という単語自体は普及していなかったようです)。トレンドを見ると、ノイズも多いですが2015年ごろからジワジワ増えてきているようです。

さて、論理削除の文脈から来た人に「イベントソーシングってなんやねん」と思われていそうなので簡単に紹介します。 イベントソーシングとは、「全てのCreate・Read・Update・Deleteといった操作をイベントとして蓄積し、それらのイベントから任意の時点のデータを構築するタイプのデータストア」を指します。イベントソーシングを単一のRDBで実現しようとすると Re: 論理削除はなぜ「筋が悪い」か - Blog by Sadayuki Furuhashi で考察されているようなものになります。 よりわかりやすそうな例だと、Gitがイメージとして近いかも知れません。Gitはファイルの追加・更新・削除といった変更をCommitとして積み重ね、結果として最新の状態はHEAD、というように参照することができます。また、Gitでは過去Commitの削除は行わず、代わりにRevertを行います。つまり、GitのCommitがイベントの操作、Gitで管理されているデータの操作がレコードの操作という感じです。それ以外の例であれば、伝票処理の黒伝・赤伝でしょうか。伝票の発行がイベントの操作、伝票内のデータの操作がレコードの操作という感じです。

f:id:amaya382:20190401012913p:plain
引用: https://docs.microsoft.com/ja-jp/azure/architecture/patterns/event-sourcing

ポイントは、関数型的なアプローチを取ることで、イベントは不変であり常に追加のみが行われること、そして検索するときは直接イベントを参照するのではなく、イベントから構築されたものをデータとして参照することです。変更を加える処理 (Create・Update・Delete) と検索処理 (Read) を分離させているのです。

さて、イベントソーシングと論理削除の関係を考えてみます。今までのRDBであれば削除してしまったら終わり (バックアップがありますが)、レコードのアーカイブを行うためには追加で論理削除やトリガーでコピーといった手間がありました。一方、イベントソーシングでは全てが不変なイベントとして保存されます。つまり、イベントから取り出したいデータに変換する部分を調整することで任意の時点のデータ取り出せます。これはアーカイブ機能を包含するため、そもそも論理削除にするかどうかといったことを考える必要すらない、というゴールなのです。

ちなみに、RDBでイベントソーシングを表現することはあまり現実的ではありません。不変なイベントの蓄積と検索用のデータ構築を行う専用のデータストアが必要になります。また、アクセスを行うアプリ側ではCQRSを採用することになります (ここではCQRSについては深追いしません)。そのため既に動いているシステムに導入するには非常に高いコストがかかります。イベントソーシング/CQRSについての詳細は以下のようなサイト等を参考にすると良いでしょう。

まとめ

安直に論理削除を導入する前に、なぜ論理削除が必要なのか論理削除を導入するメリットがデメリットを上回るかをきちんと考えるべきです。 また、ここ数年でイベントソーシングという新しい考え方も出てきました。イベントソーシングでは削除やアーカイブ等で疲弊する必要はないのです。まだあまり普及していないのが難点ですが、新規システムであれば導入を検討する価値があるでしょう。