The execution plan said Index Seek. The same query was doing about 8,000 logical reads against a 4-million-row table and burning roughly 330ms of CPU under load. Both of those were true at the same time, and the gap between them is the entire point of this post: a green seek icon in the plan does not mean the seek was cheap.
I found this profiling the geoIP lookup inside the sports SaaS, a 25-year-old classic-ASP app on Windows, IIS, and MSSQL. Every page that wants to geolocate a visitor fires a SELECT against one table to turn an IP into a location. At low traffic it is invisible. Under a bot swarm at several hundred requests a second, it became a wall, and Query Store flagged it during a slowness incident the day before.
The table and the query
The table is dbo.GeoIPRanges, 3,955,984 rows, single clustered primary key on (startIpNumber, endIpNumber, locID). IP ranges, mostly non-overlapping, stored as bigints.
The hot lookup is a range-overlap check. Given a visitor IP converted to an integer, find the row whose interval contains it:
SELECT TOP 1 locIDFROM dbo.GeoIPRangesWHERE startIpNumber <= @ip AND endIpNumber >= @ip;This is not a point lookup and it is not an equality check. It is a one-sided range against the leading key column, and that is the whole trap.
Why the seek lies
The clustered index leads on startIpNumber. The optimizer looks at startIpNumber <= @ip, sees a sargable predicate on the leading column, and does exactly what you would expect: it B-tree-seeks to the upper bound of startIpNumber and starts walking. Every row it touches on that walk gets its endIpNumber tested as a residual predicate.
The plan shows Index Seek. The estimated cost is low. The icon is green. None of that is a lie, exactly. The engine really did navigate the B-tree to an entry point. The problem is what happens after the entry point.
For a visitor whose IP sits in the middle of the address space, the seek lands roughly halfway through the table, and then the engine walks the ~2 million rows below it, applying endIpNumber >= @ip to each one, until TOP 1 finally fires. That is a scan of half the table wearing a seek costume.
This is the distinction between an index seek (navigating to a point) and the index seek range (the set of rows that actually pass the leading-column predicate). When the leading predicate is one-sided, with an upper bound and no lower bound, the seek range is enormous and everything else is a residual applied during the walk. The operator name reports the navigation. It says nothing about the size of the range you scan afterward.
The read count is where the truth lives. SET STATISTICS IO ON does not care what the operator is called. About 8,000 logical reads per call on average under load, worst case 14,718 for an IP that lands deepest in the table, with that worst sample burning around 360ms of CPU on its own. The reads and the CPU both scale with how high the IP sits in the address space, which is the signature of a scan the plan happened to label a seek.
The fix is a second seek path, not a query rewrite
There is no way to make startIpNumber <= @ip two-sided. The data is the data. But you can hand the optimizer a different B-tree whose seek range is small for the exact same query:
CREATE NONCLUSTERED INDEX IX_GeoIPRanges_endIp_startIp ON dbo.GeoIPRanges (endIpNumber, startIpNumber) INCLUDE (locID) WITH (DATA_COMPRESSION = PAGE);Now the optimizer has two angles of attack on the same predicate:
- Seek by
startIpNumber <= @ip, walk throughendIpNumber. This is the old half-table-scan path. - Seek by
endIpNumber >= @ip, walk throughstartIpNumber. This is the new path.
The second path is cheap for a reason that depends entirely on the data. In a well-formed geoIP set, almost no row has both endIpNumber greater than @ip and startIpNumber greater than @ip. Those would be ranges sitting entirely above the visitor. Because ranges do not overlap and the index is sorted, the first row where endIpNumber >= @ip is overwhelmingly the row that also satisfies startIpNumber <= @ip. The seek range collapses to a handful of rows and TOP 1 fires almost immediately.
The INCLUDE (locID) makes the index covering, so the lookup is satisfied entirely from the index leaf and never promotes to the base table.
The worst sample went from 14,718 logical reads to 3, with CPU dropping from a few hundred milliseconds to effectively zero. That is about a 5000x cut on the pathological IPs, measured after the index was live on prod, and it is the read count that proves it, not a faster-looking plan.
The assumption I had to actually check
The entire argument above rests on one claim: ranges do not overlap. If they do, the same one-sided pathology can resurface on the new index, and I would have shipped a fix that fixes nothing for some inputs. So before trusting the design, I ran one query against the live 4M rows to count overlapping pairs:
WITH ordered AS ( SELECT startIpNumber, endIpNumber, LAG(endIpNumber) OVER (ORDER BY startIpNumber) AS prev_end FROM dbo.GeoIPRanges)SELECT COUNT(*) AS overlapping_pairsFROM orderedWHERE prev_end IS NOT NULL AND prev_end >= startIpNumber;Zero overlapping pairs across all four million rows. The non-overlap assumption the whole approach depends on held, so the design was sound. If that count had come back nonzero, the second index alone would not have saved me.
A few details that mattered in production
DATA_COMPRESSION = PAGE brought the index in at about 57MB (7153 pages), against an uncompressed estimate of roughly 80MB. I had guessed it would compress harder, closer to 40MB, and it didn’t, but sorted bigint IP ranges still have enough locality that page compression earns its keep, and the smaller footprint means fewer reads per seek on top of everything else.
The build ran offline, and that was not a choice, it was the edition. I had originally written the DDL with ONLINE = ON and waved it through as fine. A reviewer flagged it as an unsafe assumption because ONLINE is edition-gated, so I went to check instead of arguing. The box is running Standard Edition. I had half-talked myself into believing an earlier 2016 service-pack feature unlock covered online index builds. It does not. Those earlier SPs unlocked things like DATA_COMPRESSION and partitioning on Standard, but online index operations stay Enterprise-only on every version. The server settled the argument when the first apply attempt came back with “Online index operations can only be performed in Enterprise edition of SQL Server.” So the build was offline, no way around it. On a calm box it took about 12 to 13 seconds against the static 4M-row table, holding a schema-modify lock for that window, which is why it went out off-hours.
When you verify a fix like this, do not verify with one sample IP. A single mid-range IP can look great while a deep one still scans. Test low, mid, and high ranges plus deliberate misses, and read the execution plan together with STATISTICS IO for each. The plan tells you which index the optimizer picked; the read count tells you whether the seek was actually cheap.
The takeaway
Index Seek in a plan does not mean fast. It means the engine used a B-tree to find an entry point, and the operator name is silent about how much it scanned after that point. A one-sided range predicate against the leading column of a composite key seeks to a boundary and then walks everything past it, which is a range scan with extra steps.
When you see a seek that costs like a scan, do not start by rewriting the query. Look at the seek range, not the operator, and ask whether a second covering index oriented around the other side of the range would give the optimizer a genuinely small seek. And trust the read count over the icon. The logical-read number is the truth; the operator name is just a label.
Related
- The Index Seek That Was Actually a Half-Table Scan, 5000x on a 4M-Row geoIP Table: earlier framing of this same incident
- It Looked Exactly Like a Bot Swarm. It Was SQL Server Parameter Sniffing.: another SQL Server problem that looked like something else entirely
- From 3.5 million round-trips to hundreds: batching a remap with temp tables: a different SQL Server performance fix: the cost of per-row queries at scale
- Codex flagged my ONLINE=ON, my own probe said it was fine, and the server proved us both wrong: the Standard Edition online-index trap that surfaced during the same fix
- The Datetime That SQL Server Can’t Read Back: Keep the Copy In the Database: another SQL Server data-representation gotcha with a similar root cause shape