【データ設計の罠】MongoDBの論理削除フラグ管理で一貫性が崩れてしまう問題を根本解決する方法

MongoDBの論理削除フラグによるデータ不整合を防ぐ設計 データベース

MongoDBでデータを削除するとき、物理削除ではなく「論理削除」を採用するケースは少なくありません。
deleted や isDeleted といったフラグを持たせ、通常の検索では削除済みデータを除外する設計です。
履歴を残せる、復元できる、監査しやすいなど、多くのメリットがあります。

しかし、論理削除は単純に1つのフラグを追加すれば終わる話ではありません。
特に、関連する複数のドキュメントで削除状態を管理し始めると、「本来同じ状態であるべきデータの整合性が崩れる」という問題が表面化します。
親ドキュメントは有効なのに子ドキュメントだけ削除済みになっている、あるいは復元処理の途中で状態が食い違う、といったケースです。

さらに厄介なのは、MongoDBではドキュメント単位で自由度の高いデータ設計ができるため、アプリケーション側に状態管理の責任が集中しやすいことです。
複数の更新処理、非同期処理、バッチ、APIなどが同じデータを変更すると、個々の処理は正しくても、システム全体では一貫性を失う可能性があります。

では、論理削除フラグをどこまで持たせるべきなのでしょうか。
親子それぞれにフラグを持たせる設計と、状態を一元管理する設計では何が違うのでしょうか。
そして、すでに一貫性が崩れやすい構造を採用してしまった場合、どのように根本から見直せばよいのでしょうか。

この記事では、MongoDBにおける論理削除の問題を単なる実装上のミスとして扱うのではなく、「誰がデータの状態を決めるのか」というデータ設計の問題として整理します。
論理削除フラグが増えることで発生する不整合の原因を掘り下げたうえで、状態の責務を適切に分離し、更新処理を単純化するための設計方針まで具体的に解説します。

  1. MongoDBの論理削除とは?論理削除フラグを使うメリットと注意点
  2. MongoDBで論理削除を実装すると一貫性が崩れる理由
    1. 論理削除フラグが複数のドキュメントに分散する
    2. 更新処理の分散が不整合を生む
    3. 「削除済み」の意味が曖昧になる問題
  3. 親子ドキュメントに削除フラグを持たせるデータ設計の問題点
    1. 親と子の削除状態が食い違うケース
    2. 状態を複製すると整合性ルールが増える
    3. 子ドキュメントだけを見ると判断できない問題
    4. 「状態」と「参照関係」を分けて考える
  4. 論理削除フラグの不整合が発生する典型的なケース
    1. 親だけ削除され、子が有効なまま残るケース
    2. 親子を順番に更新して途中で失敗するケース
    3. 復元時に削除状態が食い違うケース
    4. APIとバッチ処理で削除ルールが異なるケース
    5. 削除済みデータを検索や集計に含めてしまうケース
    6. 並行更新によって古い状態が上書きされるケース
    7. 不整合は「例外」ではなく「状態設計」の問題
  5. MongoDBの論理削除で一貫性を維持するための設計原則
    1. 削除状態の正となる場所を決める
    2. 状態を重複して保存しない
    3. 「削除」と「利用不可」を分けて定義する
    4. 削除処理を一つの責務に集約する
    5. 状態遷移を明示して設計する
    6. 一貫性を「注意力」ではなく構造で守る
  6. 削除状態を一元管理して論理削除フラグを増やさない方法
    1. 「削除された」と「表示しない」を分離する
    2. 親の削除状態を子のフラグへコピーしない
    3. 子ドキュメント自身のライフサイクルを定義する
    4. 複数の状態を一つのフラグに詰め込まない
    5. 削除処理の責務を一箇所に集約する
    6. 一元管理によって「不整合そのもの」を減らす
  7. MongoDBのトランザクションと更新処理で整合性を担保する
    1. トランザクションが必要になるケースを見極める
    2. 複数ドキュメントの更新を原子的に扱う
    3. 更新処理をアプリケーションの一箇所へ集約する
    4. 更新条件にも状態を含めて競合を防ぐ
    5. 冪等性を意識して削除処理を設計する
    6. トランザクションだけではデータ設計の問題は解決しない
    7. 実装前に確認したい整合性のチェックポイント
  8. 既存システムの論理削除設計を安全に見直す移行手順
    1. まず削除フラグの利用箇所を洗い出す
    2. 現在のデータに存在する不整合を確認する
    3. 削除状態の正となるデータを決める
    4. 新しいルールを先にアプリケーションへ反映する
    5. 既存データをバックフィルする
    6. 新旧のデータモデルを比較して検証する
    7. 古い削除フラグを段階的に廃止する
    8. 移行後も不整合を検知できる仕組みを残す
  9. 論理削除フラグの検索条件とインデックス設計を見直す
    1. 通常検索では削除済みデータを確実に除外する
    2. isDeleted だけをインデックスにするべきとは限らない
    3. 複合インデックスではクエリパターンを基準に考える
    4. explain() で実際の検索計画を確認する
    5. 部分インデックスを検討する
    6. 削除済みデータが増え続ける問題にも目を向ける
    7. 親子ドキュメントの検索条件を増やさない
    8. 検索条件を標準化してフラグ漏れを防ぐ
  10. MongoDBの論理削除はフラグの追加ではなくデータ設計として考える
    1. 「削除された」という事実を明確に定義する
    2. 親子の状態をコピーすると一貫性の制約が増える
    3. 状態の正を一箇所に集約する
    4. ドキュメントの境界をライフサイクルで決める
    5. 削除と物理削除を使い分ける
    6. データ設計と検索設計を一緒に考える
    7. 「不正な状態を作れない」設計を目指す
    8. MongoDBの柔軟性を「何でも保存できる」と誤解しない

MongoDBの論理削除とは?論理削除フラグを使うメリットと注意点

MongoDBで論理削除フラグを使ってデータを管理する仕組み

MongoDBでデータを削除する方法を考えるとき、まず候補になるのが物理削除です。
不要になったドキュメントをコレクションから実際に削除する、もっとも単純な方法です。
一方で、業務システムでは「一度削除したデータを後から確認したい」「誤削除に備えて復元できるようにしたい」「監査や履歴のためにデータを残しておきたい」といった要件が珍しくありません。
そこで登場するのが論理削除です。

論理削除では、ドキュメントそのものを削除するのではなく、削除されたことを示す状態をデータとして保持します。
たとえば、isDeleted: true のようなフラグを持たせ、通常の検索では isDeleted: false のデータだけを取得します。
見た目としてはデータが消えたように扱いながら、データベース上には元のドキュメントを残しておくわけです。

この方式には明確なメリットがあります。
代表的なのは、削除したデータを復元しやすいことです。
物理削除の場合、一度ドキュメントを削除すると、そのデータを戻すためにはバックアップなど別の仕組みに頼らなければなりません。
対して論理削除なら、削除状態を解除するだけで復元できます。
もちろん、実際のシステムでは誰がいつ削除したのかまで記録する設計も考えられますが、少なくとも「削除した瞬間にデータそのものが失われる」というリスクを下げられます。

また、監査性という観点でも有効です。
たとえば顧客情報や注文情報などでは、「現在は利用されていないが、過去には存在していた」という事実そのものに意味があります。
論理削除を採用すれば、現在有効なデータと過去のデータを同じドキュメントとして保持できるため、履歴を追跡するための基盤を作りやすくなります。

ただし、ここで注意したいのが、論理削除はデータを削除する処理ではなく、データの状態を変更する処理であるという点です。
この違いは非常に重要です。
物理削除なら、削除後のドキュメントは検索対象そのものから消えます。
しかし論理削除では、削除済みデータがデータベース内に存在し続けます。
そのため、アプリケーション側が「このデータは削除済みか」という状態を正しく扱わなければなりません。

たとえば、次のような状態を考えてみます。

  • isDeleted: false のドキュメントは通常の画面に表示する
  • isDeleted: true のドキュメントは通常の画面から除外する
  • 管理画面では削除済みデータも検索できる
  • 復元操作では isDeleted を false に戻す

このルール自体は単純です。
しかし、問題はシステムの規模が大きくなるにつれて、同じルールを守る場所が増えていくことです。
APIの検索処理、更新処理、集計処理、バッチ処理、管理画面、バックグラウンドジョブなど、それぞれが削除状態を意識する必要があります。
どこか一つでも条件を付け忘れれば、削除済みデータが通常画面に表示されたり、意図せず更新されたりする可能性があります。

さらに難しいのが、関連するドキュメントにも論理削除を適用するケースです。
たとえば「ユーザー」と「注文」のように複数のドキュメントが関連している場合、ユーザーだけを論理削除したときに、そのユーザーの注文をどう扱うのかを決めなければなりません。
注文にも削除フラグを付けるのか、それともユーザーの状態を見て注文を非表示にするのか。
この判断を曖昧にすると、同じ事実を複数の場所で管理することになります。

ここに論理削除設計の本質的な難しさがあります。
フラグを追加すること自体は簡単ですが、そのフラグが表す状態をシステム全体で一貫して維持することは簡単ではありません。
特にMongoDBはスキーマの柔軟性が高く、ドキュメント構造をアプリケーションの要件に合わせて設計しやすい反面、状態をどこで管理するのかという責務まで自動的に決めてくれるわけではありません。

したがって、論理削除を採用するときは「削除フラグを追加すればよい」と考えるのではなく、まず削除状態の定義と責務を決める必要があります。
誰が削除状態を持つのか。
関連データはその状態にどう追従するのか。
検索時にはどのデータを有効とみなすのか。
復元時にはどこまで元に戻すのか。
こうしたルールを先に明確にしておくことが重要です。

論理削除は、適切に設計すれば非常に便利な仕組みです。
しかし、データが残り続けるからこそ、物理削除にはなかった「状態管理」という問題が発生します。
そして、この状態管理を複数のドキュメントや複数の処理に分散させたとき、一貫性を維持する難易度が急激に上がります。
次章では、なぜMongoDBの論理削除で一貫性が崩れやすいのか、その原因をデータ設計と更新処理の観点から掘り下げていきます。

MongoDBで論理削除を実装すると一貫性が崩れる理由

MongoDBの論理削除でデータの一貫性が崩れる原因

MongoDBで論理削除を導入すると、最初は非常に単純な設計に見えます。
ドキュメントに削除状態を表すフラグを追加し、削除するときにその値を変更するだけです。
検索時には削除済みのデータを除外すればよいため、実装コストもそれほど高くありません。
しかし、システムが成長すると、この単純さが少しずつ失われていきます。

その原因は、論理削除が「データの存在」ではなく「データの状態」を管理する仕組みだからです。
物理削除であれば、削除されたドキュメントは存在しなくなります。
アプリケーションがそのデータを取得しようとしても、検索結果に含まれません。
ところが論理削除では、削除済みのドキュメントもMongoDBのコレクションに残り続けます。
つまり、データは存在しているが、業務上は存在しないものとして扱うという二重構造が生まれます。

この時点で、アプリケーションには「存在するデータ」と「利用可能なデータ」を区別する責任が発生します。
たとえばユーザーを論理削除した場合、単純な検索だけなら isDeleted: false を条件に追加すれば済みます。
しかし、別のAPIがユーザーIDを直接指定して取得する場合、管理画面が全件検索する場合、集計処理がユーザー数を計算する場合、バッチ処理が対象ユーザーを抽出する場合など、データを参照する場所が増えるほど、この条件を正しく適用する必要があります。

つまり、論理削除フラグは1つのフィールドを追加するだけで済む一方、その意味を理解しなければならないコードはシステム全体に広がります。
検索処理の一箇所で条件を付け忘れるだけでも、削除済みデータが通常のデータとして扱われる可能性があります。
これは単なる実装漏れに見えますが、根本的にはデータの状態をアプリケーションの複数箇所で解釈していることが問題です。

論理削除フラグが複数のドキュメントに分散する

さらに一貫性を難しくするのが、関連するドキュメントそれぞれに論理削除フラグを持たせる設計です。
たとえば、ユーザーと注文を別々のドキュメントとして管理しているとします。
ユーザーが退会したとき、ユーザードキュメントには isDeleted: true を設定するとします。
ここで、そのユーザーが過去に作成した注文をどう扱うのかが問題になります。

注文にも isDeleted を設定する設計なら、ユーザーと注文の両方の状態を変更しなければなりません。
一方、注文は残したままユーザーだけを論理削除し、注文を取得するときに親ユーザーの状態を確認する設計も考えられます。
どちらにもメリットはありますが、前者では状態の同期が必要になり、後者では参照時のロジックが複雑になります。

特に危険なのは、同じ意味の状態を複数のドキュメントに重複して保存するケースです。
ユーザーが削除済みなのに注文は有効、あるいはユーザーを復元したのに一部の注文だけ削除済みのまま、といった状態が発生すると、データベース上ではどちらの情報も形式的には正しいため、MongoDB自身が矛盾を検出してくれるわけではありません。
「この2つの状態は必ず一致しなければならない」という業務ルールは、スキーマにフィールドを追加しただけでは保証されないからです。

更新処理の分散が不整合を生む

一貫性が崩れるもう一つの理由は、更新処理が複数の場所に分散することです。
ユーザーの削除処理をAPIだけで実行しているうちは、比較的管理しやすいかもしれません。
しかし、運用が進むと管理画面から削除できるようになったり、一定期間アクセスのないユーザーをバッチで削除したり、別のサービスから状態変更が発生したりします。

このとき、それぞれの処理が完全に同じルールを実装しているとは限りません。
APIではユーザーと注文を同時に更新しているのに、バッチではユーザーだけを更新している、といった差が生まれる可能性があります。
コードレビューで個々の処理を確認しても、システム全体の状態遷移を追わなければ、この問題を見つけるのは困難です。

さらに、複数のドキュメントを順番に更新する処理では、途中で障害が発生する可能性もあります。
最初のドキュメントの更新には成功したものの、次の更新でエラーになった場合です。
すると、処理としては失敗しているにもかかわらず、一部だけ削除済みになった状態が残ります。
再実行の仕組みやトランザクションなどを適切に設計していなければ、この中間状態がそのままデータベースに残ってしまいます。

「削除済み」の意味が曖昧になる問題

論理削除では、フラグの意味そのものを明確にすることも重要です。
isDeleted が true なら、単純に「ユーザーが削除操作を行った」という意味なのでしょうか。
それとも「通常の業務処理から除外されている」という意味なのでしょうか。
あるいは「アカウントは無効だが、注文履歴は保持する」という状態まで含むのでしょうか。

この定義が曖昧なまま実装を進めると、開発者によってフラグの解釈が変わります。
ある処理では isDeleted: true を完全な削除済みとして扱い、別の処理では単なる非アクティブ状態として扱う、といった矛盾が生まれます。
フィールド名は同じでも、意味が異なれば当然ながら一貫したシステムにはなりません。

この問題を解決するには、まず「何をもって削除済みとするのか」を業務上の状態として定義する必要があります。
そのうえで、その状態をどのドキュメントが持つべきなのか、関連するデータはその状態をどのように参照するのかを決めます。

論理削除の一貫性問題は、MongoDBが特殊だから発生するわけではありません。状態を複数箇所に複製し、その更新責務を複数の処理に分散させれば、どのデータベースでも同種の問題が起こります。ただし、MongoDBではドキュメント指向の柔軟なデータモデルを活かして自由に設計できる分、「どこに状態を持たせるべきか」という設計判断の重要性が特に高くなります。

したがって、解決策は単純に「削除処理を丁寧に実装する」ことではありません。
そもそも状態を重複して保持する必要があるのか、状態の正解をどこに置くべきなのか、そして複数の更新をどの単位で一貫させるべきなのかを見直す必要があります。
次に重要になるのが、親子関係を持つドキュメントそれぞれに削除フラグを持たせる設計です。
ここを整理すると、論理削除による不整合がなぜ構造的に発生するのかが、さらに明確になります。

親子ドキュメントに削除フラグを持たせるデータ設計の問題点

親子ドキュメントそれぞれに論理削除フラグを持たせる設計

MongoDBで論理削除を設計するとき、特に判断が難しいのが親子関係にあるドキュメントの扱いです。
たとえば、ユーザーと注文、会社と部署、商品とレビューのように、あるドキュメントが別のドキュメントと論理的な関係を持っているケースです。
このとき「それぞれのドキュメントに isDeleted を持たせれば分かりやすい」と考えがちですが、実際には状態の重複が新たな問題を生みます。

親と子の両方に削除フラグを持たせる設計では、まず「どちらの状態が正しいのか」という問いが発生します。
親が有効で、子も有効なら問題はありません。
しかし、親だけが削除済みになり、子は有効なまま残った場合はどうでしょうか。
子の isDeleted が false だからといって、その子をユーザーに表示してよいとは限りません。
逆に、親を復元したときに子まで自動的に復元すべきなのかも、別途定義する必要があります。

つまり、親と子のそれぞれに同じ種類の状態を保存すると、単純に情報が増えるだけではありません。
本来は1つの業務上の事実から導かれるはずの状態を、複数の場所で保持することになります。
この状態の複製が、論理削除における一貫性問題の大きな原因です。

親と子の削除状態が食い違うケース

具体的に考えてみましょう。
ユーザーAが複数の注文を持っており、ユーザーと注文をそれぞれ別のMongoDBドキュメントとして管理しているとします。
ユーザーを論理削除するとき、注文にも isDeleted: true を設定する設計を採用したとします。

この場合、削除処理は実質的に「ユーザーの状態を変更する処理」だけではありません。
ユーザーと、それに紐づく複数の注文の状態を同時に変更する処理になります。
注文が10件なら10件、1万件なら1万件の関連ドキュメントについて状態を考慮しなければなりません。
データ量が増えるほど、更新処理の複雑性も増します。

しかも、すべての更新が成功するとは限りません。
ユーザーの論理削除には成功したものの、途中で注文の更新処理が失敗した場合、親は削除済みなのに一部の子は有効という状態が残ります。
これを単純に「バグ」と片付けるのは適切ではありません。
そもそも同じ意味の状態を複数のドキュメントに保存しているため、状態の同期という追加の責務が設計に組み込まれているからです。

さらに復元処理では問題が複雑になります。
親を復元したらすべての子も復元するのか、それとも削除前から削除済みだった子は復元しないのか。
この区別が必要になると、単純な isDeleted: false の更新では済みません。
削除前の状態まで別途記録する必要が出てくる可能性があります。

状態を複製すると整合性ルールが増える

データ設計では、同じ情報を複数箇所に持たせるほど、その情報を同期するためのルールが増えます。
論理削除フラグも例外ではありません。
親と子の両方にフラグを持たせる場合、少なくとも次のような状態遷移を考える必要があります。

  • 親を削除したとき、子も削除する
  • 親を復元したとき、子も復元する
  • 子だけを削除した場合、親の状態には影響させない
  • 親が削除済みの場合、子を個別に取得してよいか決める
  • 削除済みの親に新しい子を追加してよいか決める

このルールが少ないうちは、人間が把握することもできます。
しかし、システムが大きくなるとAPI、管理画面、バッチ処理など複数の入口から状態変更が行われます。
すると、それぞれの入口で同じ整合性ルールを実装する必要が出てきます。
1つでも例外的な処理が存在すれば、状態の組み合わせが増えていきます。

これはデータベース設計でいうところの更新異常に近い問題です。
状態を一元的に表現できるなら、更新すべき場所は少なくできます。
しかし状態を複数箇所に複製すると、1つの業務イベントが複数の更新処理へ変換されます。
その結果、システムの正しさを維持するためのコードが増えていきます。

子ドキュメントだけを見ると判断できない問題

親子それぞれに削除フラグを持たせる設計では、検索ロジックも複雑になります。
たとえば注文を取得するとき、注文自身の isDeleted だけを確認すれば十分なのでしょうか。
親であるユーザーも削除済みなら、その注文は通常の検索結果から除外すべきかもしれません。

すると、「注文が有効である」という条件だけでは検索条件として不十分になります。
親の状態まで確認する必要があるからです。
MongoDBでは関連データを参照するためにアプリケーション側で複数回検索したり、集約処理を利用したりすることになりますが、いずれの場合も設計上の複雑性が増します。

特に注意したいのは、削除状態をどこまで伝播させるかが曖昧なまま実装を始めることです。
親が削除されたら子も必ず削除扱いなのか、それとも子は履歴として残すのか。
後者なら、子自身の削除フラグは何を意味するのか。
親が復元された場合、子はどうなるのか。
これらを決めずにフィールドだけ追加すると、時間が経つほどデータの意味が分かりにくくなります。

「状態」と「参照関係」を分けて考える

ここで重要なのは、親子関係があるからといって、必ずしも削除状態まで親子で同期させる必要はないということです。
親が削除されたという事実と、子が削除されたという事実は、本当に同じ意味なのでしょうか。
もし意味が違うのであれば、それぞれ独立した状態として扱うほうが自然です。

たとえばユーザーが退会しても、過去の注文履歴は法的・業務的な理由から保持する必要があるかもしれません。
この場合、注文そのものを削除済みにするのではなく、「ユーザーは現在利用できないが、注文履歴は保持されている」という状態として扱うほうがデータの意味を正確に表現できます。
親の削除状態を子にコピーする必要もありません。

逆に、親が削除された時点で子も業務上完全に無効になるのであれば、その関係を明確な状態遷移として設計する必要があります。
その場合でも、まず「本当に子側へ状態を保存する必要があるのか」を検討すべきです。
親の状態から常に導出できる情報なら、重複して保存しないほうが一貫性を維持しやすいケースがあります。

この判断は、MongoDBを使うかどうか以前に、データモデリングの問題です。
保存すべき事実と、そこから導出できる状態を区別すること。
これが論理削除を設計するときの重要なポイントになります。

親子ドキュメントそれぞれに削除フラグを持たせる設計が常に間違いというわけではありません。
検索性能や履歴管理、独立したライフサイクルなどの要件によっては、あえて状態を複製する合理性もあります。
ただし、その場合は「状態を同期する責務」を設計上明示しなければなりません。

問題なのは、単に便利そうだからという理由で各ドキュメントに isDeleted を追加してしまうことです。
フラグが増えるほど管理できる情報が増えるように見えますが、実際には一貫性を保証しなければならない状態の数も増えます。
次章では、こうした親子間の状態不整合が実際のシステムでどのような形で発生するのか、典型的なケースを整理していきます。

論理削除フラグの不整合が発生する典型的なケース

論理削除フラグの不整合によって状態が食い違うMongoDBデータ

論理削除フラグによる不整合は、特殊な障害が起きたときだけ発生するものではありません。
むしろ、通常のCRUD処理や日常的な運用の中で、少しずつ状態の食い違いが生まれることがあります。
特に問題なのは、個々の処理だけを見ると正しく動いているように見えることです。
ユーザーの削除処理は成功している、注文の更新処理も成功している。
それでも、システム全体として見ると矛盾した状態が残っていることがあります。

ここでは、MongoDBで親子関係を持つデータを論理削除すると仮定し、どのようなケースで一貫性が崩れるのかを整理します。
重要なのは、単に「こういうバグがある」と覚えることではありません。
なぜその不整合が構造的に起こるのかを理解することです。
原因が分かれば、後続の設計判断もかなり明確になります。

親だけ削除され、子が有効なまま残るケース

もっとも典型的なのが、親ドキュメントだけが論理削除され、子ドキュメントの状態が更新されないケースです。
たとえば、ユーザーと注文を別々に管理しており、ユーザーには isDeleted、注文にも isDeleted を持たせているとします。
ユーザーを退会させる処理でユーザードキュメントのフラグだけを変更すると、注文側には以前の状態が残ります。

この状態で注文検索を実行すると、注文自身の isDeleted は false です。
そのため、注文だけを条件に検索すれば結果に含まれてしまいます。
しかし、親ユーザーはすでに削除済みです。
画面やAPIの仕様によっては、本来表示してはいけない注文が返されることになります。

一見すると検索処理の条件漏れに見えますが、根本原因はそれだけではありません。
「ユーザーが削除されたら注文も削除済みとみなす」というルールを、ユーザーと注文の両方にフラグを保存することで表現している点にあります。
どちらを正とするのかが曖昧なため、片方だけ更新されると簡単に矛盾が生まれます。

親子を順番に更新して途中で失敗するケース

次に注意したいのが、削除処理そのものは正しく設計されているにもかかわらず、途中でエラーが発生するケースです。
たとえば、まず親ユーザーを削除済みに変更し、その後に関連する注文を削除済みに変更する処理を考えてみます。

親の更新が成功した直後にアプリケーションが停止したり、ネットワーク障害が発生したり、後続のデータ更新でエラーが発生したりすると、親だけが削除済みという状態が残る可能性があります。
逆に、子の更新を先に実行する設計なら、今度は子だけが削除済みになった状態が発生する可能性があります。

ここで重要なのは、「処理を順番に書いた」ことと「処理全体が原子的に実行される」ことは別だという点です。
複数のドキュメントを個別に更新する処理では、途中まで成功して途中から失敗するという状態が起こり得ます。
これを考慮しないまま論理削除を実装すると、障害発生時にデータの一貫性が崩れます。

復元時に削除状態が食い違うケース

論理削除ならではの問題として、復元処理もあります。
物理削除では「削除したデータを元に戻す」という操作そのものが難しいため、復元時の状態設計を考える必要はありません。
しかし論理削除では、削除状態を解除すれば簡単に復元できるように見えます。

たとえばユーザーと注文の両方に削除フラグがある場合、ユーザーを復元するときに注文もすべて復元するのでしょうか。
もし注文の中に、ユーザーの退会とは無関係に個別削除されたものがあったらどうでしょうか。
単純に関連する注文の isDeleted をすべて false に戻すと、本来復元してはいけない注文まで復活してしまいます。

つまり、削除と復元は必ずしも対称な処理ではありません。
削除前の状態を正確に復元するには、削除された理由や削除された主体、削除前の状態など、追加の情報が必要になる場合があります。
isDeleted という1つの真偽値だけでは、状態遷移を十分に表現できないケースです。

APIとバッチ処理で削除ルールが異なるケース

実際のシステムでは、データを変更する入口が1つとは限りません。
ユーザー自身が操作するAPI、管理者向けの管理画面、定期的に実行するバッチ、外部サービスとの連携処理など、さまざまな経路から同じデータが更新されます。

たとえば、通常のAPIではユーザーを論理削除すると同時に関連データも更新する仕様になっている一方、夜間バッチでは一定期間利用されていないユーザーの isDeleted だけを変更しているとします。
この2つの処理は、それぞれ単独で見れば動作するかもしれません。
しかし、同じデータに対して異なる状態遷移を適用しているため、結果として不整合が発生する可能性があります。

特に危険なのが、時間が経過してから追加された処理です。
最初に作られたAPIでは論理削除のルールを正しく実装していても、数年後に追加されたバッチや管理ツールの開発者が、その背景を知らずにフラグだけを更新することがあります。
データ設計上の暗黙知がコードの外に残っていると、こうした問題は非常に見つけにくくなります。

削除済みデータを検索や集計に含めてしまうケース

論理削除では、更新処理だけでなく読み取り処理にも注意が必要です。
削除済みデータが物理的には存在しているため、検索条件を誤ると通常のデータとして取得できます。

たとえばECサイトで「現在有効な商品数」を集計するとき、単純に商品ドキュメントの件数を数えるだけでは、論理削除済みの商品まで含まれてしまいます。
売上集計、ユーザー数、在庫数、ランキングなども同様です。
通常の一覧画面では削除済みデータを除外できていても、集計処理だけ条件を付け忘れているというケースは十分に考えられます。

さらに親子関係があると、問題は複雑になります。
親が有効でも子が削除済み、親が削除済みでも子が有効という状態が存在すると、「有効な子の件数」をどのように定義するのかが必要になります。
これは単なるクエリの問題ではなく、システムがデータの有効性をどのように定義しているかというドメイン上の問題です。

並行更新によって古い状態が上書きされるケース

もう一つ見落とされやすいのが、複数の処理が同じドキュメントをほぼ同時に更新するケースです。
たとえば、管理者がユーザーを削除する操作と、別のバックグラウンド処理がユーザー情報を更新する処理が同時に走ったとします。

このとき、更新内容や実行順序によっては、先に設定された削除状態が後続の更新によって意図せず上書きされる可能性があります。
特にドキュメント全体を置き換えるような更新を行っている場合、ある処理が古い状態を読み込み、その内容を基に書き戻すことで、別の処理が追加した状態変更を消してしまうことがあります。

これは論理削除そのものの問題というより、状態を更新する競合処理の問題です。
しかし、論理削除フラグも重要な状態の一つなので、競合制御を考慮しなければ一貫性を維持できません。

不整合は「例外」ではなく「状態設計」の問題

ここまで見てきたケースには共通点があります。
親だけが更新される、子だけが更新される、復元時に状態が戻らない、バッチとAPIで処理が異なる、検索条件が漏れる、並行更新で状態が上書きされる。
表面上はそれぞれ異なる障害に見えますが、根底には同じ問題があります。

削除状態という重要な情報を、複数の場所と複数の処理で管理していることです。

状態を複数箇所に保存すれば、当然ながら同期する必要があります。
そして同期処理が増えれば、失敗するポイントも増えます。
さらに、状態を参照するコードが増えれば、読み取り時の条件漏れも起こりやすくなります。
これは「開発者が注意すれば防げる」という種類の問題ではありません。
注意だけに依存すると、システムの規模が大きくなるほど限界が来ます。

したがって、根本的な解決を考えるなら、個々のAPIやバッチの実装を修正するだけでは不十分です。
まず、削除状態の正となる場所を明確にし、その状態をどこまで他のドキュメントへ伝播させる必要があるのかを見直す必要があります。
次章では、この問題を解消するための設計原則を整理し、論理削除の状態をどのように扱えば一貫性を維持しやすくなるのかを考えていきます。

MongoDBの論理削除で一貫性を維持するための設計原則

MongoDBの論理削除でデータの一貫性を維持する設計原則

ここまで見てきたように、MongoDBの論理削除で一貫性が崩れる原因は、isDeleted の更新処理そのものが難しいからではありません。
より本質的なのは、削除という1つの業務上の状態を複数のドキュメントや複数の処理に分散して管理してしまうことです。
したがって、根本的な対策も「削除処理のコードをもっと丁寧に書く」だけでは足りません。
まずデータの責務を整理し、状態が一意に決まる構造を作る必要があります。

特に重要なのは、保存する事実と、そこから導出できる状態を区別することです。
データベースに保存すべきなのは、本当に後から再計算できない情報なのか。
それとも別のドキュメントが持つ状態から判断できる情報なのか。
この線引きが、論理削除の設計品質を大きく左右します。

削除状態の正となる場所を決める

最初に決めるべきなのは、「このデータが削除済みかどうかを最終的に誰が判断するのか」です。
親ドキュメントと子ドキュメントの両方に削除フラグを持たせるのであれば、それぞれが独立した意味を持つのか、それとも同じ状態を表しているのかを明確にします。

たとえばユーザーと注文があり、ユーザーの退会によって注文も通常画面から見えなくなるとします。
このとき、「ユーザーが削除済みだから注文も削除済み」という関係が常に成立するのであれば、注文側にも同じ意味の isDeleted を保存する必要があるのかを考えるべきです。
注文自身が削除されたという事実と、親ユーザーが削除されたことによって注文を通常表示しないという状態は、同じ意味ではありません。

この2つを分離できれば、状態の重複を減らせます。
親の状態は親が管理し、子自身のライフサイクルは子が管理する。
必要な場面では両者を組み合わせて「現在このデータを利用可能とみなすか」を判断する、という考え方です。

もちろん、検索性能やアーキテクチャ上の理由から子側にも状態を保持したいケースはあります。
その場合は、単なるコピーではなく「なぜ子側にも状態が必要なのか」を明確にし、同期の責務まで設計に含めることが重要です。

状態を重複して保存しない

データ設計では、同じ事実を複数箇所に保存すると更新異常のリスクが高まります。
論理削除フラグも同じです。
親が削除されたことを表す情報と、子が削除されたことを表す情報が実質的に同じ意味なら、両方に保存する理由は慎重に検討すべきです。

たとえば、親の削除状態から子の表示可否を常に決められるのであれば、子に「親が削除されたことを示すフラグ」をコピーする必要はありません。
親が有効かどうかは親のドキュメントを参照すれば判断できます。
情報を複製しないことで、親と子の状態が食い違うという問題そのものを消せます。

これはデータベース設計でいう正規化と完全に同じ話ではありません。
MongoDBでは、読み取り性能やドキュメント単位のアクセス効率を考えて意図的にデータを非正規化することがあります。
しかし、非正規化によって状態を複製するなら、その複製された値を誰が、いつ、どのように正しい状態へ更新するのかまでセットで設計しなければなりません。

「削除」と「利用不可」を分けて定義する

論理削除で意外と起こりやすいのが、「削除」と「利用不可」を同じものとして扱ってしまう問題です。
実際の業務では、データが表示されない理由は削除だけとは限りません。
アカウント停止、公開終了、契約終了、アーカイブ、権限不足など、複数の状態が存在することがあります。

ここで何でも isDeleted に詰め込むと、フラグの意味が曖昧になります。
たとえば isDeleted: true が「ユーザー自身が退会した」のか、「管理者が利用停止した」のか、「一定期間が経過したためアーカイブされた」のか分からなくなると、復元処理や監査処理も複雑になります。

そのため、必要に応じて状態をドメイン上の概念として整理します。
単純な論理削除だけで十分ならフラグでも構いませんが、複数の状態遷移が存在するなら、状態を表すフィールドや削除日時、削除者などを組み合わせる設計も検討できます。
重要なのはフィールド数を増やすことではなく、データが取り得る状態を曖昧にしないことです。

削除処理を一つの責務に集約する

データの一貫性を維持するうえでは、状態変更の入口をできるだけ集約することも有効です。
API、管理画面、バッチなどがそれぞれ独自に削除処理を実装すると、少しずつ仕様がずれていきます。

たとえば「ユーザーを削除する」という操作を、単なるMongoDBへの更新処理として各所に直接書くのではなく、アプリケーション上の一つのユースケースとして定義します。
そのユースケースが、削除可能かどうかの確認、状態変更、監査情報の記録、必要な関連データの処理などを担当します。

こうすると、削除ルールを変更するときにも修正箇所を限定しやすくなります。
バッチから削除する場合も、APIとは別のロジックを持つのではなく、可能な範囲で同じドメイン上の処理を利用するほうが安全です。

ただし、責務を集約しただけでデータベース上の原子性が保証されるわけではありません。
複数のドキュメントを更新する必要があるなら、処理途中の失敗や並行更新についても別途考える必要があります。

状態遷移を明示して設計する

論理削除を単純な真偽値として扱うと、「削除する」「復元する」という2つの操作だけを考えてしまいがちです。
しかし実際には、データがどの状態からどの状態へ遷移できるのかを整理したほうが、設計上の矛盾を発見しやすくなります。

たとえば、通常状態から削除済み状態へ移行できるとして、削除済みから通常状態へ戻せるのか。
子が個別に削除されていた場合、親の復元によって子も復元するのか。
削除済みの親に対して新しい子を作成できるのか。
こうしたルールを状態遷移として定義すると、曖昧だった仕様が具体的になります。

ここで大切なのは、コードを書く前に状態の組み合わせを考えることです。
データの状態を表現するフィールドが増えるほど、理論上の状態数も増えます。
不要な状態を作らないことは、バグを減らすための非常に有効な設計手段です。

一貫性を「注意力」ではなく構造で守る

最終的に目指すべきなのは、開発者が毎回「削除フラグを付け忘れていないか」と注意しなくても、一貫性を維持しやすい設計です。
人間の注意力は、システムの規模や開発者の人数が増えるほど信頼できる保証手段ではなくなります。

そのためには、状態の正を一箇所に寄せ、重複した状態をできるだけ減らし、変更処理の責務を集約します。
どうしても複数のドキュメントを同時に更新する必要がある場合は、MongoDBのトランザクションなど、データベース側の機能を利用して原子的な更新を検討します。

ここまでの設計原則をまとめると、考え方は意外にシンプルです。

  1. 削除状態の意味を明確にする
  2. 状態の正となるドキュメントを決める
  3. 導出できる状態を安易に重複保存しない
  4. 削除や復元の責務を集約する
  5. 必要な状態遷移を事前に定義する
  6. 複数ドキュメントの更新には原子性を考慮する

論理削除の設計で重要なのは、「削除フラグをどう更新するか」よりも、「削除という事実をどこで管理するか」です。
ここを正しく設計できれば、個々の処理が守るべきルールも自然に少なくなります。

一方で、既存システムではすでに複数のドキュメントに削除フラグが存在し、各APIやバッチがそれぞれ独自の更新を行っていることもあります。
その場合、いきなりデータ構造を変更するのではなく、現在の状態を分析しながら段階的に移行する必要があります。
次章では、こうした既存システムを前提に、論理削除の設計を安全に見直すための具体的な考え方を整理します。

削除状態を一元管理して論理削除フラグを増やさない方法

削除状態を一元管理してデータの不整合を防ぐ設計

論理削除による一貫性の問題を根本から解消するには、「削除対象のドキュメントにフラグを追加する」という発想から一度離れてみることが重要です。
もちろん、isDeleted のようなフィールド自体が悪いわけではありません。
問題なのは、親子関係にある複数のドキュメントへ同じ意味の削除状態をコピーし、それらを常に同期させようとする設計です。

たとえばユーザーが削除されたとき、そのユーザーに紐づく注文、コメント、設定などにも一律で isDeleted: true を設定する方法を考えてみます。
一見すると検索が簡単になりそうですが、実際には「ユーザーを削除したら、どの子データまで削除扱いにするのか」というルールが必要になります。
さらに復元時には、そのすべてを元に戻す必要があるのでしょうか。
こうして、1つの状態変更が大量の同期処理へ変換されます。

そこで基本となる考え方が、削除状態の正をできるだけ一箇所に集約することです。
親の状態から判断できる情報を子にコピーせず、必要なときに親の状態と子自身の状態を組み合わせて利用可能性を判断します。
状態の重複を減らせれば、当然ながら状態同士の矛盾も減らせます。

「削除された」と「表示しない」を分離する

まず整理したいのが、「削除された」という事実と、「現在の画面やAPIでは表示しない」という判断を同じものとして扱わないことです。
たとえばユーザーが退会したとしても、そのユーザーが過去に行った注文までデータとして削除されたとは限りません。
むしろ、注文履歴は保持する必要があるケースもあります。

この場合、ユーザーには削除状態を持たせますが、注文には「ユーザーが退会したから削除済み」という情報をコピーする必要はありません。
注文自身が削除されたのでなければ、注文データはそのまま保持できます。
そして注文を通常の画面に表示するかどうかは、ユーザーの状態や画面の要件をもとに判断します。

この分離によって、データベースに保存する事実が明確になります。
ユーザーについては「退会した」という事実、注文については「この注文が存在する」という事実です。
そこから「通常のユーザー画面にこの注文を表示するか」という判断は、アプリケーションのユースケースとして導出できます。

これはデータ設計において非常に重要な考え方です。
データベースには事実を保存し、可能な限り導出可能な状態を重複して保存しない。
もちろん性能上の理由などで非正規化が必要になる場合はありますが、その場合も、重複データを正とするのか、導出元を正とするのかを明確にしておく必要があります。

親の削除状態を子のフラグへコピーしない

親子データの一貫性を考えるなら、親の削除状態を子ドキュメントへコピーする設計は慎重に検討すべきです。
親が削除されたら子にも isDeleted: true を設定するという方式では、親と子の更新を常に同期させる必要があります。

一方で、子自身が削除されたかどうかだけを管理するのであれば、子の isDeleted は子固有の状態になります。
親が削除されたこととは別の概念です。
たとえばユーザーAが退会しても、そのユーザーの注文履歴は保持し、ユーザーが再び利用可能になった場合にも注文履歴を参照できるようにする、といった設計が可能です。

このとき「ユーザーが削除済みである」という状態と「注文が削除済みである」という状態は独立しています。
したがって、注文側の isDeleted を親の状態に合わせて変更する必要はありません。

重要なのは、親子関係があるからといって状態まで親子関係にする必要はない、ということです。
参照関係と状態のライフサイクルは別々に考えられます。
むしろ、この2つを混同しないことが、シンプルなデータモデルにつながります。

子ドキュメント自身のライフサイクルを定義する

ただし、子ドキュメントに削除フラグを持たせないことが常に正解というわけではありません。
たとえば注文自体をユーザーとは独立して削除できる要件があるなら、注文側にも削除状態が必要です。

ここで大切なのは、「親の削除状態を保存するためのフラグ」と「子自身の削除状態を保存するためのフラグ」を区別することです。
注文に isDeleted があるなら、それは「親ユーザーが削除されたか」を意味するのではなく、「この注文そのものが削除対象になったか」を表すべきです。

こうしておけば、注文の状態を変更する処理は注文自身のライフサイクルに閉じ込められます。
ユーザーの退会処理が注文の全件更新を必要とすることもありません。
逆に注文を個別削除した場合も、親ユーザーの状態には影響しません。

状態の責務を分離することで、更新処理の依存関係も小さくできます。
これは単にコードが短くなるという話ではありません。
ある処理が変更した状態によって、別の処理まで変更しなければならない範囲を狭くできるため、システム全体の予測可能性が高まります。

複数の状態を一つのフラグに詰め込まない

論理削除の設計では、isDeleted を万能な状態管理フィールドとして扱わないことも重要です。
実際の業務システムでは、データが通常利用できない理由が複数存在します。
削除、停止、非公開、アーカイブ、期限切れなどをすべて「削除」として扱うと、状態の意味が急速に曖昧になります。

たとえば「ユーザーは退会済みだが注文履歴は保持する」「商品は販売終了だが過去の注文では参照できる」「コメントは削除されたが監査用には残す」といったケースでは、単純な削除フラグだけでは業務上の状態を十分に表現できません。

このような場合は、削除の有無と公開状態、利用状態、アーカイブ状態などを必要に応じて分けて考えます。
すべてを別々のフラグにすればよいという意味ではありません。
むしろ、どの状態が本当に独立しているのかを見極め、不要な状態の組み合わせを作らないことが重要です。

状態を増やすほど組み合わせは増えます。
たとえば真偽値が3つあれば理論上は8通りの組み合わせがあります。
しかし、そのうち業務上意味のある状態が3つしかないのであれば、残り5つの扱いまで考えなければならない設計は避けたいところです。
データ設計では、表現力を高めることと同時に、不正な状態を作りにくくすることも重要です。

削除処理の責務を一箇所に集約する

状態を一元管理する場合、データモデルだけでなくアプリケーションの責務も整理する必要があります。
複数のAPIやバッチが直接MongoDBを更新すると、せっかく状態を整理しても、更新ルールが分散してしまいます。

たとえば「ユーザーを退会させる」という操作を、単純なMongoDBの更新処理として各所に実装するのではなく、アプリケーション上の一つのユースケースとして扱います。
そのユースケースが「退会可能か」「ユーザーの状態をどう変更するか」「監査情報を残すか」といったルールをまとめて管理します。

こうすると、APIからの退会処理と管理画面からの退会処理で、異なる状態遷移が実装されるリスクを減らせます。
バッチ処理についても、可能な範囲で同じユースケースやドメインサービスを利用すれば、状態変更のルールを一箇所に集約できます。

ただし、ここで注意したいのは、アプリケーション上で責務を集約することと、データベース上で複数ドキュメントの更新を原子的にすることは別問題だという点です。
複数のドキュメントを更新する必要がある設計なら、トランザクションなどの仕組みも検討する必要があります。

一元管理によって「不整合そのもの」を減らす

ここまでの話を整理すると、削除状態を一元管理する目的は、単にコードをきれいにすることではありません。
もっと重要なのは、そもそも一貫性を維持しなければならない状態の数を減らすことです。

親にも子にも同じ意味の削除フラグが存在すると、「親と子が一致している」という新しい制約が生まれます。
しかし、親だけが削除状態を持ち、子は自身のライフサイクルだけを管理するなら、この制約自体が不要になります。

これは非常に強力な考え方です。
バグを防ぐためにチェック処理を追加するのではなく、バグが成立する状態をデータモデルから減らします。
ソフトウェア設計では、こうした「不正な状態を表現しにくくする」という発想が、長期的な品質を大きく左右します。

もちろん、検索性能やアクセスパターンによっては、意図的に削除状態を非正規化するほうが適切な場合もあります。
その場合は、状態のコピーを正規のデータとして扱うのか、キャッシュとして扱うのかを明確にし、同期失敗時の復旧方法まで設計しておくべきです。

MongoDBの柔軟性を活かすなら、単にフィールドを増やしていくのではなく、「この情報は本当に保存する必要があるのか」と一つずつ問い直すことが重要です。
削除状態を一元管理できれば、親子間の同期処理、復元処理、検索条件、バッチ処理など、多くの箇所で複雑性を減らせます。

そして、どうしても複数ドキュメントの状態を同時に変更しなければならないケースもあります。
その場合に必要になるのが、更新処理をどのように原子的に実行するかという問題です。
次章では、MongoDBのトランザクションを含め、複数の更新処理で論理削除の整合性を担保する方法を見ていきます。

MongoDBのトランザクションと更新処理で整合性を担保する

MongoDBのトランザクションで論理削除処理の整合性を担保する

ここまでの章では、論理削除フラグを増やさず、削除状態の責務をできるだけ一元化する考え方を整理しました。
ただし、実際のシステムではどうしても複数のドキュメントを一度に変更しなければならないケースがあります。
たとえば、ユーザーを削除すると同時に、そのユーザーに紐づく複数のデータへ状態変更を反映する必要がある場合です。

このような処理で重要になるのが、複数の更新を一つのまとまりとして扱うことです。
単純に更新処理を順番に実行するだけでは、途中でエラーが発生したときに一部だけ変更された状態が残る可能性があります。
論理削除で問題になる「親は削除済みなのに子は有効」といった状態も、その典型です。

MongoDBでは、複数のドキュメントに対する処理についてトランザクションを利用できます。
トランザクションを適切に使えば、複数の更新を一つの処理単位として扱い、途中で失敗した場合には変更を取り消すという設計が可能になります。
ただし、トランザクションを導入すればすべての不整合が解決するわけではありません。
どの状態を正とするのか、どのデータを更新する必要があるのかというデータ設計そのものが曖昧なら、トランザクションはその曖昧さを解消してくれないからです。

トランザクションが必要になるケースを見極める

まず考えたいのは、「本当に複数のドキュメントを同時に更新する必要があるのか」です。
前章で説明したように、親の削除状態を子にコピーする必要がないのであれば、親子をまとめて更新する必要そのものをなくせます。
これはトランザクションを使うより強力な解決策です。

たとえばユーザーの退会状態だけをユーザードキュメントで管理し、注文は注文自身のライフサイクルだけを管理する設計なら、ユーザー退会時に大量の注文を更新する必要はありません。
更新対象が一つになるため、途中で子データの更新に失敗するという問題も発生しません。

一方で、業務要件によって複数のドキュメントを変更する必要がある場合もあります。
たとえば、ある処理によって親を無効化し、その結果として関連する複数の状態も必ず同時に変更しなければならないケースです。
この場合は、「これらの変更はすべて成功するか、すべて失敗する必要がある」という原子性を明確に定義します。

トランザクションを使うかどうかは、MongoDBに機能があるから決めるのではありません。
複数の変更を業務上ひとつの不可分な操作として扱う必要があるかを基準に判断するべきです。

複数ドキュメントの更新を原子的に扱う

たとえば、ユーザーとその関連データを同時に状態変更する必要があるとします。
トランザクションを使わずに単純な更新処理を行うと、次のような状態が起こり得ます。

  1. ユーザーの削除状態を変更する
  2. 関連データの更新を開始する
  3. 途中でエラーが発生する
  4. ユーザーだけが削除済みになる

この場合、処理全体としては失敗しているにもかかわらず、データベースには一部の変更だけが残ります。
アプリケーションから見ると「削除処理に失敗した」という結果なのに、再度データを取得するとユーザーがすでに削除済みになっているという、扱いにくい状態です。

トランザクションでは、こうした複数の更新を一つの処理単位として扱えます。
必要な更新をトランザクション内で実行し、すべての処理が成功した場合に確定します。
途中で問題が発生した場合には、処理全体を失敗として扱えるため、「途中まで成功した」という状態を避けやすくなります。

ただし、トランザクションの利用にはコストもあります。
処理時間、ロックや競合、リソース消費などを考慮する必要があり、すべての更新を機械的にトランザクションへ入れればよいわけではありません。
特に大量の関連ドキュメントを一度に更新するような設計では、トランザクションで解決する前に、そもそもその大量更新が必要なデータモデルなのかを見直したほうがよいでしょう。

更新処理をアプリケーションの一箇所へ集約する

トランザクションと同じくらい重要なのが、更新処理の責務を分散させないことです。
データベースのトランザクションが正しく機能していても、API、管理画面、バッチなどがそれぞれ異なるルールで状態を変更していれば、別の種類の不整合が発生します。

たとえばAPIでは「親を削除すると関連データも更新する」というルールなのに、バッチでは親だけを更新するとします。
この場合、API経由では一貫した状態になる一方、バッチ経由では別の状態が作られます。
トランザクションは一回の処理を原子的にできますが、処理ごとに異なるビジネスルールを統一するものではありません。

そのため、削除や復元といった重要な状態変更は、アプリケーション上のユースケースとして集約するのが有効です。
どの入口から操作しても同じルールを通るようにすれば、状態変更の仕様が分散することを防げます。

さらに、直接MongoDBを更新するコードを各所に散らさないことも重要です。
データアクセスの方法をある程度集約しておけば、「この状態変更には何が必要なのか」を追跡しやすくなります。
これは特定のアーキテクチャやORMを採用しなければならないという話ではありません。
重要なのは、状態変更の責務を明確にし、同じ業務ルールを複数の場所へコピーしないことです。

更新条件にも状態を含めて競合を防ぐ

一貫性を考えるとき、処理の原子性だけでなく並行更新にも注意が必要です。
たとえば、ある処理がユーザーを削除しようとしている間に、別の処理が同じユーザーを更新するケースです。

単純にドキュメントを読み込み、その内容を変更して保存するという処理では、古い状態を基にした更新が新しい状態を上書きする可能性があります。
特にドキュメント全体を置き換える方式では、別の処理が追加したフィールドや状態まで失われるリスクがあります。

このような競合を防ぐには、更新対象を必要最小限にし、可能な場合は現在の状態を更新条件に含める方法が有効です。
たとえば「現在有効なユーザーだけを削除する」という条件を付けて更新すれば、すでに別の処理によって状態が変わっている場合に、更新件数を確認して処理結果を判断できます。

ここで重要なのは、データを取得してから判断する処理と、更新そのものを分けすぎないことです。
データベースに対して「この条件を満たす場合だけ、この状態へ変更する」という形で操作できれば、競合するタイミングを減らせます。

冪等性を意識して削除処理を設計する

論理削除のような状態変更では、同じ処理が複数回実行されても問題が起きにくい設計、つまり冪等性も重要です。
たとえばネットワーク障害によってクライアントが同じ削除リクエストを再送する可能性があります。

削除処理が「有効な状態から削除済みへ変更する」という形で設計されていれば、すでに削除済みのデータに対して同じ処理を実行しても、状態がさらに壊れることはありません。
もちろん、削除回数をカウントするなど副作用があれば別途考慮が必要ですが、基本となる状態変更は再実行に耐えられるようにしておくと安全です。

復元についても同じです。
すでに復元済みのデータに対して復元処理を実行した場合に、予期しない状態になるようでは運用が難しくなります。
外部システムとの連携や非同期処理が増えるほど、同じ要求が複数回届く可能性を前提にした設計が重要になります。

トランザクションだけではデータ設計の問題は解決しない

ここで一度、重要な点を整理しておきましょう。
MongoDBのトランザクションは、複数のデータ変更を原子的に扱うための強力な仕組みです。
しかし、トランザクションを使ったからといって、親と子の削除フラグが常に正しい意味を持つようになるわけではありません。

たとえば、親と子の両方に同じ意味の isDeleted を保存していたとします。
トランザクションによって両方を確実に更新できたとしても、後から別の処理が片方だけを変更すれば、再び状態は食い違います。
また、「親が削除されたら子も削除済みなのか」という業務ルールそのものが曖昧なら、トランザクション内でどの値を設定すべきか決められません。

したがって、対策には優先順位があります。
まずデータモデルを整理して不要な状態の重複を減らし、それでも複数ドキュメントの更新が必要なら、トランザクションや競合制御を検討します。
トランザクションは悪いデータ設計を隠すための仕組みではなく、正しく定義された状態変更を安全に実行するための仕組みと考えると分かりやすいでしょう。

実装前に確認したい整合性のチェックポイント

論理削除を実装するときは、コードを書く前に次の点を確認しておくと、後から大きな修正になる可能性を減らせます。

  • 削除状態の正となるドキュメントはどれか
  • 親の削除状態を子へコピーする必要が本当にあるか
  • 複数ドキュメントを同時に更新することが業務上必須か
  • 途中で失敗した場合に、どの状態まで残ってよいか
  • API、管理画面、バッチが同じ状態変更ルールを利用しているか
  • 同じ削除処理が再実行されても問題ないか
  • 並行更新によって削除状態が上書きされる可能性はないか

このチェックを先に行うだけでも、「とりあえず全部のドキュメントに isDeleted を追加する」という設計から抜け出しやすくなります。

MongoDBで一貫性を維持するために重要なのは、機能を追加することよりも、状態の責務を減らすことです。
不要な状態を保存しない。
必要な状態変更は一箇所に集約する。
それでも複数の変更を一体として扱う必要があるなら、トランザクションや競合制御を使う。
この順番で考えると、過剰な仕組みを導入せずに済みます。

そして、すでに本番環境で複数の論理削除フラグが運用されている場合は、理想的なデータモデルへ一気に変更するのではなく、現在どの処理がどの状態を変更しているのかを調査するところから始めるべきです。
次章では、既存システムの論理削除設計を安全に見直し、段階的に移行するための具体的な進め方を整理します。

既存システムの論理削除設計を安全に見直す移行手順

既存のMongoDB論理削除設計を安全に見直す移行手順

すでに運用中のシステムで論理削除フラグの不整合が問題になっている場合、理想的なデータモデルへ一気に変更するのは危険です。
特にMongoDBでは、アプリケーションのコードだけでなく、既存データ、API、管理画面、バッチ、集計処理など、多くの箇所が現在のデータ構造を前提として動いています。
ここで削除フラグを突然廃止したり、親子関係を大きく変更したりすると、論理削除の問題を解消する前に別の障害を生みかねません。

安全に見直すポイントは、現在の状態を把握してから、責務を整理し、段階的に移行することです。
いきなりMongoDBのコレクション構造を変更するのではなく、まず「今のシステムでは、誰が、どのデータの、どの状態を変更しているのか」を可視化します。

まず削除フラグの利用箇所を洗い出す

最初に行うべきなのは、削除フラグがどこで使われているのかを調査することです。
対象となるのは、単純な検索処理だけではありません。
削除や復元を行うAPI、管理画面、定期バッチ、集計処理、データエクスポート、外部サービスとの連携なども確認する必要があります。

たとえば isDeleted というフィールドがある場合、そのフィールドを読み取っている処理と書き換えている処理を分けて整理します。
読み取り側では、通常検索で除外しているのか、管理画面では含めているのか、集計ではどう扱っているのかを確認します。
書き込み側では、どの処理が true に変更し、どの処理が false に戻すのかを把握します。

ここで重要なのは、コード検索だけで調査を終わらせないことです。
MongoDBのクエリがリポジトリやデータアクセス層に隠れている場合もありますし、バッチや管理用スクリプトが別のコードベースに存在することもあります。
さらに、BIツールや運用スクリプトなど、アプリケーション本体の外側からデータが参照されている可能性もあります。

削除フラグが存在するコレクションごとに、「どの処理が状態を変更するか」「どの処理が状態を参照するか」を整理すると、現在のデータフローが見えてきます。
ここを飛ばして設計変更を始めると、見落とした処理が古い仕様のまま動き続けることになります。

現在のデータに存在する不整合を確認する

次に必要なのが、現在MongoDBに保存されているデータそのものの調査です。
設計上は「親と子の削除状態が一致する」ことになっていても、実際のデータがそのルールを満たしているとは限りません。

むしろ、既存システムの改修では「すでに不整合したデータが存在している」ことを前提に考えたほうが安全です。
親が削除済みなのに子が有効、子が削除済みなのに親が有効、削除日時がない、存在しない親を参照している、といった状態を抽出します。

この調査で大切なのは、不整合データを見つけたからといってすぐ修正しないことです。
まず、その状態がなぜ発生したのかを確認します。
過去の仕様では正しい状態だったのか、単純なバグだったのか、現在も同じ状態を作る処理が残っているのかによって、対応方法が変わるからです。

たとえば親が削除済みで子が有効という状態が大量に存在していたとしても、それをすべて「異常データ」と判断するのは早計です。
過去の業務仕様として、親の退会後も注文履歴を保持することが正しい場合があります。
データの不一致と、データの不整合は同じではありません。
まず業務上の正しい状態を定義し、その基準に照らして問題を判断する必要があります。

削除状態の正となるデータを決める

現状を把握したら、次に「今後どのデータを正とするのか」を決めます。
ここが移行設計の中心です。

たとえばユーザーの退会状態についてはユーザードキュメントだけを正とし、注文側には親ユーザーの退会状態をコピーしないという方針を採用できます。
この場合、注文の isDeleted がユーザーの退会状態を表しているのであれば、その意味を廃止して、注文自身の削除状態だけを表すように整理します。

逆に、子データも独立したライフサイクルを持ち、子自身が削除される可能性があるなら、子側の削除状態は残します。
ただし、それは親の状態を表すものではないと明確に定義します。

ここでフィールド名だけを見て判断しないことが重要です。
同じ isDeleted でも、コレクションによって意味が異なる可能性があります。
フィールド名ではなく、「その値がtrueになる業務上の条件」を整理することで、状態の責務を正しく分離できます。

新しいルールを先にアプリケーションへ反映する

データ構造を変更する前に、新しい設計ルールをアプリケーション側へ導入する方法も有効です。
特に既存システムでは、古いデータと新しいデータが一定期間混在することを想定する必要があります。

たとえば、これまで子ドキュメントの isDeleted に親の削除状態をコピーしていた場合、新しいコードでは親の状態を正として扱うように変更します。
ただし、既存データには古い値が残っているため、移行期間中は旧データを読み取れるように互換処理を用意します。

この段階では、「書き込みのルール」と「読み取りのルール」を分けて考えると整理しやすくなります。
まず新しいルールで書き込むように変更し、読み取り側では旧データと新データの両方を安全に扱えるようにします。
その後、データ移行が完了した段階で旧形式への対応を削除します。

いわゆる段階的移行です。
データベースとアプリケーションを同じ瞬間に切り替えるのではなく、互換性を保ちながら少しずつ新しいモデルへ寄せていきます。

既存データをバックフィルする

新しいルールがアプリケーションへ導入できたら、既存データを新しい状態へ移行します。
この作業では、いきなり全件を更新するのではなく、対象範囲と移行条件を明確にします。

たとえば親の状態から子の状態を再計算できる場合は、親を基準にして子データを補正します。
しかし、過去の状態が失われていて正確に復元できない場合は、機械的に値を変更してはいけません。
監査ログや更新履歴など、別の情報源を確認する必要があります。

また、大量データを扱う場合は、処理時間やMongoDBへの負荷にも注意します。
一度にすべてのドキュメントを更新するのではなく、一定件数ずつ処理し、進捗を記録して再実行できるようにすると安全です。
途中で停止しても最初からやり直す必要がない設計にしておくことが重要です。

バックフィルでは、処理そのものよりも「再実行しても壊れないこと」が重要です。
同じデータに対して何度実行しても最終的な状態が同じになるよう、可能な限り冪等性を持たせます。

新旧のデータモデルを比較して検証する

移行後は、単にバッチが正常終了したことだけを確認してはいけません。
重要なのは、新しいデータモデルが業務上期待する状態になっているかです。

たとえば次のような観点で検証します。

  • 親の削除状態と子自身の削除状態が混同されていないか
  • 削除済みデータが通常の検索結果に混入していないか
  • 管理画面では必要な削除済みデータを確認できるか
  • 復元処理によって意図しない子データまで復元されないか
  • 集計処理が削除済みデータを誤ってカウントしていないか
  • API、バッチ、管理画面で状態の解釈が一致しているか

可能であれば、本番データの一部を対象にした検証や、移行前後の件数比較も行います。
特に「削除済みユーザー数」「有効な注文数」のような重要な指標については、移行前後で値が大きく変化していないか確認すると、想定外のデータ変更を発見しやすくなります。

古い削除フラグを段階的に廃止する

新しい設計へ移行したからといって、すぐに古いフィールドを削除する必要はありません。
むしろ、一定期間は旧フィールドを残しておくほうが安全な場合があります。

新しいアプリケーションが完全に新しいルールだけを利用し、旧フィールドへの読み書きがなくなったことを確認してから、古いフィールドの廃止を検討します。
ログやメトリクスを利用して、旧フィールドを参照する処理が残っていないか確認するのも有効です。

フィールドの削除は、データ移行の最後に行う「仕上げ」と考えるとよいでしょう。
先にフィールドを消してしまうと、まだ旧仕様に依存している処理が障害を起こす可能性があります。

移行後も不整合を検知できる仕組みを残す

設計変更が完了した後も、品質担保の仕組みは必要です。
理想的なデータモデルを作ったとしても、新しいコードによって再び状態の矛盾が作られる可能性はあります。

そこで、業務上あり得ない状態を検知するチェックを用意しておくと有効です。
たとえば、本来存在してはいけない状態を定期的に検索し、件数を監視します。
重要なデータなら、異常が発生した時点で通知する仕組みを用意してもよいでしょう。

これは「不整合が発生することを前提にする」という意味ではありません。
むしろ、設計上の制約を監視可能な形にすることで、問題が発生したときの発見を早めます。
特に論理削除のように、データが物理的には残り続ける仕組みでは、異常を放置すると後から発見することが難しくなります。

既存システムの論理削除を見直すときに最も避けたいのは、「フラグを減らすためにデータを一括更新して終わり」と考えることです。
重要なのは、現在のデータ、既存コード、業務ルール、新しいデータモデルをすべて一つの流れとして捉えることです。

基本的な進め方は、次の順番にすると整理しやすくなります。

  1. 削除フラグの読み書き箇所を洗い出す
  2. 現在のデータと不整合状態を調査する
  3. 業務上の正しい状態を定義する
  4. 削除状態の正となる場所を決める
  5. 新しい読み書きルールをアプリケーションへ導入する
  6. 既存データをバックフィルする
  7. 新旧データの整合性を検証する
  8. 旧フィールドや旧処理を段階的に廃止する
  9. 移行後も不整合を検知できるようにする

この順番で進めれば、データベースだけを変更してアプリケーションが壊れる、あるいはアプリケーションだけを変更して既存データと矛盾する、といった事故を避けやすくなります。

論理削除の問題を解決する本質は、単に isDeleted を消すことではありません。
削除状態の責務を明確にし、同じ意味の情報を複数箇所へ複製しない構造へ移行することです。
そして、複数ドキュメントを更新する必要が残る場合には、その処理を原子的に扱う仕組みを組み合わせます。

既存システムでは完全な理想論よりも、壊さずに変えることが重要です。
小さく切り分け、互換性を保ち、検証しながら段階的に移行する。
このアプローチこそが、MongoDBの柔軟性を活かしながら論理削除の一貫性問題を解消する現実的な方法です。

論理削除フラグの検索条件とインデックス設計を見直す

MongoDBの論理削除フラグと検索条件を考慮したインデックス設計

論理削除を採用したMongoDBでは、データの整合性だけでなく、検索条件とインデックス設計もセットで見直す必要があります。
論理削除ではデータそのものをコレクションから削除しないため、時間が経つほど「現役のデータ」と「削除済みのデータ」が同じコレクションに蓄積されます。
その結果、アプリケーションでは常に削除済みデータを除外する条件が必要になり、クエリの書き方とインデックスの設計が性能を大きく左右します。

特に注意したいのが、単純に isDeleted: false を付ければよいと考えてしまうことです。
検索条件としては正しくても、既存のインデックスがその条件を効率よく利用できるとは限りません。
データ量が少ないうちは問題が見えなくても、数百万件、数千万件と増えてくると、検索処理の負荷やレスポンスタイムに差が出てきます。

さらに、論理削除フラグが複数のドキュメントへコピーされている設計では、親子それぞれに異なる条件を付ける必要があり、クエリもインデックスも複雑になります。
したがって、前章までで扱った「削除状態を一元管理する」という設計原則は、検索性能の観点からも意味があります。

通常検索では削除済みデータを確実に除外する

まず決めておきたいのが、通常の検索ではどの状態のデータを取得するのかというルールです。
論理削除を採用するなら、多くの画面やAPIでは削除済みデータを利用者へ返さないはずです。
そのため、検索処理には削除状態を考慮した条件が必要になります。

ここで問題になるのが、開発者ごとに検索条件を書いてしまうケースです。
ある処理では isDeleted: false を指定しているのに、別の処理では指定を忘れている。
このような実装が増えると、削除済みデータが意図せず画面へ表示されたり、集計値に混入したりする可能性があります。

MongoDBのクエリそのものを難しくするより、アプリケーション側で「通常取得」という責務をまとめるほうが安全な場合があります。
たとえばデータアクセス層に通常検索のルールを集約し、管理画面や監査処理など、削除済みデータを明示的に扱う必要がある処理だけが別の検索方法を利用する設計です。

重要なのは、削除済みデータを除外することを「各クエリを書く人の注意力」に依存させないことです。
人間が覚えておくルールは、規模が大きくなるほど抜け漏れが発生します。
デフォルトの検索動作として安全な状態を作っておけば、単純な実装ミスを減らせます。

isDeleted だけをインデックスにするべきとは限らない

論理削除を導入すると、「では isDeleted にインデックスを作ればよいのか」と考えがちです。
しかし、ここにも注意点があります。
MongoDBのインデックス設計では、検索条件を単独で見るのではなく、実際のクエリパターンとデータ分布を考える必要があります。

たとえば、ほとんどのドキュメントが isDeleted: false であるコレクションを考えてみましょう。
この場合、isDeleted の値だけでは対象データをあまり絞り込めません。
「未削除である」という条件がほぼすべてのドキュメントに当てはまるからです。

一方で、「特定のユーザーに紐づく未削除の注文を新しい順に取得する」といった検索なら、ユーザー識別子や日時など、実際に検索やソートで使うフィールドと削除状態を組み合わせた複合インデックスを検討する価値があります。

つまり、インデックス設計の出発点は「論理削除を使っているから isDeleted にインデックスを作る」ではありません。
実際に発行されるクエリを起点にして、どのフィールドの組み合わせが検索を効率化するのかを考えることが基本です。

複合インデックスではクエリパターンを基準に考える

MongoDBの複合インデックスを設計するときは、フィールドの並び順も重要です。
たとえば、ユーザーIDで対象を絞り込み、削除状態を確認し、その後に作成日時の降順で並べる検索が頻繁に発生するなら、そのクエリに適したインデックスを検討します。

ただし、ここで「とりあえずすべての条件をインデックスに含める」と考えるのも危険です。
インデックスを増やせば検索が必ず速くなるわけではありません。
書き込み時にはインデックスも更新する必要があるため、不要なインデックスが増えると書き込みコストやストレージ使用量も増加します。

また、複合インデックスにはフィールドの順序による制約があります。
どの検索で使うのかを明確にせず作成すると、期待したクエリで十分に利用されない可能性があります。
したがって、まず代表的な検索パターンを洗い出し、その検索に対してどのインデックスが必要なのかを考えるべきです。

特に論理削除フラグを含む場合、「削除状態」「親ID」「検索対象の属性」「ソートに使う日時」のどれをどの順番に置くかは、実際のデータ分布とクエリによって変わります。
理論だけで決めるのではなく、実データに近い環境で実行計画を確認することが重要です。

explain() で実際の検索計画を確認する

インデックス設計では、作成しただけで満足してはいけません。
MongoDBではクエリの実行計画を確認し、想定したインデックスが利用されているか、どれくらいのドキュメントを調べているかを検証することが重要です。

たとえば検索条件に論理削除フラグを追加したことで、以前より大量のドキュメントを調べるようになっていないか確認します。
レスポンスタイムだけを見ると、キャッシュやデータ量など別の要因によって問題を見落とすことがあります。
そのため、実行計画と合わせて確認するほうが確実です。

また、開発環境では十分に高速でも、本番環境ではデータ量が桁違いになることがあります。
論理削除では削除済みデータも残り続けるため、時間の経過によって検索対象全体が増えることを忘れてはいけません。

そのため、現在の検索速度だけでなく、将来的なデータ増加も考慮します。
削除済みデータが全体の大半を占めるようになった場合、単純なフラグによる除外だけではなく、アーカイブやコレクション分離など、より大きなデータライフサイクルの設計が必要になる可能性もあります。

部分インデックスを検討する

論理削除を利用するケースでは、MongoDBの部分インデックスも選択肢になります。
部分インデックスは、指定した条件を満たすドキュメントだけをインデックスの対象にできるため、未削除データを中心に検索するシステムでは有効な場合があります。

たとえば通常のアプリケーション検索が常に「未削除のデータだけ」を対象にするのであれば、未削除データに限定したインデックスを設計するという考え方ができます。
これにより、削除済みデータまで含めた大きなインデックスを維持する必要性を減らせる可能性があります。

ただし、部分インデックスには適用条件があります。
すべてのクエリで期待通りに利用されるとは限らず、削除済みデータを含めて検索する管理画面などでは別のインデックスが必要になる場合もあります。
そのため、「論理削除だから部分インデックス」という単純なルールではなく、実際のクエリパターンと利用頻度を確認して採用するべきです。

削除済みデータが増え続ける問題にも目を向ける

論理削除の検索性能を考えるとき、インデックスだけに注目してはいけません。
論理削除では、不要になったデータが物理的には残り続けるため、コレクションのサイズそのものが増加していきます。

たとえば毎月大量のデータが作成され、同じ割合で論理削除されるシステムでは、利用可能なデータ量がほとんど変わらなくても、保存されているドキュメント数は増え続けます。
検索時には未削除データだけが必要なのに、データベースには過去の削除済みデータが大量に存在するという状態です。

この問題に対しては、保存期間や業務要件を整理したうえで、一定期間経過後に物理削除する仕組みや、履歴データを別の場所へ移すアーカイブ戦略を検討できます。
もちろん、監査や法令、復元要件などによって削除できないデータもあるため、単純に古いデータを消せばよいわけではありません。

重要なのは、論理削除を「永遠にデータを残す仕組み」と誤解しないことです。
論理削除はあくまでアプリケーション上で削除状態を管理する方法であり、データの保存期間そのものとは別の問題です。

親子ドキュメントの検索条件を増やさない

前章までで扱った親子ドキュメントの設計とも、検索条件は密接に関係します。
親の削除状態を子へコピーしていると、子を検索するときにも親子双方の状態を確認する必要が生じます。

たとえば「親が未削除、かつ子も未削除」という条件を常に要求する設計では、クエリだけでなくインデックス設計も複雑になります。
さらに、親と子のフラグが一致していないデータが存在すると、どちらを正として検索すべきかという問題まで発生します。

一方、親の削除状態を親自身が管理し、子は子自身の状態だけを管理するなら、検索条件の責務も分離できます。
親を対象とする検索では親の状態を確認し、子自身を対象とする検索では子の状態を確認する。
必要に応じてアプリケーション側で親の状態を参照するという構造です。

これは検索クエリを単純化するだけではありません。
状態の責務と検索条件の責務が一致するため、インデックスも設計しやすくなります。
データモデル、クエリ、インデックスを別々の問題として扱わず、一つの設計問題として考えることが重要です。

検索条件を標準化してフラグ漏れを防ぐ

最後に、インデックス以前の問題として、削除フラグの条件漏れを防ぐ仕組みを考えます。
論理削除では、「削除済みを除外する」という条件が頻繁に登場します。
これを各開発者が個別に記述する設計は、長期的にはミスの温床になります。

データアクセス層やリポジトリなどで標準的な検索方法を用意し、通常検索では削除済みデータを取得しないルールを明確にする方法があります。
管理画面や監査処理など、例外的に削除済みデータを取得する場合だけ明示的な操作を要求する設計にすると、通常経路での事故を減らせます。

さらに、テストでも削除済みデータが通常検索へ混入しないことを確認しておくと安心です。
単体テストだけでなく、実際のAPIレスポンスや集計結果まで確認すれば、検索条件の変更による影響を発見しやすくなります。

論理削除のインデックス設計で最も重要なのは、特定のフィールドにインデックスを追加することではありません。
まず、どのデータを通常検索の対象とするのかを明確にし、そのクエリパターンに合わせてインデックスを設計します。
そして explain() などで実際の実行計画を検証し、データ量が増えた場合にも性能が維持できるかを確認します。

さらに、削除済みデータが蓄積し続ける問題については、インデックスだけでは解決できません。
保存期間、アーカイブ、物理削除などのデータライフサイクルまで含めて考える必要があります。
論理削除は「検索条件を一つ追加するだけ」の機能ではなく、データモデル、アプリケーション、データベース性能を横断する設計上のテーマです。

MongoDBの論理削除はフラグの追加ではなくデータ設計として考える

MongoDBの論理削除とデータ一貫性を考えるデータ設計のまとめ

MongoDBで論理削除を実装するとき、最初に思いつきやすいのが isDeleted のような真偽値をドキュメントへ追加する方法です。
実装だけを見ると非常に単純です。
削除するときに true を設定し、通常の検索では false のデータだけを取得すればよい。
小規模なシステムなら、それで十分に機能することもあります。

しかし、システムの規模が大きくなったり、親子関係を持つデータが増えたりすると、問題は「フラグをどう更新するか」から「削除という状態を、データモデルの中でどう表現するか」へ変わります。
ここを単なる実装テクニックとして扱うと、親と子で削除状態が食い違う、復元時に一部だけ戻る、検索条件が複雑になるといった問題が起こりやすくなります。

論理削除について考えるときに重要なのは、削除フラグそのものではなく、削除という業務上の状態をどこで管理し、どのデータから導出するのかを決めることです。
これはMongoDBに限った話ではありませんが、スキーマの自由度が高いMongoDBでは特に意識しておきたいポイントです。

「削除された」という事実を明確に定義する

まず、「削除された」とは何を意味するのかを明確にします。
たとえばユーザーが退会した場合、そのユーザーのドキュメントを検索対象から外したいだけなのか、それとも関連する注文、コメント、設定などもすべて存在しないものとして扱いたいのかでは、必要なデータ設計が大きく異なります。

さらに、削除後も一定期間は復元できるようにするのか、監査目的で履歴を残すのか、法令や業務ルールによって一定期間保存するのかなども考える必要があります。
ここで仕様を曖昧にしたまま isDeleted を追加すると、後からそのフラグにさまざまな意味が詰め込まれてしまいます。

たとえば「退会済み」「非公開」「アーカイブ済み」「利用停止中」をすべて isDeleted で表現すると、検索する側はその値だけでは状態を正しく解釈できません。
状態を表現するフィールドを増やせば解決するとは限りません。
大切なのは、それぞれの状態が本当に独立した概念なのか、どの状態をデータベースに保存し、どの状態をアプリケーションで導出するのかを整理することです。

データ設計の基本は、保存する情報と計算できる情報を区別することです。
削除状態を複数のドキュメントへコピーすることで得られる情報が、親の状態から常に導出できるのであれば、そのコピーには同期コストが発生します。

親子の状態をコピーすると一貫性の制約が増える

論理削除で特に問題になりやすいのが、親ドキュメントと子ドキュメントの両方へ削除フラグを持たせる設計です。
親が削除されたら子も isDeleted: true にする。
このルール自体は理解しやすいのですが、実際には「親と子のフラグが一致していなければならない」という新しい制約をデータベースへ持ち込むことになります。

たとえばユーザーAが退会したとき、そのユーザーに紐づく注文が100件あったとします。
ユーザーの削除処理と同時に100件の注文を更新する必要があるなら、単純な1件の状態変更が大量の書き込み処理へ変わります。
途中で処理が失敗すれば、一部の注文だけが削除済みになる可能性もあります。

さらに難しいのが復元です。
ユーザーを復元したら、以前削除済みにした100件の注文もすべて復元するのでしょうか。
もし注文の中にはユーザー退会とは無関係に削除されたものがあったらどうでしょう。
親の状態を子へコピーした瞬間に、子自身のライフサイクルとの区別が難しくなります。

この問題の本質はMongoDBの更新APIではありません。
同じ意味の状態を複数箇所へ保存していることそのものが、一貫性を維持するための追加コストを生んでいるのです。

状態の正を一箇所に集約する

そこで有効なのが、削除状態の正となる場所を決める設計です。
ユーザーの退会状態はユーザードキュメントだけが管理し、注文の削除状態は注文自身が管理する。
このように状態の責務を分離します。

この設計なら、ユーザーが退会したからといって、すべての注文に同じ削除フラグをコピーする必要はありません。
注文が存在するという事実と、ユーザーが現在利用可能かどうかという事実を別々に管理できます。

もちろん、画面上では「退会ユーザーの注文を表示しない」という判断が必要になるでしょう。
その場合は、ユーザーの状態と注文の状態を組み合わせて表示可否を決定します。
ここで重要なのは、表示可否というアプリケーション上の判断まで、データベースに重複して保存しないことです。

すべてを正規化すればよいという話でもありません。
アクセス頻度が非常に高く、性能上の理由から状態を非正規化する必要がある場合もあります。
その場合は、そのフィールドを「正規データ」とするのか「キャッシュ」とするのかを明確にします。
キャッシュなら不整合が発生した場合の再構築方法まで含めて設計する必要があります。

ドキュメントの境界をライフサイクルで決める

MongoDBのデータ設計では、どの情報を同じドキュメントに入れるかも論理削除と深く関係します。
MongoDBでは関連するデータを埋め込む設計もできますが、すべてを一つのドキュメントにまとめればよいわけではありません。

一緒に作成され、一緒に変更され、一緒に削除されるデータは、同じドキュメントにまとめることで状態管理がシンプルになる場合があります。
逆に、それぞれが独立したライフサイクルを持つデータを無理に同じドキュメントへまとめると、別の問題が生じます。

たとえばユーザー設定のようにユーザーとほぼ同じライフサイクルを持つ情報なら、設計次第ではユーザードキュメントへの埋め込みを検討できます。
一方、注文履歴のようにユーザーとは独立して保存期間や状態管理を行うデータなら、別ドキュメントとして扱うほうが自然な場合があります。

つまり、論理削除の設計は「どこに isDeleted を付けるか」だけで決まりません。
「このデータは誰と同じライフサイクルを持つのか」という問いから考える必要があります。

削除と物理削除を使い分ける

論理削除を採用すると、すべてのデータを永続的に論理削除する設計になりがちですが、これも見直す余地があります。
削除後に復元する必要がなく、監査や履歴として保存する必要もないデータなら、物理削除のほうがシンプルなケースがあります。

論理削除には、検索条件への影響、インデックスへの影響、ストレージの増加、古いデータの蓄積、バックアップサイズなど、複数のコストがあります。
特に長期間運用するシステムでは、削除済みデータが増え続けることで、検索性能や運用コストに影響する可能性があります。

そのため、「削除する」という要件が出たら必ず論理削除を採用するのではなく、削除後のデータに何の価値があるのかを考えます。
復元性が必要なのか、監査証跡が必要なのか、一定期間だけ保持すればよいのか。
それぞれの要件に応じて、論理削除、物理削除、アーカイブなどを使い分けます。

データ設計と検索設計を一緒に考える

削除状態をどこに持たせるかは、検索方法にも直接影響します。
親と子の両方に削除フラグがあれば、検索時にも両方の状態を考慮しなければならない場合があります。
その結果、クエリやインデックス設計が複雑になります。

一方で、状態の責務を明確に分離できていれば、どのコレクションでどの状態を確認すべきかも明確になります。
通常検索では削除済みを除外し、管理画面や監査処理では必要に応じて削除済みデータも取得する、といったルールをデータアクセス層へ集約できます。

ここで重要なのが、データモデル、クエリ、インデックスを別々に設計しないことです。
どのデータを保存するのか、どの条件で取得するのか、どの検索を高速化するのかは互いに影響します。
論理削除フラグを追加するだけでは、検索性能の問題まで自動的に解決されるわけではありません。

「不正な状態を作れない」設計を目指す

一貫性を維持するうえで強力なのは、不整合を検知する仕組みを増やすことだけではありません。
そもそも不整合が成立しにくいデータモデルを作ることです。

親と子の両方に同じ意味の削除状態を保存すれば、「両者が一致している」という制約が必要になります。
しかし、親の削除状態を親だけで管理すれば、その制約自体をなくせます。

これはプログラミングでいうところの不変条件の設計にも近い考え方です。
後から大量のチェックコードで守るのではなく、データモデルそのものを整理して、誤った状態を作る余地を減らします。

もちろん、現実のシステムでは完全に不整合をなくすことは難しいでしょう。
それでも、状態の重複を減らし、責務を分離し、更新経路を集約するだけで、管理すべき一貫性の範囲はかなり小さくできます。

MongoDBの柔軟性を「何でも保存できる」と誤解しない

MongoDBはスキーマの柔軟性が高いため、必要になったフィールドを追加していく開発スタイルとも相性がよいデータベースです。
しかし、柔軟にフィールドを追加できることと、設計上どんな状態を保存してもよいことは別の話です。

特に論理削除では、最初は便利だった isDeleted が、数年後には複数の意味を持つ状態管理フィールドになっていることがあります。
親の削除状態をコピーした値、子自身の削除状態、画面から隠すための状態などが混在すると、フィールドの意味を理解するだけでコードを追いかけなければなりません。

MongoDBを柔軟に使うからこそ、「この情報は事実なのか」「別のデータから導出できないか」「この状態を誰が管理するのか」という設計上の問いを持つことが重要です。
柔軟性は設計を不要にする機能ではなく、設計上の選択肢を増やす機能です。

論理削除をうまく設計するための結論は、意外とシンプルです。
削除フラグを増やす前に、削除という状態の意味を定義する。
そして、同じ意味の状態を複数のドキュメントへコピーする必要がないか検討します。

もし親の状態から子の状態を常に判断できるのであれば、その状態を子へ保存しないほうが一貫性を維持しやすくなります。
子自身に独立した削除という概念があるなら、子側の状態として管理します。
複数ドキュメントをどうしても同時に変更する必要がある場合には、トランザクションや競合制御を組み合わせます。

そして最後に、検索条件やインデックス、保存期間、アーカイブまで含めて考えます。
論理削除は一つのフラグを追加するだけの小さな実装に見えて、実際にはデータのライフサイクル全体を設計するテーマです。

MongoDBで一貫性のあるシステムを作るなら、「どうやって削除フラグを同期するか」よりも、「そもそも同期が必要な状態を作らずに済むか」を考える。
この視点へ切り替えることが、論理削除の罠を避けるための最も本質的な一歩です。

コメント

タイトルとURLをコピーしました