空闲连接复原能力

下载 PHP 驱动程序

连接弹性允许 Microsoft ODBC Driver for SQL Server 在服务器和会话状态支持恢复时恢复故障的空闲连接。 PHP 扩展将ConnectRetryCount和ConnectRetryInterval传递给 ODBC;它们不实现重连协议。 ODBC 文档列出了对 SQL Server 2014 及以后版本、Azure SQL 数据库 以及 Microsoft Fabric 中 SQL 数据库的支持。 不要从 Fabric 中对 SQL 数据库的支持推断出 Warehouse 支持。

关键字 值 默认值 说明
ConnectRetryCount 介于 0 和 255 之间(含限值)的整数 1 放弃前重新建立已中断连接的最大尝试次数。 值为 0 时,将禁用重连。
ConnectRetryInterval 介于 1 和 60 之间(含限值)的整数 10 每次尝试之间等待的秒数。 如果 ConnectRetryCount 为 0,则忽略 。

该语句的查询超时限制了 ODBC 在执行过程中恢复空闲连接可花费的时间。 在 SQLSRV 语句选项中设置 QueryTimeout,或者在 PDO_SQLSRV 中设置 PDO::SQLSRV_ATTR_QUERY_TIMEOUT。 LoginTimeout 控制建立连接;增加其值不会延长语句的恢复预算。

在 SQLSRV 连接选项数组(如 ["ConnectRetryCount" => 2, "ConnectRetryInterval" => 5])或 PDO_SQLSRV 数据源名称(DSN)中设置相同的重试关键词,如 ConnectRetryCount=2;ConnectRetryInterval=5。 这些设置不会重放失败的事务,也不会重试应用程序的初始连接调用。

关于查询执行过程中发生的临时错误的应用层重试(空闲连接弹性不涵盖此部分),请参见Microsoft Drivers for PHP for SQL Server中的重试模式。

选择恢复策略

空闲连接恢复保留可恢复的会话,使应用程序能够继续使用其 PHP 连接句柄。 这并不是再次执行某个命令的一般性指令。

Failure 空闲连接复原功能可以处理这种情况吗? 应用程序操作
无法建立初始连接 No. 没有已建立的会话可以恢复。 检查连接错误。 仅对暂时性故障使用有限制的策略进行重试。
空闲且可恢复的连接断开 当连接再次被使用时,ODBC可以尝试恢复。 为预期的恢复尝试留出足够的语句超时时间;但仍能处理最终失败。
语句执行失败 不要假设能恢复或重放。 检查错误并判断操作是否完成。 仅当应用程序可以接受重复执行时,才重试读取操作。
连接在交易过程中断开 不要假设事务可以继续。 放弃已中断的会话,并在决定是否再次尝试之前,先确定该事务是否已提交。
客户端会显式关闭其 PHP 连接 No. 关闭句柄属于应用程序清理,而不是空闲断开连接。 如果还需要做更多工作,可以打开新连接。

保持恢复间隔和查询截止日期保持一致。 例如,程序使用两次重连尝试,间隔为5秒,语句超时为30秒。 连接尝试也需要时间;次数乘以间隔并不保证总恢复时间。 最终超时仍需错误处理。

备注

连接弹性仅在连接处于空闲状态时生效。 事务期间发生故障不会触发重新连接。 以下会话状态也阻止重新连接:

  • 临时表。
  • 全局和本地光标。
  • 事务上下文和会话级事务锁。
  • 应用锁。
  • EXECUTE AS / REVERT 安全上下文。
  • 对象链接与嵌入(OLE)自动化句柄。
  • 已准备好的可扩展标记语言(XML)句柄。
  • 跟踪标志。

示例

每个程序会打开到测试 SQL Server 实例的两个连接。 第二个连接通过 KILL 终止第一个会话。 对第一个连接执行后续查询会检验 ODBC 的空闲连接恢复机制。 查询使用内联行,因此无需设置表。 程序在断开连接前释放每个语句,并验证恢复查询的值和变更后的服务器登录时间。 仅凭会话 ID 并不能证明恢复成功,因为 SQL Server 可能会复用它。

Caution

仅在测试实例中运行此示例。 该登录名需要权限才能用 KILL 终止会话。 代码只终止刚刚打开的会话。 不要替换其他应用的会话ID。

在进程环境中设置 SQL_SERVER、SQL_DATABASE、SQL_USER 和 SQL_PASSWORD。 配置一个可信服务器证书,其名称与 SQL_SERVER 匹配。 本例使用 SQL 身份验证连接到测试 SQL Server 实例。 这不是 Fabric 或托管身份认证的示例。

将实际的数据库名称、用户名和密码存储在环境变量中。 每个程序通过将右花括号写成两个并将每个值包裹在大括号中,对这些值进行一次转义,以实现 PHP 驱动的 ODBC 风格连接处理。 两个连接都会重复使用转义后的值。

SQLSRV

将程序保存为 recover-sqlsrv.php,并运行 php recover-sqlsrv.php。 帮助程序会在 SQLSRV 失败时引发异常;它不会重试任何应用程序语句。

<?php
function requireSqlsrv($result)
{
    if ($result === false) {
        throw new RuntimeException(print_r(sqlsrv_errors(), true));
    }
    return $result;
}

$config = [];
foreach (['SQL_SERVER', 'SQL_DATABASE', 'SQL_USER', 'SQL_PASSWORD'] as $name) {
    $value = getenv($name);
    if ($value === false || $value === '') {
        throw new RuntimeException("Set $name before running this example.");
    }
    $config[$name] = $value;
}
foreach (['SQL_DATABASE', 'SQL_USER', 'SQL_PASSWORD'] as $name) {
    $config[$name] = '{' . str_replace('}', '}}', $config[$name]) . '}';
}
$connectionInfo = [
    'Database' => $config['SQL_DATABASE'],
    'UID' => $config['SQL_USER'],
    'PWD' => $config['SQL_PASSWORD'],
    'Encrypt' => true,
    'TrustServerCertificate' => false,
    'LoginTimeout' => 30,
    'ConnectRetryCount' => 2,
    'ConnectRetryInterval' => 5,
];
$conn = requireSqlsrv(sqlsrv_connect($config['SQL_SERVER'], $connectionInfo));
$breaker = null;
try {
    $breaker = requireSqlsrv(sqlsrv_connect($config['SQL_SERVER'], $connectionInfo));
    $sessionQuery = "SELECT @@SPID AS SessionId, "
        . "CONVERT(varchar(33), login_time, 126) AS LoginTime "
        . "FROM sys.dm_exec_sessions WHERE session_id = @@SPID";
    $idStatement = requireSqlsrv(sqlsrv_query($conn, $sessionQuery));
    $before = requireSqlsrv(sqlsrv_fetch_array($idStatement, SQLSRV_FETCH_ASSOC));
    sqlsrv_free_stmt($idStatement);
    if (!is_array($before) || !is_int($before['SessionId']) || $before['SessionId'] <= 0) {
        throw new RuntimeException('Expected the current session identity.');
    }
    $spid = $before['SessionId'];

    $query = 'SELECT id FROM (VALUES (1), (2), (3)) AS Demo(id) ORDER BY id';
    $options = ['Scrollable' => SQLSRV_CURSOR_CLIENT_BUFFERED, 'QueryTimeout' => 30];
    $stmt = requireSqlsrv(sqlsrv_query($conn, $query, [], $options));
    if (requireSqlsrv(sqlsrv_num_rows($stmt)) !== 3) {
        throw new RuntimeException('Expected three rows before the disconnect.');
    }
    sqlsrv_free_stmt($stmt);

    // KILL doesn't accept a parameter marker; $spid is a checked server-returned integer.
    $kill = requireSqlsrv(sqlsrv_query($breaker, 'KILL ' . $spid));
    sqlsrv_free_stmt($kill);
    // Allow the simulated disconnect to reach the client before the next query.
    sleep(1);

    $stmt = requireSqlsrv(sqlsrv_query($conn, $query, [], $options));
    $ids = [];
    while (($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) !== null) {
        $row = requireSqlsrv($row);
        $ids[] = $row['id'];
    }
    if ($ids !== [1, 2, 3]) {
        throw new RuntimeException('Unexpected recovered rows or PHP types.');
    }
    sqlsrv_free_stmt($stmt);

    $idStatement = requireSqlsrv(sqlsrv_query($conn, $sessionQuery));
    $after = requireSqlsrv(sqlsrv_fetch_array($idStatement, SQLSRV_FETCH_ASSOC));
    sqlsrv_free_stmt($idStatement);
    if (!is_array($after) || $after['LoginTime'] === $before['LoginTime']) {
        throw new RuntimeException('Expected a new physical session.');
    }
    echo "Recovered connection: 3 matching rows; login time changed.\n";
} finally {
    if (is_resource($breaker)) {
        sqlsrv_close($breaker);
    }
    sqlsrv_close($conn);
}

PDO_SQLSRV

将这个独立程序保存为 recover-pdo.php,并使用相同的环境和权限运行 php recover-pdo.php 。 它明确设置以数值类型提取数据,使整数断言与 SQLSRV 程序匹配。 PDO 没有 sqlsrv_errors() 调用;操作失败在异常模式下会抛出 PDOException。

<?php
$config = [];
foreach (['SQL_SERVER', 'SQL_DATABASE', 'SQL_USER', 'SQL_PASSWORD'] as $name) {
    $value = getenv($name);
    if ($value === false || $value === '') {
        throw new RuntimeException("Set $name before running this example.");
    }
    $config[$name] = $value;
}
foreach (['SQL_DATABASE', 'SQL_USER', 'SQL_PASSWORD'] as $name) {
    $config[$name] = '{' . str_replace('}', '}}', $config[$name]) . '}';
}
$server = $config['SQL_SERVER'];
$database = $config['SQL_DATABASE'];
$dsn = "sqlsrv:Server=$server;Database=$database;Encrypt=true;"
    . "TrustServerCertificate=false;LoginTimeout=30;"
    . "ConnectRetryCount=2;ConnectRetryInterval=5";
$attributes = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => true,
    PDO::SQLSRV_ATTR_QUERY_TIMEOUT => 30,
];
$pdo = new PDO($dsn, $config['SQL_USER'], $config['SQL_PASSWORD'], $attributes);
$breaker = null;
$stmt = null;
try {
    $breaker = new PDO($dsn, $config['SQL_USER'], $config['SQL_PASSWORD'], $attributes);
    $sessionQuery = "SELECT @@SPID AS SessionId, "
        . "CONVERT(varchar(33), login_time, 126) AS LoginTime "
        . "FROM sys.dm_exec_sessions WHERE session_id = @@SPID";
    $stmt = $pdo->query($sessionQuery);
    $before = $stmt->fetch(PDO::FETCH_ASSOC);
    $stmt = null;
    if (!is_array($before) || !is_int($before['SessionId']) || $before['SessionId'] <= 0) {
        throw new RuntimeException('Expected the current session identity.');
    }
    $spid = $before['SessionId'];
    $query = 'SELECT id FROM (VALUES (1), (2), (3)) AS Demo(id) ORDER BY id';
    $stmt = $pdo->query($query);
    if ($stmt->fetchAll(PDO::FETCH_COLUMN) !== [1, 2, 3]) {
        throw new RuntimeException('Unexpected initial rows or PHP types.');
    }
    $stmt = null;

    // KILL requires a checked integer, not a parameter marker.
    $stmt = $breaker->query('KILL ' . $spid);
    $stmt = null;
    sleep(1);

    $stmt = $pdo->query($query);
    if ($stmt->fetchAll(PDO::FETCH_COLUMN) !== [1, 2, 3]) {
        throw new RuntimeException('Unexpected recovered rows or PHP types.');
    }
    $stmt = null;
    $stmt = $pdo->query($sessionQuery);
    $after = $stmt->fetch(PDO::FETCH_ASSOC);
    if (!is_array($after) || $after['LoginTime'] === $before['LoginTime']) {
        throw new RuntimeException('Expected a new physical session.');
    }
    echo "Recovered connection: 3 matching rows; login time changed.\n";
} finally {
    $stmt = null;
    $breaker = null;
    $pdo = null;
}

ODBC恢复连接时任一程序的预期输出:

Recovered connection: 3 matching rows; login time changed.

测试会终止一个被故意置为空闲状态的会话。 它不会测试网络分区、服务故障切换、主动事务恢复、写重播或 Azure 限速。 不要把一次通过解读为可以证明每个中断的应用程序操作都可安全重复。

诊断失败的恢复

症状 检查
KILL 被拒绝 使用允许的测试身份,或要求管理员执行受控测试。 不要用另一个会话ID替代。
第一个查询已经失败了 在测试恢复前,先修复连接、证书、查询和权限。
KILL 之后的查询会失败 检查 ConnectRetryCount查询超时、ODBC诊断以及会话状态是否可恢复。 不要用无条件重试来掩盖错误。
在开启新连接后,临时表会消失 应用程序创建的替代连接并不是旧有的会话。 重新设计依赖会话的工作流,而不是假设状态会自动重建。
写入返回连接错误 先确认其结果,再重试。 仅仅客户端错误并不能证明服务器已将其回滚。

把诊断记录保存在受保护的日志中。 SQLSRV 暴露一个错误集合,该集合可以包含多个记录;PDO 通过异常提供 SQLSTATE 和驱动程序详细信息。 记录操作及其超时设置,不记录密码或令牌。 更多信息请参见 日志活动。

目标适用性

ODBC 和 SQL 目标执行自动重新连接。 PHP 传递设置并报告成功或失败。 它不会重建临时对象,不会恢复应用程序的变量,也不会重放事务。

受控程序使用 SQL Server,并且需要通过第二个连接获得终止自身测试会话的权限。 Azure SQL 数据库 和 Fabric 中的 SQL 数据库各自有自己的服务权限和认证要求。 不要把这些程序当作 Azure SQL 托管实例、Azure Synapse Analytics 或 Fabric 中的仓库的直接替代测试,也不要因为普通连接能够成功,就推断它们支持恢复。 在依赖它之前,请先查看所选服务的恢复支持。 Fabric 中的 Warehouse 和 SQL 数据库仍然是不同的目标。