← Back to home WHEN EVERYONE HAS A THEORY, WHAT SETTLES IT?

When everyone has a theory, what settles it?

Systems · May – Aug 2026 · Root-cause analysis, fix sequencing, measured sign-off

After the system was upgraded to .NET 9, database CPU started spiking intermittently and checkout timed out now and then. The hardest part of a problem like this is that everyone has a theory: maybe the new framework, maybe traffic growth, maybe two checkouts locking each other. Two of the performance issues had already been dragging on for over a year, each optimised once or twice — every fix helped, every fix shipped, and none of them actually solved it.

The question I actually had to answer was simple: which queries are eating the CPU? Not guess — look. As a PM who doesn't write code, I used database evidence to pin down each root cause, sequenced 21 fixes into three waves for engineering to ship, and signed off every one against before-and-after measurements.

0× Survey query speedup (153s → 7s)
0× Checkout SQL speedup (3,000ms → 0.13ms)
0.0× Batch label printing speedup (9.3 min → 1.3 min)
0 Checkout timeouts
PERFORMANCE TRIAGE MEASURED, NOT CLAIMED SURVEY QUERY 153s 7s CHECKOUT PAYLOAD SQL 3,000ms 0.13ms BATCH LABEL PRINTING 9.3min 1.3min SOURCE — AZURE QUERY STORE / MONGO PRINT LOG
EVERYONE HAD A THEORY DB CPU AFTER THE .NET 9 UPGRADE NEW FRAMEWORK? TRAFFIC? DEADLOCK? WHICH QUERIES ACTUALLY EAT THE CPU? DON'T GUESS. LOOK.

MY ROLE

Let me be clear about my role first

The code was written by the team's engineers, not me. I did three other things: found the root causes, decided which one to fix first, and proved with production measurements that it was actually fixed. That's also why this case is worth writing down — it's how a PM with no engineering background, in a company with no senior DBA, drove technical decisions with evidence.

WHO DID WHAT SAID UP FRONT ENGINEERS WROTE THE CODE ME ROOT CAUSE · PRIORITY · MEASURED VERIFICATION A PM WITH NO ENGINEERING BACKGROUND, A COMPANY WITH NO SENIOR DBA — EVIDENCE DID THE ARGUING

CHALLENGE 01

How do you prove a cause everyone believes in doesn't exist?

The dominant hypothesis at the time was "checkout lock contention" — two orders arriving at once and locking each other. It's a reasonable theory and a hard one to refute, because "it only happens sometimes" can explain away any counter-evidence. I ruled it out with three independent layers of evidence:

  1. Code: grep the entire checkout path — BeginTransaction appears 0 times. No transaction, no conditions for a deadlock.
  2. Live database state: query current blocking — 0 blocking sessions, 0 lock waits.
  3. History: pull seven days from Query Store — cumulative lock waits on checkout-related queries: 0.

All three pointed the same way: architecturally, it can't happen. Proving something doesn't exist is harder than proving something does — so I didn't use "I can't measure it" as the conclusion, I checked with three methods that don't depend on each other. Only once this was off the table did the real culprit surface.

DEADLOCK: RULED OUT ×3 THREE INDEPENDENT LAYERS CODE · grep CHECKOUT PATH BeginTransaction: 0 LIVE DB · BLOCKING + LOCK WAITS RIGHT NOW: 0 QUERY STORE · 7 DAYS CHECKOUT LOCK WAITS: 0 ARCHITECTURALLY IMPOSSIBLE PROVING ABSENCE TAKES THREE INDEPENDENT METHODS — “I COULDN'T REPRODUCE IT” IS NOT A CONCLUSION

CHALLENGE 02

How slow does slow have to be before it's worth operating on?

For evidence that "checkout is slow", I first wanted to use my own stopwatch timings — and then threw them out. A number I measured in the office only represents that moment, that network, that machine. It wouldn't convince anyone, and it couldn't carry the weight of a decision like "touch the payment path".

What I used instead: pull every "checkout problem" ticket customer service actually received over a period, and plot them against the database CPU timeline. Six complaints — every single one landed inside a CPU spike. This kind of evidence has two advantages: it's real customer pain, not my perception; and when incident timestamps line up with resource metrics, the direction of causality holds.

Choosing evidence matters as much as choosing the query. What you want isn't "I think it's slow" — it's "customers are hurting, and the timing matches the system metrics".

CUSTOMER PAIN × CPU TIMELINE 6 OF 6 INSIDE SPIKE WINDOWS ● CS TICKET NOT MY STOPWATCH — REAL CUSTOMER REPORTS, LANDING ON REAL RESOURCE SPIKES

ROOT CAUSES

Pinning down each root cause

Survey query (153 seconds): not a query-writing problem, a data-structure problem. The old approach was WHERE Reply LIKE '%score%' — every query scanned the full text of every review, because the score was buried inside JSON and computed at read time. That's exactly why the two earlier fixes ("add a view column", "add caching") could only treat symptoms. The fix: compute the score at write time, break it out into its own column and table, and switch over in stages with dual-write plus backfill. This was the only fix that touched the underlying structure — and the only one that actually cured it.

Checkout payload SQL (3,000ms): one line of code, a 20,000× difference. The LINQ was written as Where(w => w.OrdersId == X || w.AddOrderId == Y), and that OR meant the database couldn't use either index and fell back to a full table scan. The fix is tiny — split it into two indexed queries and merge — and the result is 3,000ms down to 0.13ms.

Batch shipping-label printing (9.3 minutes): signed off against real print logs. Comparing Mongo's print log — 721 real prints before, 486 after — the median dropped from 4.8 seconds to 1.1.

THREE ROOT CAUSES AND WHY EARLIER FIXES COULDN'T STICK SURVEY · 153S LIKE '%score%' — SCAN ON EVERY READ → COMPUTE AT WRITE · OWN COLUMNS CHECKOUT · 3,000MS OR ACROSS 2 COLUMNS — NO INDEX → TWO INDEXED QUERIES + CONCAT BATCH PRINTING · 9.3MIN BATCH PIPELINE BOTTLENECK → VERIFIED VIA MONGO PRINT LOG

CHALLENGE 03

21 optimisations — how do you know which one worked?

There weren't just three problems — the full list ran to 21. But shipping them all at once means you can't attribute anything: change twenty things, the system gets faster, and you never know which one did it or which was wasted effort. I ranked the 21 by risk and expected impact into three waves, measured after each wave shipped, and only pushed the next once the cause-and-effect held.

The checkout fix touched the payment path, so it also went through a full test matrix: five payment routes (web saved card, web LINE Pay, web re-payment, app LINE Pay, app ECPay) verified one by one before it hit production.

21 FIXES, THREE WAVES CHANGE EVERYTHING AT ONCE = ATTRIBUTE NOTHING WAVE 1 MEASURE WAVE 2 MEASURE WAVE 3 CHECKOUT CHANGES: FULL UAT MATRIX · 5 PAYMENT ROUTES, EACH VERIFIED MEASURE AFTER EACH WAVE — PUSH THE NEXT ONLY WHEN CAUSE AND EFFECT HOLD

RESULTS

Results

ItemBeforeAfter
Slowest single survey query153s7s
Survey query monthly CPU totalest. ~44,000 CPU-s39.8 CPU-s (measured)
Checkout payload SQL~3,000ms0.13ms
Batch printing (slowest run)9.3 min1.3 min
Single print, median4.8s1.1s
Checkout timeoutsIntermittent0

Post-release: 8 measured saved-card checkouts averaged 5.06 seconds with zero timeouts. Before the fix, the same test would hit the 30-second timeout outright.

Every data source is labelled: performance numbers from Azure Query Store, print numbers from Mongo's print log. The only estimate is "monthly CPU total before", because the raw record had aged out of the 30-day retention window — and that's exactly how I wrote it in the report: marked as an estimate, for order-of-magnitude reference only.

RESULTS MEASURED, NOT CLAIMED 22× SURVEY QUERY ~20,000× CHECKOUT SQL 7.2× BATCH PRINTING 0 TIMEOUTS SOURCES: AZURE QUERY STORE · MONGO PRINT LOG · THE ONE ESTIMATE IS LABELLED AS ONE

AUTOMATION

Writing the judgement into automation

After the project closed I did one more thing: I wrote the "how to tell who the culprit is" I'd learned over six months into rules, and handed them to an always-on monitoring bot — when a database CPU alert fires, it automatically grabs the queries burning the most CPU right now, runs a first-pass diagnosis, and emails an analysis report to my inbox.

The most important rule in there is a trap I fell into myself: look at CPU consumption, not execution time. A query that runs long may just be waiting on locks or IO — it's the victim, not the culprit. The real culprit is the one with high CPU time. Once that rule was in the bot, my judgement became something that runs without me in the room — which is my definition of performance governance: not fixing a few queries, but making sure the diagnosis starts automatically the next time it happens.

JUDGMENT → AUTOMATION THE NEXT INCIDENT DIAGNOSES ITSELF CPU ALERT BOT PULLS TOP CPU QUERIES FIRST-PASS DIAGNOSIS EMAIL REPORT RULE #1: LOOK AT CPU CONSUMED, NOT TIME ELAPSED A LONG-RUNNING QUERY MAY BE A VICTIM. THE CULPRIT IS THE ONE BURNING CPU. MY JUDGMENT EXECUTES WITHOUT ME IN THE ROOM

ENCORE

Same symptom, different culprit

Two months later "checkout is slow" came back. If I'd copied last time's answer — "probably SQL again" — I'd have been wrong. Breaking it down step by step revealed two completely different things stacked together: the checkout's intermediate page had a hard-coded fixed wait, and stale database statistics were amplifying the IO tail.

Symptoms repeat. Root causes aren't loyal. Every time you have to look again — last time's conclusion is only one of this time's hypotheses.

SAME SYMPTOM, DIFFERENT CULPRIT TWO MONTHS APART “CHECKOUT IS SLOW” MAY · OR PATTERN → NO INDEX → FULL SCAN AUG · HARD-CODED WAIT + STALE STATISTICS SYMPTOMS REPEAT; ROOT CAUSES OWE YOU NO LOYALTY — LAST TIME'S ANSWER IS ONLY THIS TIME'S FIRST HYPOTHESIS

LESSONS

What I learned

  • "Already optimised" is not "already solved". The two earlier fixes genuinely worked and were the right call at the time; the real bottleneck was just in the data structure, and changing that was high-risk and estimated at a month-plus, so it kept getting deferred. Writing down why this attempt cured it matters more than announcing the result.
  • Reports should label their confidence. In the report to my boss I split every number into "measured" and "estimated" with a note on the definition. That didn't weaken the report — it gave the measured numbers more weight.
  • Don't hog the credit, but don't undersell the contribution either. The engineers wrote the code. But these three issues had been lying there for over a year before anyone diagnosed them, and nobody knew where to start. Locating the problem is itself the output.
THREE RULES I KEEP “OPTIMIZED” ≠ “SOLVED” LABEL YOUR CONFIDENCE LOCATING THE PROBLEM IS THE DELIVERABLE MEASURED VS ESTIMATED, MARKED ON EVERY NUMBER — IT MAKES THE MEASURED ONES COUNT MORE

STACK

Stack

Azure Query Store, T-SQL, SQL Statistics, MongoDB, Azure CLI, Application Insights. The diagnostic method and decision rules have been distilled into an always-on monitoring bot and an internal playbook.

EVIDENCE SOURCES QUERY STORE T-SQL · STATISTICS MONGO PRINT LOG AZURE CLI APP INSIGHTS METHOD LIVES ON AS A MONITORING BOT + INTERNAL PLAYBOOKS