「汎用テーブル」という名の悪夢
あなたのTシャツショップが繁盛しているとします。最初は、color(色)とsize(サイズ)という単純なカラムを持つスリムなproductsテーブルで十分でした。しかし、クライアントが高性能ノートPCの販売を決めると、スキーマは崩壊します。突然colorは無関係になり、RAM、CPU、SSD_Capacityといったカラムを慌てて追加することになります。
カタログにオーガニックハチミツや電動ドリルが追加される頃には、「ゴッドテーブル(巨大すぎるテーブル)」が完成してしまいます。これは150ものカラムを持つ巨大な怪物で、どの行を見てもセルの95%がNULLで埋まっています。この怪物をクエリするのは、パフォーマンス上の惨劇です。データベースエンジンが肥大化した行サイズに苦労するため、単純なSELECT *でさえ、アプリケーションの動作を鈍らせます。
リレーショナルスキーマがこれほど硬直的に感じられる理由
リレーショナルデータベースは、予測可能性に基づいて真価を発揮します。テーブル内のすべての行が同じ設計図に従うことを期待しているのです。しかし、Eコマースのデータは本質的に乱雑で、半構造化されています。一般的な「親」商品には名前やブランドがありますが、「ミッドナイトブルー、16GB RAMのノートPC」のような特定の「バリエーション」には、固有の属性セットが必要になります。
こうした動的な詳細を静的な SQL スキーマに無理やり押し込むと、通常、次の3つの大きな問題が発生します。
- メンテナンスによるダウンタイム: 500万行あるテーブルに対して
ALTER TABLEを実行すると、データベースが数分間ロックされ、ショップがオフラインになる可能性があります。 - ストレージの無駄: 在庫のわずか2%にしか適用されない「生地の重さ」のようなカラムにインデックスを貼ることは、メモリとディスクI/Oの莫大な無駄遣いです。
- SQLの曲芸: 「16GBのRAMを搭載したノートPC、およびサイズXLの青いTシャツ」を検索するクエリを書いてみてください。結果として得られるコードは、サブクエリが複雑に絡み合ったスパゲッティ状態になることがよくあります。
3つの主要な解決策の比較
私は長年、MySQLやPostgreSQLでカタログのスケーリングに携わってきました。多くのチームは、通常、以下の3つのアーキテクチャパターンのいずれかに惹かれますが、それぞれに特有の課題があります。
1. EAV(エンティティ・アトリビュート・バリュー)モデル
これはMagentoなどのプラットフォームで使用されている古典的な手法です。カラムを横に増やすのではなく、データを縦に伸ばしていきます。
-- 商品本体
CREATE TABLE products (id INT, name TEXT);
-- 属性の定義(例:「RAM」)
CREATE TABLE attributes (id INT, name TEXT);
-- 具体的な値(例:「32GB」)
CREATE TABLE product_attribute_values (
product_id INT,
attribute_id INT,
value TEXT
);
メリット: 無限の柔軟性が得られます。スキーマをいじることなく、数秒で新しいドローンカテゴリーに「バッテリー駆動時間」属性を追加できます。
デメリット: 大規模になるとパフォーマンスが急落します。10個の属性を持つ商品を表示するには、10回の結合(JOIN)を実行しなければなりません。10万点の商品を扱うサイトでは、ページの読み込み時間が2秒を優に超えてしまうでしょう。
2. JSONBドキュメントアプローチ
PostgreSQLを使用している場合、JSONBは多くの場合、最も賢明な妥協案となります。SKUやPrice(価格)のようなコアフィールドは標準のカラムに保持し、可変の属性はバイナリJSON型の中に格納します。
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT,
base_price DECIMAL,
attributes JSONB -- 「{"RAM": "16GB", "Screen": "15-inch"}」のように格納
);
メリット: 開発スピードが非常に速いです。また、PostgreSQLはGINインデックスをサポートしているため、JSON内部の検索も通常のカラムとほぼ同等の速さで行えます。
デメリット: 厳格なデータ整合性が失われます。開発者が誤って価格を数値ではなく「$19.99」のような文字列として保存してしまうのを防ぐ手立てがありません。
3. クラス継承テーブル(ハイブリッド型)
これは、ベースとなるproductsテーブルを、electronics_details(家電詳細)やapparel_details(衣類詳細)といったカテゴリー別のテーブルにリンクさせる方法です。データをクリーンに保ち、厳格な型定義が可能です。しかし、カテゴリーが5つから500へと増えると、管理コストは悪夢に変わります。
黄金律:スケーラブルな5テーブルスキーマ
在庫の正確さが妥協できない大規模なマーケットプレイスには、正規化された構造をお勧めします。これは、商品の「概念」と、顧客がカートに入れる実際の「アイテム」を分離するものです。厳格さと、フィルタリングに必要な柔軟性のバランスを保つことができます。
ステップ1:親商品
productsテーブルには、バリエーション間で変わることのないデータを保存します。
CREATE TABLE products (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
brand_id INT,
category_id INT
);
ステップ2:購入可能なバリエーション
product_variantsテーブルは「子」となります。ここが物理的な在庫が存在する場所です。あるシャツに3つの色と3つのサイズがある場合、ここには9つの行が作成されます。
CREATE TABLE product_variants (
id SERIAL PRIMARY KEY,
product_id INT REFERENCES products(id),
sku VARCHAR(100) UNIQUE NOT NULL,
price DECIMAL(12, 2) NOT NULL,
stock_quantity INT DEFAULT 0
);
ステップ3:柔軟なマッピング
バリエーションをユニークにする要素を定義するために、多対多のリレーションシップを使用します。これにより、「カラム地獄」を完全に回避できます。
CREATE TABLE attributes (id SERIAL PRIMARY KEY, name VARCHAR(50)); -- 「色」
CREATE TABLE attribute_values (id SERIAL PRIMARY KEY, attribute_id INT, value VARCHAR(50)); -- 「ネイビー」
CREATE TABLE variant_attribute_map (
variant_id INT REFERENCES product_variants(id),
attribute_value_id INT REFERENCES attribute_values(id),
PRIMARY KEY (variant_id, attribute_value_id)
);
なぜこれが他より優れているのか
この構造は、速度と正確性を重視して構築されています。色の名前を「ネイビー」から「ミッドナイトブルー」に変更する必要がある場合、attribute_valuesテーブルの1行を変更するだけで済みます。関連するすべての商品が即座に更新されます。データの重複も、乱雑な文字列も、大規模なテーブルロックも発生しません。
エキスパートのヒント:ページロードのたびに結合しない
正規化されたデータは保存には適していますが、フロントエンドにとっては重くなることがあります。サイトの軽快さを保つために、2段構えの戦略を使いましょう。
- 戦略的なインデックス作成: 外部キーとSKUカラムには必ずインデックスを貼ります。
- リードモデルのキャッシング: 商品が更新されたときに、そのバリエーションのJSON「スナップショット」を事前に計算し、Redisに保存しておきます。商品詳細ページでは、ユーザーがリンクをクリックするたびに5つの複雑なSQL結合を実行するのではなく、この単一のキーを取得するようにします。
結論
トレードオフを賢く選択してください。わずか100点程度のアイテムを扱う小規模なMVPを構築しているのであれば、JSONBアプローチが最短で市場に投入できる方法でしょう。しかし、次の主要なマーケットプレイスを構築しようとしているのであれば、正規化された5テーブルモデルから始めてください。100万人の顧客を抱えた後に、破損して不整合が生じたJSONの山を修正するよりも、クリーンなデータベースをキャッシュする方がはるかに簡単です。

