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 keepalive를 사용하도록 설정하거나 연결 풀 유휴 시간 제한을 줄입니다.
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. 데이터베이스가 최소 보장량을 초과했으며 기반 서버가 스로틀링하고 있습니다. 재시도는 일반적으로 인접 부하가 떨어질 때 성공합니다. 지속적인 발생은 더 높은 서비스 계층 또는 덜 시끄러운 환경이 필요하다는 것을 나타냅니다.
40020, 40143, , 40166, 40540 장애 조치 중 오류 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 엔진 스로틀링 권장되는 층은 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는 ConnectRetryCountConnectRetryInterval 연결 문자열 키워드를 통해 기본 제공되는 유휴 연결 복원 기능을 제공합니다. 이러한 설정은 애플리케이션 코드가 관련되기 전에 드라이버 수준에서 삭제된 유휴 연결을 처리합니다.

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 재연결 시도 간격(초)

메모

유휴 연결 복원력은 유휴 상태에서 삭제된 연결을 다시 연결합니다. 실패한 쿼리를 다시 시도하거나 활성 트랜잭션 중에 발생하는 오류에서 복구하지 않습니다. 이러한 시나리오의 경우 애플리케이션 수준 재시도 논리를 사용합니다.

재시도를 위한 Django 데이터베이스 미들웨어

일시적인 오류를 catch하고 데이터베이스 작업을 다시 시도하는 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()

Caution

transaction.atomic() 내부에서 다시 시도하지 마세요. 재시도 데코레이터는 각 재시도에서 새 트랜잭션이 시작되도록 전체 atomic() 블록을 래핑해야 합니다.

문 수준 오류

이전 섹션의 오류 목록에는 연결 수준 오류가 포함됩니다. 다른 두 오류는 일반적으로 문 수준에서 다시 시도됩니다.

  • 1205: 세션이 교착 상태 희생자로 선택되었습니다. 트랜잭션을 다시 실행합니다.
  • 1222: 잠금 요청 시간 제한을 초과했습니다. 트랜잭션을 다시 실행하거나, 기본값이 너무 공격적이라면 해당 세션의 LOCK_TIMEOUT 값을 늘리십시오.

ConnectRetryCount 끊어진 연결을 다시 시도하므로 이러한 문 수준 오류에는 적용되지 않습니다. 다시 실행해도 안전한 트랜잭션의 경우 TRANSIENT_ERROR_CODES"1205""1222"를 추가하여 동일한 데코레이터 패턴으로 처리합니다.

CONN_MAX_AGE 및 부실 연결

Django는 CONN_MAX_AGE가 설정되면 여러 요청에 걸쳐 데이터베이스 연결을 다시 사용합니다. 서버에서 연결을 닫으면 수명이 긴 연결이 부실해질 수 있습니다(예: 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분 동안 연결을 다시 사용합니다. 대부분의 웹 애플리케이션에 대한 균형이 좋습니다.
  • 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는 오류를 발생시키는 대신 새 연결을 투명하게 엽니다.

모범 사례

  • 완전 지터를 적용한 지수 백오프를 사용합니다. 시도할 때마다 상한을 두 배로 늘린 다음, [0, cap] 범위 내에서 임의의 시간만큼 대기합니다. 지터는 지역 장애가 발생했을 때 많은 클라이언트가 일제히 재시도하는 것을 방지하여, 그렇지 않으면 일시적인 장애가 지속적인 과부하로 악화되는 일을 막습니다. 총 복구 시간이 일정 범위로 제한되도록 시도별 대기 시간(예: 30초)에 상한을 두세요.
  • 다시 시도 최대값을 설정합니다. 지수 백오프를 사용한 세 번의 재시도는 적절한 기본값입니다. 일반적으로 5번 이상의 재시도는 비전도적 문제를 나타냅니다.
  • 다시 시도하기 전에 연결을 닫습니다. 다음 시도에서 Django가 새 연결을 열도록 호출 connection.close() 합니다.
  • 모든 재시도를 기록합니다. 자동으로 성공하는 재시도는 성능 문제를 숨길 수 있습니다. 빈도를 추적할 수 있도록 WARNING 수준으로 로그를 남기세요.
  • 일시적이지 않은 오류는 재시도하지 마세요. 인증 실패, 권한 오류 및 구문 오류는 재시도의 이점을 누릴 수 없습니다.
  • 전체 트랜잭션을 다시 시도합니다. transaction.atomic()를 재시도 로직으로 감싸야 하며, 그 반대로 하면 안 됩니다.
  • CONN_MAX_AGE를 사용하는 웹 애플리케이션에 대해 CONN_HEALTH_CHECKS 활성화(Django 4.1 이상)