代表性面试主题

后端面试:如何设计在并发写入下仍一致的游标分页?

后端中等
OfferCC 编辑团队发布 更新

题干

一个无限滚动的订单列表在用户翻页时会出现重复或漏项。数据还在持续写入,你会如何设计分页接口和数据库查询?

题干与适用场景

题目考察后端工程师能否把分页从“取下一页”提升为稳定的数据读取协议。订单、评论、日志等集合会在请求之间插入、删除或更新;单纯的 OFFSET 依赖变化中的位置,深页还会扫描并丢弃大量行。回答应覆盖排序、游标编码、过滤器绑定、一致性边界、索引和前后翻页语义。

面试官考察点

强回答会先问是否需要跳页、总数和实时性,再选择 keyset/cursor 或 offset。游标必须绑定排序与过滤条件,排序键必须唯一、稳定且有索引;服务端验证签名和过期时间,避免客户端伪造。回答还要说明新增数据不会让下一页重复、删除如何处理,以及 API 如何返回 next_cursor 和终点状态。

回答前需要澄清的问题

  • 数据按什么排序?排序字段会更新吗,是否有唯一的 tie-breaker?
  • 需要上一页、跳到第 N 页、总数,还是只支持向前无限滚动?
  • 读取期间要冻结快照,还是允许“最终一致”的实时列表?
  • 过滤条件、租户权限和排序是否必须在游标中绑定?游标有效多久?
  • 删除、软删除、权限变化和跨分片查询如何处理?

30 秒回答框架

“我会用稳定的复合排序键,例如 (created_at, id),按降序做 keyset 查询。游标是签名的不透明 token,包含最后一条记录的排序值、过滤器哈希、方向和版本;服务端验证后执行 WHERE (created_at,id) < (:time,:id),依赖同序索引。新增记录留在后续刷新,不会插入已经读过的窗口;删除可能让结果变少,但不会制造重复。若业务需要绝对一致,我会增加 snapshot boundary 或数据库快照,并明确成本。”

分步骤深入解答

第一步:定义列表语义

先确定这是历史审计列表、实时 feed 还是管理后台。历史列表通常需要稳定边界;实时 feed 可以允许新记录只在刷新时出现。不要同时承诺实时、任意跳页、精确总数和低成本,这些目标互相牵制。

第二步:选择稳定排序键

时间戳可能相同或被修改,必须加唯一 id 作为 tie-breaker。排序列不能依赖不可重复的展示字段;更新会改变顺序的字段应改用不可变创建序列,或说明更新项可能在不同页移动。

第三步:设计不透明游标

游标至少包含排序值、方向、过滤器哈希、API 版本和过期时间。用签名或服务器端存储防止篡改,客户端只保存并回传 token,不依赖 Base64 提供安全性。过滤条件改变时拒绝旧游标,而不是静默返回错误页面。

第四步:编写 keyset 查询

对降序 (created_at,id),下一页条件是 created_at < t OR (created_at = t AND id < id0),并配合同序复合索引。避免把游标值拼接到 SQL 字符串;参数化查询并限制 limit,防止恶意大页。

第五步:处理动态写入与删除

读到第一页后新增的记录不应插入第二页;它们在下一次刷新时出现。已读记录被删除会造成页面少一条,这是可接受的语义,API 应用 has_more 和当前结果说明。若必须不漏项,使用 snapshot boundary 或版本化读取。

第六步:绑定权限和过滤

游标中的过滤器哈希、租户和授权范围必须与请求一致。权限收紧时重新计算结果,不能用旧游标绕过访问控制。跨分片时可让每个分片返回局部游标,再由协调器合并下一批,但要说明排序和放大成本。

第七步:定义 API 响应与错误

响应返回 items、next_cursor、has_more 和可选的快照标识。游标过期、签名失败、过滤器变化用稳定业务错误码,客户端清空旧游标并从第一页开始;不要把数据库异常或内部 SQL 暴露给用户。

第八步:用并发测试验证

测试读取两页之间插入、删除、更新时间相同、过滤器变化、游标篡改和深页查询。验证同一会话不会重复,索引命中且延迟不随页码线性增长。记录重复率、跳过率、p95 延迟和游标错误率。

查询伪代码

sql
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 可异步或单独接口,避免每页都扫描全表。

公开来源

同类题目