4

I have a fairly large table (500K - 1M rows) in PostgreSQL 9.0 that contains generic "time slice" information, that is, it determines when a row in another table (a "feature") is valid. The definition looks like this (slightly simplified):

CREATE TABLE feature_timeslice
(
  timeslice_id int NOT NULL,
  feature_id int NOT NULL,
  valid_time_begin timestamp NOT NULL,
  valid_time_end timestamp,
  sequence_number smallint,
  -- Some other columns
  CONSTRAINT pk_feature_timeslice PRIMARY KEY (timeslice_id)
  -- Some other constraints
)

CREATE INDEX ix_feature_timeslice_feature_id
ON feature_timeslice USING btree (feature_id);

Many other tables for specific features are then joined to it on timeslice_id:

CREATE TABLE specific_feature_timeslice
(
  timeslice_id int NOT NULL,
  -- Other columns
  CONSTRAINT pk_specific_feature_timeslice PRIMARY KEY (timeslice_id),
  CONSTRAINT fk_specific_feature_timeslice_feature_timeslice FOREIGN KEY (timeslice_id) REFERENCES feature_timeslice (timeslice_id)
)

There may be multiple time slices with overlapping valid periods (begin/end time), but the one with the highest sequence_number takes priority (again, a slight simplification, but close enough). I'd like to efficiently find the currently valid row for each feature_id, so I have a view defined, like this:

CREATE VIEW feature_timeslice_id_now
AS
    SELECT timeslice_id
    FROM
    (
        SELECT timeslice_id, rank() OVER
        (
            PARTITION BY feature_id
            ORDER BY sequence_number DESC, timeslice_id DESC
        )
        FROM feature_timeslice
        WHERE (current_timestamp AT TIME ZONE 'UTC', '0'::interval) OVERLAPS (valid_time_begin, COALESCE(valid_time_end, 'infinity'::timestamp))
    ) subq 
    WHERE subq.rank = 1

It's typically queried like this:

SELECT *
FROM specific_feature_timeslice sf
JOIN feature_timeslice_id_now n USING (timeslice_id)
WHERE sf.name = 'SOMETHING'

This works, but it's still a bit too slow - takes 1-2 seconds, even though there may only be 1-5 rows returned, because the specific_feature_timeslice criteria generally narrows it down a lot. (More complex queries that join multiple feature views get very slow very quickly.) I can't figure out how to get PostgreSQL to do this more efficiently. The query plan looks like this:

   Join Filter: ((r.timeslice_id)::integer = (subq.timeslice_id)::integer)
  ->  Subquery Scan on subq  (cost=32034.36..37876.98 rows=835 width=4) (actual time=2086.125..5243.467 rows=250918 loops=1)
        Filter: (subq.rank = 1)
        ->  WindowAgg  (cost=32034.36..35790.33 rows=166932 width=10) (actual time=2086.110..4066.351 rows=250918 loops=1)
              ->  Sort  (cost=32034.36..32451.69 rows=166932 width=10) (actual time=2086.065..2654.971 rows=250918 loops=1)
                    Sort Key: feature_timeslice.feature_id, feature_timeslice.sequence_number, feature_timeslice.timeslice_id
                    Sort Method:  quicksort  Memory: 13898kB
                    ->  Seq Scan on feature_timeslice  (cost=0.00..17553.93 rows=166932 width=10) (actual time=287.270..1225.595 rows=250918 loops=1)
                          Filter: overlaps(timezone('UTC'::text, now()), (timezone('UTC'::text, now()) + '00:00:00'::interval), (valid_time_begin)::timestamp without time zone, COALESCE((valid_time_end)::timestamp without time zone, 'infinity'::timestamp without time zone))
  ->  Materialize  (cost=0.00..1093.85 rows=2 width=139) (actual time=0.002..0.007 rows=2 loops=250918)
        ->  Seq Scan on specific_feature_timeslice sf  (cost=0.00..1093.84 rows=2 width=139) (actual time=1.958..7.674 rows=2 loops=1)
              Filter: ((name)::text = 'SOMETHING'::text)
Total runtime: 10319.875 ms

In reality, I'd like to do this query for any given time, not just the current time. I have a function defined for that, which takes the time as an argument, but querying for "now" is the most common scenario, so even if I could only speed that up it would be a great improvement.

== Edit ==

OK, I've tried normalising the table as suggested by both answers - that is, I moved valid_time_begin and valid_time_end into a separate table, time_period. I also replaced the window function with WHERE NOT EXISTS ([better candidate time slice]). In the process I also upgraded to PostgreSQL 9.1. With all that some queries are now twice as fast. The query plan looks the same as in wildplasser's answer. This is good, but not quite as good as I'd hoped - it still takes over a second to select from a single feature table.

Ideally, I'd like to take advantage of the selectivity of the feature WHERE condition, as Erwin Brandstetter says. If I hand-craft a query to do this the time I get is 15-30 ms. Now that's more like it! The hand-crafted query looks something like this:

WITH filtered_feature AS
(
    SELECT *
    FROM specific_feature_timeslice sf
    JOIN feature_timeslice ft USING (timeslice_id)
    WHERE sf.name = 'SOMETHING'
)
SELECT *
FROM filtered_feature ff
JOIN
(
    SELECT timeslice_id
    FROM filtered_feature candidate
    JOIN time_period candidate_time ON candidate.valid_time_period_id = candidate_time.id
    WHERE ('2011-09-26', '0'::interval) OVERLAPS (candidate_time.valid_time_begin, COALESCE(candidate_time.valid_time_end, 'infinity'::timestamp))
        AND NOT EXISTS
        (
            SELECT *
            FROM filtered_feature better
            JOIN time_period better_time ON better.valid_time_period_id = better_time.id
            WHERE ('2011-09-26', '0'::interval) OVERLAPS (better_time.valid_time_begin, COALESCE(better_time.valid_time_end, 'infinity'::timestamp))
                AND better.feature_id = candidate.feature_id AND better.timeslice_id != candidate.timeslice_id
                AND better.sequence_number > candidate.sequence_number
        )
) AS ft ON ff.timeslice_id = ft.timeslice_id

Unfortunately, this is way too big and complex to use in normal queries, which might join many other tables. I need some way to encapsulate this logic in a function (for arbitrary time) or at least a view (for current time), but I cannot figure out how to do this while still getting the query planner to filter on the specific feature first. If only I could pass a rowset into a function - but as far as I know PostgreSQL doesn't allow this. Any ideas?

== Conclusion ==

I ended up using PostgreSQL inheritance to solve this (see my answer), but I would not have come up with this idea if it wasn't for Erwin Brandstetter answer, so the bounty goes to him. wildplasser's answer was also very helpful, because it allowed me to eliminate the unnecessary window function, which sped it up further. Many thanks to both of you!

12
  • What is the setting for work_mem? You have an external merge for just 4416kb, work_mem seems to be way to low, try 10MB or so. And create an index on specific_feature.name Commented Sep 21, 2011 at 6:35
  • Thanks. It was at the default of 1MB. I tried setting it to 20MB and now the query plan says "Sort Method: quicksort Memory: 13898kB", but the query time hasn't changed noticeably. Commented Sep 21, 2011 at 6:47
  • What kind of indexes do you have on the table? I'd think that one on feature_id might speed up the sorting for the rank() evaluation Commented Sep 21, 2011 at 7:06
  • Could you update the queryplan in this topic? Do you have indexes on valid_time_begin, valid_time_end, sequence_number and timeslice_id? And why don't you use a default 'infinity' for valid_time_end? Than you can drop the COALESCE as well. Commented Sep 21, 2011 at 7:33
  • Yes, I have an index on feature_id - that's the only one. I tried creating one on valid_time_begin, but it didn't change the query plan or the running time. Query plan updated. I'd prefer to keep it as NULL for "no end time" (clearer) - do you think that would affect performance? Commented Sep 21, 2011 at 7:59

3 Answers 3

2

I ended up using PostgreSQL inheritance to solve this, so each specific_feature_timeslice table inherits from feature_timeslice (rather than referencing it, as before). This allows the "selectivity of the feature can take effect first" - the query plan starts by narrowing it down to the few rows I want. So the schema now looks like this:

CREATE TABLE feature_timeslice
(
  timeslice_id int NOT NULL,
  feature_id int NOT NULL,
  valid_time_begin timestamp NOT NULL,
  valid_time_end timestamp,
  sequence_number smallint,
  -- Some other columns
  CONSTRAINT pk_feature_timeslice PRIMARY KEY (timeslice_id)
  -- Some other constraints
)

CREATE TABLE specific_feature_timeslice
(
  -- Feature-specific columns only, eg.
  name character varying(100),

  CONSTRAINT pk_specific_feature_timeslice PRIMARY KEY (timeslice_id)
)
INHERITS (feature_timeslice);

CREATE INDEX ix_specific_feature_timeslice_feature_id
ON specific_feature_timeslice (feature_id);

Each such derived table has its own function to select the row current at the specified time:

CREATE FUNCTION specific_feature_asof(effective_time timestamp)
RETURNS SETOF specific_feature_timeslice
AS $BODY$
    SELECT candidate.*
    FROM specific_feature_timeslice candidate
    WHERE ($1, '0'::interval) OVERLAPS (candidate.valid_time_begin, COALESCE(candidate.valid_time_end, 'infinity'::timestamp))
        AND NOT EXISTS
        (
            SELECT *
            FROM specific_feature_timeslice better
            WHERE ($1, '0'::interval) OVERLAPS (better.valid_time_begin, COALESCE(better.valid_time_end, 'infinity'::timestamp))
                AND better.feature_id = candidate.feature_id AND better.timeslice_id != candidate.timeslice_id AND better.sequence_number > candidate.sequence_number
        )
$BODY$ LANGUAGE SQL STABLE;

I auto-generate these functions, of course - they are the same except for the table name. The typical query then becomes:

SELECT *
FROM specific_feature_asof('2011-09-30')
WHERE name = 'SOMETHING'

and the query plan looks like this:

Nested Loop Anti Join  (cost=0.00..412.84 rows=3 width=177) (actual time=0.044..7.038 rows=10 loops=1)
  Join Filter: (((better.timeslice_id)::integer <> (candidate.timeslice_id)::integer) AND ((better.sequence_number)::smallint > (candidate.sequence_number)::smallint))
  ->  Seq Scan on specific_feature_timeslice candidate  (cost=0.00..379.66 rows=3 width=177) (actual time=0.018..6.688 rows=10 loops=1)
        Filter: (((name)::text = 'SOMETHING'::text) AND overlaps(('2011-09-30 00:00:00'::timestamp without time zone)::timestamp without time zone, (('2011-09-30 00:00:00'::timestamp without time zone)::timestamp without time zone + '00:00:00'::interval), (valid_time_begin)::timestamp without time zone, COALESCE((valid_time_end)::timestamp without time zone, 'infinity'::timestamp without time zone)))
  ->  Index Scan using ix_specific_feature_timeslice_feature_id on specific_feature_timeslice better  (cost=0.00..8.28 rows=1 width=14) (actual time=0.008..0.011 rows=1 loops=10)
        Index Cond: ((feature_id)::integer = (candidate.feature_id)::integer)
        Filter: overlaps(('2011-09-30 00:00:00'::timestamp without time zone)::timestamp without time zone, (('2011-09-30 00:00:00'::timestamp without time zone)::timestamp without time zone + '00:00:00'::interval), (valid_time_begin)::timestamp without time zone, COALESCE((valid_time_end)::timestamp without time zone, 'infinity'::timestamp without time zone))
Total runtime: 7.150 ms

The performance difference is pretty dramatic: a simple select like the query above takes 30-60 ms. Joining two such functions takes it up to 300-400 ms, which is a bit more than I'd expect, but still quite acceptable.

With these changes I think it's no longer necessary to normalise feature_timeslice, ie. extract the valid begin/end time into a separate table, so I haven't done this.

Sign up to request clarification or add additional context in comments.

Comments

1
+100

First normalize your entities. Your setup might look like this:

CREATE TABLE feature
( feature_id int primary key,
  name text
  -- Some other columns
);

CREATE TABLE timeslice
( timeslice_id int primary key,
  valid_begin timestamp NOT NULL,
  valid_end timestamp
  -- Some other columns?
);

CREATE TABLE feature_timeslice
( feature_id int references feature (feature_id),
  timeslice_id int references timeslice (timeslice_id),
  sequence_number smallint,             -- guess it should live here?
  -- Some other columns?
  CONSTRAINT pk_feature_timeslice PRIMARY KEY (feature_id, timeslice_id)
);

Then, try combining the two SELECTs in one. Thereby the selectivity of the feature can take effect first. IOW, get rid of the view!

SELECT DISTINCT ON (1) ft.feature_id, first_value(ft.timeslice_id) OVER (PARTITION BY ft.feature_id ORDER BY ft.sequence_number DESC, ft.timeslice_id DESC) AS timeslice_id 
  FROM feature f
  JOIN feature_timeslice ft USING (feature_id)
  JOIN timeslice t USING (timeslice_id)
 WHERE f.name = 'SOMETHING'
AND t.valid_begin <= now()::timestamp
AND (t.valid_end >= now()::timestamp OR t.valid_end IS NULL);

If the feature is as selective as you implied (max. 10 timeslices per feature), then there is not much use for indexes on valid_begin or sequence_number.
Index on feature.name might help, though!
The most prominent feature here is to combine DISTINCT with a WINDOW function.

14 Comments

That is the normalisation I had in mind in my first comment. Now the timeslice_id surrogate makes sense (I once did something similar where the number of unique {begin,end} slices was only one percent of the original, and it certainly helped). The sequence_number still stinks...
@wildplasser: Yeah, the sequence_number is highly suspicious.
You're right, if I could just combine the two SELECTs into one it would work, but it's impractical to do this for real queries. See my edit of the original question. Thanks again!
Do you have adequate indices on the "outside" of the query ? Where is the time spent? Does it still need a sort step or nested loop ? Maybe you could add the relevant parts of the new model && indices to your OP?
@EMP: I have rescanned all comments, but it remains unclear: do you have an index on specific_feature.name? This would undoubtedly speed up a query with sf.name = 'SOMETHING and might convince the query planner to start at the right end.
|
1

You have a normalisation problem.

  • timeslice_id is a surrogate key.
  • (feature_id, sequence_number} are a candidate key
  • (feature_id, valid_time_begin ( valid_time_end) ) is also a candidate key.

You are misusing the windowing function, just to pick the candidate with rank=1. A self-join is probably cheaper.

EDIT:

CREATE index feature_timeslice_alt2 ON feature_timeslice
  ( feature_id,valid_time_begin);
CREATE UNIQUE index feature_timeslice_alt ON feature_timeslice
  ( feature_id,sequence_number);


CREATE VIEW feature_timeslice_id_encore AS
   SELECT timeslice_id FROM feature_timeslice t0
   WHERE (current_timestamp AT TIME ZONE 'UTC', '0'::interval)
          OVERLAPS (t0.valid_time_begin, COALESCE(t0.valid_time_end, 'infinity'::timestamp))
   AND NOT EXISTS ( 
      SELECT timeslice_id FROM feature_timeslice t1
      WHERE (current_timestamp AT TIME ZONE 'UTC', '0'::interval)
             OVERLAPS (t1.valid_time_begin, COALESCE(t1.valid_time_end, 'infinity'::timestamp))
      -- EDIT: forgot this
      AND t1.feature_id = t0.feature_id
      AND t1.sequence_number < t0.sequence_number
  );      

EDIT: the resulting query plan:

------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Hash Anti Join  (cost=9090.62..18428.34 rows=45971 width=4) (actual time=110.053..222.897 rows=9030 loops=1)
   Hash Cond: (t0.feature_id = t1.feature_id)
   Join Filter: (t1.sequence_number < t0.sequence_number)
   ->  Seq Scan on feature_timeslice t0  (cost=0.00..8228.67 rows=68956 width=12) (actual time=0.031..106.646 rows=9030 loops=1)
         Filter: "overlaps"(timezone('UTC'::text, now()), (timezone('UTC'::text, now()) + '00:00:00'::interval), valid_time_begin, COALESCE(valid_time_end, 'infinity'::timestamp without time zone))
   ->  Hash  (cost=8228.67..8228.67 rows=68956 width=8) (actual time=109.979..109.979 rows=9030 loops=1)
         Buckets: 8192  Batches: 1  Memory Usage: 353kB
         ->  Seq Scan on feature_timeslice t1  (cost=0.00..8228.67 rows=68956 width=8) (actual time=0.016..106.995 rows=9030 loops=1)
               Filter: "overlaps"(timezone('UTC'::text, now()), (timezone('UTC'::text, now()) + '00:00:00'::interval), valid_time_begin, COALESCE(valid_time_end, 'infinity'::timestamp without time zone))
 Total runtime: 223.488 ms

The query plan for the OP query was similar to his's, and had a "Total runtime: 1404.092 ms ". (but it will probably scale worse, cause of the sort step)

6 Comments

Are you suggesting that something is wrong with a surrogate key?
No, I am suggesting that this table has more than enough candidate keys.
If "many other tables .. are joined to it on timeslice_id", then a surrogate key is most likely more efficient than multi-column natural keys.
@Erwin Brandstetter: If the multi-column natural key carries all the information you need, then you don't need a join. This happens much more often than I expected it to, and it can lead to staggering speedups. (But doesn't always. You have to test.)
In the real DB neither (feature_id, sequence_number) nor (feature_id, valid_time_begin/end) are candidate keys, because it's a bit more complex than I've described in the question. However, your idea of using "WHERE NOT EXISTS (better candidate time slice)" might still work and yes, if I can avoid the window function, it may improve things considerably. I'll definitely try that out, thanks!
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.