症状
延迟曲线即诊断:第一页 12ms、offset=100000 要 900ms、offset=500000 直接超时——随 offset 线性增长就是 OFFSET 扫描签名。原因在执行计划里可见:LIMIT 50 的查询实际产出 500050 行、丢弃其中 500000 行,数据库无法跳过,必须先沿索引走完前面每一行才能开始你的页。翻到集合末尾的导出任务与爬虫把这套算术变成慢查询告警。ORDER BY 列缺索引再叠一层排序、缺唯一 tiebreaker 让深层页面漂移(重复与缺行貌似数据丢失),是两个常见并发症。
诊断思路
先循环测不同 offset 的延迟画曲线:线性增长即 OFFSET 扫描;再看深层执行计划的 rows 计数,被丢弃的行数可视化;最后从访问日志找到达极端深度的调用方,决定是否还需要专门的导出 API 而不是分页行走。
根因
OFFSET 不跳过工作:数据库必须产出(或沿索引走过)所有前序行才能返回请求页。成本随 offset 值增长,大表上的深页超出任何合理请求预算。
修复与验证
改 keyset(游标)分页:WHERE (created_at, id) < (游标值) ORDER BY created_at DESC, id DESC LIMIT 50,配 (created_at DESC, id DESC) 复合索引让索引做跳过;对客户端暴露不透明 cursor(base64 编码的排序键),防止伪造内部结构。导出场景提供专门导出或流式 API 而不是走分页。验证时任意深度延迟与第一页持平、数据库扫描有界于页大小、导出任务经专用 API 完成。keyset 无法跳转到任意页码——需要跳页的产品要么混合设计要么有意识地承担成本。id tiebreaker 不可省:没有它,共享 created_at 的行在深层页间漂移。
代码示例
EXPLAIN ANALYZE SELECT * FROM items
ORDER BY created_at DESC OFFSET 500000 LIMIT 50;
Limit (rows=50) (actual time=8412.114)
-> Index Scan Backward using items_created_idx
rows=500050 <- half a million rows produced
to discard 500000 of them
-- page 1
SELECT id, created_at, ... FROM items
ORDER BY created_at DESC, id DESC
LIMIT 50;
-- next page: WHERE the row sorts strictly after the cursor
SELECT id, created_at, ... FROM items
WHERE (created_at, id) < ($last_created, $last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;
-- supporting index:
CREATE INDEX items_created_id ON items (created_at DESC, id DESC);
GET /items?limit=50
{ "data": [...], "next_cursor": "eyJjIjoxNzI5NTg0MDAwLCJpZCI6ODgxMn0=" }
GET /items?cursor=eyJj...&limit=50