使用 mssql-django 重试逻辑和连接复原能力

由于与代码无关的原因,与SQL Server和Azure SQL的连接可能会暂时失败:

  • AlwaysOn 可用性组故障转移。
  • 在连接设置过程中,网络会丢弃数据包。
  • Resource Governor限制数据库。
  • Azure SQL 副本会在缩放或升级期间被回收。

其中大多数故障在几秒钟内就清除。 本文介绍如何在使用 mssql-django 后端的 Django 应用程序中重试瞬时错误,以及如何配置 Django 和 ODBC 驱动程序,以便在空闲连接断开后自动恢复。

暂时性错误

暂时性错误是自行解决的临时故障。 在短暂延迟后重试操作通常成功。

在建立连接期间发生或向服务器发送请求时,以下错误是暂时性的。 在短的、受限的退避时重试。 在几次重试之后保留的错误通常表示配置问题(服务器错误、权限缺失、配额耗尽),重试无法解决。

Error 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 引擎限流。 建议的最低退避时间为 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 数据库、Azure SQL 托管实例、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",
        },
    },
}
关键 字 默认 Description
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() 代码块,以便每次重试都会开启一个全新的事务。

语句级错误

上一部分中的错误列表涵盖连接级故障。 另外两种错误通常也会在语句级别进行重试:

  • 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 会透明地打开一个新连接,而不是引发错误。

最佳做法

  • 将指数退避与完全抖动一起使用。 每次尝试都将上限值翻倍,然后在 [0, cap] 范围内随机等待一段时间。 抖动可防止大量客户端在区域故障期间同步重试,否则原本短暂的故障可能会演变为持续过载。 限制每次尝试睡眠(例如 30 秒),因此总恢复时间保持有限。
  • 设置重试上限。 使用指数退避的三次重试是合理的默认值。 重试超过 5 次通常表明存在非暂时性问题。
  • 在重试之前关闭连接。 调用 connection.close() 以便 Django 在下一次尝试时打开新的连接。
  • 记录每次重试。 静默成功的重试可能会掩盖性能问题。 以 WARNING 级别记录,以便跟踪频率。
  • 不要重试非暂时性错误。 身份验证失败、权限错误和语法错误不受益于重试。
  • 重试整个事务。transaction.atomic() 包裹在重试逻辑中,而不是反过来。
  • 启用 CONN_HEALTH_CHECKS(Django 4.1 及更高版本),用于使用 CONN_MAX_AGE 的 Web 应用程序。