SQL Server - Обновление данных с использованием дат EffectiveFrom и EffectiveTo - Оптимизация Upsert

У нас есть требование создать таблицу, которая поддерживает временную достоверность (т.е. для данного ключа, в данном случае Md5 в таблице ниже, не будет перекрывающихся периодов). Пользователям нужна возможность устанавливать даты EffectiveFrom и EffectiveTo, поэтому темпоральные таблицы бесполезны, поскольку они кажутся только для разрешения сгенерированных системой дат. Сценарий использования состоит в том, что массовые данные будут загружены с установленным диапазоном дат действительности, и это необходимо применить к существующим данным, чтобы гарантировать отсутствие перекрытия периодов.

Определения таблиц:

IF OBJECT_ID('dbo.IngestedData', 'U') IS NOT NULL
    DROP TABLE IngestedData;

CREATE TABLE IngestedData
(   
    ID INT IDENTITY(1,1),
    Md5 VARCHAR(15) NOT NULL,   
    EffectiveFrom DATE NOT NULL,    
    EffectiveTo DATE NOT NULL,
    UpdateUser VARCHAR(50),
    JsonData VARCHAR(MAX),
    CONSTRAINT CK_IngestedData_Start_End CHECK (EffectiveFrom < EffectiveTo),
    CONSTRAINT UK_IngestedData_Md5_Start_End UNIQUE(Md5, EffectiveFrom),
    PRIMARY KEY (Id)
);

CREATE NONCLUSTERED INDEX AK_IngestedData_Md5 
   ON IngestedData (Md5); 
CREATE NONCLUSTERED INDEX AK_IngestedData_EffectiveFrom   
   ON IngestedData (EffectiveFrom);
CREATE NONCLUSTERED INDEX AK_IngestedData_EffectiveTo 
   ON IngestedData (EffectiveTo);

Я написал процедуру upsert, которая хорошо работает для обновлений одной строки, а именно:

Процедура обновления:

CREATE PROCEDURE dbo.usp_UpsertIngestedDataRow 
    @Md5 VARCHAR(20),
    @EffectiveFrom DateTime,
    @EffectiveTo DateTime,
    @UpdateUser VARCHAR(50),
    @JsonData VARCHAR(MAX)
AS
BEGIN
    SET NOCOUNT ON;

    BEGIN TRY;
    BEGIN TRANSACTION;
        --Select the data that needs to be modified along with the action to be taken
        WITH NewRow(ID, Md5, EffectiveFrom, EffectiveTo, UpdateUser, JsonData, [Action]) AS
        (
            SELECT NULL, @Md5, @EffectiveFrom, @EffectiveTo, @UpdateUser, @JsonData, 'I'
        ),
        OverlappingRows(ID, Md5, EffectiveFrom, EffectiveTo, UpdateUser, JsonData) AS
        (
            SELECT 
                X.ID, X.Md5, X.EffectiveFrom, X.EffectiveTo, X.UpdateUser, X.JsonData 
            FROM 
                NewRow A 
            JOIN 
                IngestedData X ON (X.EffectiveFrom < A.EffectiveTo
                               AND X.EffectiveTo > A.EffectiveFrom)
                               AND A.Md5 = X.Md5
        ),
        NewStartRows(ID, Md5, EffectiveFrom, EffectiveTo, UpdateUser, JsonData, [Action]) AS
        (
            SELECT 
                s.ID, s.Md5, s.EffectiveFrom, 
                (SELECT DATEADD(DAY, -1, MIN(EffectiveFrom)) 
                 FROM NewRow), 
                s.UpdateUser, s.JsonData, 'I'
            FROM
                OverlappingRows s
            WHERE 
                EffectiveFrom < (SELECT MIN(EffectiveFrom) FROM NewRow)
        ),
        NewEndRows(ID, Md5, EffectiveFrom, EffectiveTo, UpdateUser, JsonData, [Action]) AS
        (
            SELECT 
                s.ID, s.Md5, 
                (SELECT DATEADD(DAY, 1, MIN(EffectiveTo)) 
                 FROM NewRow), 
                s.EffectiveTo, s.UpdateUser, s.JsonData, 'I'
            FROM  
                OverlappingRows s
            WHERE 
                EffectiveTo > (SELECT MAX(EffectiveTo) FROM NewRow)
        ),
        DeleteRows(ID, Md5, EffectiveFrom, EffectiveTo, UpdateUser, JsonData, [Action]) AS
        (
            SELECT 
                del.ID, del.Md5, del.EffectiveFrom, del.EffectiveTo, 
                del.UpdateUser, del.JsonData, 'D'
            FROM
                OverlappingRows del 
            INNER JOIN 
                NewRow n ON n.EffectiveFrom <= del.EffectiveFrom 
                         AND n.EffectiveTo >= del.EffectiveTo
        )
        SELECT *
        INTO #Temp
        FROM
            (SELECT * FROM NewRow
             UNION
             SELECT * FROM NewStartRows
             UNION
             SELECT * FROM NewEndRows
             UNION
             SELECT * FROM DeleteRows) AS Data;

        --Delete any rows that are being replaced
        DELETE FROM IngestedData WHERE ID IN (SELECT DISTINCT ID FROM #Temp)

        --Insert the replacement
        INSERT INTO IngestedData(Md5, EffectiveFrom, EffectiveTo, UpdateUser, JsonData) 
        SELECT Md5, EffectiveFrom, EffectiveTo, UpdateUser, JsonData
        FROM #Temp
        WHERE [Action] = 'I'

        --Drop temp table
        IF  OBJECT_ID('tempdb.dbo.#Temp', 'U') IS NOT NULL
            DROP TABLE #Temp

    COMMIT;
    END TRY
    BEGIN CATCH
        ROLLBACK;
        THROW;
    END CATCH


END
GO

Производительность отдельного вызова хорошая: время вызова составляет около 7 мс, даже когда таблица заполнена 10 000 000 записей. Проблема заключается в большом количестве обновлений. Выполнение указанной выше хранимой процедуры для 35 000 записей с помощью курсора занимает около 5 минут.

Я попытался переписать процедуру так, чтобы она принимала табличную переменную, которая позволяла бы DML использовать операции над наборами, но потерялась в логике. Может ли кто-нибудь помочь превратить приведенную выше логику в обновление на основе набора, которое следует этому шаблону:

Новая хранимая процедура:

CREATE PROCEDURE usp_BulkUpsertIngestedData 
    @UpdateUser VARCHAR(15), 
    @NewRows DataIngestionRecord READONLY
AS
BEGIN

Определение типа

CREATE TYPE DataIngestionRecord AS TABLE
            (
                Md5 VARCHAR(15) NOT NULL,   
                EffectiveFrom DATE NOT NULL,    
                EffectiveTo DATE NOT NULL,
                JsonData VARCHAR(MAX)
            )

person Iain Macnab    schedule 03.10.2017    source источник
comment
Думали ли вы об удалении / отключении индексов и воссоздании / включении их после импорта? Это должно повысить производительность.   -  person Tanner    schedule 03.10.2017
comment
это тоже может быть полезно: stackoverflow.com/a/2899468/57475   -  person Tanner    schedule 03.10.2017
comment
и это: docs.microsoft.com/en-us/sql/relational-databases/import-export/   -  person Tanner    schedule 03.10.2017
comment
Спасибо @Tanner, но, возможно, я ввел вас в заблуждение. Я не использую массовый импорт, так как у них нет возможности применить необходимые изменения временного периода к существующим данным. Я ищу способ применить ту же логику, что и в хранимой процедуре, для одного обновления к набору обновлений, переданных в переменной таблицы. Операции с наборами обычно выполняются быстрее в SQL, который многократно выполняет операции на основе строк. Я изменю свой вопрос, чтобы было понятнее   -  person Iain Macnab    schedule 03.10.2017
comment
@Tanner, прочитав ваш первый комментарий более подробно, я быстро опробовал его на небольшом наборе данных, и он значительно улучшил производительность. Я собираюсь провести тест на большем наборе данных, но это похоже на способ значительно повысить производительность!   -  person Iain Macnab    schedule 03.10.2017
comment
Кроме того, связанный cte, вероятно, снизит производительность с большими наборами данных. попробуйте вместо этого преобразовать cte в таблицы #temp, я обнаружил, что в прошлом это помогало с точки зрения производительности. ознакомьтесь с примерами в этом ответе: stackoverflow.com/a/26205087/57475, чтобы узнать, применимо ли это в вашем кейс.   -  person Tanner    schedule 03.10.2017


Ответы (1)


Попробовав как отключить, затем перестроить индексы, так и удалить CTE в процедуре, я обнаружил, что при использовании построчных обновлений производительность действительно не улучшилась.

Я взял другой курс и решил, что могу ограничить вариант использования upsert, указав, что в любом данном обновлении для каждого уникального Md5 может применяться только один новый временной диапазон. Это упростило логику, необходимую для преобразования хранимой процедуры в операцию на основе набора (и соответствует нашим требованиям).

Я последовал совету @Tanner и удалил связанный CTE из хранимой процедуры. Последняя хранимая процедура оказалась такой:

CREATE PROCEDURE dbo.usp_UpsertIngestedDataSet 
    @NewRows DataIngestionRecord READONLY,
    @UpdateUser VARCHAR(15)
AS
BEGIN

    SET NOCOUNT ON;

    --Ensure that there are not multiple temporal regions in the update data for a given key
    SELECT Md5
    INTO #Duplicates
    FROM @NewRows
    GROUP BY Md5
    HAVING COUNT(*) > 1;

    IF(@@ROWCOUNT > 0) BEGIN
        DECLARE @Err VARCHAR(MAX)
        SELECT @Err = COALESCE(@Err + CHAR(13), '') + Md5
        FROM #Duplicates
        ORDER BY Md5;

        SET @Err = 'The following Md5 values have multiple temporal ranges in the uploaded data which is not supported: ' + char(13) + @Err;

        THROW 50002, @Err, 1;
    END

    --Determine all overlapping rows from the existing data set
    SELECT id.ID, id.Md5, id.EffectiveFrom, id.EffectiveTo, id.UpdateUser, id.JsonData
    INTO #OverlappingRecords
    FROM IngestedData id JOIN @NewRows nr ON 
        id.Md5 = nr.Md5 AND
        (id.EffectiveFrom < nr.EffectiveTo 
        AND id.EffectiveTo > nr.EffectiveFrom)

    --Calculate truncation of left overlapping rows
    SELECT ol.Id,ol.Md5, ol.EffectiveFrom, DATEADD(DAY,-1, nr.EffectiveFrom) AS EffectiveTo, 'U' AS Action
    INTO #Changes
    FROM #OverlappingRecords ol JOIN @NewRows nr ON 
        ol.Md5 = nr.Md5 
        AND ol.EffectiveFrom < nr.EffectiveFrom

    --Calculate truncation of right overlapping rows
    INSERT INTO #Changes
    SELECT ol.ID, ol.Md5, DATEADD(DAY,1,nr.EffectiveTo), ol.EffectiveTo, 'U'
    FROM #OverlappingRecords ol JOIN @NewRows nr ON
        ol.Md5 = nr.Md5 
        AND ol.EffectiveTo > nr.EffectiveTo
        AND ol.EffectiveFrom > nr.EffectiveFrom;

    --If any area overlaps both the left and right of a new region we need a new insert for the right overlap
    SELECT ol.ID, ol.Md5, DATEADD(DAY,1,nr.EffectiveTo) AS EffectiveFrom, ol.EffectiveTo, 'I' AS [Action]
    INTO #InsertRecords
    FROM #OverlappingRecords ol JOIN @NewRows nr ON
        ol.Md5 = nr.Md5 
        AND ol.EffectiveTo > nr.EffectiveTo
        AND ol.EffectiveFrom < nr.EffectiveFrom;

    BEGIN TRANSACTION;

    --Delete all overwritten regions (i.e. existing temporal ranges that are completely replaced by a new range)
    DELETE FROM IngestedData 
    WHERE ID IN (SELECT ol.ID
                 FROM #OverlappingRecords ol JOIN @NewRows nr ON 
                    ol.Md5 = nr.Md5 
                    AND nr.EffectiveFrom <= ol.EffectiveFrom 
                    AND nr.EffectiveTo >= ol.EffectiveTo);

    --Insert New Data (both from uploaded data and from existing region splits)
    INSERT INTO IngestedData (Md5, EffectiveFrom, EffectiveTo, UpdateUser, JsonData)
    SELECT Md5, EffectiveFrom, EffectiveTo, 'user2', JsonData
    FROM @NewRows
    UNION
    SELECT id.Md5,ir.EffectiveFrom, ir.EffectiveTo,id.UpdateUser,id.JsonData
    FROM IngestedData id JOIN #InsertRecords ir
    ON id.ID = ir.ID AND ir.[Action] = 'I';

    --Update truncated rows
    Update id
    SET EffectiveFrom = u.EffectiveFrom, EffectiveTo = u.EffectiveTo
    FROM IngestedData id JOIN #Changes u ON id.ID = u.ID AND u.[Action] = 'U';

    COMMIT;

END
GO

Преобразование этого кода в логику, основанную на наборах, имело значение, и теперь эта версия выполняет 20 000 обновлений против 1 000 000 строк данных за незначительные 7370 мс.

person Iain Macnab    schedule 03.10.2017