物理削除と何が違う?DynamoDBで論理削除を実装する際のデータモデリングの注意点と解決策

DynamoDBにおける論理削除のデータモデリング注意点と解決策を俯瞰したアイキャッチ画像 データベース

DynamoDBにおいて「削除」をどうモデリングするか。
この問いに対して、多くの開発者が真っ先に検討するのが論理削除(ソフトデリート)です。
しかし、経験的に言わせてもらえば、RDBMSの常識をそのまま持ち込むと、パフォーマンスとコストの両面で予期せぬ落とし穴に直面します。
物理削除が単にアイテムをDeleteItemするだけの不可逆な操作であるのに対し、論理削除は「削除フラグ」や「ステータス」を付与して存在を隠蔽するアプローチです。
一見シンプルに見えるこの差が、DynamoDBというスケールアウトを前提としたNoSQLのデータモデリングでは、検索設計やアクセスパターンに重大な制約を課すのです。

まず、両者の違いを整理しておきましょう。

観点 物理削除 論理削除
データ復元 原則不可(バックアップから復元) フラグ切り替えで即時復元可能
ストレージコスト 削除済みアイテムは容量を消費しない 削除済みアイテムも容量と課金対象のまま
クエリ設計 存在しないキーへのアクセスは即座に空を返す 毎回のクエリで「未削除」のフィルタ条件が必須
セカンダリインデックス インデックスからもアイテムが消える インデックスに残り続けるため、スキュー問題が顕在化しやすい

この比較だけでもお分かりの通り、論理削除は「監査証跡の保持」や「誤操作からのリカバリ」というビジネス要件には強力ですが、それを実現するためのモデリングを怠ると、全件スキャンやフィルタリングによるRCUの無駄遣いという代償を払うことになります。
特に、is_deletedのようなフラグ属性にGSI(グローバルセカンダリインデックス)を張る設計は、ほとんどのアクセスで「false」を検索するため、ホットパーティションを生み出し、スループットの不均等を招く危険性が高いです。

では、どうすればよいのか。
私が実践的に推奨するのは、削除状態を「ステータス属性」で多値に持つか、ソートキーに削除日時を組み込むことで、有効データと削除データを物理的に異なるパーティションやアイテムコレクションに分離する戦略です。
たとえば、PKをUser#<ID>、SKをMetadata#activeとMetadata#deleted_<timestamp>で使い分ければ、クエリ時にSKの条件で効率的に絞り込めます。
あるいは、削除済みデータを別テーブルに移動するバッチ処理と組み合わせるのも一案ですが、リアルタイム性が求められるケースでは、アプリケーションレベルで「削除済みを除外する」というデフォルトのフィルタを実装するよりも、アクセスパターンそのものを再定義する発想が必要です。

ここで一つ、単純なフラグ方式の危険性をコードで示します。

{
  "UserId": "123",
  "RecordId": "456",
  "isDeleted": false,
  "data": "..."
}

このアイテムに対して、isDeleted = false でGSIを設計したとしましょう。
アクセスが集中するアクティブユーザーはすべて同じインデックスパーティションに集約され、結果としてスロットリングや分割不可のスキューの原因になります。
この問題を回避するには、削除フラグではなく、ライフサイクルそのものをレコードの生存期間として扱う視点が有効です。
TTL(Time To Live)を活用して物理削除と連動させるハイブリッド戦略も、コスト最適化の観点からは無視できません。

結局、DynamoDBで論理削除を成功させる鍵は、「どのクエリで、どのように削除済みデータを扱うか」を、テーブル設計の最初の段階で厳密に定義することに尽きます。
本当にすべての削除データを保持する必要があるのか。
あるいは、一定期間経過後はTTLで自動消去してよいのか。
ビジネス要件とアクセスパターンを照らし合わせ、「削除とは状態遷移の一種」と捉え、ソートキーやオーバーロードされた属性を駆使したモデリングを検討してみてください。
次のセクションでは、具体的なパターン別の設計例と、実際のクエリ性能を検証したベンチマーク結果を交えながら、より実践的な実装アプローチを掘り下げていきます。
あなたのユースケースに最適な戦略はどれか、一緒に見極めていきましょう。

  1. なぜいま、DynamoDBにおける削除戦略が問われているのか
    1. スケーラビリティと整合性のトレードオフ
    2. RDBMSの常識が通用しない理由
  2. 物理削除と論理削除の本質的な違いを整理する
    1. ストレージコストと復元性の比較
    2. クエリ性能に与える影響の差
  3. 論理削除で最も使われる「フラグ属性」が招く3つの危険
    1. フィルタリングによるRCUの浪費
    2. GSIを用いた場合のスキュー問題
    3. 削除済みデータの累積によるパーティションサイズ肥大化
  4. アクセスパターンを無視したGSI設計は失敗する
    1. インデックスキーの選択がパフォーマンスを左右する
    2. オーバーロードされたソートキーの設計指針
  5. ソートキーを活用して有効データと削除データを分離する戦略
    1. プレフィックスによる物理的区分け
    2. タイムスタンプを組み込んだレンジクエリの最適化
  6. TTLと論理削除をハイブリッドで運用する現実解
    1. 保持期間を定めた自動物理削除の仕組み
    2. コストと可用性のバランスを取る閾値設計
  7. ステータス属性による多値管理とクエリ例
    1. アクティブ・アーカイブ・削除予定の3状態モデル
    2. バッチ処理での状態遷移と非同期更新
  8. ユースケース別ベストプラクティス – 監査要件とリアルタイム性の両立
    1. 金融・医療分野での厳格な保持要件への対応
    2. ソーシャルアプリでの柔軟な削除復元機能
  9. まとめ – 論理削除は「状態遷移」と捉え、アクセスパターンから逆算せよ

なぜいま、DynamoDBにおける削除戦略が問われているのか

DynamoDBのスケーラビリティと削除戦略の関係性を示す図

DynamoDBがフルマネージドのNoSQLデータベースとして広く普及するにつれ、その運用設計において「削除」がいかにクリティカルなテーマかが顕在化してきました。
特に、スケールアウトを前提としたアーキテクチャでは、単一ノードのRDBMSとは異なる制約が数多く存在します。
そして、多くのチームが「とりあえず論理削除でいいだろう」と実装した結果、運用開始後にパフォーマンス低下やコスト超過という現実的な痛みを経験しているのです。

では、なぜ削除戦略がこれほどまでに重要なのでしょうか。
その核心は、DynamoDBの課金モデル(読み書きキャパシティユニットとストレージ容量)と、パーティション分割によるデータ分散の仕組みにあります。
削除されたデータを単に「見えなくする」だけでは、ストレージも読み取り容量も無駄に消費され続けます。
さらに、アクセスパターンに適合しないインデックス設計は、スロットリングや不均等な負荷分散を招くため、スケールメリットを完全に損なうリスクすらあります。

そうした背景から、今あらためて「物理削除 vs 論理削除」だけでなく、「状態遷移」としての削除をどうモデリングするかが、設計フェーズの最初に問われるべき重要な論点となっているのです。

スケーラビリティと整合性のトレードオフ

DynamoDBは結果的整合性と強い整合性を選択できる柔軟性を持ちますが、この選択は削除戦略と直結します。
物理削除の場合、DeleteItemが成功した直後に強い整合性読み取りを行えば、そのアイテムは確実に存在しなくなります。
ところが論理削除では、削除フラグを更新するUpdateItemと、その後のクエリが「未削除」を条件に含むかどうかで、整合性の扱いが複雑化します。

具体的には、削除フラグをfalseからtrueに変更した直後に、別のクエリが古いインデックスを参照してしまうと、削除済みデータが一時的に見えてしまう可能性があります。
この問題を回避するために強い整合性を強制すると、スループットが制限され、スケーラビリティの恩恵が薄れます。

  • 結果的整合性を許容する場合、削除直後の一貫性を保証するためにアプリケーション側でリトライやバージョンチェックが必要になる
  • 強い整合性に統一すると、パーティションあたりのスループット上限に達しやすくなり、水平スケールの効果が減衰する

つまり、整合性レベルとスケーラビリティはトレードオフの関係にあり、このバランスをどう取るかが設計の最初の関門となります。
論理削除を採用するなら、このトレードオフを明確に許容した上で、クエリパターンごとに整合性要件を切り分ける工夫が必須です。

RDBMSの常識が通用しない理由

RDBMSで培った「削除フラグ+インデックス」というパターンは、DynamoDBではほぼそのままでは通用しません。
その最大の理由は、DynamoDBがセカンダリインデックス(GSI/LSI)をパーティションキーとソートキーの組み合わせで管理しており、インデックス内の属性値の分布がパーティション分割に直結する点にあります。

RDBMSでは、is_deleted = falseという条件のインデックスを作成すれば、オプティマイザがそのインデックスを選択し、スキャン範囲を絞り込んでくれます。
しかしDynamoDBのGSIでは、インデックスのパーティションキーにis_deletedを指定した場合、値がfalseのアイテムはすべて単一のパーティションに集中します。
アクティブなデータが大半を占める一般的なシステムでは、このパーティションが常に過負荷状態となり、スロットリングが頻発するのです。

また、RDBMSではUPDATE文で削除フラグを立てた後も、同じテーブル内で他のカラムとともに管理されますが、DynamoDBではアイテムサイズが肥大化するとRCU消費が増加します。
削除済みデータをいつまでも保持し続けると、パーティションあたりのサイズ上限(10GB)に達するリスクも無視できません。

さらに、RDBMSのトランザクションや外部キー制約といった機能はDynamoDBにはなく、アプリケーションレベルで整合性を担保する必要があります。
このため、論理削除を単なるフラグ切り替えで実装すると、関連エンティティとの整合性維持が著しく困難になるケースも少なくありません。

結論として、DynamoDBでは「削除」をデータの「状態」として捉え、アクセスパターンを主役にしたモデリングが求められます。
RDBMS的な正規化思考を捨て、クエリ単位で最適なキー設計を考えることが、スケーラブルなシステムへの第一歩だと言えるでしょう。

物理削除と論理削除の本質的な違いを整理する

物理削除と論理削除のデータ永続性とコスト比較表

削除戦略を語る上で、まず物理削除と論理削除の本質を明確に区別しておかなければ、後の設計判断が曖昧になります。
物理削除はDynamoDBのDeleteItemAPIを呼び出し、アイテムをストレージから完全に消去する操作です。
一方、論理削除はアイテム自体を残したまま、何らかの属性(フラグやステータス)を用いて「削除済み」とマーキングし、アプリケーション側でそのアイテムを参照対象から除外する手法を指します。

この二つの違いは単に「データが残るか残らないか」だけにとどまりません。
課金構造、クエリ設計、運用保守の複雑性にまで波及する根源的な差があるのです。
ここでは、ストレージコストと復元性、そしてクエリ性能という二つの切り口から、両者を徹底的に比較していきます。

ストレージコストと復元性の比較

まず、ストレージコストの観点を見てみましょう。
物理削除では、DeleteItemが成功した瞬間にそのアイテムが占有していたストレージ容量が解放され、以降は課金対象から外れます。
ただし、DynamoDBのバックアップ機能を有効にしている場合は、バックアップデータ自体は別途課金される点に注意が必要です。
しかし、運用中のテーブルに対するストレージコストは、アクティブなアイテムのサイズと件数にのみ依存するため、長期運用では非常に明確で予測しやすいコスト構造となります。

それに対して論理削除では、削除フラグを立てただけではアイテムの物理サイズが変わりません。
更新によって属性が増えれば、むしろアイテムサイズが増加する場合さえあります。
そして、この削除済みアイテムもアクティブなアイテムと同様にストレージ課金の対象です。
大量の削除が発生するシステムでは、論理削除されたデータが累積し、数か月後には予想を超えるストレージコストを招くことが少なくありません。

次に復元性です。
物理削除は、いわゆる「なかったこと」にする操作であり、削除後に復元するには、以下のいずれかの手段が必要です。

  • ポイントインタイムリカバリ(PITR)を有効にしていれば、任意の時点にテーブルを復元する
  • オンデマンドバックアップからリストアする
  • 変更データキャプチャ(DynamoDB Streams)を利用して事前にイベントを保存しておく

いずれも運用コストや遅延が伴い、即時復元は原則としてできません。
一方、論理削除ではフラグをfalseに戻すUpdateItemを発行するだけで、そのアイテムは即座に「復元」されます。
この即時性は、誤削除からのリカバリや、ユーザー自身による削除取り消し機能を実装する際に非常に大きなアドバンテージです。

以上の差異を表にまとめます。

比較項目 物理削除 論理削除
ストレージコスト 削除後即座に解放され、課金停止 削除後も容量を維持し、課金継続
復元の即時性 バックアップからの復元が必要で時間を要する フラグ更新のみで即時復元可能
バックアップ併用時のコスト PITRやバックアップは別途課金 バックアップに加え、アクティブと同額のストレージ課金が継続

この比較だけを見ると、論理削除には復元性という大きなメリットがある一方で、コスト面では物理削除に軍配が上がることがわかります。
では、このコスト差を埋めるために、論理削除済みデータを一定期間後にTTLで自動消去するハイブリッド戦略が有効になるのですが、それについては後のセクションで詳しく扱います。

クエリ性能に与える影響の差

次に、クエリ性能の観点から両者を比較します。
物理削除の場合、削除されたアイテムはテーブルから完全に消えるため、そのアイテムを対象にしたGetItemやQueryは、単に「見つからない」という結果を返すだけで、余計な読み取り容量を消費しません。
また、セカンダリインデックスからも即座にエントリが削除されるため、インデックスのサイズも小さく保たれます。
つまり、アクティブなデータだけがテーブルとインデックスに存在する状態が維持されるため、すべてのクエリが効率的に動作するのです。

しかし論理削除では状況が一変します。
削除済みアイテムはテーブルにもインデックスにも残り続けるため、次のような性能上の課題が生じます。

  • フィルタリングの強制:アクティブなアイテムだけを取得したい場合、必ずis_deleted = falseのようなフィルタ条件をクエリに含める必要があります。このフィルタは、インデックスで絞り込んだ後に適用されるため、実際には削除済みアイテムもいったん読み込まれた上で除外されることになります。その結果、読み取りキャパシティユニット(RCU)が削除済みデータの分だけ余計に消費されます
  • インデックスの肥大化:GSIやLSIに削除済みアイテムも含まれるため、インデックス自体のサイズが肥大化し、スキャン範囲が広がります。特に、パーティションキーが削除フラグそのものである場合、ほとんどのアイテムが同じパーティションキー(例:false)に集中するため、インデックスパーティションがホットスポット化し、スループット制限に達しやすくなります
  • パーティションサイズの上限リスク:DynamoDBの各パーティションは最大10GBまでしかデータを保持できません。論理削除でデータを残し続けると、アクティブデータが少なくても削除済みデータがパーティションを圧迫し、やがて分割処理が発生します。分割自体は自動で行われますが、分割中のパフォーマンス変動や、分割後のキー再設計が必要になるケースもあります

ここで一つ、具体的なクエリ例を考えてみましょう。
ユーザーIDをパーティションキー、作成日時をソートキーとするテーブルで、isDeleted属性を持つアイテムに対して、アクティブな最新10件を取得したいとします。

-- これはDynamoDBの実際の構文ではありませんが、概念を示します
SELECT * FROM UserActivities
WHERE UserId = 'U123'
  AND isDeleted = false
ORDER BY CreatedAt DESC
LIMIT 10;

このクエリをDynamoDBで実現するには、UserIdをパーティションキー、CreatedAtをソートキーとするLSIを作成し、かつisDeletedでフィルタリングするのが一般的です。
しかし、このLSIには削除済みアイテムも含まれるため、実際には作成日時の降順で多くのアイテムをスキャンし、その中からisDeleted=falseのものだけをフィルタリングします。
アクティブなデータが全体の1割しかない場合、10件取得するために100件分の読み取り容量が消費される計算になります。
これが多数のクエリで発生すれば、RCUの浪費は膨大です。

一方、物理削除であれば、削除済みアイテムはそもそも存在しないため、LSIはアクティブデータのみを含み、LIMIT 10はそのまま10件の読み取りで完了します。
この差は、アクセス頻度が高くなるほど顕著になり、論理削除は「見かけ上の柔軟性」と引き換えに、クエリ性能とコストで大きな代償を払う可能性があることを認識しておくべきでしょう。

論理削除で最も使われる「フラグ属性」が招く3つの危険

isDeletedフラグを用いた場合のRCU浪費とスキューの発生メカニズム

実務において、論理削除の実装で最もポピュラーなパターンは、アイテムにisDeletedやdeletedといったブール型のフラグ属性を追加し、削除時にtrueに更新する方法です。
このアプローチは直感的で実装も容易なため、多くの開発者がまず選択します。
しかし、DynamoDBという分散データベースの上では、この一見単純な設計がパフォーマンスとコストの両面で深刻な危険を内包していることを、私は数多くのプロジェクトで目の当たりにしてきました。

ここでは、その危険を3つの側面から分解し、それぞれがなぜ発生し、どのような影響を及ぼすのかを定量的に解説します。
いずれも「フラグ属性」という選択に起因するものであり、これらを理解せずに実装を進めると、運用開始後のリファクタリングが極めて困難になるでしょう。

フィルタリングによるRCUの浪費

まず最初の危険は、クエリ実行時に発生する読み取りキャパシティユニット(RCU)の浪費です。
DynamoDBのQueryやScanでは、フィルタ式(FilterExpression)を指定できますが、このフィルタはインデックスによる絞り込みが完了した後に適用されるという重要な特性があります。
つまり、isDeleted = falseというフィルタを書いたとしても、DynamoDBはまずパーティションキーとソートキーの条件に合致するすべてのアイテム(削除済みも含む)を読み取り、その後にフィルタで除外するのです。

具体例を挙げましょう。
あるユーザーのアクティビティ履歴を保持するテーブルで、パーティションキーにUserId、ソートキーにTimestampを設定し、isDeletedフラグを持つとします。
最新のアクティブな10件を取得するために、UserId = 'U123'でQueryし、Timestampの降順でLimit 10を指定し、さらにFilterExpressionでisDeleted = falseを追加したとします。
このとき、そのユーザーが過去に100件のアクティビティを持ち、そのうち最新の20件が削除済みだった場合、DynamoDBはソートキーの降順でアイテムをスキャンし、Limit 10に達するまでに、削除済みの20件をスキップして次のアクティブな10件に到達するために、合計30件のアイテムを読み取ります。
結果的に、30件分のRCUを消費して、実際に返却されるのは10件だけです。

この比率が悪化するケースは珍しくありません。
アクティブデータが全体の1割しかなく、削除済みデータが9割を占めるようなサービスでは、同じ10件を取得するのに100件近くの読み取りが発生し、RCUコストが10倍に跳ね上がります。
さらに、このようなクエリが1秒間に数百回実行されれば、RCUの無駄遣いが月間コストに直結することは容易に想像できるでしょう。

GSIを用いた場合のスキュー問題

2つ目の危険は、グローバルセカンダリインデックス(GSI)をフラグ属性に設定したときに顕在化するスキュー問題です。
多くの設計者は、アクティブなアイテムだけを高速に検索するために、isDeletedをGSIのパーティションキーとして採用します。
しかし、この選択はインデックス内のデータ分散を著しく不均一にします。

なぜなら、通常の運用では、isDeleted = false(アクティブ)のアイテム数がtrue(削除済み)よりも圧倒的に多いからです。
GSIのパーティションキーがfalseであるすべてのアイテムは、単一のインデックスパーティションに格納されます。
このパーティションには、テーブル全体のアクティブデータが集中するため、読み取り要求もそのパーティションに集中します。
結果として、そのパーティションのスループット上限(デフォルトで1000 RCUまたは1000 WCU)に達しやすくなり、スロットリングが頻発します。

  • スロットリングが発生すると、アプリケーションはリトライ処理を強いられ、レイテンシが増大する
  • パーティション分割が自動で行われても、キー設計が偏っている限り、新たなパーティションにも同様の偏りが継承される
  • 結果的に、DynamoDBの水平スケーリングのメリットが完全に損なわれ、プロビジョンドキャパシティの無駄な増強を余儀なくされる

このスキュー問題は、テーブル全体のアイテム数が数百万件を超えると、ほぼ確実に顕在化します。
そして、一度GSIをそのように設計してしまうと、インデックスの再構築には莫大な時間とコストがかかるため、修正が極めて困難です。

削除済みデータの累積によるパーティションサイズ肥大化

3つ目の危険は、削除済みデータが蓄積され続けることで、テーブルを構成する各パーティションのサイズが肥大化し、10GBという上限に達するリスクです。
DynamoDBのパーティションは、ストレージサイズが10GBを超えるか、またはそのパーティションのスループット上限に達すると、自動的に分割されます。
しかし、論理削除でアイテムを残し続けると、アクティブデータが少なくても、削除済みデータがパーティションを圧迫し続けます。

たとえば、1日あたり10万件の新規データが生成され、そのうち8万件が数日後に論理削除されるシステムを考えます。
1か月後には約240万件の削除済みアイテムがパーティション内に残り、それぞれのアイテムサイズが平均1KBだとすれば、合計で2.4GBのストレージを消費します。
これが半年続けば14.4GBとなり、パーティション分割が繰り返し発生します。

分割自体はマネージドサービスとして自動で行われますが、分割中はパフォーマンスの変動が生じる可能性があります。
さらに深刻なのは、削除済みデータがバックアップやPITRの対象にも含まれるため、バックアップストレージコストも増加する点です。
また、パーティション数が増えれば、それだけ管理オーバーヘッドが増し、結果的整合性の遅延や、クエリの際に複数パーティションにまたがるスキャンが発生する確率も高まります。

これらの3つの危険は、いずれも「フラグ属性」という単純な発想に起因します。
では、どうすればこれらの問題を回避できるのか。
次のセクションでは、ソートキーを活用した分離戦略や、TTLとのハイブリッド運用など、より堅牢なモデリング手法を具体的に提示していきます。

アクセスパターンを無視したGSI設計は失敗する

アクセス頻度とインデックスキー設計のミスマッチが性能劣化を招く

DynamoDBの設計で最も頻繁に遭遇する失敗パターンの一つが、アクセスパターンを十分に分析せずにGSI(グローバルセカンダリインデックス)を追加することです。
「とりあえずこの属性で検索できるようにしておこう」という発想でインデックスを増やすと、ストレージコストの増加、書き込みスループットの消費、そして何よりクエリ性能の不安定化を招きます。
GSIはテーブルと独立したパーティション構造を持つため、そのキー設計が実際のクエリ要件と合致していなければ、インデックスは単なるコスト増要因に成り下がります。

成功するGSI設計は、「どのようなクエリが、どのくらいの頻度で、どのような選択性で実行されるか」という問いにすべて答えた上で初めて成立します。
ここでは、インデックスキーの選択がパフォーマンスに与える影響と、それを最適化するための「オーバーロードされたソートキー」という強力な設計手法について、具体的な指針を交えながら解説します。

インデックスキーの選択がパフォーマンスを左右する

GSIのパフォーマンスは、そのパーティションキーとソートキーの組み合わせによって決定的に変わります。
パーティションキーはデータの分散先を決定し、ソートキーはそのパーティション内での順序と絞り込み範囲を提供します。
ここで、よくある誤りはパーティションキーにカーディナリティの低い属性を選んでしまうことです。

先述のisDeletedフラグをパーティションキーにした例はその典型ですが、これ以外にもstatus(数種類の値だけ)やcategory(大分類)なども同様のリスクをはらみます。
カーディナリティが低いと、特定のパーティションキー値にデータが集中し、ホットパーティションが発生します。
逆に、カーディナリティが高すぎる(例:UUID)と、各パーティションのデータ量が少なすぎて、ソートキーによる範囲検索のメリットが薄れます。

理想的なパーティションキーは、アクセスされるデータができるだけ均等に分散され、かつ個々のクエリが単一のパーティションに収まるようなカーディナリティを持ちます。
たとえば、ユーザーIDやテナントIDは、アクセスがその単位で行われる場合に優れた選択です。

次に、ソートキーの選択も重要です。
ソートキーには、クエリで頻繁に範囲指定や順序付けを行う属性を設定します。
たとえば、作成日時であれば、最新順や期間指定のクエリが効率的になります。
しかし、ソートキーは1つしか持てないため、複数の絞り込み条件を満たしたい場合には工夫が必要です。
ここで役立つのが、ソートキーのオーバーロードという手法です。

オーバーロードされたソートキーの設計指針

オーバーロードとは、ソートキーに複数の意味を持たせ、異なる種類のエンティティや状態を同一のインデックスで扱えるようにする設計パターンです。
具体的には、ソートキーの値を<種別>#<識別子>のような形式で構成し、BEGINS_WITHやBETWEENといった条件を使って目的のアイテム群だけを効率的に抽出します。

例えば、ユーザー関連のアイテムを一つのテーブルで管理する際、PKをUser#<UserId>とし、SKを以下のようにオーバーロードします。

  • プロフィール情報:Profile#<UserId>
  • アクティビティログ:Activity#<Timestamp>
  • 削除済みログ:Deleted#<Timestamp>

そして、GSIのパーティションキーにUserId、ソートキーにSKをそのまま設定すれば、特定ユーザーのアクティビティだけを取得するクエリ(SK BEGINS_WITH 'Activity#')と、削除済みログだけを取得するクエリ(SK BEGINS_WITH 'Deleted#')が、同一のGSIで異なるアクセスパターンを実現できます。
これにより、削除済みデータとアクティブデータを物理的には同じインデックスに含めつつ、クエリ時にプレフィックスで分離できるため、フィルタリングによるRCU浪費を回避できます。

オーバーロードを設計する際の指針をいくつか挙げます。

  • プレフィックスはクエリ単位で定義する:どのような検索パターンがあるかを洗い出し、そのパターンごとに一意なプレフィックスを割り当てます。プレフィックス同士が衝突しないように、末尾に区切り文字(#や:)を含めるのが一般的です
  • ソートキーの先頭に最も頻繁にフィルタリングしたい属性を置く:BEGINS_WITHは先頭一致なので、検索条件として使う要素を前方に配置します。たとえば、状態と日時の両方で絞りたい場合は、Status#<status>#<timestamp>のようにし、BEGINS_WITH 'Active#'でアクティブな全アイテムを、さらにBETWEENで日時絞り込みも可能にします
  • インデックスのプロジェクションを最小限に:GSIには必要な属性だけをプロジェクションすることで、ストレージと書き込みコストを削減します。すべての属性をプロジェクションする(ALL)は、どうしても必要な場合以外は避けましょう
  • スキューを防ぐためにパーティションキーにランダムサフィックスを追加する:どうしてもパーティションキーが偏る場合は、パーティションキー値の末尾に乱数やハッシュ値を付与し、複数のパーティションに分散させるテクニックもあります。ただし、この場合はクエリ時に全サフィックスをスキャンする必要があるため、アクセスパターンとのトレードオフを慎重に評価します

以下の表は、オーバーロード前と後でのクエリ効率の比較です。

設計方式 クエリ例 読み取り対象アイテム数 フィルタ後の有効件数 RCU効率
フラグ方式(GSI PK=isDeleted) アクティブ最新10件 100件(全スキャン相当) 10件 低
オーバーロード方式(SKプレフィックス) SK BEGINS_WITH ‘Active#’ で最新10件 10件 10件 高

このように、オーバーロードはインデックス一つで複数のアクセスパターンをカバーできる柔軟性を持ちながら、フィルタリングに頼らない効率的なクエリを実現します。
ただし、ソートキーの設計が複雑化し、アプリケーション側でプレフィックスの生成・解析ロジックが増えるというトレードオフもあります。
その複雑性を許容できるかどうかは、システムの要件次第ですが、スケーラビリティとコスト効率を重視するなら、この手法を積極的に採用する価値は十分にあるでしょう。

ソートキーを活用して有効データと削除データを分離する戦略

SKプレフィックスを用いて有効と削除を物理的に区分けする構造

前節ではオーバーロードされたソートキーの設計指針を概説しましたが、ここではその応用として、有効(アクティブ)なデータと削除済みデータを物理的に分離する具体的な戦略に焦点を当てます。
このアプローチの本質は、削除状態をフラグという属性値ではなく、ソートキー(SK)のプレフィックスとして表現することで、クエリ時にインデックススキャン自体を限定し、フィルタリングを不要にする点にあります。

DynamoDBのQuery操作は、同じパーティションキーを持つアイテム群をソートキーの範囲で効率的に取得できます。
この特性を利用し、ソートキーの先頭部分に「状態」を表すコードを埋め込むことで、アクティブなアイテムだけを、あるいは削除済みアイテムだけを、物理的な読み取り範囲のレベルで区別できるようになります。
これは、後付けのフィルタリングとは次元の異なる性能向上をもたらします。

プレフィックスによる物理的区分け

最もシンプルかつ効果的な方法は、ソートキーの先頭にACTIVE#とDELETED#といった静的なプレフィックスを設けることです。
たとえば、ユーザーIDをパーティションキー(PK)とするテーブルにおいて、各ユーザーの投稿データを管理する場合、SKを以下のように構成します。

  • アクティブな投稿:ACTIVE#<投稿ID>#<作成日時>
  • 削除済みの投稿:DELETED#<投稿ID>#<削除日時>

この設計では、特定ユーザーのアクティブな投稿だけを取得したいときは、PK = 'User#U123' AND SK BEGINS_WITH 'ACTIVE#'というQueryを発行します。
DynamoDBはこの条件を満たすアイテムだけを物理的にスキャンするため、削除済みデータは一切読み込まれません。
結果的に、RCUの消費はアクティブデータの件数に完全に比例し、無駄がゼロになります。

このプレフィックス方式のメリットは、以下のように整理できます。

  • フィルタ式が不要になるため、読み取り容量の浪費が根本的に解消される
  • アクティブと削除済みでインデックスが分かれていないため、GSIの数が増えず、ストレージコストも抑えられる
  • 削除済みデータも同じテーブル内に保持されるため、復元時にはSKをACTIVE#に更新するだけで即座に有効化できる
  • バックアップやPITRの対象としても、全データが一貫して管理される

ただし、この設計では「削除済みのデータを古い順に取得したい」といった別のアクセスパターンが発生した場合、SK BEGINS_WITH 'DELETED#'で同様に取得できるため、柔軟性も十分です。
重要なのは、アクセスパターンごとにプレフィックスを設計し、それに合わせたクエリを用意することです。
プレフィックスが増えすぎると管理複雑性が上がるため、状態は多くても3〜5種類程度に抑えるのが現実的です。

タイムスタンプを組み込んだレンジクエリの最適化

プレフィックスだけでは、同一状態内でのさらに細かい絞り込み(例:特定日時の範囲でアクティブな投稿を取得する)に対応できません。
そこで、プレフィックスに続けてタイムスタンプを組み込むことで、レンジクエリ(範囲指定)を最適化します。
SKの形式をACTIVE#<YYYY-MM-DD>#<投稿ID>やACTIVE#<エポック秒>#<投稿ID>のようにし、BETWEEN条件と組み合わせるのです。

たとえば、2026年8月1日から8月31日までのアクティブな投稿を取得したい場合、以下のようなクエリを発行します。

-- 概念的な表現
PK = 'User#U123'
AND SK BETWEEN 'ACTIVE#2026-08-01#' AND 'ACTIVE#2026-08-31#'

このクエリは、ソートキーの辞書順を利用して、指定された日付範囲のアイテムだけを効率的に読み取ります。
プレフィックスがACTIVE#で固定されているため、削除済みデータは範囲外となり、読み取り対象にすらなりません。
さらに、タイムスタンプをISO8601形式(YYYY-MM-DD)にすれば、日単位の範囲指定が直感的に行えます。
より高精度が必要なら、ミリ秒単位のエポックタイムスタンプを用いても構いません。

この設計で注意すべき点は、同じプレフィックス内でのソート順序がタイムスタンプによって決まることです。
最新順に取得したい場合は、SKを降順(ScanIndexForward=false)でクエリすれば、タイムスタンプの降順でアイテムが返ります。
また、タイムスタンプの代わりにUUIDなどを入れると範囲指定ができなくなるため、レンジクエリが必要な属性は必ずソートキーの前方に配置するという原則を守ってください。

ここで、プレフィックス+タイムスタンプ方式の設計例を表にまとめます。

状態 SKのパターン クエリ例 取得できるデータ
アクティブ ACTIVE#<日付>#<ID> BETWEEN 'ACTIVE#2026-08-01#' AND 'ACTIVE#2026-08-31#' 8月中のアクティブ投稿
削除済み DELETED#<日付>#<ID> BEGINS_WITH 'DELETED#' すべての削除済み投稿(日付順)
アーカイブ ARCHIVE#<日付>#<ID> BETWEEN 'ARCHIVE#2026-01-01#' AND 'ARCHIVE#2026-06-30#' 上半期のアーカイブデータ

この戦略の最も優れた点は、インデックスを追加せずに、テーブル単体のクエリで多様なアクセスパターンを満たせることです。
GSIを別途作成する必要がないため、書き込みスループットの消費も最小限に抑えられます。
ただし、ソートキーが長くなりすぎるとアイテムサイズが増加するため、プレフィックスや日付フォーマットはできるだけコンパクトに(例:A#20260801#)設計することをお勧めします。

また、タイムスタンプをソートキーに含める場合、削除日時と作成日時が異なる点に留意してください。
アクティブ状態では作成日時を、削除状態では削除日時をソートキーに持たせることで、それぞれの状態で意味のある範囲クエリが実現できます。
このように、状態ごとにソートキーの意味を変えることもオーバーロードの柔軟性の一部であり、これこそがDynamoDBでスケーラブルな論理削除を実現する核となる考え方です。

TTLと論理削除をハイブリッドで運用する現実解

TTLによる自動物理削除と論理削除フラグを併用したハイブリッドモデル

ここまで、論理削除のメリット(即時復元性)と物理削除のメリット(コスト効率)をそれぞれ見てきました。
では、両方の利点を同時に得ることはできないのでしょうか。
答えは「できる」です。
その鍵となるのが、DynamoDBが標準で提供するTTL(Time To Live)機能と論理削除を組み合わせたハイブリッド運用です。

この戦略の考え方はシンプルです。
削除操作が発生した時点では論理削除(フラグまたはSKプレフィックスの変更)を行い、即時復元を可能にしておく。
その一方で、削除から一定期間が経過したアイテムは、TTLによって自動的に物理削除する。
こうすることで、短期的な復元ニーズには応えつつ、長期的にはストレージコストを物理削除と同等に抑えられるのです。
まさに現実的な落とし所と言えるでしょう。

保持期間を定めた自動物理削除の仕組み

DynamoDBのTTLは、アイテムにttlという名前の数値属性(Unixエポック秒)を設定し、その時刻が現在を過ぎると、バックグラウンドで自動的にアイテムを物理削除する機能です。
この削除は無料で実行され、かつ削除されたアイテムはストレージ課金の対象から外れます。

ハイブリッド運用では、論理削除されたアイテムに対して、削除日時から保持期間(例:30日)を加算した値をTTL属性に書き込みます。
具体的な実装フローは以下の通りです。

  1. ユーザーが削除リクエストを発行する
  2. アプリケーションは該当アイテムのSKをACTIVE#からDELETED#に変更し、かつttl属性に現在時刻 + 保持期間(秒)を設定するUpdateItemを実行する
  3. 以降、そのアイテムは論理削除状態としてアプリケーションからは参照されなくなるが、復元APIが呼ばれればttlを削除し、SKをACTIVE#に戻すことで即時復元できる
  4. 保持期間が経過すると、DynamoDBのTTLバックグラウンドプロセスがアイテムを自動的に物理削除する。この時点で復元は不可能になる

この仕組みのポイントは、TTLの設定が論理削除のタイミングと連動していることです。
アクティブなアイテムにはTTLを設定しない(または十分に遠い未来に設定する)ことで、誤って削除されるのを防ぎます。
また、TTLの削除はバッチ処理ではなくストリームベースで非同期に行われるため、テーブルの書き込みスループットに影響を与えません。

コードで示すと、削除処理は以下のような疑似コードになります(PythonのBoto3を想定)

import time
import boto3
from datetime import datetime, timedelta

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('UserActivities')

def soft_delete(user_id, activity_id, retention_days=30):
    now = int(time.time())
    ttl_value = now + (retention_days * 24 * 60 * 60)
    response = table.update_item(
        Key={'PK': f'User#{user_id}', 'SK': f'ACTIVE#{activity_id}'},
        UpdateExpression='SET #sk = :new_sk, #ttl = :ttl',
        ExpressionAttributeNames={'#sk': 'SK', '#ttl': 'ttl'},
        ExpressionAttributeValues={
            ':new_sk': f'DELETED#{activity_id}',
            ':ttl': ttl_value
        }
    )
    return response

このように、TTL属性を動的に書き込むことで、アイテムごとに異なる保持期間を設定することも可能です(例:重要度に応じて90日、一般データは7日など)

コストと可用性のバランスを取る閾値設計

では、この保持期間(TTLの閾値)をどのように決定すればよいのでしょうか。
ここがハイブリッド運用の要であり、コストと復元可用性のトレードオフを最適化する設計ポイントです。

保持期間を長く設定すれば、ユーザーや運用者が誤削除から復元できる期間が延びるため、可用性(リカバリ性)は向上します。
しかし、その間はストレージコストが発生し続けます。
逆に短く設定すればコストは抑えられますが、復元のチャンスが失われます。
このバランスを取るために、以下の要素を定量的に評価する必要があります。

  • 復元リクエストの発生頻度:過去の運用データから、削除後何日以内に復元要求が来るかの分布を計測します。多くのケースで、復元の80%は削除から3日以内、95%は7日以内に発生するというデータがあれば、保持期間を7日〜14日に設定するのが合理的です
  • ストレージコストの許容範囲:1アイテムあたりの平均サイズと、1日あたりの削除件数を掛け合わせて、保持期間ごとの追加コストを試算します。たとえば、1アイテム1KB、1日10万件削除の場合、30日保持で約3GBの追加ストレージとなり、DynamoDBの標準ストレージ料金(約0.25USD/GB/月)で月額0.75USD程度です。件数が増えればこの限りではありませんが、多くの小〜中規模システムでは許容範囲でしょう
  • バックアップとの重複:PITRを有効にしている場合、TTLで削除される前のデータはバックアップにも含まれます。そのため、TTLでの物理削除後でも、PITRの保持期間内(デフォルト35日)であれば復元可能なケースもあります。この点も含めて総合的な復元性を評価します

これらの要素を踏まえた上で、私は以下の表のような判断基準を推奨します。

システムの特性 推奨保持期間 コスト影響 復元可用性
ユーザー誤操作が頻発するSaaS 30日〜90日 中〜大 非常に高い
監査ログとして保持要件がある 180日以上(TTL不使用または非常に長期間) 大 高いがコスト増
内部管理システムで復元要求が稀 3日〜7日 極小 最低限
データ量が非常に大きくコスト重視 1日〜3日 最小 限定される

さらに、保持期間を段階的に設定する手法も有効です。
たとえば、削除直後は7日間は即時復元可能(SKをRECENT_DELETED#に設定)、その後は23日間はアーカイブ状態(ARCHIVED_DELETED#)とし、合計30日でTTL削除する、といった具合です。
この場合、復元頻度の高い直近期間だけを高速にアクセスできるようにし、古いデータはコールドストレージ扱いにできます。
ただし、状態遷移を追加するバッチ処理が別途必要になるため、運用複雑性とのトレードオフは考慮してください。

最後に、TTLの注意点を一つ。
TTLによる削除は最大48時間の遅延が発生する可能性があると公式ドキュメントに明記されています。
つまり、保持期間を30日に設定しても、実際には30日+2日後に削除されるケースがあります。
この遅延を許容できるかどうかも閾値設計に含め、ビジネス要件と照らし合わせて余裕を持った期間を設定することをお勧めします。

ステータス属性による多値管理とクエリ例

アクティブ・アーカイブ・削除予定の3状態を持つステータス設計とサンプルクエリ

これまでの議論で、単純なブール型フラグ(isDeleted)がもたらす危険性と、ソートキーのプレフィックスによる分離戦略の有効性を確認してきました。
しかし、現実のビジネス要件は「削除されたか否か」という二値では収まらないことがほとんどです。
たとえば「ユーザーが削除操作を行ったが、一定期間は復元可能にしたい」「監査要件により、削除予定のデータを事前にマークしておきたい」「長期保存が必要なデータと、そうでないデータを区別したい」といった、複数の削除フェーズが求められるケースが少なくありません。

そこで登場するのが、ステータス属性による多値管理です。
フラグを拡張し、statusという文字列属性にactive、archived、deleted_pendingといった複数の状態を持たせることで、削除に至るまでのライフサイクル全体をモデリングします。
このアプローチは、単なる論理削除の枠を超え、データの状態遷移として捉える発想の転換をもたらします。

アクティブ・アーカイブ・削除予定の3状態モデル

ここでは、実践的な3状態モデルを提案します。
それぞれの状態は以下の意味を持ちます。

  • アクティブ(active):通常の運用で参照・更新されるデータ。クエリのデフォルト対象です
  • アーカイブ(archived):ユーザーまたはシステムによって「削除対象」とマークされたが、即時復元の必要はなく、かつ一定期間は保持が求められるデータ。参照頻度は低いが、監査やロールバックの用途で残します
  • 削除予定(deleted_pending):最終的に物理削除されることが確定しており、復元を想定しないデータ。ただし、関連する外部システムとの整合性チェックやバックアップ取得のために、短期間だけこの状態を経由します

この3状態をどのようにテーブル設計に落とし込むか。
先述のSKプレフィックス方式を拡張し、SKの先頭に状態コードを配置するのが最もシンプルです。

  • アクティブ:ACT#<作成日時>#<ID>
  • アーカイブ:ARC#<アーカイブ日時>#<ID>
  • 削除予定:DEL#<削除予定日時>#<ID>

この設計により、各状態に対するクエリは以下のように統一感を持って記述できます。

-- アクティブなデータだけを取得
PK = 'User#U123' AND SK BEGINS_WITH 'ACT#'

-- アーカイブされたデータを日付範囲で取得
PK = 'User#U123' AND SK BETWEEN 'ARC#2026-01-01#' AND 'ARC#2026-06-30#'

-- 削除予定のデータを全件取得(バッチ処理用)
PK = 'User#U123' AND SK BEGINS_WITH 'DEL#'

さらに、状態遷移のルールを明確に定義します。
一般的な遷移パターンは以下の通りです。

  • アクティブ → アーカイブ:ユーザーによる削除操作、または一定期間経過後の自動バッチ処理
  • アーカイブ → 削除予定:保持期間(例:30日)経過後のバッチ処理
  • 削除予定 → 物理削除(TTL):保持期間(例:3日)経過後、TTLにより自動消去
  • アーカイブ → アクティブ:管理者またはユーザーによる復元操作(即時可能)
  • 削除予定 → アクティブ:復元操作(ただし、削除予定からの復元は例外的なケースに限定)

このモデルの最大の利点は、状態ごとに異なる保持ポリシーとアクセス制御を適用できることです。
たとえば、アーカイブ状態のデータは通常のクエリでは返さず、管理画面からのみ参照可能にする、といった実装が容易になります。
また、監査ログとしての要件を満たすために、アーカイブ状態を長期間(例:1年間)維持し、その後に削除予定へ移行する、といったビジネスルールも自然に組み込めます。

バッチ処理での状態遷移と非同期更新

3状態モデルを運用する上で欠かせないのが、状態遷移を司るバッチ処理です。
ユーザー操作による個別の遷移(アクティブ→アーカイブ)はリアルタイムで処理しますが、大量データの一括移行(アーカイブ→削除予定)や、保持期間経過のチェックは、非同期のバッチジョブとして実装するのが現実的です。

このバッチ処理を設計する際のポイントをいくつか挙げます。

  • スキャンではなくクエリで対象を抽出する:全テーブルスキャンはコストが高すぎます。各状態のプレフィックスを利用したQuery(PKを適切に設計していれば)またはGSIを活用し、状態ごとに効率的に抽出します。たとえば、statusをGSIのパーティションキーにし、archived状態のアイテムを日付範囲で絞り込む設計も一案です
  • バッチ書き込み(BatchWriteItem)を活用する:一度に数千件単位でSKのプレフィックスを更新する場合、個別のUpdateItemではスループットを浪費します。BatchWriteItemを用いて25件単位でまとめて更新することで、書き込みコストを削減し、処理時間を短縮します
  • DynamoDB Streams + Lambdaによるイベント駆動:バッチ処理が完了したタイミングで、後続の処理(例:削除予定になったアイテムの外部システムへの通知)が必要な場合は、StreamsをトリガーにLambdaを起動する非同期パイプラインを構築します。これにより、バッチ処理自体は書き込みに専念し、副作用は分離できます
  • ジョブの進捗管理テーブルを別途用意する:数百万件規模のバッチ処理では、途中で失敗した場合の再開ポイントが重要です。処理済みの最終ソートキーを記録する管理テーブルを設け、バッチが中断されてもそこから再開できる仕組みを組み込みます

バッチ処理の疑似コード(バッチ更新部分)は以下のようになります。

import boto3
from decimal import Decimal

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('UserActivities')

def archive_to_pending(batch_keys, retention_days=30):
    """
    batch_keys: アーカイブ状態のアイテムのPKとSKのリスト
    保持期間を過ぎたものを削除予定状態に更新する
    """
    with table.batch_writer() as batch:
        for pk, sk in batch_keys:
            # アーカイブ日時をパースして保持期間を計算
            # 実際の実装ではSKから日時部分を抽出
            if is_expired(sk, retention_days):
                new_sk = sk.replace('ARC#', 'DEL#', 1)
                batch.put_item(
                    Item={
                        'PK': pk,
                        'SK': new_sk,
                        'ttl': int(time.time()) + 3 * 24 * 60 * 60,  # 3日後に物理削除
                        'original_sk': sk  # 元のSKを退避(監査用)
                    }
                )
                # 元のアイテムは削除(BatchWriteItemではDeleteItemも可能)
                batch.delete_item(Key={'PK': pk, 'SK': sk})

このコードでは、batch_writerのコンテキストマネージャを使い、put_itemとdelete_itemを組み合わせることで、状態遷移(アーカイブ→削除予定)と同時にTTLを設定しています。
バッチライターは自動でリトライやスロットリング制御を行うため、安定した更新が期待できます。

非同期更新のもう一つの側面として、状態遷移の遅延許容があります。
ユーザーが削除操作を行った直後に、すぐにアーカイブ状態にする必要はなく、キューやイベントバスを経由して数秒〜数分後に反映させる設計も可能です。
これにより、書き込みのピークを平準化し、プロビジョンドキャパシティの節約につなげられます。
ただし、この場合はユーザーに対して「削除処理中」などのフィードバックを別途提供する仕組みが別途必要になる点に留意してください。

最終的に、この3状態モデル+バッチ非同期更新の組み合わせは、単純なフラグ方式と比較して運用の複雑性は増すものの、スケーラビリティ、コスト、ビジネス要件への適合性のすべてにおいて優れたバランスを提供します。
状態が増えれば増えるほど、その遷移ロジックは入念なテストとモニタリングが必須ですが、それを補って余ある柔軟性が得られるでしょう。

ユースケース別ベストプラクティス – 監査要件とリアルタイム性の両立

金融・医療とソーシャルアプリそれぞれの要件に合わせた削除モデリングの比較

ここまで、DynamoDBにおける論理削除の汎用的なモデリング手法を解説してきました。
しかし、実際のシステム設計では、ビジネスドメイン固有の要件が最終的な戦略を決定づけるのが常です。
特に、削除データの扱いは、監査証跡の厳格さとユーザー体験のリアルタイム性という、しばしば相反する要求の狭間で最適解を求めることになります。

そこで本節では、代表的な二つのユースケース——金融・医療分野とソーシャルアプリケーション——を取り上げ、それぞれの要件に合わせた具体的なベストプラクティスを提示します。
共通するのは「アクセスパターンから逆算する」という原則ですが、その実装形態はまったく異なるものになることをご理解いただけるでしょう。

金融・医療分野での厳格な保持要件への対応

金融取引や医療記録を扱うシステムでは、法令や業界標準(例:GDPR、HIPAA、金融商品取引法)により、データの完全な監査可能性と長期保存が義務付けられます。
この場合、削除は事実上許容されず、「削除」はむしろ「アクセス不可状態への遷移」として設計します。

この分野での論理削除設計の核は、以下の3点です。

  • 変更履歴の完全保持:アイテムの更新や削除操作ごとに、変更前の状態を別の履歴テーブルに書き出すか、同じテーブル内でバージョニング(SKにバージョン番号を含める)を行います。これにより、いつ、誰が、どのような理由で削除フラグを立てたかをトレース可能にします
  • 保持期間の長期化とTTLの不使用:監査要件が数年間にわたる場合、TTLによる自動物理削除は利用できません。代わりに、ステータスをdeletedに変更しつつ、retention_end_dateのような属性を持たせ、バッチ処理で期間経過後も削除せずにアーカイブ領域(S3 Glacierなど)へオフロードすることを検討します。DynamoDB内に保持し続ける場合は、パーティションサイズに注意し、PKを年や四半期で分割するシャーディングが有効です
  • アクセス制御の厳格化:削除済みデータであっても、監査担当者や特権ユーザーだけが参照できるように、アプリケーションレベルでのロールベースアクセス制御(RBAC)を実装します。クエリ自体はstatus = 'deleted'を許可するが、その実行ユーザーを検証するミドルウェアを必ず挟みます

実装例として、更新履歴を同一テーブルに保持するパターンをコードで示します(SKにタイムスタンプをバージョンとして含める)

// 現在のアクティブアイテム(PK: "Transaction#TXN123", SK: "active#v3")
{
  "PK": "Transaction#TXN123",
  "SK": "active#v3",
  "amount": 10000,
  "status": "active",
  "updated_at": "2026-08-23T10:00:00Z",
  "version": 3
}

// 削除操作が発生すると、新たに削除済みバージョンを作成
// 元のアクティブアイテムはそのまま保持(論理削除ではない)
// クエリ時にはSK BEGINS_WITH 'active#' で最新のアクティブバージョンを取得

この方式では、削除済みデータもすべてのバージョンとともに残り、かつ各バージョンにタイムスタンプとオペレーターIDを付与することで、監査要件を満たします。
ただし、この設計ではテーブルサイズが急増するため、パーティションキーに年+月(例:2026-08)を組み込むなどして、時間単位でのデータ分散を図ることが必須です。

ソーシャルアプリでの柔軟な削除復元機能

対照的に、SNSやメッセージアプリなどのソーシャル系サービスでは、ユーザー体験が最優先されます。
削除操作は「すぐに反映され、かつ簡単に取り消せる」ことが求められ、同時にデータ量の爆発的な増加をコントロールする必要があります。

この分野での推奨戦略は、以下の通りです。

  • 論理削除と短いTTLのハイブリッド:削除後は即座にstatus = 'deleted'に変更し、ユーザーには表示されないようにします。同時にTTLに7日〜30日程度の短い保持期間を設定し、復元要求が来なければ自動で物理削除します。これにより、大部分のユーザーが削除後すぐに復元を試みるという行動パターン(多くのサービスで削除後24時間以内の復元が8割以上)に最適化できます
  • 復元操作の軽量化:復元APIはUpdateItemでSKのプレフィックスをDEL#からACT#に戻し、TTL属性を削除するだけの単純な操作とします。この処理はミリ秒単位で完了するため、ユーザーはストレスなく取り消しができます
  • 削除済みデータの参照を明示的に許可:「ゴミ箱」機能として、ユーザー自身が削除済みアイテムの一覧を閲覧できるUIを提供する場合があります。その際は、PK = 'User#U123' AND SK BEGINS_WITH 'DEL#'というクエリを、通常のクエリとは別に実装しますが、このクエリは頻繁に実行されないため、スキャン範囲が多少広くても許容されます
  • 自動的なクリーンアップの監視:TTLで削除される前に、削除済みデータの総量をモニタリングし、保持期間の調整(例:ユーザーアクティビティが高い期間は短く、低い期間は長く)を動的に行う仕組みを導入することも、コスト最適化の観点から有効です

両ユースケースの違いを表にまとめます。

比較項目 金融・医療分野 ソーシャルアプリ
保持期間 数年〜無期限(法令準拠) 数日〜数週間(ユーザー体験優先)
TTLの利用 原則不使用(オフロード検討) 積極的に利用(7〜30日)
復元の頻度 極めて稀(監査例外時のみ) 比較的高頻度(ユーザー誤操作)
クエリパターン 全バージョンのトレースが可能な設計 アクティブと削除済みの分離が明確
ストレージコスト 高くなることを許容 最小化が求められる

最後に、いずれのユースケースでも共通するのは、削除を「イベント」として捉え、そのイベントをトリガーに後続処理(監査ログ書き込み、通知、TTL設定)をチェーンさせる設計思想です。
DynamoDB StreamsとLambdaを組み合わせれば、削除操作を起点とした非同期ワークフローが容易に構築でき、メインのトランザクション処理をシンプルに保ちながら、各ドメイン固有の要件を満たすことが可能になります。
あなたのシステムがどの象限に属するかを冷静に見極め、その上で最も適切な削除戦略を選択してください。

まとめ – 論理削除は「状態遷移」と捉え、アクセスパターンから逆算せよ

DynamoDB論理削除設計の総括 - アクセスパターン主導のモデリングの重要性

ここまで、DynamoDBにおける論理削除の実装について、フラグ方式がもたらす落とし穴から、ソートキーのオーバーロード、TTLとのハイブリッド運用、そして3状態モデルやユースケース別の戦略まで、幅広く解説してきました。
改めて振り返れば、一貫して流れるテーマはただ一つです。
削除を「データの属性」ではなく「状態の遷移」としてモデリングし、その状態に応じたアクセスパターンをクエリ設計の出発点に据えることです。

多くのプロジェクトで見られる失敗は、RDBMS的な発想——「削除フラグを立てて、アプリケーション側でフィルタリングすればよい」——をそのままDynamoDBに持ち込んだことに起因します。
この単純なアプローチが、RCUの浪費、ホットパーティション、パーティションサイズの肥大化という3つの危険を招き、スケールアウトの恩恵を著しく損なうことを、私は数多くのコードレビューと運用障害のポストモーテムを通じて痛感してきました。
そして、その解決策として提案したのが、ソートキーのプレフィックスによる物理的区分けであり、TTLを活用したコスト最適化であり、ステータス属性を用いた多状態管理でした。

これらの手法に共通する設計原則を、ここで改めて整理しておきましょう。

  • アクセスパターンを最初に列挙する:どのようなクエリで、どの状態のデータを、どのような頻度で取得するのか。それを一覧にした上で、PKとSKの組み合わせを決定します。クエリが先、キーが後です
  • 状態はSKの先頭に持たせる:フラグ属性に頼らず、SKのプレフィックスで状態を表現することで、フィルタリングを排除し、読み取り範囲を物理的に限定します。これにより、RCUの効率が劇的に向上します
  • TTLは状態遷移の終着点として位置づける:論理削除だけでデータを永続化し続けるのではなく、復元の必要性がなくなった段階でTTLによる自動物理削除を組み合わせることで、ストレージコストを長期的に抑制します。保持期間はビジネス要件とコスト許容度から合理的に設定します
  • 状態が3つ以上になる場合は、バッチ処理で非同期遷移させる:アクティブ、アーカイブ、削除予定といった複数状態を運用する場合、状態遷移の負荷をメインのトランザクションから切り離し、バッチジョブやストリーム処理で非同期に実行する設計を採用します。これにより、ユーザー操作のレスポンスタイムを維持しながら、大規模なデータ移行を実現できます

では、具体的に「アクセスパターンから逆算する」とはどのような作業なのか。
それを実践するための簡単なフローチャートを、疑似コードで示します。

// 設計フェーズの手順
1. すべてのクエリパターンをリストアップする
   - 例: ユーザーのアクティブ投稿を最新10件取得
   - 例: 管理者が特定期間の削除済みデータを監査
   - 例: バッチ処理でアーカイブ状態を削除予定に変更

2. 各クエリに対して、PKとSKの候補を設計する
   - PKは一意性と分散性の高い属性(ユーザーID、テナントID)
   - SKは状態プレフィックス + ソート対象属性(日時、ID)

3. 各クエリの実行計画を書き出す(どのパーティションを読むか)
   - 単一PKの範囲クエリ(BEGINS_WITH / BETWEEN)に収まるか
   - 収まらない場合はGSIを検討するが、まずはテーブル単体で解決できないか再考する

4. 状態遷移のルールとTTL閾値を定義する
   - アクティブ → アーカイブ(ユーザー操作 or 経過時間)
   - アーカイブ → 削除予定(保持期間経過)
   - 削除予定 → 物理削除(TTL)

5. 各遷移のトリガー(同期API or 非同期バッチ)と、その際の更新属性(SK、ttl)を明確にする

この手順を踏むだけで、フラグ方式に頼ったアドホックな設計とは無縁になり、スケーラビリティとコスト効率を両立するモデルが自然と導き出されます。

最後に、一つだけ強調しておきたいことがあります。
DynamoDBは「何でもできる」データベースではありません。
その代わりに、「特定のアクセスパターンに対しては驚異的な性能とコストパフォーマンス」を発揮します。
論理削除の実装も、この原則に従うべきです。
すべてのデータを永遠に保持し、すべての状態を自由にクエリできる柔軟性を求めるのであれば、DynamoDBはおそらく最適な選択肢ではありません。
しかし、ビジネス上の真に必要なアクセスパターンを厳選し、そのパターンに合わせて状態遷移をデザインするのであれば、DynamoDBは強力な武器となります。

あなたが今、これから設計するシステムにおいて、削除済みデータを「どう見せるか」ではなく、「どう扱うべきか」という本質から考え直してみてください。
その答えが、この記事で伝えたかったすべてです。

コメント

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