SQL Insert 后 HttpWebRequest 超时(Dapper)
结论
问题并不是 SQL Insert “阻塞”了 HttpWebRequest。从现象看,更可能是 Azure App Service 实例耗尽了出站 TCP 连接资源,通常会出现以下情况:
- SQL 插入期间创建了大量物理连接,或者连接和响应流没有及时释放;
- 连接关闭后,大量 socket 停留在
TIME_WAIT状态; - 后续 HTTP 请求拿不到可用的出站连接,只能持续等待,最后超时;
- 等待 1~2 分钟后,socket 被回收,请求恢复;
- 取消一个正在等待的 HTTP 请求后,可能释放出一个连接资源,紧接着的请求便能成功;
- 数据量越大,问题越明显。同一 App Service 实例上的所有 ASP.NET Session 都会受到影响。
ServicePoint.ConnectionLimit 控制的是某个 HTTP ServicePoint 允许建立的并发连接数。它不会增加操作系统的临时端口、App Service 出站连接配额或 SNAT 端口,因此即使设置为 Int32.MaxValue,通常也解决不了这类问题。
不过,仅凭这些现象还不能百分之百确认原因,仍需结合连接监控数据和实际代码进行判断。数据库连接泄漏、HTTP 响应未释放以及 ASP.NET 线程池耗尽,都需要重点排查。
为什么插入行数会影响后续 HTTP 请求
Dapper 不会直接把 HTTP 请求和 SQL 命令关联起来。两者真正的联系在于底层资源,因为它们都需要 socket、线程和内存。
下面这种写法风险很高:
For Each customer In customers
Dim connection = New SqlConnection(connectionString)
connection.Open()
connection.Execute(
"INSERT INTO Customer (...) VALUES (...)",
customer
)
' connection 没有 Dispose
Next
即使连接最后会被垃圾回收,也不能把它当作及时、可靠的资源释放机制。
即便用了 Using,如果连接池被禁用、连接字符串不断变化,或者每次插入都会建立新的物理连接,插入 18,000 行数据仍可能引起严重的连接抖动:
For Each customer In customers
Using connection As New SqlConnection(connectionString)
connection.Open()
connection.Execute(sql, customer)
End Using
Next
正常情况下,只要连接字符串完全相同,ADO.NET 连接池就会复用物理连接。以下情况会破坏或削弱这种复用:
- 连接字符串包含动态内容;
- 设置了
Pooling=False; SqlConnection、SqlCommand、事务或读取器没有释放;- 连接频繁失败,触发连接池清理;
- 每条记录使用不同的凭据或数据库;
- 应用同时执行多个批量导入任务。
还要注意,connection.Close() 或 Dispose() 通常只是将逻辑连接归还连接池,不一定会关闭物理 TCP 连接。这属于正常行为,也是连接池能够避免端口耗尽的原因。
首先检查数据库插入方式
整批数据应尽量共用一个 SqlConnection,并放在同一个事务中:
Const sql As String =
"INSERT INTO dbo.Customer (CustomerId, Name, Email) " &
"VALUES (@CustomerId, @Name, @Email);"
Using connection As New SqlConnection(connectionString)
connection.Open()
Using transaction = connection.BeginTransaction()
connection.Execute(
sql,
customers,
transaction:=transaction,
commandTimeout:=120
)
transaction.Commit()
End Using
End Using
Dapper 接收集合参数后,会在同一个连接上依次执行命令。这样可以避免为每行数据创建连接,但不等于真正的批量写入。插入 18,000 行数据时,仍可能执行 18,000 次命令,速度和资源利用率都不理想。
连接字符串也应保持固定:
Dim connectionString As String =
ConfigurationManager.ConnectionStrings("MainDatabase").ConnectionString
不要在每次操作时动态拼入随机参数、时间戳或其他内容,否则连接池可能被拆分。
大批量数据优先使用 SqlBulkCopy
需要写入数千到数万行数据时,SqlBulkCopy 通常比使用 Dapper 逐行 Insert 更合适:
Dim table As New DataTable()
table.Columns.Add("CustomerId", GetType(Integer))
table.Columns.Add("Name", GetType(String))
table.Columns.Add("Email", GetType(String))
For Each customer In customers
table.Rows.Add(customer.CustomerId, customer.Name, customer.Email)
Next
Using connection As New SqlConnection(connectionString)
connection.Open()
Using transaction = connection.BeginTransaction()
Using bulkCopy As New SqlBulkCopy(
connection,
SqlBulkCopyOptions.TableLock,
transaction
)
bulkCopy.DestinationTableName = "dbo.Customer"
bulkCopy.BatchSize = 1000
bulkCopy.BulkCopyTimeout = 120
bulkCopy.ColumnMappings.Add("CustomerId", "CustomerId")
bulkCopy.ColumnMappings.Add("Name", "Name")
bulkCopy.ColumnMappings.Add("Email", "Email")
bulkCopy.WriteToServer(table)
End Using
transaction.Commit()
End Using
End Using
BatchSize 需要根据记录大小、数据库性能和事务要求进行测试,不能默认 1000 适用于所有场景。
还可以考虑以下方案:
- 使用表值参数(Table-Valued Parameter),一次传入一批记录;
- 先把数据写入临时表,再执行集合化的
MERGE或INSERT; - 分批写入,每批处理几百或几千行;
- 将耗时较长的导入移到后台任务,避免长期占用 ASP.NET 请求线程。
确保 HTTP 响应被完整释放
如果仍在使用 HttpWebRequest,必须释放 HttpWebResponse 和响应流:
Dim request = DirectCast(
WebRequest.Create(url),
HttpWebRequest
)
request.Method = "GET"
request.Timeout = 30000
request.ReadWriteTimeout = 30000
Using response = DirectCast(request.GetResponse(), HttpWebResponse)
Using stream = response.GetResponseStream()
Using reader As New StreamReader(stream)
Dim json As String = reader.ReadToEnd()
' 在 Using 范围内完成读取,
' 然后再反序列化或继续处理。
End Using
End Using
End Using
不要只读取一部分响应就丢弃对象,也不要依赖 GC 或终结器来关闭响应。响应流没有关闭时,底层连接可能长期无法回到连接池。
在 .NET Framework 4.6.2 中,也可以复用一个长期存在的 HttpClient。不要为每次请求都创建并立即销毁新的 HttpClient:
Private Shared ReadOnly Client As New HttpClient(
New HttpClientHandler() With {
.AutomaticDecompression =
DecompressionMethods.GZip Or DecompressionMethods.Deflate
}
)
Private Shared Async Function GetJsonAsync(
url As String,
cancellationToken As CancellationToken
) As Task(Of String)
Using response = Await Client.GetAsync(
url,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
)
response.EnsureSuccessStatusCode()
Return Await response.Content.ReadAsStringAsync()
End Using
End Function
复用 HttpClient 能够复用底层连接,减少 socket 的创建和 TIME_WAIT。但如果 SQL 连接仍在泄漏,仅替换 HTTP API 并不能解决根本问题。
在 Azure App Service 中如何确认
问题发生时,应查看 App Service 实例级别的连接状态,不能只观察浏览器请求。
建议检查:
- Azure Portal 中 App Service 的“诊断并解决问题”;
- SNAT Port Exhaustion、TCP Connections 或类似的网络诊断项,具体名称可能会随 Portal 更新而变化;
- 故障期间的活动 TCP 连接数;
TIME_WAIT、ESTABLISHED和连接失败的数量;- Azure SQL 当前会话数及连接池使用情况;
- 应用是否在短时间内反复创建
SqlConnection; - 问题是否只出现在某个 App Service 实例上;
- CPU、内存、线程数和请求队列是否同时升高。
还可以临时记录各阶段的耗时和数据量:
Dim stopwatch = Stopwatch.StartNew()
Log.Info($"HTTP A started; thread={Thread.CurrentThread.ManagedThreadId}")
Dim customers = Await LoadCustomersAsync()
Log.Info($"HTTP A completed; rows={customers.Count}; elapsed={stopwatch.Elapsed}")
stopwatch.Restart()
InsertCustomers(customers)
Log.Info($"SQL B completed; rows={customers.Count}; elapsed={stopwatch.Elapsed}")
stopwatch.Restart()
Dim payments = Await LoadPaymentsAsync()
Log.Info($"HTTP C completed; rows={payments.Count}; elapsed={stopwatch.Elapsed}")
除了记录 Execute 调用,还应记录连接的创建位置。需要确认的是每批导入究竟创建了多少个 SqlConnection,以及这些连接是否都进入了 Dispose()。
还要排除线程池耗尽
以下写法也可能导致 ASP.NET 线程池饥饿:
Dim result = SomeAsyncOperation().Result
SomeAsyncOperation().Wait()
把耗时较长的同步数据库导入直接放在 ASP.NET 请求线程中,也会产生类似问题。线程池耗尽后,新请求只能排队,表面上看起来很像 HTTP 超时。
调用链应尽量保持异步:
Protected Async Sub ImportButton_Click(
sender As Object,
e As EventArgs
) Handles ImportButton.Click
Dim customers = Await LoadCustomersAsync()
Await InsertCustomersAsync(customers)
Dim payments = Await LoadPaymentsAsync()
Await InsertPaymentsAsync(payments)
End Sub
但把同步工作简单包进 Task.Run,并不会减少数据库连接或出站端口消耗。在 ASP.NET 中,这通常也不是合适的后台任务方案。
建议的处理顺序
- 检查所有
SqlConnection、事务、HttpWebResponse、响应流和读取器是否都通过Using释放。 - 确认没有在逐条记录的循环中创建数据库连接。
- 确认连接池没有被禁用,并且连接字符串始终完全一致。
- 使用
SqlBulkCopy、表值参数或分批写入,替代 18,000 次单独 Insert。 - 复用同一个
HttpClient,避免频繁创建 HTTP 连接池。 - 消除
.Result、.Wait()等同步等待。 - 在故障期间检查 App Service 的 SNAT/TCP 连接诊断和 Azure SQL 会话数。
- 如果连接数正常,再采集线程池、CPU、GC 和内存数据,继续检查线程池饥饿或阻塞式 GC。
现有现象包括明显的数据量阈值、等待一段时间后自动恢复、所有 Session 同时受影响,以及取消一个请求后下一个请求成功。这些特征更符合实例级出站连接或端口资源耗尽,而不是远程服务器故障或普通的托管内存泄漏。处理重点应放在减少物理连接创建、可靠释放资源,以及用真正的批量写入替代逐行 Insert。
备注:内容仅供参考。
