游标分页较传统分页的优势以及详细实现
✨文章摘要(AI生成)
笔者在这篇文章中探讨了游标分页相较于传统分页的优势及其具体实现。在处理大量数据时,分页技术可以显著提升用户体验和应用性能。传统分页基于页数和项目数量,虽然实现简单且用户友好,但在大数据量下可能导致性能下降和数据一致性问题。因此,游标分页应运而生。
游标分页的关键在于基于上一页最后一个项目进行数据加载,避免了高OFFSET
带来的性能损失,并且即使在数据插入的情况下,也能保持页面内容的一致性。然而,游标分页的实现相对复杂,且用户无法直接跳转到任意页面,这可能影响用户体验。
笔者通过Nest.js
和GraphQL
提供了游标分页的详细实现代码示例,强调了在不同业务需求下选择合适分页方式的重要性。对于需要快速响应的移动端应用,游标分页无疑是一个更优的选择。
前言
在构建 Web 应用时,我们经常需要处理大量的数据。为了提高用户体验和应用性能,我们通常会采用分页技术,将数据分成多个页面,每个页面包含一部分数据。这样,用户可以按需加载和查看数据,而不是一次性加载所有数据,这对于数据量大的应用尤其重要。
前面使用 graphql 做了一个简单的后台管理系统,然后在官网上看见了 graphql 推荐使用游标分页的方式来实现分页,这是笔者之前从来没有使用过的一种方法,于是就瞬间激发了笔者自己的兴趣。
然后简单地做了下技术调研,了解了游标分页与传统分页之间的差异,发现基于游标分页的分页方式挺适合笔者这个项目的,于是就开始学习其中的具体实现以及开始编码了。
传统分页
简介
传统的分页方法是基于页数和每页的项目数量。例如,如果我们有 100 个项目,每页显示 10 个项目,那么我们就有 10 页。用户可以通过指定页数来获取特定的页面。在技术实现上,我们通常在 SQL 查询中使用LIMIT
和OFFSET
来获取特定页面的数据。
优缺点
传统分页的优点主要有两个:
- 实现简单:只需要在 SQL 查询中使用
LIMIT
和OFFSET
就可以实现。这使得传统分页在很多情况下都是一个快速、简单的解决方案。 - 用户友好:用户可以直接跳转到任何一页。这对于用户来说是非常直观的,他们可以很容易地理解和使用这种分页方式。
传统分页也有一些缺点:
- 性能问题:当
OFFSET
很大时,数据库需要跳过许多行,这可能会导致性能问题。这是因为在获取数据之前,数据库需要先找到OFFSET
指定的位置,这可能需要遍历大量的数据。 - 数据一致性问题:如果在用户浏览页面时有新的数据插入,那么同一页的内容可能会发生变化。这可能会导致用户看到重复的数据,或者错过一些数据。
正因为这两个问题,所以才引出了游标分页技术的出现。
基于游标的分页
简介
注意此游标非彼游标,这里并不是指的是 mysql 这类中的游标,可以简单理解为一个标记,一个 token 之类的东西。
基于游标的分页是一种新的分页方法,它不是基于页数,而是基于上一页的最后一个项目。例如,如果我们有 100 个项目,每页显示 10 个项目,那么第二页的第一个项目就是第一页的最后一个项目的下一个项目。在技术实现上,我们通常在 SQL 查询中使用WHERE
和LIMIT
,并且需要处理游标。
对于游标分页,可以参考这个链接中指定的规范
优缺点
由于就是为了解决传统分页存在的问题的,所以基于游标的分页的优点就是传统分页缺点的解决:
- 性能优化:不需要跳过任何行,只需要从上一页的最后一个项目开始。这意味着数据库只需要处理实际需要的数据,而不需要处理
OFFSET
指定的所有数据。 - 数据一致性:即使有新的数据插入,也不会影响已经浏览过的页面。这是因为每一页的数据都是基于上一页的最后一个项目,而不是基于页数。
但也不是完全都是有点,游标分页也具有下面这些缺点:
- 实现复杂:需要在 SQL 查询中使用
WHERE
和LIMIT
,并且需要处理游标。这使得基于游标的分页在实现上比传统的分页更复杂。 - 用户体验:用户不能直接跳转到任何一页。这可能会使用户在使用上感到不方便,特别是在需要浏览大量页面的情况下。
具体实现
这里以nest.js+graphql
为例,主要实现是下方的paginate.ts
文件:
page-info.ts
import { ObjectType, Field } from "@nestjs/graphql";
@ObjectType()
export class PageInfo {
@Field({ nullable: true })
startCursor: string;
@Field({ nullable: true })
endCursor: string;
@Field()
hasPreviousPage: boolean;
@Field()
hasNextPage: boolean;
}
paginate.ts
import { Logger } from '@nestjs/common';
import { PageInfo } from './page-info';
import { PaginationArgs } from './pagination.args';
import { SelectQueryBuilder, MoreThan, LessThan } from 'typeorm';
/**
* Based on https://gist.github.com/VojtaSim/6b03466f1964a6c81a3dbf1f8cec8d5c
*/
export async function paginate<T>(
query: SelectQueryBuilder<T>,
paginationArgs: PaginationArgs,
cursorColumn = 'id',
defaultLimit = 25,
): Promise<any> {
const logger = new Logger('Pagination');
// pagination ordering
query.orderBy({ [cursorColumn]: 'DESC' })
const totalCountQuery = query.clone();
// FORWARD pagination
if (paginationArgs.first) {
if (paginationArgs.after) {
const offsetId = Number(Buffer.from(paginationArgs.after, 'base64').toString('ascii'));
logger.verbose(`Paginate AfterID: ${offsetId}`);
query.where({ [cursorColumn]: MoreThan(offsetId) });
}
const limit = paginationArgs.first ?? defaultLimit;
query.take(limit)
}
// REVERSE pagination
else if (paginationArgs.last && paginationArgs.before) {
const offsetId = Number(Buffer.from(paginationArgs.before, 'base64').toString('ascii'));
logger.verbose(`Paginate BeforeID: ${offsetId}`);
const limit = paginationArgs.last ?? defaultLimit;
query
.where({ [cursorColumn]: LessThan(offsetId) })
.take(limit);
}
const result = await query.getMany();
const startCursorId: number = result.length > 0 ? result[0][cursorColumn] : null;
const endCursorId: number = result.length > 0 ? result.slice(-1)[0][cursorColumn] : null;
const beforeQuery = totalCountQuery.clone();
const afterQuery = beforeQuery.clone();
let countBefore = 0;
let countAfter = 0;
if (beforeQuery.expressionMap.wheres && beforeQuery.expressionMap.wheres.length) {
countBefore = await beforeQuery
.andWhere(`${cursorColumn} < :cursor`, { cursor: startCursorId })
.getCount();
countAfter = await afterQuery
.andWhere(`${cursorColumn} > :cursor`, { cursor: endCursorId })
.getCount();
} else {
countBefore = await beforeQuery
.where(`${cursorColumn} < :cursor`, { cursor: startCursorId })
.getCount();
countAfter = await afterQuery
.where(`${cursorColumn} > :cursor`, { cursor: endCursorId })
.getCount();
}
logger.debug(`CountBefore:${countBefore}`);
logger.debug(`CountAfter:${countAfter}`);
const edges = result.map((value) => {
return {
node: value,
cursor: Buffer.from(`${value[cursorColumn]}`).toString('base64'),
};
});
const pageInfo = new PageInfo();
pageInfo.startCursor = edges.length > 0 ? edges[0].cursor : null;
pageInfo.endCursor = edges.length > 0 ? edges.slice(-1)[0].cursor : null;
pageInfo.hasNextPage = countAfter > 0;
pageInfo.hasPreviousPage = countBefore > 0;
// pageInfo.countBefore = countBefore;
// pageInfo.countNext = countAfter;
// pageInfo.countCurrent = edges.length;
// pageInfo.countTotal = countAfter + countBefore + edges.length;
return { edges, pageInfo };
}
paginated-post.ts
/**
* Example of paginated graphql model
*/
import { Post } from "../models/post.model";
import { ObjectType } from '@nestjs/graphql';
import { Paginated } from "src/shared/pagination/types/paginated";
@ObjectType()
export class PaginatedPost extends Paginated(Post) { }
paginated.ts
import { Field, ObjectType } from '@nestjs/graphql';
import { Type } from '@nestjs/common';
import { PageInfo } from './page-info';
/**
* Based on https://docs.nestjs.com/graphql/resolvers#generics
*
* @param classRef
*/
export function Paginated<T>(classRef: Type<T>): any {
@ObjectType(`${classRef.name}Edge`, { isAbstract: true })
abstract class EdgeType {
@Field(() => String)
cursor: string;
@Field(() => classRef)
node: T;
}
@ObjectType({ isAbstract: true })
abstract class PaginatedType {
@Field(() => [EdgeType], { nullable: true })
edges: EdgeType[];
@Field(() => PageInfo, { nullable: true })
pageInfo: PageInfo;
}
return PaginatedType;
}
pagination.args.ts
import { ArgsType, Int, Field } from '@nestjs/graphql';
@ArgsType()
export class PaginationArgs {
@Field(() => Int, { nullable: true })
first: number;
@Field(() => String, { nullable: true })
after: string;
@Field(() => Int, { nullable: true })
last: number;
@Field(() => String, { nullable: true })
before: string;
}
post-resolver.ts
import { Post } from "../models/post.model";
import { PostService } from '../providers/post.service';
@Resolver(() => Post)
export class PostResolver {
constructor(private readonly postService: PostService) { }
@Query(() => PaginatedPost)
getPosts(
@Args() pagination: PaginationArgs,
@Args() filter: PostFilter,
): Promise<PaginatedPost> {
return this.postService.getPaginatedPosts(pagination, filter);
}
}
post.service.ts
import { paginate } from './paginate';
@Injectable()
export class PostService {
private readonly logger = new Logger('PostService');
constructor(
@InjectRepository(PostRepository)
private postRepository: PostRepository,
) { }
async getPaginatedPosts(paginationArgs: PaginationArgs, filter: PostFilter): Promise<PaginatedPost> {
const query = await this.postRepository
.createQueryBuilder()
.select();
// todo... you can apply filters here to the query as where clauses
return paginate(query, paginationArgs);
}
}
以上代码部分参考自如下链接
最后
是否选用游标分页还需看具体的业务要求,比如 PC 端需要分页组件,用户可以点击任意一页,那游标分页自然不能使用,但如果移动端的无限滚动,就可以使用游标分页。