必须配合orderby排序,否则结果不稳定;skip需在take前且偏移量为(page-1)*pagesize;总数与分页应复用同一iqueryable,避免内存分页或深分页性能问题。

直接用 Skip() 和 Take() 做分页,不加校验、不配排序、不看执行上下文,90% 的线上分页接口会在第 3 页之后开始丢数据或重复返回。
OrderBy 必须写在 Skip 前面,且字段要稳定
数据库不保证无序结果的行序,EF Core 生成的 SQL 若没 ORDER BY,翻页时同一条记录可能出现在两页里,也可能彻底消失。这不是偶发 bug,是 SQL 标准行为。
-
OrderBy(x => x.Id)安全,OrderBy(x => x.CreatedAt)要小心——时间精度相同会导致排序不稳定,建议补上ThenBy(x => x.Id) - 别用
OrderBy(x => Guid.NewGuid())或OrderBy(x => DateTime.Now),这些会让每次查询顺序都变 - 前端传来的排序字段(如
"name")需白名单校验,且对应列必须有数据库索引,否则Skip越大越慢 - SQLite 允许无序
Skip/Take,但结果不可靠;SQL Server 直接报错
Skip 参数计算必须防溢出和越界
(page - 1) * pageSize 看似简单,但 int 溢出、负数、超大数据量都会让查询静默失败。
- page ≤ 0 或 pageSize ≤ 0 时,
Skip(-1)会抛ArgumentOutOfRangeException,必须提前拦截 - 当
(page - 1) * pageSize超过int.MaxValue(约 21 亿),计算结果翻转为负数,查询崩掉 - 总数仅 200 条,却请求第 1000 页:SQL 照常执行 OFFSET 9990 行,返回空集合——前端无法区分“真没数据”还是“页码非法”
- 建议先查总数:
var total = await context.Users.CountAsync(),再判断if ((page - 1) * pageSize >= total) return Empty();
IQueryable 分页 ≠ 内存分页,链路断在哪,执行就在哪
只要中间调一次 ToList()、AsEnumerable() 或 ToArray(),整个表就先加载进内存,Skip/Take 只是对 List 操作——大数据量下 OOM 风险极高。
- ✅ 正确:
context.Users.OrderBy(u => u.Id).Skip(20).Take(10).ToListAsync()→ 生成OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY - ❌ 错误:
context.Users.ToList().OrderBy(u => u.Id).Skip(20).Take(10)→ 全表拉入内存再裁剪 - ⚠️ 隐形陷阱:用了
GroupBy、Select投影到匿名类、或访问未映射属性,EF Core 可能触发客户端评估(Client Evaluation),日志里会警告 “The LINQ expression could not be translated” - EF Core 5+ 支持原生分页语法,但低版本对复杂查询会降级为子查询,性能略差,仍属数据库执行
OFFSET 超过 10 万行后性能陡降,得换策略
不是代码问题,是数据库原理:SQL Server 的 OFFSET 必须扫描并跳过前 N 行,N 越大,IO 和 CPU 开销越线性增长。
- 当
(page - 1) * pageSize > 100000,响应延迟明显上升,DBA 会收到性能告警 - 游标分页(cursor-based pagination)是解法:用上一页最后一条的
Id做条件,例如WHERE Id > @lastId ORDER BY Id LIMIT 10 - 游标分页不能跳页(比如直接点第 50 页),但适合无限滚动场景;若业务强依赖跳页,可用近似总数(
COUNT(*) WITH (NOLOCK))+ 缓存缓解 - 别指望靠加索引完全解决 OFFSET 性能问题——索引加速的是 WHERE,不是跳过动作本身
最易被忽略的一点:分页逻辑里混用 IQueryable 和 IEnumerable 类型,编译器不报错,运行时才暴露问题;而 OrderBy 缺失导致的数据错乱,往往要等用户投诉“怎么又看到刚删掉的记录”才被发现。










