Skip to content

Queue table: the index that wins at 30 seconds and loses at 15 minutes

Keyboard: ← → to move, F for full screen, O for overview.

Tan with a laptop

PostgreSQL · Measurement

Queue table: the index that wins at 30 s, loses at 15 min

Same table, Postgres 17, three measurements.

Muhammet Şafak

Single machine, Postgres 17, synthetic bench

Nearly equal at 30 seconds. Apart at 15 minutes.

Partial and composite index: 11,537 vs 10,795 tps at thirty seconds. Under fifteen minutes of sustained load, composite missed the target.

Postgres does not know this distinction

Two sets in one table

Archive · doneLive set · pending
Who looksNobody queries itWorkers search it, claims lock it
SizeGrows forever unless deletedStays roughly constant
Measuredn_live_tup 10,005,7265,000 rows
PostgresLive tuples: the index, statistics and vacuum threshold all count themSame treatment; the two are not told apart

Section 01

Thirty seconds

Four strategies, three dead-row tiers. The live set is 5,000 rows at every tier.

SKIP LOCKED · 8 clients · 30 s · median of three runs

Three tiers, four strategies

Claim via FOR UPDATE SKIP LOCKED; 8 clients, 30 seconds, median of three runs. The live set is 5,000 rows at every tier.
Strategy100k dead rows1M10MIndex (10M)
no index2,001 tps247 tps7 tps—
(status)6,499 tps6,417 tps6,426 tps66.1 MB
(status, created_at)12,414 tps11,202 tps10,795 tps310.4 MB
partial (created_at) WHERE pending13,041 tps11,707 tps11,537 tps7.6 MB

No index

A queue without an index vanishes.

It does not just degrade with dead rows: 2,001 down to 7 tps. A sequential scan can find the 5,000 rows among 10 million only seven times a second.

41×

The real difference is size

smaller index

At 10 million dead rows, partial is 7.6 MB and composite is 310.4 MB. The throughput gap is only 6.9%: the reason to pick it is not speed but that the index does not grow with the table.

As dead rows grow

The partial index does not grow with the table

index size, MB

  • composite · 100k11.2
  • composite · 1M40.3
  • composite · 10M310.4
  • partial · 100k6.5
  • partial · 1M7.6
  • partial · 10M7.6
pg_relation_size, bench/run.sh

Section 02

Planner

Same table, same index, same query. The only thing that changes is the plan type.

10M dead rows · same query, parameterized

The generic plan cliff

Same table, same index, same query. The partial index was never scanned in the generic plan: Limit, LockRows, Sort, Seq Scan.
Index · plantpsLatencyPlan
partial · custom11,7520.68 msIndex Scan
partial · generic71,113 msSort → Seq Scan
composite · custom11,1050.72 msIndex Scan
composite · generic11,4160.70 msIndex Scan

Why

Roughly 1,635 times

  • NoteA generic plan is built without knowing what $1 is. It cannot prove that status = $1 means 'pending', so it rules out the partial index.
  • TipComposite is unaffected: it has no predicate to prove, and status is just a column inside the index.
  • WarningFalling off the cliff took force_generic_plan. That is not a production setting.

Single session, default auto mode

Forty runs, forty custom plans

  1. First five runs: all three on custom plandone
  2. Sixth: composite switches to genericdone
  3. Partial: 40 of 40 customdone
  4. (status): 40 of 40 customdone

Correction

It did not switch under these conditions

  • CorrectionIn my earlier post I said it 'degrades as it warms up'. When measured, that was wrong: all forty of forty runs stayed on the custom plan.
  • WhyMost likely explanation: the generic plan, which cannot use the partial index, comes out with a high estimated cost. Plan costs were not recorded.
  • CostPartial is re-planned on every run. At this scale the difference could not be measured: 0.20 ms vs 0.21 ms.

Section 03

Now, 15 minutes

Sustained churn: 2,000 jobs per second, default autovacuum settings.

Partial · ~305×

The live set is constant, the index grows

index size, MB

The live set is constant, the index grows (index size, MB): 0 s: 0.125, 135 s: 5.9, 286 s: 12.4, 436 s: 18.8, 587 s: 25.3, 738 s: 31.7, 888 s: 38.2.

pg_relation_size, sampled every 15 seconds, bench/endurance.sh. The pending→done transition leaves dead entries until vacuum.

Composite · 42%, +126 MB

Composite bloated by a smaller ratio, but more MB

index size, MB

Composite bloated by a smaller ratio, but more MB (index size, MB): 0 s: 301, 135 s: 321, 286 s: 344, 436 s: 366, 587 s: 388, 738 s: 409, 888 s: 427.

pg_relation_size, sampled every 15 seconds, bench/endurance.sh

Target 2,000 jobs/s · second 900

A bloated index cannot carry the sustained load

compositepartial
Start2,000 tps · 0.52 ms2,024 tps · 1.7 ms
Second 9001,204 tps · 61,533 ms2,016 tps · 4,906 ms
Pending jobs5,000 → 126,0245,003 → 14,364
ResultMissed the targetMet the target

While reading

Two notes

  • NoteLatency includes pgbench's schedule lag: the debt of a client that cannot keep up with the target rate is booked here.
  • NoteThe gap opens under sustained load: an index bloated to 427 MB no longer fits in memory, and every scan goes to disk.

The threshold grows with the table

Autovacuum never came

  • 1.75Million dead rows1,753,949 in 15 minutes
  • 0AutovacuumDid not run even once
  • ≈2Threshold, million rows50 + 0.2 × 10,005,000 ≈ 2,001,050
  • 17Minutes, estimated intervalSpecific to this workload; vacuum cost was not measured

Recommendation

Decouple the threshold from the table

I did not measure this setting. 50,000 is a starting point; tune it to your own throughput.

jobs.sql
ALTER TABLE jobs SET (  autovacuum_vacuum_scale_factor = 0,  autovacuum_vacuum_threshold    = 50000);CREATE INDEX CONCURRENTLY  jobs_pending_created_at  ON jobs (created_at)WHERE status = 'pending';
Tan giving a thumbs-up

Result

The claims in the Sor Bakalım answer, once measured

  • A partial index stays small: 7.6 MB at 10M (done)
  • Beats the (status) index: 11,537 vs 6,426 tps (done)
  • The ORDER BY column should go into the index (done)
  • With a parameter, the planner may not pick the partial: true, but narrow (done)
  • Churn and bloat: not measured in the first post, measured afterwards (done)
Tan smiling

Find the condition

github.com/muhammetsafak/pg-queue-bench

The value of measuring a piece of advice is not confirming it. It is finding the condition under which a correct sentence flips.

Share, embed, download