当前位置: 首页 > news >正文

从 Skip Take 到 Keyset:C# 分页原理与实践

简介

分页基础概念

  • 核心分页参数
public class PaginationParams
{private const int MaxPageSize = 100; // 最大每页条数限制public int PageNumber { get; set; } = 1;    // 当前页码(从1开始)private int _pageSize = 10;                 // 每页记录数public int PageSize{get => _pageSize;set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value;}
}
  • 分页结果封装
public class PagedResult<T>
{public int CurrentPage { get; set; }public int PageSize { get; set; }public int TotalCount { get; set; }public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);public List<T> Items { get; set; } = new List<T>();
}

普通分页(Offset & Limit)

为什么普通分页会变慢?

page * size 很大时,数据库仍要扫描并丢弃前面 N 条记录,IO 和排序开销显著,上千页甚至秒级以上延迟。此时可考虑「高性能分页」。

原理
  • 使用数据库的 OFFSET … FETCH(SQL Server/PostgreSQL/SQLite)或 LIMIT … OFFSET(MySQL)进行分页。

  • 优点:实现简单、支持任意页码跳转。

  • 缺点:当页码(offset)较大时,数据库仍需扫描并跳过大量行,性能下降明显。

EF Core 实现
public async Task<PagedResult<Product>> GetProductsAsync(PaginationParams pagination)
{var query = _context.Products.Where(p => p.IsActive).OrderBy(p => p.Name);var totalCount = await query.CountAsync();var items = await query.Skip((pagination.PageNumber - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync();return new PagedResult<Product>{CurrentPage = pagination.PageNumber,PageSize = pagination.PageSize,TotalCount = totalCount,Items = items};
}
FreeSql 实现
public PagedResult<Product> GetProducts(PaginationParams pagination)
{var query = _fsql.Select<Product>().Where(p => p.IsActive).OrderBy(p => p.Name);var totalCount = query.Count();var items = query.Skip((pagination.PageNumber - 1) * pagination.PageSize).Take(pagination.PageSize).ToList();return new PagedResult<Product>{CurrentPage = pagination.PageNumber,PageSize = pagination.PageSize,TotalCount = totalCount,Items = items};
}
LinqToDB 实现
public PagedResult<Product> GetProducts(PaginationParams pagination)
{using (var db = new AppDbContext()){var query = db.Products.Where(p => p.IsActive).OrderBy(p => p.Name);var totalCount = query.Count();var items = query.Skip((pagination.PageNumber - 1) * pagination.PageSize).Take(pagination.PageSize).ToList();return new PagedResult<Product>{CurrentPage = pagination.PageNumber,PageSize = pagination.PageSize,TotalCount = totalCount,Items = items};}
}
Dapper 实现
string sql = @"
SELECT Id, Name, CreatedAt
FROM Users
ORDER BY Id
OFFSET @Offset ROWS FETCH NEXT @Size ROWS ONLY";var list = conn.Query<User>(sql,new { Offset = (page - 1) * size, Size = size });

高性能分页:Keyset(Seek)分页

核心思路
  • 利用已有索引,通过「最后一条记录的关键列值」继续查询下一页,避免大量 OFFSET

  • 也称「Seek 方法」或「基于游标的分页」。

public async Task<PagedResult<Product>> GetProductsSeekAsync(int pageSize, int? lastId = null)
{var query = _context.Products.Where(p => p.IsActive).OrderBy(p => p.Id);if (lastId.HasValue){query = query.Where(p => p.Id > lastId.Value);}var items = await query.Take(pageSize).ToListAsync();var nextLastId = items.LastOrDefault()?.Id;return new PagedResult<Product>{CurrentPage = lastId == null ? 1 : 2, // 简化示例PageSize = pageSize,Items = items,TotalCount = -1 // 不返回总计数提升性能};
}
通用 SQL 模式
SELECT columns
FROM table
WHERE (OrderKey > @LastKey)
ORDER BY OrderKey
LIMIT @Size;
  • OrderKey 通常为自增 ID、时间戳、复合唯一键等。

  • 只扫描「大于上次最大值」的部分,IO 成本随页码增量保持稳定。

分页优化:索引覆盖 + Keyset
-- 创建优化索引
CREATE INDEX IX_Products_Active_Name_Id 
ON Products (IsActive, Name, Id);
public async Task<PagedResult<Product>> GetProductsOptimizedAsync(PaginationParams pagination,int? lastId = null,string? lastName = null)
{var query = _context.Products.Where(p => p.IsActive);if (lastId != null && lastName != null){query = query.Where(p => (p.Name == lastName && p.Id > lastId) || p.Name.CompareTo(lastName) > 0);}query = query.OrderBy(p => p.Name).ThenBy(p => p.Id);var items = await query.Take(pagination.PageSize).Select(p => new ProductDto // 只选择必要字段{Id = p.Id,Name = p.Name,Price = p.Price}).ToListAsync();// 返回最后一条记录的标识var lastItem = items.LastOrDefault();return new PagedResult<ProductDto>{Items = items,PageSize = pagination.PageSize,LastId = lastItem?.Id,LastName = lastItem?.Name};
}
分页性能对比(10万条记录测试)
分页方式第1页耗时第100页耗时第1000页耗时内存使用
Skip/Take15ms120ms850ms中等
Keyset分页10ms12ms15ms
索引覆盖+Keyset8ms9ms11ms最低

ORM 特定优化技巧

EF Core 分页优化
// 使用AsNoTracking避免变更跟踪
var items = await query.AsNoTracking().Skip(...).Take(...).ToListAsync();// 只选择必要字段
var items = await query.Select(p => new {p.Id,p.Name,p.Price}).Skip(...).Take(...).ToListAsync();// 使用原生SQL优化深度分页
var sql = @"
SELECT * FROM Products
WHERE Id > @lastId
ORDER BY Id
LIMIT @pageSize";var items = await _context.Products.FromSqlRaw(sql, parameters).AsNoTracking().ToListAsync();
  • 若需支持多列排序,可在 Where 中组合条件:
.Where(u => u.CreatedAt > lastTime|| (u.CreatedAt == lastTime && u.Id > lastId))
FreeSql 高性能分页
// FreeSql 专用分页方法
var (items, totalCount) = _fsql.Select<Product>().Where(p => p.IsActive).OrderBy(p => p.Id).Page(pagination.PageNumber, pagination.PageSize).ToList();// 使用ToChunk处理大数据量
_fsql.Select<Product>().ToChunk(null, 1000, chunk => {// 处理每1000条记录});// FreeSql 的 Keyset 分页
var items = _fsql.Select<Product>().Where(p => p.Id > lastId).OrderBy(p => p.Id).Take(pageSize).ToList();
LinqToDB 分页优化
// 使用SQL优化提示
var items = db.Products.With("INDEX(IX_Products_Active_Name_Id)").Where(p => p.IsActive).OrderBy(p => p.Name).ThenBy(p => p.Id).Skip(...).Take(...).ToList();// 使用BulkCopy处理大数据导出
var options = new BulkCopyOptions { BulkCopyTimeout = 60 };
db.BulkCopy(options, products);
Dapper
string sql = @"
SELECT Id, Name, CreatedAt
FROM Users
WHERE Id > @LastId
ORDER BY Id
OFFSET 0 ROWS FETCH NEXT @Size ROWS ONLY"; // OFFSET 0 + FETCH NEXTvar list = conn.Query<User>(sql, new { LastId = lastId, Size = size });

分页最佳实践

通用分页参数设计
public class PaginationRequest
{[Range(1, int.MaxValue)]public int PageIndex { get; set; } = 1;[Range(1, 100)]public int PageSize { get; set; } = 20;public string? SortBy { get; set; }public bool SortDescending { get; set; }public int? LastId { get; set; }public DateTime? LastDate { get; set; }
}public class PaginationResult<T>
{public List<T> Items { get; set; } = new();public int TotalCount { get; set; }public int? LastId { get; set; }public DateTime? LastDate { get; set; }public bool HasNextPage { get; set; }
}
前端交互设计
// 分页API响应格式
interface ApiResponse<T> {data: T[];pagination: {currentPage: number;pageSize: number;totalItems: number;totalPages: number;nextCursor?: string; // 用于Keyset分页};
}
分页缓存策略
public async Task<PaginationResult<Product>> GetProducts(PaginationRequest request)
{var cacheKey = $"products_page_{request.PageIndex}_size_{request.PageSize}";if (_cache.TryGetValue(cacheKey, out PaginationResult<Product> result)){return result;}// 数据库查询result = await QueryProductsFromDb(request);// 设置缓存(5分钟过期)_cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));return result;
}
分页策略选择指南
场景推荐分页方式理由
后台管理系统Skip/Take需要跳页和总计数
移动端无限滚动Keyset分页高性能连续加载
大数据量导出分块处理避免内存溢出
实时数据展示时间范围分页按时间切片
复杂报表存储过程分页最高性能优化
深度分页优化方案
// 基于游标的深度分页
public async Task<PagedResult<Product>> GetDeepPagedProducts(int pageSize, string? cursor = null)
{// 解析游标(Base64编码的ID+时间戳)var cursorData = cursor != null ? JsonSerializer.Deserialize<CursorData>(Base64Decode(cursor)) : null;var query = _context.Products.AsQueryable();if (cursorData != null){query = query.Where(p => p.CreatedAt > cursorData.Timestamp || (p.CreatedAt == cursorData.Timestamp && p.Id > cursorData.Id));}query = query.OrderBy(p => p.CreatedAt).ThenBy(p => p.Id).Take(pageSize);var items = await query.ToListAsync();// 生成下一页游标var lastItem = items.LastOrDefault();string nextCursor = null;if (lastItem != null){var nextCursorData = new CursorData { Id = lastItem.Id, Timestamp = lastItem.CreatedAt };nextCursor = Base64Encode(JsonSerializer.Serialize(nextCursorData));}return new PagedResult<Product>{Items = items,PageSize = pageSize,NextCursor = nextCursor};
}private record CursorData(long Id, DateTime Timestamp);
最佳实践建议
  • 中小型系统:EF Core + Skip/Take 满足大部分需求

  • 高性能 APIFreeSql/LinqToDB + Keyset 分页

  • 数据仓库:存储过程 + 分块处理

  • 实时系统:游标分页 + 时间窗口查询

  • 移动应用:Keyset 分页 + 增量加载

小数据量 < 10K
中等数据量 10K-1M
大数据量 > 1M
分页需求
数据量大小
Skip/Take 分页
Keyset 分页
分块处理 + 游标
EF Core/FreeSql
FreeSql/LinqToDB
原生SQL + 存储过程
简单实现
高性能
最高性能

进阶技巧与组合场景

复合排序列
  • 多列排序要在 WHERE 中组合判定,确保唯一性并保持稳定翻页。
逆向分页(Last N Rows)
  • 获取倒数几页时,可先用普通分页(反向 ORDER BY … DESC + LIMIT),再在应用层反转顺序。
跳页提示
  • Keyset 不支持直接跳到第 N 页,可结合普通分页查询总数,给用户展示页码大致范围或「上一页/下一页」导航。
缓存上次最大值
  • 对于列表实时插入较多场景,可将上次的最后一条主键/时间戳保存在客户端或服务端 Session,继续 Seek
视图或子查询分页
  • 复杂关联分页时,可先用 Seek 在主表分页,再 JOIN 子查询结果,减少 JOIN 数据量。

性能对比与实践建议

方法优点缺点
Offset & Limit简单、支持任意页跳转大页码时性能急剧下降
Keyset (Seek)IO 稳定、延迟恒定不支持直接跳页,仅「上一页/下一页」
结合两者关键页用 Seek,深度跳页用 Offset逻辑稍繁琐
  • 小数据量(<100K 行)或浅度分页(<100 页):普通分页足够。

  • 深度分页(>1000 页)或海量数据:强烈建议 Seek 分页。

  • 复杂多表关联:先按主键 Seek,后再 JOIN,避免大表 JOIN 全量扫描。

http://www.dtcms.com/a/361984.html

相关文章:

  • 考研复习-计算机网络-第一章-计算机网络概述
  • Parasoft C/C++test解析:覆盖率与可追溯性的软件测试协同方案
  • 免费GIS服务器方案:OGC标准3DTiles服务发布与跨平台渲染实践
  • 安全等保复习笔记
  • flume接收处理器:构建高可用与高性能的数据链路
  • Python 人工智能深度学习系统学习(附视频教程)
  • PowerPoint和WPS演示如何在放映PPT时用鼠标划重点
  • C++ STL序列容器-------list
  • 无人设备遥控器之安全加密体系
  • 【收藏级】Windows AI 本地开发「完全体」环境搭建清单
  • 多校区学校押金原路退回系统之免安装使用教程——东方仙盟
  • 计算机三级嵌入式填空题——真题库(28)原题附答案速记
  • matlab-神经网络的语音识别
  • 神经网络2——使用Keras实现MLP
  • 深度学习中常用的激活函数
  • 玩转OurBMC第二十二期:OurBMC之PCIe接口应用(上)
  • Triton Linalg编译
  • 系统性学习数据结构-第二讲-顺序表与链表
  • K8S-etcd数据库的相关操作
  • DHT11-温湿度传感器
  • 基于单片机智能饮水机/智能热水壶
  • Redis 如何实现分布式锁,以及redis的适应场景分析
  • 【跨平台编译】【树莓派】在x86上编译树莓派上运行的CC++代码,搭建树莓派4B的跨平台编译环境
  • ccache编译加速配置
  • Meteor主题友链页面自研
  • Qt 的信号signal的参数是否会在内部被拷贝?
  • Vue3 中 Proxy 在组件封装中的妙用
  • 【网络安全入门基础教程】网络安全零基础学习方向及需要掌握的技能
  • Electron 应用生命周期管理:app 模块核心 API
  • 【 HarmonyOS 】错误描述:The certificate has expired! 鸿蒙证书过期如何解决?