计算分页元数据无需在SQL中进行额外的往返操作(附实例)

当在SQL中对结果进行分页时,我们使用标准的SQLOFFSET .. FETCH 或供应商的特定版本,如LIMIT .. OFFSET 。比如说:

SELECT first_name, last_name
FROM actor
ORDER BY actor_id
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY

像往常一样,我们在这个例子中使用Sakila数据库

这是很直接的。它将给我们N个页面中的第2页,页面大小为10。但我们如何计算这些数值?我们怎么知道我们是在第2页?我们怎么知道第几页N ?我们能不能在不进行额外的往返计算的情况下,例如计算演员的总人数:

-- Yuck, a second round-trip!
SELECT COUNT(*)
FROM actor

我们可以用一个SQL查询和窗口函数来做,但在我解释如何做之前,请考虑阅读这篇文章:为什么OFFSET 分页对你的性能是一件坏事

如果你仍然相信OFFSET 分页是你需要的,而不是keyset分页,让我们看看如何用SQL计算上述元数据。

我们需要什么元数据?

我们通常需要使用OFFSET 分页的元数据是这些:

  • TOTAL_ROWS:如果我们没有分页,记录的总数
  • CURRENT_PAGE:当前我们所处的页面
  • MAX_PAGE_SIZE:最大的页面大小
  • ACTUAL_PAGE_SIZE:实际页面大小(当在最后一页时)
  • ROW:返回的行的实际偏移量
  • LAST_PAGE:我们是否在最后一页

最大页面大小是我们在查询中设置的,所以它不需要被计算。其他的都需要计算。下面是如何在一个单一的查询中做到这一点的

SELECT 
  t.first_name, 
  t.last_name,

  -- Calculate some meta data
  COUNT(*) OVER () AS actual_page_size,
  MAX(row) OVER () = total_rows AS last_page,

  -- Metadata from the subquery
  total_rows,
  row,
  ((row - 1) / :max_page_size) + 1 AS current_page
FROM (
  SELECT
    u.*,

    -- Calculate some meta data, repeating the ORDER BY from
    -- the original query
    COUNT(*) OVER () AS total_rows,
    ROW_NUMBER () OVER (ORDER BY u.actor_id) AS row

  -- Original query with all the predicates, joins, as a derived table
  FROM (
    SELECT *
    FROM actor
  ) AS u

  -- Ordering and pagination done here, where :offset is
  -- The maximum row value of the previous page + 1
  ORDER BY u.actor_id
  OFFSET :offset ROWS
  FETCH NEXT :max_page_size ROWS ONLY
) AS t
ORDER BY t.actor_id

就这样了。令人印象深刻吗?不要害怕,我将一步一步地指导你完成这些事情。如果你曾经对SQL语法感到困惑,可以考虑这篇文章,它解释了SQL操作的逻辑顺序,对于我们的例子来说,这就是:

  • FROM (派生表的递归顺序)
  • WHERE (这个例子省略了)
  • WINDOW 计算
  • SELECT (投影)
  • ORDER BY
  • OFFSET .. FETCH

注释我们的查询,将操作的逻辑顺序定为:1.1,1.2,2.1,2.2,2.3,2.4,2.5,3.1,3.2,3.3,3.4:

-- 3.3
SELECT 
  t.first_name, 
  t.last_name,

  -- 3.2
  COUNT(*) OVER () AS actual_page_size,
  MAX(row) OVER () = total_rows AS last_page,

  -- 3.3
  total_rows,
  row,
  ((row - 1) / :max_page_size) + 1 AS current_page
-- 3.1
FROM (
  -- 2.3
  SELECT
    u.*,

    -- 2.2
    COUNT(*) OVER () AS total_rows,
    ROW_NUMBER () OVER (ORDER BY u.actor_id) AS row

  -- 2.1
  FROM (

    -- 1.2
    SELECT *
    -- 1.1
    FROM actor
  ) AS u

  -- 2.4
  ORDER BY u.actor_id

  -- 2.5
  OFFSET :offset ROWS
  FETCH NEXT :max_page_size ROWS ONLY
) AS t

-- 3.4
ORDER BY t.actor_id

一步一步的解释

首先,原始查询SELECT * FROM actor ,被包装成一个名为u 的派生表。你几乎可以对这个原始查询做任何事情,只需应用一些转换:

  • 1.1, 1.2, 2.1:你 需要投射(SELECT 子句)你的原始查询所投射的列,加上你需要的列ORDER BY 。因为我在最外层的查询中投射了正确的东西,而且在原始查询中没有DISTINCT 子句,所以我很方便地投射了* 。另外,我还可以投射FIRST_NAMELAST_NAME (因为在原始查询中已经投射了)和ACTOR_ID (因为这就是我们的ORDER BY )。
  • 2.2:在 那个派生表u ,我们现在能够计算一些元数据,包括TOTAL_ROWS 作为COUNT(*) OVER ()ROW 作为ROW_NUMBER () OVER (ORDER BY t.actor_id)COUNT(*) OVER () 窗口函数有一个空的窗口规范OVER () ,这意味着它计算所有由FROM,WHERE,GROUP BY,HAVING 子句产生的行,即在我们的特定例子中由u 。没有第二次往返!ROW_NUMBER () OVER (ORDER BY u.actor_id)u 中的所有行按u.actor_id 排序,并根据该排序给它们分配唯一的行号。
  • 2.3:窗 口函数是隐式计算的,因为它们位于这个派生表的投影中。我们也将再次方便地从u.* ,因为最外层的查询是明确地投影列。
  • 2.4:原来的排序被移到了这里,因为如果我们对u 的内容进行排序,就不能保证排序会被保持。但是我们需要这个排序来计算OFFSET .. FETCH 之后的内容。
  • 2.5:这里是我们分页的地方。OFFSET 对应于我们之前遇到的最大ROW 值。我们从0 ,在页面大小为15 ,我们在下一页使用15 。记住,在SQL中索引是基于1 ,而OFFSET 是基于0
  • 3.1:以上所有的内容又被包裹在一个派生表中,以便对其进行进一步的计算,即。
  • 3.2:我们可以再次计算COUNT(*) OVER () ,计算由FROM,WHERE,GROUP BY,HAVING 子句产生的总行数,即在我们的特定例子中由t 。这一次,行数不能超过MAX_PAGE_SIZE ,因为这是FETCH (或LIMIT )子句在t 中所说的。但是也可以更少,所以这就是我们用来计算ACTUAL_PAGE_SIZE 。最后,我们比较MAX(row) OVER () = total_rows ,看看我们是否在最后一页,也就是说,将t 所产生的当前页中row 的最高值与总行数进行比较。另一种计算LAST_PAGE 值的方法是如果ACTUAL_PAGE_SIZE < MAX_PAGE_SIZE ,即COUNT(*) OVER () < :MAX_PAGE_SIZE
  • 3.3:除了对原始列FIRST_NAME,LAST_NAME (我们现在不再推算* !)的常规推算外,我们还要做一些最后的计算,包括除以ROW / TOTAL_ROWS ,得到页码。你可以计算更多的东西,比如TOTAL_ROWS / MAX_PAGE_SIZE ,得到TOTAL_PAGES 的值。
  • 3.4最 后,我们必须再次ORDER BY t.actor_id ,不要让任何人告诉你。在SQL中,如果你不ORDER BY ,那么排序就没有定义。当然,如果优化器没有任何好的理由就重新排序,那就太傻了。我们已经在2.4中对子查询的内容进行了排序,但是并不能保证这个排序是稳定的。只要在你的查询中添加DISTINCT,UNION, 或者一个JOIN ,导致一个哈希连接或者一些随机的其他运算符,就会破坏排序。所以,如果排序对你来说很重要的话,一定要用ORDER BY

我们就完成了!

如何在jOOQ中做到这一点?

这就是jOOQ真正闪亮的用例,因为所有这些都是关于动态SQL。你的实际业务逻辑包含在深度嵌套的u 表中。其他的都是 "表现逻辑",由于非常明显的原因,这些都是用SQL实现的。为了提高性能。

因为你想在你的某个库中只实现一次所有这些,而不是在每次查询时都要玩这个游戏,所以你要使这种查询成为动态的。该实用程序将看起来像这样:

// Assuming as always the usual static imports, including:
// import static org.jooq.impl.DSL.*;
// import com.generated.code.Tables.*;

static Select<?> paginate(
    DSLContext ctx,
    Select<?> original,
    Field<?>[] sort,
    int limit,
    int offset
) {
    Table<?> u = original.asTable("u");
    Field<Integer> totalRows = count().over().as("total_rows");
    Field<Integer> row = rowNumber().over().orderBy(u.fields(sort))
        .as("row");

    Table<?> t = ctx
        .select(u.asterisk())
        .select(totalRows, row)
        .from(u)
        .orderBy(u.fields(sort))
        .limit(limit)
        .offset(offset)
        .asTable("t");

    Select<?> result = ctx
        .select(t.fields(original.getSelect().toArray(Field[]::new)))
        .select(
            count().over().as("actual_page_size"),
            field(max(t.field(row)).over().eq(t.field(totalRows)))
                .as("last_page"),
            t.field(totalRows),
            t.field(row),
            t.field(row).minus(inline(1)).div(limit).plus(inline(1))
                .as("current_page"))
        .from(t)
        .orderBy(t.fields(sort));

    // System.out.println(result);
    return result;
}

注意到用于调试的println了吗?它将再次打印出类似于我们的原始查询的东西(但你也会在你的调试日志输出中看到,默认情况下,用jOOQ):

select
  t.ACTOR_ID,
  t.FIRST_NAME,
  t.LAST_NAME,
  count(*) over () as actual_page_size,
  (max(t.row) over () = t.total_rows) as last_page,
  t.total_rows,
  t.row,
  ((t.row / 15) + 1) as current_page
from (
  select
    u.*,
    count(*) over () as total_rows,
    row_number() over (order by u.ACTOR_ID) as row
  from (
    select
      ACTOR.ACTOR_ID,
      ACTOR.FIRST_NAME,
      ACTOR.LAST_NAME
    from ACTOR
  ) as u
  order by u.ACTOR_ID
  offset 30 rows
  fetch next 15 rows only
) as t
order by t.ACTOR_ID

这里是你如何调用这个工具的:

System.out.println(
    paginate(
        ctx,
        ctx.select(ACTOR.ACTOR_ID, ACTOR.FIRST_NAME, ACTOR.LAST_NAME)
           .from(ACTOR),
        new Field[] { ACTOR.ACTOR_ID },
        15,
        30
    ).fetch()
);

请注意,你可以将任意的SQL片段插入该工具中,并对其进行分页。无论复杂程度如何(包括连接、其他窗口函数、分组、递归等等),jOOQ都会帮你搞定,现在会替你分页。

上述的结果是:

+--------+----------+---------+----------------+---------+----------+----+------------+
|ACTOR_ID|FIRST_NAME|LAST_NAME|actual_page_size|last_page|total_rows| row|current_page|
+--------+----------+---------+----------------+---------+----------+----+------------+
|      31|SISSY     |SOBIESKI |              15|false    |       200|  31|           3|
|      32|TIM       |HACKMAN  |              15|false    |       200|  32|           3|
|      33|MILLA     |PECK     |              15|false    |       200|  33|           3|
|      34|AUDREY    |OLIVIER  |              15|false    |       200|  34|           3|
|      35|JUDY      |DEAN     |              15|false    |       200|  35|           3|
|      36|BURT      |DUKAKIS  |              15|false    |       200|  36|           3|
|      37|VAL       |BOLGER   |              15|false    |       200|  37|           3|
|      38|TOM       |MCKELLEN |              15|false    |       200|  38|           3|
|      39|GOLDIE    |BRODY    |              15|false    |       200|  39|           3|
|      40|JOHNNY    |CAGE     |              15|false    |       200|  40|           3|
|      41|JODIE     |DEGENERES|              15|false    |       200|  41|           3|
|      42|TOM       |MIRANDA  |              15|false    |       200|  42|           3|
|      43|KIRK      |JOVOVICH |              15|false    |       200|  43|           3|
|      44|NICK      |STALLONE |              15|false    |       200|  44|           3|
|      45|REESE     |KILMER   |              15|false    |       200|  45|           3|
+--------+----------+---------+----------------+---------+----------+----+------------+

或者,在最后一页,偏移量为195

+--------+----------+---------+----------------+---------+----------+----+------------+
|ACTOR_ID|FIRST_NAME|LAST_NAME|actual_page_size|last_page|total_rows| row|current_page|
+--------+----------+---------+----------------+---------+----------+----+------------+
|     196|BELA      |WALKEN   |               5|true     |       200| 196|          14|
|     197|REESE     |WEST     |               5|true     |       200| 197|          14|
|     198|MARY      |KEITEL   |               5|true     |       200| 198|          14|
|     199|JULIA     |FAWCETT  |               5|true     |       200| 199|          14|
|     200|THORA     |TEMPLE   |               5|true     |       200| 200|          14|
+--------+----------+---------+----------------+---------+----------+----+------------+

结论

jOOQ都是关于动态SQL的。几乎没有任何SQL功能是jOOQ不支持的。这包括窗口函数,例如,但也确保你的动态SQL能在大量的SQL方言上工作,而不考虑小的语法细节。

你可以建立自己的库,从其他SQL构件中构建可重用的SQL元素,正如本文所示,动态创建单查询OFFSET 分页元数据计算,而无需执行额外的数据库往返。

猜你喜欢

转载自juejin.im/post/7126372764553854990