「マスタは第3正規形、トランザクションは正規化しない」は正しいか?データベース設計の指針を初心者向けに検証

こんにちは。ゆうせいです。

データベース設計の現場では、「マスタテーブルは第3正規形まで正規化し、トランザクションテーブルは正規化しない」という指針を耳にすることがあります。シンプルで覚えやすい方針ですが、そのまま新人エンジニアに教えてよいのでしょうか。

今回は、この指針の意図をくみ取ったうえで、どこが妥当で、どこに落とし穴があるのかを具体的なテーブル例で検証します。最後に、研修や設計レビューでそのまま使える「書き換えた指針」も紹介します。

結論:意図は妥当だが、「正規化しない」という表現は危険

先に結論をお伝えします。

  • この指針が言いたいことの多くは、「取引時点の値(単価、税率、届け先など)をトランザクションに残しておくべき」という点です。この意図は正しく、実務でも重要です
  • ただし、取引時点の値を残すことは、実は「非正規化」ではありません。正規化の理論に照らしても、第3正規形のまま実現できます
  • 「トランザクションは正規化しない」を文字どおり受け取ると、繰り返し項目や不要なコピーが増え、データの不整合やバグの原因になります

そこで、次のように言い換えることをおすすめします。

「マスタもトランザクションも原則として第3正規形にする。そのうえで、トランザクションには取引時点の値をスナップショットとして持たせる。性能のための非正規化は、計測して必要性を確認してから限定的に行う。」

ここから、この結論に至る理由を順番に確認していきます。

用語の確認

マスタテーブルとトランザクションテーブル

  • マスタテーブル:顧客、商品、社員、取引先など、業務の基本となる情報を管理するテーブルです。件数は比較的少なく、内容はときどき更新されます
  • トランザクションテーブル:受注、売上、入出庫、勤怠など、日々発生する出来事を記録するテーブルです。件数はどんどん増え、一度記録した内容は基本的に変更しません

身近な例でいえば、マスタは「お店の商品カタログ」、トランザクションは「レシートの束」です。カタログの値段は改定されますが、過去のレシートに印字された値段は変わりません。この違いが、今回のテーマの出発点になります。

正規化とは

正規化とは、同じ情報が複数の場所に重複して保存されないように、テーブルを分割していく設計手法です。重複があると、1か所だけ更新して他を更新し忘れる「更新時異常」が起きやすくなります。

正規化には段階があり、実務では第3正規形までを目標にするのが一般的です。次の「受注表」を例に確認します。

受注番号受注日顧客コード顧客名商品コード商品名単価数量
10012026-11-01C01山田商事P01ノートPC1200002
10012026-11-01C01山田商事P02マウス30002
10022026-11-02C02鈴木工業P01ノートPC1200001

主キーは(受注番号, 商品コード)の組み合わせとします。

  • 第1正規形:1つのセルに1つの値だけが入り、「商品1、商品2、商品3」のような繰り返しの列がない状態です。上の表は第1正規形を満たしています
  • 第2正規形:主キーの一部だけで決まる列を別テーブルに分けた状態です。上の表では、受注日と顧客コードは受注番号だけで決まり、商品名は商品コードだけで決まります。主キーの一部だけで決まっているので、第2正規形を満たしていません
  • 第3正規形:主キー以外の列によって決まる列を別テーブルに分けた状態です。顧客名は顧客コードによって決まるため、顧客テーブルに分けます

正規化を進めると、顧客テーブル、商品テーブル、受注テーブル、受注明細テーブルの4つに分かれます。

練習問題1

上の受注表で、第2正規形を満たしていない原因として最も適切なものはどれでしょうか。

  1. 1つのセルに、複数の商品名がまとめて入っているから
  2. 商品名が、主キーの一部である商品コードだけで決まるから
  3. 顧客名が、主キーではない顧客コードによって決まるから
  4. 単価と数量を掛けた金額の列が、表に含まれていないから

解答:2

解説:第2正規形は、主キーの一部だけで決まる列(部分関数従属)がない状態です。商品名は商品コードだけで決まるため、第2正規形の条件を満たしていません。

  • 1は、第1正規形の違反の説明です。この表では1セルに1つの値しか入っていません
  • 3は、主キー以外の列で決まる推移的関数従属で、第3正規形の違反の説明です
  • 4は、正規化とは関係がありません。計算できる値はむしろ保存しないのが基本です

この指針の「妥当な部分」

取引時点の値を残す必要がある

この指針が生まれた背景には、次のような失敗があります。

商品テーブルに単価を持ち、受注明細には商品コードと数量だけを保存していたとします。売上金額は、商品テーブルの単価と結合して計算します。

ある日、ノートPCの単価を120,000円から130,000円に値上げしました。すると、値上げ前に受注した過去の売上まで、130,000円で再計算されてしまいます。前月の売上報告と今月出力した前月の売上が一致しない、という事態が起きます。

同じ問題は、次のような値でも起こります。

  • 消費税率(税率改定の前後で計算が変わる)
  • 届け先住所(顧客が引っ越すと、過去の出荷先がわからなくなる)
  • 割引率や適用した料金プラン
  • 担当営業(異動すると、過去の実績が新しい担当者に付け替わる)

そのため、取引時点の値をトランザクション側に保存しておくことは、業務システムでは必須といえます。指針の意図はここにあります。

トランザクションは変更しないことが多い

トランザクションテーブルは、一度記録した後に更新することが少ないテーブルです。更新が少なければ、データの重複による更新時異常も起きにくくなります。この点も、「トランザクションは正規化にこだわらなくてよい」という考えの根拠になっています。

練習問題2

商品テーブルの単価だけを参照して売上を計算する設計で起きる問題として、最も適切なものはどれでしょうか。

  1. 商品を追加するたびに、受注明細テーブルの列数が増えてしまう
  2. 受注明細の件数が増えると、商品テーブルの単価が自動で下がる
  3. 同じ商品を2回注文すると、受注番号が重複してエラーになる
  4. 単価を改定すると、過去の売上金額まで新しい単価で計算される

解答:4

解説:受注明細が単価を持たず、常に商品テーブルの現在の単価を参照すると、価格改定の影響が過去の取引にまで及びます。過去の売上を正しく再現できなくなります。

  • 1は、繰り返し列を持つ設計の問題で、この設計とは関係がありません
  • 2のような動作は、データベースでは起こりません
  • 3は、主キーの設計の問題で、単価の参照方法とは関係がありません

この指針の「危険な部分」

「トランザクションは正規化しない」を文字どおりに受け取ると、次のような設計が生まれます。

危険1:繰り返し項目が生まれる

受注番号受注日商品コード1数量1商品コード2数量2商品コード3数量3
10012026-11-01P012P022(空)(空)

第1正規形を満たしていない設計です。4商品以上の注文に対応するにはテーブルの列を追加する必要があり、「商品P01の販売数量の合計」を求めるSQLも、3つの列を足し合わせる複雑なものになります。

危険2:必要のないコピーが増える

「正規化しないのだから」と、顧客名、顧客の電話番号、商品のカテゴリ名、担当者の部署名などを受注テーブルにすべてコピーしてしまうケースがあります。

その結果、次の問題が起きます。

  • 顧客が社名を変更したとき、過去の受注を同じ顧客として集計できなくなる
  • 入力時の表記揺れ(「山田商事」「(株)山田商事」)がトランザクションに残り、名寄せが必要になる
  • テーブルの列数とデータ量が増え、どの列が正しい情報なのか判断しにくくなる

危険3:計算できる値を保存して不整合が起きる

受注テーブルに「合計金額」を保存し、受注明細にも単価と数量を保存したとします。明細の数量を修正したとき、合計金額の更新を忘れると、2つの値が食い違います。どちらが正しいのか、データだけでは判断できません。

「トランザクションは変更しない」と言っても、実務では受注の修正、キャンセル、返品などが発生します。変更がまったくないという前提は成り立たないことが多いのです。

練習問題3

「トランザクションは正規化しない」を文字どおりに適用した場合に起きやすい問題として、最も適切なものはどれでしょうか。

  1. 合計金額などの重複した値が、修正のときに明細と食い違う
  2. 外部キー制約が多くなりすぎて、データを1件も登録できなくなる
  3. マスタテーブルの件数が増えて、トランザクションより大きくなる
  4. 主キーが自動的に削除されて、同じ受注を区別できなくなる

解答:1

解説:計算で求められる値や他のテーブルと重複する値を保存すると、片方だけが更新されたときに不整合が起きます。トランザクションでも修正やキャンセルは発生するため、この問題は避けられません。

  • 2は、正規化を進めた場合の懸念として挙げられることがありますが、登録できなくなるわけではありません
  • 3は、正規化の有無とは関係がありません
  • 4のような動作は、データベースでは起こりません

取引時点の値を残すことは「非正規化」ではない

ここが今回の記事で最も重要なポイントです。

関数従属で考える

正規化は、「ある列の値が、どの列によって決まるか(関数従属)」をもとに判断します。

  • 商品テーブルの「現在の単価」は、商品コードによって決まります
  • 受注明細の「販売単価」は、商品コードだけでは決まりません。同じノートPCでも、11月1日の受注は120,000円、12月1日の受注は130,000円かもしれないからです

つまり、販売単価は「その受注明細」によって決まる値です。受注明細の主キー(受注番号, 明細番号)に従属しているため、受注明細テーブルに置いても第3正規形を満たします。

現在の単価と販売単価は、名前が似ているだけで、意味の異なる別の情報なのです。

コピーすべきかを判断する問い

ある値をトランザクションに持たせるべきか迷ったときは、次の問いで判断できます。

「マスタの値が変わったとき、過去のトランザクションのこの値も一緒に変わるべきか」

値の例一緒に変わるべきか判断
販売単価変わるべきではないトランザクションに保存する
適用した税率変わるべきではないトランザクションに保存する
出荷先の住所変わるべきではないトランザクションに保存する
顧客のメールアドレス最新の値を使うべき顧客テーブルを参照する
商品のカテゴリ名最新の値を使うべき商品テーブルを参照する
受注の合計金額明細から計算できる保存せず、計算する

請求書に印字した商品名を後から正確に再現する必要がある、といった業務要件があれば、商品名も「取引時点の値」としてトランザクションに保存します。判断の基準はテーブルの種類ではなく、業務上その値が何を意味するかです。

練習問題4

受注明細テーブルに「販売単価」の列を持たせる設計について、正しい説明はどれでしょうか。

  1. 商品テーブルの単価と重複するので、第1正規形に違反している
  2. 商品コードによって決まる値なので、第2正規形に違反している
  3. 受注明細ごとに決まる値なので、第3正規形のまま保存できる
  4. 計算で求められる値なので、保存すると必ず不整合が起きる

解答:3

解説:販売単価は、同じ商品でも受注ごとに異なる可能性があります。そのため、受注明細の主キーに従属する値であり、受注明細テーブルに置いても正規化の考え方に反しません。

  • 1は誤りです。第1正規形は繰り返し項目や複数値のセルに関する条件で、他テーブルとの重複は関係しません
  • 2は誤りです。販売単価は商品コードだけでは決まらないため、部分関数従属ではありません
  • 4は誤りです。販売単価は他の列から計算できる値ではなく、取引時点で確定した値です

性能のための非正規化はどう考えるか

この指針には、「トランザクションは件数が多いので、結合(JOIN)を減らして速くしたい」という意図が含まれていることもあります。

性能のための非正規化そのものは、実務で使われる正当な手法です。ただし、次の順番で検討することをおすすめします。

  1. まず正規化した設計で作る
  2. 実際のデータ量で処理時間を計測し、遅い処理を特定する
  3. インデックスの追加、SQLの見直しで改善できないか確認する
  4. 集計結果を別テーブルに持つ(集計テーブル)、ビューやマテリアライズドビューを使う、などを検討する
  5. それでも足りない場合に、限定的に非正規化し、更新漏れを防ぐ仕組み(トリガー、バッチ処理、整合性チェック)とセットで導入する

最初から非正規化すると、性能が本当に必要だったのかを確認できないまま、不整合のリスクだけを抱えることになります。

なお、売上分析のためのデータウェアハウスでは、スタースキーマと呼ばれる意図的に非正規化した設計が使われます。日々の業務処理を行うデータベース(OLTP)と、分析用のデータベース(OLAP)では、設計の目的が異なることも押さえておきましょう。

推奨する指針(書き換え案)

研修や設計レビューでは、次のような指針を使うことをおすすめします。

  • マスタもトランザクションも、原則として第3正規形にする
  • トランザクションには、取引時点で確定した値(販売単価、税率、割引率、届け先など)をスナップショットとして保存する
  • スナップショットにするかどうかは、「マスタが変わったとき、過去の取引の値も変わるべきか」で判断する
  • 合計金額など、他の列から計算できる値は原則として保存しない
  • 性能のための非正規化は、計測して必要性を確認してから行い、理由と整合性の維持方法を設計書に記録する

MySQLでのテーブル定義例

ここまでの考え方を、MySQL 8.0のテーブル定義で表すと次のようになります。

-- 顧客マスタ
CREATE TABLE customers (
    customer_code VARCHAR(10)  NOT NULL,
    customer_name VARCHAR(100) NOT NULL,
    email         VARCHAR(255),
    address       VARCHAR(255),
    PRIMARY KEY (customer_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 商品マスタ(現在の単価を持つ)
CREATE TABLE products (
    product_code VARCHAR(10)  NOT NULL,
    product_name VARCHAR(100) NOT NULL,
    unit_price   INT          NOT NULL CHECK (unit_price >= 0),
    PRIMARY KEY (product_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 受注(ヘッダ):届け先は取引時点の値として保存する
CREATE TABLE orders (
    order_no         INT          NOT NULL AUTO_INCREMENT,
    order_date       DATE         NOT NULL,
    customer_code    VARCHAR(10)  NOT NULL,
    shipping_address VARCHAR(255) NOT NULL,
    PRIMARY KEY (order_no),
    FOREIGN KEY (customer_code) REFERENCES customers (customer_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 受注明細:販売単価と税率は取引時点の値として保存する
CREATE TABLE order_details (
    order_no     INT          NOT NULL,
    line_no      INT          NOT NULL,
    product_code VARCHAR(10)  NOT NULL,
    sale_price   INT          NOT NULL CHECK (sale_price >= 0),
    tax_rate     DECIMAL(4,2) NOT NULL,
    quantity     INT          NOT NULL CHECK (quantity > 0),
    PRIMARY KEY (order_no, line_no),
    FOREIGN KEY (order_no)     REFERENCES orders (order_no),
    FOREIGN KEY (product_code) REFERENCES products (product_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

受注を登録するときは、商品テーブルの現在の単価を読み取り、受注明細の sale_price にコピーします。

INSERT INTO customers VALUES ('C01', '山田商事', 'info@example.com', '名古屋市中区');
INSERT INTO products  VALUES ('P01', 'ノートPC', 120000), ('P02', 'マウス', 3000);
INSERT INTO orders (order_date, customer_code, shipping_address)
VALUES ('2026-11-01', 'C01', '名古屋市中区');

-- 受注時点の単価を商品マスタからコピーして明細に保存する
INSERT INTO order_details (order_no, line_no, product_code, sale_price, tax_rate, quantity)
SELECT 1, 1, product_code, unit_price, 0.10, 2 FROM products WHERE product_code = 'P01';
INSERT INTO order_details (order_no, line_no, product_code, sale_price, tax_rate, quantity)
SELECT 1, 2, product_code, unit_price, 0.10, 2 FROM products WHERE product_code = 'P02';

-- 価格改定:過去の受注明細には影響しない
UPDATE products SET unit_price = 130000 WHERE product_code = 'P01';

-- 受注ごとの合計金額(税抜)は保存せず、明細から計算する
SELECT o.order_no,
       c.customer_name,
       SUM(d.sale_price * d.quantity) AS total_amount
FROM orders o
JOIN customers c     ON c.customer_code = o.customer_code
JOIN order_details d ON d.order_no = o.order_no
GROUP BY o.order_no, c.customer_name;


最後のSELECT文の total_amount は、値上げ後も 120000 × 2 + 3000 × 2 = 246,000 になります。商品テーブルの単価を130,000円に変更しても、過去の受注金額は変わりません。

コードを読むときのポイントは次のとおりです。

  • 商品テーブルの unit_price は「現在の単価」、受注明細の sale_price は「受注時点の単価」で、意味が異なる列です。名前を変えておくと、取り違えを防げます
  • 顧客名は受注テーブルにコピーせず、顧客テーブルと結合して取得しています
  • 合計金額は列として持たず、SELECT文で計算しています
  • MySQLでは、CHECK制約は8.0.16以降で有効になります。それより前のバージョンでは無視されます

練習問題5

上のテーブル定義で、商品P01の単価を130,000円に変更した後、受注番号1の合計金額を計算すると何円になるでしょうか(税抜)。

  1. 133,000円
  2. 246,000円
  3. 266,000円
  4. 252,000円

解答:2

解説:受注明細の sale_price には、受注時点の単価(120,000円と3,000円)が保存されています。合計は 120000 × 2 + 3000 × 2 = 246,000円です。

  • 1は、各商品を1個ずつ、新しい単価で計算した値(130000 + 3000)です
  • 3は、新しい単価で再計算した値(130000 × 2 + 3000 × 2)です。過去の取引まで価格改定の影響を受ける設計の場合に、この結果になります
  • 4は、ノートPCを120,000円×2、マウスを4個として計算した値です。数量を取り違えています

まとめ

今回の内容を振り返ります。

  • 「マスタは第3正規形、トランザクションは正規化しない」という指針の主な意図は、取引時点の値を残すことにあり、その意図は正しいものです
  • ただし、取引時点の値は主キーに従属する別の情報なので、保存しても第3正規形のまま実現できます。「正規化しない」と表現する必要はありません
  • 文字どおりに正規化をやめると、繰り返し項目、不要なコピー、計算値の不整合が生まれます
  • 指針は「原則として第3正規形、取引時点の値はスナップショットとして保存、性能のための非正規化は計測してから」と言い換えるのがおすすめです

理解度を確認するために、次の問いに自分の言葉で答えてみてください。

  • 商品テーブルの単価と、受注明細の販売単価は、何が違いますか
  • 顧客の電話番号を受注テーブルにコピーすべきかどうかは、どのように判断しますか
  • 受注テーブルに合計金額を保存すると、どのような問題が起きる可能性がありますか

次の学習ステップとしては、ER図を使った設計の練習に進むことをおすすめします。実際の業務(受注、出荷、請求)を題材にテーブルを洗い出し、各列が「どの列によって決まるか」を書き出すと、正規化とスナップショットの判断が自然に身に付きます。そのうえで、インデックスと実行計画(EXPLAIN)を学ぶと、性能のための非正規化が本当に必要かどうかを、自分で判断できるようになります。

投稿者プロフィール

山崎講師
山崎講師代表取締役
セイ・コンサルティング・グループ株式会社代表取締役。
岐阜県出身。
海外放浪の末、2000年創業、2004年会社設立。
IT企業向け人材育成研修歴業界歴20年以上。
すべての無駄を省いた費用対効果の高い「筋肉質」な研修を提供します!
この記事に間違い等ありましたらぜひお知らせください。

学生時代は趣味と実益を兼ねてリゾートバイトにいそしむ。長野県白馬村に始まり、志賀高原でのスキーインストラクター、沖縄石垣島、北海道トマム。高じてオーストラリアのゴールドコーストでツアーガイドなど。現在は野菜作りにはまっている。