题干与适用场景
这道题考察后端工程师能否同时处理深分页性能、并发写入下的稳定顺序和 API 游标契约。假设客户端需要按时间倒序浏览订单或动态消息列表,每页 50 条,数据持续写入,用户主要需要下一页而不是跳到任意页。
适用对象包括后端、数据服务和平台岗位。不要一上来宣布“cursor 永远更快”;先确认排序、跳页、导出、一致性、删除语义、读副本和索引条件,因为这些约束会改变方案。
面试官考察点
强回答会解释为什么深 OFFSET 会扫描并丢弃前面的行,为什么非唯一排序键会在并发写入时造成漂移,以及为什么 keyset 需要稳定的总排序和匹配索引。回答还应区分无状态 keyset token 与持有事务的数据库 cursor,并给出重复、漏项、删除边界行和伪造 token 的验证方法。
回答前需要澄清的问题
- 是否需要跳到第 N 页或显示总页数?若需要,纯 keyset 不能直接满足。
- 列表按什么字段排序?时间戳是否唯一、可变,是否需要唯一 ID 作 tie-breaker?
- 客户端要 live 视图还是一次遍历内的 snapshot?两者对新增和删除的可见性不同。
- 查询是否跨读副本、分片或租户过滤?索引和 token 必须绑定这些条件。
- 游标是否可被客户端解码、篡改或长期重放?这决定签名、过期和版本策略。
30 秒回答框架
“我先确认排序、跳页和一致性语义。对持续变化的大列表,我会使用带唯一 tie-breaker 的 keyset 分页,例如按 created_at DESC, id DESC,并建立匹配复合索引;下一页携带上页最后一条的排序键,而不是深 OFFSET。游标应编码过滤器、排序版本和边界并签名、设置过期时间。若要求整次遍历的 snapshot,再考虑带事务或时间戳的 cursor,并说明它占用的资源。最后用并发插入、删除、重复时间戳和篡改 token 的测试验证契约。”
分步骤深入解答
第一步:先定义分页契约
明确 limit 上限、默认排序、nextcursor、hasmore 和过滤条件。稳定排序至少要是总序关系;只按 created_at 在同一时间戳有多行时会产生不确定顺序,因此追加不可变唯一 ID。若客户端需要上一页,需设计反向比较和边界处理,不能把下一页逻辑直接反转后假设结果正确。
第二步:解释 OFFSET 的性能与一致性问题
OFFSET k LIMIT n 通常需要先定位并跳过前面的 k 行,深页的工作量随 k 增长。并发插入或删除发生在已读取页之前时,后续页的偏移位置会移动,导致重复或漏项。小型、静态、需要跳页的后台列表可以接受 OFFSET,但应限制最大页数并用执行计划验证成本。
第三步:用稳定总序关系实现 keyset
以倒序 (created_at, id) 为例,第一页不带边界,下一页使用上一页最后一行的键:
-- first page
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 50;
-- next page: boundary comes from the last returned row
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;查询从索引边界向前扫描固定数量的行,不需要跳过全部历史页。复合索引的列顺序、过滤条件和排序方向必须与查询匹配;否则理论上的 keyset 仍可能退化为大范围扫描。
第四步:设计可验证的游标 token
不要把数据库内部偏移量当作 cursor。token 至少应包含边界键、过滤器摘要、排序版本和过期时间,并使用签名或 AEAD 防止客户端修改。服务器收到 token 后验证版本、租户和过滤器与当前请求一致;排序规则改变时拒绝旧版本或明确重新开始,避免同一 token 在不同顺序下产生不可解释结果。
第五步:选择 live 还是 snapshot 语义
live keyset 允许新数据继续出现在列表顶部,已经越过边界的旧行通常不会重复;删除边界行不会让下一页重新出现它,但总数会变化。若导出或审计要求一次遍历看到固定集合,可以把读取时间戳、快照版本或数据库 cursor 放入契约。数据库 cursor 可能持有事务和资源,不能默认用于长时间的 HTTP 分页。
第六步:处理读副本、分片和边界行
跨副本读取时,复制延迟可能让下一页暂时看不到已在主库返回的行;可以固定读取路由、携带已确认的时间边界,或接受 eventual consistency 并在文档中说明。分片场景需要每个分片产生局部 keyset,再做带游标的 k 路合并。删除最后一条边界行、相同时间戳和过滤条件变化都应有回归测试。
第七步:验证性能与正确性
性能验证检查深页执行计划扫描行数、P95 延迟和索引命中;正确性验证在两个请求之间插入、删除和更新记录,断言不会重复或漏掉违反契约的行。记录每页的过滤器、排序版本、首尾键和 token 版本,生产中才能把漂移现象映射回具体边界。
高质量示范回答
“我会先问清楚是否需要跳页、总数和固定 snapshot,以及排序字段是否唯一。对一个持续写入的大列表,如果主要是向后浏览,我会选择 keyset:按不可变的 created_at 加唯一 id 建立总排序和复合索引,下一页用上一页最后一条的两个键做范围条件。这样深页不需要扫描并丢弃前面的行,也不会因为前面插入一条记录而整体错位。
我会把过滤条件、排序版本、边界键和过期时间编码进签名 token,验证 token 与请求的租户和筛选条件一致。live 语义下,新数据从顶部出现;需要审计级固定集合时,我会考虑时间戳或受控事务 cursor,并说明长事务的资源成本。若读副本有延迟,我会固定路由或记录可接受的可见性边界。
最后用执行计划和并发测试验证:深页扫描量、重复时间戳、插入、删除、更新边界行、读副本延迟和篡改 token 都要覆盖。小型且必须跳页的管理列表可以保留 OFFSET,但会限制深度并监控延迟。”
常见错误
- 宣称 cursor 永远更快 → 忽略跳页和长事务成本 → 比较 OFFSET、keyset 与数据库 cursor 的适用条件。
- 只按时间戳排序 → 同值行顺序不稳定 → 追加不可变唯一 ID。
- 把 offset 数字放进 token → 深页仍然昂贵且会漂移 → 携带排序边界和过滤器摘要。
- 不定义 live/snapshot → 客户端无法解释新增与删除 → 在 API 契约中声明可见性语义。
- 忽略索引方向和过滤条件 → keyset 仍扫描大量行 → 用执行计划验证复合索引。
- 接受任意客户端 cursor → 可篡改租户或读取过期边界 → 签名、校验版本并设置过期。
追问及应对
追问 1:用户必须跳到第 500 页,怎么办?
对小型或近似静态管理列表可用有上限的 OFFSET;对大列表可维护预计算页边界或搜索条件,让用户按筛选和排序定位,而不是承诺任意深页的低延迟。两者都要公开成本与一致性限制。
追问 2:created_at 会被编辑,游标还稳定吗?
不稳定。使用不可变的创建时间加唯一 ID,或把可变排序字段的版本冻结在 snapshot 中。若业务必须按可变字段排序,需要接受项目在 live 视图中移动,或改用固定版本并承担存储和读取成本。
追问 3:读副本落后导致下一页少数据,怎么处理?
根据一致性要求固定主库或同一副本,或者携带“不得早于某时间/LSN”的边界并在等待超时后返回可解释状态。不能静默把缺失当作列表结束;客户端应区分暂时不可见和 has_more=false。
追问 4:如何证明没有重复和漏项?
建立可控并发测试:读取第一页后在边界前插入、删除边界行、插入相同排序键并更新记录;连续取页后检查唯一 ID 集合、排序关系和预期可见性。日志记录每页首尾键、token 版本和数据快照标识,失败时可以复现具体边界。