PostgreSQL 深分页优化:把 OFFSET 200000 的订单查询从 3s 压到 30ms

订单后台有一个很典型的列表页:按租户查订单,按创建时间倒序排列,支持翻页。前几十页一直没问题,到了运营同事导出历史订单时,接口开始明显变慢。页码越靠后,等待时间越长。

数据库是 PostgreSQL 15.6,订单表大约 860 万行,单个租户下有 70 多万行。表结构做过简化:

CREATE TABLE order_event (
    id          BIGSERIAL PRIMARY KEY,
    tenant_id   BIGINT      NOT NULL,
    user_id     BIGINT      NOT NULL,
    status      SMALLINT    NOT NULL,
    amount      NUMERIC(12, 2) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL,
    deleted_at  TIMESTAMPTZ,
    remark      TEXT
);

列表页原来的 SQL 是这样写的:

SELECT
    id,
    user_id,
    status,
    amount,
    created_at
FROM order_event
WHERE tenant_id = 42
  AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 20 OFFSET 200000;

这个写法在业务代码里很常见。前端传 pagepageSize,后端换算成:

offset = (page - 1) * pageSize;

前几页没什么问题,真正拖垮接口的是深分页。

OFFSET 慢在“跳过”这一步

先看第 1 页。

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    id,
    user_id,
    status,
    amount,
    created_at
FROM order_event
WHERE tenant_id = 42
  AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;

执行计划大概是这样:

Limit  (cost=0.43..5.92 rows=20 width=34)
       (actual time=0.091..2.147 rows=20 loops=1)
  Buffers: shared hit=26
  ->  Index Scan using idx_order_event_tenant_ctime on order_event
        (cost=0.43..193418.29 rows=704238 width=34)
        (actual time=0.089..2.139 rows=20 loops=1)
        Index Cond: (tenant_id = 42)
        Filter: (deleted_at IS NULL)
        Buffers: shared hit=26
Planning Time: 0.342 ms
Execution Time: 2.181 ms

第 1 页只需要沿着索引拿 20 条,速度很正常。

再看第 10001 页,也就是 OFFSET 200000

Limit  (cost=54928.63..54934.12 rows=20 width=34)
       (actual time=3087.421..3087.771 rows=20 loops=1)
  Buffers: shared hit=183911 read=24166
  ->  Index Scan using idx_order_event_tenant_ctime on order_event
        (cost=0.43..193418.29 rows=704238 width=34)
        (actual time=0.083..3077.894 rows=200020 loops=1)
        Index Cond: (tenant_id = 42)
        Filter: (deleted_at IS NULL)
        Rows Removed by Filter: 18391
        Buffers: shared hit=183911 read=24166
Planning Time: 0.287 ms
Execution Time: 3087.812 ms

这里的关键是:

actual time=0.083..3077.894 rows=200020

虽然最终只返回 20 条,PostgreSQL 仍然要先按顺序找到前 200020 条,再丢掉前 200000 条。

OFFSET 的语义就是跳过指定数量的结果。跳过不等于数据库可以瞬间定位到第 200000 条。尤其在有过滤条件、MVCC 可见性判断、软删除条件时,数据库需要一行一行确认哪些记录属于结果集。

页码越深,前面被跳过的行越多,耗时自然越来越长。

这类慢查询还有一个麻烦点:执行计划看起来并不离谱。它用了索引,也没有额外排序。问题藏在扫描行数里。

原来的索引只能缓解,不能解决深分页

当时表上有这个索引:

CREATE INDEX idx_order_event_tenant_ctime
ON order_event (tenant_id, created_at DESC);

它能支持下面这个排序:

WHERE tenant_id = 42
ORDER BY created_at DESC

如果没有这个索引,查询还要额外排序,情况会更糟。索引已经让 PostgreSQL 可以按时间倒序扫描,但 OFFSET 200000 依然要求它跳过 20 万条结果。

软删除条件也影响了扫描效率:

AND deleted_at IS NULL

如果租户下有不少已删除订单,PG 沿着索引扫描时还要把这些记录过滤掉。为了减少这部分无效扫描,我先把索引改成部分索引:

CREATE INDEX CONCURRENTLY idx_order_event_tenant_ctime_alive
ON order_event (tenant_id, created_at DESC, id DESC)
INCLUDE (user_id, status, amount)
WHERE deleted_at IS NULL;

这里多加了 id DESC。原因是 created_at 可能重复,单独按时间排序不够稳定。同一秒内插入多条订单时,如果只按 created_at DESC,不同查询之间的返回顺序可能抖动,分页时容易出现重复或遗漏。

列表 SQL 也同步改成稳定排序:

SELECT
    id,
    user_id,
    status,
    amount,
    created_at
FROM order_event
WHERE tenant_id = 42
  AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200000;

加完部分索引后,深分页有改善,但不彻底:

Limit  (cost=38421.55..38425.39 rows=20 width=34)
       (actual time=1264.802..1265.119 rows=20 loops=1)
  Buffers: shared hit=82105 read=6412
  ->  Index Scan using idx_order_event_tenant_ctime_alive on order_event
        (cost=0.43..135182.80 rows=704238 width=34)
        (actual time=0.061..1255.483 rows=200020 loops=1)
        Index Cond: (tenant_id = 42)
        Buffers: shared hit=82105 read=6412
Execution Time: 1265.156 ms

从 3s 降到 1.2s,但页面深了之后还是慢。索引减少了过滤和回表压力,OFFSET 需要跳过大量记录的问题还在。

改成游标分页

后台列表后来拆成两种场景。

普通查看保留页码分页,因为用户一般只翻前几页。

历史导出和连续加载改成游标分页,也叫 keyset pagination。它不再传 page=10001,而是传上一页最后一条记录的排序键。

第一页查询:

SELECT
    id,
    user_id,
    status,
    amount,
    created_at
FROM order_event
WHERE tenant_id = 42
  AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 20;

假设第一页最后一条记录是:

created_at = 2026-01-06 10:21:35.812+08
id         = 9812731

下一页就带上这两个值:

SELECT
    id,
    user_id,
    status,
    amount,
    created_at
FROM order_event
WHERE tenant_id = 42
  AND deleted_at IS NULL
  AND (created_at, id) < ('2026-01-06 10:21:35.812+08', 9812731)
ORDER BY created_at DESC, id DESC
LIMIT 20;

这里用的是 PostgreSQL 的行值比较。排序是:

ORDER BY created_at DESC, id DESC

下一页要取“排在上一页最后一条之后”的数据,所以条件是:

(created_at, id) < (:lastCreatedAt, :lastId)

这条 SQL 能继续使用前面那条索引:

CREATE INDEX CONCURRENTLY idx_order_event_tenant_ctime_alive
ON order_event (tenant_id, created_at DESC, id DESC)
INCLUDE (user_id, status, amount)
WHERE deleted_at IS NULL;

改完后的执行计划:

Limit  (cost=0.43..6.12 rows=20 width=34)
       (actual time=0.841..31.627 rows=20 loops=1)
  Buffers: shared hit=48 read=9
  ->  Index Scan using idx_order_event_tenant_ctime_alive on order_event
        (cost=0.43..126104.79 rows=471982 width=34)
        (actual time=0.839..31.619 rows=20 loops=1)
        Index Cond: ((tenant_id = 42)
                 AND (ROW(created_at, id)
                      < ROW('2026-01-06 10:21:35.812+08'::timestamp with time zone, 9812731)))
        Buffers: shared hit=48 read=9
Planning Time: 0.391 ms
Execution Time: 31.662 ms

这次数据库没有扫描 200020 行。它从游标位置继续往后读,拿够 20 条就停。

前后对比很明显:

查询方式 执行时间 扫描行数 buffer 数量 适合场景
LIMIT 20 OFFSET 0 2ms 左右 20 26 首页
LIMIT 20 OFFSET 200000 3s 左右 200020 20 万左右 不适合深分页
部分索引 + OFFSET 200000 1.2s 左右 200020 8 万左右 只能缓解
游标分页 30ms 左右 20 50 左右 连续翻页、导出

游标分页把性能从“页码越深越慢”改成了“每页查询成本接近固定”。这对订单流水、消息列表、操作日志、账单明细这类时间线数据很合适。

排序字段必须稳定

游标分页最容易踩的坑是排序字段不唯一。

如果只用 created_at 做游标:

WHERE created_at < :lastCreatedAt
ORDER BY created_at DESC
LIMIT 20;

同一时间点有多条记录时,分页会出问题。

比如第一页最后一条是:

created_at = 2026-01-06 10:21:35.812+08

这个时间点下其实还有 10 条订单没有返回。下一页用了:

created_at < '2026-01-06 10:21:35.812+08'

这些同时间订单会被直接跳过。

所以我这里用了两个字段:

ORDER BY created_at DESC, id DESC

对应游标条件:

AND (created_at, id) < (:lastCreatedAt, :lastId)

created_at 负责业务排序,id 负责兜底排序。只要 id 唯一,整个排序结果就是稳定的。

如果页面是正序加载,条件也要跟着变:

ORDER BY created_at ASC, id ASC

下一页条件要写成:

AND (created_at, id) > (:lastCreatedAt, :lastId)

方向不能反。这个地方一旦写错,轻则重复数据,重则第一页之后查不到结果。

Java 里的分页参数也要换掉

原来的接口参数是:

{
  "page": 10001,
  "pageSize": 20
}

游标分页改成:

{
  "pageSize": 20,
  "lastCreatedAt": "2026-01-06T10:21:35.812+08:00",
  "lastId": 9812731
}

第一页不传游标:

SELECT
    id,
    user_id,
    status,
    amount,
    created_at
FROM order_event
WHERE tenant_id = :tenantId
  AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT :pageSize;

下一页传游标:

SELECT
    id,
    user_id,
    status,
    amount,
    created_at
FROM order_event
WHERE tenant_id = :tenantId
  AND deleted_at IS NULL
  AND (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT :pageSize;

如果用 MyBatis,可以把条件写成动态片段:

<select id="listOrderByCursor" resultType="OrderListItem">
    SELECT
        id,
        user_id,
        status,
        amount,
        created_at
    FROM order_event
    WHERE tenant_id = #{tenantId}
      AND deleted_at IS NULL
    <if test="lastCreatedAt != null and lastId != null">
      AND (created_at, id) &lt; (#{lastCreatedAt}, #{lastId})
    </if>
    ORDER BY created_at DESC, id DESC
    LIMIT #{pageSize}
</select>

返回结果时,把最后一条记录带回给前端:

public CursorPage<OrderListItem> listByCursor(OrderQuery query) {
    int pageSize = Math.min(query.getPageSize(), 100);

    List<OrderListItem> rows = orderMapper.listOrderByCursor(
        query.getTenantId(),
        query.getLastCreatedAt(),
        query.getLastId(),
        pageSize + 1
    );

    boolean hasNext = rows.size() > pageSize;
    if (hasNext) {
        rows = rows.subList(0, pageSize);
    }

    Cursor nextCursor = null;
    if (hasNext && !rows.isEmpty()) {
        OrderListItem last = rows.get(rows.size() - 1);
        nextCursor = new Cursor(last.getCreatedAt(), last.getId());
    }

    return new CursorPage<>(rows, nextCursor, hasNext);
}

这里多查 1 条,是为了判断后面还有没有下一页。实际返回时仍然只返回 pageSize 条。

过滤条件变化时,游标要重新开始

游标只对当前筛选条件有效。

比如第一页查询的是:

tenant_id = 42
status = 2

下一页也必须继续使用同样的过滤条件。如果用户切换了订单状态、修改了时间范围、换了关键字,之前的游标就不能继续用。

我后来在接口里做了一个处理:游标里不只放 lastCreatedAtlastId,还放一份查询条件摘要。后端收到游标后,会校验当前筛选条件和游标对应的条件是否一致。

简化后的游标结构类似这样:

{
  "lastCreatedAt": "2026-01-06T10:21:35.812+08:00",
  "lastId": 9812731,
  "filterHash": "tenant=42&status=2&deleted=false"
}

真实项目里不会直接把这个 JSON 明文暴露出去,而是做一次 Base64 编码,必要时再加签,避免前端随意改游标内容。

public String encodeCursor(CursorPayload payload) {
    String json = objectMapper.writeValueAsString(payload);
    return Base64.getUrlEncoder()
            .withoutPadding()
            .encodeToString(json.getBytes(StandardCharsets.UTF_8));
}

如果游标里的条件摘要和当前查询条件对不上,后端直接按第一页处理,或者返回参数错误。这个规则要提前和前端约定好。

有些页面仍然适合保留 OFFSET

游标分页也有不方便的地方。

它适合“上一页、下一页、继续加载”,不适合直接跳到第 500 页。运营后台有时会要求输入页码跳转,这时游标分页就不顺手。

我的处理方式是分场景:

普通管理后台保留浅分页,限制最大页码或最大 offset。

LIMIT 20 OFFSET 1000

超过阈值后,接口直接提示缩小筛选范围,或者切到导出任务。

连续滚动、历史导出、日志拉取、账单流水统一走游标分页。

这个取舍比较实际。很多后台页面虽然有页码,但用户很少真的跳到几千页之后逐页查看。为了一个低频动作,让所有列表接口都承受深分页成本,并不划算。

当用户真的要拿很深的数据,通常也不是为了“看第 10001 页”,而是为了导出某段时间内的订单。这时游标分页更适合做批量拉取。

导出任务里可以这样循环:

SELECT
    id,
    user_id,
    status,
    amount,
    created_at
FROM order_event
WHERE tenant_id = :tenantId
  AND deleted_at IS NULL
  AND created_at >= :startTime
  AND created_at < :endTime
  AND (
      :lastCreatedAt IS NULL
      OR (created_at, id) < (:lastCreatedAt, :lastId)
  )
ORDER BY created_at DESC, id DESC
LIMIT 1000;

每批 1000 条,记录最后一条的 created_atid,下一批继续往后扫。这样导出几十万条数据时,每一批的查询耗时都比较稳定。

时间范围能加就加

订单列表还有一个额外优化:尽量带时间范围。

如果接口允许用户选择时间,SQL 就变成:

SELECT
    id,
    user_id,
    status,
    amount,
    created_at
FROM order_event
WHERE tenant_id = 42
  AND deleted_at IS NULL
  AND created_at >= '2026-01-01 00:00:00+08'
  AND created_at <  '2026-02-01 00:00:00+08'
  AND (created_at, id) < ('2026-01-06 10:21:35.812+08', 9812731)
ORDER BY created_at DESC, id DESC
LIMIT 20;

这条 SQL 仍然可以使用同一个索引:

ON order_event (tenant_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;

时间范围的价值在于减少候选数据。如果一个租户有 70 万条订单,最近一个月只有 8 万条,查询路径会短很多。导出场景尤其建议强制选择时间范围,不要允许一次性从历史第一天扫到现在。

最后落地的规则

这次改完后,我给订单、账单、日志这几类列表定了几条规则。

前台用户列表可以保留页码,但最大页数要受控。超过范围后,让用户缩小筛选条件。

后台导出不再使用 OFFSET 翻批次,统一改成游标方式。

所有按时间排序的游标分页,都必须带唯一字段兜底:

ORDER BY created_at DESC, id DESC

索引顺序要和筛选、排序对齐:

CREATE INDEX CONCURRENTLY idx_order_event_tenant_ctime_alive
ON order_event (tenant_id, created_at DESC, id DESC)
INCLUDE (user_id, status, amount)
WHERE deleted_at IS NULL;

游标只在当前筛选条件下有效。筛选条件变化后,从第一页重新查。

这些规则落地后,订单导出接口从原来的越跑越慢,变成每批稳定在几十毫秒到一百多毫秒之间。前端列表页的体验也更稳定,至少不会因为某个人点到很深的页码,把数据库拖出一条 3 秒慢查询。

总结

PostgreSQL 的深分页问题,一般不会出现在前几页。等数据量上来,OFFSET 的成本会按跳过行数持续增加。索引能减少排序和过滤成本,但不能消除跳过大量结果这件事本身。

对时间线类数据,游标分页更稳。它需要一个稳定排序键,常见写法是 created_at + id。再配合部分索引、时间范围和筛选条件校验,就能把深分页查询从秒级拉回到几十毫秒。

posted @ 2026-05-09 17:23  小雨青年  阅读(3)  评论(0)    收藏  举报