16

问题:

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

项目背景:

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

该项目围绕对大型复杂机器进行研究。我对机器本身不太了解,但实验室里有传言说那里的某个地方有一个磁通电容器——我想昨天,我发现薛定谔猫的尾巴挂在旁边;-)

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

数据模型:

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

简而言之:

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

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

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

最初,我将测量数据值建模为具有自己的 ID 作为主键;测量数据索引和值之间的n:m关系是一个单独的表,只存储index:valueID对,但是由于该表本身占用了相当多的硬盘空间,我们将其删除并将值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 天。基本结果是它们都太慢了,但是它们的查询计划太复杂了,我无法理解,所以我回到上面使用的非常简单的查询。

我已经研究过对值表进行分区。数据与时间相关,分区似乎是组织此类数据的合适方法;甚至 PostgreSQL 文档中的示例也使用了类似的东西。但是,我在同一篇文章中读到:

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

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

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

我的环境:

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

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

我的问题:

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

有没有人对我如何构建和索引与时间相关的我以提高查询性能有一些建议?

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

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

我如何让这个数据库飞起来?


更新01:

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

笔记:

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

假设

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

这加起来

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

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

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

所以总的来说

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

如果我们按照Catcall 的建议将所有测量值存储在一个表中,例如

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 响应):

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

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

  • 交换时间戳和参数,即将t_stamp字段从measurement_data_index表移到measurement_data_valuefk_parameter_id从值移到索引表:通过这种更改,索引表中的所有字段都是不变的,新的测量数据只写入值表。我没想到这会带来任何重大的查询性能改进(我错了),但我觉得它使测量数据索引的概念更加清晰。虽然它需要更多的存储空间(根据一些相当粗略的估计),但当根据读/写要求将表空间移动到不同的硬盘驱动器时,拥有一个“静态”索引表也可能有助于部署。
  • 在设备表中使用代理键:据我了解,代理键是从数据库设计的角度来看并非严格要求的主键(例如,设备名称已经是唯一的,因此它也可以用作 PK),但是可能有助于提高查询性能。我再次添加它是因为,如果索引表仅引用 ID(而不是某些名称和某些 ID),我觉得它会使概念更清晰。
  • rewrite 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
4

4 回答 4

6

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

数据库模式

  • 我认为您的表格布局有一个重大的逻辑缺陷(@Catcall 也指出了这一点)。我按照我怀疑应该是的方式对其进行了更改:
    您的最后一个表measurement_data_value(我将其重命名为)应该为(现在:)中的每一行measure_val保存一个值(现在:) 。见下文。parameterparammeasurement_data_indexmeasure

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

    在这个相关问题下,我们发现在中等大小的text列上连接和排序是主要的减速。如果您坚持使用文本字符串作为主键,请阅读 PostgreSQL 9.1 或更高版本中的排序规则支持

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

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

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

  • varchar(n)如果没有令人信服的理由,请勿使用。只需使用varchar,或更简单:justtext

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

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!

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

但我param_id. 按照您的方式,columnparam_id是多列索引中的第二列,这导致param_id. 在 dba.SE 上的相关问题下阅读所有关于此的血腥细节。

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

测试数据

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

为了提高效率,我使用了数据修改 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_idx上的索引param.param
  • 索引measure_val_param_id_idxmeasure_val.param_id,喜欢在这里详细解释。

反馈后编辑

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

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

更多选项

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

    具有 1GB RAM 的虚拟 Debian 6.0 机器

    远低于你所需要的。

  • 部分索引,特别是与PostgreSQL 9.2的仅索引扫描有关。

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

    CLUSTER measure_val USING measure_val_param_id_idx
    

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

  • 一般来说,PostgreSQL 9.2应该非常适合您,因为它的改进侧重于大数据的性能

于 2012-09-22T16:08:30.850 回答
4

这个“解决方案”背后的想法是:避免 {device,spot,paramater} 使用单独的关键域。这三者只有 1000 种可能的组合。(可能被视为违反 BCNF 的坏案例)。所以我将它们组合成一个 what_how_where 表,它指的是树分离域。测量(数据)表中的关键元素数量从 4 个减少到 2 个,并且省略了代理键(因为未使用) what_how_where 表确实有一个代理键。I 的含义可以表示为:如果此表中存在元组:参数 '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 速度。

于 2012-09-23T15:09:36.587 回答
2

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

让我们以不同的方式来看待它。

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
);

(此表的更好名称是“测量”。每个表都包含数据。)

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

于 2012-09-22T16:21:29.367 回答
1

从数字看来,您正受到计时开销的影响。您可以通过使用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以确定您的瓶颈在哪里。

于 2012-09-23T14:07:37.910 回答