PAYATHON 2026

SQLBulkCopy Error 40197(%d=4815)连接被强制关闭

排障赵师傅

结论

40197 表示 Azure SQL Database 在处理请求时遇到了服务端瞬时故障或发生故障转移,当前连接已失效。4815 是错误消息中包含的服务内部子代码。公开的错误说明无法将 4815 准确对应到某个硬件、节点或数据库问题,它也不是失败的行号。

建议从两个方面处理:

  1. 记录完整的诊断信息,包括错误发生时间、SqlException.Errors、ClientConnectionId、服务器名称和数据库名称,然后提交给 Azure 支持,由其查询服务端日志。
  2. 将单次处理 20 万行的 WriteToServer 拆成可恢复的小批次导入。每批使用单独的连接和事务,并记录处理进度。遇到 40197 后重新连接并重试。

只修改 SqlBulkCopy.BatchSize、Connection Timeout 或数据库规格,无法保证连接不再中断。

为什么调整现有参数没有效果

Connection Timeout 只影响连接建立

连接字符串中的:

Connection Timeout=900

只控制 SqlConnection.Open() 等待建立连接的时间。连接建立后,如果 Azure SQL 主动关闭连接,这个参数无法阻止断线。

BulkCopyTimeout = 0 无法维持连接

sbc.BulkCopyTimeout = 0;

这项设置表示客户端不会因为 Bulk Copy 命令超时而主动终止操作,但服务端、网络设备或托管平台仍可能关闭连接。

无限超时还可能使故障请求长期占用 ASP.NET 工作线程。实际应用中通常应设置合理的有限值,失败后再重新建立连接。

BatchSize 不是可恢复的应用批次

以下设置只决定 SqlBulkCopy 如何划分内部批次:

sbc.BatchSize = 1000;

应用仍然只执行一次:

sbc.WriteToServer(dataTable);

连接断开后,代码无法判断服务器已经确认了哪些行,也没有保存恢复所需的检查点。要支持可靠恢复,需要由应用拆分数据,并为每个数据块分别调用 WriteToServer。

先收集完整错误信息

不要只记录最内层的 InnerException。SqlException.Errors 可能包含多条服务端错误,其中的 Number、State、Class 和消息都需要保留。

private static void LogSqlException(SqlException ex)
{
    System.Diagnostics.Trace.TraceError(
        "UTC={0:o}; ClientConnectionId={1}; Message={2}",
        DateTime.UtcNow,
        ex.ClientConnectionId,
        ex.Message);

    foreach (SqlError error in ex.Errors)
    {
        System.Diagnostics.Trace.TraceError(
            "Number={0}; State={1}; Class={2}; Procedure={3}; Line={4}; Message={5}",
            error.Number,
            error.State,
            error.Class,
            error.Procedure,
            error.LineNumber,
            error.Message);
    }

    Exception current = ex.InnerException;
    while (current != null)
    {
        System.Diagnostics.Trace.TraceError(
            "InnerType={0}; HResult={1}; Message={2}",
            current.GetType().FullName,
            current.HResult,
            current.Message);

        current = current.InnerException;
    }
}

向 Azure 支持提交问题时,至少应提供:

  • 错误发生的精确 UTC 时间;
  • Azure SQL 逻辑服务器和数据库名称;
  • ClientConnectionId;
  • SqlException.Errors 的完整内容;
  • 主错误号 40197 和消息中的子代码 4815;
  • 导入开始时间、失败时间和已处理行数;
  • 每次失败是否都发生在同一源数据行附近;
  • 使用其他客户端或网络环境执行相同导入的结果。

某次 4815 对应的内部故障只能通过 Azure 服务端遥测信息进一步确认,不能仅凭这个数字推断故障节点或数据行。

将导入拆成可重试的小批次

下面的示例由应用拆分 DataTable。每个数据块都使用新连接和独立事务,提交成功后才推进检查点。

private static readonly int[] TransientSqlErrors =
{
    40197, 40501, 40613, 49918, 49919, 49920
};

public void BulkCopyInChunks(
    DataTable source,
    string connectionString,
    string destinationTable,
    int chunkSize)
{
    for (int offset = 0; offset < source.Rows.Count; offset += chunkSize)
    {
        int count = Math.Min(chunkSize, source.Rows.Count - offset);
        DataTable chunk = source.Clone();

        for (int i = 0; i < count; i++)
        {
            chunk.ImportRow(source.Rows[offset + i]);
        }

        WriteChunkWithRetry(
            chunk,
            connectionString,
            destinationTable,
            offset,
            5);
    }
}

private void WriteChunkWithRetry(
    DataTable chunk,
    string connectionString,
    string destinationTable,
    int offset,
    int maxAttempts)
{
    for (int attempt = 1; attempt <= maxAttempts; attempt++)
    {
        try
        {
            using (SqlConnection connection = new SqlConnection(connectionString))
            {
                connection.Open();

                using (SqlTransaction transaction = connection.BeginTransaction())
                using (SqlBulkCopy bulkCopy = new SqlBulkCopy(
                    connection,
                    SqlBulkCopyOptions.TableLock,
                    transaction))
                {
                    bulkCopy.DestinationTableName = destinationTable;
                    bulkCopy.BatchSize = chunk.Rows.Count;
                    bulkCopy.BulkCopyTimeout = 300;
                    bulkCopy.NotifyAfter = Math.Min(500, chunk.Rows.Count);

                    bulkCopy.SqlRowsCopied += delegate(object sender, SqlRowsCopiedEventArgs e)
                    {
                        System.Diagnostics.Trace.TraceInformation(
                            "ChunkOffset={0}; RowsCopied={1}",
                            offset,
                            e.RowsCopied);
                    };

                    AddMappings(bulkCopy);
                    bulkCopy.WriteToServer(chunk);

                    transaction.Commit();
                }
            }

            return;
        }
        catch (SqlException ex)
        {
            LogSqlException(ex);

            if (!IsTransient(ex) || attempt == maxAttempts)
            {
                throw;
            }

            int delaySeconds = Math.Min(30, (int)Math.Pow(2, attempt));
            System.Threading.Thread.Sleep(
                TimeSpan.FromSeconds(delaySeconds));
        }
    }
}

private static bool IsTransient(SqlException ex)
{
    foreach (SqlError error in ex.Errors)
    {
        for (int i = 0; i < TransientSqlErrors.Length; i++)
        {
            if (error.Number == TransientSqlErrors[i])
            {
                return true;
            }
        }
    }

    return false;
}

private static void AddMappings(SqlBulkCopy bulkCopy)
{
    bulkCopy.ColumnMappings.Add("D2001_SPID", "SupplyPointId");
    bulkCopy.ColumnMappings.Add(
        "D2002_ServiceCategory",
        "D2002_ServiceCategory");
    bulkCopy.ColumnMappings.Add(
        "D2025_NotifyDisconnection/Reconnection",
        "D2025_NotifyDisconnectionReconnection");
    bulkCopy.ColumnMappings.Add("WaterBatchId", "WaterBatchId");

    // 添加其余映射。
}

示例中的瞬时错误列表需要根据应用的实际观测结果维护。如果故障只表现为 TCP 连接重置,没有返回 SQL 错误号,可以检查最内层的 Win32Exception.NativeErrorCode。重试次数仍需设限,不能把所有异常都视为瞬时故障。

在 ASP.NET 请求线程中使用 Thread.Sleep 仅用于演示重试流程。生产环境应将大文件导入放到后台任务中,并采用不会长期占用请求线程的延迟机制。

防止重试产生重复数据

连接中断后,客户端可能无法确认最后一批是否已经提交。例如,服务器已经提交事务,但提交确认还未到达客户端,连接就被关闭了。此时直接重试同一批次可能产生重复数据。

可以选择以下方案:

  • 为每次导入生成 ImportId,并为每行保存稳定的源文件行号;
  • 在暂存表上为 (ImportId, SourceRowNumber) 建立唯一约束;
  • 每批完成后保存已确认的检查点;
  • 重试前查询该批数据是否已经存在;
  • 全部导入成功后,将数据从暂存表合并到正式表;
  • 如果业务表已有天然唯一键,可以通过唯一约束实现幂等写入。

客户端没有收到成功结果,并不代表服务器一定没有写入。

检查故障是否与特定数据行有关

如果导入几乎总是在同一位置失败,可以通过数据二分测试继续排查:

  1. 单独导入失败位置前后的几百行;
  2. 继续将这段数据折半,逐步定位到具体行;
  3. 检查字符串长度、数值范围、日期格式、空值和目标列类型;
  4. 检查目标表中的触发器、计算列、约束和索引;
  5. 使用相同数据从独立客户端执行导入,排查 ASP.NET 托管进程、代理或网络路径的影响。

数据类型或约束问题通常会返回比 40197 更明确的 SQL 错误。不过,如果故障总是发生在相同行附近,仍需排除数据因素。

其他实现注意事项

  • 20 MB 的文件不必全部装入一个 DataTable。可以通过文件读取器生成 IDataReader,再调用 WriteToServer(IDataReader) 进行流式导入,以降低 Web 进程的内存压力。
  • 将 SqlBulkCopy 放入 using 后,无需再次调用 Close()。
  • 大文件导入不适合绑定在单次 HTTP 请求的生命周期内。请求超时、应用池回收或站点重启都可能中断任务。
  • DestinationTableName 应从应用配置或允许列表中读取,不能直接使用用户输入。
  • 扩容可以缓解资源压力,但无法避免故障转移、短暂的服务异常或网络连接重置。
  • 如果连接使用较旧的 .NET SQL 客户端,应在可控环境中验证受支持的客户端版本。没有测试结果和版本信息时,不能直接将问题归因于驱动程序。

备注:内容仅供参考。