8

I have a query that basically looks like this:

Select *
From UserSearches us
left outer join Quotes q on q.UserSearchId = us.Id and q.QuoteNumber is not null
left outer join ContainerDetails cd on cd.QuoteId = q.Id
left outer join Surcharges s on s.ContainerDetailId = cd.Id
where us.SearchDate between @beginDate and @endDate

Given certain values of @beginDate and @endDate, I have a search that takes 30 seconds to return around 100K rows.

The ultimate goal is to populate some objects that have parent-child-child-child relationships. So after some experimentation, I found that I could speed up the query dramatically with the following:

Select *
From UserSearches us
left outer join Quotes q on q.UserSearchId = us.Id and q.QuoteNumber is not null
left outer join ContainerDetails cd on cd.QuoteId = q.Id
where us.SearchDate between @beginDate and @endDate

Select cd.Id into #cdIds
From UserSearches us
left outer join Quotes q on q.UserSearchId = us.Id and q.QuoteNumber is not null
left outer join ContainerDetails cd on cd.QuoteId = q.Id
where us.SearchDate between @beginDate and @endDate

Select * From Surcharges s
inner join #cdIds on s.ContainerDetailId = #cdIds.Id

DROP TABLE #cdIds

This runs in 10 seconds, which makes no sense to me. Surely it should be faster just to join the Surcharges in the first place.

The Surcharge table has the following indexes:

PK:

ALTER TABLE [dbo].[Surcharges] ADD  CONSTRAINT [PK_dbo.Surcharges] PRIMARY KEY CLUSTERED 
(
    [Id] ASC
)

IX1:

CREATE NONCLUSTERED INDEX [IX_Surcharge_ContainerDetailId] ON [dbo].[Surcharges]
(
    [ContainerDetailId] ASC
)
INCLUDE (   [Id],
[Every],
[Single],
[Column],
[About],
[Twelve],
[Of],
[Them],
)

IX2:

CREATE NONCLUSTERED INDEX [IX_ContainerDetailId] ON [dbo].[Surcharges]
(
    [ContainerDetailId] ASC
)

To sum up, why is it faster to do a separate query for my Surcharges than it is to join them in the first place?

EDIT: Here are the execution plans. These are .sqlplan files that you can open in Sql Studio:

Query 1 - Combined

Query 2 - Seperate

Pharylon
  • 9,796
  • 3
  • 35
  • 59
  • Your 3rd index is redundant to your 2nd index, and you should probably just make #2 your cluster and leave your PK as unclustered, since you're basically forcing a 2nd clustered index. – user3111227 Sep 14 '15 at 14:21
  • Oops, fixed the typo. I'm doing Outer Joins because I want searches that don't return any quotes (but each quote should always have a multiple 'containerdetails' and each 'containerdetail' should have multiple 'surcharges' I realize I could trim it down without the *, but I'm hydrating some objects here, and I want pretty much all the data. – Pharylon Sep 14 '15 at 14:21
  • 1
    Your queries are not comparable for starters, and are doing different things, which makes it unsurprising the performance differs. You would need to inspect the execution plans to get a better idea of why one query outperforms the other. – GarethD Sep 14 '15 at 14:31
  • How do you figure they're not comparable? For both, I get all the UserSearches between date X and Y, plus all the Quotes, ContainerDetails, and Surcharges that would be left joins. The only difference is in one, I do it in one query, for the other I do it in two. – Pharylon Sep 14 '15 at 14:35
  • Yeah, I realized the third was redundant. It was the original index, and then we added the second a few months ago. I didn't realize it was still on there until I started to type up this question. Figured I'd include it in the question for good measure, though. :) – Pharylon Sep 14 '15 at 14:36
  • Try adding `WITH #nolock` to your joins. Are there any other users or processes running on the DB? If so stop them all and re-test. – Hogan Sep 14 '15 at 14:47
  • 1
    Because the first returns all columns from all tables, whereas the second only returns columns from `Surcharges`, and the one column from the temp table. This will have a significant performance impact because selecting all columns could mean clustered index scans are favoured over a nonclustered index seek due to the cost of a bookmark lookup. Secondly, the final join logic is different, the second query would have to be ``SELECT * FROM #cdIds LEFT JOIN Surcharges s ON s.ContainerDetailId = #cdIds.Id`` to be semantically equivalent. – GarethD Sep 14 '15 at 14:48
  • @GarethD - LEFT JOIN and inner join are the same logically. Remember he is selecting what was the right part of the join in the first query. – Hogan Sep 14 '15 at 14:50
  • @Hogan What?? Surcharges is left joined to in the original query. If there are 100,000 rows in `UserSearches` that match the specified date range, but only 3 rows that match in Surcharges, the first query will return 100,000 rows where 99,997 have `NULL` data for surcharges, the second query will return 3 rows. This is not logically the same. – GarethD Sep 14 '15 at 15:00
  • @GarethD - But The 2nd "batch" will return those 100,000 rows in the first query and iterate those rows to make the temporary table. In such an example we would expect the 2nd "batch" to be much slower because the first query in the 2nd batch is run (returning 100,000 rows) and then run again to create the temporary of 3 rows and then a 3nd query is run to get those three rows. The 2nd batch should be much slower. Of course there might be something the user is not telling us. EG lots of transactions on the surcharges table by another process is causing slowdown via lock contention. – Hogan Sep 14 '15 at 15:05
  • @Hogan Yes, the same indexes may be scanned to create the temporary table, but the additional network cost and/or cost of rendering 100,000 rows compared to 3 will make the two queries not comparable (my original point). If you run `SELECT a.object_id INTO #T FROM sys.all_objects a, sys.all_objects b; SELECT TOP 3 * FROM #T;` You will find that it runs in much less time than without the temp table - `SELECT a.object_id FROM sys.all_objects a, sys.all_objects b`. – GarethD Sep 14 '15 at 15:11
  • @GarethD - I agree 100%, but in the code he is showing he is still doing the original select. I think you are getting at what is probably the issue. The OP is not giving us the actual query -- something like it that has been simplified. If we were looking at the original queries / query plan it would obvious that changes in columns requested caused index seeks to be used over index scans OR there is an external process that effecting the results. – Hogan Sep 14 '15 at 15:16
  • There are no other users. This is being done in isolation on the development server, after I copied the database for the express purpose of testing with not even our test servers connected to it. And this is the exact query, verbatim (though the query it'll be different in production, I'm seeing the same issue with these simplified queries). Also, the times I gave were for queries that returned less rows. I updated the times to reflect what I see for the query that returns 100k rows. – Pharylon Sep 14 '15 at 15:49
  • @Pharylon - great you have isolation, now show us the query plans. – Hogan Sep 14 '15 at 16:59
  • I added the execution plans to the answer – Pharylon Sep 14 '15 at 17:54

4 Answers4

8

To understand what is going on look at the actual execution plans.

Preferably in SQL Sentry Plan Explorer.

You'll see that your first variant has Actual Data Size = 11,272 MB in 100,276 rows.

first

In the second variant the query that populate temp table returns only 173KB in 19,665 rows. The last query returns 1,685 MB in 87,510 rows.

second a

second b

11,272 MB is much more than 1,685 MB

No wonder the first query is slower.

This difference is caused by two factors:

  1. In the first variant you select all columns from UserSearches, Quotes, ContainerDetails tables. While in the second variant you select only ID from ContainerDetails. Apart from reading from disk and transmitting over network extra bytes this difference results in substantially different plans. Second variant doesn't do Sort, doesn't do Key Lookup and uses Hash joins instead of Nested Loops. It uses different indexes on Quotes. Second variant uses index Scan on ContainerDetails instead of Seek.

  2. The queries produce different number of rows, because first variant uses LEFT JOIN and second INNER JOIN.

So, to make them comparable:

  1. Instead of using * list explicitly only those columns that you need.
  2. Use INNER JOIN (or LEFT JOIN) Surcharges in both variants.

update

Your question was "why SQL Server would run the second query faster", the answer is: because the queries are different and they produce different results (different set of rows, different set of columns).

Now you are asking another question: how to make them the same and fast.

Which of your two variants produces correct result that you want? I'll assume that it is the second variant with temp table.

Please note, that I'm not answering here how to make them fast. I'm answering here how to make them the same.

The following single query should produce exactly the same results as your second variant with temporary table, but without explicit temporary table. I would expect its performance to be similar to your second variant with temporary tables. I deliberately wrote it using CTE to copy the structure of your variant with temp table, though it is easy to rewrite it without. Optimizer would be smart enough to do it anyway.

WITH
CTE
AS
(
    Select cd.Id
    From
        UserSearches us
        left outer join Quotes q on q.UserSearchId = us.Id and q.QuoteNumber is not null
        left outer join ContainerDetails cd on cd.QuoteId = q.Id
    where
        us.SearchDate between @beginDate and @endDate
)
Select *
From
    Surcharges s
    inner join CTE on s.ContainerDetailId = CTE.Id
;
Vladimir Baranov
  • 31,799
  • 5
  • 53
  • 90
  • The goal of this search is to get all `UserSearches`, whether they have `Quotes` or not, and then to get all `Quotes`, `ContainerDetails`, and `Surcharges` associated with those searches, thus the left outer joins. I am guaranteed to have at least one `ContainerDetail` per `Quote` and one `Surcharge` per `ContainerDetail`, the `left outer join` vs `inner join` doesn't really change the data I get back. I'm just breaking it up over two seperate `Selects`. If I change the `Surcharges` to use an `INNER JOIN` in the first query, I lose the `UserSeraches` without `Quotes.` – Pharylon Sep 17 '15 at 13:03
  • So the difference between the 100,276 rows returned by the first and the 87,510 returned by the second is that it doesn't include `UserSearches` without `Qutoes`. However, that's fine, I got those `UserSearchs` in the first `SELECT` of the second query. – Pharylon Sep 17 '15 at 13:09
  • Changing the `left outer joins` to `inner joins` on the `Select into` query didn't speed anything up or alter the amount of data returned. – Pharylon Sep 17 '15 at 13:43
  • Again, @Pharylon, yes, but try changing the original query (the one where you are not using a temp table/select into) to use an INNER JOIN (rather than left), which will be equivalent to what you are doing in the 2nd version. Changing the select...into to a left join is still not apples-to-apples, as you're almost certainly getting the results limited on the insert to the temp table first. – boydc7 Sep 17 '15 at 13:51
  • That was still at 32 seconds. Significantly slower than the seperate queries. :( I also lost the `UserSearches` that don't have `Quotes` by doing `inner joins` (and I need those). [Here's the new Execution Plan](http://pastebin.com/download.php?i=bmp34Acx) – Pharylon Sep 17 '15 at 14:13
  • @Pharylon, I updated the answer. Your changes to the first variant still didn't make it the same query as the second variant with temp table, that's why you still get different performance. It seems that you are moving away from your original question "why SQL Server would run the second query faster", so you'd better clarify what your question really is. – Vladimir Baranov Sep 17 '15 at 22:44
3

Well, there seem to be 2 large contributing differences in what I can see in the queries themselves as well as the plans.

First, and likely most impactful, in your second version where you are using the temp table, your final query against the Surcharges table is INNER JOINing instead of the LEFT JOIN operator you were using in the original query. I'm not sure which version is accurate, but the difference in the number of returned records seems to be very high based on the plan information (18.6 million in the first version vs. a 5.1 million in the second version). If you change your first version to an INNER JOIN on the Surcharges table, do you see similar results in terms of duration?

Second, and likely less impactful, your second version is giving you a parallel execution it seems on the select...into portion of the batch. Without seeing additional stuff, I likely wouldn't dare comment on why that may be, but it is a potential differentiator.

I'd start with the first contributor and see what you wind up with and go from there.

EDIT:

To help clarify with the comments, try changing your first query to this and attaching the query plan / reviewing the results/duration of that vs. the temp table/select...into version:

Select *
From UserSearches us
left outer join Quotes q on q.UserSearchId = us.Id and q.QuoteNumber is not null
left outer join ContainerDetails cd on cd.QuoteId = q.Id
INNER join Surcharges s on s.ContainerDetailId = cd.Id
where us.SearchDate between @beginDate and @endDate

That should hopefully give you a more-or-less similar duration as the second version - if still not, please attach the query plan for that version.

boydc7
  • 4,593
  • 20
  • 17
  • The goal of this search is to get all `UserSearches`, whether they have `Quotes` or not, and then to get all `Quotes`, `ContainerDetails`, and `Surcharges` associated with those searches, thus the left outer joins. I am guaranteed to have at least one `ContainerDetail` per `Quote` and one `Surcharge` per `ContainerDetail`, the `left outer join` vs `inner join` doesn't really change the data I get back. I'm just breaking it up over two seperate `Selects`. If I change the `Surcharges` to use an `INNER JOIN` in the first query, I lose the `UserSeraches` without `Quotes.` – Pharylon Sep 17 '15 at 13:03
  • Changing the `left outer joins` to `inner joins` on the `Select into` query didn't speed anything up or alter the amount of data returned. – Pharylon Sep 17 '15 at 13:44
  • @pharylon, in your last comment you said changing the left join to inner join on the select into didn't speed anything up or alter the amount of data returned - just to be clear, your were already performing an inner join on the select into, you are performing the left join on the original query - THAT is where you need to change from a left join to an inner join (i.e. in your original query) to match what you're doing in your 2nd version with the select...into (which is using an inner join). – boydc7 Sep 17 '15 at 13:48
  • @Pharylon, see edits to my answer with the change to the original query and check the results - attach the plan for that if it still is drastically different – boydc7 Sep 17 '15 at 13:54
  • Still at 40 seconds, so no faster. [Here's the sqlplan](http://pastebin.com/download.php?i=bFUynhNj). I also lose the `UserSearches` without `Quotes` by doing `inner joins` (I need those). – Pharylon Sep 17 '15 at 14:10
1

But you are not comparing apples to apples
The first is 3 left
The second is 2 left and 1 inner join
And in the second the results are split

Try this
Move us.SearchDate between @beginDate and @endDate up into the join
I suspect it is doing a massive join and the filtering last
Get the date filter to happen early

Select *
  From UserSearches us
  left outer join Quotes q 
        on q.UserSearchId = us.Id 
       and q.QuoteNumber is not null 
       and us.SearchDate between @beginDate and @endDate
  left outer join ContainerDetails cd 
        on cd.QuoteId = q.Id
  left outer join Surcharges s 
        on s.ContainerDetailId = cd.Id

The fast search makes no sense to me

Those left joins do absolutely nothing to this
All the left does is return cd.ID = null

Select cd.Id into #cdIds
From UserSearches us
left outer join Quotes q on q.UserSearchId = us.Id and q.QuoteNumber is not null
left outer join ContainerDetails cd on cd.QuoteId = q.Id
where us.SearchDate between @beginDate and @endDate

if you just want Surcharges then

   Select s.*
     From UserSearches us
     join Quotes q 
       on q.UserSearchId = us.Id 
      and q.QuoteNumber is not null 
      and us.SearchDate between @beginDate and @endDate
     join ContainerDetails cd 
       on cd.QuoteId = q.Id
     join Surcharges s on 
       on s.ContainerDetailId = cd.Id
paparazzo
  • 44,497
  • 23
  • 105
  • 176
  • Notice I'm still doing a `Select *` above the one where I select into #cdIds. The goal of this search is to get all `UserSearches`, whether they have `Quotes` or not, and then to get all `Quotes`, `ContainerDetails`, and `Surcharges` associated with those searches, thus the left outer joins. I am guaranteed to have at least one `ContainerDetail` per `Quote` and one `Surcharge` per `ContainerDetail`, the `left outer join` vs `inner join` doesn't really change the data I get back. I'm just breaking it up over two seperate `Selects`. – Pharylon Sep 17 '15 at 13:04
  • Yes I notice you do * in the above. Still the left in the surcharge does nothing. You are not getting the same in the second as you have changed the last in to a join - inner join #cdIds on s.ContainerDetailId = #cdIds.Id – paparazzo Sep 17 '15 at 13:15
  • Doing `Select * From #cdIds left outer join Surcharges s on s.ContainerDetailId = #cdIds.Id` gives me basically the same data and takes the same amount of time. There are a few nulls in the #cdIds table, but not enough to matter. Also, your suggestion for moving the where into the join didn't work. It was just as slow. :( – Pharylon Sep 17 '15 at 13:37
  • OK well in the join was worth a try. Interesting. – paparazzo Sep 17 '15 at 13:39
  • @Pharylon, yes, but try changing the original query (the one where you are not using a temp table/select into) to use an INNER JOIN (rather than left), which will be equivalent to what you are doing in the 2nd version. Changing the select...into to a left join is still not apples-to-apples, as you're almost certainly getting the results limited on the insert to the temp table first. – boydc7 Sep 17 '15 at 13:50
  • It's not equivalent, because I then don't get back `UserSearches` that don't have `Quotes`. And it's still slower (34 seconds). Execution plan: http://pastebin.com/bmp34Acx – Pharylon Sep 17 '15 at 14:15
0
  1. Have you tried once using <= and >= instead of between.
  2. Also create non clustered index on SearchDate.
  3. Also declare local variable in your proc to avoid parameter sniffing.
  4. Instead of * ,specify those column that you require.
  5. Are you sure that,left join is ok instead of inner join.

like in your proc do, declare @beginDate1 datetime=@beginDate declare @endDate1 datetime=@endDate

try this,

;With CTE as
(
Select us.Id
From UserSearches us
where us.SearchDate >= @beginDate1 and us.SearchDate <= @endDate1
)

Select us.id,q.col1,cd.col2
From CTE us
left outer join Quotes q on q.UserSearchId = us.Id and q.QuoteNumber is not null
left outer join ContainerDetails cd on cd.QuoteId = q.Id
left outer join Surcharges s on s.ContainerDetailId = cd.Id
KumarHarsh
  • 5,046
  • 1
  • 18
  • 22