使用 && 加入 TSTZRANGE 时,Postgresql 9.4 查询变得越来越慢

Postgresql 9.4 query gets progressively slower when joining TSTZRANGE with &&

我是 运行 一个随着记录的添加而逐渐变慢的查询。 通过自动化过程(bash 调用 psql)不断添加记录。我想纠正这个瓶颈;但是,我不知道我最好的选择是什么。

这是 pgBadger 的输出:

Hour    Count   Duration    Avg duration
00      9,990   10m3s       60ms     <---ignore this hour
02      1       60ms        60ms     <---ignore this hour
03      4,638   1m54s       24ms     <---queries begin with table empty
04      30,991  55m49s      108ms    <---first full hour of queries running
05      13,497  58m3s       258ms
06      9,904   58m32s      354ms
07      10,542  58m25s      332ms
08      8,599   58m42s      409ms
09      7,360   58m52s      479ms
10      6,661   58m57s      531ms
11      6,133   59m2s       577ms
12      5,601   59m6s       633ms
13      5,327   59m9s       666ms
14      4,964   59m12s      715ms
15      4,759   59m14s      746ms
16      4,531   59m17s      785ms
17      4,330   59m18s      821ms
18      939     13m16s      848ms

table 结构如下所示:

CREATE TABLE "Parent" (
    "ParentID" SERIAL PRIMARY KEY,
    "Details1" VARCHAR
);

Table "Parent" 与 table 具有一对多关系 "Foo":

CREATE TABLE "Foo" (
    "FooID" SERIAL PRIMARY KEY,
    "ParentID" int4 NOT NULL REFERENCES "Parent" ("ParentID"),
    "Details1" VARCHAR
);

Table "Foo" 与 table 具有一对多关系 "Bar":

CREATE TABLE "Bar" (
    "FooID" int8 NOT NULL REFERENCES "Foo" ("FooID"),
    "Timerange" tstzrange NOT NULL,
    "Detail1" VARCHAR,
    "Detail2" VARCHAR,
    CONSTRAINT "Bar_pkey" PRIMARY KEY ("FooID", "Timerange")
);
CREATE INDEX  "Bar_FooID_Timerange_idx" ON "Bar" USING gist("FooID", "Timerange");

此外,table "Bar" 可能不包含相同 "FooID""ParentID" 的重叠 "Timespan" 值。 我创建了一个触发器,它在任何 INSERTUPDATEDELETE 之后触发,以防止重叠 运行ges。

触发器包括一个部分,看起来类似于

WITH
    "cte" AS (
        SELECT
            "Foo"."FooID",
            "Foo"."ParentID",
            "Foo"."Details1",
            "Bar"."Timespan"
        FROM
            "Foo"
            JOIN "Bar" ON "Foo"."FooID" = "Bar"."FooID"
        WHERE
            "Foo"."FooID" = 1234
    )
SELECT
    "Foo"."FooID",
    "Foo"."ParentID",
    "Foo"."Details1",
    "Bar"."Timespan"
FROM
    "cte"
    JOIN "Foo" ON 
        "cte"."ParentID" = "Foo"."ParentID"
        AND "cte"."FooID" <> "Foo"."FooID"
    JOIN "Bar" ON
        "Foo"."FooID" = "Bar"."FooID"
        AND "cte"."Timespan" && "Bar"."Timespan";

来自 EXPLAIN ANALYSE 的结果:

Nested Loop  (cost=7258.08..15540.26 rows=1 width=130) (actual time=8.052..147.792 rows=1 loops=1)
  Join Filter: ((cte."FooID" <> "Foo"."FooID") AND (cte."ParentID" = "Foo"."ParentID"))
  Rows Removed by Join Filter: 76
  CTE cte
    ->  Nested Loop  (cost=0.68..7257.25 rows=1000 width=160) (actual time=1.727..1.735 rows=1 loops=1)
          ->  Function Scan on "fn_Bar"  (cost=0.25..10.25 rows=1000 width=104) (actual time=1.699..1.701 rows=1 loops=1)
          ->  Index Scan using "Foo_pkey" on "Foo" "Foo_1"  (cost=0.42..7.24 rows=1 width=64) (actual time=0.023..0.025 rows=1 loops=1)
                Index Cond: ("FooID" = "fn_Bar"."FooID")
  ->  Nested Loop  (cost=0.41..8256.00 rows=50 width=86) (actual time=1.828..147.188 rows=77 loops=1)
        ->  CTE Scan on cte  (cost=0.00..20.00 rows=1000 width=108) (actual time=1.730..1.740 rows=1 loops=1)
   **** ->  Index Scan using "Bar_FooID_Timerange_idx" on "Bar"  (cost=0.41..8.23 rows=1 width=74) (actual time=0.093..145.314 rows=77 loops=1)
              Index Cond: ((cte."Timespan" && "Timespan"))
  ->  Index Scan using "Foo_pkey" on "Foo"  (cost=0.42..0.53 rows=1 width=64) (actual time=0.004..0.005 rows=1 loops=77)
        Index Cond: ("FooID" = "Bar"."FooID")
Planning time: 1.490 ms
Execution time: 147.869 ms

(**** 强调我的)

这似乎表明 99% 的工作是在 JOIN"cte""Bar"(通过 "Foo")...但是它已经在使用适当的索引...它还是太慢了。

所以我运行:

SELECT 
    pg_size_pretty(pg_relation_size('"Bar"')) AS "Table",
    pg_size_pretty(pg_relation_size('"Bar_FooID_Timerange_idx"')) AS "Index";

结果:

    Table    |    Index
-------------|-------------
 283 MB      | 90 MB

这种大小的索引(相对于 table)在读取性能方面是否提供很多?我正在考虑一个sudo-partition,其中索引被几个部分索引替换......也许部分需要维护(和读取)并且性能会提高。我从未见过这样做,只是一个想法。如果这是一个选项,我想不出任何限制段的好方法,因为这将在 TSTZRANGE 值上。

我也认为将 "ParentID" 添加到 "Bar" 会加快速度,但我不想反规范化。

我还有什么选择?


Erwin B运行dstetter

推荐的更改的影响

在峰值性能(18:00 小时),该过程一直在每秒添加 14.5 条记录...从每秒 1.15 条记录增加。

这是以下结果:

  1. "ParentID" 添加到 table "Bar"
  2. "Foo" ("ParentID", "FooID") 添加外键约束
  3. 正在添加 EXCLUDE USING gist ("ParentID" WITH =, "Timerange" WITH &&) DEFERRABLE INITIALLY DEFERRED (btree_gist 模块已安装)

Exclusion constraint

Additionally, table "Bar" may not contain overlapping "Timespan" values for the same "FooID" or "ParentID". I have created a trigger that fires after any INSERT, UPDATE, or DELETE that prevents overlapping ranges.

我建议你改用排除约束,这样更简单、更安全、更快:

您需要先安装附加模块btree_gist。请参阅此相关答案中的说明和解释:

  • Store the day of the week and time?

并且您需要在 table "Bar" 中冗余地包含 "ParentID",这是一个很小的代价。 Table 定义可能如下所示:

CREATE TABLE "Foo" (
   "FooID"    serial PRIMARY KEY
   "ParentID" int4 NOT NULL REFERENCES "Parent"
   "Details1" varchar
   <b>CONSTRAINT foo_parent_foo_uni UNIQUE ("ParentID", "FooID")</b>  -- required for FK
);

CREATE TABLE "Bar" (
   "ParentID"  int4 NOT NULL,
   "FooID"     <b>int4</b> NOT NULL REFERENCES "Foo" ("FooID"),
   "Timerange" tstzrange NOT NULL,
   "Detail1"   varchar,
   "Detail2"   varchar,
   CONSTRAINT "Bar_pkey" PRIMARY KEY ("FooID", "Timerange"),
   <b>CONSTRAINT bar_foo_fk
      FOREIGN KEY ("ParentID", "FooID") REFERENCES "Foo" ("ParentID", "FooID"),
   CONSTRAINT bar_parent_timerange_excl
      EXCLUDE USING gist ("ParentID" WITH =, "Timerange" WITH &&)</b>
);

我还将 "Bar"."FooID" 的数据类型从 int8 更改为 int4。它引用 "Foo"."FooID",这是一个 serial,即 int4。使用匹配类型 int4(或只是 integer)有几个原因,其中之一是性能。

您不再需要触发器(至少对于此任务不需要),并且您不再创建索引 "Bar_FooID_Timerange_idx",因为它是由排除约束隐式创建的。

("ParentID", "FooID") 上的 btree 索引很可能会有用,不过:

CREATE INDEX bar_parentid_fooid_idx ON "Bar" ("ParentID", "FooID");

相关:

  • Preventing adjacent/overlapping entries with EXCLUDE in PostgreSQL

我选择了 UNIQUE ("ParentID", "FooID") 而不是相反的原因,因为在 table:

中还有一个前导 "FooID" 的索引

旁白:I never use double-quoted CaMeL-case identifiers 在 Postgres 中。我这样做只是为了符合你的布局。

避免冗余列

如果您不能或不会冗余地包含 "Bar"."ParentID",还有另一种 流氓 方式 - 条件是 "Foo"."ParentID"从未更新。确保这一点,例如使用触发器。

你可以伪造一个 IMMUTABLE 函数:

CREATE OR REPLACE FUNCTION f_parent_of_foo(int)
  RETURNS int AS
'SELECT "ParentID" FROM public."Foo" WHERE "FooID" = '
  LANGUAGE sql IMMUTABLE;

我假设 public 对 table 名称进行模式限定以确保确定。适应您的架构。

更多:

  • CONSTRAINT to check values from a remotely related table (via join etc.)
  • Does PostgreSQL support "accent insensitive" collations?

然后在排除约束中使用:

   CONSTRAINT bar_parent_timerange_excl
      EXCLUDE USING gist (<b>f_parent_of_foo("FooID")</b> WITH =, "Timerange" WITH &&)

虽然节省了一个冗余的 int4 列,但约束的验证成本更高,整个解决方案取决于更多的前提条件。

处理冲突

您可以将 INSERTUPDATE 包装到一个 plpgsql 函数中,并从排除约束 (23P01 exclusion_violation) 中捕获可能的异常以某种方式处理它。

INSERT ...

EXCEPTION
    WHEN exclusion_violation
    THEN  -- handle conflict

完整代码示例:

  • Handling EXCEPTION and return result from function

处理 Postgres 9.5 中的冲突

在 Postgres 9.5 中,您可以使用新的 "UPSERT" 实现直接处理 INSERTThe documentation:

The optional ON CONFLICT clause specifies an alternative action to raising a unique violation or exclusion constraint violation error. For each individual row proposed for insertion, either the insertion proceeds, or, if an arbiter constraint or index specified by conflict_target is violated, the alternative conflict_action is taken. ON CONFLICT DO NOTHING simply avoids inserting a row as its alternative action. ON CONFLICT DO UPDATE updates the existing row that conflicts with the row proposed for insertion as its alternative action.

但是:

Note that exclusion constraints are not supported with ON CONFLICT DO UPDATE.

但您仍然可以使用 ON CONFLICT DO NOTHING,从而避免可能的 exclusion_violation 异常。只需检查是否实际更新了任何行,这更便宜:

INSERT ... 
ON CONFLICT ON CONSTRAINT bar_parent_timerange_excl DO NOTHING;

IF NOT FOUND THEN
   -- handle conflict
END IF;

此示例将检查限制为给定的排除约束。 (我在上面的 table 定义中为此明确命名了约束。)未捕获其他可能的异常。