mssql-django を使用してロジックと接続の回復性を再試行する

SQL ServerとAzure SQLへの接続は、コードとは関係のない理由で一時的に失敗する可能性があります。

  • Always On 可用性グループはフェールオーバーします。
  • ネットワークは、接続のセットアップ中にパケットを破棄します。
  • Resource Governor はデータベースをスロットルします。
  • Azure SQL レプリカは、スケールまたはアップグレード中にリサイクルされます。

これらのエラーのほとんどは、数秒以内にクリアされます。 この記事では、 mssql-django バックエンドを使用する Django アプリケーションで一時的なエラーを再試行する方法と、アイドル接続の切断から自動的に復旧するように Django と ODBC ドライバーを構成する方法について説明します。

一時的なエラー

一時的なエラーは、単独で解決される一時的なエラーです。 通常、短い遅延の後に操作を再試行すると成功します。

次のエラーは、接続の確立中またはサーバーへの要求の送信中に発生した一時的なエラーです。 短い境界付きバックオフで再試行します。 再試行回数を超えてエラーが続く場合は、通常、構成の問題 (間違ったサーバー、アクセス許可の不足、クォータの不足) が発生しても、再試行は修正されません。

エラー Message Troubleshooting
64 A connection was successfully established with the server, but then an error occurred during the login process. (provider: TCP Provider, error: 0 - The specified network name is no longer available.) TCP 接続がハンドシェイクの途中で切断されます。 認証情報エラーではありません。 それでも解決しない場合は、クライアント側のネットワークが不安定であるか、または半分確立された接続を切断する中間デバイスを確認します。
233 The client was unable to establish a connection because of an error during connection initialization process before login. ログイン前トランスポートまたは TLS エラー。 サーバーは通常、接続を受け入れることができない場合 (リソースの枯渇、最大接続に達した場合、またはサポートされていないクライアント) にそれを返します。 認証情報エラーではありません。 サーバーの正常性を確認し、クライアント ログインのタイムアウト、TLS 設定、およびクライアント/サーバーの TLS バージョンの互換性を確認します。
4060 Cannot open database "%.*ls" requested by the login. The login failed. ログインは認証されますが、要求されたデータベースを開くことはありません。 一時的な原因には、データベースの移行中 (フェールオーバー、復元、スケーリング) や自動一時停止が含まれます。 永続的な原因 (データベースが存在せず、ログインにアクセスできない) は再試行によって修正されません。データベース名、ログイン マッピング、およびデータベースの状態を確認します。
4221 Login to read-secondary failed due to long wait on 'HADR_DATABASE_WAIT_FOR_TRANSITION_TO_VERSIONING'. レプリカがリサイクルされたときに実行中だったトランザクションに対して行バージョンが見つからないため、レプリカはログインに使用できません。 プライマリでアクティブなトランザクションをロールバックまたはコミットして、問題を解決します。 プライマリで長い書き込みトランザクションを回避することで軽減します。
10053 A transport-level error has occurred when sending the request to the server. (provider: TCP Provider, error: 0 - An established connection was aborted by the software in your host machine.) ローカル側が接続を中止します。 クライアント側のネットワーク正常性と、ローカル ファイアウォールまたは VPN クライアントを確認します。
10054 A transport-level error has occurred when sending the request to the server. (provider: TCP Provider, error: 0 - An existing connection was forcibly closed by the remote host.) リモート側は TCP リセットを送信します。 一般的な原因: ピア プロセスがクラッシュした、ファイアウォールがリセットを挿入した、またはAzure SQLゲートウェイがアイドル状態の接続を閉じた。 アイドル リセット パターンの場合は、クライアントで TCP キープアライブを有効にするか、接続プールのアイドル タイムアウトを短縮します。
10928 Resource ID: %d. The %s limit for the database is %d and has been reached. See 'http://go.microsoft.com/fwlink/?LinkId=267637' for assistance. データベースがAzure SQLリソース ガバナンスの制限を超えています。 リソース ID 1 はワーカーの制限を示します。リソース ID 2 は、セッションの制限を示します。 メッセージから制限の種類を特定し、コンカレンシーを減らすか、データベースをスケールアップするか、リソースを保持する実行時間の長い操作を短縮します。
10929 Resource ID: %d. The %s minimum guarantee is %d, maximum limit is %d, and the current usage for the database is %d. However, the server is currently too busy to support requests greater than %d for this database. データベースは最小保証を超過しており、基盤となるサーバーでスロットル制御が行われています。 再試行は通常、近隣の負荷が低下したときに成功します。 継続的な発生は、より高いサービス レベルまたはノイズの少ない環境が必要であることを示します。
40020401434016640540 フェールオーバー中にエラー 40197 の Error code %d スロットで報告されました。 一部のパスが最上位のエラー番号として表示される 40197 フェールオーバー メッセージに埋め込まれたサブコード。 40197 と同じように扱います。
40197 The service has encountered an error processing your request. Please try again. Error code %d. Azure SQLでのソフトウェアのアップグレード、ハードウェア障害、またはその他のフェールオーバー イベント。 再接続すると、正常なレプリカにルーティングされます。 埋め込みエラー コードは、フェールオーバーの種類を識別します。 エラーが解決しない場合は、セッション トレース ID をキャプチャし、サポートにお問い合わせください。
40501 The service is currently busy. Retry the request after 10 seconds. Incident ID: %ls. Code: %d. Azure SQL Engine のスロットリング。 推奨されるバックオフの下限は 10 秒です。 継続的なスロットリングは、ワークロードがデータベースのリソース割り当てを超過していることを示しています。サービス レベル階層をスケールアップするか、同時実行数を減らします。
40613 Database '%.*ls' on server '%.*ls' is not currently available. Please retry the connection later. If the problem persists, contact customer support, and provide them with the session tracing ID of '%.*ls'. データベースは使用できません。通常はフェールオーバー中、またはスケール操作中に短時間です。 バックオフ時に再試行します。数分後に保持される場合は、セッション トレース ID をキャプチャし、サポート ケースを開きます。
42108 Can not connect to the SQL pool since it is paused. Please resume the SQL pool and try again. 専用 SQL プール (Synapse) は一時停止状態です。 再試行は、プールが再開された後にのみ成功します。 プールを明示的に再開するか、プールの再開後にワークロードを実行するようにスケジュールします。
42109 The SQL pool is warming up. Please try again. 専用 SQL プールが再開中です。 プールがオンラインになるまでバックオフを再試行します。ウォームアップには通常数分かかります。
49918 Cannot process request. Not enough resources to process request. The service is currently busy. Please retry the request later. サーバーは現在、要求を満たすのに十分なリソースを割り当てられません。 バックオフ時に再試行してください。 エラーが解決しない場合は、データベースまたはエラスティック プールをスケールアップします。
49919 Cannot process create or update request. Too many create or update operations in progress for subscription "%ld". 管理操作に対するサブスクリプション レベルのコンカレンシー制限。 並列作成/更新呼び出しを減らすか、それらをずらします。
49920 Cannot process request. Too many operations in progress for subscription "%ld". 実行中の操作に対するサブスクリプション レベルでの同時実行制限。 並列処理を減らすか、進行中の操作が完了するまで待機してください。

ステートメント レベルのエラーは、接続が確立された後に発生し、エラーによってセッションが使用可能な状態になるため、この一覧には含まれません。 再試行可能な最も一般的なステートメント エラーは、1205 (デッドロックの対象) と 1222 (ロック要求タイムアウト) です。 単一の失敗したステートメントではなく、トランザクション全体を再試行してください。

エラー メッセージ テキストはAzure SQL の一時的な接続エラーから取得されます。 個々のドライバーは、それぞれ独自の組み込み再試行リストを備えています。このカタログでは、SQL Server、Azure SQL Database、Azure SQL Managed Instance、Microsoft Fabric の SQL データベース、および Azure Synapse Analytics の専用 SQL プールにおいて、どのエラーが再試行の対象となるかについて説明します。

ODBC ドライバーのアイドル接続の回復性

Microsoft ODBC Driver for SQL Server は、接続文字列キーワード ConnectRetryCount および ConnectRetryInterval により、アイドル接続の回復機能を標準で備えています。 これらの設定は、アプリケーション コードが関与する前に、ドライバー レベルで切断されたアイドル接続を処理します。

extra_paramsでアイドル状態の接続の回復性を有効にします。

DATABASES = {
    "default": {
        "ENGINE": "mssql",
        "NAME": "<your-database>",
        "HOST": "<your-server>.database.windows.net",
        "PORT": "1433",
        "OPTIONS": {
            "driver": "ODBC Driver 18 for SQL Server",
            "extra_params": "ConnectRetryCount=3;ConnectRetryInterval=10",
        },
    },
}
キーワード デフォルト 説明
ConnectRetryCount 1 アイドル状態の接続に対する自動再接続の試行回数。
ConnectRetryInterval 10 再接続の試行間隔 (秒)。

Note

アイドル接続の回復性は、アイドル中に削除された接続を再接続します。 失敗したクエリを再試行したり、アクティブなトランザクション中に発生したエラーから復旧したりすることはありません。 このようなシナリオでは、アプリケーション レベルの再試行ロジックを使用します。

再試行のための Django データベース ミドルウェア

一時的なエラーをキャッチし、データベース操作を再試行する Django ミドルウェアを作成します。 この方法は、ビュー レベルの要求処理に適しています。

# myproject/middleware.py
import random
import re
import time
import logging
from django.db import OperationalError, connection

logger = logging.getLogger(__name__)

TRANSIENT_ERROR_CODES = {
    "64", "233", "4221",
    "10053", "10054", "10928", "10929",
    "40197", "40501", "40613",
    "49918", "49919", "49920",
    # Include "4060" only if targeting Azure SQL with geo-replication failover.
    # It is usually a permanent error (wrong database name or missing permissions).
}

# Microsoft ODBC driver formats native error codes as "(<number>)" in the
# message. Extracting parenthesized codes avoids false positives that a plain
# substring match would produce for short codes like "64".
_CODE_RE = re.compile(r"\((\d+)\)")


def is_transient(error):
    codes_in_message = set(_CODE_RE.findall(str(error)))
    return bool(codes_in_message & TRANSIENT_ERROR_CODES)


class DatabaseRetryMiddleware:
    """Retry database operations on transient errors."""

    def __init__(self, get_response):
        self.get_response = get_response
        self.max_retries = 3
        self.base_delay = 1   # seconds; doubled each attempt
        self.max_delay = 30   # cap on a single sleep, regardless of attempt

    def __call__(self, request):
        for attempt in range(self.max_retries + 1):
            try:
                return self.get_response(request)
            except OperationalError as e:
                if attempt < self.max_retries and is_transient(e):
                    # Exponential backoff with full jitter, capped at max_delay.
                    # Jitter spreads simultaneous retries so many clients
                    # don't hammer the server in lock-step during an outage.
                    capped = min(self.max_delay, self.base_delay * (2 ** attempt))
                    delay = random.uniform(0, capped)
                    logger.warning(
                        "Transient DB error (attempt %d/%d), retrying in %.2fs: %s",
                        attempt + 1, self.max_retries, delay, e
                    )
                    connection.close()
                    time.sleep(delay)
                    continue
                raise

ミドルウェアを settings.pyに登録します。

MIDDLEWARE = [
    "myproject.middleware.DatabaseRetryMiddleware",
    "django.middleware.security.SecurityMiddleware",
    # ... other middleware
]

Important

データベースにアクセスする他のミドルウェアの前に DatabaseRetryMiddleware を配置して、要求パイプライン全体から一時的なエラーをキャッチして再試行できるようにします。

特定の操作のデコレーターを再試行する

細かい制御を行うには、個々の関数にデコレーターを使用します。

import random
import re
import time
import functools
import logging
from django.db import OperationalError, connection

logger = logging.getLogger(__name__)

TRANSIENT_ERROR_CODES = {
    "64", "233", "4221",
    "10053", "10054", "10928", "10929",
    "40197", "40501", "40613",
    "49918", "49919", "49920",
    # Include "4060" only if targeting Azure SQL with geo-replication failover.
}

_CODE_RE = re.compile(r"\((\d+)\)")


def is_transient(error):
    codes_in_message = set(_CODE_RE.findall(str(error)))
    return bool(codes_in_message & TRANSIENT_ERROR_CODES)


def retry_on_transient(max_retries=3, base_delay=1, max_delay=30):
    """Retry on transient database errors with exponential backoff and full jitter."""

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries + 1):
                try:
                    return func(*args, **kwargs)
                except OperationalError as e:
                    if attempt < max_retries and is_transient(e):
                        # Exponential cap doubled per attempt, then jittered
                        # within [0, cap] and limited by max_delay.
                        capped = min(max_delay, base_delay * (2 ** attempt))
                        delay = random.uniform(0, capped)
                        logger.warning(
                            "Transient error in %s (attempt %d/%d), retrying in %.2fs: %s",
                            func.__name__, attempt + 1, max_retries, delay, e
                        )
                        connection.close()
                        time.sleep(delay)
                        continue
                    raise
        return wrapper
    return decorator

デコレーターをデータベースの負荷の高い関数に適用します。

from myproject.retry import retry_on_transient

@retry_on_transient(max_retries=3, base_delay=2)
def process_order(order_id):
    """Process an order with automatic retry on transient failures."""
    order = Order.objects.select_for_update().get(id=order_id)
    order.status = "processing"
    order.save()
    return order

トランザクションを使用して再試行する

トランザクション内で一時的なエラーが発生すると、トランザクション全体がサーバーによってロールバックされます。 失敗したステートメントだけでなく、完全なトランザクションを再試行します。

from django.db import transaction

@retry_on_transient(max_retries=3)
def transfer_funds(from_account_id, to_account_id, amount):
    """Transfer funds between accounts with retry."""
    with transaction.atomic():
        from_account = Account.objects.select_for_update().get(id=from_account_id)
        to_account = Account.objects.select_for_update().get(id=to_account_id)

        from_account.balance -= amount
        to_account.balance += amount

        from_account.save()
        to_account.save()

注意事項

transaction.atomic()内で再試行しないでください。 再試行デコレーターは、再試行のたびに新しいトランザクションが開始されるように、atomic() ブロック全体を囲む必要があります。

ステートメントレベルのエラー

前のセクションのエラー一覧では、接続レベルのエラーについて説明します。 他の 2 つのエラーは、一般的にステートメント レベルで再試行されます。

  • 1205: セッションがデッドロックの被害者として選択されました。 トランザクションを再実行します。
  • 1222: ロック要求のタイムアウトを超えました。 トランザクションを再実行するか、既定値がアグレッシブすぎる場合はセッションの LOCK_TIMEOUT を増やします。

ConnectRetryCount は切断された接続を再試行するため、これらのステートメント レベルのエラーには適用されません。 再実行しても安全なトランザクションの"1205""1222"TRANSIENT_ERROR_CODESを追加して、同じデコレーター パターンで処理します。

CONN_MAX_AGE と古くなった接続

CONN_MAX_AGEが設定されている場合、Django は複数の要求でデータベース接続を再利用します。 サーバーが接続を閉じると、有効期間が長い接続が古くなる可能性があります (たとえば、Azure SQLスケール操作中やファイアウォールのタイムアウト中)。

CONN_MAX_AGEを設定して、再利用と制約のバランスを取る:

DATABASES = {
    "default": {
        "ENGINE": "mssql",
        "NAME": "<your-database>",
        "HOST": "<your-server>.database.windows.net",
        "OPTIONS": {
            "driver": "ODBC Driver 18 for SQL Server",
        },
        "CONN_MAX_AGE": 600,  # Close and reopen connections after 10 minutes
    },
}
  • CONN_MAX_AGE=0 (既定値): 各要求の最後に接続を閉じます。 最も安全だが最も遅い。
  • CONN_MAX_AGE=600: 接続を 10 分間再利用します。 ほとんどの Web アプリケーションのバランスが良い。
  • CONN_MAX_AGE=None: 接続を無期限に開いたままにします。 古い接続の再試行メカニズムでのみ使用します。

CONN_HEALTH_CHECKS (Django 4.1 以降)

Django 4.1 では、各要求の前に再利用された接続を検証する CONN_HEALTH_CHECKSが導入されました。 CONN_MAX_AGEと共に有効にして、古い接続を自動的に検出します。

DATABASES = {
    "default": {
        "ENGINE": "mssql",
        "NAME": "<your-database>",
        "HOST": "<your-server>.database.windows.net",
        "OPTIONS": {
            "driver": "ODBC Driver 18 for SQL Server",
        },
        "CONN_MAX_AGE": 600,
        "CONN_HEALTH_CHECKS": True,
    },
}

正常性チェックが有効になっている場合、Django は接続を再利用する前に軽量検証クエリを発行します。 接続が切断された場合、Django はエラーを発生させる代わりに新しい接続を透過的に開きます。

ベスト プラクティス

  • フルジッターを用いた指数バックオフを使用します。 各試行の上限を 2 倍にし、 [0, cap]内でランダムな量をスリープ状態にします。 ジッターを導入すると、リージョン障害の発生時に多数のクライアントが足並みをそろえて再試行するのを防げます。そうでなければ、短時間の障害が継続的な過負荷につながる可能性があります。 試行ごとのスリープ (たとえば、30 秒) を上限にして、合計復旧時間を制限したままにします。
  • 再試行の上限を設定します。 指数バックオフを使用した 3 回の再試行は、適切な既定値です。 通常、再試行回数が 5 回を超える場合は、一時的でない問題が示されます。
  • 再試行する前に接続を閉じます。 connection.close()呼び出して、Django が次の試行時に新しい接続を開きます。
  • 再試行するたびにログを記録します。 気付かれないまま成功する再試行は、パフォーマンス上の問題を隠してしまう可能性があります。 頻度を追跡できるように、 WARNING レベルでログに記録します。
  • 一時的でないエラーは再試行しないでください。 認証エラー、アクセス許可エラー、構文エラーは、再試行のメリットを得られません。
  • トランザクション全体を再試行します。 transaction.atomic()を再試行ロジック内でラップします。逆の方法ではありません。
  • CONN_HEALTH_CHECKSを有効にする(Django 4.1 以降) CONN_MAX_AGEを使用する Web アプリケーション用。