场景引入
在业务系统里,经常需要把大量数据写入数据库:日志采集、数据同步、Excel 导入、报表落库……如果用最朴素的方式一条一条 INSERT,10 万条数据跑下来可能要几十秒甚至几分钟,用户体验直接崩盘。
本篇就盘点一下 ADO.NET 里常见的几种批量新增方案,并重点讲解最快的一种——SqlBulkCopy,让你在面对百万级数据导入时也能从容应对。
ADO.NET 批量新增方案对比
在 ADO.NET 体系里,批量写入大致有四种主流方案,下面逐一说明。
方案一:循环 INSERT(最慢)
最常见的写法:for 循环里执行 SqlCommand.ExecuteNonQuery,每条数据单独发送一次 SQL。
// 循环 INSERT:每条数据单独执行一次,性能最差
for (int i = 0; i < list.Count; i++)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO T(UserName,Age) VALUES(@u,@a)";
cmd.Parameters.AddWithValue("@u", list[i].UserName);
cmd.Parameters.AddWithValue("@a", list[i].Age);
cmd.ExecuteNonQuery(); // 每条都走一次网络往返 + 解析 + 提交
}问题很明显:每条 SQL 都要走一次网络往返、一次解析、一次事务隐式提交,10 万条数据几十秒起步。
方案二:事务批量 INSERT
把多条 INSERT 语句包在一个事务里,或者拼成一条大 SQL 一次性执行。相比方案一,减少了事务提交次数和网络往返,性能提升明显。
// 事务批量 INSERT:一个事务包裹所有语句,减少提交次数
using var tran = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tran;
cmd.CommandText = "INSERT INTO T(UserName,Age) VALUES(@u,@a)";
cmd.Parameters.Add(new SqlParameter("@u", SqlDbType.NVarChar, 50));
cmd.Parameters.Add(new SqlParameter("@a", SqlDbType.Int));
foreach (var item in list)
{
cmd.Parameters["@u"].Value = item.UserName;
cmd.Parameters["@a"].Value = item.Age;
cmd.ExecuteNonQuery(); // 同一连接、同一事务,复用参数
}
tran.Commit(); // 一次性提交需要注意:SQL Server 单条语句最多 2100 个参数,如果用参数化拼大批量语句要分批。
方案三:SqlBulkCopy(最快)
SqlBulkCopy 是 SQL Server 专属的高速导入工具,底层走 TDS 协议的 Bulk Load 机制,相当于把内存里的 DataTable / DbDataReader 直接”灌”进目标表,绕过常规的 SQL 解析和执行计划生成。10 万条数据通常 1 秒内搞定。
方案四:表值参数 TVP
SQL Server 2008 起支持表值参数(Table-Valued Parameter)。先在数据库定义一个 Table 类型,再把 DataTable 作为参数传给存储过程。灵活、可在 SP 里做业务校验,但性能略低于 SqlBulkCopy,且需要预先建 Type。
| 方案 | 10万条耗时(参考) | 适用场景 |
|---|---|---|
| 循环 INSERT | 30~60s | 少量数据 |
| 事务批量 INSERT | 3~8s | 中等量、需要业务逻辑 |
| SqlBulkCopy | 0.3~1s | 大数据量、纯导入 |
| 表值参数 TVP | 1~3s | 中等量、需存储过程 |
SqlBulkCopy 详解
原理
SqlBulkCopy 基于 TDS(Tabular Data Stream)协议的 Bulk Load 命令。它把源数据按 BatchSize 打包成二进制流,直接写入目标表的 B-Tree,省去了普通 INSERT 的解析、编译、执行计划缓存等开销。整个过程是流式的,内存占用可控,不会因为数据量大就 OOM。
优势
- 速度极快,适合百万级以上数据导入
- 支持从
DataTable、DbDataReader、IDataReader多种数据源 - 支持列映射,源列和目标列名可以不一致
- 支持
BatchSize、BulkCopyTimeout等配置
限制
- 仅支持 SQL Server(Azure SQL Database 也支持)
- 默认会触发目标表的 INSERT 触发器(可通过选项控制)
- 不返回插入行的自增标识值
- 列顺序/类型不匹配会抛异常,需要做好映射
完整代码示例(带取消支持)
下面给出一个生产可用的封装,支持取消令牌、列映射、事务、超时配置。
using System.Data;
using System.Data.SqlClient;
using Microsoft.Data.SqlClient; // 推荐使用 Microsoft.Data.SqlClient
public static class BulkCopyHelper
{
/// <summary>
/// 使用 SqlBulkCopy 将 DataTable 高速写入目标表
/// </summary>
/// <param name="conn">已打开的连接</param>
/// <param name="tran">外部事务,可为 null</param>
/// <param name="destinationTable">目标表名</param>
/// <param name="source">源数据</param>
/// <param name="columnMappings">列映射,key=源列,value=目标列</param>
/// <param name="batchSize">每批大小,建议 5000~10000</param>
/// <param name="timeout">超时秒数</param>
/// <param name="cancellationToken">取消令牌</param>
public static void BulkInsert(
SqlConnection conn,
SqlTransaction? tran,
string destinationTable,
DataTable source,
Dictionary<string, string>? columnMappings,
int batchSize = 10000,
int timeout = 60,
CancellationToken cancellationToken = default)
{
// 使用 SqlBulkCopyOptions.KeepIdentity 可保留源表自增ID
// 这里用默认选项,让目标表自己生成标识列
using var bulk = tran is null
? new SqlBulkCopy(conn)
: new SqlBulkCopy(conn, SqlBulkCopyOptions.Default, tran);
bulk.DestinationTableName = destinationTable;
bulk.BatchSize = batchSize; // 每批写入的行数
bulk.BulkCopyTimeout = timeout; // 整体超时(秒)
// 列映射:源列名 -> 目标列名
if (columnMappings is null || columnMappings.Count == 0)
{
// 不指定时按列名自动匹配(要求源列名和目标列名一致)
foreach (DataColumn col in source.Columns)
{
bulk.ColumnMappings.Add(col.ColumnName, col.ColumnName);
}
}
else
{
foreach (var kv in columnMappings)
{
bulk.ColumnMappings.Add(kv.Key, kv.Value);
}
}
// 注册取消回调:取消时立即中止 bulk 操作
using var registration = cancellationToken.Register(() =>
{
// 取消令牌触发时,强制关闭以中断当前 bulk load
try { bulk.Close(); } catch { /* 忽略关闭异常 */ }
});
// 执行写入
bulk.WriteToServer(source);
}
}调用示例:
// 构造源数据
var dt = new DataTable();
dt.Columns.Add("UserName", typeof(string));
dt.Columns.Add("Age", typeof(int));
for (int i = 0; i < 100_000; i++)
{
dt.Rows.Add($"user_{i}", 18 + (i % 50));
}
// 使用 CancellationTokenSource 支持外部取消
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
using var conn = new SqlConnection("Server=.;Database=Demo;Trusted_Connection=True;TrustServerCertificate=True;");
await conn.OpenAsync();
var mappings = new Dictionary<string, string>
{
{ "UserName", "UserName" },
{ "Age", "Age" }
};
BulkCopyHelper.BulkInsert(
conn,
tran: null,
destinationTable: "T_User",
source: dt,
columnMappings: mappings,
batchSize: 10000,
timeout: 120,
cancellationToken: cts.Token);
Console.WriteLine("批量写入完成");性能对比数据
下面是一组实测参考(SQL Server 2019、本地环境、单表 3 列、普通网卡):
| 数据量 | 循环INSERT | 事务批量INSERT | SqlBulkCopy |
|---|---|---|---|
| 1万 | 3.2s | 0.4s | 0.05s |
| 10万 | 38s | 5.1s | 0.4s |
| 100万 | >6min | 52s | 3.8s |
可以看到,数据量越大,SqlBulkCopy 的优势越明显。对于百万级导入,它是唯一能压在秒级的方案。
使用注意事项
1. 列映射
源 DataTable 的列顺序、数量、类型不必和目标表完全一致,但必须通过 ColumnMappings 显式指定对应关系。如果不指定,会按列名匹配,列名不一致就会抛 InvalidOperationException。建议始终显式映射,避免埋坑。
2. 事务
SqlBulkCopy 默认在一个内部事务里执行。如果需要和其它 SQL 操作(比如先删后导)放在同一事务里,要在外部 BeginTransaction 后把 SqlTransaction 传给 SqlBulkCopy 构造函数。注意:一旦用了外部事务,BatchSize 只是分批发送,整个事务仍是原子的。
3. 超时
BulkCopyTimeout 是整体超时(秒),不是每批超时。如果数据量很大,记得调大这个值,否则中途超时会前功尽弃。另外,连接字符串里的 Connect Timeout 是连接超时,两者不要混淆。
4. 其它小贴士
BatchSize建议设 5000~10000,太小没性能优势,太大内存压力大。- 如果目标表有自增列且要保留源值,用
SqlBulkCopyOptions.KeepIdentity。 - 如果要禁用触发器或约束检查,用
SqlBulkCopyOptions.CheckConstraints反向选项配合SqlBulkCopyOptions.FireTriggers控制。 - 写入前临时禁用索引、写入后再重建,能进一步提速。
小结
在 ADO.NET 的批量新增方案里,SqlBulkCopy 凭借 TDS 层的 Bulk Load 机制,性能一骑绝尘,是大数据量导入的首选。配合列映射、外部事务和取消令牌,完全可以满足生产环境的需求。下次再遇到十万、百万级数据写入,别再写循环 INSERT 了,试试 SqlBulkCopy 吧。