Database
 sql >> Datenbank >  >> RDS >> Database

Bitte hören Sie auf, dieses UPSERT-Anti-Pattern zu verwenden

Ich denke jeder kennt meine Meinung zu MERGE und warum ich mich davon fernhalte. Aber hier ist ein weiteres (Anti-)Muster, das ich überall sehe, wenn Leute ein Upsert ausführen wollen (aktualisiere eine Zeile, falls vorhanden, und füge sie ein, falls nicht):

IF EXISTS (SELECT 1 FROM dbo.t WHERE [key] = @key)
BEGIN
  UPDATE dbo.t SET val = @val WHERE [key] = @key;
END
ELSE
BEGIN
  INSERT dbo.t([key], val) VALUES(@key, @val); 
END

Das sieht nach einem ziemlich logischen Ablauf aus, der widerspiegelt, wie wir im wirklichen Leben darüber denken:

  • Existiert bereits eine Zeile für diesen Schlüssel?
    • JA :OK, aktualisiere diese Zeile.
    • NEIN :OK, dann füge es hinzu.

Aber das ist Verschwendung.

Das Auffinden der Zeile, um zu bestätigen, dass sie existiert, nur um sie erneut zu finden, um sie zu aktualisieren, macht doppelt so viel Arbeit für nichts. Auch wenn der Schlüssel indexiert ist (was hoffentlich immer der Fall ist). Wenn ich diese Logik in ein Flussdiagramm einbaue und jedem Schritt die Art der Operation zuordne, die innerhalb der Datenbank stattfinden müsste, hätte ich Folgendes:

Beachten Sie, dass für alle Pfade zwei Indexoperationen erforderlich sind.

Abgesehen von der Leistung, was noch wichtiger ist, können mehrere Dinge schief gehen, wenn Sie nicht sowohl eine explizite Transaktion verwenden als auch die Isolationsstufe erhöhen:

  • Wenn der Schlüssel vorhanden ist und zwei Sitzungen gleichzeitig versuchen, sie zu aktualisieren, werden sie beide erfolgreich aktualisiert (Einer wird "gewinnen", der "Verlierer" folgt mit der Änderung, die hängen bleibt, was zu einem "verlorenen Update" führt). Dies ist an sich kein Problem und so sollten wir es erwarten, dass ein System mit Parallelität funktioniert. Paul White spricht hier ausführlicher über die internen Mechanismen, und Martin Smith spricht hier über einige andere Nuancen.
  • Wenn der Schlüssel nicht existiert, aber beide Sitzungen die Existenzprüfung auf die gleiche Weise bestehen, kann alles passieren, wenn beide versuchen, Folgendes einzufügen:
    • Deadlock wegen inkompatibler Sperren;
    • Schlüsselverletzungsfehler melden das hätte nicht passieren dürfen; oder,
    • Doppelte Schlüsselwerte einfügen wenn diese Spalte nicht richtig eingeschränkt ist.

Letzteres ist meiner Meinung nach das Schlimmste, weil es möglicherweise Daten korrumpiert . Deadlocks und Ausnahmen können leicht mit Dingen wie Fehlerbehandlung, XACT_ABORT behandelt werden und Wiederholungslogik, je nachdem, wie häufig Sie Kollisionen erwarten. Aber wenn Sie sich in ein Gefühl der Sicherheit wiegen, dass der IF EXISTS check schützt Sie vor Duplikaten (oder Schlüsselverletzungen), das ist eine Überraschung, die auf Sie wartet. Wenn Sie erwarten, dass sich eine Spalte wie ein Schlüssel verhält, machen Sie sie offiziell und fügen Sie eine Einschränkung hinzu.

"Viele Leute sagen..."

Dan Guzman hat vor mehr als einem Jahrzehnt in Conditional INSERT/UPDATE Race Condition und später in „UPSERT“ Race Condition With MERGE über Rennbedingungen gesprochen.

Auch Michael Swart hat dieses Thema mehrfach behandelt:

  • Mythbusting:Concurrent Update/Insert Solutions – wo er einräumte, dass das Belassen der anfänglichen Logik und nur das Erhöhen der Isolationsstufe nur dazu führten, dass Schlüsselverletzungen zu Deadlocks wurden;
  • Seien Sie vorsichtig mit der Merge-Anweisung – wo er seine Begeisterung für MERGE überprüfte; und,
  • Was Sie vermeiden sollten, wenn Sie MERGE verwenden möchten – wo er noch einmal bestätigte, dass es immer noch viele triftige Gründe gibt, MERGE weiterhin zu vermeiden .

Lesen Sie auch alle Kommentare zu allen drei Beiträgen.

Die Lösung

Ich habe in meiner Karriere viele Deadlocks behoben, indem ich mich einfach an das folgende Muster angepasst habe (die redundante Prüfung aufgeben, die Sequenz in eine Transaktion einschließen und den ersten Tabellenzugriff mit geeigneten Sperren schützen):

BEGIN TRANSACTION;
 
UPDATE dbo.t WITH (UPDLOCK, SERIALIZABLE) SET val = @val WHERE [key] = @key;
 
IF @@ROWCOUNT = 0
BEGIN
  INSERT dbo.t([key], val) VALUES(@key, @val);
END
 
COMMIT TRANSACTION;

Warum brauchen wir zwei Hinweise? Ist nicht UPDLOCK genug?

  • UPDLOCK dient zum Schutz vor Conversion-Deadlocks bei der Anweisung Ebene (lassen Sie eine andere Sitzung warten, anstatt ein Opfer zu ermutigen, es erneut zu versuchen).
  • SERIALIZABLE wird verwendet, um während der Transaktion vor Änderungen an den zugrunde liegenden Daten zu schützen (Stellen Sie sicher, dass eine Zeile, die nicht existiert, weiterhin nicht existiert).

Es ist etwas mehr Code, aber 1000 % sicherer und sogar am schlimmsten Fall (die Zeile existiert noch nicht), verhält es sich genauso wie das Antimuster. Wenn Sie eine bereits vorhandene Zeile aktualisieren, ist es im besten Fall effizienter, diese Zeile nur einmal zu suchen. Kombiniert man diese Logik mit den High-Level-Operationen, die in der Datenbank stattfinden müssten, ist es etwas einfacher:

In diesem Fall erfordert ein Pfad nur einen einzigen Indexvorgang.

Aber nochmal, Leistung beiseite:

  • Wenn der Schlüssel vorhanden ist und zwei Sitzungen gleichzeitig versuchen, ihn zu aktualisieren, wechseln sie sich ab und aktualisieren die Zeile erfolgreich , wie zuvor.
  • Wenn der Schlüssel nicht existiert, wird eine Sitzung "gewinnen" und die Zeile einfügen . Der andere muss warten bis die Sperren freigegeben werden, um sogar auf Existenz zu prüfen und zur Aktualisierung gezwungen zu werden.

In beiden Fällen verliert der Autor, der das Rennen gewonnen hat, seine Daten an alles, was der „Verlierer“ nach ihm aktualisiert.

Beachten Sie, dass der Gesamtdurchsatz auf einem System mit hoher Parallelität möglicherweise ist leiden, aber das ist ein Kompromiss, zu dem Sie bereit sein sollten. Dass Sie viele Deadlock-Opfer oder Schlüsselverletzungsfehler bekommen, aber sie passieren schnell, ist keine gute Leistungsmetrik. Einige Leute würden gerne sehen, dass alle Blockierungen aus allen Szenarien entfernt werden, aber einige davon blockieren Sie unbedingt für die Datenintegrität.

Aber was ist, wenn ein Update weniger wahrscheinlich ist?

Es ist klar, dass die obige Lösung für Updates optimiert ist und davon ausgeht, dass ein Schlüssel, in den Sie schreiben möchten, mindestens so oft bereits in der Tabelle vorhanden ist wie nicht. Wenn Sie lieber für Einfügungen optimieren möchten und wissen oder vermuten, dass Einfügungen wahrscheinlicher sind als Aktualisierungen, können Sie die Logik umdrehen und haben immer noch eine sichere Upsert-Operation:

BEGIN TRANSACTION;
 
INSERT dbo.t([key], val) 
  SELECT @key, @val
  WHERE NOT EXISTS
  (
    SELECT 1 FROM dbo.t WITH (UPDLOCK, SERIALIZABLE)
      WHERE [key] = @key
  );
 
IF @@ROWCOUNT = 0
BEGIN
  UPDATE dbo.t SET val = @val WHERE [key] = @key;
END
 
COMMIT TRANSACTION;

Es gibt auch den „Einfach-machen“-Ansatz, bei dem Sie blind einfügen und Kollisionen Ausnahmen für den Aufrufer auslösen lassen:

BEGIN TRANSACTION;
 
BEGIN TRY
  INSERT dbo.t([key], val) VALUES(@key, @val);
END TRY
BEGIN CATCH
  UPDATE dbo.t SET val = @val WHERE [key] = @key;
END CATCH
 
COMMIT TRANSACTION;

Die Kosten für diese Ausnahmen überwiegen oft die Kosten für die vorherige Prüfung; Sie müssen es mit einer ungefähr genauen Schätzung der Treffer-/Fehltrefferrate versuchen. Ich habe darüber hier und hier geschrieben.

Was ist mit dem Hochsetzen mehrerer Zeilen?

Das Obige befasst sich mit Singleton-Einfügungs-/Aktualisierungsentscheidungen, aber Justin Pealing fragte, was zu tun ist, wenn Sie mehrere Zeilen verarbeiten, ohne zu wissen, welche davon bereits vorhanden sind?

Angenommen, Sie senden eine Reihe von Zeilen mit etwas wie einem Tabellenwertparameter, würden Sie mit einem Join aktualisieren und dann mit NOT EXISTS einfügen, aber das Muster wäre immer noch äquivalent zum ersten Ansatz oben:

CREATE PROCEDURE dbo.UpsertTheThings
    @tvp dbo.TableType READONLY
AS
BEGIN
  SET NOCOUNT ON;
 
  BEGIN TRANSACTION;
 
  UPDATE t WITH (UPDLOCK, SERIALIZABLE) 
    SET val = tvp.val
  FROM dbo.t AS t
  INNER JOIN @tvp AS tvp
    ON t.[key] = tvp.[key];
 
  INSERT dbo.t([key], val)
    SELECT [key], val FROM @tvp AS tvp
    WHERE NOT EXISTS (SELECT 1 FROM dbo.t WHERE [key] = tvp.[key]);
 
  COMMIT TRANSACTION;
END

Wenn Sie mehrere Zeilen auf andere Weise als ein TVP (XML, kommagetrennte Liste, Voodoo) zusammenführen, fügen Sie sie zuerst in eine Tabellenform ein und verbinden Sie sie mit dem, was auch immer das ist. Achten Sie darauf, in diesem Szenario nicht zuerst für Einfügungen zu optimieren, da Sie sonst möglicherweise einige Zeilen zweimal aktualisieren.

Schlussfolgerung

Diese Upsert-Muster sind denen überlegen, die ich allzu oft sehe, und ich hoffe, Sie fangen an, sie zu verwenden. Ich werde jedes Mal auf diesen Beitrag verweisen, wenn ich den IF EXISTS entdecke Muster in freier Wildbahn. Und, hey, ein weiteres Dankeschön an Paul White (sql.kiwi | @SQK_Kiwi), weil er so hervorragend darin ist, schwierige Konzepte leicht verständlich zu machen und wiederum zu erklären.

Und wenn Sie das Gefühl haben, müssen Verwenden Sie MERGE , bitte @me nicht; Entweder haben Sie einen guten Grund (vielleicht brauchen Sie ein obskures MERGE -nur Funktionalität), oder Sie haben die obigen Links nicht ernst genommen.