0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

データベース設計入門 第10回:履歴データをどう設計するか

0
Last updated at Posted at 2026-09-30

はじめに

これまでの記事では、データベース設計について、

  • 必要なデータの洗い出し
  • テーブル・カラム設計
  • テーブル同士のリレーション
  • 正規化
  • PRIMARY KEY・FOREIGN KEY・UNIQUEなどの制約
  • データ型
  • インデックス
  • 論理削除・物理削除

といった内容を見てきました。

今回は、さらに実務寄りのテーマとして、

「データが変更されたとき、過去の値をどう残すのか」

という履歴データの設計について考えていきます。

システムでは、データが一度登録されたら永久に変わらないとは限りません。

例えば、

  • 商品の価格が変わる
  • ユーザーの住所が変わる
  • 契約内容が変わる
  • 商品のステータスが変わる
  • 社員の所属部署が変わる

といったことがあります。

単純に現在の値だけが分かればよいのであれば、既存のカラムを更新するだけでも問題ありません。

しかし、

「この商品は先月いくらだったのか?」

「この契約は2026年4月時点ではどのような内容だったのか?」

といった情報が必要になる場合、現在の値だけを保存する設計では対応できません。

今回は商品の価格変更を例に、履歴データをどのように設計するのかを考えていきます。


この記事の対象者

この記事は、次のような人を対象にしています。

  • エンジニアとして開発しているが、データベースについてあまり詳しくない人
  • SQLを書いた経験はあるものの、テーブル設計の経験が少ない人
  • データの更新は経験しているが、「過去の値を残す設計」をあまり考えたことがない人
  • 履歴テーブルを作ったことはあるものの、どのような場合に必要なのか整理したい人

SQLの基本的な知識があると理解しやすいですが、履歴管理の経験がなくても問題ありません。


値を書き換えるだけでは何が問題なのか

まず、単純な商品テーブルを考えてみます。

products

+----+----------+-------+
| id | name     | price |
+----+----------+-------+
| 1  | 商品A    | 1000  |
+----+----------+-------+

商品Aの価格は現在、

1000円

だとします。

そして翌日、価格が

1200円

へ変更されたとします。

現在の価格だけを管理するのであれば、

products.price

を書き換えればよいため、

products

+----+----------+-------+
| id | name     | price |
+----+----------+-------+
| 1  | 商品A    | 1200  |
+----+----------+-------+

となります。

一見すると、特に問題はありません。

しかし、この更新によって、

昨日:1000円
今日:1200円

という情報のうち、

昨日:1000円

という情報はデータベースから失われています。

そのため、あとから、

「2026年9月28日時点の商品Aの価格はいくらだったのか?」

と聞かれても、データベースから調べることができません。


「現在の値」と「過去の値」は別の情報

ここで重要なのは、

現在の値と、過去にどのような値だったのかという情報は別物

だということです。

例えば、

商品A

現在価格:1200円

という情報と、

2026-09-01 ~ 2026-09-28:1000円
2026-09-29 ~ 現在      :1200円

という情報では、持っている意味が違います。

前者は、

「今いくらなのか」

を表しています。

後者は、

「いつ、いくらだったのか」

を表しています。

システムで後者が必要なのであれば、単純なUPDATEだけでは十分ではありません。

そこで、履歴を保存するためのテーブルを考えます。


価格履歴テーブルを作る

例えば、商品価格の履歴を管理するために、

product_prices

というテーブルを作ることができます。

product_prices

+------------+-------+------------+------------+
| product_id | price | valid_from | valid_to   |
+------------+-------+------------+------------+
| 1          | 1000  | 2026-09-01 | 2026-09-28 |
| 1          | 1200  | 2026-09-29 | NULL       |
+------------+-------+------------+------------+

それぞれのカラムは、例えば次のような意味になります。

product_id
    どの商品についての価格なのか

price
    その期間の価格

valid_from
    その価格が有効になった日時

valid_to
    その価格が有効でなくなった日時

valid_to が NULL のデータを、

現在も有効

と扱う設計にすることもできます。

すると、

商品A

2026-09-01 ~ 2026-09-28
1000円

2026-09-29 ~ 現在
1200円

という価格の変化をデータとして残せます。


productsとproduct_pricesの関係

ここまでの構造を整理すると、

products
    │
    │ 1
    │
    └──── N
         product_prices

という関係になります。

1つの商品に対して、複数の価格履歴が存在するため、

1対多

の関係です。

例えば、

products

+----+----------+
| id | name     |
+----+----------+
| 1  | 商品A    |
+----+----------+

に対して、

product_prices

+----+------------+-------+------------+------------+
| id | product_id | price | valid_from | valid_to   |
+----+------------+-------+------------+------------+
| 1  | 1          | 1000  | 2026-09-01 | 2026-09-28 |
| 2  | 1          | 1200  | 2026-09-29 | NULL       |
+----+------------+-------+------------+------------+

のような形です。

product_prices.product_idには、

products.id

を参照する外部キーを設定することが考えられます。

こうすることで、

存在しない商品の価格履歴が登録されてしまう

といったデータの不整合を防ぎやすくなります。

外部キーについては、第4回や第6回でも扱いました。


価格を変更するときはどうするのか

履歴を残さない設計では、価格変更は単純です。

例えば、

UPDATE products
SET price = 1200
WHERE id = 1;

とすれば価格を変更できます。

一方、履歴管理をする場合は考え方が変わります。

現在、

1000円
2026-09-01 ~ 現在

というデータがあるとします。

+------------+-------+------------+----------+
| product_id | price | valid_from | valid_to |
+------------+-------+------------+----------+
| 1          | 1000  | 2026-09-01 | NULL     |
+------------+-------+------------+----------+

2026年9月29日から1200円に変更するのであれば、

まず現在の1000円の期間を終了させ、

1000円
2026-09-01 ~ 2026-09-28

とします。

そして、新しい履歴として、

1200円
2026-09-29 ~ 現在

を追加します。

結果として、

+------------+-------+------------+------------+
| product_id | price | valid_from | valid_to   |
+------------+-------+------------+------------+
| 1          | 1000  | 2026-09-01 | 2026-09-28 |
| 1          | 1200  | 2026-09-29 | NULL       |
+------------+-------+------------+------------+

となります。

つまり、履歴管理では単純に、

古い値を新しい値で上書きする

のではなく、

古い値の有効期間を終了する
        ↓
新しい値を追加する

という考え方になります。


valid_fromとvalid_to

履歴管理では、

valid_from
valid_to

のような有効期間を持たせる設計がよく使われます。

例えば、

+-------+---------------------+---------------------+
| price | valid_from          | valid_to            |
+-------+---------------------+---------------------+
| 1000  | 2026-09-01 00:00:00 | 2026-09-29 00:00:00 |
| 1200  | 2026-09-29 00:00:00 | NULL                |
+-------+---------------------+---------------------+

という形です。

この場合、

valid_from <= 対象日時

かつ、

対象日時 < valid_to

というルールで有効期間を判断できます。

valid_to が NULL の場合は、

終了日時がまだ決まっていない
=現在も有効

という扱いです。

日付だけで管理する方法もありますが、価格が1日の途中で変更される可能性があるシステムでは、日時で管理する必要があります。

何を保存するべきかは、システムの要件によって変わります。


「いつからいつまで」をどう定義するか

ここは履歴設計で特に注意したい部分です。

例えば、

1000円
valid_from = 2026-09-01 00:00:00
valid_to   = 2026-09-29 00:00:00

というデータがある場合、

valid_toの時刻そのものを含むのかどうかを決めておく必要があります。

実務では例えば、

valid_from <= 対象日時 < valid_to

のように、

開始日時は含むが、終了日時は含まない

というルールにすると扱いやすくなります。

すると、

1000円

2026-09-01 00:00:00
    以上

2026-09-29 00:00:00
    未満

となり、次の価格を、

1200円

2026-09-29 00:00:00
    以上

とすることで、

1000円の終了時刻
=
1200円の開始時刻

とできます。

期間の境界が曖昧になるのを防ぐためにも、

「valid_toを含むのか、含まないのか」

といったルールはあらかじめ決めておいたほうがよいでしょう。


過去の価格を検索する

履歴データを保存していれば、

「2026年9月15日の商品Aはいくらだったか」

といった検索もできるようになります。

概念的には、

SELECT price
FROM product_prices
WHERE product_id = 1
  AND valid_from <= '2026-09-15'
  AND (
      valid_to > '2026-09-15'
      OR valid_to IS NULL
  );

のような検索です。

この場合、

1000円

が取得されます。

一方、

2026-09-30

の価格を検索すれば、

1200円

が取得されます。

つまり履歴テーブルを作ることで、

「現在の状態」だけではなく「指定した時点の状態」

を調べられるようになります。


現在の価格はどこに持つのか

ここで設計上の疑問が出てきます。

products.priceは残したほうがよいのでしょうか?

例えば、

products

id
name
price

と、

product_prices

product_id
price
valid_from
valid_to

の両方に価格を持つ設計です。

これはシステムによって判断が分かれます。


product_pricesだけで管理する方法

例えば、

products

id
name

として、

価格はすべて、

product_prices

で管理します。

現在価格が欲しい場合は、

valid_to IS NULL

のデータを取得します。

イメージとしては、

SELECT price
FROM product_prices
WHERE product_id = 1
  AND valid_to IS NULL;

です。

この方法では、価格を保存する場所が1か所になるため、

products.price = 1200

product_pricesの現在価格 = 1000

のように、2つの価格が食い違う問題を避けやすくなります。

一方で、商品一覧などで現在価格を頻繁に利用する場合、毎回履歴テーブルを参照する必要があります。

そのため、検索方法やインデックスについても考える必要があります。


productsにも現在価格を持つ方法

もう一つは、

products.price

に現在価格を保存しながら、

product_prices

にも履歴を保存する方法です。

products

+----+----------+-------+
| id | name     | price |
+----+----------+-------+
| 1  | 商品A    | 1200  |
+----+----------+-------+
product_prices

+------------+-------+------------+------------+
| product_id | price | valid_from | valid_to   |
+------------+-------+------------+------------+
| 1          | 1000  | 2026-09-01 | 2026-09-28 |
| 1          | 1200  | 2026-09-29 | NULL       |
+------------+-------+------------+------------+

こうすると、

現在価格 → products.price

過去価格 → product_prices

という使い分けができます。

現在価格を頻繁に参照するシステムでは便利な場合があります。

しかし、

同じ意味の現在価格を2か所に保存する

ことになるため注意が必要です。

例えば更新処理に失敗して、

products.price = 1200

product_pricesの現在価格 = 1000

となれば、

「現在価格はどちらが正しいのか?」

という問題が起きます。

そのため、この方法を採用する場合は、

どちらを正しいデータとして扱うのか

を明確にしたうえで、更新を同一トランザクションで行うなど、整合性を維持する仕組みが必要になります。

単純に、

「検索が楽そうだから両方に入れておこう」

という理由だけで重複して持つのは避けたほうがよいでしょう。


履歴データで注意したい「期間の重複」

履歴テーブルを作れば、それだけで正しい履歴管理ができるわけではありません。

例えば、次のデータが登録されてしまったらどうでしょうか。

+-------+------------+------------+
| price | valid_from | valid_to   |
+-------+------------+------------+
| 1000  | 2026-09-01 | 2026-09-30 |
| 1200  | 2026-09-20 | NULL       |
+-------+------------+------------+

2026年9月20日から9月29日まで、

1000円

と

1200円

の両方が有効になっています。

すると、

「2026年9月25日の価格はいくらなのか?」

という問いに対して、2つの答えが存在してしまいます。

これでは履歴として正しくありません。

基本的には、同じ商品の価格履歴について、

期間が重複しない

ようにする必要があります。

例えば、

1000円
2026-09-01 ~ 2026-09-29

1200円
2026-09-29 ~ 現在

のようにつなげます。

ただし、期間の重複をデータベースの制約だけで完全に防ぐ方法は、利用しているデータベース製品によって異なります。

そのため、

  • DB側の制約
  • 更新処理
  • トランザクション
  • アプリケーション側のチェック

などを組み合わせて、履歴の整合性を守ることを考える必要があります。


現在有効なデータが複数存在しないようにする

もう一つ注意したいのが、

valid_to IS NULL

のデータです。

例えば、

+------------+-------+------------+----------+
| product_id | price | valid_from | valid_to |
+------------+-------+------------+----------+
| 1          | 1200  | 2026-09-29 | NULL     |
| 1          | 1300  | 2026-10-01 | NULL     |
+------------+-------+------------+----------+

となってしまうと、

現在有効な価格

が2件存在します。

これも、

「現在価格はいくらなのか?」

が分からなくなる原因になります。

そのため、

1商品につき現在有効な価格は原則1件

というルールも考える必要があります。

履歴テーブルを設計するときは、

単に、

valid_from
valid_to

を追加するだけではなく、

どのような状態を「正しい履歴」とするのか

まで考えることが重要です。


未来の価格を登録したい場合

システムによっては、

「来月から価格を1500円にする」

という情報を事前に登録したい場合があります。

例えば、

+-------+---------------------+---------------------+
| price | valid_from          | valid_to            |
+-------+---------------------+---------------------+
| 1200  | 2026-09-29 00:00:00 | 2026-10-01 00:00:00 |
| 1500  | 2026-10-01 00:00:00 | NULL                |
+-------+---------------------+---------------------+

とすれば、

現在:1200円

2026年10月1日から:1500円

という予約された価格変更も表現できます。

このように、

valid_from
valid_to

は単なる「過去の履歴」だけではなく、

いつからそのデータを有効にするのか

を表す仕組みとして利用できます。


注文時の価格はどうするのか

価格履歴を考えるとき、ECサイトではもう一つ重要な問題があります。

例えば、あるユーザーが商品Aを、

1000円

のときに購入したとします。

その後、商品Aの価格が、

1200円

になりました。

このとき注文履歴には、どちらの価格を表示するべきでしょうか。

当然、

購入時の1000円

です。

そこで、

order_items

などの注文明細には、注文時点の価格を保存することがあります。

例えば、

order_items

+----------+------------+----------+-------+
| order_id | product_id | quantity | price |
+----------+------------+----------+-------+
| 1001     | 1          | 1        | 1000  |
+----------+------------+----------+-------+

です。

一見すると、

商品価格はproduct_pricesにあるのだから、
注文テーブルにpriceを持つのは重複では?

と思うかもしれません。

しかし、このpriceは意味が違います。

product_prices.price

→ その期間の商品価格

に対して、

order_items.price

→ その注文で実際に確定した価格

です。

例えば、

  • セール
  • クーポン
  • 会員割引
  • 個別値引き
  • キャンペーン

などによって、商品の通常価格と実際の販売価格が違うこともあります。

そのため注文では、

「注文時点で確定した事実」

として価格を保存することが重要になります。

これは単なる履歴管理とは少し違い、

取引時点の情報をスナップショットとして残す

という考え方です。


何でも履歴にすればよいわけではない

ここまで見ると、

「変更される可能性があるデータは、全部履歴にすれば安全なのでは?」

と思うかもしれません。

しかし、履歴管理にもコストがあります。

履歴を保存すると、

  • データ件数が増える
  • テーブル構造が複雑になる
  • INSERT・UPDATE処理が複雑になる
  • 現在値を取得するSQLが複雑になる
  • 過去時点の検索条件を考える必要がある
  • インデックス設計も必要になる
  • 履歴同士の期間が重複しないよう管理する必要がある

といった問題が発生します。

そのため、

変更されるデータ
=
必ず履歴を残す

ではありません。


履歴を残すべきかどうかを考える

履歴管理が必要かどうかは、要件から考えます。

例えば、次のような場合は履歴が必要になる可能性があります。

過去時点の状態を確認する必要がある

2026年4月1日時点の契約内容を確認したい

変更前の値を確認する必要がある

商品の価格がいつ、いくらからいくらへ変わったのか知りたい

業務上の証跡として必要

当時どの条件で契約していたのか残しておかなければならない

将来の変更を予約したい

来月1日から新料金を適用する

一方、

過去の値を確認する必要がなく、
常に最新の値だけ分かればよい

のであれば、通常のUPDATEで十分な場合もあります。

重要なのは、

「変更されるから履歴を作る」のではなく、「過去の状態を利用する必要があるから履歴を作る」

という考え方です。


updated_atは履歴ではない

テーブルには、

created_at
updated_at

のようなカラムを持たせることがあります。

例えば、

products

id
name
price
created_at
updated_at

です。

updated_atを見ることで、

最後にいつ更新されたのか

は分かります。

しかし、

1000円 → 1200円

に変更された場合、

updated_atだけでは、

以前はいくらだったのか

は分かりません。

つまり、

updated_at

は、

「最後に更新された日時」

を保存するものであり、

変更前のデータそのものを保存する履歴ではありません。

ここは混同しやすいので注意が必要です。


履歴と監査ログも少し違う

もう一つ似たものとして、

監査ログ

があります。

例えば、

誰が
いつ
何を
どの値から
どの値へ変更したのか

を保存する仕組みです。

例えば、

product_price_change_logs

product_id
old_price
new_price
changed_by
changed_at

のようなデータです。

これは、

「変更操作の記録」

を残すことが目的です。

一方、

product_prices

のような履歴テーブルは、

「ある時点でどの状態だったのか」

を表すことが主な目的です。

似ていますが、

履歴データ
→ その時点の状態を知りたい

監査ログ
→ 誰がどのような変更をしたのか知りたい

という違いがあります。

システムによっては両方必要になることもあります。


論理削除との違い

前回の記事では、

deleted_at

などを利用した論理削除について説明しました。

論理削除も、

過去のデータを物理的には残す

という点では履歴管理に似ています。

しかし目的は異なります。

論理削除は基本的に、

このデータは現在利用できるのか
それとも削除扱いなのか

を管理するものです。

例えば、

users

id
name
deleted_at

で、

deleted_at IS NULL

なら利用中、

deleted_at IS NOT NULL

なら削除済み、

と判断します。

一方、今回扱っている履歴管理は、

あるデータがいつからいつまで、
どのような値だったのか

を管理するものです。

そのため、

deleted_atを付ければ履歴管理になる

わけではありません。

データを残す目的によって、設計を使い分ける必要があります。


履歴テーブルにもインデックスを考える

履歴データは、時間が経つほど増えていきます。

例えば、10万商品について価格変更が何度も行われれば、

product_prices

には大量のデータが蓄積されます。

その状態で、

特定商品の現在価格を探す
特定商品の指定日時の価格を探す

といった検索を頻繁に行うのであれば、インデックスも重要になります。

例えば検索条件として、

product_id
valid_from
valid_to

などが使われる可能性があります。

ただし、第8回でも説明したように、

インデックスは付ければ付けるほどよいわけではありません。

実際にどのようなSQLで検索するのかを確認しながら設計する必要があります。


履歴管理では「何を残したいのか」から考える

履歴テーブルを作るとき、

とりあえず変更前の値を全部保存しておこう

と考えるのではなく、まず要件を整理することが重要です。

例えば商品価格なら、

過去の価格を確認したいのか
価格が変更された日時を知りたいのか
未来の価格変更も予約したいのか
誰が価格を変更したのかも知りたいのか
注文時点の販売価格も残す必要があるのか

によって、必要なデータは変わります。

これはシリーズ第2回で扱った、

「要件から必要なデータを洗い出す」

という考え方と同じです。

履歴管理だから特別な設計方法を使うというより、

「システムで何を後から確認する必要があるのか」

を考え、そのために必要なデータ構造を設計することが重要です。


今回のポイント

今回は、履歴データの設計について見てきました。

ポイントを整理すると、

現在値をUPDATEするだけでは過去の値は失われる
過去の状態が必要なら履歴テーブルを検討する
valid_from / valid_toなどで有効期間を表現できる
同じ期間の履歴が重複しないようにする
現在有効なデータが複数存在しないようにする
productsとproduct_pricesのように1対多で管理できる
注文時の価格などは「その時点で確定した事実」として別途保存することがある
updated_atは変更日時であって履歴そのものではない
履歴と監査ログは目的が異なる
何でも履歴にするのではなく、要件から必要性を判断する

という点が重要です。

データベース設計では、

「今のデータをどう保存するか」

だけではなく、

「データが変化したとき、その変化をどこまで残す必要があるのか」

も考える必要があります。

商品価格のように、

昨日:1000円
今日:1200円

と変化するデータについて、

products.price = 1200

と上書きするだけでよいのか、

それとも、

product_prices

product_id
price
valid_from
valid_to

のように履歴として残す必要があるのか。

これはデータベースだけで決める問題ではなく、

「システムとして過去の状態をどこまで必要とするのか」

という業務要件から考えることが重要です。


関連記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?