Paralleles Bulk Insert mit SqlBulkCopy

22. März 2016

Vor kurzem ist bei einem unserer Kunden eine recht exotische SQL Server Fehlermeldung aufgetreten, die ich hier dokumentieren möchte.

Gegeben sei eine Datenbank mit folgendem Schema.

CREATE TABLE [Master]

(

    id INT IDENTITY(1, 1) NOT NULL

    ,start_time datetime2 NOT NULL

    ,some_other_run_specific_information VARCHAR(256)

    ,CONSTRAINT PK__Master PRIMARY KEY CLUSTERED (id ASC)

);


CREATE TABLE Details

(

    master_id INT NOT NULL

    ,payload1 INT NOT NULL

    ,payload2 VARCHAR(1024) NOT NULL

    ,CONSTRAINT FK__master_id FOREIGN KEY (master_id) REFERENCES [Master] (id),

);

GO


CREATE CLUSTERED INDEX IDX__Details_master_id ON Details(master_id);

Die beiden Tabellen werden von einer .NET Anwendung befüllt. Die Beladung in die Details-Tabelle erfolgt mit System.Data.SqlClient.SqlBulkCopy. Prinzipiell können auch mehrere Prozesse gleichzeitig gestartet werden, die ihre Ergebnisse in diese Tabellen schreiben, da aber SqlBulkCopy mit der TABLOCK-Option ausgeführt wird, schließen sich die Schreiboperationen in die Details-Tabelle praktisch aus.

Sporadisch trat auf neu aufgesetzten Umgebungen (z.B. nächtlicher Autotest auf einem Buildserver, der auch die Datenbank aus Initialskripten neu anlegt) bei parallel laufenden Beladungsprozessen folgender Fehler auf

Error: 4891, Severity: 16, State: 1

Insert bulk failed due to a schema change of the target table.

Die wohl beste Beschreibung zu diesem Fehler findet man in diesem MSDN Blog.

Allerdings war es nicht ganz klar, was in unserem konkreten Fall die (unerwünschte) Rekompilierung erzwingt (Autostats scheinen z.B. kein Problem zu sein).

Für die einfachere und zuverlässigere Reproduzierung des Problems habe ich eine kleine C# Konsolenanwendung gebastelt. Die Anwendung fügt 200 Mal einen Datensatz in die Master-Tabelle und passend dazu 400 Datensätze in die Details-Tabelle (also 80000 Datensätze in der Details-Tabelle insgesamt). Um die parallele Beladung wie im tatsächlichen Fall zu simulieren, starte ich mehrere Instanzen der Anwendung. Die einfachste Lösung dafür ist eine Batch-Datei mit mehreren “start /b ParallelBulkInsert.exe” Aufrufen.

Nach ein paar Versuchen war das Problem reproduziert. Um die Datenbank nicht immer wieder aus Initialskripten neu aufsetzen zu müssen, habe ich in meine Anwendung einen “harmlosen” SQL-Befehl am Ende der Ausführung aufgenommen, der auch die Lösung verrät.

 

int numOfIterations = 200;

int numberOfRows = 400;

string connectionString = @"Data Source=.;Initial Catalog=BulkInsert;

                            Integrated Security=SSPI";


var dataTable = new DataTable();

using (var dbConnection = new SqlConnection(connectionString))

{

    dbConnection.Open();

    using (var cmd = dbConnection.CreateCommand())

    {

        cmd.CommandText = "SELECT * FROM dbo.Details";

        dataTable.Load(cmd.ExecuteReader(CommandBehavior.SchemaOnly));

    }

}


for (int i = 0; i < numOfIterations; i++)

{

    int masterId;

    using (var dbConnection = new SqlConnection(connectionString))

    {

        dbConnection.Open();


        using (var cmd = dbConnection.CreateCommand())

        {

            cmd.CommandText = @"

                INSERT INTO dbo.Master

                (start_time, some_other_run_specific_information)

                VALUES (SYSDATETIME(), 'run information');


                SELECT CAST(SCOPE_IDENTITY() AS INT) AS master_id";


            masterId = (int) cmd.ExecuteScalar();

        }

    }


    for (int j = 0; j < numberOfRows; j++)

    {

        var newRow = dataTable.NewRow();

        newRow["master_id"] = masterId;

        newRow["payload1"] = 100;

        newRow["payload2"] = "SomeStringPayload";

        dataTable.Rows.Add(newRow);

    }



    using (var s = new SqlBulkCopy(connectionString,

        SqlBulkCopyOptions.TableLock))

    {

        s.DestinationTableName = "dbo.Details";

        s.WriteToServer(dataTable);

    }


    dataTable.Rows.Clear();


    /*Simulate like we start on a fresh database by checking the constraint

        which makes it trusted again*/

    using (var dbConnection = new SqlConnection(connectionString))

    {

        dbConnection.Open();

        using (var cmd = dbConnection.CreateCommand())

        {

            cmd.CommandText =

            "ALTER TABLE dbo.Details WITH CHECK CHECK CONSTRAINT ALL";

            cmd.ExecuteNonQuery();

        }

    }

}

Und zwar liegt das Problem daran, dass SqlBulkCopy ohne CHECKCONSTRAINTS-Option ausgeführt wird (der zweite Konstruktorparameter, der im Code oben nur TableLock aktiviert). Dementsprechend kann SQL Server nicht mehr garantieren, dass das FK-Constraint für alle schon geladenen Daten erfüllt ist und setzt es auf not_trusted. Das wird von einem parallel laufenden SqlBulkCopy als eine Metadatenänderung interpretiert, was im Endeffekt auch zum Abbruch führt.

Auf dem Screenshot aus dem SQL Server Profiler sieht man klar, dass sich die Bulk Operationen von SPIDs 60 und 61 überschneiden. Als Resultat wird die Operation von SPID 61 mit einem Fehler abgebrochen.

ParallelBulkInsert

TABLOCK hilft hier offensichtlich nicht, aber mit der aktivierten CHECKCONSTRAINTS tritt kein Fehler mehr auf. Die künstliche Krücke “WITH CHECK CHECK CONSTRAINT ALL” muss dann entfernt werden, da sie sonst mit der Prüfung von Constraints beim BulkInsert deadlockt. Sie ist auch nicht notwendig, da das Constraint nach einem Bulk Insert mit CHECKCONSTRAINTS weiterhin als trusted eingestuft wird.