☰
C# SqlSugar 批量查询优化:N+1、IN 分批与游标实践
2026/11/5 4:55:50 网站建设 项目流程

做过订单、库存这类后台系统的人大概都遇过一个画面:接口在本地跑得飞快,一上预发或线上,同一个列表页从 80ms 涨到 1.5 秒,日志里刷出几十上百条几乎一样的 SELECT 语句。点开一看,全是WHERE Id = @Id这种单行查询,一条条排队执行。这就是典型的 N+1 查询问题,而解决它的核心手段就是 C# SqlSugar 批量查询——把几十次数据库往返压成一两次。我做过几个从零到一的业务系统,每次性能优化里性价比最高的动作,基本都是把循环单查改成批量查询。这篇内容会围绕 SqlSugar 的批量查询展开,从 SQL 翻译原理、IN 列表的硬限制、分批切片策略、结果字典化、分页与游标拉取、动态条件拼装、多表联查取舍,一直讲到我自己踩过的几个坑和实测数据。适合已经会用 SqlSugar 基本 CRUD、但列表接口一到线上就卡的同学,也适合想搞清楚 ORM 到底在背后干了什么的人。

1. 批量查询的起点:把 N+1 次往返压成 1 次

1.1 一个订单列表页的耗时分布

先说个具体场景。一个订单列表页,一页 20 条记录,每条要显示下单客户的名字和商品明细条数。新手写法通常是:先db.Queryable<Order>().ToPageList(1, 20)拿到订单,然后foreach里查客户、查明细,总共 41 次数据库交互。本地开发环境数据库同机部署,41 次查询加起来 30ms,看起来毫无问题。

上线之后链路变了:应用和数据库隔着网络,一次往返的 RTT 大概 2ms,41 次就是 82ms 的纯网络等待。再加上每次查询都要从连接池借一个连接、归还一个连接、数据库端做 SQL 解析、执行计划缓存匹配、结果集序列化和传输,这些开销单次可能只有 0.5ms,乘上 41 就变成了 20ms 以上。更麻烦的是并发:50 个用户同时刷这个页面,瞬间就是 2050 次数据库交互,连接池默认 100 个连接很快被占满,后面的请求开始排队,排队时间又被算进接口耗时,雪崩往往就是这么开始的。

这里的关键认知是:数据库交互的成本大头不在"查询本身有多快",而在"来回多少次"。一次查询哪怕只返回一行数据,它的固定开销也远高于想象。所以批量查询的第一目标不是"让单条 SQL 更快",而是"让 SQL 条数变少"。

换个写法只需要三行:

var page = db.Queryable<Order>().ToPageList(pageIndex, pageSize, ref total); var orderIds = page.Select(o => o.Id).ToList(); var details = db.Queryable<OrderItem>().In(it => it.OrderId, orderIds).ToList();

SQL 从 41 条变成 3 条(分页、总数、明细批量查),如果客户也要查,就是 4 条。实测下来这个页面从 400ms 左右降到 60ms,而且并发时的连接占用下降了十倍不止。这个改动量很小,收益却极大,所以我说它性价比最高。

1.2 Contains 到底被翻译成了什么

很多人写批量查询是靠ids.Contains(it.Id)这个直觉写法,但说不清它背后生成了什么 SQL。SqlSugar 的表达式解析器会把Contains识别出来,翻译成 SQL 的IN子句,并且用参数化的方式传值,而不是拼接字符串。你可以写一段代码验证:

var ids = new List<int> { 1, 2, 3 }; var list = db.Queryable<Order>() .Where(it => ids.Contains(it.Id)) .ToList();

打开 AOP 日志看实际 SQL:

db.Aop.OnLogExecuting = (sql, pars) => { Console.WriteLine(sql); foreach (var p in pars) Console.WriteLine($" {p.ParameterName} = {p.Value}"); };

你会看到类似这样的输出:

SELECT [Id],[OrderNo],[CustomerId],[Amount] FROM [Order] WHERE [Id] IN (@Id0,@Id1,@Id2) @Id0 = 1 @Id1 = 2 @Id2 = 3

参数化的意义有两点。第一是执行计划复用:如果拼成IN (1,2,3)这样的字面量 SQL,每个不同的 ID 组合都是一条全新的 SQL,数据库要重新解析、重新生成执行计划,执行计划缓存也会被这些一次性 SQL 撑爆。参数化之后,不管 ID 是多少,SQL 文本都一样,执行计划只生成一次。第二是防注入,这个不用多说。

SqlSugar 另外提供了.In()方法,效果等价但语义更明确:

var list = db.Queryable<Order>().In(it => it.Id, ids).ToList();

我倾向于用.In(),原因是它对空集合的处理更符合预期——传入空 List 时直接返回空结果集,不会生成一条语义含糊的 SQL。而用Where(it => ids.Contains(it.Id))时,如果ids是空列表,不同版本的表现不完全一致,有可能生成WHERE 1=0,也有可能把整个条件忽略掉变成全表扫描。生产环境上全表扫描是个很危险的事情,所以我习惯在批量查询前先做一次空判断:

if (orderIds == null || orderIds.Count == 0) return new List<OrderItem>();

多写这两行,能省掉很多排查时间。

2. IN 列表的硬天花板:分批切片的算法与实现

2.1 各数据库对 IN 元素个数的限制

一次性把 8000 个 ID 塞进IN里,在开发环境可能还能跑,一上生产就报错。因为不同数据库对 IN 列表长度和 SQL 参数总数有各自的硬限制,这个限制不是 ORM 层能绕过去的,只能在应用层分批。

数据库类型IN 列表元素上限单条 SQL 参数总数上限超限后的表现
SQL Server无独立限制2100报"传入的请求具有过多的参数"
MySQL受包大小限制65535 个占位符包超限或占位符超限
PostgreSQL无硬限制65535参数超限报错
某款常见商业数据库100065535IN 列表超 1000 直接语法报错
SQLite默认 999(可编译调整)999变量过多报错

看这张表能得出一个结论:分批大小不能拍脑袋定,要按最严格的数据库来。如果你的系统要同时兼容多种数据库,那最安全的分批大小是 500 到 1000。哪怕只用 SQL Server,我也建议按 2000 而不是 2100 来切,因为一条 SQL 里除了 IN 的参数,可能还有分页参数、时间范围参数、其他的等值条件参数,这些都要占配额。留 100 个余量,比上线后半夜被报警叫醒划算得多。

还有一个容易忽略的点:参数上限统计的是整条 SQL 的参数总数,不是 IN 里的元素个数。如果你的查询里还有it.CreateTime >= @t1 AND it.Status = @s1,那这两个参数也要算进去。复杂的多表关联查询参数更多,分批大小要相应调小。

2.2 写一个能复用的分批工具方法

分批逻辑本身很简单,但值得封装成一个可复用的方法,因为项目里到处都是。我在几个项目里用的都是同一个写法:

public static class BatchHelper { public static IEnumerable<List<T>> Split<T>(IEnumerable<T> source, int batchSize) { if (source == null || batchSize <= 0) yield break; var buffer = new List<T>(batchSize); foreach (var item in source) { buffer.Add(item); if (buffer.Count >= batchSize) { yield return buffer; buffer = new List<T>(batchSize); } } if (buffer.Count > 0) yield return buffer; } }

为什么用yield return而不是先ToList()再切?两个原因。第一是内存:如果上游传来的是一个流式集合(比如从文件读出来的 ID),yield可以做到边读边切边查,内存峰值只有一个批次的大小;先ToList()会把所有数据一次性加载到内存。第二是响应速度:yield的写法下,第一批切出来就可以立刻去查数据库,不用等全部切完。对于几万个 ID 的场景,这点差异很实在。

不过如果你的项目跑在 .NET 6 及以上,直接用内置的Enumerable.Chunk就行,语义完全一样:

foreach (var batch in orderIds.Chunk(1000)) { var items = db.Queryable<OrderItem>().In(it => it.OrderId, batch).ToList(); allItems.AddRange(items); }

Chunk返回的是IEnumerable<T[]>,注意它是数组不是 List,传给 SqlSugar 的In方法没问题,但如果要再Add就需要转一下。

分批大小怎么定?我给的经验值是:只查一个 IN 条件、表不宽的时候用 1000;SQL 里还有其它参数、或者表很宽(列多)的时候用 500。表宽的时候每行数据大,1000 行一次拉回来可能几 MB,网络传输和反序列化的开销反而上去了,分小一点更均衡。这个值不是绝对的,压测一下看曲线拐点最靠谱。

2.3 分批之后结果合并与顺序还原

分批查完之后把结果合并,是最容易出错的地方。AddRange合并当然没问题,但顺序会丢。数据库返回的顺序是按它自己的执行计划来的,跟你传进去的 ID 顺序没有必然关系。如果你的业务要求"按传入 ID 的顺序展示",就必须在内存里重建顺序:

var dict = allItems.ToDictionary(x => x.Id); var ordered = ids.Where(id => dict.ContainsKey(id)) .Select(id => dict[id]) .ToList();

这段代码有个巧妙的副产品:ids里如果有重复的 ID,Where会按重复次数输出多次,正好符合"输入什么顺序输出什么顺序"的语义。另外,如果某个 ID 在数据库里查不到,ContainsKey会自动过滤掉,不会抛异常。

如果业务需要的是按业务字段排序(比如按创建时间倒序),那就别在内存里排,直接在每一批的查询里加OrderBy,合并后再排一次全局的。听起来排出两次有点浪费,但数据量不大时完全可以接受,比起把排序逻辑分散到各个地方,全局再排一次更不容易出错。

还有一种情况是分页展示的批量查询:比如你要展示"最近 30 天订单的明细",明细量可能上万条。这时候分批查完之后,需要在内存里做一次分页切片:

var paged = allItems.OrderBy(x => x.Id).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();

这种"先全量拉再内存分页"的做法只适合总量可控的场景(比如确定不超过几千条),如果总量不可控,还是老老实实把分页下推到数据库,用 join 或者子查询解决。

3. 批量拿到手了,怎么在内存里找:字典化的艺术

3.1 ToDictionary 的 key 重复问题

批量查询只解决了一半问题。数据拿回来之后,你还要把它们跟主列表对应起来。最常见的错误写法是在循环里FirstOrDefault:

foreach (var order in page) { var customer = allCustomers.FirstOrDefault(c => c.Id == order.CustomerId); }

FirstOrDefault是线性扫描,20 条订单乘 20 个客户就是 400 次比较,数据量再大一点就是 O(n*m) 的复杂度。正确的做法是先建索引,把线性查找变成哈希查找,复杂度降到 O(n+m):

var customerMap = allCustomers.ToDictionary(c => c.Id); foreach (var order in page) { if (customerMap.TryGetValue(order.CustomerId, out var customer)) { order.CustomerName = customer.Name; } }

SqlSugar 的 Queryable 直接提供了ToDictionary扩展,可以省掉一次内存遍历:

var customerMap = db.Queryable<Customer>() .In(it => it.Id, customerIds) .ToDictionary(it => it.Id, it => it.Name);

这个重载返回的是Dictionary<int, string>,只装 ID 到名称的映射,比装整个实体再取字段更省内存。如果只需要一两个字段,强烈建议用这个重载。

但ToDictionary有个致命的坑:key 重复会直接抛异常。System.ArgumentException: 已添加了具有相同键的项。什么时候会重复?最常见的是查一对多的数据,比如按商品 ID 查规格明细,一个商品有多个规格,ToDictionary(x => x.ProductId)立刻炸。还有一个隐蔽的情况是数据本身有重复(历史脏数据、软删除标记没过滤干净),这种在测试环境很难碰到,上线才暴露。

防御写法是先分组:

var specMap = allSpecs .GroupBy(x => x.ProductId) .ToDictionary(g => g.Key, g => g.First());

用First()还是Last()取决于业务语义,一般是取更新时间最新的那条,那就OrderByDescending(x => x.UpdateTime).First()。总之不能假设数据一定唯一。

3.2 ToLookup 处理一对多

如果业务本来就是一对多,比如订单下面有多个明细,那就不该用 Dictionary,而该用ToLookup。它是 LINQ 自带的,SqlSugar 查出来之后在内存里转换:

var itemLookup = allItems.ToLookup(x => x.OrderId); foreach (var order in page) { var items = itemLookup[order.Id]; order.ItemCount = items.Count(); order.TotalAmount = items.Sum(x => x.Amount); }

ToLookup和Dictionary最关键的行为差异在于:索引器访问不存在的 key 时,ToLookup 返回空集合,Dictionary 抛 KeyNotFoundException。这个差异在列表页显示场景下非常重要——某个订单没有明细是很正常的,用 Dictionary 就得写TryGetValue判断,用 ToLookup 直接foreach都不用判空。

还有一个性能上的细节:ToLookup是延迟构建的,第一次访问索引器时才真正建索引;ToDictionary是立即构建。所以在数据量很小的场景下,ToLookup 可能还更省一点。但差异很小,主要还是看语义。

顺手提一句,ILookup接口提供了Contains方法,但不要用它来判断 key 是否存在——它判断的是"这个 key 对应的集合是否非空"。如果某个 key 存在但集合为空(这在正常数据里不会出现,因为 ToLookup 是遍历源集合建的),结果会不符合预期。判断存在性直接看lookup[key].Any()更稳。

4. 大数据量批量拉取:分页与游标的选择

4.1 ToPageList 深分页为什么慢

ToPageList是 SqlSugar 里用得最多的方法,分页参数、总数一次搞定:

int total = 0; var page = db.Queryable<Order>() .Where(it => it.Status == 1) .OrderBy(it => it.Id, OrderByType.Desc) .ToPageList(pageIndex, pageSize, ref total);

它生成的 SQL 在 SQL Server 上大概是OFFSET (n-1)*size ROWS FETCH NEXT size ROWS ONLY的形式。这个语法看起来优雅,但深分页时性能会断崖式下跌。原因是OFFSET要求数据库先扫描并丢弃前面的所有行,然后再取后面那一段。翻到第 5000 页(每页 20 条)时,数据库要扫描 10 万行只为了返回 20 行。

我实测过一个 100 万行的订单表:第 1 页耗时 8ms,第 100 页 25ms,第 5000 页 1.2 秒,第 20000 页直接 5 秒往上。而如果查询条件没有合适的索引,这个数字还要翻几倍。

所以ToPageList的适用边界很明确:面向用户的交互式分页,页数不会太深。后台管理系统的列表页一般翻不到几百页,用ToPageList完全没问题,简单可靠。但如果是数据导出、定时任务全量扫描这种场景,就绝对不能用ToPageList翻页,必须换游标。

4.2 主键游标滚动的实现

游标分页的核心思路是"记住上一批的最后一个 ID,下一批从它之后开始",这样每一批都是范围查询,走主键索引,不管翻到多深耗时都一样:

long lastId = 0; const int pageSize = 2000; var allData = new List<Order>(); while (true) { var batch = db.Queryable<Order>() .Where(it => it.Id > lastId) .OrderBy(it => it.Id) .Take(pageSize) .ToList(); if (batch.Count == 0) break; allData.AddRange(batch); lastId = batch[^1].Id; if (batch.Count < pageSize) break; }

这段代码有几个细节值得说。

第一,Take(pageSize)之后要判断batch.Count < pageSize,如果这次返回不足一页,说明数据到底了,可以提前退出循环,省掉最后一次空查询。这个优化在数据量刚好是 pageSize 整数倍时特别有用,能少一次无谓的往返。

第二,batch[^1].Id是 C# 8 的索引语法,等价于batch[batch.Count - 1].Id,如果项目还在老框架上,换成后者。

第三,主键必须是单调递增且有序的。自增主键、雪花 ID 都满足。但如果主键是 GUID,Id > lastId这种比较没有业务意义(GUID 的排序是字节序,跟生成时间无关),游标就不适用了,得改用时间戳字段做游标,并处理同一时间戳多条记录的问题。

第四,游标分页的代价是不能跳页。用户没法从第 1 页直接跳到第 50 页,只能"下一页"或者"加载更多"。对于后台任务这无所谓,对于面向用户的前端,需要产品层面配合改成"无限滚动"的交互。

还有一个常见的批量拉取场景是分表。SqlSugar 的SplitTable支持按时间或按 ID 取模分表,批量查询时可以指定表范围:

var list = db.Queryable<Order>() .SplitTable(tabs => tabs.Where(t => t.TableName.Contains("2024"))) .Where(it => it.Id > lastId) .Take(2000) .ToList();

SplitTable的tabs参数是个表集合,可以按表名过滤。如果不指定,SqlSugar 默认会路由到所有分表,然后Union All起来查,这个代价非常高。所以分表场景下的批量查询一定要显式指定表范围,能用时间范围定位的,就别让它全表路由。

5. 动态条件批量查:WhereIF 与 Expressionable 的正确用法

5.1 WhereIF 的短路行为

后台管理系统的列表页条件往往是"可选"的:用户填了状态就按状态过滤,没填就不过滤。新手写这种逻辑的思路是拼字符串 SQL,或者用if-else写好几套查询。SqlSugar 提供了WhereIF来优雅解决:

var list = db.Queryable<Order>() .WhereIF(status.HasValue, it => it.Status == status.Value) .WhereIF(!string.IsNullOrWhiteSpace(keyword), it => it.OrderNo.Contains(keyword)) .WhereIF(startTime.HasValue, it => it.CreateTime >= startTime.Value) .WhereIF(endTime.HasValue, it => it.CreateTime <= endTime.Value) .ToPageList(pageIndex, pageSize, ref total);

它的机制很直白:第一个 bool 参数为 false 时,整个表达式既不会被解析成 SQL,也不会被求值。所以哪怕keyword是 null,it.OrderNo.Contains(keyword)也不会执行到,不会抛空引用。这一点和Where(it => status == null || it.Status == status)完全不同——后者会把@status IS NULL OR Status = @status写进 SQL,虽然结果正确,但执行计划会变差(数据库很难为这种"或"条件下推索引)。

WhereIF有个必须注意的地方:连续多个WhereIF之间是 AND 关系,不会互相干扰。但如果你在中间插了一个普通的Where,两个条件也都是 AND,这点符合直觉。真正容易出问题的是把 OR 逻辑塞进来——WhereIF不支持 OR,需要 OR 的地方必须用Expressionable。

还有一个藏在细节里的坑:it.OrderNo.Contains(keyword)翻译成 SQL 是LIKE '%kw%',左边带通配符,索引直接失效,全表扫描。如果业务允许前缀匹配,改成it.OrderNo.StartsWith(keyword),翻译成LIKE 'kw%',索引能用上,性能差出几十倍。这个在数据量大的表上是决定性的优化,值得在代码评审时专门盯一下。

5.2 Expressionable 拼装复杂条件

当条件组合里出现 OR,或者你需要把条件构建逻辑抽到一个独立方法里时,Expressionable就派上用场了:

var exp = Expressionable.Create<Order>(); exp.AndIF(status.HasValue, it => it.Status == status.Value); exp.AndIF(customerIds != null && customerIds.Count > 0, it => customerIds.Contains(it.CustomerId)); if (!string.IsNullOrWhiteSpace(keyword)) { exp.And(it => it.OrderNo.Contains(keyword) || it.Remark.Contains(keyword)); } var list = db.Queryable<Order>() .Where(exp.ToExpression()) .ToPageList(pageIndex, pageSize, ref total);

注意这里And里面套了一个||,SqlSugar 会把它整体括起来生成AND (OrderNo LIKE ... OR Remark LIKE ...),运算符优先级是正确的。这正是字符串拼 SQL 最容易出错的地方——手拼时一个括号忘了加,条件范围就全错了。

Expressionable支持And、Or、AndIF、OrIF四种方法,其中AndIF和OrIF都带条件判断。用的时候要注意第一个条件如果用了Or,生成的 SQL 开头就是 OR,会导致语法错误。SqlSugar 内部做了处理,但保险起见,第一个条件始终用And,后面的按需选。

5.3 表达式树深度与参数数量

有一个坑我印象很深。当时的需求是"查询属于这 300 个分类中任意一个的商品",同事写了一个循环拼接表达式的实现:

Expression<Func<Product, bool>> exp = p => false; foreach (var cid in categoryIds) { var id = cid; exp = exp.Or(p => p.CategoryId == id); }

300 个分类,表达式树嵌套了 300 层。第一次跑还勉强过去了,后来分类涨到 2000 个,直接抛InsufficientExecutionStackException(栈溢出)——表达式树编译时会递归遍历,嵌套太深就爆栈了。而且就算不爆栈,SqlSugar 解析这棵树的耗时也很可观,光解析就要几十毫秒。

正确的做法简单的多:直接用In。

var list = db.Queryable<Product>() .In(it => it.CategoryId, categoryIds) .ToList();

生成WHERE CategoryId IN (@p0, @p1, ...),一条 SQL 搞定,解析开销跟参数个数近乎线性,完全可以接受。需要"多选一"语义时永远优先用 IN,不要拼 OR。这个原则在 90% 的情况下都成立,只有当每个分支的条件结构不同(比如一个是等值、一个是范围)时,才需要拼表达式树,而且分支数量要控制在几十个以内。

6. 多表批量:LeftJoin 一次拿全还是分开拿

6.1 LeftJoin 的批量写法与一个经典陷阱

订单列表要显示客户名,有两种查法:先批量查订单,再按CustomerId批量查客户,内存里关联;或者直接 LeftJoin 一次查出来。数据量不大的时候前者更快(两条简单 SQL),因为 LeftJoin 在跨表数据量不对等时可能生成低效的执行计划。但当关联表的字段要用得多、关联层级也深时,一次 Join 能省掉不少应用层代码。

SqlSugar 的多表查询写法是这样的:

var list = db.Queryable<Order, Customer>((o, c) => new JoinQueryInfos( JoinType.Left, o.CustomerId == c.Id)) .Where(o => orderIds.Contains(o.Id)) .Select((o, c) => new OrderDto { Id = o.Id, OrderNo = o.OrderNo, Amount = o.Amount, CustomerName = c.Name }) .ToList();

这里有个极其经典的陷阱:LeftJoin 的右表过滤条件写错位置,会让 LeftJoin 静默退化成 InnerJoin。

假设你要查"订单及其客户,客户只算状态正常的",错误写法是:

.JoinTable<Customer>((o, c) => new JoinQueryInfos(JoinType.Left, o.CustomerId == c.Id)) .Where((o, c) => c.Status == 1) // 错在这里

生成的 SQL 是LEFT JOIN Customer c ON ... WHERE c.Status = 1。对于客户已被删除的订单,c.Status是 NULL,NULL = 1的结果是 UNKNOWN,这一行就被 WHERE 过滤掉了。最终结果等价于 InnerJoin,那些没有有效客户的订单凭空消失了。

正确做法是把右表条件放到 ON 里:

.JoinTable<Customer>((o, c) => new JoinQueryInfos( JoinType.Left, o.CustomerId == c.Id && c.Status == 1))

这样不匹配的订单行仍然保留,客户字段为 NULL,符合 LeftJoin 的语义。这个坑我第一次踩的时候排查了很久,因为 SQL 语法没错、结果数量"看起来也对"(只是少了些行),最后是对着订单总数才发现的。

6.2 Includes 的适用边界

SqlSugar 支持导航属性,用[Navigate]特性标注实体之间的关系,然后通过Includes一次性加载:

public class Order { [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] public int Id { get; set; } [Navigate(NavigateType.OneToMany, nameof(OrderItem.OrderId))] public List<OrderItem> Items { get; set; } }

查询时:

var list = db.Queryable<Order>() .Includes(o => o.Items) .Where(o => orderIds.Contains(o.Id)) .ToList();

它的执行机制值得说明白:Includes不是生成一条巨型 Join SQL,而是分两次(或多层就多次)查询——先查主表,拿到所有主键,再用IN批量查子表,最后在内存里组装。所以它本质上是"框架帮你做了批量查询 + 字典化",跟你手写两段代码的原理完全一样。

这种设计的好处是避免了 Join 产生的笛卡尔积膨胀(一个订单 10 个明细,Join 之后主表字段要重复 10 遍,网络传输量大)。坏处是 SQL 条数变多,且层级越深条数按层数增长。

我的使用边界是:一层导航用 Includes 省事,两层以上就手写批量查询。三层嵌套的 Includes 会生成 3 到 4 条 SQL,而且中间的组装逻辑黑盒化,出了问题很难定位。手写虽然代码多几行,但每一步做了什么清清楚楚,性能问题也好排查。

另外Includes需要实体上配[Navigate]特性,如果你用的是 DTO 模型或者不打算改动实体,那还是手写。Includes还有一个隐性问题:它默认会把关联数据全字段加载,如果你的子表有 30 列而你只用 3 列,那传输量白白多出 90%。手写查询时用Select投影可以精准控制列,这一点Includes做不到。

7. 踩坑排查实录:从报错到根因的完整链路

7.1 "传入的请求具有过多的参数"

这个报错我在两个项目里都碰到过,错误信息是The incoming request has too many parameters. The server supports a maximum of 2100 parameters。第一次碰到时我的第一反应是"我哪里写了 2100 个参数?",因为代码里明明只有一个In调用和两个普通条件。

排查链路是这样的。

第一步,看堆栈定位到具体的业务方法。异常堆栈会指向某一行ToList()调用,但那一行只是"执行点",不是"原因点"。

第二步,打开 AOP 打印实际 SQL 和参数个数。这一步是关键,直接看执行的 SQL 长什么样:

db.Aop.OnLogExecuting = (sql, pars) => { logger.LogInformation("SQL 执行,参数个数={Count}\n{Sql}", pars?.Length ?? 0, sql); };

日志打出来一看,那一条 SQL 的参数个数是 8347,原因是上游的批量导入功能把整个 Excel 的所有行 ID 一次性传了进来,而我在代码里没做分批。

第三步,评估影响面。搜索同一个方法的所有调用方,发现有两个入口,一个是正常的列表页(最多十几条),一个是导入回滚接口(可能几万条)。前者没问题,后者必须修。

第四步,修复。我抽了个公共方法,把分批逻辑封进去,所有调用方都走它:

public List<Order> GetOrdersByIds(List<int> ids, int batchSize = 1000) { if (ids == null || ids.Count == 0) return new List<Order>(); if (ids.Count <= batchSize) return db.Queryable<Order>().In(it => it.Id, ids).ToList(); var result = new List<Order>(ids.Count); foreach (var batch in BatchHelper.Split(ids, batchSize)) result.AddRange(db.Queryable<Order>().In(it => it.Id, batch).ToList()); return result; }

第五步,验证。改完之后用同样的数据量重跑,SQL 条数从 1 条变成 9 条,每条参数不超过 1000,问题解决。同时我加了一个监控告警:任何 SQL 的参数个数超过 1500 就打 warning 日志,这样以后再有类似情况能提前发现。

这条经验我后来总结成一个习惯:对外暴露的批量查询接口,内部必须强制分批,无论调用方传多少。因为调用方是不可控的,今天传 10 条,明天可能就传 10 万条,你不能指望每个调用方都懂这个限制。

7.2 SQL 写得对,数据却在内存里过滤

有一类 bug 很隐蔽:接口返回的结果是正确的,但慢得离谱,而且数据库的 CPU 和 IO 都不高。这种情况十有八九是过滤发生在内存里。

典型的错误写法:

var all = db.Queryable<Order>().ToList(); // 全表加载到内存 var result = all.Where(x => ids.Contains(x.Id)) // 内存里过滤 .ToList();

或者更隐蔽的版本:

var query = db.Queryable<Order>().Where(it => it.Status == 1); var temp = query.ToList(); // 这里已经执行了 var result = temp.Where(x => ids.Contains(x.Id)).ToList();

第二种写法里,开发者以为query是延迟执行的、后面的Where会拼进 SQL,但实际上ToList()已经触发了执行,把Status == 1的所有数据全部加载进来了,后面的过滤纯属内存操作。

判断方法很简单:看 AOP 打印的 SQL 里有没有 WHERE 条件。如果没有,或者 WHERE 条件不完整,那就是内存过滤。我在排查这类问题时习惯把 AOP 日志级别调到 Debug,然后把实际 SQL 复制到数据库客户端里跑一下执行计划,一目了然。

SqlSugar 的 Queryable 是延迟执行的,Where、OrderBy、Select这些方法只是在构建表达式,只有调用ToList()、First()、Count()、Any()、ToPageList()这类终结方法时才真正发 SQL。理解这一点,就能避免大部分"性能莫名很差"的问题。

顺带说一个相关的坑:AsEnumerable()。有些场景下需要在内存里做数据库不支持的运算(比如调用自定义方法),就会用AsEnumerable()把IQueryable转成IEnumerable。但这一步之后所有的 LINQ 操作都发生在内存里,如果你在AsEnumerable()之后还写了Where,那它就不进 SQL 了。用的时候要清楚边界在哪。

7.3 SqlSugarClient 单例共用引发的诡异错乱

这个坑最折磨人,因为它不报错,只是偶尔返回错误的数据,而且测试环境几乎复现不了。

背景是这样:项目里把SqlSugarClient注册成了单例(AddSingleton),理由是"创建一次反复用,性能好"。单线程压测完全正常,一上并发就出问题——A 用户的查询返回了 B 用户的数据,或者事务提交到了别的连接上,或者偶发"连接已关闭"的异常。

根因是SqlSugarClient不是线程安全的。它内部持有一个Ado对象,Ado里持有一个数据库连接。如果多个线程共用一个实例,就会出现线程 A 正在用连接执行查询,线程 B 把同一个连接拿去做别的事,连接上的状态(当前事务、当前命令)就串了。

SqlSugar 官方推荐的线程安全用法是用SqlSugarScope,它在内部通过AsyncLocal做了上下文隔离,每个异步执行流拿到的是独立的连接,用起来跟单例一样方便:

services.AddSingleton<ISqlSugarClient>(sp => { var config = new ConnectionConfig { ConnectionString = "...", DbType = DbType.SqlServer, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute }; return new SqlSugarScope(config, db => { db.Aop.OnLogExecuting = (sql, pars) => { /* 日志 */ }; }); });

两者的差别可以对比一下:

对比项SqlSugarClientSqlSugarScope
线程安全否,必须每次 new是,可注册为单例
连接隔离无,多线程会串按异步上下文隔离
事务支持需要同一个实例同一上下文内自动共享
适用场景控制台、单线程脚本Web 应用、并发服务
创建成本低低(内部复用)

注意IsAutoCloseConnection = true这个配置,它的作用是"每次查询完自动关闭连接、归还连接池",配合SqlSugarScope使用是标准组合。如果不设这个,连接会一直被持有,连接池很快耗尽。

如果确实需要在某个场景下创建独立的实例(比如后台任务里要跑一个长事务,不想影响主流程),用db.CopyNew()而不是new SqlSugarClient(...)。CopyNew()会复制当前配置创建新实例,配置不会丢。但要记住:CopyNew()出来的实例不在原实例的事务里,如果原来已经BeginTran,新实例的写操作是独立提交的,这个差异会导致数据不一致,用之前一定要想清楚。

7.4 事务里的批量查询走了别的连接

接着上面说。有个项目里的代码是这样的:

db.Ado.BeginTran(); try { db.Insertable(order).ExecuteCommand(); var items = GetOrderItems(order.Id); // 内部用了 db.CopyNew() db.Updateable(order).ExecuteCommand(); db.Ado.CommitTran(); } catch { db.Ado.RollbackTran(); throw; }

问题在于GetOrderItems内部为了"避免影响主流程",用了db.CopyNew()创建了新实例。这个新实例拿到的是另一个连接,它看不到主事务里刚插入但未提交的order数据(取决于隔离级别,READ COMMITTED 下看不到未提交数据)。结果就是查询返回空,后续逻辑走错分支。

修复方式是把方法改成接收ISqlSugarClient参数,由调用方传入当前的db:

public List<OrderItem> GetOrderItems(ISqlSugarClient db, int orderId) { return db.Queryable<OrderItem>().Where(it => it.OrderId == orderId).ToList(); }

这样事务内的所有操作共用同一个上下文,能看到彼此未提交的修改。这是个典型的依赖注入反面教材——为了"解耦"而隐藏了连接的生命周期,反而破坏了事务语义。

8. 压测数据与选型对照

8.1 用例设计与测试环境

光讲原理不够,贴一组我自己压出来的数据。测试环境:应用和数据库同机房(RTT 约 0.5ms),订单表 100 万行,主键自增,Id上有聚集索引。查询目标是"按 5000 个 ID 批量取订单",每个 ID 都存在。

四个方案对比:A 是循环单查 5000 次;B 是一次性IN5000 个;C 是分批 1000、串行执行;D 是分批 1000、用Task.WhenAll并行执行。统计指标是总耗时(取 10 次平均)、SQL 条数、峰值内存增量。

8.2 结果对比与结论

方案SQL 条数平均耗时峰值内存增量稳定性
A 循环单查500012400ms+8MB并发时连接池打满
B 一次 IN 50001直接报错-参数超限,不可用
C 分批 1000 串行5182ms+12MB稳定
D 分批 1000 并行596ms+35MB需控制并发度,否则连接池吃紧

几个结论值得展开说。

方案 A 的耗时里,绝大部分不是查询成本,而是往返成本。12400ms 除以 5000 是 2.48ms,这个数字跟单次查询的 SQL 执行时间(不到 0.1ms)差了一个数量级,多出来的全是连接借还、网络往返和协议开销。这也解释了为什么本地测试完全感知不到——本地往返成本几乎为零。

方案 B 在生产环境根本跑不起来,但它在开发环境能跑通,这个差异非常危险。开发库和测试库的参数上限配置可能不同,或者数据量小的时候数据库客户端做了特殊处理。所以批量相关的代码,必须用接近生产的数据量来验证。

方案 C 是推荐方案,182ms 处理 5000 个 ID,平均每个 ID 0.036ms,比方案 A 快 68 倍。5 条 SQL 对数据库的压力也很小。这个方案还有个隐藏优势:分批可以配合流式处理,如果你不需要把 5000 条全放内存,可以每批查完就处理掉,内存占用恒定在一个批次。

方案 D 快了一倍,但代价是内存翻了三倍、连接占用翻倍。并行度必须控制,Task.WhenAll一次性启动 5 个任务,每个任务占一个连接。如果分批数量是 50 批(5 万个 ID),并行跑就是 50 个连接同时占用,很容易把连接池耗光,影响其他接口。正确的做法是加一个并行度限制:

var batches = orderIds.Chunk(1000); var result = new List<Order>(); await Parallel.ForEachAsync(batches, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (batch, ct) => { var part = await db.Queryable<Order>().In(it => it.Id, batch).ToListAsync(); lock (result) { result.AddRange(part); } });

MaxDegreeOfParallelism设成 4 到 8 之间比较安全,具体看连接池配置。并行方案只推荐用在离线任务、导出、报表这类不跟用户请求抢资源的地方,在线接口还是串行分批更稳。

最后还有一个不写在表格里但性价比极高的优化:用 Select 投影只取需要的列。上面所有测试我用的都是全字段查询,那个订单表有 32 列。如果改成只取Id、OrderNo、Amount三列,同样是方案 C,耗时从 182ms 降到 71ms,内存增量从 12MB 降到 4MB。原因是网络传输量少了八成多,反序列化的对象也小得多。这个优化几乎零成本,但对宽表的意义巨大——我见过一个 60 多列的表,投影之后查询提速 4 倍。

var list = db.Queryable<Order>() .In(it => it.Id, batch) .Select(it => new OrderSummaryDto { Id = it.Id, OrderNo = it.OrderNo, Amount = it.Amount }) .ToList();

我个人在实际项目里的做法是,所有列表类查询默认加Select投影,只在详情页才查全字段。这个习惯一旦养成,性能问题会少一大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询