A whole network of websites went slow at once, and not one server was busy.
A network of recruitment sites felt overloaded all at once. Every server was idle. One city lookup was more than half of all database time. Here is how we found it and fixed it with a single online index.
In this article 8 sections
- Every site in a recruitment network slowed down together while the servers sat idle, and the instinct was to buy bigger ones.
- Every tier was idle except one: the load balancers and web servers were barely working. The whole farm was waiting on the database, and the database was waiting on one query.
- That query, a simple city lookup, was more than half of all database time, scanning a whole table on every call to return a single row.
- One online index, applied without interrupting the sites, turned the scan into a seek. No bigger servers required.
A network of recruitment sites went soft at the same time. Not one page, not one site: all of them, together, the kind of farm-wide fog that makes an operator assume the servers are drowning and it is time to buy bigger ones. That assumption is expensive, it is common, and in this case it was wrong. Every machine in the farm was bored. The whole farm was waiting on the database, and the database was waiting on a single query.
Every tier was idle except one
On a farm-wide slowdown, read the utilisation of every tier before anything else, and see which one is actually pinned. The load balancers were idling. The web servers were bored. Nothing in the request path was working hard enough to explain a slow site.
- Load balancers
- idle
- Web servers
- barely working
- Database
- waiting on one query
This is the tell that ends the capacity argument before it starts. If every tier is idle except the database, a bigger web server buys you a faster idle machine. The constraint was never compute, and no hardware order was going to fix it.
The expensive answer was to buy more machines
| The instinct | The measurement |
|---|---|
| The whole farm is slow, so the servers are overloaded. Buy bigger ones. | Every tier except the database was idle. Capacity was never the constraint. |
| WHAT THE DATA SAIDIt has to be a hardware problem. | The hardware was healthy and provably so: load balancers and web servers barely working. |
| Spread the load across more machines. | One query was more than half of all database time. More machines would run it more often, still scanning. |
More hardware would have scaled the waste, not removed it. The only honest next step was to find out what the database was actually spending its time on.
We aggregated the query history by fingerprint
An application rarely writes the same query the same way twice. The same lookup shows up parameterised several different ways, so counting raw query text hides the offender in the noise. The fix is to aggregate by a fingerprint that survives that inconsistency, then rank every statement by its share of total database time. We ran it across the full query history.
-- reconstructed, de-identified rendering of cleared facts. not a real capture.
-- full query history, grouped by query_hash
-- (a fingerprint that survives inconsistent parameterisation)
SELECT TOP 5
query_hash,
SUM(execution_count) AS calls,
SUM(total_logical_reads) AS pages_read,
SUM(total_elapsed_time) * 100.0
/ SUM(SUM(total_elapsed_time)) OVER () AS pct_of_db_time
FROM sys.dm_exec_query_stats
GROUP BY query_hash
ORDER BY pct_of_db_time DESC;One row stood out: more than half of everything the database did, spent on one statement. The method is deliberately boring, it does not guess, and it is the same every time.
One query was more than half of the database
The statement that fell out is the kind you would never look at twice. A city lookup, two columns, one filter.
-- reconstructed, de-identified. table/column names renamed from the real schema.
SELECT CityID, CityName
FROM Cities
WHERE RoutingPath = @p;
-- Execution plan: Clustered Index Scan (a full scan)
-- Returns: ONE row
-- Filtered column: RoutingPath --> no indexThe column it filtered on had no index. So every call scanned the whole table to hand back a single row. On its own that is a small cost; repeated on every page view across the whole network, it became the biggest thing the database did.
A one-row answer should read a couple of pages, not the whole table
“A one-row answer should read two or three pages, not hundreds.”
That sentence is the whole diagnosis, compressed to something you can say to the person who owns the code. Nothing else makes a single-row lookup read that much. Not slow disk, not a busy CPU, not a bad night. A one-row answer reading the whole table is a full scan, and a full scan on a filtered column is an unmistakable signature: the column needs an index. We did not need to argue about hardware, we just showed the reads.
One online index, from a scan to a seek
- A full scan of the table, for one row
- No index on the filtered column
- More than half of all database time
- An index seek, a couple of pages for the same row
- A small fraction of the work per call
- The index went in online, without interrupting the sites
-- reconstructed, de-identified. index name illustrative, on the renamed identifiers.
-- Index the column in the WHERE clause; cover the two columns it returns
CREATE NONCLUSTERED INDEX IX_Cities_RoutingPath
ON Cities (RoutingPath)
INCLUDE (CityID, CityName)
WITH (ONLINE = ON); -- applied without interrupting the site
-- New execution plan: Index SeekOne index, on the one column the application actually filtered on. It was applied online, without interrupting the sites, and the lookup that had dominated the database became one of the cheapest things it did.
The customer's engineer added to the diagnosis
The best part of this engagement was not our index. It was what happened when the customer's own database engineer joined in and added to the diagnosis: he brought a reader/writer blocking pattern from his side of the system. We folded his fix in with ours, and the two went in together.
“The customer's engineer left the call a collaborator.”
That is the difference between a support ticket and a diagnosis. When the finding is true and you show the receipts, both sides stop defending positions and start improving the platform together.
When every tier is idle except one, stop looking at capacity
The lesson is short and it is the opposite of the reflex. Farm-wide slowness with idle machines is not a capacity problem, it is a software question wearing a capacity costume. Here is what to check before you sign a hardware order.
Read every tier's utilization first
Load balancers, web, database. If everything is idle except one tier, capacity is not your problem and a bigger server will not fix it.
Aggregate query stats by fingerprint
Group your history by a hash that survives inconsistent parameterisation, so the same statement written several ways counts as one. The worst offender usually stands out by a wide margin.
Compare pages read to rows returned
A one-row answer that reads hundreds of pages is scanning, not seeking. That gap is the signature of a missing index.
Index the column the app actually filters on
Not every column. The one in the WHERE clause that runs millions of times. Apply it online, without interrupting the site.
Cache the tiny hot tables
Small, rarely-changing lookup tables belong in cache, not on the database's critical path for every page view.
Through all of it, the machine stayed calm. The load balancers idled, the web servers sat bored, and the fix was one index on the column the application filtered on. The database server was healthy; one statement inside it needed an index.
Straight answers
How can one query be more than half of a database's load?+
When a query runs often enough and does too much work each time, it dominates everything else. This one ran on every page view and scanned a whole table on every call to return a single row, because the column it filtered on had no index. Multiply a small waste by millions of calls and it becomes the whole database.
Why were the servers idle if the sites were slow?+
Because the bottleneck was inside the database, waiting on one query. The load balancers and web servers were barely working. The whole farm was waiting on the database, and the database was waiting on a lookup that should have been instant. Adding servers would only have added more idle machines.
How do you find the query that's actually the problem?+
You aggregate the full query history by a fingerprint that survives inconsistent parameterisation, then rank by share of total database time. The method is boring and it does not guess.
What is the signature of a missing index?+
A one-row answer that reads hundreds of pages. If a query returns one row and the plan shows a scan over the whole table, the column it filters on is not indexed.
Did fixing it require downtime?+
No. The index went in online, applied without interrupting the sites, and turned the scan into a seek.
Who fixed it, you or the customer?+
Both, together. We found the lookup and added the index; the customer's own database engineer joined the diagnosis and brought a reader/writer blocking pattern from his side. We folded his fix in with ours.
Slow site, idle servers? We will find the real cause.
Talk to an engineer who reads query plans, not just dashboards, about what is actually slowing your platform down.
Talk to an engineerThe instinct on a slow farm is to buy your way out of it. The honest answer is almost always cheaper: read every tier, find the one that is pinned, and go read its queries.
You’ve reached the end.
Put the reading to work
What are
you running?
Tell us about the workload, traffic and recovery requirements. Start with the server builder, or talk through the details with an engineer.
Written by the engineers who build and run the infrastructure it describes.Talk to an engineer
