1. 项目概述为什么我们需要游标分页如果你在开发一个后端API特别是涉及数据列表查询的接口比如用户动态、商品列表或者交易记录分页Pagination几乎是绕不开的话题。传统的分页方案比如基于页码和每页数量pagelimit的OFFSET/LIMIT模式在数据量小的时候工作得很好。但随着数据量增长尤其是达到百万、千万级别时它的性能瓶颈和用户体验问题就会暴露无遗。想象一下你正在翻阅一本很厚的书。OFFSET/LIMIT就像让你直接翻到第1000页。数据库为了找到这第1000页的起始点需要先“数过”前面的999页数据即使你并不需要它们。数据量越大这个“数数”的过程就越慢。更糟糕的是如果在你“数数”的过程中前面的数据发生了插入或删除比如新增了一条记录你最终翻到的“第1000页”内容可能已经不是你期望的了这就是数据漂移问题。而游标分页Cursor-based Pagination提供了另一种思路。它不关心绝对的“第几页”只关心“从这个点之后”或“从这个点之前”。就像书签一样你记住上次看到哪里下次直接从那里继续。typeorm-cursor-pagination这个库就是为 TypeORM 的 Query Builder 量身打造的游标分页解决方案。它把复杂的游标生成、解析和查询条件构建封装起来让你用几行清晰的 TypeScript 代码就能在项目中实现高性能、稳定的分页功能特别适合用于无限滚动列表、实时数据流等场景。2. 核心原理游标分页是如何工作的要理解这个库的价值我们得先拆解下游标分页的核心机制。它与OFFSET分页的本质区别在于定位方式。2.1 游标的本质一个稳定的定位点游标Cursor通常是一个指向数据集中某条唯一记录的、不透明的标记。在实践中它往往是由一个或多个具有唯一性且有序的字段如自增ID、创建时间戳编码而成。例如最后一条数据的id是150那么afterCursor可能就是“150”或其Base64编码。关键在于这个游标是稳定的。只要那条记录本身没有被修改对于ID而言几乎不可能这个游标就永远指向它。这解决了OFFSET分页中因数据增删导致页面内容错乱的问题。2.2 查询逻辑基于条件的筛选而非跳过当我们使用afterCursor: “150”查询下一页时库在内部会将其转换成一个查询条件。假设我们按id升序排列那么查询就变成了SELECT * FROM user WHERE id 150 ORDER BY id ASC LIMIT 10;数据库可以利用id字段上的索引高效地找到id 150的第一条记录然后返回接下来的10条。这个过程不需要计算和跳过前面的150条记录因此性能是常数时间O(1)与数据总量无关。同理beforeCursor用于查询上一页其逻辑是WHERE id ? ORDER BY id DESC LIMIT 10然后再将结果反转回正序。typeorm-cursor-pagination库的核心工作就是根据你提供的游标和排序方式自动构建出这些正确的WHERE和ORDER BY子句并处理好边界情况。2.3 多字段排序与游标编码现实场景中排序条件可能很复杂。例如“先按评分降序再按创建时间降序最后按ID升序”。这时游标就必须包含所有这些字段的信息才能准确定位。typeorm-cursor-pagination通过paginationKeys参数支持多字段游标。库会将指定字段的值按顺序序列化例如组合成score:95|created_at:1640995200000|id:200这样的字符串然后进行Base64编码生成一个紧凑、不透明的游标字符串。在解析时再将其拆解回各个字段的值用于构建查询条件。这保证了即使在复杂的排序规则下分页也能准确无误。3. 从零开始安装与基础使用了解了原理我们来看如何把它用起来。整个过程非常直观。3.1 安装与引入首先通过 npm 安装库npm install typeorm-cursor-pagination --save # 或使用 yarn yarn add typeorm-cursor-pagination确保你的项目已经正确配置了 TypeORM 和数据库连接。3.2 构建第一个分页查询假设我们有一个User实体我们想分页查询所有男性用户。基础代码如下import { getConnection } from typeorm; import { buildPaginator } from typeorm-cursor-pagination; import { User } from ./entity/User; // 你的实体 async function getFirstPage() { // 1. 创建 TypeORM QueryBuilder const queryBuilder getConnection() .getRepository(User) .createQueryBuilder(user) .where(user.gender :gender, { gender: male }); // 2. 构建分页器 const paginator buildPaginator({ entity: User, // 【必需】对应的 TypeORM 实体 alias: user, // 【可选】与 QueryBuilder 的别名保持一致默认会尝试从 QueryBuilder 推断 paginationKeys: [id], // 【可选】用于分页的字段默认是 [id] query: { limit: 10, // 【可选】每页数量默认 100 order: ASC, // 【可选】排序方向ASC 或 DESC默认 DESC // afterCursor: null, // 查询第一页不需要游标 // beforeCursor: null, }, }); // 3. 执行分页查询 const { data, cursor } await paginator.paginate(queryBuilder); console.log(用户数据:, data); console.log(游标信息:, cursor); // { beforeCursor: null, afterCursor: encoded_cursor_string } return { data, cursor }; }关键参数解析entity: 必须提供。库需要知道实体的元数据如字段类型来正确构建查询。alias: 建议显式提供尤其是当你的QueryBuilder涉及多个JOIN或子查询时确保别名匹配可以避免列名歧义错误。paginationKeys: 这是游标分页的“灵魂”。它定义了游标由哪些字段构成。必须满足以下条件字段组合必须能唯一确定一条记录通常包含主键id就能保证。字段的顺序必须与你想实现的排序顺序一致。如果你按[createdAt, id]排序那么这里也应该填[createdAt, id]。query.limit: 控制单次返回的数据量。需要根据前端需求和性能权衡来设置。一次返回过多数据会增加数据库和网络压力。query.order: 全局排序方向。注意游标分页要求排序方向必须明确且固定不能在分页过程中改变。执行paginate方法后你会得到一个包含data当前页的数据数组和cursor对象的结果。cursor对象包含beforeCursor和afterCursor分别指向当前页数据的前后边界。对于第一页beforeCursor通常是null。3.3 查询下一页与上一页拿到cursor.afterCursor后获取下一页就非常简单了async function getNextPage(afterCursor: string) { const queryBuilder getConnection() .getRepository(User) .createQueryBuilder(user) .where(user.gender :gender, { gender: male }); const nextPaginator buildPaginator({ entity: User, paginationKeys: [id], query: { limit: 10, order: ASC, afterCursor: afterCursor, // 使用上一页返回的 afterCursor }, }); const { data, cursor: newCursor } await nextPaginator.paginate(queryBuilder); // data 将是上一页之后的数据 return { data, cursor: newCursor }; }查询上一页的逻辑类似只是使用beforeCursorasync function getPrevPage(beforeCursor: string) { const queryBuilder ... // 同样的 QueryBuilder const prevPaginator buildPaginator({ entity: User, paginationKeys: [id], query: { limit: 10, order: ASC, beforeCursor: beforeCursor, // 使用当前页的 beforeCursor }, }); const { data, cursor } await prevPaginator.paginate(queryBuilder); // 注意返回的 data 顺序与你请求时的 order 一致。 // 当使用 beforeCursor 且 orderASC 时库内部会先做一次 DESC 查询再反转以获取“之前”的数据。 return { data, cursor }; }一个重要的实践细节在API设计中客户端如前端通常只需要关心一个“下一页”的游标。常见的做法是服务器始终返回afterCursor指向当前页最后一条记录之后的位置。客户端在请求下一页时带上这个afterCursor。对于“上一页”功能客户端可以缓存之前访问过的页面的游标或者使用当前页的beforeCursor。typeorm-cursor-pagination同时提供两个游标给了你更大的灵活性。4. 高级用法与实战技巧掌握了基础用法我们来看看在实际项目中可能会遇到的一些复杂场景以及如何处理。4.1 多字段排序与复合游标这是游标分页中最能体现其优势的场景之一。假设你的产品列表需要按“评分(score)降序、上架时间(releaseDate)降序”来排列并且要处理评分相同、上架时间也相同的情况这时需要第三个字段如id来保证顺序稳定。首先确保你的QueryBuilder设置了正确的排序const queryBuilder getConnection() .getRepository(Product) .createQueryBuilder(product) .orderBy(product.score, DESC) .addOrderBy(product.releaseDate, DESC) .addOrderBy(product.id, ASC); // 最终保障唯一性的字段然后在构建分页器时paginationKeys必须与这个排序顺序完全对应const paginator buildPaginator({ entity: Product, alias: product, paginationKeys: [score, releaseDate, id], // 顺序至关重要 query: { limit: 20, order: DESC, // 注意这里的 order 是第一个 paginationKey 的主顺序 afterCursor: req.query.after, }, });重要提示query.order参数在这里仅代表第一个paginationKey(score) 的排序方向。后续字段 (releaseDate,id) 的排序方向是由你在QueryBuilder中使用.addOrderBy()指定的。库会读取QueryBuilder中的完整排序信息来构建正确的游标和查询条件。因此务必保持QueryBuilder中的排序与paginationKeys的字段顺序一致。4.2 在复杂查询中集成JOIN子查询typeorm-cursor-pagination与 TypeORM 的QueryBuilder无缝集成这意味着你可以在任何合法的QueryBuilder上使用它。场景查询用户及其最新发布的文章。const queryBuilder getConnection() .getRepository(User) .createQueryBuilder(user) .leftJoinAndSelect(user.articles, article) .where(article.isPublished :isPublished, { isPublished: true }) .orderBy(user.createdAt, DESC) // 按用户创建时间排序 .addOrderBy(user.id, ASC); const paginator buildPaginator({ entity: User, alias: user, // 别名必须是主查询的 FROM 实体的别名 paginationKeys: [createdAt, id], query: { limit: 15, order: DESC }, }); const { data, cursor } await paginator.paginate(queryBuilder);注意事项alias必须正确它必须与createQueryBuilder(user)中的别名一致因为游标条件会以user.createdAt和user.id的形式添加到WHERE子句中。小心SELECT字段分页逻辑依赖于paginationKeys字段的值来构造游标。确保你的QueryBuilder没有使用.select()过度筛选字段导致这些关键字段未被选中。如果遇到问题可以尝试在QueryBuilder中显式地addSelect这些字段。性能JOIN操作本身会增加查询复杂度。确保关联字段和被排序的字段如user.createdAt上有合适的数据库索引这是高性能分页的前提。4.3 自定义查询与过滤分页器构建的游标条件WHERE id :cursor会以AND的形式追加到你原有的QueryBuilder的WHERE条件之后。这意味着你可以自由地添加任何前置过滤条件。const queryBuilder getConnection() .getRepository(Order) .createQueryBuilder(order) .where(order.status :status, { status: PAID }) // 基础过滤条件 .andWhere(order.amount :minAmount, { minAmount: 100 }) // 另一个过滤条件 .orderBy(order.paidAt, DESC) .addOrderBy(order.id, ASC); const paginator buildPaginator({ entity: Order, paginationKeys: [paidAt, id], query: { limit: 50, order: DESC, afterCursor: nextCursor }, }); // 最终生成的 SQL 类似于 // SELECT ... FROM orders order // WHERE order.status PAID // AND order.amount 100 // AND (order.paidAt :cursorPaidAt OR (order.paidAt :cursorPaidAt AND order.id :cursorId)) // ORDER BY order.paidAt DESC, order.id ASC // LIMIT 50这种设计非常灵活你可以先构建一个复杂的、满足业务需求的查询然后再轻松地为其加上游标分页的能力。5. 性能优化与索引策略游标分页的性能优势建立在数据库索引的有效利用上。如果索引没设计好性能可能比OFFSET还差。5.1 必须为paginationKeys创建复合索引这是最重要的优化点。数据库索引就像书的目录。如果你要按“姓氏、名字”排序那么一个包含姓氏名字的复合索引就比两个单独索引高效得多。以[createdAt, id]为例最佳索引CREATE INDEX idx_pagination ON your_table (created_at DESC, id ASC);注意索引中字段的顺序必须与paginationKeys顺序一致。排序方向 (DESC/ASC) 也应尽量与你的主要查询顺序匹配。如果查询ORDER BY created_at DESC, id ASC那么索引(created_at DESC, id ASC)是最优的。不过大多数数据库的索引也能高效地支持反向扫描所以(created_at, id)这样的升序索引通常也够用但明确指定方向更稳妥。错误示例只有(id)索引或只有(created_at)索引。当数据库执行WHERE created_at ? AND id ?这样的复合条件时可能只能利用其中一个索引另一个条件就需要回表过滤影响性能。5.2 避免在paginationKeys字段上使用函数或计算游标分页的WHERE条件通常是简单的字段比较。如果你在QueryBuilder中对paginationKeys字段使用了函数例如.orderBy(DATE(user.createdAt), DESC) // 对日期字段使用函数那么库生成的游标条件也会是DATE(user.createdAt) ?。这种条件无法有效利用user.createdAt上的普通索引会导致全表扫描。解决方案是如果可能在数据库设计阶段就增加一个计算列并为其建立索引或者在应用层避免这种操作。5.3 监控与EXPLAIN分析在将分页查询部署到生产环境前务必使用数据库的EXPLAIN命令如EXPLAIN ANALYZEin PostgreSQL来分析查询计划。你需要确认是否使用了正确的索引在输出中寻找Index Scan或Index Range Scan字样。是否有不必要的全表扫描Seq Scan这通常意味着索引缺失或查询条件无法使用索引。是否有排序操作Sort理想的游标分页查询应该是一个Index Scan后直接LIMIT不需要额外的Sort步骤。如果出现了Sort检查你的ORDER BY子句是否与索引顺序完全匹配。6. 常见问题与排查实录在实际集成typeorm-cursor-pagination时我踩过一些坑这里总结出来帮你快速排雷。6.1 问题返回的data顺序不符合预期现象请求下一页时返回的数据顺序看起来是乱的或者包含了上一页已经出现过的数据。排查步骤检查paginationKeys顺序这是最常见的原因。paginationKeys数组中的字段顺序必须与QueryBuilder中orderBy/addOrderBy的完整顺序完全一致。包括排序方向。如果QueryBuilder是.orderBy(A, DESC).addOrderBy(B, ASC)那么paginationKeys必须是[A, B]。检查query.orderquery.order参数应该与第一个paginationKey的排序方向一致。在上面的例子中query.order应该是DESC。检查数据唯一性确保paginationKeys的组合能唯一确定一行数据。如果最后一条记录不唯一例如仅按非唯一的createdAt排序那么游标定位就会模糊导致分页错乱。务必在最后加上主键id作为最终的排序和游标字段。检查游标传递确保客户端在请求下一页时正确传递了afterCursor参数并且服务器端没有错误地修改或丢失它。6.2 问题使用beforeCursor查询上一页时数据顺序反了现象点击“上一页”按钮返回的数据顺序是倒过来的。原因与解决这是游标分页的特性而非bug。当你用beforeCursor且orderASC查询“之前”的数据时库内部执行的是WHERE id :cursor ORDER BY id DESC LIMIT然后手动将结果反转以保证返回给你的数据顺序依然是ASC。这个过程是透明的。你只需要确保前端按照你给定的order方向这里是ASC来渲染列表即可库返回的data顺序已经是正确的。6.3 问题在包含JOIN的查询中报错 “column reference is ambiguous”现象执行分页时数据库抛出错误提示某个字段名存在歧义。原因当QueryBuilder中涉及多个表通过JOIN时如果paginationKeys中的字段名在两个表中都存在SQL 引擎就不知道你要用哪个表的字段。解决方案显式指定alias在buildPaginator参数中明确设置alias: ‘main’与你的主createQueryBuilder(‘main’)别名一致。在paginationKeys中使用完整别名虽然库的文档示例是[‘id’]但在复杂查询中你可以尝试使用[‘main.id’]。不过根据库的源码它通常会使用你提供的alias来修饰字段名。最可靠的方法是确保你的QueryBuilder中主表的别名清晰并在buildPaginator中传入相同的alias。检查QueryBuilder确保你的QueryBuilder中没有重复的别名并且所有的列引用都尽可能明确。6.4 问题分页到末尾的判断现象如何知道已经没有更多数据了标准做法检查返回的cursor.afterCursor。如果它是null说明当前页已经是最后一页后面没有更多数据了。同理如果cursor.beforeCursor是null说明当前页是第一页。前端可以根据这个信息来禁用“下一页”或“上一页”按钮。const { data, cursor } await paginator.paginate(queryBuilder); const hasNextPage cursor.afterCursor ! null; const hasPrevPage cursor.beforeCursor ! null;6.5 性能问题分页查询依然很慢排查方向索引如第5节所述运行EXPLAIN确认是否使用了复合索引。limit大小一次性获取的limit是否过大尝试减小limit值如从100降到20看是否有改善。原始查询复杂度你的QueryBuilder本身是否非常复杂包含大量的JOIN、子查询或者复杂的WHERE条件游标分页优化的是“跳过”数据的部分但基础查询的复杂度依然存在。考虑对基础查询进行优化比如减少不必要的JOIN或者为WHERE条件中的其他字段也建立索引。数据总量即使使用游标当WHERE条件筛选出的结果集非常大时数据库在索引中定位起始点的开销也会变大但这仍然远优于OFFSET的全表扫描。7. 在真实API项目中的集成示例让我们把这些知识点串联起来看一个在 NestJS 框架中集成typeorm-cursor-pagination的完整服务层示例。// product.service.ts import { Injectable } from nestjs/common; import { InjectRepository } from nestjs/typeorm; import { Repository } from typeorm; import { buildPaginator } from typeorm-cursor-pagination; import { Product } from ./product.entity; export interface PaginatedProductResponse { data: Product[]; meta: { limit: number; hasNextPage: boolean; hasPrevPage: boolean; nextCursor: string | null; prevCursor: string | null; }; } Injectable() export class ProductService { constructor( InjectRepository(Product) private productRepository: RepositoryProduct, ) {} async findProducts( category?: string, minPrice?: number, limit: number 20, afterCursor?: string, beforeCursor?: string, ): PromisePaginatedProductResponse { // 1. 构建基础查询 const queryBuilder this.productRepository .createQueryBuilder(product) .where(product.isActive :isActive, { isActive: true }); // 2. 动态添加过滤条件 if (category) { queryBuilder.andWhere(product.category :category, { category }); } if (minPrice) { queryBuilder.andWhere(product.price :minPrice, { minPrice }); } // 3. 定义排序规则必须稳定 queryBuilder .orderBy(product.featuredScore, DESC) // 主排序推荐分数 .addOrderBy(product.createdAt, DESC) // 次排序上新时间 .addOrderBy(product.id, ASC); // 最终保障ID // 4. 构建分页器 const paginator buildPaginator({ entity: Product, alias: product, paginationKeys: [featuredScore, createdAt, id], // 与排序对应 query: { limit, order: DESC, // 与第一个 paginationKey 的排序方向一致 afterCursor, beforeCursor, }, }); // 5. 执行分页查询 const { data, cursor } await paginator.paginate(queryBuilder); // 6. 构造友好的响应格式 return { data, meta: { limit, hasNextPage: cursor.afterCursor ! null, hasPrevPage: cursor.beforeCursor ! null, nextCursor: cursor.afterCursor, prevCursor: cursor.beforeCursor, }, }; } }然后在控制器中// product.controller.ts import { Controller, Get, Query } from nestjs/common; import { ProductService, PaginatedProductResponse } from ./product.service; Controller(products) export class ProductController { constructor(private readonly productService: ProductService) {} Get() async findAll( Query(category) category?: string, Query(minPrice) minPrice?: number, Query(limit) limit?: number, Query(after) after?: string, Query(before) before?: string, ): PromisePaginatedProductResponse { return this.productService.findProducts( category, minPrice ? Number(minPrice) : undefined, limit ? Number(limit) : 20, after, before, ); } }前端调用示例第一页GET /products?limit20下一页GET /products?limit20after{上一页返回的nextCursor}上一页GET /products?limit20before{当前页返回的prevCursor}这种设计清晰地将分页状态封装在游标中API 接口简洁且无状态非常适合现代前端框架和无限滚动加载。8. 与其他分页方案的对比与选型思考在项目技术选型时了解不同方案的优劣很重要。这里将游标分页与常见的OFFSET/LIMIT以及“下一页ID”方案做个简单对比。特性游标分页 (Cursor-based)传统 OFFSET/LIMIT基于ID的“下一页” (WHERE id last_id)性能优秀。常数时间 O(1)不受数据偏移量影响。差。线性时间 O(N)偏移量越大越慢。优秀。与游标分页单字段本质相同性能好。数据稳定性优秀。基于唯一游标不受中间数据增删影响。差。页面漂移问题严重。良好。如果数据只追加不删除且按ID顺序查询则稳定。功能灵活性高。支持多字段复杂排序支持向前、向后翻页。高。支持随机跳转到任意页面。低。通常只支持按ID顺序的“下一页”不支持复杂排序和向前翻页。客户端复杂度低。只需管理一个不透明的游标字符串。低。只需管理页码。低。只需管理最后一个ID。适用场景无限滚动、实时数据流、社交动态、任何需要稳定高效顺序分页的场景。后台管理系统、数据总量固定且小的列表、需要跳页的场景。简单的按时间线或ID递增排列的列表如日志、消息。选型建议绝大多数面向用户的列表API尤其是移动端首选游标分页。它的高性能和稳定性对用户体验至关重要。后台管理系统的表格如果数据量不大且需要跳页功能可以使用OFFSET/LIMIT。如果数据量大可以考虑使用游标分页并辅以前端输入框实现“跳转到某条记录附近”的功能这需要额外的设计。极其简单的时序数据如果排序方式永远是ID或createdAt递增那么简单的WHERE id :lastId方案就足够了无需引入库。typeorm-cursor-pagination库的价值在于它让在 TypeORM 中实现功能完整、稳定高效的游标分页变得异常简单几乎消除了所有的样板代码和容易出错的细节处理让你可以专注于业务逻辑本身。