データ保存期間の設計とは、テーブルごとに「いつから数えて、どれだけ残し、期限が来たらどう扱い、どの方式で実行するか」を決めて一覧表にすることです。 期間の数字だけを決めても、起算日と実行方式が決まっていないと処理が作れません。

この記事では、保存期間一覧表のテンプレートを Markdown とタブ区切りで示し、架空の備品予約システムでの記入例を付けます。あわせて、一括DELETE・分割DELETE・パーティションの切り離し・退避テーブルへの移動を MySQL と PostgreSQL の両方で 150 万件のテーブルに対して実行し、かかった時間と、外部キーで止まるときのエラー文をそのまま載せます。

確認環境:Ubuntu 24.04、PostgreSQL 16.15、MySQL 8.0.46(InnoDB)。確認日 2026-10-06。出力例のデータベース名は appdb に置き換えています。実行時間は手元の1台で各1回計った値で、機器やデータによって変わります。

この記事の結論

  • 保存期間はテーブル単位で決める。 決めるのは「起算日・保持期間・期限後の扱い・実行方式」の4項目。
  • 期限後の扱いは3つから選ぶ。 削除(消す)、退避(別の場所へ移す)、匿名化(行は残して個人が分かる列だけ消す)。
  • 量が多いテーブルは日付パーティションの切り離し、少ないテーブルは分割DELETE。 今回の計測では、30万件を消すのに DELETE は 0.2〜7秒、パーティションの切り離しは 0.001〜0.07秒でした。
  • 外部キーがあるテーブルは、子から先に消す。 親から消すとエラーで止まります。
  • アーカイブ・バックアップ・論理削除は目的が違う。 後半の判断表で選べます。

データ保存期間の早見表(決める4項目と選択肢)

項目 決めること 主な選択肢 決まらないと起きること
起算日 どの列の日時から数えるか 作成日、完了日、退会日、最終更新日、最終ログイン日 同じ「1年」でも対象行が変わる
保持期間 起算日からどれだけ残すか 日数・月数・年数、または「無期限」 テーブルが増え続ける
期限後の扱い 期限が来た行をどうするか 削除/退避/匿名化 消してよいか毎回相談になる
実行方式 どの SQL で、いつ実行するか 分割DELETE/パーティションの切り離し/退避してから DELETE/UPDATE 件数が増えたときに処理が終わらない

実行方式は、次の表で選べます。時間は後半の計測結果(150万件のうち約30万件が対象)です。

実行方式 向いているテーブル PostgreSQL 16 MySQL 8.0 補足
一括 DELETE 対象が数万件まで 190 ms 7,387 ms 1つのトランザクションで全対象行をロックする
分割 DELETE(1万件ずつ) 対象が数十万件まで、外部キーあり 5,633 ms 3,417 ms 1回ごとにコミットし、ロックを短く保つ
退避テーブルへ INSERT してから DELETE 期限後も検索したいデータ 472 ms 9,268 ms 移動と削除を同じトランザクションにする
パーティションを削除(DROP) 月に数十万件以上増えるログ・履歴 9.9 ms 71 ms 行を1件ずつ消さない
パーティションを切り離して残す 上と同じ。切り離した月を保管したいとき 0.8 ms(DETACH) 8.9 ms(EXCHANGE) 切り離した表を書き出してから消せる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「データ保存期間の設計|アーカイブと削除方式の比較と一覧表例」

保存期間一覧表のテンプレートと記入例

1行が1テーブルです。空のテンプレートは次のとおりです。

テーブル データの内容 起算日(列) 保持期間 期限後の扱い 実行方式 実行タイミング 月あたりの増加件数 期間の根拠 判断する担当
テーブル    データの内容  起算日(列)  保持期間    期限後の扱い  実行方式    実行タイミング 月あたりの増加件数   期間の根拠   判断する担当

架空の備品予約システムでの記入例です。期間の数字は例で、実際の期間は適用される法令・契約・社内規程を確認して決めます。

テーブル データの内容 起算日(列) 保持期間 期限後の扱い 実行方式 実行タイミング 月あたりの増加件数 期間の根拠 判断する担当
op_logs 操作ログ 記録日時(logged_at) 12か月 削除 月単位パーティションの DROP 毎月1日 2:00 100,000 社内規程(調査に使う期間) 運用の担当
reservations 予約 利用日(reserved_on) 2年 退避(reservations_archive、退避後5年で削除) 退避テーブルへ INSERT してから DELETE 毎月1日 3:00 5,000 社内規程(利用記録) 総務の担当
reservation_items 予約の明細 親の利用日 親と同じ 親と同時に退避 親より先に処理 毎月1日 3:00 12,000 親に合わせる 総務の担当
members 会員 退会日時(withdrawn_at) 180日 匿名化(氏名とメールを消す) UPDATE 毎日 4:00 20 利用規約 会員管理の担当
mail_outbox メール送信依頼 送信日時(sent_at) 90日 削除 分割 DELETE(1万件ずつ) 毎日 4:30 30,000 再送の調査に使う期間 運用の担当
sessions ログインセッション 有効期限(expires_at) 1日 削除 分割 DELETE(1万件ずつ) 毎時 60,000 保持の必要なし 運用の担当
items 備品マスタ ― 無期限 ― ― ― 10 予約から参照される 総務の担当
テーブル    データの内容  起算日(列)  保持期間    期限後の扱い  実行方式    実行タイミング 月あたりの増加件数   期間の根拠   判断する担当
op_logs 操作ログ    記録日時(logged_at) 12か月    削除  月単位パーティションのDROP 毎月1日 2:00   100000  社内規程(調査に使う期間)   運用の担当
reservations    予約  利用日(reserved_on)    2年  退避(reservations_archive、退避後5年で削除)   退避テーブルへINSERTしてからDELETE 毎月1日 3:00   5000    社内規程(利用記録)  総務の担当
reservation_items   予約の明細   親の利用日   親と同じ    親と同時に退避 親より先に処理 毎月1日 3:00   12000   親に合わせる  総務の担当
members 会員  退会日時(withdrawn_at)  180日    匿名化(氏名とメールを消す)  UPDATE  毎日 4:00 20  利用規約    会員管理の担当
mail_outbox メール送信依頼 送信日時(sent_at)   90日 削除  分割DELETE(1万件ずつ) 毎日 4:30 30000   再送の調査に使う期間  運用の担当
sessions    ログインセッション   有効期限(expires_at)    1日  削除  分割DELETE(1万件ずつ) 毎時  60000   保持の必要なし 運用の担当
items   備品マスタ   ―   無期限 ―   ―   ―   10  予約から参照される   総務の担当

記入するときの要点は4つです。

  • 「無期限」も1行として書く。 書いていないテーブルは「決めていない」のか「残すと決めた」のか区別できません。
  • 起算日は列名まで書く。 「作成から1年」と「完了から1年」では、長く進行中のままの行の扱いが変わります。
  • 親子のテーブルは続けて並べる。 子の期間を「親と同じ」と書けば、処理順(子が先)も表から読み取れます。
  • 月あたりの増加件数を書く。 実行方式を選ぶ材料になります。保持期間 × 増加件数が、そのテーブルに常に残る行数の目安です(op_logs なら 12 × 100,000 = 120万件)。

期限後の扱い:削除・退避・匿名化の選び方

条件 選ぶもの 理由
期限後は誰も見ない(セッション、一時データ、送信済みの依頼) 削除 残す場所が要らない
期限後もまれに調べる(過去の予約、問い合わせ) 退避 本体を小さく保ちつつ、SQL で検索できる
期限後は見ないが、一定期間は保管だけ必要 退避(ファイルへ書き出し) データベースの外に置ける
行は集計に使い続けるが、個人が分かる情報は消したい 匿名化 件数や関連が保たれる
他のテーブルから外部キーで参照されている 匿名化、または子と一緒に退避 親だけ消すと参照が切れる

匿名化は UPDATE で行います。退会から 180 日を過ぎた会員の氏名とメールアドレスを消す例です(PostgreSQL 16 で実行。基準日は 2026-10-06 に固定)。

CREATE TABLE members (
  id bigint PRIMARY KEY,
  name text NOT NULL,
  email text UNIQUE,
  withdrawn_at timestamptz,
  anonymized_at timestamptz
);
INSERT INTO members VALUES
 (1, '見本 太郎', 'taro@example.com', '2025-01-15 10:00+09', NULL),
 (2, '見本 花子', 'hanako@example.com', '2026-08-01 10:00+09', NULL),
 (3, '見本 次郎', 'jiro@example.com', NULL, NULL);

UPDATE members
SET name = '退会済み会員', email = NULL, anonymized_at = timestamptz '2026-10-06 00:00+09'
WHERE withdrawn_at < timestamptz '2026-10-06 00:00+09' - interval '180 days'
  AND anonymized_at IS NULL;

SELECT id, name, email, withdrawn_at::date AS withdrawn, anonymized_at::date AS anonymized
FROM members ORDER BY id;
UPDATE 1
 id |     name     |       email        | withdrawn  | anonymized 
----+--------------+--------------------+------------+------------
  1 | 退会済み会員 |                    | 2025-01-15 | 2026-10-06
  2 | 見本 花子    | hanako@example.com | 2026-08-01 | 
  3 | 見本 次郎    | jiro@example.com   |            | 

anonymized_at を持たせると、同じ行を何度も更新せずに済み、いつ処理したかも残ります。どの列を消せば十分かは、自社の規程と法務の確認に従って決めます。

計測に使ったテーブルとデータ

操作ログのテーブルに、2025-07-01 から 2026-09-30 までの 15 か月分・150 万件を等間隔で入れました。保持期間を 12 か月とし、2025-10-01 より前の 301,970 件(3か月分)を期限切れとして処理します。

PostgreSQL のデータ作成です。

CREATE TABLE op_logs_src AS
SELECT n::bigint AS id,
       timestamptz '2025-07-01 00:00+09'
         + (n - 1) * ((timestamptz '2026-10-01 00:00+09' - timestamptz '2025-07-01 00:00+09') / 1500000) AS logged_at,
       (n % 500) + 1 AS user_id,
       (ARRAY['login','reserve','cancel','return'])[n % 4 + 1] AS action,
       'item-' || (n % 200) AS detail
FROM generate_series(1, 1500000) AS n;

CREATE TABLE op_logs (
  id bigint PRIMARY KEY,
  logged_at timestamptz NOT NULL,
  user_id int NOT NULL,
  action text NOT NULL,
  detail text
);
INSERT INTO op_logs SELECT * FROM op_logs_src;
CREATE INDEX op_logs_logged_at_idx ON op_logs (logged_at);
VACUUM ANALYZE op_logs;

SELECT count(*) AS total,
       count(*) FILTER (WHERE logged_at < '2025-10-01 00:00+09') AS expired
FROM op_logs_src;
  total  | expired 
---------+---------
 1500000 |  301970

MySQL のデータ作成です。

SET SESSION cte_max_recursion_depth = 2000000;

CREATE TABLE op_logs_src (
  id bigint PRIMARY KEY,
  logged_at datetime NOT NULL,
  user_id int NOT NULL,
  action varchar(20) NOT NULL,
  detail varchar(50)
);
INSERT INTO op_logs_src
WITH RECURSIVE seq (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1500000)
SELECT n,
       TIMESTAMPADD(SECOND, FLOOR((n - 1) * TIMESTAMPDIFF(SECOND, '2025-07-01', '2026-10-01') / 1500000), '2025-07-01'),
       (n % 500) + 1,
       ELT(n % 4 + 1, 'login', 'reserve', 'cancel', 'return'),
       CONCAT('item-', n % 200)
FROM seq;

CREATE TABLE op_logs (
  id bigint PRIMARY KEY,
  logged_at datetime NOT NULL,
  user_id int NOT NULL,
  action varchar(20) NOT NULL,
  detail varchar(50),
  KEY idx_logged_at (logged_at)
);
INSERT INTO op_logs SELECT * FROM op_logs_src;
ANALYZE TABLE op_logs;

方式ごとに op_logs を作り直してから計っています。

方式1:一括DELETE と分割DELETE

一括DELETE

-- PostgreSQL
DELETE FROM op_logs WHERE logged_at < timestamptz '2025-10-01 00:00+09';
DELETE 301970
Time: 189.829 ms
-- MySQL
SET @t = NOW(6);
DELETE FROM op_logs WHERE logged_at < '2025-10-01';
SELECT ROW_COUNT() AS deleted, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS ms;
+---------+-----------+
| deleted | ms        |
+---------+-----------+
|  301970 | 7386.6740 |
+---------+-----------+

一括DELETE は書くのが最も簡単です。一方で、対象の全行を1つのトランザクションで処理するため、終わるまで対象行のロックを持ち続け、途中で止めると全部が巻き戻ります。対象が数万件までのテーブルに向いています。

PostgreSQL では、DELETE のあとにテーブルの大きさを見ると次のようになりました。

SELECT pg_size_pretty(pg_total_relation_size('op_logs')) AS size_after_delete;
VACUUM op_logs;
SELECT pg_size_pretty(pg_total_relation_size('op_logs')) AS size_after_vacuum;
 size_after_delete 
-------------------
 162 MB

VACUUM
Time: 122.995 ms

 size_after_vacuum 
-------------------
 162 MB

30 万件を消して VACUUM をかけても、ファイルの大きさは 162 MB のままでした。空いた領域は同じテーブルへの次の書き込みで再利用されますが、ディスクの空きとしてはすぐに戻りません。「ディスクを空けたい」が目的なら、後述のパーティションの削除が合っています。

分割DELETE(1万件ずつコミット)

1回に消す件数を決めて、対象がなくなるまで繰り返します。PostgreSQL の DELETE には LIMIT がないので、主キーをサブクエリで絞ります。プロシージャの中で COMMIT することで、1回ごとにロックを手放します。

-- PostgreSQL
CREATE OR REPLACE PROCEDURE purge_op_logs(p_before timestamptz, p_chunk int)
LANGUAGE plpgsql AS $$
DECLARE
  n bigint;
  total bigint := 0;
  loops int := 0;
BEGIN
  LOOP
    DELETE FROM op_logs
    WHERE id IN (SELECT id FROM op_logs
                 WHERE logged_at < p_before
                 ORDER BY logged_at
                 LIMIT p_chunk);
    GET DIAGNOSTICS n = ROW_COUNT;
    EXIT WHEN n = 0;
    total := total + n;
    loops := loops + 1;
    COMMIT;
  END LOOP;
  RAISE NOTICE 'deleted % rows in % loops', total, loops;
END $$;

CALL purge_op_logs(timestamptz '2025-10-01 00:00+09', 10000);
NOTICE:  deleted 301970 rows in 31 loops
CALL
Time: 5633.410 ms (00:05.633)

MySQL は DELETE に ORDER BY と LIMIT を書けます。自動コミットのまま呼ぶと、1回の DELETE ごとにコミットされます。

-- MySQL
DELIMITER //
CREATE PROCEDURE purge_op_logs(IN p_before datetime, IN p_chunk int)
BEGIN
  DECLARE n bigint DEFAULT 1;
  DECLARE total bigint DEFAULT 0;
  DECLARE loops int DEFAULT 0;
  WHILE n > 0 DO
    DELETE FROM op_logs WHERE logged_at < p_before ORDER BY logged_at LIMIT p_chunk;
    SET n = ROW_COUNT();
    SET total = total + n;
    IF n > 0 THEN SET loops = loops + 1; END IF;
  END WHILE;
  SELECT total AS deleted, loops;
END//
DELIMITER ;

SET @t = NOW(6);
CALL purge_op_logs('2025-10-01', 10000);
SELECT TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS ms;
+---------+-------+
| deleted | loops |
+---------+-------+
|  301970 |    31 |
+---------+-------+
+-----------+
| ms        |
+-----------+
| 3416.9970 |
+-----------+

分割DELETE の目的は、合計時間を縮めることではなく、1回のロックとトランザクションを短くすることです。今回の PostgreSQL では、合計時間は一括の 0.19 秒に対して 5.6 秒と長くなりました。そのかわり、1回あたりは約 0.18 秒で終わり、途中で止めてもそこまでの分は消えたまま残るので、次回は続きから再開できます。利用者の操作と同じ時間帯に動かす処理では、繰り返しの間に短い待ち時間を入れると、ほかの処理に順番を譲れます。

方式2:日付パーティションを切り離す

テーブルを月ごとのパーティションに分けておくと、期限切れの月を表ごと外すだけで済みます。行を1件ずつ消さないので、件数が増えても時間がほとんど変わりません。

PostgreSQL:DROP と DETACH

CREATE TABLE op_logs_p (
  id bigint NOT NULL,
  logged_at timestamptz NOT NULL,
  user_id int NOT NULL,
  action text NOT NULL,
  detail text,
  PRIMARY KEY (id, logged_at)
) PARTITION BY RANGE (logged_at);

-- 2025-07 から 2026-09 まで、月ごとのパーティションを作る
DO $$
DECLARE d date := date '2025-07-01';
BEGIN
  WHILE d < date '2026-10-01' LOOP
    EXECUTE format(
      'CREATE TABLE op_logs_p_%s PARTITION OF op_logs_p FOR VALUES FROM (%L) TO (%L)',
      to_char(d, 'YYYYMM'),
      d::text || ' 00:00+09',
      (d + interval '1 month')::date::text || ' 00:00+09');
    d := (d + interval '1 month')::date;
  END LOOP;
END $$;

INSERT INTO op_logs_p SELECT * FROM op_logs_src;

期限切れの3か月を、3通りの書き方で外します。

-- (1) そのまま削除する
DROP TABLE op_logs_p_202507;

-- (2) 切り離して、独立した表として残す
ALTER TABLE op_logs_p DETACH PARTITION op_logs_p_202508;

-- (3) 親テーブルへの読み書きを止めずに切り離す
ALTER TABLE op_logs_p DETACH PARTITION op_logs_p_202509 CONCURRENTLY;

SELECT count(*) FROM op_logs_p;
SELECT count(*) AS detached_rows FROM op_logs_p_202508;
DROP TABLE
Time: 9.922 ms
ALTER TABLE
Time: 0.763 ms
ALTER TABLE
Time: 1.202 ms

  count  
---------
 1198030

 detached_rows 
---------------
        101751

3か月分・約30万件が、合計 12 ミリ秒ほどで親テーブルから外れました。切り離した op_logs_p_202508 は通常の表として残るので、ファイルへ書き出したあとで DROP できます。

PostgreSQL 16 の公式ドキュメントには、(1) と (2) は親テーブルに ACCESS EXCLUSIVE ロックを取り、(3) の CONCURRENTLY を付けた形は SHARE UPDATE EXCLUSIVE ロックで済むと書かれています。(3) を BEGIN の中で実行すると ERROR: ALTER TABLE ... DETACH CONCURRENTLY cannot run inside a transaction block になるので、トランザクションブロックの外で実行します。

MySQL:DROP PARTITION と EXCHANGE PARTITION

CREATE TABLE op_logs_p (
  id bigint NOT NULL,
  logged_at datetime NOT NULL,
  user_id int NOT NULL,
  action varchar(20) NOT NULL,
  detail varchar(50),
  PRIMARY KEY (id, logged_at)
)
PARTITION BY RANGE COLUMNS (logged_at) (
  PARTITION p202507 VALUES LESS THAN ('2025-08-01'),
  PARTITION p202508 VALUES LESS THAN ('2025-09-01'),
  PARTITION p202509 VALUES LESS THAN ('2025-10-01'),
  PARTITION p202510 VALUES LESS THAN ('2025-11-01'),
  PARTITION p202511 VALUES LESS THAN ('2025-12-01'),
  PARTITION p202512 VALUES LESS THAN ('2026-01-01'),
  PARTITION p202601 VALUES LESS THAN ('2026-02-01'),
  PARTITION p202602 VALUES LESS THAN ('2026-03-01'),
  PARTITION p202603 VALUES LESS THAN ('2026-04-01'),
  PARTITION p202604 VALUES LESS THAN ('2026-05-01'),
  PARTITION p202605 VALUES LESS THAN ('2026-06-01'),
  PARTITION p202606 VALUES LESS THAN ('2026-07-01'),
  PARTITION p202607 VALUES LESS THAN ('2026-08-01'),
  PARTITION p202608 VALUES LESS THAN ('2026-09-01'),
  PARTITION p202609 VALUES LESS THAN ('2026-10-01'),
  PARTITION pmax VALUES LESS THAN (MAXVALUE)
);
INSERT INTO op_logs_p SELECT * FROM op_logs_src;
-- (1) そのまま削除する
SET @t = NOW(6);
ALTER TABLE op_logs_p DROP PARTITION p202507;
SELECT TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS drop_ms;

-- (2) 同じ定義の空の表と中身を入れ替えて、独立した表として残す
CREATE TABLE op_logs_202508 (
  id bigint NOT NULL,
  logged_at datetime NOT NULL,
  user_id int NOT NULL,
  action varchar(20) NOT NULL,
  detail varchar(50),
  PRIMARY KEY (id, logged_at)
);
SET @t = NOW(6);
ALTER TABLE op_logs_p EXCHANGE PARTITION p202508 WITH TABLE op_logs_202508;
SELECT TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS exchange_ms;

-- 入れ替え後、空になったパーティションを外す
ALTER TABLE op_logs_p DROP PARTITION p202508;

SELECT (SELECT COUNT(*) FROM op_logs_p) AS main,
       (SELECT COUNT(*) FROM op_logs_202508) AS detached;
+---------+
| drop_ms |
+---------+
| 71.0440 |
+---------+
+-------------+
| exchange_ms |
+-------------+
|      8.9040 |
+-------------+
+---------+----------+
| main    | detached |
+---------+----------+
| 1296498 |   101751 |
+---------+----------+

MySQL には DETACH がありません。EXCHANGE PARTITION で空の表と中身を入れ替えると、同じ結果になります。一括DELETE が約 7.4 秒だったのに対し、DROP PARTITION は 0.07 秒でした。

パーティションにするときの前提

パーティションの切り離しは速いかわりに、テーブル定義に条件が付きます。どちらも実際に出たエラーです。

前提 起きること 実際のエラー文
主キー・一意キーに、分割に使う列を含める(MySQL) id だけを主キーにすると作成できない ERROR 1503 (HY000): A PRIMARY KEY must include all columns in the table's partitioning function (prefixed columns are not considered).
パーティション表に外部キーを付けない(MySQL) 外部キー付きでは作成できない ERROR 1506 (HY000): Foreign keys are not yet supported in conjunction with partitioning
参照されているパーティションは、子を先に片付ける(PostgreSQL) 子の行が残っていると切り離せない ERROR: removing partition "orders_p_2024" violates foreign key constraint "order_items_order_id_ordered_at_fkey1"
来月分のパーティションを先に作っておく(PostgreSQL) 入れ先がない行は登録できない ERROR: no partition of relation "op_logs_p" found for row
来月分のパーティションを先に作っておく(MySQL) 同上。MAXVALUE のパーティションを置けば受けられる ERROR 1526 (HY000): Table has no partition for value from column_list

MySQL 8.0 のリファレンスマニュアルにも、InnoDB の外部キーとパーティションは併用できず、パーティション表は外部キーを持てず、外部キーから参照されることもできないと書かれています。外部キーが必要なテーブルは分割DELETE か退避、外部キーが要らないログ・履歴はパーティションという分け方になります。

方式3:退避テーブルへ INSERT してから DELETE

期限後も検索できるようにしたいデータは、同じ列を持つ退避テーブルへ移します。INSERT と DELETE を同じトランザクションに入れ、同じ条件を使うのが要点です。

-- PostgreSQL
CREATE TABLE op_logs_archive (
  LIKE op_logs INCLUDING DEFAULTS,
  archived_at timestamptz NOT NULL DEFAULT now()
);

BEGIN;
INSERT INTO op_logs_archive (id, logged_at, user_id, action, detail)
SELECT id, logged_at, user_id, action, detail
FROM op_logs WHERE logged_at < timestamptz '2025-10-01 00:00+09';
DELETE FROM op_logs WHERE logged_at < timestamptz '2025-10-01 00:00+09';
COMMIT;

SELECT (SELECT count(*) FROM op_logs) AS main,
       (SELECT count(*) FROM op_logs_archive) AS archive;
INSERT 0 301970
Time: 253.341 ms
DELETE 301970
Time: 213.719 ms
COMMIT
Time: 5.440 ms

  main   | archive 
---------+---------
 1198030 |  301970

PostgreSQL は、DELETE の RETURNING を使って1文にまとめることもできます。今回の時間は 469 ms で、2文に分けた場合(合計 472 ms)とほぼ同じでした。

WITH moved AS (
  DELETE FROM op_logs WHERE logged_at < timestamptz '2025-10-01 00:00+09'
  RETURNING id, logged_at, user_id, action, detail
)
INSERT INTO op_logs_archive (id, logged_at, user_id, action, detail)
SELECT * FROM moved;
INSERT 0 301970
Time: 468.883 ms

MySQL は2文で書きます。

-- MySQL
CREATE TABLE op_logs_archive (
  id bigint PRIMARY KEY,
  logged_at datetime NOT NULL,
  user_id int NOT NULL,
  action varchar(20) NOT NULL,
  detail varchar(50),
  archived_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);

START TRANSACTION;
INSERT INTO op_logs_archive (id, logged_at, user_id, action, detail)
SELECT id, logged_at, user_id, action, detail FROM op_logs WHERE logged_at < '2025-10-01';
DELETE FROM op_logs WHERE logged_at < '2025-10-01';
COMMIT;

SELECT (SELECT COUNT(*) FROM op_logs) AS main,
       (SELECT COUNT(*) FROM op_logs_archive) AS archive;
INSERT: 301970 行  2131.2270 ms
DELETE: 301970 行  7078.2710 ms
COMMIT までの合計: 9267.9660 ms

+---------+---------+
| main    | archive |
+---------+---------+
| 1198030 |  301970 |
+---------+---------+

(MySQL の時間は、各文の前後で NOW(6) の差を取った値を並べています。)

退避テーブルを設計するときの決めごとは次の3つです。

決めること 選び方
退避テーブルの索引 本体より少なくする。調べるときに使う列(日付、利用者ID)だけに付ける
退避先での保持期間 一覧表に「退避後○年で削除」と書く。退避テーブルも増え続けるため、月単位のパーティションにしておくと期限後の削除が軽い
件数の確認 処理の最後に、INSERT した件数と DELETE した件数が一致することを記録する

外部キーがあるテーブルの削除順

親(予約)と子(予約の明細)が外部キーでつながっているとき、親から消すと止まります。

CREATE TABLE reservations (id bigint PRIMARY KEY, reserved_on date NOT NULL);
CREATE TABLE reservation_items (
  id bigint PRIMARY KEY,
  reservation_id bigint NOT NULL REFERENCES reservations (id),
  item_name text NOT NULL
);
INSERT INTO reservations VALUES (1, '2024-05-10'), (2, '2024-06-20'), (3, '2026-09-01');
INSERT INTO reservation_items VALUES
  (1, 1, 'プロジェクター'), (2, 1, 'スクリーン'), (3, 2, '会議用マイク'), (4, 3, 'ノートPC');

DELETE FROM reservations WHERE reserved_on < date '2025-10-01';

PostgreSQL 16 のエラーです。

ERROR:  update or delete on table "reservations" violates foreign key constraint "reservation_items_reservation_id_fkey" on table "reservation_items"
DETAIL:  Key (id)=(1) is still referenced from table "reservation_items".

MySQL 8.0 のエラーです(item_name を varchar(50)、制約名を fk_items_reservation として同じ表を作成)。

ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`appdb`.`reservation_items`, CONSTRAINT `fk_items_reservation` FOREIGN KEY (`reservation_id`) REFERENCES `reservations` (`id`))

子から先に、親と同じ条件で消します。

BEGIN;
DELETE FROM reservation_items
WHERE reservation_id IN (SELECT id FROM reservations WHERE reserved_on < date '2025-10-01');
DELETE FROM reservations WHERE reserved_on < date '2025-10-01';
COMMIT;

SELECT (SELECT count(*) FROM reservations) AS reservations,
       (SELECT count(*) FROM reservation_items) AS items;
DELETE 3
DELETE 2
COMMIT
 reservations | items 
--------------+-------
            1 |     1

削除順の決め方

どのテーブルがどのテーブルを参照しているかは、システムカタログから一覧にできます。

-- PostgreSQL
SELECT conrelid::regclass AS child, confrelid::regclass AS parent
FROM pg_constraint
WHERE contype = 'f' AND connamespace = 'public'::regnamespace
ORDER BY 1;
       child       |    parent    
-------------------+--------------
 reservation_items | reservations
-- MySQL
SELECT table_name AS child, referenced_table_name AS parent, constraint_name
FROM information_schema.referential_constraints
WHERE constraint_schema = 'appdb';
+-------------------+--------------+----------------------+
| child             | parent       | CONSTRAINT_NAME      |
+-------------------+--------------+----------------------+
| reservation_items | reservations | fk_items_reservation |
+-------------------+--------------+----------------------+

この一覧から、次の手順で順番を決めます。

  1. どこからも参照されていないテーブル(child にだけ出てくるもの)を最初に処理する。
  2. 処理済みのテーブルを一覧から除き、残りの中でどこからも参照されていないものを次に処理する。
  3. これを繰り返す。最後に残るのが、いちばん上の親です。
  4. 子の対象は「親の条件」で決める。子の日付で決めると、親が残っているのに明細だけ消える行が出ます。

外部キーに ON DELETE CASCADE を付けておけば、親を消すと子も消えます。ただし保存期間の処理では、退避が必要な子を、退避する前に消してしまうことになりやすいので、順番を明示して子から処理する形が扱いやすくなります。

PostgreSQL では、パーティション表が親(参照される側)の場合も同じです。子の行が残っているパーティションを切り離そうとすると止まります。

CREATE TABLE orders_p (
  id bigint NOT NULL, ordered_at date NOT NULL, PRIMARY KEY (id, ordered_at)
) PARTITION BY RANGE (ordered_at);
CREATE TABLE orders_p_2024 PARTITION OF orders_p FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE orders_p_2025 PARTITION OF orders_p FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
CREATE TABLE order_items (
  id bigint PRIMARY KEY, order_id bigint NOT NULL, ordered_at date NOT NULL,
  FOREIGN KEY (order_id, ordered_at) REFERENCES orders_p (id, ordered_at)
);
INSERT INTO orders_p VALUES (1, '2024-03-01'), (2, '2025-03-01');
INSERT INTO order_items VALUES (1, 1, '2024-03-01'), (2, 2, '2025-03-01');

ALTER TABLE orders_p DETACH PARTITION orders_p_2024;
ERROR:  removing partition "orders_p_2024" violates foreign key constraint "order_items_order_id_ordered_at_fkey1"
DETAIL:  Key (order_id, ordered_at)=(1, 2024-03-01) is still referenced from table "order_items".

子の行を先に消すと、切り離しと削除が通ります。

DELETE FROM order_items WHERE ordered_at < date '2025-01-01';
ALTER TABLE orders_p DETACH PARTITION orders_p_2024;
DROP TABLE orders_p_2024;
DELETE 1
ALTER TABLE
DROP TABLE

アーカイブ・バックアップ・論理削除の違いと選び方

3つとも「データを残す」ように見えますが、目的が違います。

項目 アーカイブ(退避) バックアップ 論理削除
目的 使わなくなったデータを本体から移して保管する 壊れた・消えたデータを元に戻す 消した行を画面から隠し、取り消せるようにする
対象 期限を過ぎた行 ある時点の全データ 利用者が削除した行
本体のテーブル 小さくなる 変わらない 小さくならない
取り出し方 退避先を SQL やファイルで検索 復元の手順を実行 印を外す UPDATE
残す期間 一覧表の「退避後の保持期間」 保持世代 一覧表の「保持期間」(過ぎたら削除か退避)
やりたいこと 選ぶもの
テーブルを小さくして検索や保守を軽くしたい アーカイブ(退避)または削除
障害や操作の誤りから元に戻せるようにしたい バックアップ
利用者の「削除」を取り消せるようにしたい 論理削除
過去のデータを、数年後でも行単位で調べたい アーカイブ(退避テーブル)
期限後は確実に消えている状態にしたい 削除。バックアップの保持世代も一覧表の備考に書く
論理削除した行が増えてきた 論理削除の行に保持期間を決め、期限後に退避か削除

バックアップはアーカイブの代わりになりません。 バックアップから1行だけを取り出すには復元の作業が必要で、保持世代を過ぎると消えます。逆に、退避テーブルも本体と同じデータベースにあれば同じ障害の影響を受けるので、バックアップの対象に含めます。

保存期間の処理を作る手順

  1. テーブルを全部並べる。 無期限のものも含めて一覧表に書く。
  2. 起算日の列を決める。 列がなければ追加する(完了日時、退会日時など)。
  3. 保持期間と根拠を書く。 根拠が書けない行は、判断する担当と期間を相談する。
  4. 期限後の扱いを選ぶ。 削除・退避・匿名化。
  5. 増加件数から実行方式を選ぶ。 外部キーの有無も確認する。
  6. 親子の処理順を決める。 参照の一覧から、子を先にする。
  7. 期限切れの件数を数える SQL を先に作る。 処理の前後で件数を記録する。
  8. 少ない件数で試してから、定期実行に載せる。 初回は溜まった分が多いので、分割して流す。

実行方式を選ぶ判断表です。

条件 選ぶ方式
1回の対象が数万件まで 一括 DELETE
1回の対象が数万〜数十万件、または外部キーがある 分割 DELETE
月に数十万件以上増える、外部キーが要らない、日付で区切れる 日付パーティションの DROP
上と同じで、消す前に保管が必要 パーティションを切り離して書き出し、そのあと DROP
期限後も行単位で検索する 退避テーブルへ INSERT してから DELETE
行は残して個人が分かる列だけ消す UPDATE(匿名化)

確かめて分かったこと

  • 分割DELETE は合計時間が短くなるとは限らない。 PostgreSQL では一括 0.19 秒に対して分割は 5.6 秒でした。MySQL では一括 7.4 秒に対して分割 3.4 秒でした。分割の利点は、1回のロックが短いことと、途中から再開できることです。
  • PostgreSQL は DELETE と VACUUM のあともファイルの大きさが変わらなかった(162 MB のまま)。 空きは同じテーブルで再利用されます。ディスクを空ける目的ならパーティションの DROP が合います。
  • パーティションの切り離しは桁が違う。 約10万件の1か月分が、PostgreSQL の DETACH で 0.8 ms、MySQL の EXCHANGE PARTITION で 8.9 ms でした。
  • MySQL には DETACH がなく、EXCHANGE PARTITION で空の表と入れ替える。 入れ替えたあと、空になったパーティションを DROP します。
  • MySQL のパーティション表には外部キーを付けられない(ERROR 1506)。 外部キーが必要なテーブルは別の方式を選びます。
  • PostgreSQL は、子から参照されている行があるパーティションを切り離せない。 エラー文に、残っている子のキーが表示されます。

よくある質問

Q. データの保存期間はどうやって決めればよいですか? A. テーブルごとに「起算日・保持期間・期限後の扱い・実行方式」の4項目を一覧表に書いて決めます。期間の数字は、法令・契約・社内規程と、そのデータを調べる必要がある期間から決め、根拠の列に書き残します。

Q. アーカイブとバックアップの違いは何ですか? A. アーカイブは使わなくなったデータを本体から移して保管すること、バックアップは壊れたデータを元に戻すための写しです。アーカイブすると本体は小さくなり、バックアップでは本体は変わりません。

Q. 大量の行を DELETE すると時間がかかるときはどうすればよいですか? A. 1万件ずつなどに分けてコミットする分割DELETE にするか、日付パーティションにして期限切れの月を表ごと外します。今回の計測では、30万件の DELETE が MySQL で約 7.4 秒、同じ量を含むパーティションの DROP は 0.07 秒でした。

Q. 外部キーのエラーで削除できないときはどうすればよいですか? A. 参照している側(子)のテーブルから先に、親と同じ条件で消します。参照の関係は、PostgreSQL なら pg_constraint、MySQL なら information_schema.referential_constraints で一覧にできます。

Q. 論理削除した行はいつまで残せばよいですか? A. 論理削除の行にも保持期間を決め、一覧表に書きます。期限を過ぎたら、退避テーブルへ移すか削除して、本体のテーブルが増え続けないようにします。

まとめ

  • 保存期間はテーブル単位で、起算日・保持期間・期限後の扱い・実行方式の4項目を一覧表で決める。
  • 期限後の扱いは、削除・退避・匿名化の3つから選ぶ。 無期限のテーブルも1行として書く。
  • 量が多いログ・履歴は日付パーティションにして、期限切れの月を表ごと外す。 30万件が 0.001〜0.07 秒で外れた。
  • 量が少ないテーブルや外部キーがあるテーブルは分割DELETE。 目的は合計時間ではなく、ロックを短く保つこと。
  • 退避は INSERT と DELETE を同じトランザクション・同じ条件で行い、件数を記録する。
  • 外部キーがあるときは子から先に消す。 参照の一覧をカタログから取り、順番を決める。
  • アーカイブ・バックアップ・論理削除は目的が違う。 バックアップはアーカイブの代わりにならない。

参考・出典

  • PostgreSQL 16 Documentation「5.11. Table Partitioning」 https://www.postgresql.org/docs/16/ddl-partitioning.html (2026-10-06 確認)
  • MySQL 8.0 Reference Manual「Partitioning Limitations Relating to Storage Engines」 https://dev.mysql.com/doc/refman/8.0/en/partitioning-limitations-storage-engines.html (2026-10-06 確認)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「データ保存期間の設計|アーカイブと削除方式の比較と一覧表例」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?