😇

【DB設計】正規化だけが全てじゃないと学んだ話

に公開

はじめに

データベース設計において、正規化は重要な原則の一つです。正規化により、データの整合性を保ち、更新時の不整合を防ぐことができます。

しかし、正規化は常に最適な選択とは限りません。読み取りパフォーマンスが重要な場面では、非正規化を検討することで、大幅なパフォーマンス向上が期待できる場合があります。

この記事では、実際の要件を想定して、正規化と非正規化のパフォーマンスを比較し、それぞれのメリット・デメリットを検証してみようと思います!!

想定する要件

ある社内管理システムで、以下の機能を実装する必要があるとします:

  1. ユーザー一覧画面: 部署名でフィルタリングして、該当するユーザーをページネーション付きで表示する
  2. 要件:
    • 部署名で検索・フィルタリングができる
    • 1ページあたり50件表示
    • ユーザーID順にソート
    • レスポンスタイムは100ms以下を目標

この要件を実現するために、正規化と非正規化それぞれ試してみます。

正規化されたスキーマ

まず、正規化されたスキーマを設計します。ユーザーと部署は多対多の関係なので、中間テーブルを使用します。

-- departments(部署)
CREATE TABLE departments (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL UNIQUE
);

-- users(ユーザー)
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

-- user_departments(中間テーブル)
CREATE TABLE user_departments (
  user_id INTEGER NOT NULL,
  department_id INTEGER NOT NULL,
  PRIMARY KEY (user_id, department_id),
  FOREIGN KEY (user_id) REFERENCES users(id),
  FOREIGN KEY (department_id) REFERENCES departments(id)
);

インデックスの作成

パフォーマンスを向上させるため、JOINで使用するカラムにインデックスを作成します。

CREATE INDEX idx_user_departments_department_id
  ON user_departments(department_id);

CREATE INDEX idx_user_departments_user_id
  ON user_departments(user_id);

ER図

非正規化テーブル

次に、非正規化版のテーブルを設計します。部署名を直接ユーザーテーブルに含めることで、JOINを不要にします。

CREATE TABLE users_denormalized (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  department_id INTEGER NOT NULL,
  department_name TEXT NOT NULL
);

ER図

データ投入

実際の運用を想定して、適当なダミーデータを投入します。

-- 部署データの投入(20部署)
INSERT INTO departments (name)
SELECT '部署_' || g
FROM generate_series(1, 20) AS g;

-- ユーザーデータの投入(10,000ユーザー)
INSERT INTO users (name)
SELECT 'user_' || g
FROM generate_series(1, 10000) AS g;

-- ユーザーと部署の関連データの投入(10,000件)
INSERT INTO user_departments (user_id, department_id)
SELECT
  u.id,
  (random() * 19 + 1)::int
FROM users u;

要件1: 正規化されたスキーマでの実装

正規化されたスキーマでは、部署名でフィルタリングしてユーザー一覧を取得するクエリは以下のようになります。

EXPLAIN ANALYZE
SELECT
  u.id,
  u.name AS user_name,
  d.name AS department_name
FROM users u
JOIN user_departments ud
  ON u.id = ud.user_id
JOIN departments d
  ON d.id = ud.department_id
WHERE d.name = '部署_5'
ORDER BY u.id
LIMIT 50 OFFSET 0;

実行結果:

Execution Time: 2.720 ms

クエリプランの特徴:

  • departmentsテーブルでのインデックススキャン(部署名で検索)
  • user_departmentsテーブルでのビットマップヒープスキャン(部署IDでフィルタリング)
  • usersテーブルでのインデックススキャン(ユーザー情報を取得)
  • 3つのテーブルをJOINする必要がある

この実装では、要件の目標レスポンスタイム(100ms以下)は達成できていますが、データ量が増えるとパフォーマンスが低下するかもしれないという懸念が残ります。

要件2: 非正規化テーブルでの実装

同じ要件を非正規化テーブルで試してみます。

CREATE TABLE users_denormalized (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  department_id INTEGER NOT NULL,
  department_name TEXT NOT NULL
);

-- 正規化テーブルから非正規化テーブルにデータを移行
INSERT INTO users_denormalized (name, department_id, department_name)
SELECT
  u.name,
  d.id AS department_id,
  d.name AS department_name
FROM users u
JOIN user_departments ud
  ON u.id = ud.user_id
JOIN departments d
  ON d.id = ud.department_id;

非正規化テーブルでのクエリは以下のようになります。

EXPLAIN ANALYZE
SELECT
  id,
  name AS user_name,
  department_name
FROM users_denormalized
WHERE department_name = '部署_5'
ORDER BY id
LIMIT 50 OFFSET 0;

前述したものと比べるとすっきりしたかと思います。
実行結果:

Execution Time: 0.618 ms

クエリプランの特徴:

  • 単一テーブルでのインデックススキャン
  • JOINが不要
  • フィルタリングのみで結果を取得

パフォーマンス比較

項目 正規化版 非正規化版
実行時間 2.720 ms 0.618 ms 約4.4倍速い
JOIN数 2回 0回 -
スキャンするテーブル数 3テーブル 1テーブル -
要件の目標達成 ✅ 達成 ✅ 達成 -

なぜ非正規化版が速いのか

  1. JOIN処理が不要: 正規化版は3つのテーブルをJOINする必要がありますが、非正規化版は単一テーブルから取得できます。JOIN処理はCPUとメモリを消費するため、これを削減できるのは大きなメリットです。

  2. インデックススキャンの回数が少ない: 正規化版は複数のインデックススキャンが必要ですが、非正規化版は1回のみです。これにより、I/O処理が削減されます。

  3. データの局所性: 必要なデータが1つのテーブルに集約されているため、メモリアクセスが効率的です。キャッシュのヒット率も向上します。

  4. クエリがシンプルで読みやすい: 非正規化テーブルでは、必要なデータが1つのテーブルにまとまっているため、JOINや中間テーブルを意識せずにクエリを書くことができるため、実装時の負担も減るかと思います。

パフォーマンス面だけでなく、クエリを書く・読むコストが下がるという点も、実務では無視できない非正規化の利点だと感じました。

非正規化のデメリットと対策

ただし、非正規化には以下のデメリットがあります:

1. データの重複

department_nameが各ユーザーレコードに重複して保存されるため、ストレージ容量が増加します。

対策: 部署名は比較的小さなデータなので、ストレージへの影響は限定的です。10,000ユーザー × 部署名(約10バイト) = 約100KB程度の増加です。

2. 更新の複雑さ

部署名が変更された場合、関連するすべてのレコードを更新する必要があります。

対策:

  • 部署名の変更頻度が低い場合は問題になりにくい
  • 更新処理をバッチで実行する
  • 正規化テーブルをマスターとして保持し、非正規化テーブルは読み取り専用として扱う

3. データ整合性のリスク

正規化テーブルと非正規化テーブルでデータが不整合になる可能性があります。

対策:

  • 非正規化テーブルは正規化テーブルから定期的に再生成する
  • 読み取り専用のビューやマテリアライズドビューとして扱う
  • アプリケーション層で整合性チェックを行う

まとめ

この要件(部署名でフィルタリングしたユーザー一覧の表示)では、非正規化テーブルの方が約4.4倍速いという結果になりました。要件に応じて、正規化と非正規化を適切に使い分けることが、実践的なデータベース設計では大事になりそうです。

Discussion