SQLBulkCopy Error 40197(%d=4815)连接被强制关闭
结论
40197 表示 Azure SQL Database 在处理请求时遇到了服务端瞬时故障或发生故障转移,当前连接已失效。4815 是错误消息中包含的服务内部子代码。公开的错误说明无法将 4815 准确对应到某个硬件、节点或数据库问题,它也不是失败的行号。
建议从两个方面处理:
- 记录完整的诊断信息,包括错误发生时间、
SqlException.Errors、ClientConnectionId、服务器名称和数据库名称,然后提交给 Azure 支持,由其查询服务端日志。 - 将单次处理 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)建立唯一约束; - 每批完成后保存已确认的检查点;
- 重试前查询该批数据是否已经存在;
- 全部导入成功后,将数据从暂存表合并到正式表;
- 如果业务表已有天然唯一键,可以通过唯一约束实现幂等写入。
客户端没有收到成功结果,并不代表服务器一定没有写入。
检查故障是否与特定数据行有关
如果导入几乎总是在同一位置失败,可以通过数据二分测试继续排查:
- 单独导入失败位置前后的几百行;
- 继续将这段数据折半,逐步定位到具体行;
- 检查字符串长度、数值范围、日期格式、空值和目标列类型;
- 检查目标表中的触发器、计算列、约束和索引;
- 使用相同数据从独立客户端执行导入,排查 ASP.NET 托管进程、代理或网络路径的影响。
数据类型或约束问题通常会返回比 40197 更明确的 SQL 错误。不过,如果故障总是发生在相同行附近,仍需排除数据因素。
其他实现注意事项
- 20 MB 的文件不必全部装入一个
DataTable。可以通过文件读取器生成IDataReader,再调用WriteToServer(IDataReader)进行流式导入,以降低 Web 进程的内存压力。 - 将
SqlBulkCopy放入using后,无需再次调用Close()。 - 大文件导入不适合绑定在单次 HTTP 请求的生命周期内。请求超时、应用池回收或站点重启都可能中断任务。
DestinationTableName应从应用配置或允许列表中读取,不能直接使用用户输入。- 扩容可以缓解资源压力,但无法避免故障转移、短暂的服务异常或网络连接重置。
- 如果连接使用较旧的 .NET SQL 客户端,应在可控环境中验证受支持的客户端版本。没有测试结果和版本信息时,不能直接将问题归因于驱动程序。
备注:内容仅供参考。
