mssql-django のパフォーマンス チューニング

この記事では、SQL Serverで mssql-django バックエンドを使用する場合の Django アプリケーションのパフォーマンスの最適化に関するガイダンスを提供します。

接続の最適化

プール、永続化、タイムアウトの設定を調整することで、接続のオーバーヘッドを軽減します。

接続プールを有効にする

接続プールは既定で有効になっています。 settings.pyで無効になっていないことを確認します。

# Keep this True (or omit it entirely) for best connection performance
DATABASE_CONNECTION_POOLING = True

CONN_MAX_AGEを使用する

CONN_MAX_AGEを設定して、要求間でデータベース接続を開いたままにし、要求ごとに新しい接続を確立するオーバーヘッドを回避します。

DATABASES = {
    "default": {
        "ENGINE": "mssql",
        "NAME": "<your-database>",
        "USER": "<your-username>",
        "PASSWORD": "<your-password>",
        "HOST": "<your-server>",
        "PORT": "1433",
        "CONN_MAX_AGE": 600,  # Keep connections open for 10 minutes
        "OPTIONS": {
            "driver": "ODBC Driver 18 for SQL Server",
        },
    },
}

クエリ タイムアウトの設定

実行時間の長いクエリでリソースが無期限に消費されないようにします。

DATABASES = {
    "default": {
        "ENGINE": "mssql",
        "NAME": "<your-database>",
        "USER": "<your-username>",
        "PASSWORD": "<your-password>",
        "HOST": "<your-server>",
        "PORT": "1433",
        "OPTIONS": {
            "driver": "ODBC Driver 18 for SQL Server",
            "query_timeout": 30,
        },
    },
}

クエリ最適化

これらの ORM 手法を使用して、データベースのラウンド トリップとクエリ数を減らします。

N+1 クエリ パターンを回避する

外部キー リレーションシップ (単一の JOIN クエリ) に select_related を使用し、多対多または逆のリレーションシップ (IN 句を使用して個別のクエリ) に prefetch_related します。

# Bad: N+1 queries
orders = Order.objects.all()
for order in orders:
    print(order.customer.name)  # Each access triggers a query

# Good: Single JOIN query
orders = Order.objects.select_related("customer").all()
for order in orders:
    print(order.customer.name)  # No additional queries

# Good: Two queries instead of N+1
orders = Order.objects.prefetch_related("items").all()
for order in orders:
    for item in order.items.all():  # Uses prefetched data
        print(item.name)

only() と defer() を使用する

すべてのフィールドが不要な場合に取得される列を制限します。

# Retrieve only specific fields
products = Product.objects.only("name", "price").all()

# Defer loading of large fields
products = Product.objects.defer("description", "metadata").all()

values() と values_list() を使用する

モデル インスタンスが不要な場合は、 values() または values_list() を使用して、より軽いクエリを実行します。

# Returns dictionaries instead of model instances
prices = Product.objects.values("name", "price")

# Returns tuples
names = Product.objects.values_list("name", flat=True)

2,100 個のパラメーター制限内で作業する

SQL Serverでは、各クエリが 2,100 個のパラメーターに制限されます。 Django はパラメーター化されたクエリを生成するため、大きな IN 句または一括値リストを生成する操作がこの制限に達する可能性があります。

大規模な IN 句の自動最適化:

filter(field__in=list)呼び出しに 2,048 を超える値がある場合、mssql-django バックエンドは自動的に値を (1,000 のバッチで) 一時テーブルに挿入し、クエリをWHERE field IN (SELECT params FROM #Temp_params)として書き換えます。 この最適化により、コードを変更せずにパラメーターの制限を回避できます。 __inによって生成されたものも含め、すべてのprefetch_related()参照に適用されます。 2,048 しきい値は、バックエンドのmax_in_list_size()によって設定され、SQL Serverの 2,100 パラメーター制限を安全に維持します。

この書き換えにはコストがかかります。 #Temp_params を作成して設定すると、ラウンド トリップと tempdb アクティビティが追加されます。 しきい値に近いリストについては、ワークロードの両方のアプローチをベンチマークします。

手動による介入がまだ必要な場合:

一時テーブルの自動最適化では __in 参照が処理されますが、各フィールド値が個別のパラメーターであるため、これらの操作は引き続き 2,100 パラメーターの制限に達する可能性があります。

  • bulk_create()または、多くのオブジェクトと多くのフィールドを持つbulk_update()
  • 多数のチェーン条件を持つ複雑な Q()
  • #Temp_paramsの設定に必要なラウンド トリップを回避する必要がある場合 (たとえば、リストが小さく、通常のIN (...)が高速になる場合)

解決策:

  1. batch_sizeを使用する各バッチを制限の下に保つ一括操作の場合:

    # Backend cap with 10 fields: min(1000, 2050 // 10 // 2) = 102 rows per batch
    # The backend applies the conservative // 2 divisor for both bulk_create and bulk_update.
    Product.objects.bulk_create(products, batch_size=100)
    
  2. 自動 temp-table メカニズムを回避したい場合は、大きな INクエリを分割してください。

    from itertools import islice
    
    def chunked_filter(queryset, field, values, chunk_size=2000):
        """Filter a queryset in chunks to stay within the 2,100 parameter limit."""
        results = []
        it = iter(values)
        while chunk := list(islice(it, chunk_size)):
            results.extend(queryset.filter(**{f"{field}__in": chunk}))
        return results
    
    # Returns a list of model instances, not a QuerySet
    products = chunked_filter(Product.objects, "pk", large_id_list)
    
  3. ID リストを具体化する代わりにサブクエリを使用します。

    # Instead of: Order.objects.filter(product_id__in=list(Product.objects.values_list("id", flat=True)))
    # Use a subquery (Django generates a single SQL statement with no parameter explosion)
    Order.objects.filter(product__in=Product.objects.filter(active=True))
    
  4. フィルター処理されたクエリセットで Prefetch を使用してprefetch_related()に渡される ID の数を制限します。

    from django.db.models import Prefetch
    
    orders = Order.objects.prefetch_related(
        Prefetch("items", queryset=OrderItem.objects.select_related("product"))
    )[:500]  # Limit parent queryset size
    

一括操作

一括操作を使用して、データベースラウンドトリップの数を減らします。

from decimal import Decimal

from myapp.models import Product

# Bulk create
new_products = [Product(name=f"Item {i}", price=Decimal("1.99") * i) for i in range(1000)]
Product.objects.bulk_create(new_products, batch_size=500)

# Bulk update: refetch so each instance has a primary key
products = list(Product.objects.filter(name__startswith="Item "))
for product in products:
    product.price *= Decimal("1.10")
Product.objects.bulk_update(products, ["price"], batch_size=500)

Important

bulk_createまたはbulk_updateを使用する場合は、オブジェクトあたりのフィールド数に基づいてbatch_sizeを設定します。 バックエンドのbulk_batch_size()は、各バッチを 1,000 行に制限し、保守的な2050 / (fields * 2) パラメーター制限をbulk_createbulk_updateに適用します。 追加の / 2 は、 bulk_update が使用するフィールドごとに 2 つのパラメーター (CASE 一致用に 1 つ、値用に 1 つ) 用に予約され、同じ除数が bulk_create に適用されるため、どちらの操作でも同じコード パスが安全です。

batch_sizeを省略すると、バックエンドは安全な値を自動的に計算します。 また、batch_size を指定することもでき、バックエンド側で安全な上限までさらに制限されます。

return_rows_bulk_insertパラメーターとdefault パラメーターの詳細については、「mssql-django を使用した一括操作」を参照してください。

インデックス戦略

Django は、ForeignKeyOneToOneField、および db_index=True を持つフィールドに対してインデックスを自動的に作成します。 インデックスを追加する場合は、 Meta.indexesを使用します。

from django.db import models

class Product(models.Model):
    name = models.CharField(max_length=100, db_index=True)
    category = models.CharField(max_length=50)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["category", "price"]),
            models.Index(fields=["-created_at"]),
        ]

SQL Server固有のインデックス (INCLUDE列を含むインデックスなど) の場合は、移行で生 SQL を使用します。

from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [("myapp", "0001_initial")]
    operations = [
        migrations.RunSQL(
            sql="CREATE INDEX IX_product_category ON myapp_product (category) INCLUDE (name, price);",
            reverse_sql="DROP INDEX IX_product_category ON myapp_product;",
        ),
    ]

mssql-django バックエンドは、カバリングインデックス(mssql/features.pysupports_covering_indexes = True)をサポートしています。 mssql-django (3.2 以降) でサポートされているすべての Django バージョンでは、生の SQL ではなく、includemodels.Index パラメーターを使用できます。

class Product(models.Model):
    name = models.CharField(max_length=100)
    category = models.CharField(max_length=50)
    price = models.DecimalField(max_digits=10, decimal_places=2)

    class Meta:
        indexes = [
            models.Index(fields=["category"], include=["name", "price"], name="ix_product_cat_cover"),
        ]

ファイルグループの配置

mssql-djangoバックエンドは、Django のdb_tablespaceをSQL ServerのON filegroup句にマップします。 これを使用して、特定のファイル グループにテーブルまたはインデックスを配置します。

class LargeAuditLog(models.Model):
    timestamp = models.DateTimeField(auto_now_add=True)
    message = models.TextField()

    class Meta:
        db_tablespace = "ARCHIVE_FG"

これにより、 CREATE TABLE ... ON [ARCHIVE_FG]が生成されます。

Important

migrateを実行する前に、SQL Server データベースにファイル グループが既に存在している必要があります。 ALTER DATABASE [<your-database>] ADD FILEGROUP [ARCHIVE_FG]で作成し、少なくとも 1 つのファイルを追加します。

ウィンドウ関数

バックエンドは、SQL Serverのウィンドウ関数 (supports_over_clause = True) をサポートします。 Django の Window 式を使用して、ランク付け、合計の実行、パーティション分割された計算を行います。

from django.db.models import F, Window
from django.db.models.functions import Rank, RowNumber

# Rank products by price within each category
products = Product.objects.annotate(
    price_rank=Window(
        expression=Rank(),
        partition_by=F("category"),
        order_by=F("price").desc(),
    )
)

# Row numbers across the full result set
products = Product.objects.annotate(
    row_num=Window(
        expression=RowNumber(),
        order_by=F("created_at").asc(),
    )
)

Note

SQL ServerはNTH_VALUE()をサポートしていません。 代わりに、 FIRST_VALUELAST_VALUE、またはサブクエリの回避策を使用してください。 mssql-django の制限事項とサポートされていない機能を参照してください。

クエリ パフォーマンスを監視する

Django の組み込みのクエリ ログを使用して、開発中に低速なクエリを特定します。

LOGGING = {
    "version": 1,
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
        },
    },
    "loggers": {
        "django.db.backends": {
            "level": "DEBUG",
            "handlers": ["console"],
        },
    },
}

ステージング ワークロードと運用ワークロードの場合は、SQL Serverパフォーマンス ツールを使用して、Django によって生成される SQL を分析します。

  1. DMV に直接クエリを実行する前に、組み込みのパフォーマンス レポートから始めます。

    通常、これらのレポートは、アドホック DMV クエリよりも間違いの余地が少なく、コストの高いクエリ、待機、ブロック、リソース負荷を検出する最速の方法です。

  2. クエリ ストアを使用して、リソースを消費する上位のクエリと、最近後退したクエリを特定します。

  3. ボトルネックが CPU、I/O、メモリ、または待機のいずれであるかを判断するには、SQL Server Management Studioの上位リソース消費クエリ後退クエリ、クエリ待機統計ビューを使用します。 ガイダンスについては、「クエリ ストアを使用してワークロードを監視するためのベスト プラクティス」を参照してください。

  4. 低速ステートメントの 実際の実行プラン を開いて、スキャン、コストの高いキー参照、不正確な行の見積もり、不足しているインデックスを検索します。

  5. デプロイまたはスキーマの変更後にクエリの速度が低下した場合は、アプリケーション コードを変更する前に、クエリ ストアでプランを比較します。 DBA は、基になるインデックス、統計、またはクエリシェイプの問題を修正するときに、既知の適切なプランを一時的に強制できます。

クエリ ストア に高い CPU 時間ではなく待機時間が示される場合は、ボトルネックの特定 を使用して、CPU、メモリ、ディスク I/O、接続負荷、ブロックの問題を切り分けます。