Esiste un'opzione / funzionalità MySQL per tenere traccia della cronologia delle modifiche ai record?


122

Mi è stato chiesto se posso tenere traccia delle modifiche ai record in un database MySQL. Quindi, quando un campo è stato modificato, il vecchio vs il nuovo è disponibile e la data in cui è avvenuto. C'è una caratteristica o una tecnica comune per farlo?

Se è così, stavo pensando di fare qualcosa del genere. Crea una tabella chiamata changes. Conterrebbe gli stessi campi della tabella principale ma con il prefisso vecchio e nuovo, ma solo per quei campi che sono stati effettivamente modificati e TIMESTAMPper essa. Sarebbe indicizzato con un ID. In questo modo, è SELECTpossibile eseguire un report per mostrare la cronologia di ogni record. È un buon metodo? Grazie!

Risposte:


83

È sottile.

Se il requisito aziendale è "Voglio controllare le modifiche ai dati - chi ha fatto cosa e quando?", Di solito puoi utilizzare le tabelle di controllo (come nell'esempio di trigger pubblicato da Keethanjan). Non sono un grande fan dei trigger, ma ha il grande vantaggio di essere relativamente indolore da implementare: il tuo codice esistente non ha bisogno di conoscere i trigger e le cose di controllo.

Se il requisito aziendale è "mostrami qual era lo stato dei dati in una determinata data nel passato", significa che l'aspetto del cambiamento nel tempo è entrato nella tua soluzione. Sebbene sia possibile ricostruire lo stato del database semplicemente guardando le tabelle di controllo, è difficile e soggetto a errori e, per qualsiasi logica di database complicata, diventa ingombrante. Ad esempio, se l'azienda vuole sapere "trovare gli indirizzi delle lettere che avremmo dovuto inviare ai clienti che avevano fatture in sospeso e non pagate il primo giorno del mese", probabilmente dovrai sfogliare una mezza dozzina di tabelle di audit.

Invece, puoi incorporare il concetto di cambiamento nel tempo nella progettazione dello schema (questa è la seconda opzione suggerita da Keethanjan). Questa è una modifica alla tua applicazione, sicuramente a livello di logica aziendale e persistenza, quindi non è banale.

Ad esempio, se hai una tabella come questa:

CUSTOMER
---------
CUSTOMER_ID PK
CUSTOMER_NAME
CUSTOMER_ADDRESS

e volevi tenerne traccia nel tempo, lo modificheresti come segue:

CUSTOMER
------------
CUSTOMER_ID            PK
CUSTOMER_VALID_FROM    PK
CUSTOMER_VALID_UNTIL   PK
CUSTOMER_STATUS
CUSTOMER_USER
CUSTOMER_NAME
CUSTOMER_ADDRESS

Ogni volta che si desidera modificare un record del cliente, invece di aggiornare il record, si imposta VALID_UNTIL sul record corrente su NOW () e si inserisce un nuovo record con VALID_FROM (ora) e VALID_UNTIL nullo. Si imposta lo stato "CUSTOMER_USER" all'ID di accesso dell'utente corrente (se è necessario mantenerlo). Se il cliente deve essere eliminato, utilizza il flag CUSTOMER_STATUS per indicarlo: potresti non eliminare mai i record da questa tabella.

In questo modo, puoi sempre trovare qual era lo stato della tabella clienti per una determinata data: qual era l'indirizzo? Hanno cambiato nome? Unendoti ad altre tabelle con date valid_from e valid_until simili, puoi ricostruire l'intera immagine storicamente. Per trovare lo stato corrente, cerca i record con una data VALID_UNTIL nulla.

È ingombrante (in senso stretto, non è necessario valid_from, ma rende le query un po 'più semplici). Complica la progettazione e l'accesso al database. Ma rende la ricostruzione del mondo molto più semplice.


Ma aggiungerebbe dati duplicati per quei campi che non vengono aggiornati? Come gestirlo?
itzmukeshy7

Con il secondo approccio sorgono problemi per la generazione di report se un record del cliente viene modificato nel tempo, è difficile riconoscere se una particolare voce appartiene allo stesso cliente o a un'altra.
Akshay Joshi,

Il miglior suggerimento che abbia mai visto per questo problema
Worthy7

Oh e, in risposta ai commenti, che ne dici di memorizzare semplicemente null per tutto il resto che non è cambiato? Quindi la versione più recente sarà tutti i dati più recenti, ma se il nome era "Bob" 5 giorni fa, allora hai solo una riga, name = bob e valida fino a 5 giorni fa.
Worthy7

2
La combinazione di customer_id e le date sono la chiave primaria, quindi saranno garantite uniche.
Neville Kuyt

186

Ecco un modo semplice per farlo:

Innanzitutto, crea una tabella della cronologia per ogni tabella di dati che desideri monitorare (query di esempio di seguito). Questa tabella avrà una voce per ogni query di inserimento, aggiornamento ed eliminazione eseguita su ogni riga nella tabella dati.

La struttura della tabella della cronologia sarà la stessa della tabella dei dati che tiene traccia ad eccezione di tre colonne aggiuntive: una colonna per memorizzare l'operazione che si è verificata (chiamiamola 'azione'), la data e l'ora dell'operazione e una colonna per memorizzare un numero di sequenza ("revisione"), che aumenta per operazione ed è raggruppato in base alla colonna della chiave primaria della tabella dati.

Per eseguire questo comportamento di sequenziamento, viene creato un indice a due colonne (composito) sulla colonna della chiave primaria e sulla colonna della revisione. Nota che puoi eseguire la sequenza in questo modo solo se il motore utilizzato dalla tabella della cronologia è MyISAM ( vedi "Note MyISAM" in questa pagina)

La tabella della cronologia è abbastanza facile da creare. Nella query ALTER TABLE di seguito (e nelle query trigger di seguito), sostituisci "primary_key_column" con il nome effettivo di quella colonna nella tabella dei dati.

CREATE TABLE MyDB.data_history LIKE MyDB.data;

ALTER TABLE MyDB.data_history MODIFY COLUMN primary_key_column int(11) NOT NULL, 
   DROP PRIMARY KEY, ENGINE = MyISAM, ADD action VARCHAR(8) DEFAULT 'insert' FIRST, 
   ADD revision INT(6) NOT NULL AUTO_INCREMENT AFTER action,
   ADD dt_datetime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER revision,
   ADD PRIMARY KEY (primary_key_column, revision);

E poi crei i trigger:

DROP TRIGGER IF EXISTS MyDB.data__ai;
DROP TRIGGER IF EXISTS MyDB.data__au;
DROP TRIGGER IF EXISTS MyDB.data__bd;

CREATE TRIGGER MyDB.data__ai AFTER INSERT ON MyDB.data FOR EACH ROW
    INSERT INTO MyDB.data_history SELECT 'insert', NULL, NOW(), d.* 
    FROM MyDB.data AS d WHERE d.primary_key_column = NEW.primary_key_column;

CREATE TRIGGER MyDB.data__au AFTER UPDATE ON MyDB.data FOR EACH ROW
    INSERT INTO MyDB.data_history SELECT 'update', NULL, NOW(), d.*
    FROM MyDB.data AS d WHERE d.primary_key_column = NEW.primary_key_column;

CREATE TRIGGER MyDB.data__bd BEFORE DELETE ON MyDB.data FOR EACH ROW
    INSERT INTO MyDB.data_history SELECT 'delete', NULL, NOW(), d.* 
    FROM MyDB.data AS d WHERE d.primary_key_column = OLD.primary_key_column;

E hai finito. Ora, tutti gli inserimenti, gli aggiornamenti e le eliminazioni in "MyDb.data" verranno registrati in "MyDb.data_history", dandoti una tabella di cronologia come questa (meno la colonna "data_columns" forzata)

ID    revision   action    data columns..
1     1         'insert'   ....          initial entry for row where ID = 1
1     2         'update'   ....          changes made to row where ID = 1
2     1         'insert'   ....          initial entry, ID = 2
3     1         'insert'   ....          initial entry, ID = 3 
1     3         'update'   ....          more changes made to row where ID = 1
3     2         'update'   ....          changes made to row where ID = 3
2     2         'delete'   ....          deletion of row where ID = 2 

Per visualizzare le modifiche per una determinata colonna o colonne dall'aggiornamento all'aggiornamento, è necessario unire la tabella della cronologia a se stessa sulla chiave primaria e sulle colonne della sequenza. È possibile creare una vista per questo scopo, ad esempio:

CREATE VIEW data_history_changes AS 
   SELECT t2.dt_datetime, t2.action, t1.primary_key_column as 'row id', 
   IF(t1.a_column = t2.a_column, t1.a_column, CONCAT(t1.a_column, " to ", t2.a_column)) as a_column
   FROM MyDB.data_history as t1 INNER join MyDB.data_history as t2 on t1.primary_key_column = t2.primary_key_column 
   WHERE (t1.revision = 1 AND t2.revision = 1) OR t2.revision = t1.revision+1
   ORDER BY t1.primary_key_column ASC, t2.revision ASC

Modifica: Oh wow, alle persone piace la mia cosa della tabella di storia di 6 anni fa: P.

La mia implementazione continua a ronzare, diventando più grande e più ingombrante, presumo. Ho scritto visualizzazioni e un'interfaccia utente piuttosto carina per esaminare la cronologia in questo database, ma non credo sia mai stato utilizzato molto. Così è andata.

Per indirizzare alcuni commenti senza un ordine particolare:

  • Ho realizzato la mia implementazione in PHP che è stata un po 'più complicata ed ho evitato alcuni dei problemi descritti nei commenti (avendo gli indici trasferiti, in modo significativo. Se trasferisci su indici univoci alla tabella della cronologia, le cose si interromperanno. Ci sono soluzioni per questo nei commenti). Seguire questo post alla lettera potrebbe essere un'avventura, a seconda di quanto è consolidato il tuo database.

  • Se la relazione tra la chiave primaria e la colonna di revisione sembra fuori posto, di solito significa che la chiave composita è in qualche modo bloccata. In alcune rare occasioni mi è capitato questo e non sapevo quale fosse la causa.

  • Ho trovato questa soluzione piuttosto performante, usando i trigger come fa. Inoltre, MyISAM è veloce negli inserimenti, che è tutto ciò che fanno i trigger. Puoi migliorarlo ulteriormente con l'indicizzazione intelligente (o la mancanza di ...). L'inserimento di una singola riga in una tabella MyISAM con una chiave primaria non dovrebbe essere un'operazione che devi ottimizzare, davvero, a meno che tu non abbia problemi significativi in ​​corso altrove. Per tutto il tempo in cui ho eseguito il database MySQL su cui si trovava l'implementazione della tabella di cronologia, non è mai stata la causa di nessuno dei (molti) problemi di prestazioni che si sono verificati.

  • se si ottengono inserimenti ripetuti, controllare il livello del software per query di tipo INSERT IGNORE. Hrmm, non ricordo ora, ma penso che ci siano problemi con questo schema e le transazioni che alla fine falliscono dopo aver eseguito più azioni DML. Qualcosa di cui essere consapevoli, almeno.

  • È importante che i campi nella tabella della cronologia e nella tabella dei dati corrispondano. O, piuttosto, che la tabella dei dati non ha PIÙ colonne della tabella della cronologia. In caso contrario, le query di inserimento / aggiornamento / cancellazione sulla tabella dati falliranno, quando gli inserimenti nelle tabelle cronologiche inseriranno nella query colonne che non esistono (a causa di d. * Nelle query trigger) e il trigger fallirà. Sarebbe fantastico se MySQL avesse qualcosa come i trigger di schema, dove potresti alterare la tabella della cronologia se le colonne fossero aggiunte alla tabella dei dati. MySQL ce l'ha adesso? Reagisco in questi giorni: P


3
mi piace molto questa soluzione. tuttavia, se la tua tabella principale non ha una chiave primaria o non sai quale sia la primaria, è un po 'complicato.
Benjamin Eckstein

1
Recentemente ho riscontrato un problema utilizzando questa soluzione per un progetto, a causa del modo in cui tutti gli indici dalla tabella originale vengono copiati nella tabella della cronologia (a causa di come funziona CREATE TABLE ... LIKE ....). La presenza di indici univoci nella tabella della cronologia può causare il barf della query INSERT nel trigger AFTER UPDATE, quindi è necessario rimuoverli. Nello script php che ho che fa queste cose, cerco eventuali indici univoci sulle tabelle di cronologia appena create (con "SHOW INDEX FROM data_table WHERE Key_name! = 'PRIMARY' e Non_unique = 0"), quindi li rimuovo.
chiusura transitoria

3
Qui stiamo ricevendo dati ripetuti ogni volta inseriti nella tabella di backup. Supponiamo che se abbiamo 10 campi in una tabella e ne abbiamo aggiornati 2, stiamo aggiungendo dati ripetuti per i restanti 8 campi. Come superarlo?
itzmukeshy7

6
È possibile evitare il trasferimento accidentale dei vari indici modificando l'istruzione create table inCREATE TABLE MyDB.data_history as select * from MyDB.data limit 0;
Eric Hayes

4
@transientclosure come proporresti di inserire altri campi nella cronologia che non facevano parte della query originale? ad esempio, voglio tenere traccia di chi apporta tali modifiche. per l'inserimento ha già un ownercampo e per l'aggiornamento potrei aggiungere un updatedbycampo, ma per l'eliminazione non sono sicuro di come avrei potuto farlo tramite i trigger. l'aggiornamento della data_historyriga con l'ID utente in sembra sporco: P
Cavallo

16

Potresti creare trigger per risolvere questo problema. Ecco un tutorial per farlo (link archiviato).

L'impostazione di vincoli e regole nel database è meglio che scrivere codice speciale per gestire la stessa attività poiché impedirà a un altro sviluppatore di scrivere una query diversa che ignori tutto il codice speciale e potrebbe lasciare il database con una scarsa integrità dei dati.

Per molto tempo ho copiato le informazioni su un'altra tabella utilizzando uno script poiché MySQL non supportava i trigger in quel momento. Ora ho scoperto che questo trigger è più efficace nel tenere traccia di tutto.

Questo trigger copierà un vecchio valore in una tabella di cronologia se viene modificato quando qualcuno modifica una riga. Editor IDe last modvengono memorizzati nella tabella originale ogni volta che qualcuno modifica quella riga; l'ora corrisponde a quando è stata modificata nella sua forma attuale.

DROP TRIGGER IF EXISTS history_trigger $$

CREATE TRIGGER history_trigger
BEFORE UPDATE ON clients
    FOR EACH ROW
    BEGIN
        IF OLD.first_name != NEW.first_name
        THEN
                INSERT INTO history_clients
                    (
                        client_id    ,
                        col          ,
                        value        ,
                        user_id      ,
                        edit_time
                    )
                    VALUES
                    (
                        NEW.client_id,
                        'first_name',
                        NEW.first_name,
                        NEW.editor_id,
                        NEW.last_mod
                    );
        END IF;

        IF OLD.last_name != NEW.last_name
        THEN
                INSERT INTO history_clients
                    (
                        client_id    ,
                        col          ,
                        value        ,
                        user_id      ,
                        edit_time
                    )
                    VALUES
                    (
                        NEW.client_id,
                        'last_name',
                        NEW.last_name,
                        NEW.editor_id,
                        NEW.last_mod
                    );
        END IF;

    END;
$$

Un'altra soluzione sarebbe mantenere un campo Revisione e aggiornare questo campo al salvataggio. Potresti decidere che il massimo è la revisione più recente o che 0 è la riga più recente. Dipende da te.


9

Ecco come l'abbiamo risolto

una tabella Users aveva questo aspetto

Users
-------------------------------------------------
id | name | address | phone | email | created_on | updated_on

E i requisiti aziendali sono cambiati e dovevamo controllare tutti gli indirizzi e i numeri di telefono precedenti che un utente avesse mai avuto. il nuovo schema ha questo aspetto

Users (the data that won't change over time)
-------------
id | name

UserData (the data that can change over time and needs to be tracked)
-------------------------------------------------
id | id_user | revision | city | address | phone | email | created_on
 1 |   1     |    0     | NY   | lake st | 9809  | @long | 2015-10-24 10:24:20
 2 |   1     |    2     | Tokyo| lake st | 9809  | @long | 2015-10-24 10:24:20
 3 |   1     |    3     | Sdny | lake st | 9809  | @long | 2015-10-24 10:24:20
 4 |   2     |    0     | Ankr | lake st | 9809  | @long | 2015-10-24 10:24:20
 5 |   2     |    1     | Lond | lake st | 9809  | @long | 2015-10-24 10:24:20

Per trovare l'indirizzo corrente di qualsiasi utente, cerchiamo UserData con revisione DESC e LIMIT 1

Per ottenere l'indirizzo di un utente tra un certo periodo di tempo possiamo usare created_on bewteen (date1, date 2)


È la soluzione che voglio avere ma voglio sapere Come puoi inserire id_user in questa tabella usando il trigger?
passione

1
Cosa è successo al revision=1di id_user=1? All'inizio pensavo che il tuo conteggio fosse, 0,2,3,...ma poi ho visto che per id_user=2il conteggio delle revisioni il conteggio è0,1, ...
Pathros

1
Non hai bisogno di ide id_usercolonne . Just use a group ID of id` (ID utente) e revision.
Gajus

6

MariaDB supporta il controllo delle versioni del sistema dalla 10.3, che è la funzionalità SQL standard che fa esattamente ciò che desideri: memorizza la cronologia dei record della tabella e fornisce l'accesso tramite SELECT query. MariaDB è un fork di sviluppo aperto di MySQL. Puoi trovare ulteriori informazioni sul controllo delle versioni del sistema tramite questo link:

https://mariadb.com/kb/en/library/system-versioned-tables/


Si prega di notare quanto segue dal collegamento sopra: "mysqldump non legge le righe storiche dalle tabelle con versione, quindi non verrà eseguito il backup dei dati storici. Inoltre, un ripristino dei timestamp non sarebbe possibile in quanto non possono essere definiti da un inserimento / un utente."
Daniel

4

Perché non utilizzare semplicemente i file di registro bin? Se la replica è impostata sul server Mysql e il formato del file binlog è impostato su ROW, è possibile acquisire tutte le modifiche.

Può essere utilizzata una buona libreria python chiamata noplay. Maggiori info qui .


2
Binlog può essere utilizzato anche se non hai / hai bisogno di replica. Binlog ha molti casi d'uso vantaggiosi. La replica è probabilmente il caso d'uso più comune, ma può essere utilizzata anche per i backup e la cronologia dei controlli, come menzionato qui.
webaholik

3

Solo i miei 2 centesimi. Creerei una soluzione che registra esattamente ciò che è cambiato, molto simile alla soluzione transitoria.

La mia tabella delle modifiche sarebbe semplice:

DateTime | WhoChanged | TableName | Action | ID |FieldName | OldValue

1) Quando un'intera riga viene modificata nella tabella principale, molte voci andranno in questa tabella, MA questo è molto improbabile, quindi non è un grosso problema (le persone di solito cambiano solo una cosa) 2) OldVaue (e NewValue se tu want) deve essere una sorta di epico "anytype" poiché potrebbe essere qualsiasi dato, potrebbe esserci un modo per farlo con i tipi RAW o semplicemente usando stringhe JSON per convertire dentro e fuori.

Utilizzo minimo dei dati, memorizza tutto ciò di cui hai bisogno e può essere utilizzato per tutte le tabelle contemporaneamente. Sto facendo ricerche da solo in questo momento, ma potrebbe finire per essere il modo in cui vado.

Per Crea ed Elimina, solo l'ID riga, nessun campo necessario. In caso di eliminazione di un flag sulla tabella principale (attivo?) Sarebbe utile.


0

Il modo diretto per farlo è creare trigger sulle tabelle. Imposta alcune condizioni o metodi di mappatura. Quando si verifica l'aggiornamento o l'eliminazione, verrà inserito automaticamente nella tabella "modifica".

Ma la parte più importante è se avessimo molte colonne e molte tabelle. Dobbiamo digitare il nome di ogni colonna di ogni tabella. Ovviamente è una perdita di tempo.

Per gestirlo in modo più splendido, possiamo creare alcune procedure o funzioni per recuperare il nome delle colonne.

Possiamo anche usare uno strumento di terza parte semplicemente per farlo. Qui, scrivo un programma java Mysql Tracker


come posso usare il tuo Mysql Tracker?
webchun

1
1. Assicurati di avere una colonna id come chiave primaria in ogni tabella. 2. Copiare il file java in locale (o IDE) 3. Importare le librerie e modificare le variabili statiche dalla riga 9-15 in base alla configurazione e struttura del database. 4. Analizza ed esegui il file java 5. Copia il log della console ed
eseguilo

create table like tablePenso che replichi facilmente tutte le colonne
Jonathan
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.