题干与适用场景
题目考察后端工程师能否把分页从“取下一页”提升为稳定的数据读取协议。订单、评论、日志等集合会在请求之间插入、删除或更新;单纯的 OFFSET 依赖变化中的位置,深页还会扫描并丢弃大量行。回答应覆盖排序、游标编码、过滤器绑定、一致性边界、索引和前后翻页语义。
面试官考察点
强回答会先问是否需要跳页、总数和实时性,再选择 keyset/cursor 或 offset。游标必须绑定排序与过滤条件,排序键必须唯一、稳定且有索引;服务端验证签名和过期时间,避免客户端伪造。回答还要说明新增数据不会让下一页重复、删除如何处理,以及 API 如何返回 next_cursor 和终点状态。
回答前需要澄清的问题
- 数据按什么排序?排序字段会更新吗,是否有唯一的 tie-breaker?
- 需要上一页、跳到第 N 页、总数,还是只支持向前无限滚动?
- 读取期间要冻结快照,还是允许“最终一致”的实时列表?
- 过滤条件、租户权限和排序是否必须在游标中绑定?游标有效多久?
- 删除、软删除、权限变化和跨分片查询如何处理?
30 秒回答框架
“我会用稳定的复合排序键,例如 (createdat, id),按降序做 keyset 查询。游标是签名的不透明 token,包含最后一条记录的排序值、过滤器哈希、方向和版本;服务端验证后执行 WHERE (createdat,id) < (:time,:id),依赖同序索引。新增记录留在后续刷新,不会插入已经读过的窗口;删除可能让结果变少,但不会制造重复。若业务需要绝对一致,我会增加 snapshot boundary 或数据库快照,并明确成本。”
分步骤深入解答
第一步:定义列表语义
先确定这是历史审计列表、实时 feed 还是管理后台。历史列表通常需要稳定边界;实时 feed 可以允许新记录只在刷新时出现。不要同时承诺实时、任意跳页、精确总数和低成本,这些目标互相牵制。
第二步:选择稳定排序键
时间戳可能相同或被修改,必须加唯一 id 作为 tie-breaker。排序列不能依赖不可重复的展示字段;更新会改变顺序的字段应改用不可变创建序列,或说明更新项可能在不同页移动。
第三步:设计不透明游标
游标至少包含排序值、方向、过滤器哈希、API 版本和过期时间。用签名或服务器端存储防止篡改,客户端只保存并回传 token,不依赖 Base64 提供安全性。过滤条件改变时拒绝旧游标,而不是静默返回错误页面。
第四步:编写 keyset 查询
对降序 (createdat,id),下一页条件是 createdat < t OR (created_at = t AND id < id0),并配合同序复合索引。避免把游标值拼接到 SQL 字符串;参数化查询并限制 limit,防止恶意大页。
第五步:处理动态写入与删除
读到第一页后新增的记录不应插入第二页;它们在下一次刷新时出现。已读记录被删除会造成页面少一条,这是可接受的语义,API 应用 has_more 和当前结果说明。若必须不漏项,使用 snapshot boundary 或版本化读取。
第六步:绑定权限和过滤
游标中的过滤器哈希、租户和授权范围必须与请求一致。权限收紧时重新计算结果,不能用旧游标绕过访问控制。跨分片时可让每个分片返回局部游标,再由协调器合并下一批,但要说明排序和放大成本。
第七步:定义 API 响应与错误
响应返回 items、nextcursor、hasmore 和可选的快照标识。游标过期、签名失败、过滤器变化用稳定业务错误码,客户端清空旧游标并从第一页开始;不要把数据库异常或内部 SQL 暴露给用户。
第八步:用并发测试验证
测试读取两页之间插入、删除、更新时间相同、过滤器变化、游标篡改和深页查询。验证同一会话不会重复,索引命中且延迟不随页码线性增长。记录重复率、跳过率、p95 延迟和游标错误率。
查询伪代码
SELECT id, created_at, total
FROM orders
WHERE tenant_id = :tenant
AND (created_at, id) < (:cursor_time, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_size;设计取舍与边界
| 需求 | 选择 | 代价 |
|---|---|---|
| 大表无限滚动 | keyset cursor | 不支持任意跳页 |
| 小型后台表 | offset | 深页变慢且动态写入不稳定 |
| 绝对一致结果 | snapshot boundary | 快照存储与清理成本 |
| 精确总数 | 独立 count 或异步统计 | 额外查询与可能过时 |
游标解决的是位置稳定与查询效率,不自动解决跨页业务去重、权限变化或数据更新移动。对搜索结果还要绑定查询版本;对聚合结果可能需要搜索引擎的 point-in-time 或数据库快照。
落地计划与证据
先选一个高读写列表,测量现有重复、遗漏、深页延迟和 count 成本。将排序键建成覆盖索引,发布版本化游标,增加并发写入测试与监控。Django REST framework 将 cursor pagination 定义为不透明游标;Hello Interview 与 TechInterview 的面试材料均强调动态数据下 cursor/keyset 相比 offset 更稳定。
试点的退出条件
并发插入和删除测试中重复/遗漏符合已声明语义;深页 p95 稳定;篡改或过滤器变化被拒绝;客户端能从游标错误安全回到第一页;权限审计确认 token 不泄露数据。
怎样证明收益不是巧合
在相同数据规模和写入率下比较 offset 与 cursor 的 p95/p99 查询延迟、扫描行数、重复率、遗漏率和数据库 CPU。还要分离缓存命中率影响,避免只比较一次冷查询。
常见误区与追问
把 Base64 当作安全游标
Base64 只是编码,客户端可以篡改其中的 id 或租户。使用签名或服务器端 token,并绑定过滤器、版本和过期时间。
只按时间戳排序
同一毫秒可能有多行,时间戳相同会导致边界不确定。加入唯一 tie-breaker,并建立对应复合索引。
游标还能跳到任意页吗?
标准 cursor 不适合任意跳页。若产品必须跳页,可提供受限 offset、预计算锚点或搜索引擎页码,并明确一致性与成本。
记录被更新后会不会重复?
若排序字段可变,记录可能移动到另一页。使用不可变创建序列,或采用 snapshot/version 语义并告诉消费者结果可能变化。
如何处理上一页按钮?
保留前一页游标栈,或反向查询后在服务端反转结果。不要让客户端猜测游标内部的排序方向。
精确总数必须返回吗?
不是。无限滚动通常只需 has_more;精确 count 可异步或单独接口,避免每页都扫描全表。