后台翻到第 5000 页就超时:深分页与改写

运营后台的订单列表翻到第 5000 页直接超时。你治好了自己的 OFFSET 恐惧症。

学 40 min
练 65 min
盘 15 min
共 120 分钟

学 · 40 min

  1. 01SELECT * 的三项代价

    场景深分页优化的第一步不是改分页,是先把 select * 扫掉。

    传输:列表页要 20 行 8 列,select * 把整行几十列全传;② 回表:所有列都要回堆取,Index Only Scan 的机会被掐死;③ 扩展性:表加一列,这个接口的流量悄悄变大。只取需要的列是成本最低的优化。

    -- 反面
    select * from orders order by created_at desc offset 100000 limit 20;
    -- 正面
    select id, user_id, status, total_amount, created_at
    from orders order by created_at desc offset 100000 limit 20;

    易错select * 还会破坏 prepare 语句的缓存效果(结构一变计划全重算)。

  2. 02谓词下推与提前过滤:能早过滤就别晚过滤

    场景「先 join 宽表再过滤」和「先过滤再 join」结果一样、速度差十倍。

    数据越早变小,后面每一步越便宜:过滤条件尽量推到扫描层(走索引),聚合前先收窄范围。深分页的「延迟关联」就是它的应用:第一步只在索引里拿 20 个 id(窄而快),第二步再拿 id 回表取全部列。

    -- 延迟关联:先 id 后详情
    select o.*
    from (
      select id from orders
      where user_id = '...'
      order by created_at desc
      offset 100000 limit 20        -- 只在索引层翻页
    ) page
    join orders o on o.id = page.id;   -- 20 次回表

    易错外层的排序和过滤条件别丢:内层不排序,外层顺序就是乱的。

  3. 03OFFSET 深分页为什么越翻越慢(要先扫过并丢弃前 N 行)

    场景第 1 页 5ms,第 5000 页 8 秒--OFFSET 的工作方式决定了它。

    offset 100000 limit 20 的执行 = 扫过前 100000 行、全部丢掉,再取 20 行。翻得越深,丢弃越多,耗时线性增长。OFFSET 的本意是「跳过 N 行」,而跳过也是要一行行数过去的。

    -- 耗时曲线实验
    \timing on
    select id from orders order by id offset 0       limit 20;
    select id from orders order by id offset 10000   limit 20;
    select id from orders order by id offset 500000  limit 20;
    -- 记录三档耗时,画出来就是一条上升直线

    易错愿意翻到 5000 页的多半是爬虫不是用户--限页深 + 返回条数上限,本身就是防爬设计。

  4. 04keyset 分页(游标分页)的写法与限制

    场景治本方案:让「下一页」的代价和「第一页」一样。

    记住上一页最后一行的排序键,下一页从它接着取:where (排序键) < :last_value order by 排序键 desc limit 20。无论第几页,都只扫 20 行。排序键必须唯一且有序(复合排序加 id 兜底),否则边界行会漏或重。

    -- 下一页:拿着上一页最后的 (created_at, id) 来
    select id, created_at, total_amount
    from orders
    where (created_at, id) < (:last_created_at, :last_id)   -- 行比较语法
    order by created_at desc, id desc
    limit 20;

    易错限制要主动说:不能随机跳页(只支持上一页 / 下一页)、排序键中途不能变。产品要跳页就和「延迟关联」组合用。

  5. 05精确 count 的替代方案

    场景列表页要显示「共 1,234,567 条」--这个数字本身就要扫几秒。

    PG 的 count(*) 要真数(MVCC 下不能用索引元数据偷懒),百万级秒起。替代:① pg_class.reltuples 估算(毫秒级,误差百分之几);② 计数表 + 触发器维护(精确、有写放大);③ 前端展示「约 123 万条」。绝大多数列表页,用户根本不在乎精确总数。

    -- 估算版(毫秒)
    select reltuples::bigint from pg_class where relname = 'orders';
    -- 精确版(慢)
    select count(*) from orders;

    易错reltuples 依赖统计新鲜度(D41);号称「精确」的功能页(对账、结算)别用估算。

练 · 65 min

  1. OFFSET 0 / 10000 / 500000 各跑一次,记录耗时曲线
    参考答案

    预期:三档耗时近似线性增长(第 0 页毫秒级,50 万页慢一到两个量级)--OFFSET 不是「跳到第 N 行」,是把前 N 行扫出来再丢掉。把三档数字记进 notes.md,画出来就是一条上升直线。

    \timing on
    select id from orders order by id offset 0      limit 20;
    select id from orders order by id offset 10000  limit 20;
    select id from orders order by id offset 500000 limit 20;
  2. 把同一分页改写成 keyset 分页(WHERE id < :last_id ORDER BY id DESC LIMIT 20
    参考答案

    (a, b) < (x, y) 的行比较是 PG 的标准写法。无论翻到第几页,耗时都和第一页同量级(只扫 20 行)--和第 1 题的曲线对比就是本天的核心结论。限制主动说:只能上一页/下一页,不能随机跳页,排序键中途不能换。

    -- 第一页(按 id 降序的极简版)
    select id, created_at, total_amount
    from orders
    order by id desc
    limit 20;
    
    -- 下一页:把上一页最后一行的 id 代进来
    select id, created_at, total_amount
    from orders
    where id < '上一页最后一行的id'::uuid
    order by id desc
    limit 20;
    
    -- 业务排序版(按 created_at desc):复合排序键必须带 id 兜底
    select id, created_at, total_amount
    from orders
    where (created_at, id) < ('上一页末行的created_at', '上一页末行的id'::uuid)
    order by created_at desc, id desc
    limit 20;
  3. 用延迟关联优化:先取 id 再回表取详情
    参考答案

    谓词下推的应用:内层只取 id(窄、可走索引),外层用 20 个 id 回表取全部列。对比直接 offset 500000 的版本,深页耗时明显下降。产品非要跳页时用这招,能顺序翻页就用 keyset。

    -- 前提:created_at 上有索引(没有就先补一个)
    create index concurrently if not exists idx_orders_created on orders (created_at);
    
    select o.id, o.user_id, o.status, o.total_amount, o.created_at
    from (
      select id from orders
      order by created_at desc
      offset 500000 limit 20        -- 只在索引层翻页,先拿 20 个 id
    ) page
    join orders o on o.id = page.id
    order by o.created_at desc;
  4. pg_class.reltuples 做近似总数,对比精确 count 的耗时
    参考答案

    预期:精确版慢两三个量级。reltuples 的误差取决于上次 analyze(D41),一般百分之几以内;对账、结算这类号称「精确」的页面别用估算。

    \timing on
    -- 精确版:百万行逐行可见性检查
    select count(*) from orders;
    
    -- 估算版:读统计信息
    select reltuples::bigint as 估算行数
    from pg_class
    where relname = 'orders';
  5. 做一张三种分页方案的对比表(耗时、能否跳页、适用场景)
    参考答案

    对比表三行:OFFSET--能随机跳页,但越翻越慢,只适合浅分页;keyset--耗时恒定最优,但只能上一页/下一页、排序键不能中途变,适合信息流和无限滚动;延迟关联--仍用 OFFSET 但只在索引层翻页,深分页救急用。列建议:方案 / 耗时(用本天实测数字填)/ 能否跳页 / 适用场景。

过关标准 能写出 keyset 分页 SQL,并主动说出它的限制(不能随机跳页)。