1

I'm doing a Pretty hefty LinqToSql statement that returns a new object. Due to the amount of SQL Methods (Sum and convert mainly) the SQL is taking a significantly long time to run and therefore loading the web page takes a long time (10-15 seconds). While I could use AJAX or similar with a CSS loader. I'm wondering first if there is a simple way to achieve what i am trying to get from the SQL Database.

I am trying to:

  1. Return all users where a given Field is not null
  2. Get all of the current items in the opportunities table where the status is 'open' and the Foreign Key matches. (after doing a manual join)
  3. Inside those opportunities, store the sum of all monetary values for several fields into my class
  4. Get the count of those monetary values.

The Linq Statement itself was a pretty long write, however when turned into SQL it is full of COALESCE and other hefty SQL methods.

Here is my LINQ statement:

 decimal _default = (decimal)0.0000;
            var users = from bio in ctx.tbl_Bios.Where(bio => bio.SLXUID != null)
                      join opp in ctx.slx_Opportunities.Where(opp => opp.STATUS == "open") on bio.SLXUID equals opp.ACCOUNTMANAGERID  into opps
                      select new UserStats{
                          Name = bio.FirstName + " " + bio.SurName,
                          EnquiryMoney = opps.Where(opp => opp.SALESCYCLE == "Enquiry").Sum(opp => (opp.ACTUALAMOUNT.HasValue && opp.ACTUALAMOUNT.Value != _default ? opp.ACTUALAMOUNT : opp.SALESPOTENTIAL.HasValue ? (decimal)opp.SALESPOTENTIAL.Value : _default)).GetValueOrDefault(_default),
                          EnquiryNum = opps.Where(opp =>  opp.SALESCYCLE == "Enquiry").Count(),
                          GoingAheadMoney = opps.Where(opp => opp.SALESCYCLE == "Going Ahead").Sum(opp => (opp.ACTUALAMOUNT.HasValue && opp.ACTUALAMOUNT.Value != _default ? opp.ACTUALAMOUNT : opp.SALESPOTENTIAL.HasValue ? (decimal)opp.SALESPOTENTIAL.Value : _default)).GetValueOrDefault(_default),
                          GoingAheadNum = opps.Where(opp =>  opp.SALESCYCLE == "Going Ahead").Count(),
                          GoodPotentialMoney = opps.Where(opp => opp.SALESCYCLE == "Good Potential").Sum(opp => (opp.ACTUALAMOUNT.HasValue && opp.ACTUALAMOUNT.Value != _default ? opp.ACTUALAMOUNT : opp.SALESPOTENTIAL.HasValue ? (decimal)opp.SALESPOTENTIAL.Value : _default)).GetValueOrDefault(_default),
                          GoodPotentialNum = opps.Where(opp =>  opp.SALESCYCLE == "Good Potential").Count(),
                          LeadMoney = opps.Where(opp => opp.SALESCYCLE == "Lead").Sum(opp => (opp.ACTUALAMOUNT.HasValue && opp.ACTUALAMOUNT.Value != _default ? opp.ACTUALAMOUNT : opp.SALESPOTENTIAL.HasValue ? (decimal)opp.SALESPOTENTIAL.Value : _default)).GetValueOrDefault(_default),
                          LeadNum = opps.Where(opp =>  opp.SALESCYCLE == "Lead").Count(),
                          PriceOnlyMoney = opps.Where(opp => opp.SALESCYCLE == "Price Only").Sum(opp => (opp.ACTUALAMOUNT.HasValue && opp.ACTUALAMOUNT.Value != _default ? opp.ACTUALAMOUNT : opp.SALESPOTENTIAL.HasValue ? (decimal)opp.SALESPOTENTIAL.Value : _default)).GetValueOrDefault(_default),
                          PriceOnlyNum = opps.Where(opp =>  opp.SALESCYCLE == "Price Only").Count(),
                          ProvisionalMoney = opps.Where(opp => opp.SALESCYCLE == "Provisional").Sum(opp => (opp.ACTUALAMOUNT.HasValue && opp.ACTUALAMOUNT.Value != _default ? opp.ACTUALAMOUNT : opp.SALESPOTENTIAL.HasValue ? (decimal)opp.SALESPOTENTIAL.Value : _default)).GetValueOrDefault(_default),
                          ProvisionalNum = opps.Where(opp =>  opp.SALESCYCLE == "Provisional").Count()
                      };
George Stocker
  • 57,289
  • 29
  • 176
  • 237
LiamHT
  • 1,312
  • 3
  • 12
  • 28
  • 1
    i think you should limit number of records and do server side paging – DevelopmentIsMyPassion Feb 16 '15 at 14:24
  • @DevelopmentIsMyPassion There's only 35 users that it goes through. Its more the fact that the number of associated opportunities could be in the low hundreds, which in itself wouldnt be alot, until you start doing the SUM and other methods – LiamHT Feb 16 '15 at 14:28
  • 2
    You may want to consider writing your own SQL and tune it for the best performance. Then you could just use ADO.Net instead of Ling-to-Sql for this query. Also take a look at [Dappe](https://github.com/StackExchange/dapper-dot-net)r as it make dealing with ADO.Net much smoother. – juharr Feb 16 '15 at 14:30
  • What's the SQL Statement that is generated? – mrdenny Feb 16 '15 at 18:01

2 Answers2

4

There are multiple things you could do:

  1. Filtered Indexes: Depending on the breakdown of records in the Opportunities table around the value 'open', you could create a Filtered Index on 'open'. If you have roughly equal amounts of 'open' and 'closed' (or whatever other values are), then a filtered index would let your TSQL only look at records that have 'open'. A Filtered Index stores only the data that meets the predicate; in this case, anything you'd join on that has a value of 'open'. That way it doesn't have to scan other indexes for records that may have 'open' in them.

  2. Summary/Rollup table: Create a Rollup table that has the values you're looking for; in this case you're looking for Sums and counts -- why not create a table that simply has one row that has those counts? You could use a Stored Procedure/Agent job to keep it up to date. If your query permits it, you could also try to create an Indexed View; I'll go into that below. For the summary table; you'd essentially run a Stored Procedure that calculates those fields and updates them periodically (say once every few minutes or once a minute, depending on the load) and writes those results to a new table; this would be your Rollup table. Then your results are as easy as a select statement. This would be very fast, at the cost of the load for calculating those sums every few minutes. Depending on the number of records, that could be problematic.

  3. Indexed View: Probably the 'right' way to solve a problem like this, depending on your constraints, and how many rows we're talking about (in my case; I pursued it for a case where there were hundreds of thousands of rows).

Filtered Indexes

You could also create a filtered index (it's a bit of an abuse; but it would work) for each of those states, and then simply when it's summing/ counting, it only needs to rely on the index that matches the state it's looking for.

To create a filtered Index:

CREATE NONCLUSTERED INDEX FI_OpenStatus_Opportunities
    ON dbo.Opportunities (AccountManagerId, Status, ActualAmount)
    WHERE status = 'OPEN';
GO

Likewise for your sums and counts (one per column):

CREATE NONCLUSTERED INDEX FI_SalesCycleEnquiry_Status_Opportunities
    ON dbo.Opportunities (AccountManagerId, Status, SalesCycle, ActualAmount)
    WHERE status = 'OPEN' and SalesCycle = 'Enquiry'

(and so on for the rest).

I'm not saying this is your best idea; but it is an idea. Whether or not it's a good one depends on how it performs in your environment on your workload (something I can't answer).

Indexed View

You could also create an Indexed View that contains this rollup information; this is a little bit more advanced and depends on you.

To do that:

  CREATE VIEW [SalesCycle_Summary] WITH SCHEMABINDING AS
    SELECT AccountManagerID, Status, SUM(ActualAmount) AS MONETARY
    ,COUNT_BIG(Status) as Counts 
FROM [DBO].Opportunities
GROUP BY AccountManagerID, Status
GO


-- Create clustered index on the view; making it an indexed view
CREATE UNIQUE CLUSTERED INDEX IDX_SalesCycle_Summary ON [SalesCycle_Summary] (AccountManagerId);

And then (depending on your set up) you can either join to that Indexed View directly, or include it via a hint (try for the former).

Finally, if none of that works (there are some gotchas around Indexed Views -- I haven't used them in about 6 months, so I don't quite remember the specific issue that bit me), you can always create a CTE and ditch the Linq-To-SQL entirely.

That answer is a bit out of scope (because I've already given two approaches and they require a lot of investigation on your part).

To investigate how these do:

  1. Get the Generated SQL from your Linq-To-SQL statement (here's how you do that).

  2. Open up SSMS and turn on the following in a query window:

    • SET STATISTICS IO ON
    • SET STATISTICS TIME ON
    • Check the box that says "display actual query plan" and "display estimated query plan"
    • Copy the generated SQL into it; run it.
  3. Fix any issues with Indexes before continuing. If you get Missing Index Warnings; investigate them and resolve those, and then re-run the benchmarks.

Those starting numbers are your benchmarks.

  • Statistics IO tells you the number of logical and physical reads your query is making (lower is better -- concentrate on the areas where there are a high number of reads first)
  • Statistics TIME tells you how much time the query took to run and to display its results to SSMS (make sure to turn SET NOCOUNT ON so you're not affecting the results)
  • The Actual Query plan tells you exactly what it's using, what indexes SQL Server thinks you're missing, and other issues like Implicit Conversions or bad statistics that would affect your results. Brent Ozar Unlimited has a great video on the subject, so I won't reproduce the answer here.
  • The estimated query plan tells you what SQL Server thinks is going to happen -- those are not always the same as the Actual Query plan -- and you want to be sure to account for the difference in your investigation.

There are no 'easy' answers here; the answer depends on your data, your data usage, as well as what changes you can make to the underlying schema. Once you run this in SSMS, you'll see how much of it is Linq-To-SQL overhead, and how much of it is the query itself.

Community
  • 1
  • 1
George Stocker
  • 57,289
  • 29
  • 176
  • 237
  • Thats a pretty great answer, however I managed to change my linq statement enough to get it to work quickly. Thanks though! – LiamHT Feb 16 '15 at 15:38
  • Given the small dataset that's being worked with here indexes and fixing the query is all that needs to be done. Advanced things like indexed views are overkill. – mrdenny Feb 16 '15 at 18:02
  • @mrdenny That's a great point. How would you feel about using Filtered indexes in this case? – George Stocker Feb 16 '15 at 18:15
  • Filtered indexes would probably be fine, provided that the filter reduces the amount of data in the indexes by a large percentage. Otherwise it's probably not worth it as the logical IO per seek will probably stay the same. – mrdenny Feb 25 '15 at 20:21
1

I made my linq query local earlier in my query, doing a group by and then creating my objects. I was only able to do this due to the small amount of items returned so the server can easily handler them. Anyone else getting a similar problem would be better advised to user George Stocker's Answer

I updated my query to the following:

 var allOpps = ctx.slx_Opportunities.Where(opp => opp.STATUS == "open").GroupBy(opp => opp.SALESCYCLE).ToList();

        var users = ctx.tbl_Bios.Where(bio => bio.SLXUID != null).ToList().Select(bio => new UserStats
        {
            LeadNum = allOpps.Single(group => group.Key == "Lead").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Count(),
            LeadMoney = allOpps.Single(group => group.Key == "Lead").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Sum(opp =>  opp.SALESPOTENTIAL.GetValueOrDefault(_default)),
            GoingAheadNum = allOpps.Single(group => group.Key == "Going Ahead").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Count(),
            GoingAheadMoney = allOpps.Single(group => group.Key == "Going Ahead").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Sum(opp => opp.SALESPOTENTIAL.GetValueOrDefault(_default)),
            EnquiryNum = allOpps.Single(group => group.Key == "Enquiry").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Count(),
            EnquiryMoney = allOpps.Single(group => group.Key == "Enquiry").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Sum(opp => opp.SALESPOTENTIAL.GetValueOrDefault(_default)),
            GoodPotentialNum = allOpps.Single(group => group.Key == "Good Potential").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Count(),
            GoodPotentialMoney = allOpps.Single(group => group.Key == "Good Potential").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Sum(opp => opp.SALESPOTENTIAL.GetValueOrDefault(_default)),
            PriceOnlyNum = allOpps.Single(group => group.Key == "Price Only").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Count(),
            PriceOnlyMoney = allOpps.Single(group => group.Key == "Price Only").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Sum(opp => opp.SALESPOTENTIAL.GetValueOrDefault(_default)),
            ProvisionalNum = allOpps.Single(group => group.Key == "Provisional Booking").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Count(),
            ProvisionalMoney = allOpps.Single(group => group.Key == "Provisional Booking").Where(opp => opp.ACCOUNTMANAGERID == bio.SLXUID).Sum(opp => opp.SALESPOTENTIAL.GetValueOrDefault(_default)),
            Name = bio.FirstName + " " + bio.SurName
        }).ToList();
LiamHT
  • 1,312
  • 3
  • 12
  • 28