EF Core 的 Skip()/Take() 会翻译为数据库原生分页语法(如 OFFSET-FETCH、LIMIT-OFFSET),前提是保持 IQueryable 链路不提前执行;否则将导致全量加载内存分页,丧失性能优势。

用 EF Core 的 Skip() 和 Take() 就够了,但必须确保它在数据库端执行,而不是内存里拉全量再切片。
EF Core 中 Skip/Take 是怎么翻译成 SQL 的
EF Core 会根据目标数据库自动适配分页语法:OFFSET-FETCH(SQL Server)、LIMIT-OFFSET(PostgreSQL)、LIMIT(MySQL)。你写 query.Skip(20).Take(10),它不会先查出全部数据再跳行——前提是整个链路保持 IQueryable<t></t>,没提前触发执行。
- ✅ 正确:在
ToListAsync()前调用Skip()/Take(),且前面没出现AsEnumerable()、ToList()、ToArray() - ❌ 错误:写成
context.Users.Where(...).ToList().Skip(20).Take(10)—— 这会把整张表或全量 WHERE 结果加载进内存,分页失去意义 - ⚠️ 注意:如果查询中用了
GroupBy或复杂投影(比如匿名类型 + 导航属性),EF 可能无法翻译,退化为客户端评估(Client Evaluation),日志里会警告 “The LINQ expression could not be translated”
为什么 OFFSET 越大越慢,以及怎么绕过它
SQL Server 的 OFFSET @skip ROWS 本质是扫描并跳过前 @skip 行,@skip 超过 10 万后,IO 和 CPU 开销陡增。这不是 C# 层能优化的,但你可以感知并切换策略。
- 当
(page - 1) * pageSize > 100000时,建议改用游标分页(cursor-based pagination):用上一页最后一条记录的Id或CreatedAt作为下一页起点,例如WHERE Id > @lastId ORDER BY Id LIMIT @take - 游标分页不能跳页(比如直接点第 50 页),但滚动加载、无限下拉场景更稳更快
- 若业务强依赖跳页,可对总数做近似估算(如
COUNT(*) WITH (NOLOCK))+ 缓存,避免每次翻页都 COUNT 大表
参数校验和类型溢出必须手动处理
前端传来的 page 和 pageSize 不能直接算 skip = (page - 1) * pageSize,否则 int 溢出或负数会崩掉查询。
- 限制范围:
page≥ 1 且 ≤ 1000,pageSize≥ 1 且 ≤ 100(业务允许的话设 20 或 50 更安全) - 用
long计算 skip:var skip = (long)(page - 1) * pageSize,防止int.MaxValue溢出(比如 page=100000, size=1000 → 99999000000) - EF Core 的
Skip()接收int,所以最终要检查skip ,超了就返回空或报错 - 别信 URL 参数:用户改
?page=9999999,后端不拦,数据库可能卡死或被拖垮
总数统计和分页结果不能共用一个查询
CountAsync() 和分页主查询逻辑独立,EF Core 不支持“一条 SQL 同时返回分页数据 + 总数”。强行 JOIN 或子查询反而更慢,尤其带复杂 WHERE 或多表关联时。
- 单独走
CountAsync(),但加WITH (NOLOCK)提速(SQL Server)或缓存总数(如 5 分钟 TTL) - 避免在事务里同时跑 COUNT 和分页主查——锁竞争高发区
- 返回结构里至少带
TotalCount、PageNumber、PageSize,前端分页控件靠这些渲染页码栏 - 实体映射要严格匹配字段名和顺序,否则
FromSqlRaw分页结果可能部分属性为 null
真正麻烦的从来不是写那两行 Skip/Take,而是校验边界、防溢出、躲开深度分页陷阱、还有总数和主查的性能撕扯——这些地方一松懈,上线后第一个大流量就教你做人。











