【DB設計】正規化だけが全てじゃないと学んだ話
はじめに
データベース設計において、正規化は重要な原則の一つです。正規化により、データの整合性を保ち、更新時の不整合を防ぐことができます。
しかし、正規化は常に最適な選択とは限りません。読み取りパフォーマンスが重要な場面では、非正規化を検討することで、大幅なパフォーマンス向上が期待できる場合があります。
この記事では、実際の要件を想定して、正規化と非正規化のパフォーマンスを比較し、それぞれのメリット・デメリットを検証してみようと思います!!
想定する要件
ある社内管理システムで、以下の機能を実装する必要があるとします:
- ユーザー一覧画面: 部署名でフィルタリングして、該当するユーザーをページネーション付きで表示する
-
要件:
- 部署名で検索・フィルタリングができる
- 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テーブル | - |
| 要件の目標達成 | ✅ 達成 | ✅ 達成 | - |
なぜ非正規化版が速いのか
-
JOIN処理が不要: 正規化版は3つのテーブルをJOINする必要がありますが、非正規化版は単一テーブルから取得できます。JOIN処理はCPUとメモリを消費するため、これを削減できるのは大きなメリットです。
-
インデックススキャンの回数が少ない: 正規化版は複数のインデックススキャンが必要ですが、非正規化版は1回のみです。これにより、I/O処理が削減されます。
-
データの局所性: 必要なデータが1つのテーブルに集約されているため、メモリアクセスが効率的です。キャッシュのヒット率も向上します。
-
クエリがシンプルで読みやすい: 非正規化テーブルでは、必要なデータが1つのテーブルにまとまっているため、JOINや中間テーブルを意識せずにクエリを書くことができるため、実装時の負担も減るかと思います。
パフォーマンス面だけでなく、クエリを書く・読むコストが下がるという点も、実務では無視できない非正規化の利点だと感じました。
非正規化のデメリットと対策
ただし、非正規化には以下のデメリットがあります:
1. データの重複
department_nameが各ユーザーレコードに重複して保存されるため、ストレージ容量が増加します。
対策: 部署名は比較的小さなデータなので、ストレージへの影響は限定的です。10,000ユーザー × 部署名(約10バイト) = 約100KB程度の増加です。
2. 更新の複雑さ
部署名が変更された場合、関連するすべてのレコードを更新する必要があります。
対策:
- 部署名の変更頻度が低い場合は問題になりにくい
- 更新処理をバッチで実行する
- 正規化テーブルをマスターとして保持し、非正規化テーブルは読み取り専用として扱う
3. データ整合性のリスク
正規化テーブルと非正規化テーブルでデータが不整合になる可能性があります。
対策:
- 非正規化テーブルは正規化テーブルから定期的に再生成する
- 読み取り専用のビューやマテリアライズドビューとして扱う
- アプリケーション層で整合性チェックを行う
まとめ
この要件(部署名でフィルタリングしたユーザー一覧の表示)では、非正規化テーブルの方が約4.4倍速いという結果になりました。要件に応じて、正規化と非正規化を適切に使い分けることが、実践的なデータベース設計では大事になりそうです。
Discussion