PostgreSQL:如何构建和索引与时间相关的数据以获得最佳查询性能?

ssc*_*ssc 15 sql postgresql indexing performance database-design

问题:

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

项目背景:

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

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

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

数据模型:

每个参数的每个点和每个设备都有测量数据,例如在6天的时间内每分钟一次.我的工作是将数据存储在数据库中,并提供对它的有效访问.

简而言之:

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

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

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

最初,我已将测量数据值建模为具有自己的ID作为主键; n:m测量数据索引和值之间的关系是一个单独的表,只存储了index:valueID对,但由于该表本身消耗了相当多的硬盘空间,我们将其消除并将值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
);
Run Code Online (Sandbox Code Playgroud)

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

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();
Run Code Online (Sandbox Code Playgroud)

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

VACUUM ANALYZE device;
VACUUM ANALYZE measurement_data_index;
VACUUM ANALYZE measurement_data_value;
VACUUM ANALYZE parameter;
VACUUM ANALYZE spot;
Run Code Online (Sandbox Code Playgroud)

示例查询:

如果我现在运行一个非常简单的查询来获取某个参数的所有值,它已经需要几秒钟,尽管数据库还不是很大:

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';
Run Code Online (Sandbox Code Playgroud)

在我的开发机器上的示例性结果(请参阅下面有关我的环境的一些细节):

                                                                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)
Run Code Online (Sandbox Code Playgroud)

除了隐式索引之外,数据库中没有索引,因此规划器仅执行顺序扫描并不奇怪.如果我遵循似乎是经验法则并为每个外键添加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);
Run Code Online (Sandbox Code Playgroud)

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

                                                                                   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)
Run Code Online (Sandbox Code Playgroud)

但是,对于非常简单的查询,这仍然是执行时间的一秒多.

到目前为止我做了什么:

  • 得到了自己的PostgreSQL 9.0高性能副本- 很棒的书!
  • 做了一些基本的PostgreSQL服务器配置,请参阅下面的环境
  • created a framework to run a series of performance tests using real queries from the project and to display the results graphically; these queries use devices, spots, parameters and a time interval as input parameters and the test series run over e.g. 5, 10 devices, 5, 10 spots, 5, 10, 15, 20 parameters and 1..7 days. The basic result is that they're all too slow, but their query plan was way too complex for me to understand, so I went back to the really simple query used above.

I have looked into partitioning the value table. The data is time-related and partitioning seems an appropriate means to organize that kind of data; even the examples in the PostgreSQL documentation use something similar. However, I read in the same article:

The benefits will normally be worthwhile only when a table would otherwise be very large. The exact point at which a table will benefit from partitioning depends on the application, although a rule of thumb is that the size of the table should exceed the physical memory of the database server.

The entire test database is less than 1GB in size and I am running my tests on a development machine with 8GB of RAM and on a virtual machine with 1GB (see also environment below), so the table is far from being very large or even exceeding the physical memory. I might implement partitioning anyway at some stage, but I have a feeling that approach does not target the performance problem itself.

Furthermore, I am considering to cluster the value table. I dislike the fact that clustering must be re-done whenever new data is inserted and that it furthermore requires an exclusive read/write lock, but looking at this SO question, it seems that it anyway has its benefits and might be an option. However, clustering is done on an index and as there are up to 4 selection criteria going into a query (devices, spots, parameters and time), I would have to create clusters for all of them - which in turn gives me the impression that I'm simply not creating the right indexes...

My Environment:

  • development is taking place on a MacBook Pro (mid-2009) with a dual-core CPU and 8GB of RAM
  • I am running database performance tests on a virtual Debian 6.0 machine with 1GB of RAM, hosted on the MBP
  • PostgreSQL version is 9.1 as that was the latest version when I installed it, upgrading to 9.2 would be possible
  • I have changed shared_buffers from the default 1600kB to 25% of RAM on both machines as recommended in the PostgreSQL docs (which involved enlarging kernel settings like SHMALL, SHMMAX, etc.)
  • similarly, I have changed effective_cache_size from the default 128MB to 50% of the RAM available
  • I ran performance test with different work_mem settings, but did not see any major difference in performance

NOTE: One aspect that I believe is important is that the performance test series with real queries from the project do not differ performance-wise between the MacBook with 8GB and the virtual machine with 1GB; i.e. if a query takes 10s on the MacBook, it also takes 10s on the VM. Also, I ran the same performance tests before and after changing shared_buffers, effective_cache_size and work_mem and the configuration changes did not improve performance by more than 10%; some results in fact even got worse, so it seems any difference is caused rather by test variation than by configuration change. These observations lead me to believe that RAM and postgres.conf settings are not the limiting factors here yet.

My Questions:

I don't know if different or additional indexes would speed up the query and if they did, which ones to create. Looking at the size of the database and how simple my query is, I have the impression there is something fundamentally wrong about my data model or how I have chosen my indexes so far.

Does anyone have some advice for me how to structure and index time-related my to improve query performance ?

Asked more broadly, is tuning query performance

  • usually done 'on an incident base', i.e. once a query does not perform satisfactorily ? It seems all my queries are too slow...
  • mainly a question of looking at (and understanding) query plans, then adding indexes and measuring if things improved, possibly accelerating the process by applying one's experience ?

How do I get this database to fly ?


Update 01:

Looking at the responses so far, I think I have not explained the need for measurement data index/values tables properly, so let me try again. Storage space is the issue here.

NOTE:

  • the figures used here are more of illustrative purpose and meant for comparison only, i.e. the numbers themselves are not relevant, what matters is the percental difference in storage requirements between using a single table vs. using an index and a value table
  • PostgreSQL data type storage sizes are documented in this chapter
  • this makes no claim to be scientifically correct, e.g. the units are probably mathematical bogus; the numbers should add up though

Assuming

  • 1 day of measurements
  • 1 set of measurements per minute
  • 10 devices
  • 10 parameters
  • 10 spots

This adds up to

1 meas/min x 60 min/hour x 24 hour/day = 1440 meas/day

Each measurement has data from every spot and every device for every parameter, so

10 spots x 10 devices x 10 parameters = 1000 data sets/meas

So in total

1440 meas/day x 1000 data sets/meas = 1 440 000 data sets/day

If we store all measurements in a single table as Catcall suggested, e.g.

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...
);
Run Code Online (Sandbox Code Playgroud)

a single row would add up to

17 + 4 + 4 + 8 + 17 = 50 bytes/row

in the worst case where all varchar fields are fully filled. This amounts to

50 bytes/row x 1 440 000 rows/day = 72 000 000 bytes/day

or ~69 MB per day.

While this does not sound a lot, the storage space requirement in the real database would be prohibitive (again, the numbers used here are only for illustration). We have therefore split measurement data into an index and a value table as explained earlier in the question:

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...
);
Run Code Online (Sandbox Code Playgroud)

where the ID of a value row is equal to the ID of the index it belongs to.

The sizes of a row in the index and value tables are

index: 4 + 17 + 4 + 8 = 33 bytes
value: 4 + 4 + 17     = 25 bytes

(again, worst case scenario). The total amount of rows is

index: 10 devices x 10 spots x 1440 meas/day =   144 000 rows/day
value: 10 parameters x 144 000 rows/day      = 1 440 000 rows/day

so the total is

index: 33 bytes/row x   144 000 rows/day =  4 752 000 bytes/day
value: 25 bytes/row x 1 440 000 rows/day = 36 000 000 bytes/day
total:                                   = 40 752 000 bytes/day

or ~39 MB per day - as opposed to ~69 MB for a single table solution.


Update 02 (re: wildplassers response):

This question is getting pretty long as it is, so I was considering updating the code in place in the original question above, but I think it might help to have both the first and the improved solutions in here to better see the differences.

Changes compared to the original approach (somewhat in order of importance):

  • swap timestamp and parameter, i.e. move t_stamp field from measurement_data_index table to measurement_data_value and move fk_parameter_id field from value to index table: With this change, all fields in the index table are constant and new measurement data is written to the value table only. I did not expect any major query performance improvement from this (I was wrong), but I feel it makes the measurement data index concept clearer. While it requires fractionally more storage space (according to some rather crude estimate), having a 'static' index table might also help in deployment when tablespaces are moved to different harddrives according to their read/write requirements.
  • use a surrogate key in device table: From what I understand, a surrogate key is a primary key that is not strictly required from a database design point of view (e.g. device name is already unique, so it could also serve as PK), but might help to improve query performance. I added it because again, I feel it makes the concept clearer if the index table references IDs only (instead of some names and some IDs).
  • rewrite insert_data(): Use generate_series() instead of nested FOR loops; makes the code much 'snappier'.
  • As a side effect of these changes, inserting test data takes only about 50% of the time required by the first solution.
  • I did not add the view as wildplasser suggested; there's no backward compatibility required.
  • Additional indexes for the FKs in the index table seem to be ignored by the query planner and have no impact on query plan or performance.

(it seems without this line, the code below is not properly displayed as code on the SO page...)

\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();
Run Code Online (Sandbox Code Playgroud)

At some stage, I will change my own conventions to using inline PRIMARY KEY and REFERENCES statements instead of explicit CONSTRAINTs; for the moment, I think keeping this the way it was makes it easier to compare the two solutions.

Don't forget to update statistics for the query planner:

VACUUM ANALYZE device;
VACUUM ANALYZE measurement_data_index;
VACUUM ANALYZE measurement_data_value;
VACUUM ANALYZE parameter;
VACUUM ANALYZE spot;
Run Code Online (Sandbox Code Playgroud)

Run a query that should produce the same result as the one in the first approach:

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';
Run Code Online (Sandbox Code Playgroud)

Result:

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
Run Code Online (Sandbox Code Playgroud)

This is almost half of the ~1.3s the first approach required; considering I'm loading 432K rows, it is a result I can live with for the moment.

NOTE: The field order in the value table PK is id, t_stamp; the order in wildplassers response is t_stamp, whw_id. I did this that way because I feel a 'regular' field order is the one in which fields are listed in the table declaration (and 'reverse' is then the other way around), but that's just my own convention that keeps me from getting confused. Either way, as Erwin Brandstetter pointed out, this order is absolutely critical for the performance improvement; if it is the wrong way around (and a reverse index as in wildplassers solution is missing), the query plan looks like below and performance is more than 3 times worse:

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
Run Code Online (Sandbox Code Playgroud)

Erw*_*ter 6

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

数据库架构

  • 我认为你的表格布局有一个主要的逻辑缺陷(同样由@Catcall指出).我改变它,我怀疑它应该是这样:
    你的最后一个表格measurement_data_value(我改名为measure_val)应该保存每一个值parameter(现:param),每行measurement_data_index(现:measure).见下文.

  • 即使"设备具有唯一名称",仍然使用整数代理主键.文本字符串本质上更庞大,更慢,可用作大表中的外键.它们还需要进行整理,这可能会显着减慢查询速度.

    在这个相关的问题下,我们发现在中型text柱上加入和分选是主要的减速.如果您坚持使用文本字符串作为主键,请阅读PostgreSQL 9.1或更高版本中的排序规则支持.

  • 不要使用id作为主键名称的反模式.当你加入几张桌子时(就像你将要做很多事情一样!)你最终得到了几个名字id- 这真是一团糟!(可悲的是,一些ORM使用它.)

    相反,在表之后命名一个代理主键列以使其自身有意义.然后你可以让引用它的外键具有相同的名称(这是一个好的,因为它们包含相同的数据).

    CREATE TABLE spot
    ( spot_id SERIAL PRIMARY KEY);
    Run Code Online (Sandbox Code Playgroud)
  • 不要使用超长标识符.它们难以打字且难以阅读.经验法则:只要有必要清楚,尽可能短.

  • varchar(n)如果您没有令人信服的理由,请不要使用.只是使用varchar,或简单:只是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!
Run Code Online (Sandbox Code Playgroud)

我改名笨重measurement_data_valuemeasure_val,因为这是在表:用于测量的参数值.现在,多列pk也是有意义的.

但我添加了一个单独的索引param_id.你有这种方式,列param_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$;
Run Code Online (Sandbox Code Playgroud)

呼叫:

SELECT insert_data();
Run Code Online (Sandbox Code Playgroud)

询问

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

USING子句仅用于简化语法,但并不优于ON其他方法.

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

  • 指数param_param_idxparam.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
    
    Run Code Online (Sandbox Code Playgroud)

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

  • 一般来说,PostgreSQL 9.2应该对你很有用,因为它的改进主要集中在大数据的性能上.


wil*_*ser 4

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

-- 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'
        ;
Run Code Online (Sandbox Code Playgroud)

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

  • 给定估计行大小为 50 字节
  • 并且只需要 5% (1/20) 参数的查询特异性
  • 这意味着操作系统磁盘页面上存在大约 4 个“想要的”元组(+76 个不需要的元组)

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

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