Skip to content

游标分页较传统分页的优势以及详细实现

前言

在构建Web应用时,我们经常需要处理大量的数据。为了提高用户体验和应用性能,我们通常会采用分页技术,将数据分成多个页面,每个页面包含一部分数据。这样,用户可以按需加载和查看数据,而不是一次性加载所有数据,这对于数据量大的应用尤其重要。

前面使用graphql做了一个简单的后台管理系统,然后在官网上看见了graphql推荐使用游标分页的方式来实现分页,这是笔者之前从来没有使用过的一种方法,于是就瞬间激发了笔者自己的兴趣。

然后简单地做了下技术调研,了解了游标分页与传统分页之间的差异,发现基于游标分页的分页方式挺适合笔者这个项目的,于是就开始学习其中的具体实现以及开始编码了。

传统分页

简介

传统的分页方法是基于页数和每页的项目数量。例如,如果我们有100个项目,每页显示10个项目,那么我们就有10页。用户可以通过指定页数来获取特定的页面。在技术实现上,我们通常在SQL查询中使用LIMITOFFSET来获取特定页面的数据。

优缺点

传统分页的优点主要有两个:

  1. 实现简单:只需要在 SQL 查询中使用 LIMIT 和 OFFSET 就可以实现。这使得传统分页在很多情况下都是一个快速、简单的解决方案。
  2. 用户友好:用户可以直接跳转到任何一页。这对于用户来说是非常直观的,他们可以很容易地理解和使用这种分页方式。

传统分页也有一些缺点:

  1. 性能问题:当 OFFSET 很大时,数据库需要跳过许多行,这可能会导致性能问题。这是因为在获取数据之前,数据库需要先找到OFFSET指定的位置,这可能需要遍历大量的数据。
  2. 数据一致性问题:如果在用户浏览页面时有新的数据插入,那么同一页的内容可能会发生变化。这可能会导致用户看到重复的数据,或者错过一些数据。

正因为这两个问题,所以才引出了游标分页技术的出现。

基于游标的分页

简介

注意此游标非彼游标,这里并不是指的是mysql这类中的游标,可以简单理解为一个标记,一个token之类的东西。

基于游标的分页是一种新的分页方法,它不是基于页数,而是基于上一页的最后一个项目。例如,如果我们有100个项目,每页显示10个项目,那么第二页的第一个项目就是第一页的最后一个项目的下一个项目。在技术实现上,我们通常在SQL查询中使用WHERELIMIT,并且需要处理游标。

对于游标分页,可以参考这个链接中指定的规范

优缺点

由于就是为了解决传统分页存在的问题的,所以基于游标的分页的优点就是传统分页缺点的解决:

  1. 性能优化:不需要跳过任何行,只需要从上一页的最后一个项目开始。这意味着数据库只需要处理实际需要的数据,而不需要处理OFFSET指定的所有数据。
  2. 数据一致性:即使有新的数据插入,也不会影响已经浏览过的页面。这是因为每一页的数据都是基于上一页的最后一个项目,而不是基于页数。

但也不是完全都是有点,游标分页也具有下面这些缺点:

  1. 实现复杂:需要在 SQL 查询中使用 WHERE 和 LIMIT,并且需要处理游标。这使得基于游标的分页在实现上比传统的分页更复杂。
  2. 用户体验:用户不能直接跳转到任何一页。这可能会使用户在使用上感到不方便,特别是在需要浏览大量页面的情况下。

具体实现

这里以nest.js+graphql为例,主要实现是下方的paginate.ts文件:

page-info.ts

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

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

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

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

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

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

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端需要分页组件,用户可以点击任意一页,那游标分页自然不能使用,但如果移动端的无限滚动,就可以使用游标分页。