EF Core 分页不能只靠 Skip + Take,因数据库需扫描前 N 行导致性能骤降;应优先用游标分页、避免 COUNT(*)、确保排序字段有覆盖索引并禁用客户端求值。

EF Core 分页不能只靠 Skip + Take,否则查大表会越来越慢,甚至超时。
为什么 Skip + Take 在大数据量下很危险
SQL Server 或 PostgreSQL 执行 OFFSET 10000 ROWS FETCH NEXT 20 ROWS ONLY 时,数据库仍需扫描前 10000 行;MySQL 8.0+ 虽有优化,但 LIMIT 10000, 20 同样走全索引扫描。数据量过万后,响应时间从毫秒级跳到秒级,用户刷新一次页面卡住几秒是常态。
常见错误现象:TaskCanceledException、HTTP 504、SQL 查询执行计划里出现“Index Scan”而非“Index Seek”。
- 仅适用于总记录数
- 必须配合
OrderBy(否则 EF Core 会报错) - 无法跳转到指定页码(比如直接点“第 200 页”),因为要先算出前 199 × pagesize 条
用游标分页(Cursor-based Pagination)替代页码分页
核心思路:不依赖“第几页”,而是记住“上一页最后一条的排序字段值”,下一页查询就从这个值之后开始取。适合按时间、ID 等单调递增字段排序的场景(如订单列表、日志流)。
实操建议:
- 排序字段必须有索引,且**不允许为空**(
NOT NULL) - 推荐组合主键或唯一索引(如
(CreatedAt DESC, Id DESC)),避免同值冲突 - 查询语句形如:
WHERE CreatedAt - EF Core 中不用
Skip,改用Where+ 参数化条件,再Take(21)(多取 1 条用于判断是否有下一页)
示例(获取下一页订单):
var nextOrders = await context.Orders
.Where(o => o.CreatedAt < lastCreatedAt || (o.CreatedAt == lastCreatedAt && o.Id < lastId))
.OrderByDescending(o => o.CreatedAt)
.ThenByDescending(o => o.Id)
.Take(21)
.ToListAsync();需要显示总页数?先评估是否真有必要
前端显示“共 1234 页”看似友好,实则代价高昂:每次分页都得执行 COUNT(*),在高并发下成为数据库瓶颈。很多产品已转向“无限滚动”或“加载更多”按钮,彻底规避总数计算。
如果业务强依赖总数(如导出页数预估、权限校验),可考虑:
- 缓存总数:用 Redis 存
orders:count,写操作时异步更新(注意最终一致性) - 估算总数:对大表用
SELECT reltuples::BIGINT AS estimate FROM pg_class WHERE relname='orders'(PostgreSQL) - 分页接口返回
hasNext布尔值,而不是totalPages—— 用户真正需要的是“还能不能往下翻”,不是精确页码
排序字段含中文或复杂表达式怎么办
EF Core 对 OrderBy 中的字符串比较默认使用数据库 collation,但若涉及 .ToUpper()、.Substring() 或自定义函数,可能触发客户端求值(Client Evaluation),导致整张表拉到内存排序 —— 这比 Skip 更糟。
排查方法:启用 EF Core 日志,看生成的 SQL 是否含 ORDER BY;若日志里出现 Microsoft.EntityFrameworkCore.Query: Warning: The LINQ expression could not be translated,说明已降级为客户端排序。
- 避免在
OrderBy里调用 C# 方法,改用EF.Functions.Collate(...)或数据库原生函数 - 中文模糊搜索排序,优先建好带
COLLATE Chinese_PRC_CI_AS的计算列索引 - 确实需要复杂排序逻辑时,提前在视图或 CTE 里固化排序依据字段,让 EF 只查视图
游标分页不是银弹——它要求排序稳定、字段可比较、前端能维护游标状态。而页码分页的“慢”,往往暴露的是缺少覆盖索引、未分离读写、或根本没做查询性能压测。别急着套分页模板,先 EXPLAIN 你的 SQL。


















