The issue

We have a query like

  FROM article
  JOIN reservation ON a_id = r_article_id 
 WHERE r_last_modified < now() - '8 weeks'::interval 
   AND r_group_id = 1 
   AND r_status = 'OPEN';

As it runs into a timeout (after 10 minutes) more often than not, I decided to investigate the issue.

The EXPLAIN (ANALYZE, BUFFERS) output looks like this:

 Aggregate  (cost=264775.48..264775.49 rows=1 width=0) (actual time=238960.290..238960.291 rows=1 loops=1)
   Buffers: shared hit=200483 read=64361 dirtied=666 written=8, temp read=3631 written=3617
   I/O Timings: read=169806.955 write=0.154
   ->  Hash Join  (cost=52413.67..264647.65 rows=51130 width=0) (actual time=1845.483..238957.588 rows=21644 loops=1)
         Hash Cond: (reservation.r_article_id = article.a_id)
         Buffers: shared hit=200483 read=64361 dirtied=666 written=8, temp read=3631 written=3617
         I/O Timings: read=169806.955 write=0.154
         ->  Index Scan using reservation_r_article_id_idx1 on reservation  (cost=0.42..205458.72 rows=51130 width=4) (actual time=34.035..237000.197 rows=21644 loops=1)
               Filter: ((r_group_id = 1) AND (r_status = 'OPEN') AND (r_last_modified < (now() - '56 days'::interval)))
               Rows Removed by Filter: 151549
               Buffers: shared hit=200193 read=48853 dirtied=450 written=8
               I/O Timings: read=168614.105 write=0.154
         ->  Hash  (cost=29662.22..29662.22 rows=1386722 width=4) (actual time=1749.392..1749.392 rows=1386814 loops=1)
               Buckets: 32768  Batches: 8  Memory Usage: 6109kB
               Buffers: shared hit=287 read=15508 dirtied=216, temp written=3551
               I/O Timings: read=1192.850
               ->  Seq Scan on article  (cost=0.00..29662.22 rows=1386722 width=4) (actual time=23.822..1439.310 rows=1386814 loops=1)
                     Buffers: shared hit=287 read=15508 dirtied=216
                     I/O Timings: read=1192.850
 Total runtime: 238961.812 ms

The bottleneck node is obviously the index scan. So let's see the index definition:

CREATE INDEX reservation_r_article_id_idx1 
    ON reservation USING btree (r_article_id)

Sizes and row numbers

It's size (reported by \di+ or by visiting the physical file) is 36 MB. As the reservations usually spend only a relatively short time in all the statuses not listed above, there is a lot of updating happening, so the index is quite bloated (around 24 MB is wasted here) - still, the size is relatively small.

The reservation table is about 3.8 GB in size, containing around 40 million rows. The number of reservations that are not yet closed is about 170,000 (the exact number is reported in the index scan node above).

Now the surprise: the index scan reports fetching a huge amounts of buffers (that is, 8 kb pages):

Buffers: shared hit=200193 read=48853 dirtied=450 written=8

The numbers read from cache and disk (or the OS cache) add up to 1.9 GB!

Worst-case scenario

On the other hand, the worst-case scenario, when every tuple sits on a different page of the table, would account for visiting (21644 + 151549) + 4608 pages (total rows fetched from the table plus the index page number from the physical size). This is still only under 180,000 - far below the observed nearly 250,000.

Interesting (and maybe important) is that the disk read speed is around 2.2 MB/s, which is quite normal, I guess.

So what?

Does anyone have an idea about where this discrepancy can come from?

Note: To be clear, we have ideas what to improve/change here, but I really would like to understand the numbers I got - this is what the question is about.

Update: checking the effect of caching or microvacuuming

Based on jjanes's answer, I've checked what happens when I re-run the exact same query straight away. The number of affected buffers does not really change. (To do this, I simplified the query to its bare minimum that still shows the issue.) This is what I see from the first run:

 Aggregate  (cost=240541.52..240541.53 rows=1 width=0) (actual time=97703.589..97703.590 rows=1 loops=1)
   Buffers: shared hit=413981 read=46977 dirtied=56
   I/O Timings: read=96807.444
   ->  Index Scan using reservation_r_article_id_idx1 on reservation  (cost=0.42..240380.54 rows=64392 width=0) (actual time=13.757..97698.461 rows=19236 loops=1)
         Filter: ((r_group_id = 1) AND (r_status = 'OPEN') AND (r_last_modified < (now() - '56 days'::interval)))
         Rows Removed by Filter: 232481
         Buffers: shared hit=413981 read=46977 dirtied=56
         I/O Timings: read=96807.444
 Total runtime: 97703.694 ms

and after the second one:

 Aggregate  (cost=240543.26..240543.27 rows=1 width=0) (actual time=388.123..388.124 rows=1 loops=1)
   Buffers: shared hit=460990
   ->  Index Scan using reservation_r_article_id_idx1 on reservation  (cost=0.42..240382.28 rows=64392 width=0) (actual time=0.032..385.900 rows=19236 loops=1)
         Filter: ((r_group_id = 1) AND (r_status = 'OPEN') AND (r_last_modified < (now() - '56 days'::interval)))
         Rows Removed by Filter: 232584
         Buffers: shared hit=460990
 Total runtime: 388.187 ms
  • 1
    Probably irrelevant but do you need the join to article? Seems like all the columns involved are from the reservation table and (assuming) there is an FK, the result should be the same. Oct 2, 2015 at 13:58
  • That's a very good question. And you are right, it's not needed - this is a query used in monitoring by an other team. Still, at least looking at the query plan, everything else is just decoration to that nasty index scan :) Oct 2, 2015 at 14:01
  • 1
    Let me add that removing the join doesn't make a huge difference - the overblown index scan stays there. Oct 2, 2015 at 14:16
  • Toast table access? Although I doubt any of the columns you show there would be toasted. If you have an idle clone of the database for testing purposes, you could run pg_stat_reset() on it, and then run the query, and then look in pg_statio_user_tables to see where it attributes the blocks.
    – jjanes
    Oct 2, 2015 at 16:07

2 Answers 2


I think that the key here is the lot of updates, and the bloat on the index.

The index contains pointers to rows in the table that are no longer 'live'. These are the old versions of updated rows. The old row versions are kept around for a while, to satisfy queries with an old snapshot, and then kept around for a while more because no one want to do the work of removing them more often than necessary.

When scanning the index, it has to go visit these rows, and then notices they are no longer visible, so ignores them. The explain (analyze,buffers) statement does not report on this activity explicitly, except through the counting of the buffers read/hit in the process of inspecting these rows.

There is some "microvacuum" code for btrees, such that when the scan gets back to the index again, it remembers that the pointer it chased down was no longer live, and marks it as dead in the index. That way the next similar query that gets run doesn't need to chase it down again. So if you run the exact same query again, you will probably see the buffer accesses drop closer to what you predicted.

You can also VACUUM the table more often, that will clean the dead tuples out of the table itself, not just out of the partial index. In general, tables with a high-turn-over partial index are likely to benefit from more aggressive vacuum than the default level.

  • Please see my edit - to me, it looks like caching, not microvacuuming. Oct 5, 2015 at 8:17
  • Your new numbers are much different than your old ones (roughly double), so it is hard to interpret what they mean without also seeing the new numbers for actual rows and rows filtered for the index scan.
    – jjanes
    Oct 5, 2015 at 16:29
  • Added the full plans as they look today. The affected buffer number grew a lot since Friday, as did the row counts. Oct 6, 2015 at 9:40
  • Do you have long-lived transactions hanging around? If so, it is possible that the index scan is still tracking down rows that are not visible to it (which causes the extra buffer hits), but it can't microvacuum them away yet because they might be visible to someone else with an older snapshot.
    – jjanes
    Oct 6, 2015 at 15:59
  • I have none - the typical transaction takes less than a second. Occasionally a few seconds, but not longer. Oct 6, 2015 at 16:13

I fixed this issue by following 2 steps:

  1. Killing all long-running transactions and then running vacuuming.
  2. By adding a multi-column index. In your last query plan, you are fetching 19236+232584 rows. 12 times extra from what you actually need. Filtering is not free and you've to fetch the actual row before throwing it away. Because of that, you end up reading a lot of buffers. Try to make your number of rows filtered as low as possible. Possibly 0.

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.