Case 14: The "Invisible" Slowdown: Bloat from UPDATEs and DELETEs
This time, we won't learn a new SQL pattern. Instead, we'll look at a deeper issue related to the physical "health" of the database over time: Table and Index Bloat.
The Story
- Table:
shopping_carts, a table that tracks user shopping carts. This table has a very high "churn" rate: users are constantly adding, removing (INSERT,DELETE), and updating items (UPDATE). - Size: The table might only have 500,000 active carts at any given moment, but over a month, tens of millions of
INSERT,UPDATE, andDELETEoperations have occurred. - The Problem: A simple query to find abandoned carts (e.g., not updated in the last day) starts to run surprisingly slowly, even though there is an index on the
updated_atcolumn.
The Cause of the Problem: MVCC and Bloat
PostgreSQL uses a mechanism called MVCC (Multi-Version Concurrency Control).
- When you
UPDATEa row, PostgreSQL does not overwrite the old data. Instead, it creates a new version of the row and marks the old version as "dead" (a dead tuple). - When you
DELETEa row, it simply marks that row as "dead."
Over time, the table and its indexes become filled with these "dead tuples." Although they are invisible to your queries, they still take up physical space on the disk. This phenomenon is called bloat.
The SQL Query and Execution Plan (BEFORE Maintenance)
sql
-- Get abandoned shopping carts
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, user_id
FROM shopping_carts
WHERE updated_at < NOW() - INTERVAL '1 day'
LIMIT 100;Even if only a few thousand carts match the condition, the execution plan might look like this:
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.57..150.50 rows=100 width=12) (actual time=25.12..28.45 rows=100 loops=1)
Buffers: shared hit=12500
-> Index Scan using idx_carts_updated_at on shopping_carts (cost=0.57..25000.75 rows=16500 width=12) (actual time=0.05..27.98 rows=100 loops=1)
Index Cond: (updated_at < (now() - '1 day'::interval))
Buffers: shared hit=12500
Planning Time: 0.15 ms
Execution Time: 28.55 msThe Bottleneck
- Look at this:
Buffers: shared hit=12500. This is the key. - The Problem: Even though the query only returned 100 rows, the database had to read 12,500 data blocks/pages from the index and the table. Why? Because the index and table have become "bloated" and "sparse" with dead tuples. The database has to read through a lot of empty or garbage-filled blocks to find the 100 valid rows of data. The actual I/O is much higher than it should be.
The Solution: Cleaning Up the Bloat ✅
Method 1:
VACUUM(Standard Cleanup)VACUUM shopping_carts;- This command scans the table and its indexes, marking the space occupied by dead tuples as reusable for new data. It does not shrink the file size on disk, but it helps future queries avoid reading those garbage blocks.
- PostgreSQL has an
autovacuumprocess that does this automatically, but for tables with extremely high churn, it sometimes needs to be run manually or have its settings tuned.
Method 2:
REINDEX(Rebuild the Index)REINDEX TABLE shopping_carts;- This command deletes and completely rebuilds all of the table's indexes from scratch. The new indexes will be compact, contain zero garbage, and have the most optimal structure. This is the direct solution for index bloat.
Method 3:
VACUUM FULL(The Strongest Solution, Use with Caution)VACUUM FULL shopping_carts;- This command creates a brand new copy of the table containing only the valid data, and then deletes the old file. It completely removes bloat from both the table and its indexes, and it shrinks the file size on disk.
- Warning:
VACUUM FULLrequires an exclusive lock on the entire table, making it unreadable and unwritable while it runs. It is very slow and should only be used when absolutely necessary and during a maintenance window.
The Execution Plan (AFTER REINDEX)
After running REINDEX, the index is now compact and efficient.
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.42..15.80 rows=100 width=12) (actual time=0.03..0.08 rows=100 loops=1)
Buffers: shared hit=55
-> Index Scan using idx_carts_updated_at on shopping_carts (cost=0.42..2350.50 rows=16500 width=12) (actual time=0.02..0.07 rows=100 loops=1)
Index Cond: (updated_at < (now() - '1 day'::interval))
Buffers: shared hit=55
Planning Time: 0.12 ms
Execution Time: 0.15 msAnalyzing the Result
- The Big Change: The number of
Buffersread dropped from 12,500 to just 55. The amount of I/O was reduced by over 200 times! - Performance Comparison:
- Execution Time: Dropped from 28.55 ms to just 0.15 ms.
Conclusion
- Database performance depends not only on query writing and indexes but also on the physical health of the data.
- For tables with a high frequency of
UPDATEs andDELETEs (high churn), bloat is an unavoidable problem that will silently slow down your system. - Ensure that
autovacuumis configured properly and consider performing a periodicREINDEXon your "hottest" tables to maintain optimal performance.