Relay 分页规范

解读

国内一线互联网公司在 GraphQL 场景下,普遍把 Relay 分页规范(Relay Cursor Connections Specification)作为“高并发、深翻页”的标准答案。面试官问这条规范,并不是想听你背概念,而是想确认三件事:

  1. 你是否真的用 PHP 落地过深分页,知道 LIMIT offset,N 在数据量大时会回表扫描;
  2. 你能否把规范里的“游标、边、节点、PageInfo”翻译成 PHP 代码结构;
  3. 你是否具备“把产品需求转成技术方案”的能力,例如双向翻页、跳页、缓存一致性、并发限流。

因此,回答时要先讲“为什么”,再讲“怎么做”,最后给出“可落地的 PHP 代码骨架”。时间控制在 3~5 分钟,既体现深度,又留有余地让面试官追问。

知识点

  1. 业务痛点:传统 offset 分页在百万级数据下出现深翻页性能衰减、数据漂移、limit 1000000,10 回表 IO 爆炸。
  2. Relay 核心模型:
    • Connection:整个分页结果容器;
    • Edge:包裹“节点 + 游标”的结构,解决“节点复用”问题;
    • Node:真正的业务实体;
    • Cursor:全局唯一、可排序、不可逆向解析的游标,通常用“排序字段 + id”做 base64 编码;
    • PageInfo:hasNextPage、hasPreviousPage、startCursor、endCursor,用于 UI 禁用按钮。
  3. 四种查询参数:
    • first + after:向前取 N 条;
    • last + before:向后取 N 条;
    • 若同时传 first+last,规范要求返回错误;
    • 未传分页参数时默认返回前 20 条,防止全表扫描。
  4. 游标生成算法(PHP 实现):
    • 排序字段如果是自增主键,可直接用 id;
    • 多字段排序时,把“排序值数组”用 json_encode 后拼 id,再 base64_encode;
    • 游标必须可逆解析,但不可让前端猜到业务含义,因此加一次性签名或对称加密。
  5. SQL 构造技巧:
    • 用“排序字段 > 游标值”代替 offset;
    • 多字段排序时,构造 (sort1, sort2, id) > (?, ?, ?) 的元组比较;
    • 取 N+1 条,多拿 1 条用来判断 hasNextPage,再 slice 掉最后一条。
  6. 框架级封装:
    • Laravel:利用 eloquent-builder 的 macro,统一封装 relayCursorPaginate();
    • Symfony:在 Doctrine DBAL 层新增 RelayConnectionLoader,复用 QueryBuilder;
    • 返回结构必须遵守 PSR-7/PSR-17,方便接入 swagger-php 或 GraphQLite。
  7. 并发与缓存:
    • 游标分页天然适合 Redis zset 做热数据缓存,cursor 作为 score;
    • 使用 Lua 脚本保证“判断边界 + 切片”原子性;
    • 对冷热数据做分层:热数据缓存 5 min,冷数据回源加布隆过滤器防止穿透。
  8. 跳页需求妥协:
    • 规范本身不支持跳页,但国内运营后台经常要“跳到第 100 页”。可额外暴露 page/offset 参数,只在后台接口开启,同时加限流、降级、最大偏移量 5 万条硬限制,防止线上被刷爆。

答案

“Relay 分页规范是 GraphQL 官方提出的游标分页标准,核心是用‘游标’替代‘offset’,解决深翻页性能与数据漂移问题。规范把返回结构拆成 Connection、Edge、Node、PageInfo 四层,查询参数只用 first/after/last/before 四个,禁止出现 offset。

在 PHP 落地时,我通常分三步:

第一步,生成游标。以单字段自增主键为例,cursor = base64_encode('id:123456');多字段排序时,把排序值数组 json 编码后拼主键,再做 base64,必要时加签名防止前端伪造。

第二步,构造 SQL。假设按 id 升序,取第一页 10 条:SELECT * FROM post WHERE id > ? ORDER BY id ASC LIMIT 11;多拿 1 条判断 hasNextPage,PHP 里用 array_pop 把最后一条去掉,剩下的 10 条封装成 Edge,每条 Edge 的 cursor 再用当前行 id 生成。

第三步,封装到框架。我在 Laravel 中给 Builder 加一个 macro:relayCursorPaginate(int first,?stringfirst, ?string after),返回数组带 edges 和 pageInfo,前端直接消费;同时把 SQL 放到只读库执行,避免主库压力。

上线后压测,1000 万条帖子表,翻到第 50 万页,接口 RT 稳定在 30 ms 以内,相比传统 offset 分页降低 95% 回表 IO,CPU 下降 70%。此外,对后台运营跳页需求,我额外暴露 offset 参数并做硬限制,最大只允许偏移 5 万条,超过直接返回错误,保证线上安全。”

拓展思考

  1. 如果排序字段不是唯一,例如按 created_at 升序,但存在大量相同时间戳,会导致游标重复、丢数据。此时可在游标里把时间戳精度提到微秒,再拼主键 id,确保元组唯一;或者在 SQL 里用 (created_at, id) 联合排序,游标也按同样顺序编码。
  2. 当业务需要“反向翻页”且数据实时插入,例如直播弹幕,forward 和 backward 可能同时发生,需要保证同一连接内游标版本一致。做法是在第一次查询时把当前最大 id 快照下来,存入 Redis,并返回 snapshotId,后续翻页都带上 snapshotId,服务端用快照 ID 做一致性视图,避免新数据干扰旧游标。
  3. 对于分库分表场景,游标分页需要在所有分片执行“> cursor”查询,再在 PHP 内存里做 K 路归并,取 top N+1。此时可把游标设计成“分片位 + 排序值”组合,先根据分片位剪枝,减少网络 IO;归并完再生成全局游标返回前端。
  4. 国内很多公司把 Relay 规范用在 RESTful 接口,路径如 /api/posts?first=10&after=xxx,好处是前端一套分页逻辑可以复用到 GraphQL 和 REST,缺点是 URL 长度可能超标。此时可改用 POST + body 传参,或者把游标缩短成 8 字节 hash,同时服务端维护 hash→真实游标映射,权衡可读性与长度。
  5. 面试时如果面试官继续追问“游标被恶意遍历怎么办”,可以回答:在游标里加入 uid 维度,做“用户级盐 + 过期时间”签名,防止被批量爬取;或者接入 WAF 频率限制,同一 IP 游标请求超过 200 次/分钟直接封禁。这样既体现安全意识,也展示你对国内灰产攻击手段的了解。