Skip to content

分页查询技术与优化 ​

目录 ​


分页查询基础 ​

为什么需要分页? ​

分页(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>
  );
}

总结 ​

核心要点 ​

  1. 偏移分页: 简单但深度分页性能差
  2. 游标分页: 性能稳定,适合 API
  3. 键集分页: 最优性能,支持任意排序
  4. 索引优化: 为分页字段创建合适索引
  5. 缓存策略: 缓存热门页(如第一页)

分页策略选择 ​

场景推荐策略原因
传统 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 查询
❌ 避免忘记参数验证

下一步 ​

基于 MIT 许可发布