2

I have a query which first performs joins inside cte and then based on cte join with other table and perform group by with some aggregations and get the top 15 records.

WITH join_table as (
    SELECT pl.yearmonth, 
    pl.pdid, 
    pl.OrderId,
    pl.OrderNo AS OrderNumber, 
    pl.pmc,
    pl.pmcode AS pmaa,
    pl.spid,
    pl.issuingdate,
    pl.plguid, 
    pusa.SizeCode,
    pusa.atnumber,
    pu.tpc, 
    pu.ItemQty AS puItemQty
    FROM table1 pl 
    JOIN table3 pusa ON pl.plguid=pusa.plguid
    JOIN table2 pu ON pusa.plguid=pu.plguid AND pusa.puguid=pu.puguid
    WHERE pl.pmode='BBB'
      and pl.pltype='CCC'
      and pl.plstatus='AAA'
      and pu.ctcode='S'
      AND pu.ctype='DDD'
      AND pu.tpc <> 'EEE' 
),
get_top_15PM as (
    SELECT TOP 15 pl.pmc, sum(cast(puItemQty as BIGINT)) AS SumpuItemQty
    FROM join_table pl
    join abctable b on b.pdid= pl.pdid and b.spid= pl.spid
    WHERE pl.issuingdate > '2021-8-1'
    and prodtypeid in (select prodtypeid from prodtype where prodgrpid in (3,4,5,7,8,9,10,13,20))
    group by pl.pmc
    ORDER BY SumpuItemQty DESC
)
SELECT DISTINCT 
    pl.yearmonth, 
    pl.pdid, 
    pl.OrderId,
    pl.OrderNumber, 
    pl.pmc,
    pl.pmaa,
    pl.spid,
    pl.issuingdate, 
    pl.atnumber,
    pl.tpc, 
    pl.puItemQty,
    s.SizeName AS Size,
    s.SizeLength,
    s.SizeWidth,
    s.SizeHeight,
    s.SizeVolume,
    s.SizeWeight
FROM join_table pl 
JOIN PLSize s ON pl.plguid=s.plguid AND pl.SizeCode=s.SizeCode
WHERE  pl.pmc IN ( SELECT pmc from get_top_15PM) 
      AND pl.yearmonth>=202100

This query is taking 20+ hours to run on Azure SQL Database.

Details:

Azure database pricing tier: S6 with 750GB storage with 20% unused storage space left

TableName   rows         TotalSpaceGB   UsedSpaceGB UnusedSpaceGB
table2      332,318,173  117.72         117.71      0.01
table3      153,700,352  60.78          60.76       0.01
table1      15,339,815   13.21          13.20       0.01
abctable    1,232,868    0.81           0.80        0.00

Estimation execution plan with query

Actual Execution plan with query

wait type: (14ms)PAGEIOLATCH_SH:dev-db:1(*) or NULL sometimes. Got this using sp_WhoIsActive.

Note:

Actual execution plan was taken from the running query as shown in this SO answer

This tables contains clustered index on primary keys and nonclustered index on other fields which I am not using in filters or joins or order by.

table1 is parent of table2 and table2 is parent of table3

Any advice or suggestions are highly appreciable.

Update-1

--table1
index_name          index_description                 index_keys
idx_table1_pmcode   nonclustered                      pmcode
PK_table1           clustered, unique, primary key    plguid

--table2
index_name          index_description                 index_keys
IX_table2_plguid    nonclustered                      plguid
PK_table2           clustered, unique, primary key    puguid

--table3
index_name          index_description                 index_keys
IX_table3_plguid    nonclustered                      plguid
PK_table3           clustered, unique, primary key    pusguid

--abctable
index_name             index_description               index_keys
nci_wi_abctable_2BC1D  nonclustered                    dpyearmonth
nci_wi_abctable_FAA89  nonclustered                    dpnumber, ptid
PK_abctable            clustered, unique, primary key  OrderId

DDL

--table1
SET ANSI_PADDING ON
GO

CREATE NONCLUSTERED INDEX [idx_table1_pmcode] ON [dbo].[table1]
(
    [pmcode] ASC
)
INCLUDE([OrderID],[OrderNo],[yearmonth],[pdid]) WITH (STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO

--table2
SET ANSI_PADDING ON
GO

CREATE NONCLUSTERED INDEX [IX_table2_plguid] ON [dbo].[table2]
(
    [plguid] ASC
)WITH (STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO

--table3
SET ANSI_PADDING ON
GO

CREATE NONCLUSTERED INDEX [IX_table3_plguid] ON [dbo].[table3]
(
    [plguid] ASC
)WITH (STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO

--abctable
SET ANSI_PADDING ON
GO


CREATE NONCLUSTERED INDEX [nci_wi_abctable_2BC1D] ON [dbo].[abctable]
(
    [dpyearmonth] ASC
)
INCLUDE([pduid],[pdid]) WITH (STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO

CREATE NONCLUSTERED INDEX [nci_wi_abctable_FAA89] ON [dbo].[abctable]
(
    [dpyearmonth] ASC,
    [ptid] ASC
)
INCLUDE([OrderStatus],[spid]) WITH (STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO
O. Jones
  • 103,626
  • 17
  • 118
  • 172
Python coder
  • 743
  • 5
  • 18
  • 2
    Could you provide the table and index DDL ? Where did "pmode" come from ? – SQLpro Aug 02 '23 at 07:23
  • 2
    It seems like you have very few covering Indexes here as most table references are a scan. Also is that `DISTINCT` needed? O that meant columns and rows it likely to be *extremely " expensive. Normally such an operator is omused on just 1 or 2 columns. Perhaps one of you `JOIN`s are wrong and that's why you're getting duplicate rows? – Thom A Aug 02 '23 at 07:35
  • @SQLpro By *table and index DDL*, you mean schema definition, what is index DDL? pmode is from `table1 pl` table – Python coder Aug 02 '23 at 07:37
  • 1
    DDL is the part of SQL for Data Definition Language that includes all CREATE, ALTER, DROP... of logical and physical objects. So gives us the CREATE of all table's indexes. – SQLpro Aug 02 '23 at 07:40
  • @ThomA `DISTINCT` is needed because I am taking the fewer columns and they seem to duplicates. Any suggestions to try out in this? or Do you mean without `DISTINCT` with there be a good performance improvement? and can you share usually how much this kind of queries will take, is it minutes or hours? – Python coder Aug 02 '23 at 07:48
  • 17 columns isn't "fewer columns" @Pythoncoder . Like I mentioned, I suspect you have a problem with one of your `JOIN`s creating a many to many relationship and therefore you "think" you have duplicates. Eliminating that `DISTINCT` will likely also be a good performance improvement as you're be fixing the `JOIN`. – Thom A Aug 02 '23 at 08:03
  • @SQLpro, updated the post, can you check now? – Python coder Aug 02 '23 at 08:03
  • So *none* of your indexes `INCLUDE` any columns? Then they aren't covering. Can you actually post the DDL though please? – Thom A Aug 02 '23 at 08:06
  • @ThomA Some of the indexes have include, I have update the post with DDL – Python coder Aug 02 '23 at 08:26
  • I'm a bit suspicious of the costings in that plan. I'm not seeing any "Actual" properties. If the 100% cost node is to be believed then a filtered index on `table2` where `ctype='DDD'` and `tpc <> 'EEE'` with appropriate key and include columns probably wouldn't go astray - depending on what percentage of rows actually satisfy those conditions (the fewer the better). – AlwaysLearning Aug 02 '23 at 09:22
  • @AlwaysLearning I have taken that execution plan while the query is still running, I have added estimated execution plan to post. The assumption is, the final output contains around 4M records. I didn't exactly followed your advice, what can be improved in the query? The estimated plan look little different, because I try use sub-query to improve performance with no luck yet – Python coder Aug 02 '23 at 09:31
  • Filtered indexes only reference a subset of rows in the tables they index, i.e.: only those rows whose values match the filter conditions of the index. When a query can utilize a filtered index that contains only a small subset of table rows the performance benefits can be substantial - less pages read from storage, faster tree seeks/traversals, etc.. A filtered index that is also covering can be a big win. – AlwaysLearning Aug 02 '23 at 09:38

2 Answers2

2

Apart from the improvements in indexes, you can also significantly improve this query by using window functions.

Note how join_table is only referenced once in this query. We do a windowed sum by pmc, then we number the rows using DENSE_RANK, finally taking the rows numbered 15 or less.

You should also remove DISTINCT and instead ensure your rows are unique a different way, such as by limiting your joins better or using a row-number.

WITH join_table as (
  SELECT
    pl.yearmonth, 
    pl.pdid, 
    pl.OrderId,
    pl.OrderNo AS OrderNumber, 
    pl.pmc,
    pl.pmcode AS pmaa,
    pl.spid,
    pl.issuingdate,
    pl.plguid, 
    pusa.SizeCode,
    pusa.atnumber,
    pu.tpc, 
    pu.ItemQty AS puItemQty,
    SUM(CAST(pu.ItemQty AS bigint)) OVER (PARTITION BY pl.pmc) AS SumpuItemQty
  FROM table1 pl 
  JOIN table3 pusa ON pl.plguid=pusa.plguid
  JOIN table2 pu ON pusa.plguid=pu.plguid AND pusa.puguid=pu.puguid
  WHERE pl.pmode='BBB'
    and pl.pltype='CCC'
    and pl.plstatus='AAA'
    and pu.ctcode='S'
    AND pu.ctype='DDD'
    AND pu.tpc <> 'EEE' 
    AND pl.issuingdate >= '20210801'
    AND pl.yearmonth >= 202100
),
get_top_15PM as (
    SELECT *,
      DENSE_RANK() OVER (PARTITION BY pl.pmc ORDER BY plSumpuItemQty DESC) AS dr
    FROM join_table pl
    JOIN abctable b on b.pdid = pl.pdid and b.spid = pl.spid
    WHERE prodtypeid in (
        select pd.prodtypeid
        from prodtype pd
        where prodgrpid in (3,4,5,7,8,9,10,13,20)
    )
)
SELECT
    pl.yearmonth, 
    pl.pdid, 
    pl.OrderId,
    pl.OrderNumber, 
    pl.pmc,
    pl.pmaa,
    pl.spid,
    pl.issuingdate, 
    pl.atnumber,
    pl.tpc, 
    pl.puItemQty,
    s.SizeName AS Size,
    s.SizeLength,
    s.SizeWidth,
    s.SizeHeight,
    s.SizeVolume,
    s.SizeWeight
FROM get_top_15PM pl 
JOIN PLSize s ON pl.plguid = s.plguid AND pl.SizeCode = s.SizeCode
WHERE pl.dr <= 15
Charlieface
  • 52,284
  • 6
  • 19
  • 43
  • +1 Thanks for improvising the query. Getting `CardinalityEstimate` warnings for `CONVERT(bigint,[pu].[ItemQty],0)` and `CONVERT(int,[pl].[spid],0)`, does that lower the performance or efficient execution plan won't be used? – Python coder Aug 03 '23 at 04:44
  • It's just a warning that conversions may affect cardinality estimations. But the first one is definitely not going to make any difference because you are just doing `SUM` on it. – Charlieface Aug 03 '23 at 09:35
  • Thanks for clarifying. Replacing `table1` with subquery which contains all the filters like `(select col1,col2, .. from table1 where filter1, filter2) as table1`, will improve the performance? – Python coder Aug 03 '23 at 13:28
  • I doubt it, `WHERE` clauses are anyway calculated before `OVER` clauses. You should push `WHERE` clauses from lower CTEs into the higher ones where possible (but still keeping your desired semantics) – Charlieface Aug 03 '23 at 13:33
  • In the provided query, does `get_top_15PM` considered as lower CTE? – Python coder Aug 03 '23 at 13:38
  • Yes. Basically, move as much `WHERE` clauses as you can up to the top. And move joins the other direction (again only where possible, if you don't need them to do the summing and ranking), so that you join on a smaller subset. It's hard to advise properly in this case as I can't see what the tables are supposed to be. – Charlieface Aug 03 '23 at 13:45
  • Noted! Thanks for the details, I will try to do that. – Python coder Aug 03 '23 at 13:50
1

Keep in mind that SQL is a declarative language. You tell it what you want, and the query engine figures out how to get it. By contrast, python and other languages are procedural. You tell them how to get what you want. This is relevant because SQL Server's query planner shuffles around the terms in your CTEs and main query. It doesn't usually use the CTEs as procedures for getting your results.

The server's query planner uses indexes wherever they are helpful to random-access your table data. SQL Server indexes are usually B-TREE indexes. You can think of a B-TREE index as if it were an old-school telephone directory book. To find me (O.Jones), for example, you'll open up the book to the letter J. Then you'll look for the first "Jones" in the surnames. Then you'll look for the first "O" in the given-name column. Then you'll scan through the "O"s to try and figure out which one is me.

Indexes work the same way, and random-accessing them to the first interesting entry is a very fast O(log(n)) operation. Then scanning the entries one by one from there is O(1). So you want to use an index. Scanning through the book looking at every entry would take an absurd amount of time. Like 20 hours or more. The point of an index is to reduce the number of entries we must scan through one by one.

For this to work, you need a multicolumn index appropriate to your query. Let's look at your table1 (the one with a third of a gigarow in it). Various parts of your large query apply WHERE filters to these columns. (This may be incomplete, your query is large.)

Column Expression Type of expression
pmode pl.mode='BBB' Equality
pltype pl.pltype='CCC' Equality
plstatus pl.plstatus='AAA' Equality
issuingdate pl.issuingdate > '2021-8-1' Open-ended range scan
yearmonth pl.yearmonth >= 202100 Open-ended range scan

So, we have three equality matches and two range scans here, which is a reasonably good situation for a B-TREE index. This index might help.

CREATE INDEX mode_pltype_plstatus_issuingdate
          ON table1(mode, pltype, plstatus, issuingdate);

Edit A multi-column B-TREE index is most useful when it contains zero or more columns for equality matching, and then just one column for range scanning. This query calls for two range scans. I chose issuingdate for the index's single range scan column, because I guess it will be more selective -- match fewer rows -- than yearmonth. But that's a guess on my part.

The query planner will random-access this index to the first matching row of that big table, then scan the index until it gets to the last matching row, then stop. That's faster than looking through the whole table.

Also, your actual execution plan for the running query recommends creating this index.

CREATE INDEX plguid_sizecode 
          ON table3(plguid, SizeCode)
     INCLUDE (puguid, atnumber);

That index will help accelerate two different JOINs, the last one in the query and the first one in the first CTE.

The indexes recommended by SQL Server actual execution plans are generally useful and worth paying attention to.

Pro tip Read Markus Winand's e-book https://use-the-index-luke.com/ to learn a lot about this topic.

Pro tip Be careful with datestamp inequalities. pl.issuingdate > '2021-8-1' removes rows occurring exactly at midnight on 1-Aug-2021, but takes all the rows from that day after midnight. It seems unlikely that's what you want.

O. Jones
  • 103,626
  • 17
  • 118
  • 172
  • Thanks for the detailed answer. I got to know new things in depth. What is the reason for not considering `yearmonth` while creating the `mode_pltype_plstatus_issuingdate` index? – Python coder Aug 02 '23 at 11:41
  • 1
    Please see my edit. – O. Jones Aug 02 '23 at 11:55
  • As you mentioned that execution plan is suggesting to create `plguid_sizecode` index, but why `atnumber` is added inside `INCLUDE`, it is not used in joins or order by or in where clause? Can I ignore `atnumber` while creating index? – Python coder Aug 02 '23 at 13:59
  • You could omit it, but why? Read up on covering indexes. If the execution plan says it needs that column in the index, it probably does. Notice that a SELECT DISTINCT is a kind of GROUP BY operation and the query planner has probably determined it can use that column as scanned from the index to help that operation, rather than having to bounce over to the table to get it. That bounce-over to the table takes IO and CPU to carry out. – O. Jones Aug 02 '23 at 15:20
  • Not sure how much it will save memory, if I omit one field in `INCLUDE`. I see the other index on that table is taking around 30 GB, so if It's not having major impact on performance I can ignore it. And I have created index on `table1` as per your suggestions and added some field in `INCLUDE` as the execution plan recommendations. `table1` with filters is working fast, getting values in couples of mins. This new index on `table1` is taking around 12GB of space. – Python coder Aug 02 '23 at 16:21