2

We have a multi-tenant database, data separation is achieved with a key attached to all tables. The number of rows in each table may be very different for each tenant (0 to 500000+). Since migrating to SQL Server 2017 sometimes very bad execution plans are used. We didn't have any problems with SQL Server 2008 previously used.

The biggest problem is, that sometimes non-correlated sub queries are executed in a loop for each row of the outer query. The subquery would take 0 seconds, and the outer query using an IN(list of ids) also takes 0 seconds, but together they run for several minutes.

I have tried different solutions:

  • OPTION(RECOMPILE)
  • Changing to a JOIN and OPTION(FORCE ORDER)
  • UPDATE STATISTICS for the tables
  • Parameter Sniffing is enabled but doesn't make a difference

none of these solved the problem.

Example Query:

SELECT address.adrId, address.adrCity FROM address 
WHERE address.orgId=1 AND
address.adrId IN (SELECT instAddress.adrId 
                    FROM instAddress
                    WHERE instAddress.instId = 12345 
                    AND instAddress.orgId = 1) 

UPDATE: actual execution plan, number of executions and estimated number of executions highlighted. enter image description here

Is it somehow possible to tell SQL Server to always execute the sub query first and only once?

UPDATE:

This one query changed to the correct execution plan after latest update of statistics. This one and similar queries are generated by our application dynamically. We would like to prevent us from rewriting the complete database tier.

I chose this query for the example because it was the most basic one where the issue occurred. Another example (fetching translations for some names) where the independent sub query is executed in loops for the outer query actual execution plan available: https://pastebin.com/tbB0vPUZ

select institutions.inst_id , institutions.inst_nr , inst_name , 
translations1.trans_to as trans_to , translations2.trans_to as trans_to2 , 
translations3.trans_to as trans_to3 , translations4.trans_to as trans_to4 
from institutions 
left join translations translations1 on institutions.inst_nr = translations1.trans_from and translations1.lang_locale = @0 and translations1.org_id = @1 
left join translations translations2 on institutions.inst_nr = translations2.trans_from and translations2.lang_locale = @2 and translations2.org_id = @3 
left join translations translations3 on institutions.inst_name = translations3.trans_from and translations3.lang_locale = @4 and translations3.org_id = @5 
left join translations translations4 on institutions.inst_name = translations4.trans_from and translations4.lang_locale = @6 and translations4.org_id = @7 
where ( institutions.org_id = @8 and lcd_id = @12 
and exists ( 
    select inst_id_partner 
    from agreements 
    where agreements.inst_id_partner = institutions.inst_id and org_id = @13 and agree_id in ( 
        select agree_id 
        from agreements 
        where org_id = @14 and pers in ( @15 , @16 ) and art in ( @17 , @18 ) and prog_id not in ( 
            select prog_id 
            from programs 
            where org_id = @19 and is_not_part_of_all_prog = @20 ) 
        and agree_id in ( 
            select agree_id 
            from year_agree where year_id = @21 and sem_id = @22 ) 
        and ( agree_id not in ( 
            select agree_id 
            from agree_stat 
            where org_id = @23 ) 
        or agree_id in ( 
            select agree_id 
            from agree_stat 
            where org_id = @24 and agree_stat.year_id = @25 and agree_stat.sem_id = @26 and agree_stat.count1 < agreements.total ) ) 
        and inst_id in ( @27 ) ) 
) 
order by translations1.trans_to
Georg
  • 183
  • 4
  • 18
  • Did you try EXISTS? – EzLo Feb 13 '19 at 16:28
  • Have you tried using `EXISTS` in stead of `IN` ? – GuidoG Feb 13 '19 at 16:30
  • 2
    I was about to suggest either EXISTS(), or populate a table variable with the subquery and JOIN to it. – Tab Alleman Feb 13 '19 at 16:31
  • I came here to suggest sticking your subquery in a CTE and then JOINing that to your main query. – digital.aaron Feb 13 '19 at 16:36
  • @TabAlleman, table variables have issues with statistics and cardinality estimation and they have overhead comparable to temporary tables, in this case temporary tables will provide better information for a query optimizer to produce a better plan with a similar cost – Alexander Volok Feb 13 '19 at 16:41
  • It chooses nested loops because it thinks that is the most efficient approach. You could add an `OPTION (MERGE JOIN, HASH JOIN)` so it doesn't consider that but best to identify why it chooses that plan in the first place - if it is not the most efficient approach – Martin Smith Feb 13 '19 at 16:42
  • Some possibly useful insights here: https://stackoverflow.com/questions/3577658/why-is-this-non-correlated-subquery-causing-such-problems?rq=1 – Tab Alleman Feb 13 '19 at 16:45
  • Can you provide the plan XML for the actual (not estimated) plan. This has additional useful info and I'm not sure what metrics are being highlighted in the german tooltips – Martin Smith Feb 13 '19 at 16:49
  • What does the *actual* query and *actual* execution plan look like? That image shows multiple operations. You could get rid of the lookups if for example `adrId`, `instId` and `orgId` were covered by the same index. – Panagiotis Kanavos Feb 13 '19 at 16:54
  • I updated the question, exists didn't change anything, with the join option specified it couldn't generate an execution plan – Georg Feb 13 '19 at 18:12
  • @Georg, "This one query changed to the correct execution plan after latest update of statistics" - so stats update resolved your initial issue? – Alexander Volok Feb 13 '19 at 18:32
  • what compatibility level is set on that user db? What trace flags are enabled? – Alexander Volok Feb 13 '19 at 18:43
  • Compatibility Level is set between 100 and 120 on different databases running our application, no trace flags are enabled. The query in the initial example was one of about 10 similar queries for which this issue occurred, some of them appear and disappear when the statistics or the parameters change, but eventually pop up gain after a while. – Georg Feb 14 '19 at 09:16
  • *"some of them appear and disappear when the **statistics** or the parameters change"*, then keeping up your statistics up to date is important. As I mentioned in the answer, consider to set latest compatibility level or enable trace flag 2371, so your statistics will be autoupdated on more frequent basis, also consider to update statistics of involved tables manually on a frequent basis, during maintenance windows. – Alexander Volok Feb 14 '19 at 10:39

2 Answers2

1

I think that rewrite of the original query to use EXISTS can lead to more robust query plans:

SELECT a.adrId, a.adrCity FROM address  a
WHERE EXISTS ( SELECT *
                    FROM instAddress i
                    WHERE i.instId = 12345 
                    and i.orgId=1
                    AND i.orgId = d.orgId 
                    AND i.adrId = a.adrId 

             )

Another observation is that such index will increase a chance that query optimizer will set a correlated query as an initial one to execute and then fetch rows from [address]

CREATE INDEX IX_1 ON instAddress (instId) INCLUDE (orgid, adrId )

Another possible reason - outdated statistics.

According to OP:

The query in the initial example was one of about 10 similar queries for which this issue occurred, some of them appear and disappear when the statistics or the parameters change, but eventually pop up gain after a while

If compatibility level is lower than 130, default threshold of statistics to be auto updated is 20% changed rows. This is especially not nice for large tables and can be fixed by:

DBCC TRACEON (2371, -1)

Another possible reason: New cardinality estimator and correlated queries.

Since migrating to SQL Server 2017 sometimes very bad execution plans are used. We didn't have any problems with SQL Server 2008 previously used.

The old (legacy) cardinality estimation (that was available in SQL 2008) can be enabled as a query hint, on a database level or on instance level, via trace flag

SELECT .. FROM .. WHERE ..
OPTION (USE HINT ('FORCE_LEGACY_CARDINALITY_ESTIMATION'));  

or on a database level:

USE DB
ALTER DATABASE SCOPED CONFIGURATION SET LEGACY_CARDINALITY_ESTIMATION = ON

Related:

Alexander Volok
  • 5,630
  • 3
  • 17
  • 33
  • I don't. `IN` and `EXISTS` generally get identical plans (unlike `NOT IN` and `NOT EXISTS`) - though your rewrite does if anything make it more difficult for the `orgId=1` predicate to be pushed to both tables as it relies on the implied predicate logic figuring this out. And you have lost the `orgId=1` condition entirely anyway. I assume this was supposed to be present somewhere – Martin Smith Feb 13 '19 at 16:54
  • @MartinSmith, thanks fixed missed orgId. From my observation EXISTS quite often results into a left semi join with a **desired** physical operator. The main idea was to use both orgid and adrid as a predicate to increase a chance of a correct estimation. – Alexander Volok Feb 13 '19 at 17:02
  • If you add `OPTION (RECOMPILE, QUERYTRACEON 8606, querytraceon 3604)` you will see the "Tree After Project Normalization" is basically the same and it doesn't make any difference – Martin Smith Feb 13 '19 at 17:13
  • @MartinSmith, I tend to agree with you but still have some doubts. Original query has only one column for a correlation, my one has two, this should have an impact on a final query plan. But in the end, indeed both (exists or in ) to use left semi joins. – Alexander Volok Feb 13 '19 at 17:17
  • The input tree to the optimisation process is identical so there is no difference in the output. DB Fiddle doesn't allow these trace flags but try this locally and look at the output https://dbfiddle.uk/?rdbms=sqlserver_2017&fiddle=a3f3243a9788d6357a5a93c295dd7b26 – Martin Smith Feb 13 '19 at 17:18
  • It already knows that it is only selecting `orgId = 1` from both tables and so they are bound to be the same and no need to evaluate that predicate – Martin Smith Feb 13 '19 at 17:23
  • That quoted text is not from an authorative source nor is it accurate. Looks to be written by someone that doesn't understand SQL is declarative and goes through an optimization process. Try and come up with an example where you don't get the same plan – Martin Smith Feb 13 '19 at 18:03
  • 1
    @MartinSmith, I am agree, it was perhaps like that in earlier versions, but not in modern. That is why i removes that text after evaluation – Alexander Volok Feb 13 '19 at 18:09
1

You can also achieve this with a standard join:

SELECT a.adrId, a.adrCity 
FROM address  a
inner join instAddress i on i.orgId = a.ordId and i.adrId = a.adrId
where i.instId = 12345 and a.orgId = 1
simon at rcl
  • 7,326
  • 1
  • 17
  • 24