Existe-t-il une option / fonctionnalité MySQL pour suivre l'historique des modifications apportées aux enregistrements?

122

On m'a demandé si je pouvais suivre les modifications apportées aux enregistrements dans une base de données MySQL. Ainsi, lorsqu'un champ a été modifié, l'ancien vs le nouveau est disponible et la date à laquelle cela a eu lieu. Existe-t-il une fonctionnalité ou une technique commune pour faire cela?

Si oui, je pensais faire quelque chose comme ça. Créez une table appelée changes. Il contiendrait les mêmes champs que le maître table , mais préfixé avec anciens et nouveaux, mais seulement pour les champs qui ont été effectivement changé et TIMESTAMPpour elle. Il serait indexé avec un fichier ID. De cette façon, un SELECTrapport pourrait être exécuté pour afficher l'historique de chaque enregistrement. Est-ce une bonne méthode? Merci!

Edward
la source

Réponses:

83

C'est subtil.

Si l'exigence métier est "Je veux auditer les modifications apportées aux données - qui a fait quoi et quand?", Vous pouvez généralement utiliser des tables d'audit (selon l'exemple de déclencheur publié par Keethanjan). Je ne suis pas un grand fan des déclencheurs, mais cela a le grand avantage d'être relativement simple à mettre en œuvre - votre code existant n'a pas besoin de connaître les déclencheurs et les audits.

Si l'exigence métier est "montre-moi quel était l'état des données à une date donnée dans le passé", cela signifie que l'aspect du changement dans le temps est entré dans votre solution. Bien que vous puissiez, à peu près, reconstruire l'état de la base de données simplement en regardant les tables d'audit, c'est difficile et sujet aux erreurs, et pour toute logique de base de données compliquée, cela devient compliqué. Par exemple, si l'entreprise veut savoir "trouver les adresses des lettres que nous aurions dû envoyer aux clients qui avaient des factures impayées et impayées le premier jour du mois", vous devrez probablement parcourir une demi-douzaine de tableaux d'audit.

Au lieu de cela, vous pouvez intégrer le concept de changement au fil du temps dans la conception de votre schéma (c'est la deuxième option suggérée par Keethanjan). Il s'agit d'une modification de votre application, certainement au niveau de la logique métier et de la persistance, donc ce n'est pas anodin.

Par exemple, si vous avez une table comme celle-ci:

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

et vous vouliez garder une trace au fil du temps, vous le modifieriez comme suit:

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

Chaque fois que vous souhaitez modifier un enregistrement client, au lieu de mettre à jour l'enregistrement, vous définissez VALID_UNTIL sur l'enregistrement actuel sur NOW () et insérez un nouvel enregistrement avec un VALID_FROM (maintenant) et un VALID_UNTIL nul. Vous définissez le statut "CUSTOMER_USER" sur l'ID de connexion de l'utilisateur actuel (si vous devez le conserver). Si le client doit être supprimé, vous utilisez l'indicateur CUSTOMER_STATUS pour l'indiquer - vous ne pouvez jamais supprimer des enregistrements de cette table.

De cette façon, vous pouvez toujours trouver quel était le statut de la table client à une date donnée - quelle était l'adresse? Ont-ils changé de nom? En vous joignant à d'autres tables avec des dates valid_from et valid_until similaires, vous pouvez reconstruire l'image entière de manière historique. Pour trouver l'état actuel, vous recherchez des enregistrements avec une date VALID_UNTIL nulle.

C'est peu maniable (à proprement parler, vous n'avez pas besoin de valid_from, mais cela rend les requêtes un peu plus faciles). Cela complique votre conception et votre accès à la base de données. Mais cela rend la reconstruction du monde beaucoup plus facile.

Neville Kuyt
la source
Mais cela ajouterait des données en double pour les champs qui ne sont pas mis à jour? Comment le gérer?
itzmukeshy7
Avec la deuxième approche, des problèmes se posent pour la génération de rapports si un enregistrement client est édité au fil du temps, il est difficile de reconnaître si une entrée particulière appartient au même client ou est différente.
Akshay Joshi
Meilleure suggestion que j'ai vue pour ce problème
Worthy7
Oh et, en réponse aux commentaires, que diriez-vous de simplement stocker null pour tout ce qui n'a pas changé? Donc, la dernière version sera toutes les dernières données, mais si le nom était "Bob" il y a 5 jours, alors il n'y a qu'une ligne, name = bob et valide jusqu'à il y a 5 jours.
Worthy7
2
La combinaison de customer_id et des dates est la clé primaire, donc ils seront garantis uniques.
Neville Kuyt
187

Voici un moyen simple de le faire:

Tout d'abord, créez une table d'historique pour chaque table de données que vous souhaitez suivre (exemple de requête ci-dessous). Cette table aura une entrée pour chaque requête d'insertion, de mise à jour et de suppression effectuée sur chaque ligne de la table de données.

La structure de la table d'historique sera la même que la table de données qu'elle suit à l'exception de trois colonnes supplémentaires: une colonne pour stocker l'opération qui s'est produite (appelons-la `` action ''), la date et l'heure de l'opération, et une colonne pour stocker un numéro de séquence ('révision'), qui s'incrémente par opération et est regroupé par la colonne de clé primaire de la table de données.

Pour effectuer ce comportement de séquencement, un index à deux colonnes (composite) est créé sur la colonne de clé primaire et la colonne de révision. Notez que vous ne pouvez effectuer de séquençage de cette manière que si le moteur utilisé par la table d'historique est MyISAM ( voir 'MyISAM Notes' sur cette page)

La table d'historique est assez facile à créer. Dans la requête ALTER TABLE ci-dessous (et dans les requêtes de déclenchement ci-dessous), remplacez «primary_key_column» par le nom réel de cette colonne dans votre table de données.

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

Et puis vous créez les déclencheurs:

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;

Et tu as fini. Désormais, toutes les insertions, mises à jour et suppressions dans 'MyDb.data' seront enregistrées dans 'MyDb.data_history', vous donnant une table d'historique comme celle-ci (moins la colonne 'data_columns' artificielle)

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 

Pour afficher les modifications d'une ou plusieurs colonnes données de mise à jour à mise à jour, vous devrez joindre la table d'historique à elle-même sur les colonnes de clé primaire et de séquence. Vous pouvez créer une vue à cet effet, par exemple:

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

Edit: Oh wow, les gens aiment ma table d'histoire d'il y a 6 ans: P

Ma mise en œuvre continue de fredonner, de plus en plus grande et de plus en plus lourde, je suppose. J'ai écrit des vues et une interface utilisateur assez sympa pour regarder l'historique de cette base de données, mais je ne pense pas qu'elle ait jamais été beaucoup utilisée. Alors ça va.

Pour répondre à certains commentaires sans ordre particulier:

  • J'ai fait ma propre implémentation en PHP qui était un peu plus complexe, et j'ai évité certains des problèmes décrits dans les commentaires (transfert d'index, de manière significative. Si vous transférez des index uniques vers la table d'historique, les choses vont casser. Il existe des solutions pour ceci dans les commentaires). Suivre ce message à la lettre pourrait être une aventure, selon la façon dont votre base de données est établie.

  • Si la relation entre la clé primaire et la colonne de révision semble incorrecte, cela signifie généralement que la clé composite est bloquée d'une manière ou d'une autre. À quelques rares occasions, cela s'est produit et j'ai perdu la cause.

  • J'ai trouvé cette solution assez performante, utilisant des déclencheurs comme elle le fait. De plus, MyISAM est rapide aux insertions, ce que font les déclencheurs. Vous pouvez encore améliorer cela avec une indexation intelligente (ou l'absence de ...). L'insertion d'une seule ligne dans une table MyISAM avec une clé primaire ne devrait pas être une opération que vous devez optimiser, vraiment, à moins que vous ayez des problèmes importants ailleurs. Pendant tout le temps où j'exécutais la base de données MySQL sur laquelle était implémentée cette table d'historique, cela n'a jamais été la cause des (nombreux) problèmes de performances qui se sont posés.

  • si vous recevez des insertions répétées, vérifiez votre couche logicielle pour les requêtes de type INSERT IGNORE. Hrmm, je ne m'en souviens pas maintenant, mais je pense qu'il y a des problèmes avec ce schéma et les transactions qui échouent finalement après l'exécution de plusieurs actions DML. Quelque chose dont il faut être conscient au moins.

  • Il est important que les champs de la table d'historique et de la table de données correspondent. Ou, plutôt, que votre table de données n'a pas PLUS de colonnes que la table d'historique. Sinon, les requêtes d'insertion / mise à jour / suppression sur la table de données échoueront, lorsque les insertions dans les tables d'historique placent des colonnes dans la requête qui n'existent pas (en raison de d. * Dans les requêtes de déclencheur) et que le déclencheur échoue. Ce serait génial si MySQL avait quelque chose comme des déclencheurs de schéma, où vous pourriez modifier la table d'historique si des colonnes étaient ajoutées à la table de données. MySQL a-t-il cela maintenant? Je réagis ces jours-ci: P

fermeture transitoire
la source
3
j'aime vraiment cette solution. cependant si votre table principale n'a pas de clé primaire ou que vous ne savez pas ce qu'est la principale, c'est un peu délicat.
Benjamin Eckstein
1
J'ai récemment rencontré un problème en utilisant cette solution pour un projet, en raison de la façon dont tous les index de la table d'origine sont copiés dans la table d'historique (en raison de la façon dont CREATE TABLE ... LIKE .... fonctionne). Avoir des index uniques sur la table d'historique peut entraîner la requête INSERT dans le déclencheur AFTER UPDATE à barf, ils doivent donc être supprimés. Dans le script php que j'ai qui fait cela, je recherche tous les index uniques sur les tables d'historique nouvellement créées (avec "SHOW INDEX FROM data_table WHERE Key_name! = 'PRIMARY' et Non_unique = 0"), puis les supprimer.
fermeture transitoire
3
Ici, nous obtenons des données répétées insérées dans la table de sauvegarde à chaque fois. Supposons que si nous avons 10 champs dans une table et que nous en avons mis à jour 2, nous ajoutons des données répétées pour les 8 champs restants. Comment en surmonter?
itzmukeshy7
6
Vous pouvez éviter de transférer accidentellement les différents indices en modifiant l'instruction create table enCREATE TABLE MyDB.data_history as select * from MyDB.data limit 0;
Eric Hayes
4
@transientclosure Comment proposeriez-vous d'intégrer d'autres champs dans l'historique qui ne faisaient pas partie de la requête d'origine? Par exemple, je veux savoir qui fait ces changements. pour insérer, il a déjà un ownerchamp, et pour la mise à jour, je pourrais ajouter un updatedbychamp, mais pour supprimer, je ne sais pas comment je pourrais le faire via les déclencheurs. mettre à jour la data_historyligne avec l'identifiant de l'utilisateur dans se sent sale: P
Cheval
16

Vous pouvez créer des déclencheurs pour résoudre ce problème. Voici un tutoriel pour le faire (lien archivé).

La définition de contraintes et de règles dans la base de données est préférable à l'écriture de code spécial pour gérer la même tâche, car cela empêchera un autre développeur d'écrire une requête différente qui contourne tout le code spécial et pourrait laisser votre base de données avec une mauvaise intégrité des données.

Pendant longtemps, j'ai copié des informations dans une autre table à l'aide d'un script car MySQL ne supportait pas les déclencheurs à l'époque. J'ai maintenant trouvé que ce déclencheur était plus efficace pour garder une trace de tout.

Ce déclencheur copiera une ancienne valeur dans une table d'historique si elle est modifiée lorsque quelqu'un modifie une ligne. Editor IDet last modsont stockés dans la table d'origine chaque fois que quelqu'un modifie cette ligne; l'heure correspond au moment où il a été changé dans sa forme actuelle.

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

Une autre solution serait de conserver un champ de révision et de mettre à jour ce champ lors de l'enregistrement. Vous pouvez décider que le max est la dernière révision ou que 0 est la ligne la plus récente. C'est à toi de voir.

Keethanjan
la source
9

Voici comment nous l'avons résolu

une table Utilisateurs ressemblait à ceci

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

Et les exigences commerciales ont changé et nous devions vérifier toutes les adresses et numéros de téléphone précédents qu'un utilisateur ait jamais eu. le nouveau schéma ressemble à ceci

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

Pour trouver l'adresse actuelle d'un utilisateur, nous recherchons UserData avec la révision DESC et LIMIT 1

Pour obtenir l'adresse d'un utilisateur entre une certaine période, nous pouvons utiliser created_on bewteen (date1, date 2)

Zenex
la source
C'est une solution que je veux avoir mais que je veux savoir Comment pouvez-vous insérer id_user dans cette table à l'aide de trigger?
thecassion
1
Qu'est-il arrivé à revision=1l ' id_user=1? D'abord, je pensais que votre comptage était 0,2,3,...mais ensuite j'ai vu que pour id_user=2le comptage des révisions est0,1, ...
Pathros
1
Vous n'avez pas besoin idet les id_usercolonnes . Just use a group ID of id` (ID utilisateur) et revision.
Gajus
6

MariaDB prend en charge la gestion des versions du système depuis la version 10.3, qui est la fonction SQL standard qui fait exactement ce que vous voulez: elle stocke l'historique des enregistrements de table et y donne accès via des SELECTrequêtes. MariaDB est un fork de MySQL à développement ouvert. Vous pouvez en savoir plus sur son système de versioning via ce lien:

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

Midenok
la source
Veuillez noter ce qui suit à partir du lien ci-dessus: "mysqldump ne lit pas les lignes historiques des tables versionnées, et les données historiques ne seront donc pas sauvegardées. De plus, une restauration des horodatages ne serait pas possible car ils ne peuvent pas être définis par une insertion / un utilisateur."
Daniel
4

Pourquoi ne pas simplement utiliser les fichiers journaux bin? Si la réplication est définie sur le serveur Mysql et que le format de fichier binlog est défini sur ROW, toutes les modifications peuvent être capturées.

Une bonne bibliothèque python appelée noplay peut être utilisée. Plus d'infos ici .

Ouroboros
la source
2
Binlog peut être utilisé même si vous n'avez pas / avez besoin de réplication. Binlog a de nombreux cas d'utilisation bénéfiques. La réplication est probablement le cas d'utilisation le plus courant, mais elle peut également être exploitée pour les sauvegardes et l'historique d'audit, comme mentionné ici.
webaholik
3

Juste mes 2 cents. Je créerais une solution qui enregistre exactement ce qui a changé, très similaire à la solution de transitoire.

Ma table des modifications serait simple:

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

1) Lorsqu'une ligne entière est modifiée dans la table principale, beaucoup d'entrées iront dans cette table, MAIS c'est très peu probable, donc pas un gros problème (les gens ne changent généralement qu'une chose) 2) OldVaue (et NewValue si vous want) doit être une sorte de "anytype" épique car il peut s'agir de n'importe quelle donnée, il pourrait y avoir un moyen de le faire avec des types RAW ou simplement en utilisant des chaînes JSON pour convertir en entrée et en sortie.

Utilisation minimale des données, stocke tout ce dont vous avez besoin et peut être utilisé pour toutes les tables à la fois. Je fais des recherches moi-même en ce moment, mais cela pourrait finir par être la voie à suivre.

Pour créer et supprimer, juste l'ID de ligne, aucun champ nécessaire. Sur supprimer un drapeau sur la table principale (actif?) Serait bien.

Digne7
la source
0

La manière directe de le faire est de créer des déclencheurs sur les tables. Définissez des conditions ou des méthodes de mappage. Lorsque la mise à jour ou la suppression se produit, elle s'insère automatiquement dans la table de «modification».

Mais la plus grande partie est que se passe-t-il si nous avons beaucoup de colonnes et beaucoup de tables. Nous devons taper le nom de chaque colonne de chaque table. De toute évidence, c'est une perte de temps.

Pour gérer cela plus magnifiquement, nous pouvons créer des procédures ou des fonctions pour récupérer le nom des colonnes.

Nous pouvons également utiliser un outil 3ème partie simplement pour ce faire. Ici, j'écris un programme java Mysql Tracker

goforu
la source
comment puis-je utiliser votre Mysql Tracker?
webchun
1
1. Assurez-vous que vous avez une colonne id comme clé primaire dans chaque table. 2. Copiez le fichier java dans local (ou IDE). 3. Importez les bibliothèques et modifiez les variables statiques de la ligne 9 à 15 en fonction de la configuration et de la structure de votre base de données. 4. Analysez et exécutez le fichier java 5. Copiez le journal de la console et exécutez-le en tant que commandes Mysql
goforu
create table like tableJe pense qu'il reproduit facilement toutes les colonnes
Jonathan