All posts
Database CostJun 25, 20267 min read

The Overprovisioning Tax: Your Biggest Cloud DB Expense is Ignorance

You're paying for a db.r6g.16xlarge but your p95 CPU is 12%. This isn't a safety net, it's a multi-thousand dollar monthly tax on technical debt. Here's how to stop paying it.

VS

Venkat Sakamuri

DeepSQL R&D · Ex Oracle Query Engine Team · YC & CMU

Line art illustration of a tiny workload inside a vast, empty database instance container.

TL;DR

  • Your massive, under-utilized RDS instance isn't a safety net; it's a permanent tax on your P&L, often costing you 40-70% more than necessary.
  • Standard observability (CloudWatch CPU) lies by omission. Low CPU is meaningless if your workload's memory demands cause constant disk I/O, negating the power of the expensive instance.
  • Right-sizing isn't just picking a smaller instance. It's co-optimizing the workload (queries, indexes) with the instance to find a new, cheaper equilibrium.

I just got off a call with a new customer. They're running a critical Postgres service on an RDS db.r6g.16xlarge. That's 64 vCPUs and 512 GiB of RAM. Their bill was astronomical, north of $10k/month just for this one instance. They brought us in to diagnose intermittent but severe query latency spikes.

My first look at their CloudWatch dashboard showed what I expected: p95 CPU utilization was 11%. Their provisioned IOPS were 3x their actual usage. Classic overprovisioning. The story is always the same: a crisis during launch or a Black Friday panic led to throwing the biggest instance at the problem. The fire was put out, the war room disbanded, and nobody ever looked back.

That decision, made in a moment of panic two years ago, was now a recurring, silent tax on their engineering budget. This isn't a safety net. It's paying a premium to mask deeper problems.

The Illusion of Safety

Engineers overprovision out of fear. Fear of the pager, fear of a marketing campaign overwhelming the database, fear of being the person who approved a too-small instance. So they click the 16xlarge button and write it off as insurance.

Here’s the hard truth: that headroom isn't making you safer. It's just enabling bad behavior. A massive instance will happily brute-force its way through a table scan on a 500-million-row table that's missing a critical index. It will chew through CPU cycles to perform a nested loop join that a better plan would avoid entirely. You don't get an alert. The only signal is the quiet debit of $10,000 from your company's bank account every month.

You're paying 100% of the cost for that 16xlarge to paper over a query that could be fixed in an hour. Your expensive instance becomes a form of technical debt payment, not a production asset.

Why Your CloudWatch Dashboard Lies to You

Everyone looks at the CPU graph. It's the simplest, most prominent metric. And it's almost always the wrong place to start. A database's performance is rarely defined by its CPU in isolation. The real battleground is memory.

Your database server, whether it's Postgres with its shared_buffers or MySQL with the InnoDB Buffer Pool, is fundamentally a game of caching. The goal is to keep the working set of your data—the hot tables and indexes you access constantly—in RAM. Accessing data from RAM is nanoseconds. Accessing it from an EBS volume, even a fast one, is milliseconds. That's a 1,000,000x difference.

When your working set doesn't fit in RAM, the database is forced to perform physical I/O. It reads data blocks from disk into the buffer pool, displacing other, potentially useful data. This is called cache churn. Your CloudWatch graph might show 15% CPU, but the database is internally thrashing, spending its time waiting on disk.

How do you see this? Use the tools the database gives you. Don't guess.

For Postgres, run EXPLAIN (ANALYZE, BUFFERS) on a slow query:

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM event_log WHERE user_id = 'a1b2c3d4-...' AND event_time > now() - '1 day'::interval;

-- Hypothetical Output Snippet
-> Index Scan using event_log_user_id_event_time_idx on event_log (cost=0.57..85.34 rows=142 width=102)
   (actual time=0.051..12.543 rows=150 loops=1)
   Buffers: shared hit=215, shared read=180

Look at that Buffers line. shared hit means a data block was found in RAM. shared read means the database had to go to disk to fetch it. In this case, 180 blocks had to be read from slow storage. The query itself might only take a few milliseconds, but when thousands of these are running, the cumulative effect of those shared read operations chokes your performance. The problem isn't CPU; it's that your working set (or at least the portion needed by these queries) doesn't fit in your configured buffer pool, forcing constant, slow disk reads.

Seeing high shared read counts across your workload is a direct signal that your instance, however large, lacks sufficient RAM for its workload, or your queries are unnecessarily scanning cold data.

The Right-Sizing Fallacy

The typical response to an under-utilized instance is still wrong. Engineers will eyeball the p95 CPU, see it's 12%, and say, "Let's try the 8xlarge instead of the 16xlarge." This is a blind cost-cutting exercise, not an engineering one.

It completely ignores the workload's true needs. Dropping the instance size reduces RAM. If your 16xlarge with 512 GiB of RAM was already struggling to hold your 600 GiB working set, moving to an 8xlarge with 256 GiB will be catastrophic. Your cache hit ratio will plummet, I/O will skyrocket, and the CPU you do have will be spent waiting on disk.

True right-sizing is a two-part process:

  1. Characterize the Workload: Use pg_stat_statements in Postgres or the Performance Schema/Sys Schema in MySQL. What are your top 10 queries by total execution time? By I/O? What is their buffer cache hit rate? This tells you what the database is actually doing.

  2. Optimize the Workload: Maybe the reason your working set is 600 GiB is because of a few terrible queries that perform full table scans. Adding one or two indexes could reduce the I/O for those queries by 99%, effectively shrinking the active working set to something that fits comfortably in 100 GiB of RAM. Suddenly, that 8xlarge isn't just feasible, it's oversized. Maybe a 4xlarge is the right answer.

This is co-optimization. You don't just shrink the container. You refactor the contents to require a smaller container. You look at things like Postgres's autovacuum behavior, which is heavily I/O dependent, and realize that by adding an index and reducing query I/O, you also reduce the cleanup burden, creating a virtuous cycle.

Stop guessing. Start measuring what matters: not just instance metrics, but query-level performance statistics. Your goal isn't to find an instance that's 20% utilized instead of 10%. Your goal is to find the smallest, cheapest instance that can serve your optimized workload with an acceptable cache hit ratio and I/O profile.

What DeepSQL does about this

DeepSQL was built to solve this exact co-optimization problem. We connect to your production database, analyzing workload history from sources like pg_stat_statements to build a deep profile of your query patterns, working set size, and I/O demands. Our query engine simulation models the effect of instance changes—like reduced RAM or CPU—in conjunction with specific optimizations like adding an index or changing a parameter. The result is not a guess, but a concrete plan: 'Downsize from db.m6g.8xlarge to db.r6g.4xlarge, and apply these two indexes. We have a 95% confidence this will reduce your costs by 45% while keeping p99 latency below your 50ms SLO.'

Diagram of the database memory hierarchy, showing fast RAM cache versus slow disk access.