Djangoアプリケーションが成長するにつれて、ProductやArticleのような中心的なモデルに、次々と新しいフィールドが追加されていくのはよくあることです。初めは小さなモデルだったものが、いつしか数十個のカラムを持つ「太ったモデル(Fat Model)」になってしまうことがあります。
この「太ったモデル」は、特にパフォーマンス面で深刻な問題を引き起こす可能性があります。Product.objects.all()のような単純なクエリを実行するたびに、データベースは**「今すぐには必要ない」**重たいデータ(例:JSONFieldに格納された巨大な仕様データや、TextFieldに保存された長い説明文)まで、すべてメモリに読み込もうとするからです。
今回は、このような問題を解決し、アプリケーションのパフォーマンスを劇的に改善するデータベース設計テクニック、**「テーブルの垂直分割」**をOneToOneFieldを使って実現する方法を解説します。
問題点:アクセス頻度が混在した「太った」モデル
あるECサイトのProduct(商品)モデルを例に考えてみましょう。
悪い例:すべての情報が一つのテーブルに混在
from django.db import models
class Product(models.Model):
# --- 頻繁にアクセスするカラム ---
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock_quantity = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
# --- 稀にしかアクセスしない(が、データサイズが重い)カラム ---
# 商品詳細ページでのみ使用
full_description = models.TextField(blank=True)
# 「仕様」タブでのみ使用
technical_specs_json = models.JSONField(default=dict, blank=True)
# 管理画面でのみ使用
internal_notes = models.TextField(blank=True)
この設計では、商品一覧ページ(nameとpriceだけが必要)を表示するためにProduct.objects.filter(is_active=True)を実行するだけで、データベースはfull_descriptionやtechnical_specs_jsonといった重たいカラムのデータまで読み込みます。テーブルの行サイズが大きくなるため、DBのキャッシュ効率が悪化し、クエリ速度が著しく低下します。
解決策:アクセス頻度に基づき、テーブルをOneToOneFieldで分割する
解決策は、アクセス頻度に基づいてテーブルを2つに分割することです。
Productテーブル:nameやpriceなど、頻繁にアクセスされる軽量なカラムだけを残します。ProductDetailsテーブル:full_descriptionなど、たまにしか必要ないが重いカラムを移動させます。
この2つのテーブルを、OneToOneField(一対一の関係)で結びつけます。
良い例:テーブルを「コア情報」と「詳細情報」に分割
from django.db import models
# 1. コア情報テーブル(軽量で、頻繁にアクセスされる)
class Product(models.Model):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock_quantity = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
def __str__(self):
return self.name
# 2. 詳細情報テーブル(重く、稀にしかアクセスされない)
class ProductDetails(models.Model):
# Productを主キー(primary_key)として設定し、一対一の関係を定義
product = models.OneToOneField(
Product,
on_delete=models.CASCADE,
primary_key=True,
)
full_description = models.TextField(blank=True)
technical_specs_json = models.JSONField(default=dict, blank=True)
internal_notes = models.TextField(blank=True)
def __str__(self):
return f"Details for {self.product.name}"
OneToOneFieldにprimary_key=Trueを設定しているのがポイントです。これにより、ProductDetailsテーブルは独自のidを持たず、ProductのIDを主キーとして共有します。これは、2つのテーブルが実質的に「一つのエンティティの分割である」ことをデータベースレベルで示す、非常にクリーンな設計です。
パフォーマンスとアクセスの変化
この設計変更により、クエリの挙動は以下のように変わります。
1. 商品一覧(コア情報のみ)の取得
# ProductDetailsテーブルには一切アクセスしない
products = Product.objects.filter(is_active=True)
for p in products:
print(p.name, p.price) # 非常に高速
メインのProductテーブルが軽量になったため、このクエリは劇的に高速化されます。
2. 商品詳細(詳細情報も必要)の取得
詳細情報が必要になった場合、Djangoの逆参照(product.productdetails)を使って明示的にアクセスします。
product = Product.objects.get(id=123)
print(product.name) # コア情報
# ここで初めてProductDetailsテーブルへの追加クエリが発行される
details = product.productdetails
print(details.full_description)
3. 最初から両方が必要な場合
もし最初から両方のテーブルのデータが必要だと分かっている場合は、select_relatedを使って効率的に1回のクエリでJOIN(結合)できます。
# 1回のDBクエリでProductとProductDetailsの両方を取得
product = Product.objects.select_related('productdetails').get(id=123)
# 追加クエリは発生しない
print(product.name)
print(product.productdetails.technical_specs_json)
まとめ
defer()やonly()を使ってクエリごとに読み込むカラムを調整する方法もありますが、それは対症療法に過ぎません。モデルのアクセスパターンを分析し、**アクセス頻度が著しく異なるカラム群(特に重いTextFieldやJSONField)**が存在する場合は、モデル自体をOneToOneFieldで分割する「垂直分割」を検討しましょう。
この設計は、アプリケーションの最も一般的なクエリ(一覧表示など)をデフォルトで高速化し、データベースの負荷を軽減する、非常に効果的なパフォーマンスチューニング戦略です。
