web-dev-qa-db-ja.com

FILTER句でサブクエリを回避する方法は?

スキーマ

  CREATE TABLE "applications" (
  "id"             SERIAL                   NOT NULL PRIMARY KEY,
  "country"        VARCHAR(2)               NOT NULL,
  "created"        TIMESTAMP WITH TIME ZONE NOT NULL,
  "is_preliminary" BOOLEAN                  NOT NULL,
  "first_name"     VARCHAR(128)             NOT NULL,
  "last_name"      VARCHAR(128)             NOT NULL,
  "birth_number"   VARCHAR(11)              NULL
);

CREATE TABLE "persons" (
  "id"       UUID                     NOT NULL PRIMARY KEY,
  "created"  TIMESTAMP WITH TIME ZONE NOT NULL,
  "modified" TIMESTAMP WITH TIME ZONE NOT NULL
);

ALTER TABLE "applications" ADD COLUMN "physical_person_id" UUID NULL;
CREATE INDEX "physical_person_id_idx" ON "applications" ("physical_person_id");

ALTER TABLE "applications" ADD CONSTRAINT "physical_person_id_fk" FOREIGN KEY ("physical_person_id") REFERENCES "persons" ("id") DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX "country_created" ON "applications" (country, created);

persons.createdの値は、application.createdの値に関係なく、この人の最初のis_preliminaryと同じである必要があります。

クエリ

SELECT
  to_char(created, 'YYYY-MM-DD') AS "Date",
  COUNT(*) AS "Total",
  COALESCE(
    COUNT(*) FILTER(
      WHERE applications.is_preliminary = false
      AND NOT EXISTS(
        SELECT 1
        FROM applications A
        WHERE A.physical_person_id = applications.physical_person_id
          AND A.created < applications.created
        LIMIT 1
      )
    )
    , 0
  ) AS "Is first app"
FROM applications
WHERE
  created >= '2017-01-01'::TIMESTAMP AND created < '2017-07-01'::TIMESTAMP
  AND country = 'CZ'
GROUP BY 1
ORDER BY 1

目標:私の目標は、特定の国における1日あたりのアプリケーションの総数と最初のアプリケーションの数を比較することです。最初のアプリケーションとは、特定の日に、最初に登録され、以前にアプリケーションがなかった多数のアプリケーションを意味します。

問題:クエリのパフォーマンス。行数が増えており、パフォーマンスは現在、良いレベルではありません。

データサンプルここxzpg_dumpの圧縮出力)

次のクエリプランは私のラップトップから取得されます(本番環境では「外部マージ」はありませんでした)

クエリプラン

 GroupAggregate  (cost=54186.11..2391221.59 rows=186832 width=48) (actual time=2137.029..3224.937 rows=181 loops=1)
   Group Key: (to_char(applications.created, 'YYYY-MM-DD'::text))
   ->  Sort  (cost=54186.11..54653.19 rows=186832 width=57) (actual time=2128.554..2370.798 rows=186589 loops=1)
         Sort Key: (to_char(applications.created, 'YYYY-MM-DD'::text))
         Sort Method: external merge  Disk: 8176kB
         ->  Bitmap Heap Scan on applications  (cost=5262.54..30803.18 rows=186832 width=57) (actual time=93.993..411.096 rows=186589 loops=1)
               Recheck Cond: (((country)::text = 'CZ'::text) AND (created >= '2017-01-01 00:00:00'::timestamp without time zone) AND (created < '2017-07-01 00:00:00'::timestamp without time zone))
               Heap Blocks: exact=19640
               ->  Bitmap Index Scan on country_created  (cost=0.00..5215.83 rows=186832 width=0) (actual time=90.945..90.945 rows=186589 loops=1)
                     Index Cond: (((country)::text = 'CZ'::text) AND (created >= '2017-01-01 00:00:00'::timestamp without time zone) AND (created < '2017-07-01 00:00:00'::timestamp without time zone))
   SubPlan 1
     ->  Index Scan using physical_person_id_idx on applications a  (cost=0.43..72.77 rows=6 width=0) (actual time=0.006..0.006 rows=1 loops=127558)
           Index Cond: (physical_person_id = applications.physical_person_id)
           Filter: (created < applications.created)
           Rows Removed by Filter: 0
 Planning time: 0.235 ms
 Execution time: 3261.530 ms

質問:クエリのパフォーマンスを向上させるにはどうすればよいですか? 「Is first app」でサブクエリを取り除くことは可能かもしれないと思いますが、方法がわかりません。

PostgreSQLバージョン:9.6.3

Evan Carrollからの更新後のクエリプラン:

    Subquery Scan on t  (cost=51624.73..2390836.50 rows=186782 width=52) (actual time=291.726..1129.435 rows=181 loops=1)
 ->  GroupAggregate  (cost=51624.73..2388034.77 rows=186782 width=20) (actual time=291.707..1128.057 rows=181 loops=1)
       Group Key: ((applications.created)::date)
       ->  Sort  (cost=51624.73..52091.69 rows=186782 width=29) (actual time=280.283..334.391 rows=186589 loops=1)
             Sort Key: ((applications.created)::date)
             Sort Method: external merge  Disk: 6720kB
             ->  Bitmap Heap Scan on applications  (cost=5261.90..30801.54 rows=186782 width=29) (actual time=42.944..181.325 rows=186589 loops=1)
                   Recheck Cond: (((country)::text = 'CZ'::text) AND (created >= '2017-01-01 00:00:00+01'::timestamp with time zone) AND (created <= '2017-07-01 00:00:00+02'::timestamp with time zone))
                   Heap Blocks: exact=19640
                   ->  Bitmap Index Scan on country_created  (cost=0.00..5215.20 rows=186782 width=0) (actual time=40.003..40.003 rows=186589 loops=1)
                         Index Cond: (((country)::text = 'CZ'::text) AND (created >= '2017-01-01 00:00:00+01'::timestamp with time zone) AND (created <= '2017-07-01 00:00:00+02'::timestamp with time zone))
       SubPlan 1
         ->  Index Scan using physical_person_id_idx on applications a  (cost=0.43..72.77 rows=6 width=0) (actual time=0.006..0.006 rows=1 loops=127558)
               Index Cond: (physical_person_id = applications.physical_person_id)
               Filter: (created < applications.created)
               Rows Removed by Filter: 0
Planning time: 0.232 ms
Execution time: 1145.761 ms

is_first_app列のない最初のクエリには、約300ミリ秒かかります。

Erwin Brandstetterの代替ソリューションのクエリプラン:

 GroupAggregate  (cost=51356.14..55562.83 rows=186964 width=20) (actual time=562.470..620.993 rows=181 loops=1)
   Group Key: ((a.created)::date)
   Buffers: shared hit=2137 read=4491, temp read=2491 written=2485
   ->  Sort  (cost=51356.14..51823.55 rows=186964 width=20) (actual time=562.216..592.226 rows=186589 loops=1)
         Sort Key: ((a.created)::date)
         Sort Method: external merge  Disk: 2640kB
         Buffers: shared hit=2137 read=4491, temp read=2491 written=2485
         ->  Hash Right Join  (cost=13394.71..31149.19 rows=186964 width=20) (actual time=119.488..464.407 rows=186589 loops=1)
               Hash Cond: ((p.id = a.physical_person_id) AND (p.created = a.created))
               Join Filter: (NOT a.is_preliminary)
               Buffers: shared hit=2137 read=4491, temp read=2159 written=2153
               ->  Seq Scan on persons p  (cost=0.00..9003.04 rows=364404 width=24) (actual time=3.800..73.486 rows=364404 loops=1)
                     Buffers: shared hit=868 read=4491
               ->  Hash  (cost=9311.25..9311.25 rows=186964 width=25) (actual time=115.213..115.213 rows=186589 loops=1)
                     Buckets: 65536  Batches: 4  Memory Usage: 2875kB
                     Buffers: shared hit=1269, temp written=681
                     ->  Index Only Scan using app_country_created_person_preliminary_idx on applications a  (cost=0.56..9311.25 rows=186964 width=25) (actual time=0.054..64.392 rows=186589 loops=1)
reated < '2017-07-01 00:00:00+02'::timestamp with time zone))
                           Heap Fetches: 0
                           Buffers: shared hit=1269
 Planning time: 0.401 ms
 Execution time: 628.100 ms
3
Stranger6667

いくつかのマイナーな改善:

SELECT created::date AS the_date
     , COUNT(*) AS total
     , COUNT(*) FILTER( WHERE is_preliminary = false
                        AND   NOT EXISTS (
                           SELECT 1
                           FROM   applications
                           WHERE  physical_person_id = a.physical_person_id
                           AND    created < a.created
                        -- AND    created < a.created::date  -- alternative? see below
                        -- AND    is_preliminary = false     -- omission? see below
                        -- AND    country = 'CZ'             -- not sure. see below
                           LIMIT  1
                           )
                        ) AS is_first_app
FROM   applications a
WHERE  created >= '2017-01-01'::timestamptz
AND    created <  '2017-07-01'::timestamptz
AND    country = 'CZ'
GROUP  BY created::date
ORDER  BY created::date;
  • COALESCE( count(...), 0)常に冗長ノイズですcount()はNULLそもそも。削除するだけです。関連:

  • あなたがそれを持っていた方法、あなたをグループ化し、timestamptzcreatedのテキスト表現でソートします、これは起こりますうまく動作するように。ただし、実際の日付(内部的には4バイトの整数値)でグループ化およびソートするよりもコストがかかります。実際の日付またはタイムスタンプによるソートも、この特定のクエリでは何の違いもありませんが、通常はより信頼性が高くなります。これを達成する最も簡単な方法は、これまでの単純なキャストです:created::date。必要に応じて、出力をフォーマットすることもできます:to_char(created::date, 'YYYY-MM-DD') AS date。同じ結果ですが、GROUP BY created::dateなので、グループ化された式を繰り返す必要があります。

  • 推奨されているようにBETWEENを使用しないしない>=および<を使用したフィルターの方が優れています。 BETWEEN>=および<=に変換され、timestamp(またはtimestamptz)に小数値が含まれる醜いコーナーケースが発生します。ただし、基になる列のデータ型はtimestamptzであるため、timestamptzに直接キャストします。同じ結果、キャスト演算が1つ少なくなります。

    WHERE  created >= '2017-01-01'::timestamptz
    AND    created <  '2017-07-01'::timestamptz
    
  • timestamptz値から派生したdate(およびタイムゾーンを指定せずにtimestamptzへのキャスト)は常に現在のタイムゾーン設定に依存します、そうですか?この卑劣なエラーソースを排除する場合は、選択したタイムゾーンにクエリを明示的に配置できます。基本:

  • is_first_appの計算で論理エラーが発生する可能性があります。これは私の側の推測にすぎません。同じ人のapplicationsの行が現在の行よりも古いかどうかを確認しています。ただし、現在の行にはis_preliminary = falseのみを許可しますが、比較する行に同じ述語を強制することはありません。通常、is_preliminary = falseでもある行と比較する必要があります。上記のクエリにコメント行を追加しました。

    また、グループ/日を作成するので、same日に前のエントリがある行を本当にカウントしますか?も?多分そうかもしれませんが、多分あなたは本当にdayより前の行をcreated < a.created::dateでチェックしたいでしょう。

    最後に、その国についてさらに確信が持てない場合、比較を同じ国に制限するために、述語AND country = 'CZ'を繰り返すことができます。詳細を説明するのに十分な情報がありません。

  • また、ノイズの二重引用符を削除し(いずれにしてもすべての識別子は有効です)、外側のSELECTで戦略的なテーブルエイリアス(applications a)を使用することで、構文を短くしました。

指数

読み取りパフォーマンスの最適化に関心があるので...

マルチカラムインデックスcountry_createdは、外側のSELECTに最適です。しかし、読んでください...

ただし、別のマルチカラムインデックスを使用すると、EXISTSサブクエリを簡単に改善できます。

CREATE INDEX app_person_created_idx ON applications (physical_person_id, created);

index-onlyスキャンを許可するには(書き込みパターンで許可されている場合のみ!):

CREATE INDEX app_country_created_person_preliminary_idx
ON applications (country, created, physical_person_id, is_preliminary);

追加された列physical_person_idおよびis_preliminaryは、インデックスのみのスキャンを実行する場合にのみ意味があります。

最後のインデックスを追加した後、2つのインデックスのみのスキャンを取得します。これは、大きなテーブルでは大幅に高速化されます

インデックスのみのスキャンの詳細:

代替ソリューション

最後のコメント 新しいオプションが開きます:

アプリケーションが初めて作成されるとき、同じ作成された値で新しい個人も作成されます。

(質問の最初のステートメントはあいまいすぎて処理できません。)

これが確実に実行される場合(およびcreatedがどちらのテーブルでも更新されない場合)、 "avoidにも発生する単純で高速なクエリがあります。 FILTER句のサブクエリ "-代わりにLEFT [OUTER] JOINを使用:

SELECT a.created::date AS date
     , COUNT(*)        AS total
     , COUNT(p.id)     AS is_first_app  -- count only counts non-null values
FROM   applications a
LEFT   JOIN persons p ON a.is_preliminary = false
                     AND p.id = a.physical_person_id  -- FK enforces max. 1 match
                     AND p.created = a.created
WHERE  a.created >= '2017-01-01'::timestamptz
AND    a.created <  '2017-07-01'::timestamptz
AND    a.country = 'CZ'
GROUP  BY a.created::date
ORDER  BY a.created::date;

2つのインデックスのみのスキャンで完璧な読み取りパフォーマンスを得るには、上からapp_country_created_person_preliminary_idxのインデックスを取得します。さらに、これはpersonsにあります:

CREATE INDEX pers_id_created ON persons (id, created);
5

いくつかの点について。

  1. タイプがすでに_timestamp with timezone_である場合、_::timestamp_は何もしません。
  2. あなたの範囲はBETWEENでよりきれいに書かれています。
  3. _GROUP BY 1_あなたの場合、実際には時間を文字列にキャストし、それによってグループ化します。やりたいことは単に_GROUP BY date_、そしてサーバーでそれを主張する場合は別の選択で文字列化するように日付を設定します(これはとにかく行いません)。
  4. _ORDER BY 1_あなたの場合、実際には文字列のリストを注文しています
  5. 上記のvarcharはすべてテキストである必要があります。 PostgreSQLでは、まれにvarcharを使用します。これはテキストですが、長さの制約が頻繁に使用されないために遅くなります。スキーマにテキストではない唯一のことは、2文字の国コードです。country2charと呼び、明示的にchar(2)にしますが、冗長性のためだけです。
  6. 二重引用符は本当に悪い習慣であり、常に非常に推奨されていません。

これを試してみてください

_SELECT to_char(created, 'YYYY-MM-DD') AS "Date", total AS "Total", is_first_app AS "Is First App"
FROM (
  SELECT
    created::date AS created
    COUNT(*) AS total,
    COALESCE(
      COUNT(*) FILTER(
        WHERE applications.is_preliminary = false
        AND NOT EXISTS(
          SELECT 1
          FROM applications A
          WHERE A.physical_person_id = applications.physical_person_id
            AND A.created < applications.created
          LIMIT 1
        )
      )
      , 0
    ) AS is_first_app
  FROM applications
  WHERE
    created BETWEEN '2017-01-01' AND '2017-07-01'
    AND country = 'CZ'
  GROUP BY 1
) AS t
ORDER BY created;
_

さて、サブクエリについては、データにアクセスしてデータを書き換えて書き換える必要があると思います。頭の中でできません。

詳細については、この投稿を参照してください

1
Evan Carroll

パフォーマンスが向上するかどうかはわかりませんが、2つのカウント関数を分離して、結果をマージすることができます。

SELECT "Date"
      ,"Total"
      ,"Is First App"
FROM
       (SELECT
          to_char(created, 'YYYY-MM-DD')     AS "Date",
          COUNT(*) AS "Total",
        FROM
          applications
        WHERE
          created >= '2017-01-01'::TIMESTAMP AND created < '2017-07-01'::TIMESTAMP
          AND country = 'CZ'
        GROUP BY 1
       ) ttl
       LEFT  JOIN 
       (SELECT
          to_char(created, 'YYYY-MM-DD')     AS "Date",
          COUNT(*) AS "Is First App"
        FROM
             (SELECT
                physical_person_id,
                MIN(created) as created
              FROM
                applications
              WHERE
                country = 'CZ'
                AND is_preliminary = false
              GROUP BY 1
             ) fdt -- first date
        WHERE
          created >= '2017-01-01'::TIMESTAMP AND created < '2017-07-01'::TIMESTAMP
        GROUP BY 1
       ) ifa ON (ttl."Date" = ifa."Date")
ORDER BY 1
;

fdtというラベルの付いたサブクエリは、各'CZ'の最初の申請日(countryphysical_person_id)を取得します。

ifaというラベルの付いたサブクエリはそれらの結果を取得し、目的のate範囲外のすべての行を削除し、各日付の最初のアプリケーションの数を提供します。

ttlというラベルの付いたサブクエリは、「is first app」の部分が削除された元のクエリです。 LEFT JOINこれらの結果をifa結果に変換します。

ttlifaの間でphysical_person_idを一致させる必要はありません。各カウントは独立しており、それ自体で完全なので、日付を一致させる必要があります。

もちろん、すべてのユーザーが最初のアプリを取得するのに時間がかかりすぎると、実際には元のクエリよりもパフォーマンスが低下する可能性があります。ただし、相互に関連するサブクエリを削除することで、ユーザーごとではなく、最初のアプリを1回だけ検索します。

注:コードはテストされていません。

0
RDFozz