論理削除

レコードを物理的に削除せず、削除フラグで非表示にするデータ管理手法

データベース設計

論理削除とは

論理削除 (Soft Delete) は、データベースのレコードを DELETE 文で物理的に削除するのではなく、deleted_at タイムスタンプや is_deleted フラグを設定して論理的に削除する手法である。データを残しつつ、アプリケーションからは「削除された」ように見せる。

物理削除との比較

物理削除との主な違いを以下に比較する。

観点論理削除物理削除
データ復旧フラグを戻すだけバックアップからの復元
監査証跡削除履歴が残る履歴が消える
クエリ性能WHERE 条件が常に必要テーブルサイズが小さく保たれる
ストレージ削除データが蓄積不要データが解放される
外部キー行が残るため制約違反にはならないが、連鎖削除も発火しない参照元が残っていると既定 (NO ACTION) で削除が拒否される
消去要求 (GDPR 等)行を残す限り単体では応えられない行は消えるがバックアップ・ログ・派生データの手当ては別途必要

実装パターン

deleted_at タイムスタンプ (推奨)

-- 論理削除
UPDATE users SET deleted_at = NOW() WHERE id = 123;

-- 取得時は常にフィルタ
SELECT * FROM users WHERE deleted_at IS NULL;

-- 復元
UPDATE users SET deleted_at = NULL WHERE id = 123;

deleted_atis_deleted (boolean) より情報量が多い。いつ削除されたかが分かるため、監査やデバッグに有用だ。

ユニーク制約との共存

論理削除で問題になるのがユニーク制約だ。ユーザーがメールアドレス user@example.com で登録し、論理削除後に同じメールで再登録しようとすると、ユニーク制約に違反する。

-- PostgreSQL: 部分インデックスで解決
CREATE UNIQUE INDEX idx_users_email_active
  ON users (email) WHERE deleted_at IS NULL;

述語を満たす行だけが索引に載るので、生きている行同士でのみ一意性が課され、削除済みの行は何行でも同じメールアドレスを持てる。

MySQL には述語付き (部分) インデックスが無い。使えるのは前方一致インデックスと式インデックスだけで、どちらも行を絞る機能ではない。代わりに UNIQUE インデックスが NULL の重複を許すという性質を利用する。

-- MySQL: 生成列を NULL にして一意性の対象から外す
ALTER TABLE users
  ADD COLUMN email_active VARCHAR(255)
    AS (IF(deleted_at IS NULL, email, NULL)) STORED,
  ADD UNIQUE INDEX idx_users_email_active (email_active);

(email, deleted_at) の複合ユニークインデックスで済ませようとすると、この性質が裏目に出る。未削除の行は deleted_at が NULL なので「重複した NULL」として何行でも通ってしまい、守りたかった生存行の一意性が消える。複合キーで解くなら deleted_at を NOT NULL にして、未削除を '1970-01-01 00:00:00' のような固定値で表す設計にする。

DynamoDB での論理削除 + TTL

DynamoDB では TTL を使った「遅延物理削除」が実用的だ。TTL は先にテーブル側で有効化し、期限を入れる属性名を登録しておく必要がある。値は Unix エポック秒の Number 型でなければならず、ISO 8601 の文字列などを入れると TTL の対象外として無視され、いつまでも消えない。

// 論理削除: deleted_at を設定し、TTL に 30 日後を設定
await ddb.send(new UpdateCommand({
  TableName: 'Users',
  Key: { userId: '123' },
  UpdateExpression: 'SET deletedAt = :now, expiresAt = :ttl',
  ExpressionAttributeValues: {
    ':now': new Date().toISOString(),
    ':ttl': Math.floor(Date.now() / 1000) + 30 * 86400, // 30日後
  },
}));

// 取得時: 論理削除されたアイテムを除外
const result = await ddb.send(new QueryCommand({
  TableName: 'Users',
  KeyConditionExpression: 'tenantId = :tenant',
  FilterExpression: 'attribute_not_exists(deletedAt)',
  ExpressionAttributeValues: { ':tenant': 'acme' },
}));

Query はパーティションキーの値を必ず与える必要があるため、フィルタ式だけでは実行できない (絞り込み条件しか無い場面は Scan になる)。そしてフィルタは読み取りが終わった後に適用される。除外されたアイテムの読み取り分も消費容量に計上されるので、論理削除データが積み上がるほどフィルタ前提の設計は無駄な読み取りを増やす。件数が増える面では、削除時に索引キー属性そのものを消して該当アイテムをグローバルセカンダリインデックスから落とす方式のほうが素直だ。

30 日間は復元可能だが、期限が来た瞬間に消えるわけではない。公式ドキュメントの表現では「期限後、通常は数日以内」の削除で、期限切れのまま残っているアイテムは通常の読み取り・Query・Scan の結果に現れる。除外フィルタが期限後も必要なのはこのためだ。この削除は書き込みキャパシティを消費せず、DynamoDB Streams にはユーザー操作ではなくサービスによる削除として流れる。消える直前の姿を監査用に残したい場合は、この Streams レコードを別の保存先へ移す。

論理削除の落とし穴

WHERE 条件の付け忘れ

全クエリに WHERE deleted_at IS NULL が必要だが、1 箇所でも忘れると削除済みデータが表示される。ORM のデフォルトスコープやミドルウェアで自動付与する。

連鎖削除が発火しない

ON DELETE CASCADE は参照先の行が削除されたときだけ働く。deleted_at を UPDATE しても参照アクションは何も起きないので、親を論理削除しても子レコードは生きたまま残る。親の一覧からは消えているのに子の集計には出てくる、という食い違いはここから生まれる。子側も連鎖して論理削除するのか、参照するたびに親の状態を JOIN して判定するのかを設計時に決めておく。JOIN で判定する方式は素直だが、階層が深くなるほどクエリが重くなる。

データの肥大化

論理削除されたデータが蓄積し、テーブルサイズが肥大化する。定期的に古い論理削除データを物理削除するバッチ処理を設ける。

GDPR の「忘れられる権利」

GDPR のデータ削除要求には論理削除では不十分で、物理削除が必要になる場合がある。個人情報を含むフィールドだけを物理的に消去し、レコード自体は監査用に残す方法もある。

論理削除を選ぶかどうかの判断

論理削除は「取り消せる削除」という要求があるときの手段であって、既定の削除方式ではない。全テーブルに一律で deleted_at を入れると、全クエリに条件が付き、ユニーク制約は回避策だらけになり、連鎖の面倒だけが残る。判断の目安は 3 点ある。

  • 誤削除の取り消しや削除履歴の提示が業務要求にあるか。無ければ物理削除 + バックアップで足りる。
  • 復元できる期限を決められるか。無期限に残すと肥大化と消去要求の板挟みになる。期限を決められるなら、論理削除 + 期限後の物理削除という形に落とせる。
  • そのテーブルが参照される側か。子を持つ親テーブルほど連鎖の設計コストが高い。

監査証跡が目的なら、生存行にフラグを立てるのではなく、削除操作そのものを履歴テーブルへ記録する方式も比較対象になる。元テーブルは物理削除で小さく保ち、消えた事実と消えた内容は履歴側に持たせる形だ。

この記事は役に立ちましたか?

関連用語

関連する記事