【问题标题】:PostgreSQL: How to structure and index time-related data for optimal query performance?PostgreSQL:如何构造和索引与时间相关的数据以获得最佳查询性能?
【发布时间】:2012-09-14 16:27:48
【问题描述】:

问题:

我的数据库中有与时间相关的数据,我正在努力以某种方式组织、构造和索引这些数据,以便用户可以有效地检索它;即使是简单的数据库查询也需要更长的时间。

项目背景:

虽然这是一个纯粹的数据库问题,但某些上下文可能有助于理解数据模型:

该项目的中心是对大型复杂机器进行研究。我对机器本身了解不多,但实验室里有传言说在某处有一个flux capacitor - 我想昨天,我发现Schrödinger's cat 的尾巴挂在它旁边;- )

我们在机器运行时使用传感器测量许多不同的参数的时间。我们不仅使用一个设备来测量这些参数,而且还使用整个范围的参数;它们的测量数据质量不同(我认为这涉及采样率、传感器质量、价格和我不关心的许多其他方面);该项目的一个目标实际上是在这些设备之间进行比较。您可以将这些测量设备想象成一堆实验室手推车,每个手推车都有许多连接到机器的电缆,每个都提供测量数据。

数据模型:

每个参数都有来自每个地点和每个设备的测量数据,例如,每分钟一次,为期 6 天。我的工作是将这些数据存储在数据库中并提供对它的有效访问。

简而言之:

  • 设备具有唯一的名称
  • 参数也有名称;它们不是唯一的,所以它也有一个 ID
  • 一个地点有一个 ID

项目数据库当然更复杂,但这些细节似乎与问题无关。

  • 测量数据索引具有 ID、测量完成时间的时间戳以及对设备的引用以及进行测量的地点
  • 测量数据具有对参数和实际测量值的引用

最初,我将测量数据值建模为具有自己的 ID 作为主键;测量数据索引和值之间的n:m 关系是一个单独的表,只存储了index:value ID 对,但是由于该表本身占用了相当多的硬盘空间,我们将其删除并将值 ID 更改为一个简单的整数存储其所属的测量数据索引的ID;测量数据值的主键现在由该 ID 和参数 ID 组成。

附带说明:当我创建数据模型时,我仔细遵循了常见的设计准则,例如 3NF 和适当的表约束(例如唯一键);另一个经验法则是为每个外键创建一个索引。我怀疑测量数据索引/值表与“严格”3NF 的偏差可能是我现在正在查看的性能问题的原因之一,但是将数据模型改回来并没有解决问题。

DDL 中的数据模型:

注意:下面有此代码的更新。

下面的脚本创建数据库和所有相关的表。请注意,目前还没有明确的索引。在运行此程序之前,请确保您没有碰巧拥有一个名为 so_test 的数据库,其中包含任何有价值的数据...

\c postgres
DROP DATABASE IF EXISTS so_test;
CREATE DATABASE so_test;
\c so_test

CREATE TABLE device
(
  name VARCHAR(16) NOT NULL,
  CONSTRAINT device_pk PRIMARY KEY (name)
);

CREATE TABLE parameter
(
  -- must have ID as names are not unique
  id SERIAL,
  name VARCHAR(64) NOT NULL,
  CONSTRAINT parameter_pk PRIMARY KEY (id)
);

CREATE TABLE spot
(
  id SERIAL,
  CONSTRAINT spot_pk PRIMARY KEY (id)
);

CREATE TABLE measurement_data_index
(
  id SERIAL,
  fk_device_name VARCHAR(16) NOT NULL,
  fk_spot_id INTEGER NOT NULL,
  t_stamp TIMESTAMP NOT NULL,
  CONSTRAINT measurement_pk PRIMARY KEY (id),
  CONSTRAINT measurement_data_index_fk_2_device FOREIGN KEY (fk_device_name)
    REFERENCES device (name) MATCH FULL
    ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT measurement_data_index_fk_2_spot FOREIGN KEY (fk_spot_id)
    REFERENCES spot (id) MATCH FULL
    ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT measurement_data_index_uk_all_cols UNIQUE (fk_device_name, fk_spot_id, t_stamp)
);

CREATE TABLE measurement_data_value
(
  id INTEGER NOT NULL,
  fk_parameter_id INTEGER NOT NULL,
  value VARCHAR(16) NOT NULL,
  CONSTRAINT measurement_data_value_pk PRIMARY KEY (id, fk_parameter_id),
  CONSTRAINT measurement_data_value_fk_2_parameter FOREIGN KEY (fk_parameter_id)
    REFERENCES parameter (id) MATCH FULL
    ON UPDATE NO ACTION ON DELETE NO ACTION
);

我还创建了一个脚本来用一些测试数据填充表格:

CREATE OR REPLACE FUNCTION insert_data()
RETURNS VOID
LANGUAGE plpgsql
AS
$BODY$
  DECLARE
    t_stamp  TIMESTAMP := '2012-01-01 00:00:00';
    index_id INTEGER;
    param_id INTEGER;
    dev_name VARCHAR(16);
    value    VARCHAR(16);
  BEGIN
    FOR dev IN 1..5
    LOOP
      INSERT INTO device (name) VALUES ('dev_' || to_char(dev, 'FM00'));
    END LOOP;
    FOR param IN 1..20
    LOOP
      INSERT INTO parameter (name) VALUES ('param_' || to_char(param, 'FM00'));
    END LOOP;
    FOR spot IN 1..10
    LOOP
      INSERT INTO spot (id) VALUES (spot);
    END LOOP;

    WHILE t_stamp < '2012-01-07 00:00:00'
    LOOP
      FOR dev IN 1..5
      LOOP
        dev_name := 'dev_' || to_char(dev, 'FM00');
        FOR spot IN 1..10
        LOOP
          INSERT INTO measurement_data_index
            (fk_device_name, fk_spot_id, t_stamp)
            VALUES (dev_name, spot, t_stamp) RETURNING id INTO index_id;
          FOR param IN 1..20
          LOOP
            SELECT id INTO param_id FROM parameter
              WHERE name = 'param_' || to_char(param, 'FM00');
            value := 'd'  || to_char(dev,   'FM00')
                  || '_s' || to_char(spot,  'FM00')
                  || '_p' || to_char(param, 'FM00');
            INSERT INTO measurement_data_value (id, fk_parameter_id, value)
              VALUES (index_id, param_id, value);
          END LOOP;
        END LOOP;
      END LOOP;
      t_stamp := t_stamp + '1 minute'::INTERVAL;
    END LOOP;

  END;
$BODY$;

SELECT insert_data();

PostgreSQL 查询计划器需要最新的统计信息,因此请分析所有表。可能不需要吸尘,但无论如何都要这样做:

VACUUM ANALYZE device;
VACUUM ANALYZE measurement_data_index;
VACUUM ANALYZE measurement_data_value;
VACUUM ANALYZE parameter;
VACUUM ANALYZE spot;

示例查询:

如果我现在运行一个非常简单的查询,例如获取某个参数的所有值,已经花费了几秒钟,虽然数据库还不是很大:

EXPLAIN (ANALYZE ON, BUFFERS ON)
SELECT measurement_data_value.value
  FROM measurement_data_value, parameter
 WHERE measurement_data_value.fk_parameter_id = parameter.id
   AND parameter.name = 'param_01';

我的开发机器上的示例结果(有关我的环境的一些详细信息,请参见下文):

                                                                QUERY PLAN                                                                
------------------------------------------------------------------------------------------------------------------------------------------
 Hash Join  (cost=1.26..178153.26 rows=432000 width=12) (actual time=0.046..2281.281 rows=432000 loops=1)
   Hash Cond: (measurement_data_value.fk_parameter_id = parameter.id)
   Buffers: shared hit=55035
   ->  Seq Scan on measurement_data_value  (cost=0.00..141432.00 rows=8640000 width=16) (actual time=0.004..963.999 rows=8640000 loops=1)
         Buffers: shared hit=55032
   ->  Hash  (cost=1.25..1.25 rows=1 width=4) (actual time=0.010..0.010 rows=1 loops=1)
         Buckets: 1024  Batches: 1  Memory Usage: 1kB
         Buffers: shared hit=1
         ->  Seq Scan on parameter  (cost=0.00..1.25 rows=1 width=4) (actual time=0.004..0.008 rows=1 loops=1)
               Filter: ((name)::text = 'param_01'::text)
               Buffers: shared hit=1
 Total runtime: 2313.615 ms
(12 rows)

除了隐式索引之外,数据库中没有索引,因此规划器只进行顺序扫描也就不足为奇了。如果我遵循似乎是经验法则并为每个外键添加 btree 索引,例如

CREATE INDEX measurement_data_index_idx_fk_device_name
    ON measurement_data_index (fk_device_name);
CREATE INDEX measurement_data_index_idx_fk_spot_id
    ON measurement_data_index (fk_spot_id);
CREATE INDEX measurement_data_value_idx_fk_parameter_id
    ON measurement_data_value (fk_parameter_id);

然后再做一次真空分析(为了安全起见)并重新运行查询,规划器使用位图堆和位图索引扫描,总查询时间有所改善:

                                                                                   QUERY PLAN                                                                                   
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Nested Loop  (cost=8089.19..72842.42 rows=431999 width=12) (actual time=66.773..1336.517 rows=432000 loops=1)
   Buffers: shared hit=55033 read=1184
   ->  Seq Scan on parameter  (cost=0.00..1.25 rows=1 width=4) (actual time=0.005..0.012 rows=1 loops=1)
         Filter: ((name)::text = 'param_01'::text)
         Buffers: shared hit=1
   ->  Bitmap Heap Scan on measurement_data_value  (cost=8089.19..67441.18 rows=431999 width=16) (actual time=66.762..1237.488 rows=432000 loops=1)
         Recheck Cond: (fk_parameter_id = parameter.id)
         Buffers: shared hit=55032 read=1184
         ->  Bitmap Index Scan on measurement_data_value_idx_fk_parameter_id  (cost=0.00..7981.19 rows=431999 width=0) (actual time=65.222..65.222 rows=432000 loops=1)
               Index Cond: (fk_parameter_id = parameter.id)
               Buffers: shared read=1184
 Total runtime: 1371.716 ms
(12 rows)

但是,对于一个非常简单的查询,这仍然需要一秒以上的执行时间。

到目前为止我做了什么:

  • 给自己买了一份PostgreSQL 9.0 High Performance - 好书!
  • 做了一些基本的 PostgreSQL 服务器配置,参见下面的环境
  • 创建了一个框架来使用来自项目的真实查询运行一系列性能测试并以图形方式显示结果;这些查询使用设备、点、参数和时间间隔作为输入参数,并且测试系列运行在例如5、10 个设备、5、10 个点、5、10、15、20 个参数和 1..7 天。基本结果是它们都太慢了,但它们的查询计划太复杂了,我无法理解,所以我回到上面使用的非常简单的查询。

我查看了partitioning 的值表。数据与时间相关,分区似乎是组织此类数据的合适方法;甚至 PostgreSQL 文档中的 examples 也使用了类似的东西。不过,我在same article读到:

这些好处通常只有在表格非常大的情况下才值得。表从分区中受益的确切时间取决于应用程序,但经验法则是表的大小应该超过数据库服务器的物理内存。

整个测试数据库的大小小于 1GB,我在具有 8GB RAM 的开发机器和具有 1GB 的虚拟机上运行测试(另请参见下面的环境),因此该表远非很大甚至超过物理内存。无论如何,我可能会在某个阶段实现分区,但我觉得这种方法并不针对性能问题本身。

此外,我正在考虑cluster 值表。我不喜欢每次插入新数据时都必须重新进行集群并且它还需要独占读/写锁这一事实,但看看this SO 问题,它似乎无论如何都有它的好处并且可能是一个选项.但是,集群是在索引上完成的,由于查询中最多有 4 个选择条件(设备、点、参数和时间),我必须为所有这些条件创建集群——这反过来给我的印象是我只是没有创建正确的索引...

我的环境:

  • 正在开发具有双核 CPU 和 8GB RAM 的 MacBook Pro(2009 年中)
  • 我正在 MBP 上托管的具有 1GB RAM 的虚拟 Debian 6.0 机器上运行数据库性能测试
  • PostgreSQL 版本是 9.1,因为这是我安装时的最新版本,可以升级到 9.2
  • 按照PostgreSQL docs 中的建议,我已将两台机器上的shared_buffers 从默认的1600kB 更改为25% 的RAM(这涉及扩大kernel settings,如SHMALL、SHMMAX 等)
  • 同样,我已将 effective_cache_size 从默认的 128MB 更改为可用 RAM 的 50%
  • 我使用不同的work_mem 设置进行了性能测试,但没有发现任何主要的性能差异

注意:我认为重要的一个方面是,具有来自项目的真实查询的性能测试系列在 8GB 的​​ MacBook 和 1GB 的虚拟机之间在性能方面没有差异;也就是说,如果在 MacBook 上查询需要 10 秒,那么在 VM 上也需要 10 秒。另外,我在更改shared_bufferseffective_cache_sizework_mem 前后进行了相同的性能测试,但配置更改并没有将性能提高10% 以上;实际上,有些结果甚至变得更糟,因此似乎任何差异都是由测试变化而不是由配置更改引起的。这些观察让我相信 RAM 和 postgres.conf 设置还不是这里的限制因素。

我的问题:

我不知道不同的或额外的索引是否会加快查询速度,如果可以,我不知道要创建哪些索引。查看数据库的大小和查询的简单程度,我觉得我的数据模型或到目前为止我选择索引的方式存在根本性错误。

有人对我如何构建和索引时间相关的 my 以提高查询性能有什么建议吗?

问得更广泛,是调优查询性能

  • 通常是“在事件基础上”完成的,即一旦查询不能令人满意地执行?看来所有我的查询都太慢了...
  • 主要是查看(和理解)查询计划的问题,然后添加索引并衡量情况是否有所改善,可能会通过应用自己的经验来加速流程?

如何让这个数据库运行起来?


更新 01:

看看到目前为止的回复,我想我没有正确解释测量数据索引/值表的必要性,所以让我再试一次。 存储空间是这里的问题。

注意:

  • 此处使用的数字更多地用于说明目的,仅用于比较,即数字本身并不相关,重要的是使用单个表与使用索引和值表之间存储要求的百分比差异
  • PostgreSQL 数据类型存储大小记录在this 章节中
  • 这并没有声称在科学上是正确的,例如这些单位可能在数学上是伪造的;这些数字应该加起来

假设

  • 1 天测量
  • 每分钟 1 组测量值
  • 10 台设备
  • 10个参数
  • 10 点

这加起来

1 测量/分钟 x 60 分钟/小时 x 24 小时/天 = 1440 测量/天

每次测量都有来自每个地点和每个设备的每个参数的数据,所以

10 个点 x 10 个设备 x 10 个参数 = 1000 个数据集/测量

总之

1440 个测量值/天 x 1000 个数据集/测量值 = 1 440 000 个数据集/天

如果我们将所有测量值存储在单个表中为Catcall suggested,例如

CREATE TABLE measurement_data
(
  device_name character varying(16) NOT NULL,
  spot_id integer NOT NULL,
  parameter_id integer NOT NULL,
  t_stamp timestamp without time zone NOT NULL,
  value character varying(16) NOT NULL,
  -- constraints...
);

一行会加起来

17 + 4 + 4 + 8 + 17 = 50 字节/行

在最坏的情况下,所有 varchar 字段都已完全填充。这相当于

50 字节/行 x 1 440 000 行/天 = 72 000 000 字节/天

或每天约 69 MB。

虽然这听起来不是很多,但实际数据库中的存储空间要求会令人望而却步(再次重申,此处使用的数字仅用于说明)。因此,我们将测量数据拆分为索引和值表,如问题前面所述:

CREATE TABLE measurement_data_index
(
  id SERIAL,
  fk_device_name VARCHAR(16) NOT NULL,
  fk_spot_id INTEGER NOT NULL,
  t_stamp TIMESTAMP NOT NULL,
  -- constraints...
);

CREATE TABLE measurement_data_value
(
  id INTEGER NOT NULL,
  fk_parameter_id INTEGER NOT NULL,
  value VARCHAR(16) NOT NULL,
  -- constraints...
);

其中值行的 ID 等于其所属索引的 ID

索引表和值表中一行的大小是

索引:4 + 17 + 4 + 8 = 33 字节 值:4 + 4 + 17 = 25 个字节

(同样,最坏的情况)。总行数为

索引:10 个设备 x 10 个点 x 1440 次测量/天 = 144 000 行/天 值:10 个参数 x 144 000 行/天 = 1 440 000 行/天

所以总数是

索引:33 字节/行 x 144 000 行/天 = 4 752 000 字节/天 值:25 字节/行 x 1 440 000 行/天 = 36 000 000 字节/天 总计:= 40 752 000 字节/天

或每天约 39 MB - 而单表解决方案约 69 MB。


更新 02(回复:wildplassers response):

这个问题已经变得很长了,所以我正在考虑更新上面原始问题中的代码,但我认为这里有第一个和改进的解决方案可能有助于更好地看到差异.

与原始方法相比的变化(按重要性排序):

  • 交换时间戳和参数,即将t_stamp字段从measurement_data_index表移动到measurement_data_value,将fk_parameter_id字段从值移动到索引表:通过这个改变,索引表中的所有字段都是不变的并且是新的度量数据仅写入值表。我没想到这会带来任何重大的查询性能改进(我错了),但我觉得它使测量数据索引的概念更加清晰。虽然它需要更多的存储空间(根据一些相当粗略的估计),但当 tablespaces 根据其读/写要求移动到不同的硬盘驱动器时,拥有一个“静态”索引表也可能有助于部署。
  • 在设备表中使用代理键:据我了解,代理键是从数据库设计的角度来看并非严格要求的主键(例如设备名称已经是唯一的,因此它也可以用作 PK ),但可能有助于提高查询性能。我再次添加它是因为,如果索引表仅引用 ID(而不是某些名称和某些 ID),我觉得它会使概念更清晰。
  • 重写insert_data():使用generate_series()而不是嵌套的FOR循环;使代码更加“简洁”。
  • 作为这些更改的副作用,插入测试数据只需要第一个解决方案所需时间的大约 50%。
  • 我没有按照 wildplasser 的建议添加视图;不需要向后兼容。
  • 查询计划器似乎忽略了索引表中 FK 的其他索引,并且对查询计划或性能没有影响。

(好像没有这行,下面的代码没有正确显示为SO页面上的代码……)

\c postgres
DROP DATABASE IF EXISTS so_test_03;
CREATE DATABASE so_test_03;
\c so_test_03

CREATE TABLE device
(
  id SERIAL,
  name VARCHAR(16) NOT NULL,
  CONSTRAINT device_pk PRIMARY KEY (id),
  CONSTRAINT device_uk_name UNIQUE (name)
);

CREATE TABLE parameter
(
  id SERIAL,
  name VARCHAR(64) NOT NULL,
  CONSTRAINT parameter_pk PRIMARY KEY (id)
);

CREATE TABLE spot
(
  id SERIAL,
  name VARCHAR(16) NOT NULL,
  CONSTRAINT spot_pk PRIMARY KEY (id)
);

CREATE TABLE measurement_data_index
(
  id SERIAL,
  fk_device_id    INTEGER NOT NULL,
  fk_parameter_id INTEGER NOT NULL,
  fk_spot_id      INTEGER NOT NULL,
  CONSTRAINT measurement_pk PRIMARY KEY (id),
  CONSTRAINT measurement_data_index_fk_2_device FOREIGN KEY (fk_device_id)
    REFERENCES device (id) MATCH FULL
    ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT measurement_data_index_fk_2_parameter FOREIGN KEY (fk_parameter_id)
    REFERENCES parameter (id) MATCH FULL
    ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT measurement_data_index_fk_2_spot FOREIGN KEY (fk_spot_id)
    REFERENCES spot (id) MATCH FULL
    ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT measurement_data_index_uk_all_cols UNIQUE (fk_device_id, fk_parameter_id, fk_spot_id)
);

CREATE TABLE measurement_data_value
(
  id INTEGER NOT NULL,
  t_stamp TIMESTAMP NOT NULL,
  value VARCHAR(16) NOT NULL,
  -- NOTE: inverse field order compared to wildplassers version
  CONSTRAINT measurement_data_value_pk PRIMARY KEY (id, t_stamp),
  CONSTRAINT measurement_data_value_fk_2_index FOREIGN KEY (id)
    REFERENCES measurement_data_index (id) MATCH FULL
    ON UPDATE NO ACTION ON DELETE NO ACTION
);

CREATE OR REPLACE FUNCTION insert_data()
RETURNS VOID
LANGUAGE plpgsql
AS
$BODY$
  BEGIN
    INSERT INTO device (name)
    SELECT 'dev_' || to_char(item, 'FM00')
    FROM generate_series(1, 5) item;

    INSERT INTO parameter (name)
    SELECT 'param_' || to_char(item, 'FM00')
    FROM generate_series(1, 20) item;

    INSERT INTO spot (name)
    SELECT 'spot_' || to_char(item, 'FM00')
    FROM generate_series(1, 10) item;

    INSERT INTO measurement_data_index (fk_device_id, fk_parameter_id, fk_spot_id)
    SELECT device.id, parameter.id, spot.id
    FROM device, parameter, spot;

    INSERT INTO measurement_data_value(id, t_stamp, value)
    SELECT index.id,
           item,
           'd'  || to_char(index.fk_device_id,    'FM00') ||
           '_s' || to_char(index.fk_spot_id,      'FM00') ||
           '_p' || to_char(index.fk_parameter_id, 'FM00')
    FROM measurement_data_index index,
         generate_series('2012-01-01 00:00:00', '2012-01-06 23:59:59', interval '1 min') item;
  END;
$BODY$;

SELECT insert_data();

在某个阶段,我会将自己的约定更改为使用内联 PRIMARY KEYREFERENCES 语句,而不是显式的 CONSTRAINTs;目前,我认为保持这种方式更容易比较两种解决方案。

不要忘记更新查询计划器的统计信息:

VACUUM ANALYZE device;
VACUUM ANALYZE measurement_data_index;
VACUUM ANALYZE measurement_data_value;
VACUUM ANALYZE parameter;
VACUUM ANALYZE spot;

运行一个应该产生与第一种方法相同的结果的查询:

EXPLAIN (ANALYZE ON, BUFFERS ON)
SELECT measurement_data_value.value
  FROM measurement_data_index,
       measurement_data_value,
       parameter
 WHERE measurement_data_index.fk_parameter_id = parameter.id
   AND measurement_data_index.id = measurement_data_value.id
   AND parameter.name = 'param_01';

结果:

Nested Loop  (cost=0.00..34218.28 rows=431998 width=12) (actual time=0.026..696.349 rows=432000 loops=1)
  Buffers: shared hit=435332
  ->  Nested Loop  (cost=0.00..29.75 rows=50 width=4) (actual time=0.012..0.453 rows=50 loops=1)
        Join Filter: (measurement_data_index.fk_parameter_id = parameter.id)
        Buffers: shared hit=7
        ->  Seq Scan on parameter  (cost=0.00..1.25 rows=1 width=4) (actual time=0.005..0.010 rows=1 loops=1)
              Filter: ((name)::text = 'param_01'::text)
              Buffers: shared hit=1
        ->  Seq Scan on measurement_data_index  (cost=0.00..16.00 rows=1000 width=8) (actual time=0.003..0.187 rows=1000 loops=1)
              Buffers: shared hit=6
  ->  Index Scan using measurement_data_value_pk on measurement_data_value  (cost=0.00..575.77 rows=8640 width=16) (actual time=0.013..12.157 rows=8640 loops=50)
        Index Cond: (id = measurement_data_index.id)
        Buffers: shared hit=435325
Total runtime: 726.125 ms

这几乎是第一种方法所需的约 1.3 秒的一半;考虑到我正在加载 432K 行,这是我目前可以接受的结果。

注意:值表PK中的字段顺序为id, t_stamp; wildplassers 响应中的顺序是t_stamp, whw_id。我这样做是因为我觉得“常规”字段顺序是在表声明中列出字段的顺序(而“反向”则是另一种方式),但这只是我自己的约定,让我无法获得使困惑。无论哪种方式,正如Erwin Brandstetter 指出的那样,这个顺序对于性能提升绝对是关键;如果方法错误(并且缺少 wildplassers 解决方案中的反向索引),查询计划如下所示,性能会差 3 倍以上:

Hash Join  (cost=22.14..186671.54 rows=431998 width=12) (actual time=0.460..2570.941 rows=432000 loops=1)
  Hash Cond: (measurement_data_value.id = measurement_data_index.id)
  Buffers: shared hit=63537
  ->  Seq Scan on measurement_data_value  (cost=0.00..149929.58 rows=8639958 width=16) (actual time=0.004..1095.606 rows=8640000 loops=1)
        Buffers: shared hit=63530
  ->  Hash  (cost=21.51..21.51 rows=50 width=4) (actual time=0.446..0.446 rows=50 loops=1)
        Buckets: 1024  Batches: 1  Memory Usage: 2kB
        Buffers: shared hit=7
        ->  Hash Join  (cost=1.26..21.51 rows=50 width=4) (actual time=0.015..0.359 rows=50 loops=1)
              Hash Cond: (measurement_data_index.fk_parameter_id = parameter.id)
              Buffers: shared hit=7
              ->  Seq Scan on measurement_data_index  (cost=0.00..16.00 rows=1000 width=8) (actual time=0.002..0.135 rows=1000 loops=1)
                    Buffers: shared hit=6
              ->  Hash  (cost=1.25..1.25 rows=1 width=4) (actual time=0.008..0.008 rows=1 loops=1)
                    Buckets: 1024  Batches: 1  Memory Usage: 1kB
                    Buffers: shared hit=1
                    ->  Seq Scan on parameter  (cost=0.00..1.25 rows=1 width=4) (actual time=0.004..0.007 rows=1 loops=1)
                          Filter: ((name)::text = 'param_01'::text)
                          Buffers: shared hit=1
Total runtime: 2605.277 ms

【问题讨论】:

  • 三件事不是你的问题,但我不明白:(1)如果parameter.id的原因是parameter.name不是唯一的,那你怎么会有@987654377 @? (2)measurement_data_indexmeasurement_data_value之间有什么关系(如果有的话)? (3) 隐式/逗号/非 ANSI 连接是什么? :-P
  • EXPLAIN (ANALYZE ON, BUFFERS ON) SELECT measurement_data_value.value FROM measurement_data_value JOIN parameter ON measurement_data_value.fk_parameter_id = parameter.id WHERE parameter.name = 'param_01'; 是否给出相同的解释结果?对我来说,循环内参数的顺序扫描看起来有点奇怪。
  • (1) 有效点。当我将原始数据模型简化为此处显示的形式时,这是一个遗漏。我已经更新了问题并删除了唯一约束。 (2) 如问题所述,一个 measure_data_index 行与许多 measure_data_value 行具有相同的 ID (3) ANSI 一致性在项目中并不重要,根据 postgres 文档 (postgresql.org/docs/9.1/static/tutorial-join.html),“逗号”形式 I' m using 等效于显式连接并且“不常用”。对我来说,它更容易理解。
  • @Joachim:相同的结果,相同的性能(在测试变体中)。顺便说一句,作为另一个 SO 问题 (stackoverflow.com/a/11523894/217844) 的一部分,我已经开始对 JOIN 的“逗号”/隐式和“ANSI”/显式版本进行基准测试,到目前为止还没有发现任何显着差异
  • 1) 我认为不需要单独的measurement_data_index 和measurement_data_value 表。对我来说,测量项目的 natural 键是 {spot,device,timestamp},直接导致 -> 值。因为你有一个有效的自然键,2)我也会省略序列号(任何其他表都没有引用)3)我会为复合自然 PK 制作measurement_item.device_id integer NOT NULL references device(id) 4),额外的索引(以不同的顺序)可能是必要的,具体取决于您的特定查询需求。

标签: sql performance postgresql database-design indexing


【解决方案1】:

我基本上修改了你的整个设置。在 PostgreSQL 9.1.5 下测试。

数据库架构

  • 我认为您的表格布局存在重大逻辑缺陷(@Catcall 也指出了这一点)。我改变了它,我怀疑它应该是这样的:
    您的最后一张表measurement_data_value(我将其重命名为measure_val)应该为measurement_data_index(现在:measure)中的每一行每个parameter(现在:param)保存一个值。见下文。

  • 即使“设备具有唯一名称”,仍然使用整数代理主键。文本字符串在大表中用作外键本质上更庞大且更慢。它们还受制于collat​​ion,这会显着降低查询速度。

    Under this related question 我们发现在中等大小的text 列上连接和排序是主要的减速。 如果您坚持使用文本字符串作为主键,请阅读 PostgreSQL 9.1 或更高版本中的 collation support

  • 不要相信使用id 作为主键名称的反模式。当您加入几张桌子时(就像您必须做很多事情一样!)您最终会得到几个列名称id - 真是一团糟! (遗憾的是,一些 ORM 使用它。)

    取而代之的是,以某种方式在表之后命名一个代理主键列,以使其本身有意义。然后你可以让引用它的外键具有相同的名称(这很好,因为它们包含相同的数据)。

    CREATE TABLE spot
    ( spot_id SERIAL PRIMARY KEY);
  • 不要使用超长标识符。它们很难打字,也很难阅读。经验法则:要清楚,越短越好。

  • 如果您没有令人信服的理由,请勿使用 varchar(n)。只需使用varchar, or simpler: just text

所有这些以及更多内容都包含在我关于更好的数据库架构的建议中:

CREATE TABLE device
( device_id serial PRIMARY KEY 
 ,device text NOT NULL
);

CREATE TABLE param
( param_id serial PRIMARY KEY
 ,param text NOT NULL
);
CREATE INDEX param_param_idx ON param (param); -- you are looking up by name!

CREATE TABLE spot
( spot_id  serial PRIMARY KEY);

CREATE TABLE measure
( measure_id serial PRIMARY KEY
 ,device_id int NOT NULL REFERENCES device (device_id) ON UPDATE CASCADE
 ,spot_id int NOT NULL REFERENCES spot (spot_id) ON UPDATE CASCADE
 ,t_stamp timestamp NOT NULL
 ,CONSTRAINT measure_uni UNIQUE (device_id, spot_id, t_stamp)
);

CREATE TABLE measure_val   -- better name? 
( measure_id int NOT NULL REFERENCES measure (measure_id)
                 ON UPDATE CASCADE ON DELETE CASCADE  -- guessing it fits
 ,param_id int NOT NULL REFERENCES param (param_id)
                 ON UPDATE CASCADE ON DELETE CASCADE  -- guessing it fits
 ,value text NOT NULL
 ,CONSTRAINT measure_val_pk PRIMARY KEY (measure_id, param_id)
);
CREATE INDEX measure_val_param_id_idx ON measure_val (param_id);  -- !crucial!

我将笨重的 measurement_data_value 重命名为 measure_val,因为这就是表格中的内容:测量的参数值。现在,多列 pk 也很有意义。

但我在param_id 上添加了一个单独的索引。按照您的方式,param_id 列是多列索引中的第二列,这导致param_id 的结果不佳。阅读所有gory details about that under this related question on dba.SE

单独实施后,您的查询应该会更快。但你可以做的还有更多。

测试数据

这会更快地填充数据。关键是我使用基于集合的 DML 命令,执行批量插入而不是执行单个插入的循环,这需要很长时间。对您要插入的大量测试数据产生很大影响。它也更短更简单。

为了提高效率,我使用了data-modifying CTE(Postgres 9.1 中的新功能),它可以立即重用最后一步中的大量行。

CREATE OR REPLACE FUNCTION insert_data()
RETURNS void LANGUAGE plpgsql AS
$BODY$
BEGIN
   INSERT INTO device (device)
   SELECT 'dev_' || to_char(g, 'FM00')
   FROM generate_series(1,5) g;

   INSERT INTO param (param)
   SELECT 'param_' || to_char(g, 'FM00')
   FROM generate_series(1,20) g;

   INSERT INTO spot (spot_id)
   SELECT nextval('spot_spot_id_seq'::regclass)
   FROM generate_series(1,10) g; -- to set sequence, too

   WITH x AS (
      INSERT INTO measure (device_id, spot_id, t_stamp)
      SELECT d.device_id, s.spot_id, g
      FROM   device    d
      CROSS  JOIN spot s
      CROSS  JOIN generate_series('2012-01-06 23:00:00' -- smaller set
                                 ,'2012-01-07 00:00:00' -- for quick tests
                                 ,interval '1 min') g
      RETURNING *
      )
   INSERT INTO measure_val (measure_id, param_id, value)
   SELECT x.measure_id
         ,p.param_id
         ,x.device_id || '_' || x.spot_id || '_' || p.param
   FROM  x
   CROSS JOIN param p;
END
$BODY$;

呼叫:

SELECT insert_data();

查询

  • 使用显式 JOIN 语法和表别名,使您的查询更易于阅读和调试:
SELECT v.value
FROM   param p
JOIN   measure_val v USING (param_id)
WHERE  p.param = 'param_01';

USING 子句仅用于简化语法,但不优于 ON

现在应该快得多,原因有两个:

  • param.param 上索引param_param_idx
  • measure_val.param_id 上索引measure_val_param_id_idx,如详细解释here

反馈后编辑

我的主要疏忽是您已经在问题的下方添加了measurement_data_value_idx_fk_parameter_id 形式的关键索引。 (我责怪你的名字太神秘了!:p)仔细观察,你的测试设置中有超过 10M (7 * 24 * 60 * 5 * 10 * 20) 行,并且查询检索到 > 500K。我只测试了一个小得多的子集。

此外,当您检索整个表的 5% 时,索引只会到此为止。我当时是乐观的,这么大的数据量肯定需要一些时间。查询 500k 行是否符合实际要求?我会假设您在现实生活中的应用程序中进行聚合?

更多选项

  • Partitioning
  • 更多 RAM 和使用它的设置。

    具有 1GB 内存的虚拟 Debian 6.0 机器

    远低于您的需求。

  • Partial indexes,尤其是与 PostgreSQL 9.2 的仅索引扫描相关。

  • Materialized views 的聚合数据。显然,您不会显示 500K 行,而是某种聚合。您可以计算一次并将结果保存在物化视图中,从而可以更快地检索数据。
  • 如果您的查询主要是通过参数(如示例),您可以使用CLUSTER 根据索引物理重写表:

    CLUSTER measure_val USING measure_val_param_id_idx
    

    这样,一个参数的所有行都会连续存储。意味着更少的块读取和更容易缓存。应该使手头的查询更快。或者 INSERT 以有利的顺序开始的行,达到同样的效果。
    分区可以很好地与CLUSTER 混合使用,因为您不必每次都重写整个(巨大的)表。由于您的数据显然只是插入并没有更新,因此CLUSTER 之后的分区将保持“有序”。

  • 一般来说,PostgreSQL 9.2 应该适合您,因为它的 improvements focus on performance with big data

【讨论】:

  • 非常感谢您的详细解答! :-) 我已经运行了您的代码:插入数据现在需要约 9-10 分钟,而不是之前约 10-11 分钟。这一点有点没有实际意义,因为真正的数据库无论如何都会大量加载测试数据。运行查询大约需要 1300 毫秒,这与之前相同(在测试方差内)。
  • @ssc:这令人失望。我的主要疏忽是您已经添加了关键索引。我在答案中添加了一些内容。
  • 如果这里的 RAM 是个问题,我的性能测试为什么会显示 1GB 的 VM 和 8GB 的​​ MacBook 相同的数据?
  • 您必须反复运行测试才能填充缓存。您的 10M 行在磁盘上大约占用 500 MB 到 1 GB,在 RAM 中占用更多。向其中添加索引。具有 1 GB RAM 的机器显然无法缓存它。无论哪种方式,要使查询更快,请使用 PostgreSQL 9.2。聚合数据的物化视图也可以提供帮助,我添加了一行关于此的内容。
  • 您必须多次运行测试才能填充缓存。您的 10M 行在磁盘上大约占用 500 MB 到 1 GB,在 RAM 中占用更多。向其中添加索引。具有 1 GB RAM 的机器显然无法缓存它。无论哪种方式,要使查询更快,请使用 PostgreSQL 9.2。我还在回答中添加了物化视图CLUSTER的提示。
【解决方案2】:

这个“解决方案”背后的想法是:避免 {device,spot,paramater} 使用单独的键域。这三者只有 1000 种可能的组合。 (可能被视为违反 BCNF 的坏案例)。所以我将它们组合成一个 what_how_where 表,它指的是树分离域。测量(数据)表中的关键元素数量从 4 个减少到 2 个,并且省略了代理键(因为没有使用) what_how_where 表确实有一个代理键。我的意思可以表示为:如果这个表中存在一个元组:参数'what'可以通过设备'how'在位置'where'上测量。

-- temp schema for scratch
DROP SCHEMA tmp CASCADE;
CREATE SCHEMA tmp;
SET search_path=tmp;

        -- tables for the three "key domain"s
CREATE TABLE device
        ( id SERIAL NOT NULL PRIMARY KEY
        , dname VARCHAR NOT NULL -- 'name' might be a reserve word
        , CONSTRAINT device_name UNIQUE (dname)
        );

CREATE TABLE parameter
        ( id SERIAL PRIMARY KEY -- must have ID as names are not unique
        , pname VARCHAR NOT NULL
        );

CREATE TABLE spot
        ( id SERIAL PRIMARY KEY
        , sname VARCHAR NOT NULL
        );
        -- One table to combine the three "key domain"s
CREATE TABLE what_how_where
        ( id SERIAL NOT NULL PRIMARY KEY
        , device_id INTEGER NOT NULL REFERENCES device(id)
        , spot_id INTEGER NOT NULL REFERENCES spot(id)
        , parameter_id INTEGER NOT NULL REFERENCES parameter(id)
        , CONSTRAINT what_natural UNIQUE (device_id,spot_id,parameter_id)
        );

CREATE TABLE measurement
        ( whw_id INTEGER NOT NULL REFERENCES what_how_where(id)
        , t_stamp TIMESTAMP NOT NULL
        , value VARCHAR(32) NOT NULL
        , CONSTRAINT measurement_natural PRIMARY KEY (t_stamp,whw_id)
        );

INSERT INTO device (dname)
SELECT 'dev_' || d::text
FROM generate_series(1,10) d;

INSERT INTO parameter (pname)
SELECT 'param_' || p::text
FROM generate_series(1,10) p;

INSERT INTO spot (sname)
SELECT 'spot_' || s::text
FROM generate_series(1,10) s;

INSERT INTO what_how_where (device_id,spot_id,parameter_id)
SELECT d.id,s.id,p.id
FROM device d
JOIN spot s ON(1=1)
JOIN parameter p ON(1=1)
        ;
ANALYSE what_how_where;

INSERT INTO measurement(whw_id, t_stamp, value)
SELECT w.id
        , g
        , random()::text
FROM what_how_where w
JOIN generate_series('2012-01-01'::date, '2012-09-23'::date, '1 day'::interval) g
        ON (1=1)
        ;

CREATE UNIQUE INDEX measurement_natural_reversed ON measurement(whw_id,t_stamp);
ANALYSE measurement;

        -- A view to *more or less* emulate the original behaviour
DROP VIEW measurement_data ;
CREATE VIEW measurement_data AS (
        SELECT d.dname AS dname
        , p.pname AS pname
        , w.spot_id AS spot_id
        , w.parameter_id AS parameter_id
        , m.t_stamp AS t_stamp
        , m.value AS value
        FROM measurement m
        JOIN what_how_where w ON m.whw_id = w.id
        JOIN device d ON w.device_id = d.id
        JOIN parameter p ON w.parameter_id = p.id
        );


EXPLAIN (ANALYZE ON, BUFFERS ON)
SELECT md.value
  FROM measurement_data md
 WHERE md.pname = 'param_8'
   AND md.t_stamp >= '2012-07-01'
   AND md.t_stamp < '2012-08-01'
        ;

更新:有一个实际问题,只能通过某种聚类来解决:

  • 假设行大小为 50 字节
  • 并且只需要 5% (1/20) 参数的查询的特异性
  • 这意味着大约 4 个“需要的”元组存在于 OS 磁盘页面上(+76 个不需要的元组)

没有集群,这意味着所有页面必须被拉入+检查。索引在这里没有帮助(它们只有在能够避免页面被拉入时才有帮助,这可能是在第一个键列上进行(范围)搜索的情况)) 索引可能有助于扫描内存中的页面之后这些已被提取。

因此,这意味着(一旦您的查询占用的空间大于可用缓冲区空间)您的查询实际上会测量您机器的 I/O 速度。

【讨论】:

  • 这应该会大大减少第二大表的大小,但最大表的大小(主要空间要求)保持不变。这个特定的查询甚至会更慢,因为现在您必须加入更大的表并解决更复杂的条件。不过,这是一个有趣的变体。
  • 我不同意。索引的使用可以看成是“联表”的“物理变体”。在我的情况下,连接的表非常小(1K 记录),在 OP 的版本中,需要三个单独的索引连接(在非常“稀疏”的索引上)此外,四个关键元素的集群/碎片化会更糟。 BTW:OP(400K)的报告行数很大;如果这些行分散在整个磁盘上,则可以预期每个元组一个磁盘页的典型 I/O 成本。
  • 因此,如果我正确理解了您的方法,则您已将参数 FK 与时间戳交换,从而使“索引”表“恒定”,大小为 1000 行。如果我们应用相同的粗略存储空间估计:测量表现在大了 4 个字节,我们仍然得到 1440 meas/day,所以 29x1.44MB = ~40MB/day,这比我的方法的 ~39MB 多不了多少,因此对存储需求没有太大影响。
  • 当我使用与我的方法相同的数据范围运行代码时,相同的查询似乎比我的方法或@ErwinBrandstetter 的方法慢约 10%。查询计划看起来更复杂(这可能与性能无关,但对我的理解很重要;-),但这可能是由于视图。不需要“向后兼容”,所以我可以跳过它。我想我会将我的代码更改为使用您的方法进行测试并进行一些测量。谢谢! :-)
  • 如前所述更改数据模型会将存储过程的执行时间从几秒甚至几分钟缩短到几毫秒。不幸的是,该项目同时被取消了,所以我无法进一步调查 - 但我从中得到的是:如果性能对于即使是微不足道的查询来说真的很糟糕,请仔细看看数据模型。谢谢你的帮助! :-)
【解决方案3】:

我不明白您如何将特定测量值与设备、地点和时间的特定组合联系起来。我错过了什么明显的东西吗?

让我们换个角度看。

CREATE TABLE measurement_data
(
  device_name character varying(16) NOT NULL,
  spot_id integer NOT NULL,
  parameter_id integer NOT NULL,
  t_stamp timestamp without time zone NOT NULL,
  value character varying(16) NOT NULL,
  CONSTRAINT measurement_data_pk PRIMARY KEY (device_name , spot_id , t_stamp , parameter_id ),
  CONSTRAINT measurement_data_fk_device FOREIGN KEY (device_name)
      REFERENCES device (name) MATCH FULL
      ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT measurement_data_fk_parameter FOREIGN KEY (parameter_id)
      REFERENCES parameter (id) MATCH SIMPLE
      ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT measurement_data_fk_spot FOREIGN KEY (spot_id)
      REFERENCES spot (id) MATCH FULL
      ON UPDATE NO ACTION ON DELETE NO ACTION
);

(这个表的一个更好的名字是“measurements”。每个表都包含数据。)

我希望这种桌子有更好的性能。但我也希望任何返回许多行的查询都会与性能作斗争。 (除非硬件和网络与任务相匹配。)

【讨论】:

  • 我希望我能够解释我的“更新 01”中索引和值表的必要性以及它们之间的关系
【解决方案4】:

从数字看来,您正受到计时开销的影响。您可以通过使用pg_test_timing 或将timing off 添加到您的解释参数来验证这一点(两者都在PostgreSQL 版本9.2 中引入)。通过将时钟源设置为 HPET 而不是 TSC,我可以大致复制您的结果。

使用 HPET:

 Nested Loop  (cost=8097.73..72850.98 rows=432000 width=12) (actual time=29.188..905.765 rows=432000 loops=1)
   Buffers: shared hit=56216
   ->  Seq Scan on parameter  (cost=0.00..1.25 rows=1 width=4) (actual time=0.004..0.008 rows=1 loops=1)
         Filter: ((name)::text = 'param_01'::text)
         Rows Removed by Filter: 19
         Buffers: shared hit=1
   ->  Bitmap Heap Scan on measurement_data_value  (cost=8097.73..68529.73 rows=432000 width=16) (actual time=29.180..357.848 rows=432000 loops=1)
         Recheck Cond: (fk_parameter_id = parameter.id)
         Buffers: shared hit=56215
         ->  Bitmap Index Scan on measurement_data_value_idx_fk_parameter_id  (cost=0.00..7989.73 rows=432000 width=0) (actual time=21.710..21.710 rows=432000 loops=1)
               Index Cond: (fk_parameter_id = parameter.id)
               Buffers: shared hit=1183
 Total runtime: 1170.409 ms

使用 HPET 和定时关闭:

 Nested Loop  (cost=8097.73..72850.98 rows=432000 width=12) (actual rows=432000 loops=1)
   Buffers: shared hit=56216
   ->  Seq Scan on parameter  (cost=0.00..1.25 rows=1 width=4) (actual rows=1 loops=1)
         Filter: ((name)::text = 'param_01'::text)
         Rows Removed by Filter: 19
         Buffers: shared hit=1
   ->  Bitmap Heap Scan on measurement_data_value  (cost=8097.73..68529.73 rows=432000 width=16) (actual rows=432000 loops=1)
         Recheck Cond: (fk_parameter_id = parameter.id)
         Buffers: shared hit=56215
         ->  Bitmap Index Scan on measurement_data_value_idx_fk_parameter_id  (cost=0.00..7989.73 rows=432000 width=0) (actual rows=432000 loops=1)
               Index Cond: (fk_parameter_id = parameter.id)
               Buffers: shared hit=1183
 Total runtime: 156.537 ms

使用 TSC:

 Nested Loop  (cost=8097.73..72850.98 rows=432000 width=12) (actual time=29.090..156.233 rows=432000 loops=1)
   Buffers: shared hit=56216
   ->  Seq Scan on parameter  (cost=0.00..1.25 rows=1 width=4) (actual time=0.004..0.008 rows=1 loops=1)
         Filter: ((name)::text = 'param_01'::text)
         Rows Removed by Filter: 19
         Buffers: shared hit=1
   ->  Bitmap Heap Scan on measurement_data_value  (cost=8097.73..68529.73 rows=432000 width=16) (actual time=29.083..114.908 rows=432000 loops=1)
         Recheck Cond: (fk_parameter_id = parameter.id)
         Buffers: shared hit=56215
         ->  Bitmap Index Scan on measurement_data_value_idx_fk_parameter_id  (cost=0.00..7989.73 rows=432000 width=0) (actual time=21.667..21.667 rows=432000 loops=1)
               Index Cond: (fk_parameter_id = parameter.id)
               Buffers: shared hit=1183
 Total runtime: 168.869 ms

因此,您的缓慢似乎主要是由仪器开销引起的。但是,在 PostgreSQL 中选择大量行不会非常快。如果您需要对大量数据进行数字运算,那么结构化数据可能是一个好主意,以便您可以以更大的块获取它。 (例如,如果您需要始终处理至少一天的数据,请将一天的所有测量值汇总到一个数组中)

一般来说,您必须了解您的工作负载将是什么才能进行调优。一种情况下的胜利在另一种情况下可能是巨大的损失。我建议您查看pg_stat_statements 以找出您的瓶颈所在。

【讨论】:

  • 差别很大! (而且我认为这也可以解释为什么我在 1GB 的小型机器上看到与 8GB 相同的计时结果。但是,我无法重现您的结果 - 如果我在解释分析之前执行 \timeing off,结果几乎是一样。
  • 在旁注中,我认为 \timing on/off 是在 8.4 中引入的,参见例如depesz.com/2008/06/11/waiting-for-84-waiting-onoff,所以也许我只需要更新我的服务器以使用 pg_test_timing ?!?
  • 您能否在您的答案中添加一些信息,您为获得第二个和第三个结果采取了哪些步骤?我不知道如何将我的时钟源设置为 HPET 或 TSC(我自己的谷歌搜索未决......)
  • 为了在 hpet 和 tsc 时钟源之间切换,我使用了以下 shell 命令“echo hpet > /sys/devices/system/clocksource/clocksource0/current_clocksource”
  • 为了在没有时间的情况下进行解释,我使用了 EXPLAIN (ANALYZE ON, TIMING OFF),如下所述:postgresql.org/docs/9.2/static/sql-explain.html
猜你喜欢
  • 1970-01-01
  • 2013-09-11
  • 2022-01-23
  • 2017-03-24
  • 1970-01-01
  • 1970-01-01
  • 2023-04-02
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多