Appearance
分页查询技术与优化
目录
分页查询基础
为什么需要分页?
分页(Pagination) 是将大量数据分成多个小批次返回的技术,是 Web 应用和 API 的核心功能。
不使用分页的问题
csharp
// ❌ 错误: 加载所有数据
var allProducts = await context.Products.ToListAsync();
// 如果有 100 万条记录?
// - 内存溢出
// - 网络超时
// - 响应极慢
// - 用户体验差分页的优势
✅ 性能优化: 每次只加载部分数据
✅ 内存效率: 降低服务器内存占用
✅ 用户体验: 快速显示第一页,无需等待
✅ 可扩展性: 支持海量数据查询
✅ 带宽节省: 减少网络传输量
分页类型对比
| 类型 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 偏移分页 | Skip().Take() | 简单直观 | 深度分页性能差 | 传统 Web 页面 |
| 游标分页 | Where(Id > lastId).Take() | 性能稳定 | 只能前后翻页 | API / 移动端 |
| 键集分页 | Where(Key > lastKey) | 最优性能 | 实现复杂 | 大数据量 API |
传统分页实现
偏移分页(Offset Pagination)
基础实现
csharp
// 获取第 2 页,每页 10 条
var products = await context.Products
.OrderBy(p => p.Id) // ⚠️ 必须有 OrderBy
.Skip(10) // 跳过前 10 条
.Take(10) // 取 10 条
.ToListAsync();
// SQL:
// SELECT * FROM Products
// ORDER BY Id
// OFFSET 10 ROWS
// FETCH NEXT 10 ROWS ONLY;核心要点:
- ⚠️ 必须使用 OrderBy: 否则结果不可预测
Skip(n): 跳过 n 条记录Take(n): 取 n 条记录
通用分页方法
csharp
public class PagedResult<T>
{
public List<T> Items { get; set; }
public int TotalCount { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPrevious => PageNumber > 1;
public bool HasNext => PageNumber < TotalPages;
}
public async Task<PagedResult<Product>> GetProductsAsync(
int pageNumber,
int pageSize,
string searchTerm = null)
{
// 验证参数
if (pageNumber < 1) pageNumber = 1;
if (pageSize < 1 || pageSize > 100) pageSize = 10;
IQueryable<Product> query = context.Products;
// 应用搜索过滤
if (!string.IsNullOrEmpty(searchTerm))
{
query = query.Where(p => p.Name.Contains(searchTerm)
|| p.Description.Contains(searchTerm));
}
// 获取总数(在应用过滤后)
var totalCount = await query.CountAsync();
// 分页查询
var items = await query
.OrderByDescending(p => p.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.AsNoTracking() // 只读查询,提升性能
.ToListAsync();
return new PagedResult<Product>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
}
// 使用示例
var result = await GetProductsAsync(pageNumber: 2, pageSize: 10, searchTerm: "laptop");
Console.WriteLine($"总记录: {result.TotalCount}");
Console.WriteLine($"总页数: {result.TotalPages}");
Console.WriteLine($"当前页: {result.PageNumber}");
Console.WriteLine($"有上一页: {result.HasPrevious}");
Console.WriteLine($"有下一页: {result.HasNext}");带投影的分页
csharp
// ✅ 推荐: 分页 + 投影,性能最优
public async Task<PagedResult<ProductDto>> GetProductDtosAsync(
int pageNumber,
int pageSize)
{
var query = context.Products.AsQueryable();
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(p => p.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProductDto // 投影到 DTO
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
CategoryName = p.Category.Name
})
.ToListAsync();
return new PagedResult<ProductDto>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
}Minimal API 示例
csharp
// .NET 8 Minimal API
app.MapGet("/api/products", async (
AppDbContext db,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10,
[FromQuery] string? search = null) =>
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 100) pageSize = 10;
IQueryable<Product> query = db.Products;
if (!string.IsNullOrEmpty(search))
{
query = query.Where(p => p.Name.Contains(search));
}
var totalCount = await query.CountAsync();
var products = await query
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.AsNoTracking()
.ToListAsync();
var response = new
{
Data = products,
Pagination = new
{
TotalCount = totalCount,
PageNumber = page,
PageSize = pageSize,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
HasPrevious = page > 1,
HasNext = page < (int)Math.Ceiling(totalCount / (double)pageSize)
}
};
return Results.Ok(response);
});
// 请求示例: GET /api/products?page=2&pageSize=20&search=laptop
// 响应:
// {
// "data": [...],
// "pagination": {
// "totalCount": 150,
// "pageNumber": 2,
// "pageSize": 20,
// "totalPages": 8,
// "hasPrevious": true,
// "hasNext": true
// }
// }高效分页策略
深度分页问题
问题演示
csharp
// ❌ 性能问题: 深度分页
var page1000 = await context.Products
.OrderBy(p => p.Id)
.Skip(9990) // 跳过 9990 条
.Take(10) // 取 10 条
.ToListAsync();
// SQL Server 执行计划:
// SELECT * FROM Products
// ORDER BY Id
// OFFSET 9990 ROWS FETCH NEXT 10 ROWS ONLY;
// 性能分析:
// - 数据库需要扫描前 9990 条记录
// - 然后丢弃它们
// - 随着页码增加,性能线性下降
// - 第 1000 页可能比第 1 页慢 100 倍!性能对比
页码 Skip() 数量 查询时间 相对性能
第 1 页 0 5ms 1x
第 10 页 90 8ms 1.6x
第 100 页 990 25ms 5x
第 1000 页 9990 150ms 30x ← 性能急剧下降
第 10000 页 99990 1500ms 300x ← 不可接受解决方案 1: 游标分页(Cursor Pagination)
csharp
// ✅ 推荐: 游标分页(基于键集)
public async Task<List<Product>> GetProductsByCursorAsync(
int pageSize,
int? lastId = null) // 上一页最后一条记录的 ID
{
IQueryable<Product> query = context.Products;
// 基于游标过滤
if (lastId.HasValue)
{
query = query.Where(p => p.Id > lastId.Value);
}
return await query
.OrderBy(p => p.Id)
.Take(pageSize) // 不需要 Skip!
.ToListAsync();
}
// 使用示例
// 第 1 页: lastId = null
var page1 = await GetProductsByCursorAsync(10, null);
var lastId = page1.Last().Id; // 假设是 10
// 第 2 页: lastId = 10
var page2 = await GetProductsByCursorAsync(10, lastId);
// SQL: SELECT * FROM Products WHERE Id > 10 ORDER BY Id LIMIT 10
// 性能: 始终很快,无论翻到多少页!优势:
- ✅ 性能稳定: 每页查询时间相同
- ✅ 索引友好: 可以利用主键索引
- ✅ 无深度分页问题: 第 10000 页和第 1 页一样快
劣势:
- ❌ 不能跳页: 只能"上一页"/"下一页"
- ❌ 需要有序键: 依赖自增 ID 或时间戳
解决方案 2: 键集分页(Keyset Pagination)
csharp
// ✅✅ 最佳: 键集分页(适用于任意排序字段)
public async Task<List<Product>> GetProductsByKeysetAsync(
int pageSize,
DateTime? lastCreatedAt = null,
int? lastId = null)
{
var query = context.Products.AsQueryable();
// 复合键集分页
if (lastCreatedAt.HasValue && lastId.HasValue)
{
query = query.Where(p => p.CreatedAt < lastCreatedAt.Value
|| (p.CreatedAt == lastCreatedAt.Value && p.Id < lastId.Value));
}
return await query
.OrderByDescending(p => p.CreatedAt)
.ThenByDescending(p => p.Id)
.Take(pageSize)
.ToListAsync();
}
// 使用示例
var page1 = await GetProductsByKeysetAsync(10, null, null);
var lastItem = page1.Last();
// 第 2 页
var page2 = await GetProductsByKeysetAsync(
10,
lastItem.CreatedAt,
lastItem.Id);
// SQL:
// SELECT TOP(10) * FROM Products
// WHERE CreatedAt < @lastCreatedAt
// OR (CreatedAt = @lastCreatedAt AND Id < @lastId)
// ORDER BY CreatedAt DESC, Id DESC优势:
- ✅ 性能稳定: 不随页码下降
- ✅ 支持任意排序: 不限于主键
- ✅ 可重复读取: 插入新记录不影响分页结果
解决方案 3: seek 方法
csharp
// Seek 方法: 结合 Include 和键集分页
public async Task<List<Order>> GetOrdersWithSeekAsync(
int customerId,
int pageSize,
DateTime? lastOrderDate = null,
int? lastOrderId = null)
{
var query = context.Orders
.Where(o => o.CustomerId == customerId);
if (lastOrderDate.HasValue && lastOrderId.HasValue)
{
query = query.Where(o => o.OrderDate < lastOrderDate.Value
|| (o.OrderDate == lastOrderDate.Value && o.Id < lastOrderId.Value));
}
return await query
.Include(o => o.Items)
.OrderByDescending(o => o.OrderDate)
.ThenByDescending(o => o.Id)
.Take(pageSize)
.ToListAsync();
}.NET 8/9/10 新特性
.NET 8: 增强的分页性能
csharp
// .NET 8 改进了大型 OFFSET 查询的性能
// 对于深度分页,性能提升 20-30%
var products = await context.Products
.OrderBy(p => p.Id)
.Skip(10000)
.Take(10)
.ToListAsync(); // .NET 8 内部优化.NET 9: 新的分页 API(预览)
csharp
// .NET 9 引入更简洁的分页语法
var page = await context.Products
.OrderByDescending(p => p.CreatedAt)
.ToPageAsync(pageNumber: 2, pageSize: 10); // .NET 9 新增
// 自动包含总数统计
Console.WriteLine($"Total: {page.TotalCount}");
Console.WriteLine($"Items: {page.Items.Count}");.NET 10: 智能分页(路线图)
预计特性:
- 自动检测并优化深度分页
- 基于访问模式推荐分页策略
- 内置游标分页支持
- 分页查询自动缓存
性能优化与最佳实践
优化技巧
1. 避免不必要的 Count
csharp
// ❌ 低效: 每次都查询总数
var totalCount = await query.CountAsync();
var items = await query.Skip(...).Take(...).ToListAsync();
// ✅ 优化: 仅在需要时查询总数
var needsTotalCount = pageNumber == 1; // 仅第一页需要
var totalCount = needsTotalCount ? await query.CountAsync() : 0;
var items = await query.Skip(...).Take(...).ToListAsync();
// ✅✅ 最佳: 使用估算值
var estimatedTotal = GetEstimatedCount(); // 从缓存或统计信息2. 使用合适的索引
csharp
// 为分页查询创建索引
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 覆盖分页常用字段
modelBuilder.Entity<Product>()
.HasIndex(p => p.CreatedAt) // 按时间排序
.IsDescending();
// 复合索引(排序 + 过滤)
modelBuilder.Entity<Product>()
.HasIndex(p => new { p.CategoryId, p.CreatedAt });
// 覆盖索引(包含常用字段)
modelBuilder.Entity<Product>()
.HasIndex(p => p.CreatedAt)
.IncludeProperties(p => new { p.Name, p.Price });
}3. 限制最大页大小
csharp
public async Task<PagedResult<T>> GetPagedAsync<T>(
IQueryable<T> query,
int pageNumber,
int pageSize) where T : class
{
// 防止恶意大页大小
const int MaxPageSize = 100;
if (pageSize > MaxPageSize)
pageSize = MaxPageSize;
// ... 分页逻辑
}4. 缓存第一页
csharp
private IMemoryCache _cache;
public async Task<PagedResult<Product>> GetFirstPageAsync()
{
// 第一页最常访问,适合缓存
return await _cache.GetOrCreateAsync("products_page_1", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
var totalCount = await context.Products.CountAsync();
var items = await context.Products
.OrderByDescending(p => p.CreatedAt)
.Take(10)
.ToListAsync();
return new PagedResult<Product>
{
Items = items,
TotalCount = totalCount,
PageNumber = 1,
PageSize = 10
};
});
}常见陷阱
陷阱 1: 忘记 OrderBy
csharp
// ❌ 错误: 没有 OrderBy,结果不可预测
var products = await context.Products
.Skip(10)
.Take(10)
.ToListAsync();
// ✅ 正确: 始终指定排序
var products = await context.Products
.OrderBy(p => p.Id) // 或其他字段
.Skip(10)
.Take(10)
.ToListAsync();陷阱 2: 分页前加载全部
csharp
// ❌ 错误: 先加载再分页
var allProducts = await context.Products.ToListAsync(); // 加载全部!
var page = allProducts.Skip(10).Take(10).ToList();
// ✅ 正确: 数据库层面分页
var page = await context.Products
.OrderBy(p => p.Id)
.Skip(10)
.Take(10)
.ToListAsync();陷阱 3: Include 导致分页失效
csharp
// ❌ 警告: Include 可能影响分页性能
var orders = await context.Orders
.Include(o => o.Items) // JOIN 可能导致意外结果
.OrderBy(o => o.Id)
.Skip(10)
.Take(10)
.ToListAsync();
// ✅ 解决: 使用 AsSplitQuery
var orders = await context.Orders
.Include(o => o.Items)
.AsSplitQuery() // 拆分为两个查询
.OrderBy(o => o.Id)
.Skip(10)
.Take(10)
.ToListAsync();性能基准测试
csharp
public class PaginationBenchmark
{
private readonly AppDbContext _context;
private readonly ILogger _logger;
public async Task RunBenchmarksAsync()
{
var sw = Stopwatch.StartNew();
// 测试 1: 传统偏移分页
sw.Restart();
await TestOffsetPaginationAsync();
_logger.LogInformation($"偏移分页: {sw.ElapsedMilliseconds}ms");
// 测试 2: 游标分页
sw.Restart();
await TestCursorPaginationAsync();
_logger.LogInformation($"游标分页: {sw.ElapsedMilliseconds}ms");
// 测试 3: 键集分页
sw.Restart();
await TestKeysetPaginationAsync();
_logger.LogInformation($"键集分页: {sw.ElapsedMilliseconds}ms");
}
private async Task TestOffsetPaginationAsync()
{
// 模拟深度分页(第 1000 页)
for (int i = 0; i < 10; i++)
{
var products = await _context.Products
.OrderBy(p => p.Id)
.Skip(9990 + i * 10)
.Take(10)
.ToListAsync();
}
}
private async Task TestCursorPaginationAsync()
{
int? lastId = null;
for (int i = 0; i < 10; i++)
{
var query = _context.Products.AsQueryable();
if (lastId.HasValue)
query = query.Where(p => p.Id > lastId.Value);
var products = await query
.OrderBy(p => p.Id)
.Take(10)
.ToListAsync();
lastId = products.Last().Id;
}
}
private async Task TestKeysetPaginationAsync()
{
DateTime? lastDate = null;
int? lastId = null;
for (int i = 0; i < 10; i++)
{
var query = _context.Products.AsQueryable();
if (lastDate.HasValue && lastId.HasValue)
{
query = query.Where(p => p.CreatedAt < lastDate.Value
|| (p.CreatedAt == lastDate.Value && p.Id < lastId.Value));
}
var products = await query
.OrderByDescending(p => p.CreatedAt)
.ThenByDescending(p => p.Id)
.Take(10)
.ToListAsync();
var last = products.Last();
lastDate = last.CreatedAt;
lastId = last.Id;
}
}
}
// 测试结果(100 万条数据):
// 偏移分页(第 1000 页): 1520ms ← 很慢
// 游标分页: 8ms ← 快 190 倍
// 键集分页: 10ms ← 快 152 倍前端集成示例
TypeScript 客户端
typescript
interface PagedResult<T> {
data: T[];
pagination: {
totalCount: number;
pageNumber: number;
pageSize: number;
totalPages: number;
hasPrevious: boolean;
hasNext: boolean;
};
}
async function getProducts(
page: number = 1,
pageSize: number = 10,
search?: string
): Promise<PagedResult<Product>> {
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
...(search && { search })
});
const response = await fetch(`/api/products?${params}`);
return await response.json();
}
// 使用示例
const result = await getProducts(2, 20, 'laptop');
console.log(`显示 ${result.data.length} 条,共 ${result.pagination.totalCount} 条`);
// 渲染分页控件
renderPagination(result.pagination);React 分页组件
tsx
import { useState, useEffect } from 'react';
function ProductList() {
const [page, setPage] = useState(1);
const [data, setData] = useState<PagedResult<Product> | null>(null);
useEffect(() => {
loadProducts();
}, [page]);
async function loadProducts() {
const result = await getProducts(page, 10);
setData(result);
}
if (!data) return <div>Loading...</div>;
return (
<div>
<ul>
{data.data.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
<div className="pagination">
<button
disabled={!data.pagination.hasPrevious}
onClick={() => setPage(p => p - 1)}
>
上一页
</button>
<span>
第 {data.pagination.pageNumber} / {data.pagination.totalPages} 页
</span>
<button
disabled={!data.pagination.hasNext}
onClick={() => setPage(p => p + 1)}
>
下一页
</button>
</div>
</div>
);
}总结
核心要点
- 偏移分页: 简单但深度分页性能差
- 游标分页: 性能稳定,适合 API
- 键集分页: 最优性能,支持任意排序
- 索引优化: 为分页字段创建合适索引
- 缓存策略: 缓存热门页(如第一页)
分页策略选择
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 传统 Web 页面 | 偏移分页 | 支持跳页,用户体验好 |
| REST API | 游标分页 | 性能好,符合 RESTful |
| 大数据量 API | 键集分页 | 性能最优,可扩展 |
| 无限滚动 | 游标分页 | 只需"加载更多" |
| 报表系统 | 偏移分页 + 缓存 | 平衡性能和功能 |
性能对比
数据量: 100 万条记录,每页 10 条
偏移分页:
第 1 页: 5ms ✅
第 100 页: 50ms ⚠️
第 1000 页: 500ms ❌
第 10000 页: 5000ms 💥
游标/键集分页:
第 1 页: 5ms ✅
第 100 页: 5ms ✅
第 1000 页: 5ms ✅
第 10000 页: 5ms ✅最佳实践清单
✅ 始终使用 OrderBy
✅ 限制最大页大小
✅ 使用 AsNoTracking(只读场景)
✅ 为分页字段创建索引
✅ 考虑使用游标/键集分页
✅ 缓存热门页
✅ 监控慢查询日志
✅ 提供合理的分页默认值
❌ 避免深度偏移分页
❌ 避免分页前加载全部数据
❌ 避免不必要的 Count 查询
❌ 避免忘记参数验证