在C#访问SQL Server或其他关系型数据库时,开发者常遇到这样的场景:需要根据上千个订单号查询明细,或同时校验几组条件。如果采用循环里反复打开连接、执行单条命令的做法,每一次调用都会产生一次网络往返。数据库往返不仅包括SQL执行时间,还涵盖TCP传输、命令编译、结果序列化等固定成本,当次数达到几百上千次时,这些成本会远超实际查询本身。要避免多次往返,核心思路是把多个逻辑查询合并到一次通信中完成。

使用表值参数一次性传入查询条件
SQL Server支持表值参数(Table-Valued Parameter,TVP),允许C#端将一个DataTable或 IEnumerable<SqlDataRecord> 作为一个参数整体传给存储过程。服务端再用该临时表与业务表做连接查询,从而实现“一次往返、批量条件”的效果。这种方式既保持了参数化查询防止注入的优点,又避免了拼接SQL带来的语法风险。
下面示例定义一个只读TVP类型,并在C#中用ADO.NET构造参数。注意 SqlDbType.Structured 是绑定TVP的关键,且列结构必须与数据库类型一致,否则会在执行时抛出类型不匹配异常。
using System.Data;
using System.Data.SqlClient;
var dt = new DataTable();
dt.Columns.Add("Id", typeof(int));
for (int i = 1; i <= 500; i++)
{
dt.Rows.Add(i);
}
using var conn = new SqlConnection("Server=.;Database=Test;Integrated Security=true");
using var cmd = new SqlCommand("SELECT o.* FROM Orders o JOIN @Ids t ON o.Id = t.Id", conn);
var p = cmd.Parameters.AddWithValue("@Ids", dt);
p.SqlDbType = SqlDbType.Structured;
p.TypeName = "dbo.IdList"; // 数据库中已定义的TVP类型
conn.Open();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
// 处理批量结果
}
相比循环查询,TVP方案将网络往返压缩为一次,且执行计划可稳定缓存。缺点是需要在数据库中预先创建TVP类型,并且对于极大量数据(数万行以上)可能占用较多临时内存,此时可配合分页或分批TVP来平衡。
在单命令中拼接多条查询并用DataSet接收
如果不想改动数据库 schema 定义TVP,也可以在一个 SqlCommand 的 CommandText 中写多条 SELECT,用分号隔开,然后通过 SqlDataAdapter 填充 DataSet,每张表对应一个结果集。这种方法在一次往返里拿回多个独立结果,适合同时查不同维度的数据。
需要注意,拼接SQL绝不能把用户输入直接串入文本,而应使用参数并在语句中多次引用同一参数,或使用临时表先写入再查询。下面示例展示安全的一次性多查询写法,所有变量均参数化,避免注入。
using System.Data;
using System.Data.SqlClient;
using var conn = new SqlConnection("Server=.;Database=Test;Integrated Security=true");
using var cmd = new SqlCommand(@"
SELECT * FROM Users WHERE DeptId = @Dept;
SELECT * FROM Logs WHERE DeptId = @Dept;", conn);
cmd.Parameters.AddWithValue("@Dept", 10);
using var da = new SqlDataAdapter(cmd);
var ds = new DataSet();
da.Fill(ds);
// ds.Tables[0] 为用户,ds.Tables[1] 为日志
这种写法在网络层只发生一次 Execute,但服务端仍会编译两条语句。若查询条件来自一个ID集合,可以把ID先写入临时表(同连接内 INSERT 一次),再让多条查询都 JOIN 该临时表,从而减少单命令文本长度。不过临时表方案要求使用同一连接且不被连接池重置,需手动控制连接生命周期。
借助Dapper的批量查询与多映射能力
Dapper作为轻量ORM,提供了 Query 与 QueryMultiple 方法。其中 QueryMultiple 能在一次往返中执行多个查询并分别读取。对于按ID集合批量取数,更常见的做法是把ID数组作为参数传给 WHERE Id IN @Ids,Dapper会自动展开为参数列表,底层仍是一条语句、一次往返。
以下代码演示用Dapper做IN查询批量返回,以及用 QueryMultiple 同时取主表和明细。Dapper在处理 IEnumerable 参数时会生成如 @Ids1,@Ids2... 的占位符,保持参数化,不会拼接字符串。
using Dapper;
using System.Data.SqlClient;
using System.Linq;
using var conn = new SqlConnection("Server=.;Database=Test;Integrated Security=true");
var ids = Enumerable.Range(1, 1000).ToArray();
var orders = conn.Query("SELECT * FROM Orders WHERE Id IN @Ids", new { Ids = ids });
using var multi = conn.QueryMultiple(@"
SELECT * FROM Customers WHERE Region=@R;
SELECT * FROM Orders WHERE Region=@R;", new { R = "East" });
var custs = multi.Read<Customer>();
var ords = multi.Read<Order>();
在真实项目中,若ID数量极大,IN子句展开会导致语句过长,可改回TVP或分批次查询(每批一千左右)。Dapper本身不隐藏往返次数,开发者要明确每次方法调用对应的网络行为。配合 Async 版本还能在等待数据库时释放线程,但异步并不会减少往返次数,只是提升吞吐。
事务与连接复用对往返的影响
不少开发者误以为把循环查询包进一个 TransactionScope 就能减少往返,其实事务只保证原子性,不改变每条命令独立发送的事实。真正减少往返的是连接复用配合批量命令:同一连接上顺序发多条命令仍会多次往返,而合并命令或TVP才是根本。
另外,启用连接池后,物理连接虽可复用,但逻辑上的命令往返依旧计入延迟。建议在数据访问层封装“批量查询接口”,内部根据条件数量自动选择TVP或IN参数,对上层屏蔽优化细节。同时监控 SqlConnection 的耗时命令,定位隐藏在循环中的单条查询,是避免多次往返的运维手段。