◆ 件数取得とデータの取得で 2 クエリにしたくない
◆ WHERE の絞り込みは同じ内容になるのに 2 回するのはムダになる
◆ 1 クエリにまとめて 件数は全レコードの追加の列として受け取る

検索の合計ヒット件数はいらないと思う

検索画面ってよくヒットした件数も表示されてたりしますよね
Google だと大まかな数値になってますが 「約 4,060,000,000 件」 みたいな表示があります

Google みたいなのだと この数値も価値がありそうですが その他のほとんどのシステムではいらない情報だと思うんです
全部が 500 件でも 1000 件でもどうでもいいじゃないですか
ページャで次のページがあるかないかだけ分かれば十分です
UI ライブラリなどを見ていても シンプルに ◀ ▶ ボタンだけで 合計件数や昔ながらのページャを使わないものもよく見るようになって来ている気がします

使う側としては あって困るわけじゃないのでどうでもいいのですが 作る側としては面倒なんです
パフォーマンス的にも遅くなる要因ですし
最後のページを表示するページャを使うなら そのページの番号を知るために全件数は使うかもしれません
でも 最後のページに飛ぶなんてかなりレアなケースです
1% も使う人がいるんでしょうか?

このブログはライブドアブログの機能で 最後のページヘのリンクがあるページャがついてるので アクセス解析を見てみました
この 1 年で約 60 万アクセスあったうちの 10 件もない程度でした
しかも存在しない 100 ページ目とかへのアクセスもあるくらいで ページャを使わず適当にページ番号を入れたようなアクセスも含まれてそうです

普通に SQL で検索するなら 一度のクエリで両方を持ってこれません
件数を取得するクエリと LIMIT や OFFSET 付きで必要な部分のデータを持ってくる 2 クエリになります
マッチする全件を持ってきて プログラム側でその件数をカウントして LIMIT と OFFSET に当たる処理もすればクエリは 1 つですが ムダなデータ転送が多く良い方法とは思えません

クエリが 2 つになるだけならまだいいのですが WHERE で絞り込まれるレコードは同じなのに 2 クエリに分けて同じ絞り込み処理を 2 回するというのがとてもムダに感じます
インデックスだけで検索できない複雑なクエリで 絞り込みに時間がかかる場合は 2 回になることのパフォーマンス低下は大きいです

1 クエリで取得する

そういうわけで完全に個人で使うものは件数は取得せず ページャや無限スクロール用に次にまだデータがあるかどうかだけ見ています
でもまあ 色々あって件数を表示せざるを得ないようなこともあるんです

どうにか この二度手間感をなくしたくていろいろ試した結果 全レコードの追加の列として取得することにしました
全体にかかるデータなので すべての行に持たせる必要はないのですが RDB の仕組み上 表形式でしか取得できないので列を追加することにしました
JOIN した場合も全部のレコードに保持する必要がないのに すべてのレコードに同じデータを持っていたりしますし
取得する全レコードで同じ件数を受け取ることで 転送量の増加は気になりましたが ページャ等を使って部分的な取得をする場合って 1 回あたりはせいぜい数十件で 多くても 100 件くらいです
数値型が 100 個増えても大した差はないと思うので この方法にしました

例として article テーブルを作ります
id と user_id を保持していて こういうデータです

testdb1=# select id, user_id from article;
id | user_id
----+---------
1 | 1
2 | 1
3 | 1
4 | 1
5 | 1
6 | 1
7 | 1
8 | 1
9 | 1
10 | 1
11 | 1
12 | 1
13 | 2
14 | 2
15 | 2
(15 rows)

user_id が 1 のデータを検索して 1 ページには 5 件表示するとします
id が 1~5 のデータを取得できて 合わせて件数の 12 も取得できれば良いです

普通の方法だと

testdb1=# select count(*) from article where user_id = 1;
count
-------
12
(1 row)

testdb1=# select id, user_id from article where user_id = 1 order by id limit 5;
id | user_id
----+---------
1 | 1
2 | 1
3 | 1
4 | 1
5 | 1
(5 rows)

この 2 つのクエリを送ります
WHERE の絞り込みでインデックスが有効なら これでもいいかもしれません

一括で取得するクエリだとこうなります

testdb1=# with t as (select id, user_id from article where user_id = 1)
select id, user_id, (select count(*) from t) from t order by id limit 5;
id | user_id | count
----+---------+-------
1 | 1 | 12
2 | 1 | 12
3 | 1 | 12
4 | 1 | 12
5 | 1 | 12
(5 rows)

WITH を使って user_id = 1 で絞り込んだ内容を一時テーブルとして そのテーブルから件数取得と limit 5 でデータの取得の両方を行います

速度的にはクエリが減ってますし 同じ WHERE を何度も実行しないので速くなってるはずです
一応計測してみます
このまま user_id = 1 だと WHERE が高速すぎるので適当に遅くなるようにしました
user_id の数字を 100 万回繰り返して最後の 1 文字を取得してから比較してます
user_id は 1 桁で 1 か 2 なので最後の 1 文字でも同じです
型が文字列になるので = 1 を = '1' にしてますが 検索結果は同じになります

testdb1=# \timing
Timing is on.

testdb1=# select count(*) from article where right(repeat(user_id::text, 1000000), 1) = '1';
count
-------
12
(1 row)

Time: 100.512 ms

testdb1=# select id, user_id from article where right(repeat(user_id::text, 1000000), 1) = '1' order by id limit 5;
id | user_id
----+---------
1 | 1
2 | 1
3 | 1
4 | 1
5 | 1
(5 rows)

Time: 104.623 ms

testdb1=# with t as (select id, user_id from article where right(repeat(user_id::text, 1000000), 1) = '1')
select id, user_id, (select count(*) from t) from t order by id limit 5;
id | user_id | count
----+---------+-------
1 | 1 | 12
2 | 1 | 12
3 | 1 | 12
4 | 1 | 12
5 | 1 | 12
(5 rows)

Time: 101.411 ms

速度は何度か実行してみて だいたい中央値なものを選んでます
with を使っても 特別遅いことはなく 単純に count や limit したクエリと同じ程度です
なので 1 クエリで済む分 高速になっています