题干与适用场景
一个多租户订单服务使用 PostgreSQL,orders 表有 2 亿行,持续发生新增订单和状态变更。运营端按租户与状态筛选订单,固定使用 createdat DESC, id DESC 排序,每页最多 50 条。createdat 和 id 都非空,创建后不再修改;id 唯一。产品需要“下一页”和“上一页”,不要求直接跳到第 500 页。
请设计分页 API、游标内容、查询和索引。方案要处理相同创建时间的订单、深分页、翻页期间的新建与删除、状态筛选变化、游标篡改和双向导航,并明确它提供的是实时遍历还是固定快照。
本题中的 2 亿行和每页 50 条是面试假设。核心考察能力是把 API 语义落实到数据库访问路径、并发边界和验证方法,因此归入 backend。跨地域复制、分库分表和前端列表状态不在首轮范围。
面试官考察点
第一项信号是先定义一致性,再选择分页算法。“不重不漏”必须有对象:是避免 offset 位移造成的重复与跳过,还是要求整个浏览过程等同于读取同一数据库快照?普通游标分页只能稳定地越过一个排序边界,不能自动冻结删除、状态变更或其他筛选字段。
第二项信号是排序必须全序。仅按 created_at 排序时,多条订单可能拥有相同时间,数据库可以在它们之间任意排列。把唯一 id 作为第二排序键,游标也同时携带两列,才能准确表达“上一页最后一行之后”。
第三项信号是查询、索引和游标来自同一顺序。tenantid、status 是等值筛选,createdat、id 是范围边界和排序键,因此候选 B-tree 索引是 (tenantid, status, createdat DESC, id DESC)。回答若只说“把 offset 换成 cursor”,却没有给出比较条件、索引和反向查询,仍未完成设计。
最后看契约和测试。强回答会绑定筛选条件与授权作用域、签名游标、限制页大小,并用并发插入、删除边界行、相同时间戳和前后翻页往返验证结果。它也会保留 offset 的适用位置:小而稳定、需要页码跳转的数据集。
回答前需要澄清的问题
- 需要实时列表还是固定快照? 实时列表允许后续页面反映部分变化;固定快照要求一次遍历看到同一集合,通常需要版本化数据、物化结果或可跨请求保持的快照机制。两者的成本和过期策略不同。
- 是否必须跳到任意页? keyset 分页擅长顺序前进和后退,无法凭“第 500 页”直接算出边界。若页码跳转是硬需求,可为有限深度保留 offset,或预计算锚点。
- 排序和筛选字段会不会修改?
createdat与id必须稳定;若按可变的updatedat、价格或热度排序,记录可能跨越已经访问的边界。状态变化也会改变结果集成员,需要在契约中公开。 - 是否需要精确总数和最后一页?
hasNextPage可通过多取一行得到,精确totalCount则是独立聚合成本。若 UI 只需要“继续加载”,不应让每次分页都执行全量计数。 - 游标可以保留多久? 版本升级、筛选规则改变、签名密钥轮换和快照保留期都会影响有效期。服务端要定义过期错误与重新从第一页开始的方式。
- 租户与权限可能在翻页期间变化吗? 游标不能替代当前请求的认证授权。每一页都要重新检查当前用户,并把游标绑定到规范化筛选和作用域,避免跨租户复用。
30 秒回答框架
“我会先确认这里要的是实时顺序遍历,不承诺跨请求数据库快照。排序固定为不可变的 createdat DESC, id DESC,其中唯一 ID 解决同一时间戳的并列。下一页游标携带上一页末行的两列值、版本和筛选指纹,并做 URL 安全编码与 HMAC 签名。查询使用 (createdat, id) < (cursortime, cursorid)、同样的倒序和 LIMIT 51,返回 50 条并用第 51 条判断 hasNextPage;索引按 (tenantid, status, createdat DESC, id DESC) 建立。上一页反转比较符和查询顺序,取完再反转响应。新增的更近订单不会把后续页向后推,但删除和状态变化不会被游标冻结;需要严格快照时,我会改用版本化或物化会话。最后用相同时间戳、并发写入、边界删除、游标篡改和深页执行计划验证。”
分步骤深入解答
先写清 API 契约。第一页不传游标;向后浏览使用 after,向前浏览使用 before,两者不能同时出现。页大小默认 50,服务端限制在 1 到 100。一个响应可以采用以下形状:
{
"data": [],
"pageInfo": {
"startCursor": "...",
"endCursor": "...",
"hasNextPage": true,
"hasPreviousPage": false
}
}startCursor 和 endCursor 来自本页第一行与最后一行。hasNextPage 通过请求 51 行、只返回前 50 行得到。若要精确返回另一方向的布尔值,可用本页另一端做一次很小的索引 EXISTS 查询;仅根据“请求里出现过 after”推断,在前面的记录已被删除时可能失真。
用复合边界替代位置编号
第一页查询为:
SELECT id, created_at, total_cents, status
FROM orders
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC, id DESC
LIMIT 51;下一页把上一页最后一行的 (created_at, id) 放进严格小于条件:
SELECT id, created_at, total_cents, status
FROM orders
WHERE tenant_id = $1
AND status = $2
AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT 51;PostgreSQL 的行比较按列从左到右,遇到第一组不相等的值就决定结果。这与两个倒序排序键的字典序边界一致。两列都定义为非空,可以避开行比较遇到 NULL 后得到未知值的语义。id 不要求反映时间,只需在同一 created_at 内提供唯一且稳定的决胜顺序。
对应索引为:
CREATE INDEX CONCURRENTLY orders_tenant_status_created_id_idx
ON orders (tenant_id, status, created_at DESC, id DESC);前导等值列把扫描限制在一个租户和状态,后两列提供边界与顺序。是否加入返回列做覆盖索引,要根据行宽、回表次数和写放大实测;不能把所有响应字段都塞进 INCLUDE。状态频繁变化时,这个索引也会产生维护成本,因此要同时压测写入延迟、WAL 和索引尺寸。
设计可演进、不可伪造的游标
游标应由服务端定义为不透明协议,客户端不得自行拼接 created_at。解码后的逻辑载荷可以是:
{
"v": 1,
"createdAt": "2026-07-16T02:30:00.123Z",
"id": "ord_01J...",
"filterHash": "sha256:...",
"expiresAt": "2026-07-17T02:30:00Z"
}服务端对规范化后的载荷做 URL 安全编码,并附加 HMAC。Base64 只编码、不保密;若边界值本身敏感,就不要把它直接放入客户端可见载荷,改用服务端保存的随机游标 ID。filterHash 至少覆盖排序版本、状态筛选和授权作用域标识。每次请求仍以当前登录态重新授权,再验证签名、版本、过期时间和筛选指纹;任何一项不匹配都返回稳定的 invalid_cursor,要求客户端从第一页重启,不能静默改用新筛选。
版本字段允许以后改变排序或编码。密钥轮换时可在游标最长有效期内同时接受当前与上一把验证密钥。日志只记录错误类型和游标版本,不输出完整游标或筛选明文。
对称实现上一页
当前页的第一行是向前浏览的边界。要找更近的 50 条,比较符改为 >,数据库查询顺序改为升序,以便先取得离边界最近的记录:
SELECT id, created_at, total_cents, status
FROM orders
WHERE tenant_id = $1
AND status = $2
AND (created_at, id) > ($3, $4)
ORDER BY created_at ASC, id ASC
LIMIT 51;服务端去掉用于探测的第 51 行后,把结果反转,再按公开的倒序返回。若仍用倒序加 LIMIT,取到的会是整个结果集最前面的 50 条,偏离紧邻当前页的上一段。前后翻页必须共享同一套筛选指纹和排序版本。
明确并发变化下的保证
在实时语义下,游标表示“继续读取严格小于这个不可变边界的记录”。用户读完第一页后,即使插入 100 条更新的订单,下一页仍从旧边界向后读取,不会因为前面新增记录导致第一页末尾再次出现。边界行随后被删除也不影响比较,因为游标保存的是值,不需要重新找到那一行。
这不等于固定快照。尚未读取的订单若从其他状态变成当前筛选状态,并且排序位置在边界之后,它可能进入后续页面;已经存在的订单若离开筛选集合,则不会再出现。删除也会让本次遍历少看到记录。仅在游标中加入“第一页开始时间”只能挡住更新的新增记录,不能冻结删除与状态变化。
若业务要求导出、对账或审计时一条不少,选择之一是创建短期物化结果集,只保存符合条件的订单 ID 与顺序;另一种是读取支持时间旅行的版本化数据;也可以在受控后台任务中保持同一数据库快照。三种方式都会增加存储、事务寿命或清理成本,需要设置会话有效期。面向交互列表时,实时 keyset 通常更合适;面向审计时,应为快照成本单独设计流程。
保留 offset 的适用边界
Offset 的优势是实现简单、可以直接跳页,也容易表达页码。它适合数据量小、排序稳定的后台表,或已经固定的搜索结果。缺点有两类:PostgreSQL 仍要计算并丢弃 OFFSET 之前的行,偏移越深,工作量通常越大;并发插入或删除还会改变位置编号,使同一行重复或被跳过。
Keyset 把工作锚定在有索引的值边界,深页查询不再随 offset 数值扫描并丢弃前面所有行,但它不能无锚点跳到任意页。选择规则可以直接写成:需要顺序浏览、数据持续变化且页数很深时用 keyset;需要有限页码跳转且数据小或已冻结时用 offset;需要全程固定集合时,在任一分页方式之外再增加快照机制。
用对抗用例证明契约
验证先检查集合和顺序,再看延迟:
- 插入 120 条具有同一
created_at的订单,确认唯一id让前后页无重复,合并所有页后顺序严格递减。 - 读取第一页后插入 100 条更新订单。用 offset 复现实验中的位移,再确认 keyset 的第二页不会重复第一页记录。
- 删除第一页最后一行后继续,确认保存的边界值仍能取得正确下一页。
- 在两次请求之间改变未读订单的
status,确认结果符合公开的实时语义;该结果不能标记为快照。 - 修改游标一个字节、跨租户复用、改变状态筛选、使用过期版本,全部应被拒绝。
- 连续前进三页再后退三页,检查每页的 ID 与顺序;同时覆盖 0 行、1 行、50 行和 51 行。
- 对第一页、第二页和很深的边界运行
EXPLAIN (ANALYZE, BUFFERS),确认使用预期复合索引,扫描行数接近页大小,并记录读 p95、写 p95、WAL 和索引尺寸。
高质量示范回答
“我先把保证限定清楚:运营列表需要实时顺序浏览,不要求直接跳页,也不承诺跨多个 HTTP 请求保持数据库快照。因此我会选择 keyset。
排序用创建后不变的 createdat DESC, id DESC。ID 是唯一决胜键,否则同一毫秒创建的订单没有稳定顺序。第一页取 51 条,返回 50 条。下一页游标保存末行的时间和 ID,查询条件是 (createdat, id) < (?, ?),排序完全相同。索引按等值筛选在前、排序边界在后,使用 (tenantid, status, createdat DESC, id DESC)。上一页以本页首行为边界,改用 > 和升序取最近 51 条,再反转成公开倒序。
游标会包含版本、两列边界、筛选指纹和过期时间,做 URL 安全编码与 HMAC 签名。服务端每页都重新授权,并拒绝篡改、过期或筛选不匹配的游标。它不依赖边界行继续存在,所以删除边界行后仍能翻页。
一致性上,我只承诺避免 offset 位移造成的重复。第一页后新增的更近订单不会混入后续页,但状态变化和删除仍会改变尚未读取的集合。若对账要求固定快照,我会另建物化导出会话或读取版本化数据,不能在普通游标上贴一个时间戳就宣称完整快照。
最后我会用相同时间戳、并发插入、边界删除、状态变化、游标篡改和前后往返测试正确性,再比较第一页、第二页与深页的执行计划,以及新索引带来的写延迟和 WAL。”
常见错误
- 只把页码编码成 Base64 → 服务端仍执行
OFFSET,性能和位置漂移都没有改变 → 游标必须表达有索引的排序边界。 - 只用
created_at当游标 → 相同时间戳的订单无法形成全序,可能重复或跳过 → 加入唯一、稳定的id,查询与游标都使用两列。 - 查询排序和比较条件不一致 → 边界不再代表公开顺序,翻页会交叉或缺口 → 从同一字典序同时推导
ORDER BY、比较符和索引。 - 把 Base64 当成防篡改 → 客户端可以修改时间、ID 或筛选范围 → 使用 HMAC 验证完整性,并重新执行当前授权。
- 游标不绑定筛选条件 → 把 pending 页的游标用到 paid 查询会产生难以解释的缺口 → 把规范化筛选、排序版本和作用域纳入签名指纹。
- 上一页仍按倒序直接 LIMIT → 取得的是结果集最前端,偏离边界附近的上一段 → 反转查询顺序,截取后再反转响应。
- 声称 keyset 提供完整快照 → 删除、状态变化和可变排序键仍能改变集合 → 公开实时语义;严格一致时增加版本化或物化快照。
- 为了
totalCount每页都扫完整结果 → 分页查询变快,计数却成为新的慢路径 → 把计数作为独立、可缓存或可近似的产品能力。 - 只测第一页 → 第二页才会触发复合边界条件,索引不匹配可能在这里暴露 → 同时检查首两页、深页和正反两个方向的计划。
追问及应对
追问一:产品要求按可修改的 total_cents 排序怎么办?
复合游标可以改为 (total_cents, id),但只解决并列,不能解决金额修改后记录跨过已访问边界。若允许实时排序,就公开可能重复或遗漏,并在客户端按 ID 去重;若必须稳定,进入浏览会话时冻结排序值、物化 ID 列表,或让修改生成新版本。不能继续承诺与不可变排序键相同的保证。
追问二:现在必须支持直接跳到第 500 页怎么办?
Keyset 没有第 500 页的边界值。先确认真实需求是寻找某个日期、订单号,还是纯页码;日期和订单号可以变成有索引的搜索锚点。若页码是硬要求,可限制最大可跳深度并在这部分使用 offset,或周期性保存页锚点,再从最近锚点做 keyset。锚点在实时数据中也会过期,所以需要版本或快照标识。
追问三:导出 2 亿行时还能复用这个 API 吗?
交互 API 的短游标和实时语义不适合长时间审计导出。建立后台导出任务,在固定快照或版本边界上按不可变主键分块,写入对象存储,并记录断点与校验数量。这样重试、过期、资源限额和完成校验都有独立生命周期,也不会让一个 HTTP 游标长期绑定在线索引与签名兼容性。
追问四:如何处理分页过程中签名密钥轮换?
游标携带版本或密钥标识。验证端在最长游标有效期内同时保留当前和上一把验证密钥,新游标只用当前密钥签名;旧密钥超过窗口后删除。若安全事件要求立即撤销,就统一返回 invalid_cursor,让客户端从第一页重启,不能为了无感翻页继续接受已泄露密钥。
追问五:为什么删除边界行后仍能继续,修改排序键却不行?
下一页查询只需要游标中的两个值,不需要数据库重新找到边界行,所以删除它不会改变严格小于条件。修改排序键会让同一记录从边界一侧移动到另一侧:它可能再次落入未来范围,或从尚未访问的范围跳到已经访问的一侧。稳定排序键是正确性条件;边界行可以已经不存在。