Chapter 08

分页:Cursor vs Offset

掌握 GraphQL 分页最佳实践——Relay Connection 规范

分页方案对比

GraphQL API 中最常见的两种分页方案各有适用场景:

Offset 分页(skip/limit)
传统分页方式,通过跳过 N 条记录实现。实现简单,支持跳转到任意页(如"第15页")。适合数据变化不频繁、需要"跳页"功能的场景(如管理后台列表)。
Cursor 分页(Relay Connection)
基于不透明游标(Cursor)的分页,游标通常是对记录 ID 或排序字段的编码。不会受并发写入影响,大数据集性能稳定(不需要 OFFSET 扫描)。适合无限滚动、实时流、大数据集的场景(如社交动态、新闻流)。

Offset 分页的局限

# Offset 分页:skip/take
type Query {
  posts(skip: Int = 0, take: Int = 20): [Post!]!
}

query {
  posts(skip: 0, take: 20)  # 第1页
  posts(skip: 20, take: 20) # 第2页
}
并发写入问题
当用户浏览第2页时,如果有新帖子插入,会导致数据"漂移"——原来第1页的最后一条可能变成第2页的第一条,产生重复数据。
大偏移量性能差
SQL 中 OFFSET 10000 需要扫描并丢弃前 10000 条数据,性能随分页深度线性下降。
总数查询开销
计算总页数需要 COUNT(*) 查询,在大表中代价昂贵。

Relay Cursor Connection 规范

Schema 定义

# Connection 模式的通用结构
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!  # 不透明的分页游标(通常是 base64 编码的 ID)
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String     # 当前页第一个元素的 cursor
  endCursor: String       # 当前页最后一个元素的 cursor
}

type Query {
  posts(
    first: Int    # 向前分页:取前 N 条
    after: String # 从指定 cursor 之后开始
    last: Int     # 向后分页:取后 N 条
    before: String # 从指定 cursor 之前开始
  ): PostConnection!
}

客户端查询示例

# 第1页:获取前20条
query {
  posts(first: 20) {
    edges {
      cursor
      node { id title createdAt author { name } }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

# 第2页:从 endCursor 之后获取下20条
query {
  posts(first: 20, after: "Y3Vyc29yOjIw") {
    edges {
      cursor
      node { id title }
    }
    pageInfo { hasNextPage endCursor }
  }
}

服务器实现

function encodeCursor(id: string): string {
  return Buffer.from(`cursor:${id}`).toString('base64');
}

function decodeCursor(cursor: string): string {
  return Buffer.from(cursor, 'base64').toString().replace('cursor:', '');
}

const resolvers = {
  Query: {
    posts: async (_, { first = 20, after, last, before }, { db }) => {
      const take = first ?? last ?? 20;
      const cursor = after ? { id: decodeCursor(after) } : undefined;

      const posts = await db.posts.findMany({
        take: take + 1, // 多取1条用于判断 hasNextPage
        cursor,
        skip: cursor ? 1 : 0,
        orderBy: { createdAt: 'desc' },
      });

      const hasNextPage = posts.length > take;
      const nodes = hasNextPage ? posts.slice(0, -1) : posts;
      const totalCount = await db.posts.count();

      return {
        totalCount,
        edges: nodes.map(post => ({
          node: post,
          cursor: encodeCursor(post.id),
        })),
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: nodes[0] ? encodeCursor(nodes[0].id) : null,
          endCursor: nodes.at(-1) ? encodeCursor(nodes.at(-1)!.id) : null,
        },
      };
    },
  },
};
使用 @pothos/plugin-relay 简化

手写 Connection 类型很繁琐。推荐使用 Pothos GraphQLNexus 等 schema-first 构建工具,它们内置了 Relay Connection 的实现,几行代码即可生成完整的分页 Schema 和 Resolver。

Cursor 的编码原理

游标(Cursor)是一个不透明(Opaque)的字符串,客户端不应该尝试解析它的内容,只需原样传回给服务器。服务器内部决定游标的编码方式:

基于 ID 的游标
将记录的唯一 ID(如数据库主键)用 base64 编码,格式通常为 cursor:${id}。优点:简单高效;缺点:如果需要多字段排序,则无法仅靠 ID 确定位置。
基于排序字段的游标
将排序字段值(如 createdAt + id)一起编码。解码后用 WHERE (createdAt, id) < (:cursor_time, :cursor_id) 查询,适合复合排序场景。
不透明性保证
使用 base64 编码而非明文,是向客户端传达"请勿解析"的约定,而非安全手段。若需防篡改,可加 HMAC 签名校验游标的合法性。

前端无限滚动实现

// React 无限滚动示例(配合 Apollo Client)
import { useQuery } from '@apollo/client';
import { useRef, useCallback } from 'react';

const GET_POSTS = gql`
  query GetPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        cursor
        node { id title createdAt }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostFeed() {
  // fetchMore 是 Apollo 提供的追加数据方法
  const { data, loading, fetchMore } = useQuery(GET_POSTS, {
    variables: { first: 20 },
  });

  // IntersectionObserver 监听"加载更多"触发元素
  const observer = useRef<IntersectionObserver>();
  const lastPostRef = useCallback((node: Element | null) => {
    if (loading) return;
    if (observer.current) observer.current.disconnect();

    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && data?.posts.pageInfo.hasNextPage) {
        // 用 endCursor 获取下一页
        fetchMore({
          variables: { after: data.posts.pageInfo.endCursor },
          updateQuery: (prev, { fetchMoreResult }) => {
            if (!fetchMoreResult) return prev;
            return {
              posts: {
                ...fetchMoreResult.posts,
                // 合并 edges 数组:旧数据 + 新数据
                edges: [
                  ...prev.posts.edges,
                  ...fetchMoreResult.posts.edges,
                ],
              },
            };
          },
        });
      }
    });

    if (node) observer.current.observe(node);
  }, [loading, data?.posts.pageInfo]);

  const posts = data?.posts.edges ?? [];

  return (
    <div>
      {posts.map((edge, i) => (
        // 最后一条加 ref,触发加载更多
        <PostCard
          key={edge.node.id}
          post={edge.node}
          ref={i === posts.length - 1 ? lastPostRef : null}
        />
      ))}
      {loading && <Spinner />}
    </div>
  );
}

两种分页方案选择指南

选择 Offset 分页的场景
管理后台列表(需要跳到第 N 页)、数据基本静态(无并发写入)、需要展示总页数(如"共 50 页")、数据集较小(< 10 万条)。
选择 Cursor 分页的场景
社交动态/新闻流(无限滚动)、数据实时变化(频繁插入/删除)、大数据集(> 100 万条,OFFSET 性能不可接受)、移动端 App(用户不需要跳页)。
常见误区
Cursor 分页并非万能——它不支持"跳页"(无法直接跳到第50页),也不方便显示"当前在第几页"。很多产品同时提供两种接口:公开 API 用 Cursor(符合 Relay 规范),内部管理 API 用 Offset(便于运营操作)。

本章小结

本章核心要点