データベーススキーマ設計:「技術的負債」でシステムを破綻させないために

Database tutorial - IT technology blog
Database tutorial - IT technology blog

なぜ「まともな」スキーマ設計が重要なのか?

開発者になりたての頃、私はテーブルをいくつか作って適当にカラムを追加すれば、それで終わりだと思っていました。1,000万レコードに達するまでは、すべてが順調に見えました。しかし、その時、単純なクエリに突然15〜20秒もかかるようになり、原因不明 di データ不整合(アノマリー)が発生し始めました。それが、ずさんなスキーマ設計に対して支払わなければならない代償でした。

MySQL、PostgreSQL、MongoDBのどれを使っていようと、スキーマ設計の考え方はすべての問題の根幹にあります。優れたスキーマは、システムのレスポンスを数秒ではなく数ミリ秒に短縮します。サーバーリソースを節約し、データの整合性を常に保ちます。逆に、粗悪なスキーマは、データの欠陥を補うために、バックエンドのコードに肥大化した処理ロジックを強いることになります。

データベース設計は、実のところトレードオフの連続です。読み取り速度、書き込み速度、そして整合性のバランスを取らなければなりません。それでは、具体的な実践方法を深掘りしていきましょう。

1. 正規化(Normalization):データをクリーンかつコンパクトに保つ

正規化とは、データの重複を排除するためのデータベース組織化手法です。ほとんどのWebプロジェクトやアプリにおいて、最初の3つの正規形(1NF、2NF、3NF)をしっかりマスターしていれば、自信を持って開発に臨めます。

第1正規形(1NF):原子性

各データセルには、単一の値のみを保持する必要があります。phonesカラムに "090..., 091..." のような形式で電話番号のリストを保存してはいけません。後で検索やインデックス作成を容易にするために、これらは分割して保存しましょう。

第2正規形(2NF):完全関数従属

主キー以外のすべてのカラムは、その主キーに完全に依存していなければなりません。例えば、OrderDetailsテーブルにproduct_nameを含めないでください。製品名が変更された場合、数千行の古い注文データを更新する羽目になります。代わりに、product_idだけを保存するようにします。

第3正規形(3NF):推移的従属の排除

あるカラムが別のカラム(主キーではないもの)に依存している状態を排除します。例えば、Usersテーブルにprovince_idprovince_nameがあるとします。province_nameprovince_idに依存しているため、都道府県情報は別のテーブルに切り出すべきです。

-- 3NFを達成し、都道府県名の重複を避けるためにテーブルを分割する
CREATE TABLE Provinces (
    id INT PRIMARY KEY,
    name VARCHAR(100)
);

CREATE TABLE Users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50),
    province_id INT REFERENCES Provinces(id)
);

2. いつ「非正規化(Denormalization)」すべきか?

過度な正規化は、時にパフォーマンスの災いとなります。システムが7〜8つの異なるテーブルからデータを取得する必要がある場合、頻繁なJOINはサーバーのCPUに過負荷をかけます。ここで登場するのが非正規化(Denormalization)です。クエリを高速化するために、制御された範囲内でデータの重複を許容します。

私は通常、以下の3つのケースで非正規化を選択します:

  • ダッシュボード/レポート作成: ユーザーがページを読み込むたびにデータベースに数百万行のCOUNT(*)をさせる代わりに、Usersテーブルにtotal_ordersカラムを設けて値を保持しておきます。
  • 時点データ: OrderItemsテーブルには、購入時の価格をそのまま保存すべきです。後で製品価格が変動しても、顧客の古い請求書の数値は維持される必要があります。
  • JOINの深度を減らす: 表示用の名前を1つ取得するためだけに4つのテーブルをJOINしなければならない場合は、その名前をターゲットテーブルにコピーすることを検討してください。

PostgreSQLを使っているなら、JSONBという強力な武器があります。これにより、リレーショナルテーブル内に非構造化データを直接保存できます。色やサイズなど、頻繁に変更される製品属性を、数十のサブテーブルを作ることなく管理するのに最適です。

-- スキーマの柔軟性を保ちつつ速度を維持するためにJSONBを使用する
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255),
    specs JSONB -- 色、素材、重量などを保存
);

-- JSONB内のフィールドへの直接クエリは非常に高速
SELECT * FROM products WHERE specs->>'color' = 'black';

3. 回避すべき「死の落とし穴(アンチパターン)」

多くのコードレビューを通じて、開発者が陥りやすい3つの典型的なミスを見てきました:

ミス1:EAV(Entity-Attribute-Value)設計

keyvalueカラムを持つ「万能」なテーブルを作る手法です。一見柔軟に見えますが、クエリは地獄です。3つの条件でフィルタリングするだけで、同じテーブルを3回JOINしなければなりません。データが増えるにつれ、パフォーマンスは劇的に低下します。

ミス2:カンマ区切り文字列による配列の保存

tags = "1,2,3"のように保存するのは致命的なミスです。インデックスを貼ることができず、正確なJOINも不可能で、その文字列から特定のタグを削除する処理はロジック上の苦行となります。

ミス3:外部キー(Foreign Key)の軽視

データベースを「軽く」するために外部キーを外せというアドバイスを信じてはいけません。外部キーがないと、運用開始から数ヶ月でデータベースは「ゴミ」だらけになります。存在しないユーザーを参照している注文データなどは、いつシステムをクラッシュさせてもおかしくありません。

4. 実践的な計測と最適化

設計が終われば完了ではありません。本番環境で実際にどのように動作しているかを観察する必要があります。

EXPLAIN ANALYZEの活用: 重要なクエリに対してこのコマンドを実行する習慣をつけましょう。大きなテーブルでSeq Scanが発生している場合、それはインデックスが不足しているか、スキーマ設計が間違った方向に向かっているという警告です。

不要なインデックスの整理: インデックスは読み取りを高速化しますが、書き込み速度を低下させます。むやみにインデックスを作成しないでください。以下は、Postgresで「使われていない」インデックスを見つけるためによく使うクエリです:

SELECT s.relname AS table_name,
       indexrelname AS index_name,
       i.idx_scan
FROM pg_stat_user_indexes AS i
JOIN pg_stat_user_tables AS s ON s.relid = i.relid
WHERE i.idx_scan = 0; -- 一度も使用されていないインデックス

マイグレーションによる変更管理: データベースのスキーマを直接手動で変更してはいけません。Flyway、Liquibase、またはLaravelやDjangoのマイグレーション機能を使用してください。これにより、チームで変更履歴を管理し、問題発生時に素早くロールバックできるようになります。

まとめると、スキーマ設計はバランスの芸術です. 正規化に固執しすぎず、かといってデータベースをゴミ捨て場にするほど適当にもならないでください。これらの実践的な経験が、皆さんがプロジェクトの最初のテーブルを描く際の助けになれば幸いです。

Share: