PAYATHON 2026

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 中,这通常也不是合适的后台任务方案。

建议的处理顺序

  1. 检查所有 SqlConnection、事务、HttpWebResponse、响应流和读取器是否都通过 Using 释放。
  2. 确认没有在逐条记录的循环中创建数据库连接。
  3. 确认连接池没有被禁用,并且连接字符串始终完全一致。
  4. 使用 SqlBulkCopy、表值参数或分批写入,替代 18,000 次单独 Insert。
  5. 复用同一个 HttpClient,避免频繁创建 HTTP 连接池。
  6. 消除 .Result、.Wait() 等同步等待。
  7. 在故障期间检查 App Service 的 SNAT/TCP 连接诊断和 Azure SQL 会话数。
  8. 如果连接数正常,再采集线程池、CPU、GC 和内存数据,继续检查线程池饥饿或阻塞式 GC。

现有现象包括明显的数据量阈值、等待一段时间后自动恢复、所有 Session 同时受影响,以及取消一个请求后下一个请求成功。这些特征更符合实例级出站连接或端口资源耗尽,而不是远程服务器故障或普通的托管内存泄漏。处理重点应放在减少物理连接创建、可靠释放资源,以及用真正的批量写入替代逐行 Insert。

备注:内容仅供参考。