游标分页与中继连接:从 offset 到 cursor 的工程实践

GraphQL 游标分页与 Relay Connection 实战:offset vs cursor 分页、edges/node/pageInfo 连接规范、游标编码、排序稳定性与过滤、totalCount 获取策略、无限滚动、分页缓存配合与常见坑。

分页是每个列表接口都绕不开的问题。REST 时代我们习惯 ?page=1&pageSize=20,但进入 GraphQL 后,页面、App、管理后台对分页的需求更加多元:有的要无限滚动、有的要跳页、有的要精确总数。Relay Connection(连接)规范用 edges / node / pageInfo 三层结构加不透明游标,成为 GraphQL 世界最主流的分页方案。本文从 offset 分页的缺陷讲起,系统讲解游标分页的原理、实现、与缓存、增量加载的配合,并列出生产环境最常见的分页坑。

一、offset vs cursor 分页

1.1 offset 分页的直觉与缺陷

# offset 分页:page = 2, size = 20
query {
  users(offset: 20, limit: 20) {
    id
    name
  }
}

offset 分页直觉、易实现,但有两个结构性缺陷:数据漂移(第 1 页期间有数据插入/删除,第 2 页会重复或漏掉记录)与深度分页性能(OFFSET 1000000 LIMIT 20 需扫描并丢弃前 100 万行,数据库随页深线性退化)。

1.2 cursor 分页的核心思路

游标分页不依赖"跳过多少条",而是锚定一条具体记录:客户端把上一页最后一条记录的游标传给服务端,服务端从游标位置继续取数,天然免疫数据漂移。

query {
  users(first: 20, after: "YXJyYXljb25uZWN0aW9uOjE5") {
    edges { node { id name } }
    pageInfo { hasNextPage endCursor }
  }
}

1.3 两者的适用场景对比

维度offset 分页cursor 分页
数据漂移有(插入/删除错位)无(锚定记录)
深度分页性能O(n) 扫描,页深即慢O(log n),始终快
随机跳页天然支持不支持(无页号)
实现复杂度低中
典型场景管理后台、页码 UIFeed、无限滚动、消息流

原则:面向人的"页码 UI"用 offset;面向流的"滚动/增量"用 cursor。两者可以共存于同一 Schema(users(offset, limit) 与 usersConnection(first, after))。


二、中继连接规范(edges/node/pageInfo)

2.1 Connection 的标准形态

Relay Connection 的完整结构:

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}

各部分的职责:

部分职责
edges[].node实际的业务数据
edges[].cursor该条记录的不透明游标(用于继续翻页)
pageInfo.hasNextPage是否还有下一页(驱动"加载更多"按钮)
pageInfo.startCursor/endCursor首尾游标,便于反向遍历
totalCount总条数(非 Relay 规范字段,按需提供)

2.2 为什么叫 Connection / Edge / Node

这套命名来自图论:Connection 是一条"边",Edge 连接 Node。语义上的收益是:分页信息(游标)与业务数据(node)解耦,客户端可以只关注 node,游标由 Edge 承载。

2.3 参数的正交性

Relay 规范要求 first/after 与 last/before 成对使用:

参数组合语义
first: 20, after: cursor从游标之后取 20 条(向后翻)
last: 20, before: cursor从游标之前取 20 条(向前翻)
first: 20(无 after)从头取前 20 条
last: 20(无 before)从尾取最后 20 条

服务端应校验:first 与 last 不能同时为空(二者至少一个),且 first/last 必须有上限(如 100)。


三、游标编码(base64/不透明)

3.1 游标的"不透明"原则

游标对客户端必须完全不透明:客户端只负责原样传递,永远不解析、不构造。这样服务端可以随时改变游标内部编码而不破坏兼容性。

3.2 常见编码方案

// 方案一:Base64 编码(Relay 默认,明文可读但可逆)
export function encodeCursor(offsetOrId: string | number): string {
  return Buffer.from(`arrayconnection:${offsetOrId}`).toString('base64');
}

export function decodeCursor(cursor: string): string | number {
  const raw = Buffer.from(cursor, 'base64').toString('utf8');
  return raw.replace(/^arrayconnection:/, '');
}
// 方案二:键集编码(多列排序时编码完整排序键)
export function encodeKeysetCursor(row: { createdAt: Date; id: string }): string {
  const payload = JSON.stringify([row.createdAt.toISOString(), row.id]);
  return Buffer.from(payload).toString('base64url');
}

3.3 各种编码方式的取舍

方案优点缺点适用
Base64(offset)简单、调试方便可逆、暴露偏移量中小型单列分页
Base64(id)无偏移语义仅适合按 ID 顺序按主键排序
Base64URL(键集)支持多列稳定排序实现略复杂复杂排序 Feed
无意义随机串完全不可逆需存储映射高安全要求

原则:游标内容的编码可简可繁,但"不透明"是不可妥协的。客户端一旦开始解析游标,你就失去了演进游标格式的自由。


四、排序稳定性与过滤

4.1 为什么需要稳定排序

游标分页的根基是"游标之后的记录"。如果排序不稳定(相同 createdAt 的两条记录每次顺序不同),翻页会出现重复或漏数据。解决方法是总排序键 = 业务排序键 + 唯一键(如 created_at DESC, id DESC),保证全序。

4.2 键集分页(Keyset Pagination)的实现

基于排序键的键集分页是游标分页的高效落地方式:

async function usersConnection(args, ctx) {
  const { first = 20, after } = args;

  let where = '';
  const params: unknown[] = [];

  if (after) {
    const { createdAt, id } = decodeKeysetCursor(after);
    params.push(createdAt, id);
    // keyset 条件:created_at DESC, id DESC 的"在其后"
    where = `WHERE (created_at, id) < ($1::timestamptz, $2::text)`;
  }

  // 多取一条判断 hasNextPage
  const rows = await db.query(
    `SELECT id, name, created_at
     FROM users
     ${where}
     ORDER BY created_at DESC, id DESC
     LIMIT $${params.length + 1}`,
    [...params, first + 1],
  );

  const hasNextPage = rows.length > first;
  const pageRows = rows.slice(0, first);

  return {
    edges: pageRows.map((row) => ({
      node: row,
      cursor: encodeKeysetCursor(row),
    })),
    pageInfo: {
      hasNextPage,
      startCursor: pageRows[0] ? encodeKeysetCursor(pageRows[0]) : null,
      endCursor: pageRows[pageRows.length - 1] ? encodeKeysetCursor(pageRows[pageRows.length - 1]) : null,
    },
    totalCount: await countUsers(ctx), // 见第五节
  };
}

4.3 过滤与排序的组合

过滤条件会改变"游标之后"的语义,实现上必须把过滤条件同时作用于游标位置与查询:

过滤类型实现要点
等值过滤(status、categoryId)WHERE 增加条件,游标仍基于排序键
范围过滤(价格区间)WHERE 增加范围,注意与游标条件用 AND 连接
全文搜索(keyword)排序键可能变为相关性分数,游标需编码分数
软删除/权限过滤过滤条件必须也作用于翻页后的查询,否则越界泄露

一个经典 bug:第一页过滤了 status = 'published',翻页时忘记带过滤条件,导致后续页混入草稿。过滤条件必须内聚在同一个取数函数中,翻页时整体复用。


五、总条数获取策略(count 开销)

5.1 totalCount 的代价

totalCount 不是 Relay 规范必需字段,但它常被业务要求(“共 1280 条”)。问题在于 SELECT count(*) 在大表上是全表扫描,对高频 Feed 查询是灾难。

5.2 获取 totalCount 的三种策略

策略实现代价适用
每次实时 countSELECT count(*)高,表大时不可用小表、低频
缓存 countRedis 缓存,按写失效中,有短暂延迟高频只读列表
估算/不提供采样估算或省略字段低Feed、无限滚动
// 缓存 totalCount 的模板(写路径失效)
async function getCachedTotalCount(key: string, freshCount: () => Promise<number>) {
  const cached = await redis.get(key);
  if (cached != null) return Number(cached);
  const total = await freshCount();
  await redis.set(key, total, 'EX', 300); // 5 分钟
  return total;
}

// 任何写操作成功后
await redis.del(`list:users:total`);

5.3 totalCount 与游标语义的一致性

注意:totalCount 与游标分页天然不完全一致——游标锚定的是"快照之后",而 count 是"当前时刻"。高频写入下两者未必对得上;若业务需要精确一致性,应引入版本化快照(如 asOf 参数),否则建议明确 totalCount 是"近似值"。对于无限滚动场景,根本不需要 totalCount——只需要 hasNextPage。是否提供 totalCount 应基于产品需求而非惯性。


六、无限滚动/增量加载

6.1 无限滚动的客户端状态

基于 Relay Connection 的无限滚动,客户端只需要维护两个状态:已加载的 node 列表与最后的 endCursor(配合 fetchMore 追加):

import { useCallback, useState } from 'react';
import { gql, useQuery } from '@apollo/client';

const FEED_QUERY = gql`
  query Feed($first: Int!, $after: String) {
    feed(first: $first, after: $after) {
      edges { node { id title } cursor }
      pageInfo { hasNextPage endCursor }
    }
  }
`;

export function useInfiniteFeed() {
  const [items, setItems] = useState<FeedItem[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const { data, fetchMore, loading } = useQuery(FEED_QUERY, {
    variables: { first: 20, after: null },
  });

  const loadMore = useCallback(async () => {
    if (loading || !data?.feed.pageInfo.hasNextPage) return;
    const { data: more } = await fetchMore({
      variables: { first: 20, after: data.feed.pageInfo.endCursor },
    });
    setCursor(more.feed.pageInfo.endCursor);
  }, [data, fetchMore, loading]);

  return { items, loadMore, hasNextPage: data?.feed.pageInfo.hasNextPage };
}

6.2 fetchMore 与 cache 的 merge

Apollo Client 的 fetchMore 会把新结果合并进缓存,需要自定义 merge 策略,否则新页会覆盖旧页:

import { InMemoryCache } from '@apollo/client';

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: false, // 忽略分页变量,按同一列表缓存
          merge(existing: any = { edges: [], pageInfo: {} }, incoming: any) {
            return {
              edges: [...existing.edges, ...incoming.edges],
              pageInfo: incoming.pageInfo,
            };
          },
        },
      },
    },
  },
});

6.3 增量加载的三种触发方式

触发方式实现适用
手动按钮“加载更多"按钮列表页、兼容 SEO
滚动监听IntersectionObserver移动端 Feed
虚拟列表 + 预取滚近底部提前 fetchMore长列表高性能场景
// IntersectionObserver 触发 loadMore
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) loadMore();
  });
  if (sentinelRef.current) observer.observe(sentinelRef.current);
  return () => observer.disconnect();
}, [loadMore]);

七、分页与缓存配合

7.1 分页查询的缓存键设计

分页查询的缓存失效是难点:某条记录更新后,所有包含它的列表缓存都可能过期。两条路线:

路线做法代价
短 TTL列表缓存 60–300s 自动过期可接受延迟
精准失效按 userId 维度做列表缓存 key,写路径失效需要维护失效映射

多租户场景下,列表缓存 key 必须包含租户维度:feed:v3:{tenantId}:{userId}。

7.2 Redis 分页缓存模板

// 游标分页 + Redis 排序集(ZSET)缓存
const KEY = `feed:${tenantId}:${userId}`;

// 写入时维护 ZSET:score = createdAt, member = postId
await redis.zadd(KEY, post.createdAtMs, post.id);
await redis.zremrangebyrank(KEY, 0, -5000); // 只保留最近 5000 条,防膨胀

// 翻页:ZREVRANGEBYSCORE 从游标 score 之后取
const nextPosts = await redis.zrevrangebyscore(KEY, cursorScore - 1, '-inf', 'LIMIT', 0, first);

7.3 游标与缓存的天然契合

游标分页与"append-only 缓存"高度契合:新数据追加在列表头,旧页数据不变。这比 offset 分页更适合 CDN/Redis 缓存——只要排序键稳定,某一页的游标结果可以被安全缓存一段时间。

原则:分页缓存的关键是"排序键稳定”。一旦排序键(如按热度动态排序)变化,所有基于旧游标的缓存都失去意义,此时应缩短 TTL 或直接禁用列表缓存。


八、常见坑

8.1 坑位清单

坑现象对策
游标解析失败客户端传了非法/过期游标解码后校验,失败返回 INVALID_CURSOR 错误
排序不唯一翻页重复/漏数据排序键补唯一键(id)
过滤条件丢失第二页混入不该出现的记录过滤内聚在统一取数函数
first 无上限一次取 10 万条运行时 clamp 到 max(如 100)
fetchMore 覆盖旧数据无限滚动只显示最后一页配置 cache merge
totalCount 实时扫描大表拖垮查询缓存/省略/估算
游标编码格式变更旧游标全部失效编码版本化,兼容解码旧格式
深链分享无法定位分享"第 300 条"链接提供 search 定位或接受 offset 混合

8.2 游标过期与数据删除

游标指向的记录被删除时,翻页不应报错,而是从游标位置继续向后取——这正是键集分页 WHERE (created_at, id) < (...) 的优势:条件本身不依赖记录是否存在。对于按 ID 偏移的游标,删除会导致"跳过一条",可用 isDeleted 软删除 + 过滤来规避。

8.3 混合模式:cursor + offset 并存

某些产品需要页码 UI(管理后台)+ 无限滚动(App 端)并存。此时可提供两套根字段,而非在同一个字段上叠加两种参数:

type Query {
  # 面向页码 UI
  users(page: Int = 1, pageSize: Int = 20): UsersPage!
  # 面向滚动流
  usersConnection(first: Int, after: String): UserConnection!
}

九、综合案例

9.1 一个消息流接口的完整实现

需求:即时通讯的消息流,按时间倒序,无限滚动,每条消息可变状态(已读/撤回)。

type MessageConnection {
  edges: [MessageEdge!]!
  pageInfo: PageInfo!
}

type MessageEdge {
  node: Message!
  cursor: String!
}

type Message {
  id: ID!
  content: String!
  status: MessageStatus!
  createdAt: String!
}

enum MessageStatus {
  SENT
  DELIVERED
  READ
  RECALLED
}

type Query {
  messages(conversationId: ID!, first: Int = 20, after: String): MessageConnection!
}

实现要点:排序键为 created_at DESC, id DESC 全序稳定;游标用键集编码 [createdAt, id] 做 base64url;状态可变的 status 字段用 @defer 或独立订阅(见 mutation 设计专题)做增量更新;新消息插入在列表头,after 游标不受影响;totalCount 用 Redis ZCARD 获得(ZSET 已维护),零额外查询。

9.2 性能对比示例

以下是一个 100 万行 users 表的实测对比(第一页均为 20 条):

方案第 1 页第 50000 页
OFFSET 999980 LIMIT 201ms850ms
键集分页((created_at, id) <)1ms1.2ms

游标分页在深分页场景下性能恒定,这正是它成为主流分页方案的根本原因。

9.3 十条分页铁律

  • 面向滚动/增量用 cursor,面向页码 UI 用 offset,不混在一个字段;
  • 排序键 = 业务键 + 唯一键,保证全序;
  • 游标对客户端不透明,编码格式可随时演进;
  • 过滤条件必须内聚,翻页时整体复用;
  • first/last 至少一个且设上限;
  • totalCount 能不提供就不提供,提供则缓存或估算;
  • fetchMore 必须配置 cache merge;
  • 多租户列表缓存 key 必须带租户维度;
  • 游标指向的记录删除不应导致翻页报错;
  • 深链定位需求另行设计,不强行塞进游标分页。

FAQ

Q1: 游标分页能支持随机跳页吗?

原生游标分页不支持"跳到第 N 页",因为它没有页号概念。若产品需要页码 UI,可提供独立的 offset 字段或为游标附加 page 信息。不要试图用 after 做随机跳页。

Q2: totalCount 每次查询都 count 一遍可以吗?

小表可以;大表不要。高频列表用 Redis 缓存 count(写路径失效),或明确告知前端 totalCount 为近似值。无限滚动场景通常根本不需要 totalCount。

Q3: 游标 Base64 编码会不会暴露业务数据?

Base64 是可逆的,如果游标内含 createdAt、id,客户端解码即可看到。若业务敏感,用不可逆的随机串作为游标并映射到记录;若不敏感(多数场景),Base64 足够且便于调试。

Q4: 为什么用 last/before 向前翻时游标语义容易出错?

before: cursor 取的是游标之前的记录,但返回顺序仍需按业务排序键(倒序时即自然倒序)。容易错的是"取出来了但顺序反了"——先按 DESC 取、再 reverse 才是正确的展示顺序。

Q5: 分页列表中的记录被更新后,游标会失效吗?

取决于排序键。若记录更新改变了排序键(如 createdAt 变更、热度值变更),它可能"跑到"游标另一侧,导致翻页重复或遗漏。稳定的业务排序键(如 createdAt)加上唯一 id 作为 tie-breaker 是规避此类问题的最优解。


一句话总结

游标分页用"锚定记录的不透明游标 + 稳定的全序排序键"替代"跳过 N 条",天然免疫数据漂移且深分页性能恒定;配合 Relay Connection 的 edges/node/pageInfo 结构与 fetchMore 合并策略,它成为无限滚动与增量加载的事实标准。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「GraphQL」更多文章

  1. GraphQL Mutation 设计实战:从语义命名到乐观更新
  2. GraphQL 持久化查询与生产安全:从 APQ 到白名单的完整方案
  3. GraphQL 错误处理与可观测性:从 errors[] 到链路追踪