7

I am dealing with an issue that I can't seem to solve. I was given this stored procedure that has speed issues. It takes 35 seconds or so. When run adhoc, it completes instantly.

This made me think it was something to do with parameter sniffing. I have tried rewriting this to use local variables, optimize for unknown\variable\etc, with recompile.

The table the scan is happening on is massive, but even with stats updated with fullscan, estimates are still WAY off. Perhaps the query could just be rewritten but I would like to figure this out.

all data from testing environment with names altered .sql text files for sp and ad hoc query - let me know if this doesn't work

ad hoc plan - https://www.brentozar.com/pastetheplan/?id=Hkvg5Ge40

Parameters for ad hoc -

enter image description here

SP plan - https://www.brentozar.com/pastetheplan/?id=S1w49GxER

Parameters for SP -

parameters for sp


Parameters come in as:

(sp name)
    @ TransactionDescriptionId VARCHAR(32),
    @ CategoryCode VARCHAR(4000)
AS

DECLARE
    @ CategoryHash VARBINARY(16) = HashBytes('MD5', @CategoryCode);

The same values are passed in both the SP and ad hoc version. I thought it might be due to something with the hash being created as a local variable from the passed parameter, but in the ad hoc version it's doing the same (to me).

0

1 Answer 1

12

eh

I have tried rewriting this to use local variables, optimize for unknown\variable\etc, with recompile.

The first two methods listed here are pretty gosh-heckin' useless most of the time for making performance better. You may run into a situation every 5-10 years where they make one specific query better, but I'd keep those pretty low on my list of things to try.

In the slow plan, the part that sucks up the most time looks like this:

NUTS

You've apparently got a computed column on the Catalog_Items table that generates the MD5, and it's not persisted. You don't have to persist it, but the reason why you end up with a Filter operator on an expression, and you can't apply the predicate while you scan the table, is because it has to be calculated per-row at runtime.

In the query that's fast, you've got CategoryCodeHash in the index [IX_Catalog_Items_IdRetailer_CategoryCodeHash], which does persist the value and makes it seekable.

As to why you got such a bad estimate in the slow plan, it's unclear if the @CategoryHash argument is a parameter or local variable in this context, but...

        <ParameterList>
          <ColumnReference 
Column="@TransactionDescriptionId" 
ParameterDataType="varchar(32)" 
ParameterCompiledValue="'Instacart'" 
ParameterRuntimeValue="'Instacart'" 
/>
          <ColumnReference 
Column="@CategoryHash" 
ParameterDataType="binary(16)" 
ParameterRuntimeValue="0xD1DC3A8270A6F9394F88847D7F0050CF" 
/>
        </ParameterList>

The fact that @TransactionDescriptionId has the ParameterCompiledValue attribute, and @CategoryHash does not seems to indicate that it's not a sniffed parameter. In the fast plan, neither has the ParameterCompiledValue attribute, but without the full repro I don't know exactly what you did differently.

anyway

SQL Server's cost-based optimizer either found the seek + lookup more expensive than the clustered index scan + filter, or never arrived at the seek + lookup plan. Many people will tell you to "make the index covering" so that all of the columns you need would be available in the [IX_Catalog_Items_IdRetailer_CategoryCodeHash] index. That's an option, of course, but you can also do things to trick the optimizer into using narrow indexes, like using a self-join.

SELECT TOP (1)
    ci2.IdItem,
    ci2.IdCatalog,
    ci2.IdRetailer,
    ci2.LastUpdatedFromIdFile,
    ci2.Upc11,
    ci2.Upc12,
    ci2.Gtin,
    ci2.[Description],
    ci2.ShortDescription,
    ci2.CategoryName,
    ci2.SubCategoryName,
    ci2.FineLineCategoryName,
    ci2.SubCategoryCode,
    ci2.Manufacturer
FROM
(
    SELECT
        DelegateMerchantDescription = 
            ISNULL(delegateRetailers.TransactionDescriptionId, retailers.TransactionDescriptionId),
        retailers.TransactionDescriptionId,
        retailers.MerchantId
    FROM Retailers AS retailers (NOLOCK)
    LEFT JOIN Retailers AS delegateRetailers (NOLOCK)
      ON delegateRetailers.TransactionDescriptionId = retailers.DelegateCatalogMerchantDescription
    WHERE retailers.TransactionDescriptionId IS NOT NULL
) AS delegate
JOIN Retailers AS r
  ON r.TransactionDescriptionId = delegate.DelegateMerchantDescription
JOIN Catalog_Items AS ci WITH (NOLOCK)
  ON ci.IdRetailer = r.IdRetailer
/*This is new*/
JOIN Catalog_Items AS ci2 WITH (NOLOCK)
/*I'm not sure what the PK of Catalog_Items is*/  
  ON ci2.primary_key_column = ci.primary_key_column
WHERE ci.CategoryCodeHash = @CategoryHash
AND
(
    delegate.TransactionDescriptionId = @TransactionDescriptionId
OR  delegate.MerchantId = @TransactionDescriptionId
);

The idea is to join a table to itself on primary key values, with one reference to the table used for selecting all the columns you want to display, and the other reference responsible for joining and filtering and any other relational activities.

contextually

One thing that may be worth looking at as to why the plan is different would be various SET options. Some are required for indexed views, computed columns, and filtered indexes to be evaluated during optimization:

The NUMERIC_ROUNDABORT option must be set to OFF, and the following options must be set to ON:

ANSI_NULLS ANSI_PADDING ANSI_WARNINGS ARITHABORT CONCAT_NULL_YIELDS_NULL QUOTED_IDENTIFIER

The DatabaseContextSettingsId is different for the two plans:

NUTS

vs.

NUTS

You can see a couple of the settings here:

SELECT
    procedure_name = p.name,
    sm.uses_ansi_nulls,
    sm.uses_quoted_identifier
FROM sys.procedures AS p
JOIN sys.sql_modules AS sm
  ON p.object_id = sm.object_id
WHERE p.name = 'YourProcedureName';

You can also see if the database has any defaults that go against the requirements for all modules created with no specification otherwise:

SELECT
    database_name = name,
    is_ansi_null_default_on,
    is_ansi_nulls_on,
    is_ansi_padding_on,
    is_ansi_warnings_on,
    is_arithabort_on,
    is_concat_null_yields_null_on,
    is_numeric_roundabort_on,
    is_quoted_identifier_on,
    is_recursive_triggers_on,
    is_cursor_close_on_commit_on,
    is_local_cursor_default
FROM sys.databases
WHERE name = 'YourDatabaseName';

Depending on what you see, you may want to try re-creating the stored procedure with the following:

SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
SET QUOTED_IDENTIFIER ON;
SET NUMERIC_ROUNDABORT OFF;

some other stuff

For your general consideration, though not necessarily a big deal for this specific query:

  1. NOLOCK stinks and can return incorrect/inconsistent data
  2. TOP without an ORDER BY is an iffy proposition
  3. @Table Variables force modifications to run single-threaded, and are not usually so hot when you start joining them off to other tables as they don't get column-level statistics even when indexed

Note the CouldNotGenerateValidParallelPlan attribute of the INSERT operator. In this case though, both plan costs are so low they are unlikely to get a parallel execution plan anyway.

1
  • 1
    Both the join and set options both fixed this issue, both providing the same plans even after recompiles\dbcc freeproccache. I am going to look more into the set options that were running during the bad plan to narrow down what it was exactly for the sake of learning, but thank you very much Erik!
    – anakata
    Commented May 28 at 14:38

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.