¿Existe una opción / función de MySQL para rastrear el historial de cambios en los registros?

122

Me han preguntado si puedo realizar un seguimiento de los cambios en los registros en una base de datos MySQL. Entonces, cuando se ha cambiado un campo, el antiguo frente al nuevo está disponible y la fecha en que se realizó. ¿Existe una característica o técnica común para hacer esto?

Si es así, estaba pensando en hacer algo como esto. Crea una tabla llamada changes. Contendría los mismos campos que la tabla maestra pero con el prefijo antiguo y nuevo, pero solo para aquellos campos que realmente se cambiaron y un TIMESTAMPpara él. Estaría indexado con un ID. De esta forma, se SELECTpodría ejecutar un informe para mostrar el historial de cada registro. ¿Es este un buen método? ¡Gracias!

Eduardo
fuente

Respuestas:

83

Es sutil.

Si el requisito empresarial es "Quiero auditar los cambios en los datos, ¿quién hizo qué y cuándo?", Normalmente puede utilizar tablas de auditoría (según el ejemplo de activación que publicó Keethanjan). No soy un gran admirador de los desencadenantes, pero tiene la gran ventaja de ser relativamente fácil de implementar: su código existente no necesita saber sobre los desencadenantes y las cosas de auditoría.

Si el requisito comercial es "muéstreme cuál era el estado de los datos en una fecha determinada en el pasado", significa que el aspecto del cambio a lo largo del tiempo ha entrado en su solución. Si bien puede, prácticamente, reconstruir el estado de la base de datos con solo mirar las tablas de auditoría, es difícil y propenso a errores, y para cualquier lógica de base de datos complicada, se vuelve difícil de manejar. Por ejemplo, si la empresa quiere saber "encontrar las direcciones de las cartas que deberíamos haber enviado a los clientes que tenían facturas pendientes de pago el primer día del mes", es probable que tenga que rastrear media docena de tablas de auditoría.

En su lugar, puede incorporar el concepto de cambio a lo largo del tiempo en el diseño de su esquema (esta es la segunda opción que sugiere Keethanjan). Este es un cambio en su aplicación, definitivamente a nivel de lógica empresarial y persistencia, por lo que no es trivial.

Por ejemplo, si tiene una mesa como esta:

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

y desea realizar un seguimiento a lo largo del tiempo, lo modificaría de la siguiente manera:

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

Cada vez que desee cambiar un registro de cliente, en lugar de actualizar el registro, configure VALID_UNTIL en el registro actual en NOW () e inserte un nuevo registro con VALID_FROM (ahora) y VALID_UNTIL nulo. Establece el estado "CUSTOMER_USER" en el ID de inicio de sesión del usuario actual (si necesita conservarlo). Si es necesario eliminar el cliente, utilice la marca CUSTOMER_STATUS para indicarlo; nunca puede eliminar registros de esta tabla.

De esa manera, siempre puede encontrar el estado de la tabla de clientes para una fecha determinada: ¿cuál era la dirección? ¿Han cambiado de nombre? Al unirse a otras tablas con fechas valid_from y valid_until similares, puede reconstruir la imagen completa históricamente. Para encontrar el estado actual, busque registros con una fecha VALID_UNTIL nula.

Es difícil de manejar (estrictamente hablando, no necesita valid_from, pero facilita un poco las consultas). Complica su diseño y el acceso a su base de datos. Pero hace que reconstruir el mundo sea mucho más fácil.

Neville Kuyt
fuente
¿Pero agregaría datos duplicados para aquellos campos que no están actualizados? ¿Cómo gestionarlo?
itzmukeshy7
Con el segundo enfoque, surgen problemas para la generación de informes si el registro de un cliente se edita durante un tiempo, es difícil reconocer si una entrada en particular pertenece al mismo cliente o diferente.
Akshay Joshi
La mejor sugerencia que he visto hasta ahora para este problema
Worthy7
Ah, y, en respuesta a los comentarios, ¿qué tal simplemente almacenar nulo para todo lo demás que no cambió? Entonces, la última versión será toda la información más reciente, pero si el nombre solía ser "Bob" hace 5 días, entonces solo tiene una fila, name = bob y válida hasta hace 5 días.
Worthy7
2
La combinación de customer_id y las fechas son la clave principal, por lo que se garantizará que sean únicos.
Neville Kuyt
186

Aquí tienes una forma sencilla de hacer esto:

Primero, cree una tabla de historial para cada tabla de datos que desee rastrear (consulta de ejemplo a continuación). Esta tabla tendrá una entrada para cada consulta de inserción, actualización y eliminación realizada en cada fila de la tabla de datos.

La estructura de la tabla de historial será la misma que la tabla de datos que rastrea excepto por tres columnas adicionales: una columna para almacenar la operación que ocurrió (llamémosla 'acción'), la fecha y hora de la operación y una columna para almacenar un número de secuencia ('revisión'), que se incrementa por operación y se agrupa por la columna de clave principal de la tabla de datos.

Para realizar este comportamiento de secuenciación, se crea un índice de dos columnas (compuesto) en la columna de clave principal y la columna de revisión. Tenga en cuenta que solo puede realizar la secuenciación de esta manera si el motor utilizado por la tabla de historial es MyISAM ( consulte 'Notas de MyISAM' en esta página)

La tabla de historial es bastante fácil de crear. En la consulta ALTER TABLE a continuación (y en las consultas de activación debajo de eso), reemplace 'primary_key_column' con el nombre real de esa columna en su tabla de datos.

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

Y luego creas los disparadores:

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;

Y tu estas listo. Ahora, todas las inserciones, actualizaciones y eliminaciones en 'MyDb.data' se registrarán en 'MyDb.data_history', brindándole una tabla de historial como esta (menos la columna artificial 'data_columns')

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 

Para mostrar los cambios para una columna o columnas determinadas de actualización a actualización, deberá unir la tabla de historial a sí misma en la clave principal y las columnas de secuencia. Puede crear una vista para este propósito, por ejemplo:

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

Editar: Oh wow, a la gente le gusta mi tabla de historial de hace 6 años: P

Mi implementación sigue avanzando, haciéndose más grande y más difícil de manejar, supongo. Escribí vistas y una interfaz de usuario bastante agradable para ver el historial en esta base de datos, pero no creo que se haya usado mucho. Así que va.

Para abordar algunos comentarios sin ningún orden en particular:

  • Hice mi propia implementación en PHP que fue un poco más complicada y evité algunos de los problemas descritos en los comentarios (tener índices transferidos, significativamente. Si transfieres índices únicos a la tabla de historial, las cosas se romperán. Hay soluciones para esto en los comentarios). Seguir esta publicación al pie de la letra podría ser una aventura, dependiendo de qué tan establecida esté tu base de datos.

  • Si la relación entre la clave principal y la columna de revisión parece incorrecta, generalmente significa que la clave compuesta está bloqueada de alguna manera. En algunas raras ocasiones me sucedió esto y no sabía la causa.

  • Encontré que esta solución es bastante eficaz, utilizando desencadenadores como lo hace. Además, MyISAM es rápido en inserciones, que es todo lo que hacen los desencadenantes. Puede mejorar esto aún más con la indexación inteligente (o la falta de ...). Insertar una sola fila en una tabla MyISAM con una clave principal no debería ser una operación que deba optimizar, en realidad, a menos que tenga problemas importantes en otro lugar. Durante todo el tiempo que estuve ejecutando la base de datos MySQL en la que estuvo la implementación de la tabla de historial, nunca fue la causa de ninguno de los (muchos) problemas de rendimiento que surgieron.

  • si recibe inserciones repetidas, verifique su capa de software para las consultas de tipo INSERT IGNORE. Mmmm, no puedo recordar ahora, pero creo que hay problemas con este esquema y transacciones que finalmente fallan después de ejecutar múltiples acciones DML. Algo a tener en cuenta, al menos.

  • Es importante que los campos de la tabla de historial y la tabla de datos coincidan. O, más bien, que su tabla de datos no tiene MÁS columnas que la tabla de historial. De lo contrario, las consultas de inserción / actualización / eliminación en la tabla de datos fallarán cuando las inserciones en las tablas del historial coloquen columnas en la consulta que no existen (debido a d. * En las consultas de activación) y el activador falla. Sería increíble si MySQL tuviera algo como activadores de esquema, donde podría alterar la tabla de historial si se agregan columnas a la tabla de datos. ¿MySQL tiene eso ahora? Reacciono estos días: P

cierre transitorio
fuente
3
Realmente me gusta esta solución. sin embargo, si su tabla principal no tiene una clave principal o no sabe cuál es la principal, es un poco complicado.
Benjamin Eckstein
1
Recientemente me encontré con un problema al usar esta solución para un proyecto, debido a cómo todos los índices de la tabla original se copian en la tabla de historial (debido a cómo funciona CREATE TABLE ... LIKE ....). Tener índices únicos en la tabla de historial puede hacer que la consulta INSERT en el desencadenador AFTER UPDATE deje de funcionar, por lo que deben eliminarse. En el script php que tengo que hace estas cosas, busco índices únicos en tablas de historial recién creadas (con "SHOW INDEX FROM data_table WHERE Key_name! = 'PRIMARY' y Non_unique = 0"), y luego los elimino.
cierre transitorio
3
Aquí estamos insertando datos repetidos en la tabla de respaldo cada vez. Supongamos que si tenemos 10 campos en una tabla y hemos actualizado 2, entonces estamos agregando datos repetidos para los 8 campos restantes. ¿Cómo superarlo?
itzmukeshy7
6
Puede evitar trasladar accidentalmente los diversos índices cambiando la declaración de creación de tabla aCREATE TABLE MyDB.data_history as select * from MyDB.data limit 0;
Eric Hayes
4
@transientclosure ¿cómo propondría incluir otros campos en el historial que no formaban parte de la consulta original? por ejemplo, quiero hacer un seguimiento de quién hace esos cambios. para insertar, ya tiene un ownercampo, y para actualizar podría agregar un updatedbycampo, pero para eliminar, no estoy seguro de cómo podría hacerlo a través de los activadores. actualizar la data_historyfila con la identificación de usuario en se siente sucio: P
Caballo
16

Podría crear desencadenantes para resolver esto. Aquí hay un tutorial para hacerlo (enlace archivado).

Establecer restricciones y reglas en la base de datos es mejor que escribir código especial para manejar la misma tarea, ya que evitará que otro desarrollador escriba una consulta diferente que omita todo el código especial y podría dejar su base de datos con una integridad deficiente de los datos.

Durante mucho tiempo estuve copiando información en otra tabla usando un script ya que MySQL no admitía activadores en ese momento. Ahora he descubierto que este disparador es más eficaz para realizar un seguimiento de todo.

Este disparador copiará un valor antiguo a una tabla de historial si se cambia cuando alguien edita una fila. Editor IDy last modse almacenan en la tabla original cada vez que alguien edita esa fila; la hora corresponde a cuando se cambió a su forma actual.

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

Otra solución sería mantener un campo de Revisión y actualizar este campo al guardar. Puede decidir que el máximo es la revisión más reciente o que 0 es la fila más reciente. Eso depende de usted.

Keethanjan
fuente
9

Así es como lo solucionamos

una tabla de usuarios se veía así

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

Y el requisito comercial cambió y necesitábamos verificar todas las direcciones y números de teléfono anteriores que un usuario haya tenido. el nuevo esquema se ve así

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

Para encontrar la dirección actual de cualquier usuario, buscamos UserData con revisión DESC y LIMIT 1

Para obtener la dirección de un usuario entre un cierto período de tiempo, podemos usar created_on bewteen (fecha1, fecha 2)

Zenex
fuente
Es la solución que quiero tener pero quiero saber ¿Cómo se puede insertar id_user en esta tabla usando el disparador?
thecassion
1
¿Qué pasó con el revision=1de la id_user=1? Primero pensé que su conteo era, 0,2,3,...pero luego vi que para id_user=2el conteo de revisiones es0,1, ...
Pathros
1
No es necesario idy id_usercolumnas . Just use a group ID of id` (ID de usuario) y revision.
Gajus
6

MariaDB admite el control de versiones del sistema desde la versión 10.3, que es la función estándar de SQL que hace exactamente lo que usted desea: almacena el historial de los registros de la tabla y proporciona acceso a él mediante SELECTconsultas. MariaDB es una bifurcación de desarrollo abierto de MySQL. Puede encontrar más información sobre el control de versiones del sistema a través de este enlace:

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

midenok
fuente
Tenga en cuenta lo siguiente del enlace anterior: "mysqldump no lee las filas históricas de las tablas versionadas, por lo que no se hará una copia de seguridad de los datos históricos. Además, no sería posible restaurar las marcas de tiempo ya que no pueden definirse mediante una inserción / un usuario."
Daniel
4

¿Por qué no utilizar simplemente archivos de registro bin? Si la replicación está configurada en el servidor Mysql y el formato de archivo binlog está configurado en ROW, entonces se pueden capturar todos los cambios.

Se puede usar una buena biblioteca de Python llamada noplay. Más info aquí .

Ouroboros
fuente
2
Binlog se puede usar incluso si no tiene / necesita replicación. Binlog tiene muchos casos de uso beneficiosos. La replicación es probablemente el caso de uso más común, pero también se puede aprovechar para las copias de seguridad y el historial de auditoría, como se menciona aquí.
webaholik
3

Solo mis 2 centavos. Crearía una solución que registre exactamente lo que cambió, muy similar a la solución de transient.

Mi CambiosTable sería simple:

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

1) Cuando se cambia una fila completa en la tabla principal, muchas entradas entrarán en esta tabla, PERO eso es muy poco probable, por lo que no es un gran problema (las personas generalmente solo cambian una cosa) 2) OldVaue (y NewValue si want) tiene que ser una especie de "anytype" épico, ya que podría ser cualquier dato, podría haber una manera de hacer esto con tipos RAW o simplemente usando cadenas JSON para convertir dentro y fuera.

Uso mínimo de datos, almacena todo lo que necesita y se puede utilizar para todas las tablas a la vez. Estoy investigando esto yo mismo en este momento, pero podría terminar siendo mi camino.

Para Crear y Eliminar, solo el ID de fila, no se necesitan campos. Sería bueno eliminar una bandera en la tabla principal (¿activa?).

Digno7
fuente
0

La forma directa de hacer esto es crear disparadores en tablas. Establezca algunas condiciones o métodos de mapeo. Cuando se realiza una actualización o eliminación, se insertará automáticamente en la tabla de "cambios".

Pero la mayor parte es qué pasaría si tuviéramos muchas columnas y muchas tablas. Tenemos que escribir el nombre de cada columna de cada tabla. Obviamente, es una pérdida de tiempo.

Para manejar esto de manera más hermosa, podemos crear algunos procedimientos o funciones para recuperar el nombre de las columnas.

También podemos usar la herramienta de tercera parte simplemente para hacer esto. Aquí, escribo un programa java Mysql Tracker

goforu
fuente
¿Cómo puedo usar su Mysql Tracker?
webchun
1
1. Asegúrese de tener una columna de identificación como clave principal en cada tabla. 2. Copie el archivo java a local (o IDE). 3. Importe bibliotecas y edite las variables estáticas de la línea 9-15 de acuerdo con la configuración y estructura de su base de datos. 4. Analizar y ejecutar el archivo java 5. Copie el registro de la consola y ejecútelo como comandos Mysql
goforu
create table like tableCreo que replica todas las columnas fácilmente
Jonathan