暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

分页和结果总数的问题

在交互式应用程序中处理大结果集时,您希望对结果集进行分页,即逐页显示。从第一次网络搜索开始,每个人都熟悉这一点。您还会得到一个滚动到下一页的按钮,并且您会得到一个总结果数。本文展示了结果集分页的各种选项及其性能。它还讨论了总结果计数的问题。


演示分页技术的示例

让我们创建一个包含足够时间序列数据的表:

    CREATE UNLOGGED TABLE data (
       id bigint GENERATED ALWAYS AS IDENTITY,
       value double precision NOT NULL,
       created timestamp with time zone NOT NULL
    );
    /* make the example repeatable */
    SELECT setseed(0.2740184);
    INSERT INTO data (value, created)
    SELECT random() * 1000, d
    FROM generate_series(
            TIMESTAMP '2022-01-01 00:00:00 UTC',
            TIMESTAMP '2022-12-31 00:00:00 UTC',
            INTERVAL '1 second'
         ) AS d(d);
    /* add a primary key */
    ALTER TABLE data ADD PRIMARY KEY (id);
    /* set hint bits, create visibility map, gather statistics */
    VACUUM (ANALYZE) data;

我们感兴趣的查询是:
    SELECT value, created
    FROM data
    WHERE value BETWEEN 0 AND 10
    ORDER BY created;

查询的最佳索引(至少在我的机器上)是一个两列索引,它允许 PostgreSQL 使用支持该ORDER BY子句的index-only扫描:
   CREATE INDEX data_created_value_idx ON data
       (created, value);

该索引适用于快速获取第一行,但它也是获取整个结果集的最佳索引。


简单分页:OFFSET和LIMIT

    /* the first page */
    SELECT value, created
    FROM data
    WHERE value BETWEEN 0 AND 10
    ORDER BY created
    LIMIT 50;
    /* page number 100 */
    SELECT value, created
    FROM data
    WHERE value BETWEEN 0 AND 10
    ORDER BY created
    OFFSET 4950 LIMIT 50;

我们希望以50行的块的形式获取结果集。使用OFFSET和 LIMIT,我们可以这样做:

当您获取后面的页面时,性能会变得更差。要获得页码 100,PostgreSQL 必须计算结果集的前 5000 行,只丢弃前 4950 行。因此,当您翻阅结果集时,处理速度会变慢,我们会做很多不必要的工作。如果你知道用户只会看前几个结果,你就可以避开这个问题。

并发数据修改可能会导致问题。如果有人在我们查看第一页时向表中插入一个新行,那么该行可能会出现在第一页上。现在如果我们获取第二页,整个结果集将移动一个位置,第二页将从第一页的最后一行开始。更糟糕的是,如果并发语句删除一行,当我们移动到第二页时,可能会跳过结果集中的一行。

OFFSET和LIMIT的优势

这种技术有一个主要优点:它很简单并且可以用于所有查询。另外,只要你只看前几页,它的表现也很不错。

WITH HOLD使用游标分页WITH HOLD使用游标分页

游标是获取块中结果集的自然方式。但是,普通游标只能在单个事务的上下文中工作。因此,普通游标对分页没有用,因为在事务打开时进行用户交互是一个非常糟糕的主意:长事务不仅会长时间持有表锁(这可能会阻塞DDL或TRUNCATE语句),而且它也会阻塞autovacuum的进程,这会导致表膨胀。

但是你可以使用WITH HOLD游标。这样的游标在事务结束时获取完整的结果集并将其具体化在服务器上:
    START TRANSACTION;
    /*
    * Don't use SCROLL unless you need to scroll backwards,
    * for example to get the total count (see below).
    */
    DECLARE c SCROLL CURSOR WITH HOLD FOR
    SELECT value, created
    FROM data
    WHERE value BETWEEN 0 AND 10
    ORDER BY created;
    /* this will calculate the whole result set */
    COMMIT;


现在获取任意页面很简单:
    /* get page number 100 */
    MOVE ABSOLUTE 4950 IN c;
    FETCH 50 FROM c;


完成后不要忘记关闭光标:
     CLOSE c;

在提交事务之前获取第一页是一个明智的想法,因为COMMIT会具体化结果集并且可能需要很长时间。在那段时间里,客户端已经可以渲染出第一个结果页面,用户可以看到了。

WITH HOLD游标的优点


适用于所有查询
结果集是稳定的,并且不存在像OFFSET和LIMIT那样跳过或重复结果的危险
如果您最终想要检索整个结果集或需要总计数(见下文),这可能是最好的方法,因为它无论如何都会计算整个结果集

WITH HOLD游标的缺点

完成后一定不要忘记关闭游标,否则结果集将保留在服务器上,直到数据库会话结束

事务级连接池不适用于WITH HOLD游标,因为它们绑定到数据库连接

整个结果集是在初始事务结束时计算的,因此获取第一页可能会很慢(但后续页面会很快)

如果光标保持打开的时间较长,结果将变得“陈旧”

键集分页

这是对结果集进行分页的最先进的方法。它也要求我们获取该id列。第一页是通过以下方式获取的:

    SELECT id, value, created
    FROM data
    WHERE value BETWEEN 0 AND 10
    ORDER BY created, id
    LIMIT 50;

我们必须记住页面最后id一行created的值。然后我们可以获取下一页

    SELECT id, value, created
    FROM data
    WHERE value BETWEEN 0 AND 10
    AND (created, id) > ('2022-01-01 01:27:35+01', 5256)
    ORDER BY created, id
    LIMIT 50;

我们从上一页中记住的值是2022-01-01 01:27:35+01和5256。我们需要id作为分界符,以防有多行具有相同的created值。
为了使键集分页高效,我们需要创建一个支持第二个WHERE条件的特殊索引:

     CREATE INDEX data_keyset_idx ON data
     (created, id, value);

如果value不包含在索引中,键集分页就足够快了,但我添加它以获得index-only扫描,因此与其他方法的性能比较是公平的。

键集分页的优点


每个查询只获取我们需要的那部分结果集,因此性能会比其他技术更好
没有跳过或重复结果的危险,例如 with OFFSET和LIMIT
每个查询将检索反映最新并发数据修改的当前数据

键集分页的缺点

需要专门为查询设计的特殊索引

只有事先知道确切的查询才有用

比较分页方法的性能

以下结果是在开始时针对示例测得的。我通过本地连接测量了客户端的响应时间(五次运行的平均值)。此外,我还用于EXPLAIN (ANALYZE, BUFFERS)确定查询必须处理多少个缓冲区。所有数据都缓存在共享缓冲区中,“ random_page_cost”设置为 1.1。

第一页的分页OFFSET和LIMIT获胜(可能是因为它少获取一列),但是如果您必须获取更多页面,则键集分页会更好。光标分页只能在您COMMIT异步进行时竞争,而用户正在观看第一页。但是,对于后续页面,它比其他方法更快,因为结果集已经计算好了。

结果总数有什么问题?

到目前为止,我们已经看到有一些聪明的方法可以对查询结果进行分页。但是,没有聪明的方法来获得总结果数。您必须计算完整的结果集,然后对其进行计数。其影响因所选的分页方法而异:

使用OFFSET和键集分页,需要额外的昂贵查询。如果仅检索前几页,则计算结果集总数的查询通常比所有其他查询加在一起的开销更大。

使用WITH HOLD游标分页,整个结果集都是在开始时计算的,因此结果集计数是免费的:

    /* the result will be "MOVE 314711" */
    MOVE ALL IN c;

不管你怎么做,计算整个结果集都很慢。我见过许多情况,其中count(*)计算总结果集计数的查询是应用程序工作负载中最昂贵的查询。因此,我的建议是:


最简单的解决方案就是不显示结果集计数。把事情简单化!

下一个最佳解决方案是显示近似的结果集计数。如果您运行“ EXPLAIN (FORMAT JSON) SELECT 1 FROM / your query /”而不是“ SELECT count() FROM / your query */”,这很容易。EXPLAIN然后您可以从输出中提取行计数估计值。有关详细信息,请参阅我关于行计数的文章。请记住,Google 也不会显示确切的计数!

如果您确实需要准确的结果集计数,请不要立即显示它。相反,将其设置为最初禁用的选项,例如按钮“获取精确计数”。然后用户只需在他或她认为有必要时付款。

无论做什么,只在显示第一页时才计算或估计计数。不要陷入为用户显示的每个结果集页面重复查询的陷阱。

结论

对结果进行分页时,您可以在OFFSET/ LIMIT、WITH HOLD游标和键集分页之间进行选择。这些方法中的每一种都有其优点和缺点,您必须决定哪种方法最适合您的情况。考虑到显示总结果集数是非常昂贵的,所以尽可能避免它。

点击此处阅读原文

↓↓↓

文章转载自开源软件联盟PostgreSQL分会,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论