Existe uma opção / recurso do MySQL para rastrear o histórico de alterações nos registros?

122

Perguntaram-me se posso controlar as alterações nos registros de um banco de dados MySQL. Assim, quando um campo é alterado, o antigo x novo está disponível e a data em que isso ocorreu. Existe um recurso ou técnica comum para fazer isso?

Nesse caso, estava pensando em fazer algo assim. Crie uma tabela chamada changes. Ele conteria os mesmos campos da tabela mestre, mas prefixados com antigo e novo, mas apenas para os campos que foram realmente alterados e um TIMESTAMPpara eles. Ele seria indexado com um ID. Dessa forma, um SELECTrelatório poderia ser executado para mostrar o histórico de cada registro. Este é um bom método? Obrigado!

Edward
fonte

Respostas:

83

É sutil.

Se o requisito de negócios for "Quero auditar as alterações nos dados - quem fez o quê e quando?", Você geralmente pode usar tabelas de auditoria (conforme o exemplo de gatilho postado por Keethanjan). Não sou um grande fã de gatilhos, mas tem o grande benefício de ser relativamente fácil de implementar - seu código existente não precisa saber sobre os gatilhos e outras coisas de auditoria.

Se o requisito de negócios for "mostre-me qual era o estado dos dados em uma determinada data no passado", isso significa que o aspecto da mudança ao longo do tempo entrou em sua solução. Embora você possa reconstruir o estado do banco de dados apenas olhando as tabelas de auditoria, é difícil e sujeito a erros e, para qualquer lógica de banco de dados complicada, torna-se difícil de controlar. Por exemplo, se a empresa deseja saber "encontrar os endereços das cartas que deveríamos ter enviado aos clientes que tinham faturas pendentes e não pagas no primeiro dia do mês", você provavelmente terá que vasculhar meia dúzia de tabelas de auditoria.

Em vez disso, você pode incorporar o conceito de mudança ao longo do tempo no design do seu esquema (esta é a segunda opção que Keethanjan sugere). Esta é uma mudança em seu aplicativo, definitivamente no nível de lógica de negócios e persistência, portanto, não é trivial.

Por exemplo, se você tiver uma mesa como esta:

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

e você quisesse acompanhar ao longo do tempo, você o alteraria da seguinte maneira:

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

Cada vez que você deseja alterar um registro de cliente, em vez de atualizar o registro, você define VALID_UNTIL no registro atual para NOW () e insere um novo registro com um VALID_FROM (agora) e um VALID_UNTIL nulo. Você define o status "CUSTOMER_USER" para a ID de login do usuário atual (se precisar mantê-la). Se o cliente precisar ser excluído, use o sinalizador CUSTOMER_STATUS para indicar isso - você nunca pode excluir registros desta tabela.

Dessa forma, você sempre poderá saber qual era o status da mesa do cliente para uma determinada data - qual era o endereço? Eles mudaram de nome? Ao juntar a outras tabelas com datas valid_from e valid_until semelhantes, você pode reconstruir a imagem inteira historicamente. Para encontrar o status atual, você procura por registros com uma data VALID_UNTIL nula.

É complicado (estritamente falando, você não precisa do valid_from, mas torna as consultas um pouco mais fáceis). Isso complica seu design e seu acesso ao banco de dados. Mas torna a reconstrução do mundo muito mais fácil.

Neville Kuyt
fonte
Mas isso adicionaria dados duplicados para os campos que não são atualizados? Como administrar isso?
itzmukeshy7
Com a segunda abordagem, surgem problemas para a geração de relatórios, se um registro de cliente for editado ao longo de um tempo, é difícil reconhecer se uma entrada específica pertence ao mesmo cliente ou é diferente.
Akshay Joshi
A melhor sugestão que já vi para este problema
Worthy7
Ah e, em resposta aos comentários, que tal simplesmente armazenar null para tudo o mais que não mudou? Portanto, a versão mais recente será todos os dados mais recentes, mas se o nome costumava ser "Bob" 5 dias atrás, então terá apenas uma linha, nome = bob e válido até 5 dias atrás.
Worthy7 de
2
A combinação de customer_id e as datas são a chave primária, portanto, serão exclusivas garantidas.
Neville Kuyt,
186

Esta é uma maneira simples de fazer isso:

Primeiro, crie uma tabela de histórico para cada tabela de dados que deseja rastrear (consulta de exemplo abaixo). Esta tabela terá uma entrada para cada consulta de inserção, atualização e exclusão realizada em cada linha da tabela de dados.

A estrutura da tabela de histórico será a mesma da tabela de dados que rastreia, exceto por três colunas adicionais: uma coluna para armazenar a operação que ocorreu (vamos chamá-la de 'ação'), a data e hora da operação e uma coluna para armazenar um número de sequência ('revisão'), que aumenta por operação e é agrupado pela coluna de chave primária da tabela de dados.

Para fazer esse comportamento de sequenciamento, um índice de duas colunas (composto) é criado na coluna da chave primária e na coluna de revisão. Note que você só pode fazer o sequenciamento desta maneira se o motor usado pela tabela de histórico for MyISAM ( Veja 'Notas MyISAM' nesta página)

A tabela de histórico é bastante fácil de criar. Na consulta ALTER TABLE abaixo (e nas consultas de gatilho abaixo dela), substitua 'primary_key_column' pelo nome real dessa coluna em sua tabela de dados.

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 então você cria os gatilhos:

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 pronto. Agora, todas as inserções, atualizações e exclusões em 'MyDb.data' serão registradas em 'MyDb.data_history', dando a você uma tabela de histórico como esta (menos a coluna 'data_columns' inventada)

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 exibir as alterações para uma determinada coluna ou colunas de atualização para atualização, você precisará juntar a tabela de histórico a si mesma na chave primária e colunas de sequência. Você pode criar uma vista para este propósito, por exemplo:

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, as pessoas gostam da minha história na tabela de 6 anos atrás: P

Minha implementação ainda está zumbindo, ficando maior e mais difícil de manejar, eu diria. Eu escrevi visualizações e uma interface de usuário muito boa para olhar o histórico neste banco de dados, mas não acho que tenha sido muito usado. Assim vai.

Para abordar alguns comentários sem ordem específica:

  • Fiz minha própria implementação em PHP que era um pouco mais envolvente e evitei alguns dos problemas descritos nos comentários (transferência de índices, significativamente. Se você transferir índices exclusivos para a tabela de histórico, as coisas vão quebrar. Existem soluções para isso nos comentários). Seguir este post ao pé da letra pode ser uma aventura, dependendo de como está estabelecido seu banco de dados.

  • Se o relacionamento entre a chave primária e a coluna de revisão parecer incorreto, geralmente significa que a chave composta está danificada de alguma forma. Em algumas raras ocasiões, isso aconteceu e não sabia a causa.

  • Achei que essa solução tinha um ótimo desempenho, usando gatilhos como faz. Além disso, MyISAM é rápido em inserções, que é tudo o que os triggers fazem. Você pode melhorar ainda mais com a indexação inteligente (ou a falta de ...). Inserir uma única linha em uma tabela MyISAM com uma chave primária não deve ser uma operação que você precise otimizar, realmente, a menos que você tenha problemas significativos acontecendo em outro lugar. Durante todo o tempo em que estive executando o banco de dados MySQL, essa implementação da tabela de histórico nunca foi a causa de nenhum dos (muitos) problemas de desempenho que surgiram.

  • se você estiver obtendo inserções repetidas, verifique se há consultas do tipo INSERT IGNORE em sua camada de software. Hrmm, não me lembro agora, mas acho que há problemas com esse esquema e transações que falham depois de executar várias ações DML. Algo a ter em conta, pelo menos.

  • É importante que os campos da tabela de histórico e da tabela de dados correspondam. Ou melhor, que sua tabela de dados não tenha MAIS colunas do que a tabela de histórico. Caso contrário, as consultas de inserção / atualização / del na tabela de dados irão falhar, quando as inserções nas tabelas de histórico colocarem colunas na consulta que não existem (devido a d. * Nas consultas do gatilho), e o gatilho falhar. Seria incrível se o MySQL tivesse algo como gatilhos de esquema, onde você pudesse alterar a tabela de histórico se colunas fossem adicionadas à tabela de dados. O MySQL tem isso agora? Eu reajo hoje em dia: P

fechamento transitório
fonte
3
eu realmente gosto dessa solução. entretanto, se sua tabela principal não tiver uma chave primária ou você não souber o que é a primária, é um pouco complicado.
Benjamin Eckstein
1
Recentemente, tive um problema ao usar essa solução para um projeto, por causa de como todos os índices da tabela original são copiados para a tabela de histórico (devido a como CREATE TABLE ... LIKE .... funciona). Ter índices exclusivos na tabela de histórico pode fazer com que a consulta INSERT no gatilho AFTER UPDATE seja barf, portanto, eles precisam ser removidos. No script php que tenho que fazer isso, eu procuro por índices exclusivos em tabelas de histórico recém-criadas (com "MOSTRAR INDEX DE data_table WHERE Key_name! = 'PRIMARY' e Non_unique = 0") e, em seguida, removê-los.
fechamento temporário
3
Aqui estamos obtendo dados repetidos inseridos na tabela de backup todas as vezes. Vamos supor que temos 10 campos em uma tabela e atualizamos 2, então estamos adicionando dados repetidos para os 8 campos restantes. Como superar isso?
itzmukeshy7
6
Você pode evitar a transferência acidental de vários índices alterando a instrução de criação de tabela paraCREATE TABLE MyDB.data_history as select * from MyDB.data limit 0;
Eric Hayes
4
@transientclosure como você proporia a inclusão de outros campos no histórico que não faziam parte da consulta original? por exemplo, quero rastrear quem faz essas alterações. para inserir já tem um ownercampo, e para atualização eu poderia adicionar um updatedbycampo, mas para excluir não tenho certeza de como faria isso via triggers. atualizar a data_historylinha com o ID do usuário parece sujo: P
Cavalo
16

Você pode criar gatilhos para resolver isso. Aqui está um tutorial para fazer isso (link arquivado).

Definir restrições e regras no banco de dados é melhor do que escrever código especial para lidar com a mesma tarefa, pois isso impedirá outro desenvolvedor de escrever uma consulta diferente que ignore todo o código especial e poderia deixar seu banco de dados com integridade de dados insatisfatória.

Por muito tempo, copiei informações para outra tabela usando um script, pois o MySQL não suportava triggers na época. Agora descobri que este gatilho é mais eficaz para controlar tudo.

Este gatilho irá copiar um valor antigo para uma tabela de histórico se for alterado quando alguém edita uma linha. Editor IDe last modsão armazenados na tabela original sempre que alguém edita essa linha; a hora corresponde a quando foi alterado para sua forma atual.

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

Outra solução seria manter um campo Revisão e atualizar esse campo ao salvar. Você pode decidir que max é a revisão mais recente ou que 0 é a linha mais recente. Isso é contigo.

Keethanjan
fonte
9

Aqui está como resolvemos isso

uma tabela de usuários era parecida com esta

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

E os requisitos de negócios mudaram e precisávamos verificar todos os endereços e números de telefone anteriores que um usuário já teve. novo esquema se parece com este

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 o endereço atual de qualquer usuário, procuramos UserData com revisão DESC e LIMIT 1

Para obter o endereço de um usuário entre um determinado período de tempo, podemos usar created_on bewteen (data1, data 2)

Zenex
fonte
É a solução que desejo ter, mas quero saber como inserir id_user nesta tabela usando trigger?
paixão de
1
O que aconteceu com o revision=1do id_user=1? Primeiro pensei que a sua contagem era, 0,2,3,...mas depois vi que para id_user=2a revisão a contagem é0,1, ...
Pathros
1
Você não precisa ide id_usercolunas . Just use a group ID of id` (ID de usuário) e revision.
Gajus
6

MariaDB suporta o controle de versão do sistema desde 10.3, que é o recurso padrão do SQL que faz exatamente o que você deseja: armazena o histórico dos registros da tabela e fornece acesso a ele por meio de SELECTconsultas. MariaDB é um fork de desenvolvimento aberto do MySQL. Você pode encontrar mais informações sobre o controle de versão do sistema por meio deste link:

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

Midenok
fonte
Observe o seguinte no link acima: "mysqldump não lê linhas históricas de tabelas versionadas e, portanto, os dados históricos não serão copiados. Além disso, uma restauração dos carimbos de data / hora não seria possível, pois eles não podem ser definidos por um insert / um usuário."
Daniel
4

Por que não simplesmente usar arquivos de log bin? Se a replicação for definida no servidor Mysql e o formato do arquivo binlog for definido como ROW, todas as alterações podem ser capturadas.

Uma boa biblioteca python chamada noplay pode ser usada. Mais informações aqui .

Ouroboros
fonte
2
Binlog pode ser usado mesmo se você não tiver / precisar de replicação. Binlog tem muitos casos de uso benéficos. A replicação é provavelmente o caso de uso mais comum, mas também pode ser aproveitada para backups e histórico de auditoria, conforme mencionado aqui.
webaholik
3

Apenas meus 2 centavos. Eu criaria uma solução que registrasse exatamente o que mudou, muito semelhante à solução do transiente.

Minha tabela de alterações seria simples:

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

1) Quando uma linha inteira é alterada na tabela principal, muitas entradas irão para esta tabela, MAS isso é muito improvável, então não é um grande problema (as pessoas geralmente estão alterando apenas uma coisa) 2) OldVaue (e NewValue se você deseja) tem que ser algum tipo de "qualquer tipo" épico, pois pode ser qualquer dado, pode haver uma maneira de fazer isso com tipos RAW ou apenas usando strings JSON para converter dentro e fora.

Uso mínimo de dados, armazena tudo que você precisa e pode ser usado para todas as tabelas de uma vez. Estou pesquisando isso agora, mas pode acabar sendo meu caminho.

Para criar e excluir, apenas o ID da linha, nenhum campo necessário. Ao excluir um sinalizador na mesa principal (ativo?) Seria bom.

Digno 7
fonte
0

A maneira direta de fazer isso é criar gatilhos nas tabelas. Defina algumas condições ou métodos de mapeamento. Quando a atualização ou exclusão ocorrer, ele será inserido na tabela de 'alteração' automaticamente.

Mas a maior parte é o que aconteceria se tivéssemos muitas colunas e muitas tabelas. Temos que digitar o nome de cada coluna de cada tabela. Obviamente, é perda de tempo.

Para lidar com isso de forma mais bonita, podemos criar alguns procedimentos ou funções para recuperar nomes de colunas.

Também podemos usar a ferramenta de 3ª parte simplesmente para fazer isso. Aqui, eu escrevo um programa java Mysql Tracker

goforu
fonte
como posso usar o seu Mysql Tracker?
webchun
1
1. Certifique-se de ter uma coluna id como chave primária em cada tabela. 2. Copie o arquivo java para o local (ou IDE) 3. Importe libs e edite as variáveis ​​estáticas da linha 9-15 de acordo com a configuração e estrutura do banco de dados. 4. Analise e execute o arquivo java 5. Copie o log do console e execute-o como comandos Mysql
goforu
create table like tableAcho que replica todas as colunas facilmente
Jonathan,