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

Minimierung der Auswirkungen der Erweiterung einer IDENTITY-Spalte – Teil 4

[ Teil 1 | Teil 2 | Teil 3 | Teil 4 ]

In Teil 3 dieser Serie habe ich zwei Problemumgehungen gezeigt, um das Erweitern einer IDENTITY zu vermeiden Spalte – eine, die Ihnen einfach Zeit verschafft, und eine andere, die IDENTITY aufgibt insgesamt. Ersteres verhindert, dass Sie sich mit externen Abhängigkeiten wie Fremdschlüsseln befassen müssen, aber Letzteres löst dieses Problem immer noch nicht. In diesem Beitrag wollte ich detailliert beschreiben, wie ich vorgehen würde, wenn ich unbedingt zu bigint wechseln müsste , musste Ausfallzeiten minimieren und hatte viel Zeit für die Planung.

Aufgrund all der potenziellen Blocker und der Notwendigkeit einer minimalen Störung kann der Ansatz als etwas komplex angesehen werden, und es wird nur noch komplizierter, wenn zusätzliche exotische Funktionen verwendet werden (z. B. Partitionierung, In-Memory-OLTP oder Replikation). .

Auf einer sehr hohen Ebene besteht der Ansatz darin, einen Satz Schattentabellen zu erstellen, bei dem alle Einfügungen zu einer neuen Kopie der Tabelle (mit dem größeren Datentyp) geleitet werden und die Existenz der beiden Tabellensätze ebenso transparent ist wie möglich für die Anwendung und ihre Benutzer.

Auf einer detaillierteren Ebene würden die Schritte wie folgt aussehen:

  1. Erstellen Sie Schattenkopien der Tabellen mit den richtigen Datentypen.
  2. Ändern Sie die gespeicherten Prozeduren (oder den Ad-hoc-Code), um bigint für Parameter zu verwenden. (Dies kann eine Änderung über die Parameterliste hinaus erfordern, wie lokale Variablen, temporäre Tabellen usw., aber das ist hier nicht der Fall.)
  3. Benennen Sie die alten Tabellen um und erstellen Sie Ansichten mit diesen Namen, die die alten und neuen Tabellen vereinen.
    • Diese Ansichten haben anstelle von Triggern DML-Vorgänge ordnungsgemäß an die entsprechende(n) Tabelle(n) geleitet, sodass Daten während der Migration weiterhin geändert werden können.
    • Dies erfordert auch, dass SCHEMABINDING aus allen indizierten Ansichten gelöscht wird, dass bestehende Ansichten Vereinigungen zwischen neuen und alten Tabellen haben und dass Prozeduren, die auf SCOPE_IDENTITY() angewiesen sind, geändert werden müssen.
  4. Migrieren Sie die alten Daten in Blöcken in die neuen Tabellen.
  5. Aufräumen, bestehend aus:
    • Löschen der temporären Ansichten (wodurch die INSTEAD OF-Trigger gelöscht werden).
    • Umbenennung der neuen Tabellen zurück in die ursprünglichen Namen.
    • Korrektur der gespeicherten Prozeduren, um zu SCOPE_IDENTITY() zurückzukehren.
    • Löschen der alten, jetzt leeren Tabellen.
    • SCHEMABINDING wieder auf indizierte Ansichten setzen und Clustered-Indizes neu erstellen.

Sie können wahrscheinlich einen Großteil der Ansichten und Trigger vermeiden, wenn Sie den gesamten Datenzugriff durch gespeicherte Prozeduren steuern können, aber da dieses Szenario selten ist (und man ihm nicht hundertprozentig vertrauen kann), werde ich den schwierigeren Weg zeigen.

Anfangsschema

Um diesen Ansatz so einfach wie möglich zu halten und gleichzeitig viele der Blocker, die ich zuvor in der Serie erwähnt habe, anzugehen, nehmen wir an, wir haben dieses Schema:

CREATE TABLE dbo.Employees
(
  EmployeeID int          IDENTITY(1,1) PRIMARY KEY,
  Name       nvarchar(64) NOT NULL,
  LunchGroup AS (CONVERT(tinyint, EmployeeID % 5))
);
GO
 
CREATE INDEX EmployeeName ON dbo.Employees(Name);
GO
 
CREATE VIEW dbo.LunchGroupCount
WITH SCHEMABINDING
AS
  SELECT LunchGroup, MemberCount = COUNT_BIG(*)
  FROM dbo.Employees
  GROUP BY LunchGroup;
GO
 
CREATE UNIQUE CLUSTERED INDEX LGC ON dbo.LunchGroupCount(LunchGroup);
GO
 
CREATE TABLE dbo.EmployeeFile
(
  EmployeeID  int           NOT NULL PRIMARY KEY
              FOREIGN KEY REFERENCES dbo.Employees(EmployeeID),
  Notes       nvarchar(max) NULL
);
GO

Eine einfache Personaltabelle mit einer geclusterten IDENTITY-Spalte, einem nicht geclusterten Index, einer berechneten Spalte basierend auf der IDENTITY-Spalte, einer indizierten Ansicht und einer separaten HR/Dirt-Tabelle, die einen Fremdschlüssel zurück zur Personaltabelle (I befürworte dieses Design nicht unbedingt, sondern verwende es nur für dieses Beispiel). Dies sind alles Dinge, die dieses Problem komplizierter machen, als wenn wir eine eigenständige, unabhängige Tabelle hätten.

Mit diesem Schema haben wir wahrscheinlich einige gespeicherte Prozeduren, die Dinge wie CRUD tun. Diese dienen eher der Dokumentation als irgendetwas anderem; Ich werde Änderungen am zugrunde liegenden Schema vornehmen, sodass die Änderung dieser Prozeduren minimal sein sollte. Dies soll die Tatsache simulieren, dass das Ändern von Ad-hoc-SQL aus Ihren Anwendungen möglicherweise nicht möglich und möglicherweise nicht erforderlich ist (naja, solange Sie kein ORM verwenden, das Tabellen vs. Ansichten erkennen kann).

CREATE PROCEDURE dbo.Employee_Add
  @Name  nvarchar(64),
  @Notes nvarchar(max) = NULL
AS
BEGIN
  SET NOCOUNT ON;
 
  INSERT dbo.Employees(Name) 
    VALUES(@Name);
 
  INSERT dbo.EmployeeFile(EmployeeID, Notes)
    VALUES(SCOPE_IDENTITY(),@Notes);
END
GO
 
CREATE PROCEDURE dbo.Employee_Update
  @EmployeeID int,
  @Name       nvarchar(64),
  @Notes      nvarchar(max)
AS
BEGIN
  SET NOCOUNT ON;
 
  UPDATE dbo.Employees 
    SET Name = @Name 
    WHERE EmployeeID = @EmployeeID;
 
  UPDATE dbo.EmployeeFile
    SET Notes = @Notes 
    WHERE EmployeeID = @EmployeeID;
END
GO
 
CREATE PROCEDURE dbo.Employee_Get
  @EmployeeID int
AS
BEGIN
  SET NOCOUNT ON;
 
  SELECT e.EmployeeID, e.Name, e.LunchGroup, ed.Notes
    FROM dbo.Employees AS e
    INNER JOIN dbo.EmployeeFile AS ed
    ON e.EmployeeID = ed.EmployeeID
    WHERE e.EmployeeID = @EmployeeID;
END
GO
 
CREATE PROCEDURE dbo.Employee_Delete
  @EmployeeID int
AS
BEGIN
  SET NOCOUNT ON;
 
  DELETE dbo.EmployeeFile WHERE EmployeeID = @EmployeeID;
  DELETE dbo.Employees    WHERE EmployeeID = @EmployeeID;
END
GO

Lassen Sie uns nun 5 Datenzeilen zu den ursprünglichen Tabellen hinzufügen:

EXEC dbo.Employee_Add @Name = N'Employee1', @Notes = 'Employee #1 is the best';
EXEC dbo.Employee_Add @Name = N'Employee2', @Notes = 'Fewer people like Employee #2';
EXEC dbo.Employee_Add @Name = N'Employee3', @Notes = 'Jury on Employee #3 is out';
EXEC dbo.Employee_Add @Name = N'Employee4', @Notes = '#4 is moving on';
EXEC dbo.Employee_Add @Name = N'Employee5', @Notes = 'I like #5';

Schritt 1 – neue Tabellen

Hier erstellen wir ein neues Tabellenpaar, das die Originale widerspiegelt, mit Ausnahme des Datentyps der Spalte „EmployeeID“, des Ausgangswerts für die Spalte „IDENTITY“ und eines temporären Suffixes für die Namen:

CREATE TABLE dbo.Employees_New
(
  EmployeeID bigint       IDENTITY(2147483648,1) PRIMARY KEY,
  Name       nvarchar(64) NOT NULL,
  LunchGroup AS (CONVERT(tinyint, EmployeeID % 5))
);
GO
 
CREATE INDEX EmployeeName_New ON dbo.Employees_New(Name);
GO
 
CREATE TABLE dbo.EmployeeFile_New
(
  EmployeeID  bigint        NOT NULL PRIMARY KEY
              FOREIGN KEY REFERENCES dbo.Employees_New(EmployeeID),
  Notes       nvarchar(max) NULL
);

Schritt 2 – Verfahrensparameter festlegen

Die Prozeduren hier (und möglicherweise Ihr Ad-hoc-Code, es sei denn, er verwendet bereits den größeren Integer-Typ) benötigen eine sehr geringfügige Änderung, damit sie in Zukunft EmployeeID-Werte über die oberen Grenzen einer Ganzzahl hinaus akzeptieren können. Während Sie argumentieren könnten, dass Sie, wenn Sie diese Verfahren ändern, sie einfach auf die neuen Tische richten könnten, versuche ich zu argumentieren, dass Sie das ultimative Ziel mit *minimalem* Eingriff in das Bestehende erreichen können, dauerhaft Code.

ALTER PROCEDURE dbo.Employee_Update
  @EmployeeID bigint, -- only change
  @Name       nvarchar(64),
  @Notes      nvarchar(max)
AS
BEGIN
  SET NOCOUNT ON;
 
  UPDATE dbo.Employees 
    SET Name = @Name 
    WHERE EmployeeID = @EmployeeID;
 
  UPDATE dbo.EmployeeFile
    SET Notes = @Notes 
    WHERE EmployeeID = @EmployeeID;
END
GO
 
ALTER PROCEDURE dbo.Employee_Get
  @EmployeeID bigint -- only change
AS
BEGIN
  SET NOCOUNT ON;
 
  SELECT e.EmployeeID, e.Name, e.LunchGroup, ed.Notes
    FROM dbo.Employees AS e
    INNER JOIN dbo.EmployeeFile AS ed
    ON e.EmployeeID = ed.EmployeeID
    WHERE e.EmployeeID = @EmployeeID;
END
GO
 
ALTER PROCEDURE dbo.Employee_Delete
  @EmployeeID bigint -- only change
AS
BEGIN
  SET NOCOUNT ON;
 
  DELETE dbo.EmployeeFile WHERE EmployeeID = @EmployeeID;
  DELETE dbo.Employees    WHERE EmployeeID = @EmployeeID;
END
GO

Schritt 3 – Aufrufe und Auslöser

Leider kann dies nicht *alles* im Stillen geschehen. Wir können die meisten Operationen parallel und ohne Beeinträchtigung der gleichzeitigen Nutzung ausführen, aber wegen SCHEMABINDING muss die indizierte Ansicht geändert und der Index später neu erstellt werden.

Dies gilt für alle anderen Objekte, die SCHEMABINDING verwenden und auf eine unserer Tabellen verweisen. Ich empfehle, sie zu Beginn des Vorgangs in eine nicht indizierte Ansicht zu ändern und den Index nur einmal neu zu erstellen, nachdem alle Daten migriert wurden, und nicht mehrmals im Prozess (da Tabellen mehrmals umbenannt werden). Tatsächlich werde ich die Ansicht ändern, um die neue und alte Version der Employees-Tabelle für die Dauer des Prozesses zu vereinen.

Eine andere Sache, die wir tun müssen, ist, die gespeicherte Prozedur Employee_Add so zu ändern, dass sie vorübergehend @@IDENTITY anstelle von SCOPE_IDENTITY() verwendet. Dies liegt daran, dass der INSTEAD OF-Trigger, der neue Aktualisierungen für „Employees“ handhabt, keine Sichtbarkeit des SCOPE_IDENTITY()-Werts hat. Dies setzt natürlich voraus, dass die Tabellen keine After-Trigger haben, die sich auf @@IDENTITY auswirken. Hoffentlich können Sie diese Abfragen entweder innerhalb einer gespeicherten Prozedur ändern (wo Sie einfach mit INSERT auf die neue Tabelle zeigen könnten), oder Ihr Anwendungscode muss sich nicht von vornherein auf SCOPE_IDENTITY() verlassen.

Wir werden dies unter SERIALIZABLE tun, damit sich keine Transaktionen einschleichen, während die Objekte im Fluss sind. Dies ist eine Reihe von weitgehend reinen Metadaten-Operationen, daher sollte es schnell gehen.

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION;
GO
 
-- first, remove schemabinding from the view so we can change the base table
 
ALTER VIEW dbo.LunchGroupCount
--WITH SCHEMABINDING -- this will silently drop the index
                     -- and will temp. affect performance 
AS
  SELECT LunchGroup, MemberCount = COUNT_BIG(*)
  FROM dbo.Employees
  GROUP BY LunchGroup;
GO
 
-- rename the tables
EXEC sys.sp_rename N'dbo.Employees',    N'Employees_Old',    N'OBJECT';
EXEC sys.sp_rename N'dbo.EmployeeFile', N'EmployeeFile_Old', N'OBJECT';
GO
 
-- the view above will be broken for about a millisecond
-- until the following union view is created:
 
CREATE VIEW dbo.Employees 
WITH SCHEMABINDING 
AS
  SELECT EmployeeID = CONVERT(bigint, EmployeeID), Name, LunchGroup
  FROM dbo.Employees_Old
  UNION ALL
  SELECT EmployeeID, Name, LunchGroup
  FROM dbo.Employees_New;
GO
 
-- now the view will work again (but it will be slower)
 
CREATE VIEW dbo.EmployeeFile 
WITH SCHEMABINDING
AS
  SELECT EmployeeID = CONVERT(bigint, EmployeeID), Notes
  FROM dbo.EmployeeFile_Old
  UNION ALL
  SELECT EmployeeID, Notes
  FROM dbo.EmployeeFile_New;
GO
 
CREATE TRIGGER dbo.Employees_InsteadOfInsert
ON dbo.Employees
INSTEAD OF INSERT
AS
BEGIN
  SET NOCOUNT ON;
 
  -- just needs to insert the row(s) into the new copy of the table
  INSERT dbo.Employees_New(Name) SELECT Name FROM inserted;
END
GO
 
CREATE TRIGGER dbo.Employees_InsteadOfUpdate
ON dbo.Employees
INSTEAD OF UPDATE
AS
BEGIN
  SET NOCOUNT ON;
 
  BEGIN TRANSACTION;
 
  -- need to cover multi-row updates, and the possibility
  -- that any row may have been migrated already
  UPDATE o SET Name = i.Name
    FROM dbo.Employees_Old AS o
    INNER JOIN inserted AS i
    ON o.EmployeeID = i.EmployeeID;
 
  UPDATE n SET Name = i.Name
    FROM dbo.Employees_New AS n
    INNER JOIN inserted AS i
    ON n.EmployeeID = i.EmployeeID;
 
  COMMIT TRANSACTION;
END
GO
 
CREATE TRIGGER dbo.Employees_InsteadOfDelete
ON dbo.Employees
INSTEAD OF DELETE
AS
BEGIN
  SET NOCOUNT ON;
 
  BEGIN TRANSACTION;
 
  -- a row may have been migrated already, maybe not
  DELETE o FROM dbo.Employees_Old AS o
    INNER JOIN deleted AS d
    ON o.EmployeeID = d.EmployeeID;
 
  DELETE n FROM dbo.Employees_New AS n
    INNER JOIN deleted AS d
    ON n.EmployeeID = d.EmployeeID;
 
  COMMIT TRANSACTION;
END
GO
 
CREATE TRIGGER dbo.EmployeeFile_InsteadOfInsert
ON dbo.EmployeeFile
INSTEAD OF INSERT
AS
BEGIN
  SET NOCOUNT ON;
 
  INSERT dbo.EmployeeFile_New(EmployeeID, Notes)
    SELECT EmployeeID, Notes FROM inserted;
END
GO
 
CREATE TRIGGER dbo.EmployeeFile_InsteadOfUpdate
ON dbo.EmployeeFile
INSTEAD OF UPDATE
AS
BEGIN
  SET NOCOUNT ON;
 
  BEGIN TRANSACTION;
 
  UPDATE o SET Notes = i.Notes
    FROM dbo.EmployeeFile_Old AS o
    INNER JOIN inserted AS i
    ON o.EmployeeID = i.EmployeeID;
 
  UPDATE n SET Notes = i.Notes
    FROM dbo.EmployeeFile_New AS n
    INNER JOIN inserted AS i
    ON n.EmployeeID = i.EmployeeID;
 
  COMMIT TRANSACTION;
END
GO
 
CREATE TRIGGER dbo.EmployeeFile_InsteadOfDelete
ON dbo.EmployeeFile
INSTEAD OF DELETE
AS
BEGIN
  SET NOCOUNT ON;
 
  BEGIN TRANSACTION;
 
  DELETE o FROM dbo.EmployeeFile_Old AS o
    INNER JOIN deleted AS d
    ON o.EmployeeID = d.EmployeeID;
 
  DELETE n FROM dbo.EmployeeFile_New AS n
    INNER JOIN deleted AS d
    ON n.EmployeeID = d.EmployeeID;
 
  COMMIT TRANSACTION;
END
GO
 
-- the insert stored procedure also has to be updated, temporarily
 
ALTER PROCEDURE dbo.Employee_Add
  @Name  nvarchar(64),
  @Notes nvarchar(max) = NULL
AS
BEGIN
  SET NOCOUNT ON;
 
  INSERT dbo.Employees(Name) 
    VALUES(@Name);
 
  INSERT dbo.EmployeeFile(EmployeeID, Notes)
    VALUES(@@IDENTITY, @Notes);
    -------^^^^^^^^^^------ change here
END
GO
 
COMMIT TRANSACTION;

Schritt 4 – Alte Daten in neue Tabelle migrieren

Wir werden Daten in Blöcken migrieren, um die Auswirkungen sowohl auf die Parallelität als auch auf das Transaktionsprotokoll zu minimieren, wobei wir uns die grundlegende Technik aus einem alten Beitrag von mir leihen:„Große Löschvorgänge in Blöcke aufteilen“. Wir werden diese Stapel auch in SERIALIZABLE ausführen, was bedeutet, dass Sie mit der Stapelgröße vorsichtig sein sollten, und ich habe der Kürze halber die Fehlerbehandlung weggelassen.

CREATE TABLE #batches(EmployeeID int);
 
DECLARE @BatchSize int = 1; -- for this demo only
  -- your optimal batch size will hopefully be larger
 
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
 
WHILE 1 = 1
BEGIN
  INSERT #batches(EmployeeID)
    SELECT TOP (@BatchSize) EmployeeID 
      FROM dbo.Employees_Old
      WHERE EmployeeID NOT IN (SELECT EmployeeID FROM dbo.Employees_New)
      ORDER BY EmployeeID;
 
  IF @@ROWCOUNT = 0
    BREAK;
 
  BEGIN TRANSACTION;
 
  SET IDENTITY_INSERT dbo.Employees_New ON;
 
  INSERT dbo.Employees_New(EmployeeID, Name) 
    SELECT o.EmployeeID, o.Name 
    FROM #batches AS b 
    INNER JOIN dbo.Employees_Old AS o
    ON b.EmployeeID = o.EmployeeID;
 
  SET IDENTITY_INSERT dbo.Employees_New OFF;
 
  INSERT dbo.EmployeeFile_New(EmployeeID, Notes)
    SELECT o.EmployeeID, o.Notes
    FROM #batches AS b
    INNER JOIN dbo.EmployeeFile_Old AS o
    ON b.EmployeeID = o.EmployeeID;
 
  DELETE o FROM dbo.EmployeeFile_Old AS o
    INNER JOIN #batches AS b
    ON b.EmployeeID = o.EmployeeID;
 
  DELETE o FROM dbo.Employees_Old AS o
    INNER JOIN #batches AS b
    ON b.EmployeeID = o.EmployeeID;
 
  COMMIT TRANSACTION;
 
  TRUNCATE TABLE #batches;
 
  -- monitor progress
  SELECT total = (SELECT COUNT(*) FROM dbo.Employees),
      original = (SELECT COUNT(*) FROM dbo.Employees_Old),
	   new = (SELECT COUNT(*) FROM dbo.Employees_New);
 
  -- checkpoint / backup log etc.
END
 
DROP TABLE #batches;

Ergebnisse:

Sehen Sie, wie die Zeilen einzeln migriert werden

Während dieser Sequenz können Sie jederzeit Einfügungen, Aktualisierungen und Löschungen testen, und sie sollten entsprechend gehandhabt werden. Sobald die Migration abgeschlossen ist, können Sie mit dem Rest des Prozesses fortfahren.

Schritt 5 – Aufräumen

Eine Reihe von Schritten ist erforderlich, um die vorübergehend erstellten Objekte zu bereinigen und Employees / EmployeeFile als ordnungsgemäße, erstklassige Bürger wiederherzustellen. Viele dieser Befehle sind einfache Metadatenoperationen – mit Ausnahme der Erstellung des Clustered-Index für die indizierte Ansicht sollten sie alle sofort ausgeführt werden.

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION;
 
-- drop views and restore name of new tables
 
DROP VIEW dbo.EmployeeFile; --v
DROP VIEW dbo.Employees;    -- this will drop the instead of triggers
EXEC sys.sp_rename N'dbo.Employees_New',    N'Employees',    N'OBJECT';
EXEC sys.sp_rename N'dbo.EmployeeFile_New', N'EmployeeFile', N'OBJECT';
GO
 
-- put schemabinding back on the view, and remove the union
ALTER VIEW dbo.LunchGroupCount
WITH SCHEMABINDING
AS
  SELECT LunchGroup, MemberCount = COUNT_BIG(*)
  FROM dbo.Employees
  GROUP BY LunchGroup;
GO
 
-- change the procedure back to SCOPE_IDENTITY()
ALTER PROCEDURE dbo.Employee_Add
  @Name  nvarchar(64),
  @Notes nvarchar(max) = NULL
AS
BEGIN
  SET NOCOUNT ON;
 
  INSERT dbo.Employees(Name) 
    VALUES(@Name);
 
  INSERT dbo.EmployeeFile(EmployeeID, Notes)
    VALUES(SCOPE_IDENTITY(), @Notes);
END
GO
 
COMMIT TRANSACTION;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
 
-- drop the old (now empty) tables
-- and create the index on the view
-- outside the transaction
 
DROP TABLE dbo.EmployeeFile_Old;
DROP TABLE dbo.Employees_Old;
GO
 
-- only portion that is absolutely not online
CREATE UNIQUE CLUSTERED INDEX LGC ON dbo.LunchGroupCount(LunchGroup);
GO

An diesem Punkt sollte alles wieder normal funktionieren, obwohl Sie vielleicht typische Wartungsaktivitäten nach größeren Schemaänderungen in Betracht ziehen möchten, wie z. B. das Aktualisieren von Statistiken, das Neuerstellen von Indizes oder das Entfernen von Plänen aus dem Cache.

Schlussfolgerung

Dies ist eine ziemlich komplexe Lösung für ein eigentlich einfaches Problem. Ich hoffe, dass SQL Server es irgendwann ermöglicht, Dinge wie das Hinzufügen/Entfernen der IDENTITY-Eigenschaft, das Neuerstellen von Indizes mit neuen Zieldatentypen und das Ändern von Spalten auf beiden Seiten einer Beziehung zu tun, ohne die Beziehung zu opfern. In der Zwischenzeit würde mich interessieren, ob Ihnen diese Lösung weiterhilft oder ob Sie einen anderen Ansatz haben.

Großes Dankeschön an James Lupolt (@jlupoltsql) dafür, dass er mir dabei geholfen hat, meinen Ansatz zu überprüfen und ihn an einem seiner eigenen, echten Tische auf die ultimative Probe gestellt hat. (Es lief gut. Danke James!)

[ Teil 1 | Teil 2 | Teil 3 | Teil 4 ]