At work we've been running into a sort of mysterious problem. Every few hours, one of our tables (tasks
) sees query latency grow (caused by CPU use) followed by a burst of LWLock:BufferContent
, then it drops back down to normal. Here's a screenshot of RDS's performance insights:
We've done a ton of work to try to mitigate this, and the work has resulted in the incidents being (anecdotally) less frequent and less severe. However, the problem has not gone away.
First we noticed that there were some queries that were not hitting indexes. I've spent time making sure that all of our queries for this table perform index and index-only scans. Queries tend to look like this:
SELECT COUNT(1) FROM tasks WHERE status = 'PENDING' AND NOT deleted
and we have corresponding indexes on status
with a condition status = 'PENDING' AND deleted = FALSE
. EXPLAIN ANALYZE
shows that the index is being used correctly.
We did discover that some queries which specify a condition on our userId
column had a bad query plan when the user has a huge number of records. This was remedied with fixes to the query and index improvements. As best we can tell, there are no queries which have a bad query plan for any values (yet the problem persists).
During the spikes, there is no increase in incoming load. Before, during, and after the incident, the queries which are affected show the same number of "Calls/sec" in the RDS performance insights, and our client application shows a stable QPS, so this isn't caused by an increase in queries.
I did observe that the EXPLAIN ANALYZE
results for these queries does show an increase in heap fetches during these spikes, even for index-only scans. I read that the heap is still hit during index scans because the visibility map might indicate that pages containing tuples referenced by the index might not be visible. This seemed to be a strong signal: PENDING
tasks (as shown in the query above) are often "hot" rows that receive many updates, so it makes sense that the visibility map for pages containing those rows would require fetches.
To compensate, the autovacuum settings were adjusted to cause much more frequent vacuums. We now see autovacuums every few hours, but the problem has not gone away. I've seen these spikes only 20 minutes after an autovacuum for the table completes. Moreover, the number of heap fetches reported by EXPLAIN ANALYZE
during the spike can be 1-2 orders of magnitude greater than the number of PENDING
task records: the number rarely exceeds 200 total, and we can see many thousands of heap fetches on index-only scans for indexes that only contain PENDING
records.
During debugging, I noticed that the query planner does tend to adjust which indexes it queries over time, even when load remains ~constant. It sometimes chooses indexes which allow for index scans, but not index-only scans. For example, it might choose an index on status
alone and ignores an index on status
that's conditional on status = 'PENDING' and not deleted
, even when the latter perfectly satisfies the query. I adjusted the seq_page_cost
and random_page_cost
settings to both by 1.0
instead of the Postgres defaults (1 and 4, respectively), which should direct Postgres to prefer index scans. Unfortunately the issue has persisted (though perhaps with less frequency). The screenshot above is from after the settings adjustment.
Some additional information:
- The issue only affects this one table (or I should say, the queries for this one table).
- All queries on the table during the spike are affected, not a single query.
At this point, I'm almost out of ideas. For reference, we're on Postgres 14.10 running on db.m5.16xlarge
with a GP2 SSD volume. My suspicion is that this our Postgres version just needs an update (planned work) but this isn't a satisfying solution.
Something I'm a bit stumped by is why the resource use causing the latency is CPU. You can see in the screenshot above the increase in green (CPU). In essentially every query, there should be very very few records scanned. All of the queries are count aggregations, and the conditions on the queries are all simple equality. Here's one of the affected query outputs when there is not a spike:
Aggregate (cost=46.02..46.03 rows=1 width=8) (actual time=0.362..0.362 rows=1 loops=1)
" -> Index Only Scan using ""tasks_globalPending"" on tasks (cost=0.25..37.54 rows=3393 width=0) (actual time=0.239..0.353 rows=128 loops=1)"
Heap Fetches: 178
Planning Time: 0.161 ms
Execution Time: 0.377 ms
Very fast! Arguably extremely efficient (though I'd prefer zero heap fetches). And for a COUNT()
that ~never returns a value greater than 300, it's mystifying how this can go from 0.4ms to 10-20ms per query and burn that time on CPU use. What is it doing?