分页是几乎所有业务系统都会遇到的需求:订单列表、用户管理、日志查询,只要数据量超过一页能展示的范围,就需要分页。很多初学者以为分页只是前端展示层面的事,实际上真正关键的分页逻辑发生在数据库层。如果在C#中先查询全部数据再用Skip和Take在内存中截取,数据量一大程序就会卡顿甚至崩溃。本文将从原理到实践,系统讲解数据库分页的实现方式与性能优化。

数据库分页的基本原理
所谓数据库分页,就是客户端每次只请求第N页、每页M条记录,数据库只返回这M条数据,而不是整张表。举例来说,一张有百万条记录的订单表,用户查看第1页时只需返回前20条,数据库端完成筛选和截取,网络传输和内存占用都大幅降低。
在SQL Server 2012及以上版本中,标准写法是使用OFFSET ... FETCH子句,它必须配合ORDER BY使用:
SELECT Id, OrderNo, Amount, CreatedAt FROM Orders ORDER BY CreatedAt DESC, Id DESC OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;
其中OFFSET表示跳过的行数,FETCH NEXT表示取多少行。翻到第10页时,OFFSET就是9乘以20等于180。在旧版SQL Server或MySQL中,常用ROW_NUMBER()窗口函数或LIMIT offset, size实现同样的效果。无论哪种写法,都必须有明确且稳定的排序字段,否则同一页在两次查询中可能出现重复或遗漏记录。
在C#中使用LINQ与EF Core实现分页
在Entity Framework Core中,分页写法非常直观:Skip()负责跳过指定数量的记录,Take()负责取指定数量,两者组合后EF Core会自动翻译成数据库端的分页SQL,而不会把整表加载到内存。
public class PagedResult<T>
{
public List<T> Items { get; set; }
public int TotalCount { get; set; }
public int PageIndex { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
}
public async Task<PagedResult<Order>> GetOrdersAsync(int pageIndex, int pageSize)
{
if (pageIndex < 1) pageIndex = 1;
if (pageSize < 1 || pageSize > 100) pageSize = 20;
var query = _dbContext.Orders
.OrderByDescending(o => o.CreatedAt)
.ThenByDescending(o => o.Id);
var totalCount = await query.CountAsync();
var items = await query
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<Order>
{
Items = items,
TotalCount = totalCount,
PageIndex = pageIndex,
PageSize = pageSize
};
}上面这段代码有几个值得注意的细节。第一,排序字段选择了CreatedAt加Id的组合,Id作为兜底排序可以保证排序的唯一性,避免分页边界处出现数据重复。第二,pageSize做了上限校验,防止恶意请求一次拉取上万条数据拖垮服务。第三,CountAsync与分页查询是两次独立的数据库往返,如果列表页不需要精确的总条数,可以省略统计以提升性能。
大偏移量分页的性能问题与游标分页优化
OFFSET分页有一个隐藏的性能陷阱:偏移量越大,数据库需要扫描并丢弃的行就越多。查询第100万页时,数据库实际要扫过前2000万行再丢弃它们,只为了返回20条结果,响应时间会随页码线性劣化。对于CSDN、淘宝这类深分页场景,这种写法难以承受。
优化思路之一是改用游标分页,也叫键集分页。客户端不再传页码,而是传上一页最后一条记录的排序值,数据库利用索引直接定位起点,扫描量与页码无关:
// 前端传递 lastCreatedAt 和 lastId,而不是 pageIndex
public async Task<List<Order>> GetOrdersByCursorAsync(
DateTime? lastCreatedAt, long? lastId, int pageSize)
{
var query = _dbContext.Orders
.OrderByDescending(o => o.CreatedAt)
.ThenByDescending(o => o.Id)
.AsQueryable();
if (lastCreatedAt.HasValue && lastId.HasValue)
{
query = query.Where(o => o.CreatedAt < lastCreatedAt
|| (o.CreatedAt == lastCreatedAt && o.Id < lastId));
}
return await query.Take(pageSize).ToListAsync();
}这种写法完全避开了OFFSET,只要(CreatedAt, Id)上建有复合索引,无论翻到多深的位置,查询耗时都基本恒定。它的代价是失去了随机跳页的能力,用户只能上一页下一页地顺序浏览,但绝大多数用户翻页时根本不会跳到几百页之后,因此游标分页在信息流、消息记录等场景中是更优解。
除了游标分页,还可以采用子查询延迟关联的技巧:先用覆盖索引快速定位目标页的主键集合,再回表取完整字段,能显著减轻深分页时的IO压力。同时要养成用ToQueryString()查看EF Core生成的SQL的习惯,确认分页确实下推到了数据库,而不是发生在内存中,这是验证分页实现是否高效最直接的手段。