海量数据分页的难点通常不在SQL语法本身,而在于数据库执行计划是否真的只读取了需要的那一小块数据。以SQL Server为例,一条典型的 SELECT ... ORDER BY CreateTime DESC OFFSET 1000000 ROWS FETCH NEXT 20 ROWS ONLY 语句在页号较小时表现尚可,一旦偏移量达到百万级,响应时间经常从几十毫秒涨到数秒。原因很简单:数据库并不知道第1000001行在磁盘或索引页的哪个位置,它只能按照排序列从头扫描,把前100万行依次丢掉,再返回后面的20行。

换句话说,OFFSET 的值并不是一个可以直接寻址的物理坐标,而是一个逻辑行号。SQL Server 在执行计划里通常会先按 ORDER BY 的键读取所有符合过滤条件的行,再根据行号做偏移。这意味着即使你只取20条,读到的中间结果也可能有几百万行。如果排序列上的索引不能覆盖查询列,数据库还要回到聚簇索引或堆表里逐行取数据,也就是常说的回表。回表次数越多,随机I/O越多,分页自然越慢。
实际压测中,同样一张2000万行的订单表,按创建时间倒序分页,OFFSET 0 时查询大约 8 毫秒,OFFSET 5000000 时可能超过 2 秒。这个差距不是硬件能简单抹平的,需要从分页算法本身入手。
一、OFFSET大偏移量分页为什么越来越慢
先说结论:OFFSET 分页的性能下降不是线性的,而是随着偏移量增加逐渐放大。因为扫描前N行的开销与N相关,同时还要处理排序列上的重复值。比如按创建时间排序时,同一秒可能插入很多条数据,仅靠 CreateTime 排序,结果顺序是不稳定的,分页可能出现重复或漏读。为了解决稳定性,通常会在 ORDER BY 里增加主键作为第二排序键,例如 ORDER BY CreateTime DESC, Id DESC。
一旦加上双列排序,WHERE 条件如果只过滤了时间范围,索引仍然有效;但OFFSET 本身要求跳过指定行数,SQL Server 只能沿索引顺序扫描,无法直接跳到目标页。对于复合索引 (CreateTime DESC, Id DESC),索引中的行是按时间降序再按ID降序排列,数据库完全可以按索引顺序数到第1000001行。但问题是,如果查询还需要返回订单号、用户ID、金额等列,而这些列没有包含在索引里,就会触发大量的 Key Lookup 或 RID Lookup,回表代价极高。
所以优化大偏移量分页的核心思路有两个:要么让排序索引覆盖查询列,减少回表;要么根本不要用OFFSET,改用上一页最后一条数据的位置作为查询起点。后者就是游标分页。
二、游标分页的实现原理与SQL写法
游标分页不依赖页号,而是依赖上一页最后一条记录的唯一键和排序键。客户端翻页时不再传 pageIndex,而是传上一页最后一条的 CreateTime 和 Id。服务端把它们作为过滤条件,直接定位到下一页数据。
例如查询条件是:按 CreateTime DESC, Id DESC 排序,取时间早于上一页最后一条的20条。SQL 可以这样写:
SELECT TOP 20
Id,
OrderNo,
UserId,
Amount,
CreateTime
FROM dbo.Orders
WHERE CreateTime < @LastCreateTime
OR (CreateTime = @LastCreateTime AND Id < @LastId)
ORDER BY CreateTime DESC, Id DESC;这里为什么用 OR 条件?因为 CreateTime 不是唯一值,同一时间可能有多条。如果只写 CreateTime < @LastCreateTime,会漏掉与上一页最后一条时间相同但ID更小的数据。增加 (CreateTime = @LastCreateTime AND Id < @LastId) 后,才保证分页既不漏数据也不重复。
要让这条 SQL 走最优执行计划,必须在 (CreateTime DESC, Id DESC) 上建复合索引。数据库会先根据第一个 OR 分支定位到小于锚点的区间,再通过第二个分支补齐相同时间的剩余行,然后按索引顺序取前20条。因为索引本身有序,且查询列可以通过索引覆盖或少量回表获取,性能通常可以稳定在毫秒级。
游标分页的缺点是没办法直接跳到第N页,用户界面通常只能提供上一页、下一页或加载更多。如果业务必须显示总页数并支持任意页跳转,可以结合延迟关联等方案,后文会提到。
三、C#封装游标分页:EF Core与Dapper实现
在 C# 项目中,推荐把分页参数和返回结果封装成可复用模型,避免每个接口都散落一堆命名不一的参数。可以先定义一个通用的游标请求类和返回类:
public class CursorPageRequest
{
public int PageSize { get; set; } = 20;
public DateTime? LastCreateTime { get; set; }
public long? LastId { get; set; }
}
public class CursorPageResult<T>
{
public List<T> Items { get; set; } = new List<T>();
public bool HasMore { get; set; }
public DateTime? NextCreateTime { get; set; }
public long? NextId { get; set; }
}这里使用可空的 DateTime? 和 long? 表示首页时没有游标。服务端可根据这两个参数是否为 null 决定是否加 WHERE 过滤条件。返回结果里额外给出 HasMore 和 NextCursor,方便客户端继续往下拉。
如果使用 EF Core,可以构建动态查询:
public async Task<CursorPageResult<OrderPageItem>> GetOrdersAsync(
CursorPageRequest request,
CancellationToken cancellationToken = default)
{
var query = _dbContext.Orders.AsNoTracking();
if (request.LastCreateTime.HasValue && request.LastId.HasValue)
{
var lastTime = request.LastCreateTime.Value;
var lastId = request.LastId.Value;
query = query.Where(o =>
o.CreateTime < lastTime ||
(o.CreateTime == lastTime && o.Id < lastId));
}
var items = await query
.OrderByDescending(o => o.CreateTime)
.ThenByDescending(o => o.Id)
.Take(request.PageSize + 1)
.Select(o => new OrderPageItem
{
Id = o.Id,
OrderNo = o.OrderNo,
UserId = o.UserId,
Amount = o.Amount,
CreateTime = o.CreateTime
})
.ToListAsync(cancellationToken);
var hasMore = items.Count > request.PageSize;
if (hasMore)
{
items = items.Take(request.PageSize).ToList();
}
var last = items.LastOrDefault();
return new CursorPageResult<OrderPageItem>
{
Items = items,
HasMore = hasMore,
NextCreateTime = last?.CreateTime,
NextId = last?.Id
};
}注意上面故意多取一条 PageSize + 1,用来判断是否还有下一页。这样客户端拿到 HasMore 后可以决定是否展示加载更多按钮。多取一条的成本非常低,不建议为了省这一条而多发一次 COUNT 查询。
如果用 Dapper,原生 SQL 反而更直接。关键是把游标参数传进去,同时同样多取一条:
public async Task<CursorPageResult<OrderPageItem>> GetOrdersByCursorAsync(
IDbConnection connection,
CursorPageRequest request)
{
var sql = @"
SELECT TOP (@PageSize + 1)
Id,
OrderNo,
UserId,
Amount,
CreateTime
FROM dbo.Orders
WHERE @LastCreateTime IS NULL
OR CreateTime < @LastCreateTime
OR (CreateTime = @LastCreateTime AND Id < @LastId)
ORDER BY CreateTime DESC, Id DESC";
var list = (await connection.QueryAsync<OrderPageItem>(sql, new
{
PageSize = request.PageSize,
LastCreateTime = request.LastCreateTime,
LastId = request.LastId
})).ToList();
var hasMore = list.Count > request.PageSize;
if (hasMore)
{
list = list.Take(request.PageSize).ToList();
}
var last = list.LastOrDefault();
return new CursorPageResult<OrderPageItem>
{
Items = list,
HasMore = hasMore,
NextCreateTime = last?.CreateTime,
NextId = last?.Id
};
}Dapper 版本中 @LastCreateTime IS NULL 用来处理首页场景。当参数为 null 时该条件为真,后面的 OR 条件会被短路,查询返回前21条即可。不过实际生产环境建议根据是否首页分别拼接 SQL,避免 NULL 比较给优化器带来不必要的干扰。
两种方式的本质一样:都是利用索引顺序和锚点条件替代 OFFSET。EF Core 版本更适合领域模型复杂的项目,Dapper 版本更适合希望精确控制 SQL 的读写分离场景。
四、必须保留跳页功能时怎么优化
游标分页虽然快,但满足不了总页数、跳转到第8页这类需求。如果产品必须支持跳页,不要直接放弃优化,可以先限制最大跳转页数,例如只允许跳到前100页,超过后提示使用筛选条件缩小范围。这样即便 OFFSET 有成本,也被压在可控范围内。
另一个有效方案是延迟关联。先用覆盖索引定位目标页的主键,再根据主键回表查完整字段。假设排序索引只包含 (CreateTime, Id),查询可以改写为:
SELECT o.Id, o.OrderNo, o.UserId, o.Amount, o.CreateTime
FROM dbo.Orders o
INNER JOIN (
SELECT Id
FROM dbo.Orders
ORDER BY CreateTime DESC, Id DESC
OFFSET 1000000 ROWS FETCH NEXT 20 ROWS ONLY
) AS t ON o.Id = t.Id
ORDER BY o.CreateTime DESC, o.Id DESC;内层查询只扫描索引中的 CreateTime 和 Id,不需要回表,因此跳过100万行的成本大幅降低。外层再通过主键关联取20条完整数据,回表次数从100万次降到20次。这是海量数据分页里非常实用的折中方案。
还有一种思路是缓存游标映射。比如给每个分页请求生成一个短时效的游标键,服务端在 Redis 里保存该游标对应的 CreateTime 和 Id 位置,客户端只传游标键。这样即使不暴露具体锚点,也能获得游标分页的性能。缺点是需要额外的缓存一致性和过期处理。
五、索引设计与参数嗅探注意事项
无论使用 OFFSET 还是游标分页,索引都是决定性的。对于订单时间倒序场景,复合索引 (CreateTime DESC, Id DESC) 是最基本的。若查询还要过滤 UserId 或订单状态,就应该把过滤列放在索引前面,排序键放在后面,例如 (UserId, CreateTime DESC, Id DESC)。索引列不是越多越好,但过滤和排序列应保证连续,避免出现中间缺失导致排序无法利用索引。
参数嗅探也是分页接口经常遇到的坑。如果 SQL Server 第一次执行时传入了很小的游标,优化器可能选择适合少量行的执行计划并缓存,后续传入大偏移量时反而变差。对策包括在 SQL 中使用 OPTION (RECOMPILE),或者用 OPTIMIZE FOR UNKNOWN 降低嗅探影响。EF Core 中可以针对特定查询使用 .TagWith() 增加提示,但要注意编译开销。
最后,压测时不要只看第一页。建议分别准备 1 万、100 万、1000 万偏移量下的测试数据,对比 OFFSET、游标分页、延迟关联三种方式的执行时间和逻辑读取次数。只有结合执行计划和真实数据量,才能选出适合当前业务的方案。