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

Ridurre al minimo l'impatto dell'ampliamento di una colonna IDENTITY - parte 4

[ Parte 1 | Parte 2 | Parte 3 | Parte 4]

Nella parte 3 di questa serie, ho mostrato due soluzioni alternative per evitare di ampliare un IDENTITY colonna:una che ti fa semplicemente guadagnare tempo e un'altra che abbandona IDENTITY del tutto. Il primo ti impedisce di avere a che fare con dipendenze esterne come chiavi esterne, ma il secondo non risolve questo problema. In questo post, volevo descrivere in dettaglio l'approccio che adotterei se avessi assolutamente bisogno di passare a bigint , necessario per ridurre al minimo i tempi di inattività e aveva tutto il tempo per la pianificazione.

A causa di tutti i potenziali bloccanti e della necessità di interruzioni minime, l'approccio potrebbe essere visto come un po' complesso e lo diventa solo se vengono utilizzate funzionalità esotiche aggiuntive (ad esempio, partizionamento, OLTP in memoria o replica) .

Ad un livello molto alto, l'approccio consiste nel creare un set di tabelle shadow, in cui tutti gli inserti vengono indirizzati a una nuova copia della tabella (con il tipo di dati più grande) e l'esistenza dei due set di tabelle è altrettanto trasparente possibile all'applicazione e ai suoi utenti.

A un livello più granulare, l'insieme dei passaggi sarebbe il seguente:

  1. Crea copie shadow delle tabelle, con i tipi di dati corretti.
  2. Modificare le procedure memorizzate (o il codice ad hoc) per utilizzare bigint per i parametri. (Ciò potrebbe richiedere modifiche oltre l'elenco dei parametri, come variabili locali, tabelle temporanee, ecc., ma non è il caso qui.)
  3. Rinomina le vecchie tabelle e crea viste con quei nomi che uniscono la vecchia e la nuova tabella.
    • Quelle viste avranno invece dei trigger per indirizzare correttamente le operazioni DML alle tabelle appropriate, in modo che i dati possano ancora essere modificati durante la migrazione.
    • Ciò richiede anche che SCHEMABINDING venga eliminato da tutte le viste indicizzate, che le viste esistenti abbiano unioni tra tabelle nuove e vecchie e che le procedure che si basano su SCOPE_IDENTITY() vengano modificate.
  4. Migra i vecchi dati alle nuove tabelle in blocchi.
  5. Pulizia, composto da:
    • Eliminazione delle visualizzazioni temporanee (che rilascerà i trigger INSTEAD OF).
    • Rinominare le nuove tabelle con i nomi originali.
    • Correzione delle procedure memorizzate per ripristinare SCOPE_IDENTITY().
    • Eliminando i vecchi tavoli ormai vuoti.
    • Riportare SCHEMABINDING nelle viste indicizzate e ricreare gli indici cluster.

Probabilmente puoi evitare gran parte delle visualizzazioni e dei trigger se riesci a controllare tutto l'accesso ai dati tramite le procedure archiviate, ma poiché questo scenario è raro (e impossibile da considerare attendibile al 100%), mostrerò il percorso più difficile.

Schema iniziale

Nel tentativo di mantenere questo approccio il più semplice possibile, pur continuando ad affrontare molti dei blocchi che ho menzionato in precedenza nella serie, supponiamo di avere questo 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

Quindi una semplice tabella del personale, con una colonna IDENTITY in cluster, un indice non in cluster, una colonna calcolata basata sulla colonna IDENTITY, una vista indicizzata e una tabella HR/dirt separata che ha una chiave esterna alla tabella del personale (I non sto necessariamente incoraggiando quel design, lo sto solo usando per questo esempio). Queste sono tutte cose che rendono questo problema più complicato di quanto sarebbe se avessimo un tavolo autonomo e indipendente.

Con quello schema in atto, probabilmente abbiamo alcune stored procedure che fanno cose come CRUD. Questi sono più per motivi di documentazione che altro; Apporterò modifiche allo schema sottostante in modo tale che la modifica di queste procedure dovrebbe essere minima. Questo per simulare il fatto che la modifica dell'SQL ad hoc dalle tue applicazioni potrebbe non essere possibile e potrebbe non essere necessaria (a patto che tu non stia utilizzando un ORM in grado di rilevare tabelle e viste).

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

Ora aggiungiamo 5 righe di dati alle tabelle originali:

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';

Fase 1:nuove tabelle

Qui creeremo una nuova coppia di tabelle, rispecchiando gli originali ad eccezione del tipo di dati delle colonne EmployeeID, il seme iniziale per la colonna IDENTITY e un suffisso temporaneo sui nomi:

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
);

Fase 2:correggi i parametri della procedura

Le procedure qui (e potenzialmente il tuo codice ad hoc, a meno che non stia già utilizzando il tipo intero più grande) richiederanno una modifica molto minore in modo che in futuro possano accettare valori EmployeeID oltre i limiti superiori di un intero. Mentre potresti sostenere che se hai intenzione di alterare queste procedure, potresti semplicemente indicarle ai nuovi tavoli, sto cercando di sostenere che puoi raggiungere l'obiettivo finale con *minima* intrusione nell'esistente, permanente codice.

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

Fase 3:visualizzazioni e attivatori

Sfortunatamente, questo non può *tutto* essere fatto in silenzio. Possiamo eseguire la maggior parte delle operazioni in parallelo e senza influire sull'utilizzo simultaneo, ma a causa di SCHEMABINDING, la vista indicizzata deve essere modificata e l'indice successivamente ricreato.

Questo è vero per qualsiasi altro oggetto che utilizza SCHEMABINDING e fa riferimento a una delle nostre tabelle. Consiglio di cambiarla in una vista non indicizzata all'inizio dell'operazione e di ricostruire l'indice solo una volta dopo che tutti i dati sono stati migrati, piuttosto che più volte nel processo (poiché le tabelle verranno rinominate più volte). In effetti, quello che farò è cambiare la visualizzazione per unire le nuove e vecchie versioni della tabella Impiegati per la durata del processo.

Un'altra cosa che dobbiamo fare è modificare la procedura memorizzata Employee_Add per utilizzare @@IDENTITY invece di SCOPE_IDENTITY(), temporaneamente. Questo perché il trigger INSTEAD OF che gestirà i nuovi aggiornamenti a "Employees" non avrà visibilità del valore SCOPE_IDENTITY(). Questo, ovviamente, presuppone che le tabelle non abbiano trigger after che influenzeranno @@IDENTITY. Si spera che tu possa modificare queste query all'interno di una stored procedure (dove potresti semplicemente puntare INSERT alla nuova tabella), o il codice della tua applicazione non deve fare affidamento su SCOPE_IDENTITY() in primo luogo.

Lo faremo in SERIALIZABLE in modo che nessuna transazione tenti di intrufolarsi mentre gli oggetti sono in flusso. Questo è un insieme di operazioni principalmente basate sui metadati, quindi dovrebbe essere rapido.

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;

Passaggio 4:migra i vecchi dati nella nuova tabella

Migreremo i dati in blocchi per ridurre al minimo l'impatto sia sulla concorrenza che sul registro delle transazioni, prendendo in prestito la tecnica di base da un mio vecchio post, "Rompi grandi operazioni di eliminazione in blocchi". Eseguiremo questi batch anche in SERIALIZABLE, il che significa che dovrai prestare attenzione alle dimensioni del batch e ho omesso la gestione degli errori per brevità.

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;

Risultati:

Guarda la migrazione delle righe una per una

In qualsiasi momento durante quella sequenza, puoi testare inserimenti, aggiornamenti ed eliminazioni e dovrebbero essere gestiti in modo appropriato. Una volta completata la migrazione, puoi passare al resto del processo.

Fase 5:ripulisci

È necessaria una serie di passaggi per ripulire gli oggetti che sono stati creati temporaneamente e per ripristinare Employees / EmployeeFile come cittadini di prima classe. Molti di questi comandi sono semplicemente operazioni sui metadati:ad eccezione della creazione dell'indice cluster nella vista indicizzata, dovrebbero essere tutti istantanei.

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

A questo punto, tutto dovrebbe tornare al normale funzionamento, anche se potresti voler considerare le attività di manutenzione tipiche a seguito di importanti modifiche allo schema, come l'aggiornamento delle statistiche, la ricostruzione degli indici o l'eliminazione dei piani dalla cache.

Conclusione

Questa è una soluzione piuttosto complessa a quello che dovrebbe essere un problema semplice. Spero che a un certo punto SQL Server renda possibile eseguire operazioni come aggiungere/rimuovere la proprietà IDENTITY, ricostruire gli indici con nuovi tipi di dati di destinazione e modificare le colonne su entrambi i lati di una relazione senza sacrificare la relazione. Nel frattempo, sarei interessato a sapere se questa soluzione ti aiuta o se hai un approccio diverso.

Un grande ringraziamento a James Lupolt (@jlupoltsql) per aver aiutato la sanità mentale a controllare il mio approccio e averlo messo alla prova definitiva su uno dei suoi tavoli reali. (È andata bene. Grazie James!)

[ Parte 1 | Parte 2 | Parte 3 | Parte 4]