Design de banco de dados para revisões?

125

Temos um requisito no projeto para armazenar todas as revisões (histórico de alterações) das entidades no banco de dados. Atualmente, temos 2 propostas projetadas para isso:

por exemplo, para entidade "Empregado"

Projeto 1:

-- Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

-- Holds the Employee Revisions in Xml. The RevisionXML will contain
-- all data of that particular EmployeeId
"EmployeeHistories (EmployeeId, DateModified, RevisionXML)"

Projeto 2:

-- Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

-- In this approach we have basically duplicated all the fields on Employees 
-- in the EmployeeHistories and storing the revision data.
"EmployeeHistories (EmployeeId, RevisionId, DateModified, FirstName, 
      LastName, DepartmentId, .., ..)"

Existe alguma outra maneira de fazer isso?

O problema com o "Design 1" é que precisamos analisar o XML sempre que precisar acessar dados. Isso retardará o processo e também adicionará algumas limitações, pois não podemos adicionar junções nos campos de dados das revisões.

E o problema com o "Design 2" é que precisamos duplicar todos os campos de todas as entidades (temos entre 70 e 80 entidades para as quais queremos manter as revisões).

Ramesh Soni
fonte
1
FYI: Apenas no caso que pode ajudar servidor sql 2008 e acima tem tecnologia que mostra o histórico das alterações em table..visit simple-talk.com/sql/learn-sql-server/... saber mais e estou certo de DB de como a Oracle também terá algo parecido com isto.
Durai Amuthan.H
Lembre-se de que algumas colunas podem armazenar XML ou JSON. Se não for o caso agora, poderá acontecer no futuro. É melhor garantir que você não precise aninhar esses dados um no outro.
precisa saber é o seguinte

Respostas:

38
  1. Você não colocar tudo em uma mesa com um atributo discriminador IsCurrent. Isso apenas causa problemas na linha, requer chaves substitutas e todos os tipos de outros problemas.
  2. O Design 2 tem problemas com alterações de esquema. Se você alterar a tabela Funcionários, precisará alterar a tabela EmployeeHistories e todos os sprocs relacionados que a acompanham. Dobra potencialmente o esforço de mudança de esquema.
  3. O design 1 funciona bem e, se feito corretamente, não custa muito em termos de desempenho. Você pode usar um esquema xml e até índices para solucionar possíveis problemas de desempenho. Seu comentário sobre a análise do xml é válido, mas você pode facilmente criar uma exibição usando o xquery - que você pode incluir nas consultas e participar. Algo assim...
CREATE VIEW EmployeeHistory
AS
, FirstName, , DepartmentId

SELECT EmployeeId, RevisionXML.value('(/employee/FirstName)[1]', 'varchar(50)') AS FirstName,

  RevisionXML.value('(/employee/LastName)[1]', 'varchar(100)') AS LastName,

  RevisionXML.value('(/employee/DepartmentId)[1]', 'integer') AS DepartmentId,

FROM EmployeeHistories 
Simon Munro
fonte
25
Por que você diz para não armazenar tudo em uma tabela com o gatilho IsCurrent. Você poderia me indicar alguns exemplos em que isso seria problemático?
Nathan W
@ Simon Munro Que tal uma chave primária ou chave em cluster? Qual chave podemos adicionar na tabela de histórico do Design 1 para tornar a pesquisa mais rápida?
gotqn
Eu assumo um SELECT * FROM EmployeeHistory WHERE LastName = 'Doe'resultado simples em uma verificação de tabela completa . Não é a melhor idéia para dimensionar um aplicativo.
21418 Kaii
54

Acho que a principal pergunta a fazer aqui é 'Quem / O que usará a história'?

Se for principalmente para relatórios / histórico legível por humanos, implementamos esse esquema no passado ...

Crie uma tabela chamada 'AuditTrail' ou algo que possua os seguintes campos ...

[ID] [int] IDENTITY(1,1) NOT NULL,
[UserID] [int] NULL,
[EventDate] [datetime] NOT NULL,
[TableName] [varchar](50) NOT NULL,
[RecordID] [varchar](20) NOT NULL,
[FieldName] [varchar](50) NULL,
[OldValue] [varchar](5000) NULL,
[NewValue] [varchar](5000) NULL

Você pode adicionar uma coluna 'LastUpdatedByUserID' a todas as suas tabelas, que devem ser definidas sempre que você fizer uma atualização / inserção na tabela.

Em seguida, você pode adicionar um gatilho a todas as tabelas para capturar qualquer inserção / atualização que ocorra e criar uma entrada nessa tabela para cada campo alterado. Como a tabela também está sendo fornecida com o 'LastUpdateByUserID' para cada atualização / inserção, você pode acessar esse valor no gatilho e usá-lo ao adicionar à tabela de auditoria.

Usamos o campo RecordID para armazenar o valor do campo-chave da tabela que está sendo atualizada. Se for uma chave combinada, fazemos apenas uma concatenação de string com um '~' entre os campos.

Tenho certeza de que esse sistema pode ter desvantagens - para bancos de dados altamente atualizados, o desempenho pode ser atingido, mas para meu aplicativo da web, temos muito mais leituras do que gravações e parece estar funcionando muito bem. Até escrevemos um pequeno utilitário VB.NET para gravar automaticamente os gatilhos com base nas definições da tabela.

Apenas um pensamento!

Chris Roberts
fonte
5
Não há necessidade de armazenar o NewValue, pois ele é armazenado na tabela auditada.
Petrus Theron
17
Estritamente falando, isso é verdade. Mas - quando há várias alterações no mesmo campo ao longo de um período de tempo, o armazenamento do novo valor facilita consultas como 'me mostre todas as alterações feitas por Brian' muito mais facilmente, pois todas as informações sobre uma atualização são mantidas em um registro. Apenas um pensamento!
22610 Chris Roberts
1
Eu acho que sysnamepode ser um tipo de dados mais adequado para os nomes de tabela e coluna.
Sam
2
@ Sam usando sysname não adiciona nenhum valor; pode até ser confuso ... stackoverflow.com/questions/5720212/...
Jowen
19

O artigo Tabelas do histórico no blog do programador de banco de dados pode ser útil - aborda alguns dos pontos levantados aqui e discute o armazenamento de deltas.

Editar

No ensaio Tabelas de História , o autor ( Kenneth Downs ) recomenda manter uma tabela de história de pelo menos sete colunas:

  1. Registro de data e hora da alteração,
  2. Usuário que fez a alteração,
  3. Um token para identificar o registro que foi alterado (onde o histórico é mantido separadamente do estado atual),
  4. Se a alteração foi uma inserção, atualização ou exclusão,
  5. O valor antigo,
  6. O novo valor,
  7. O delta (para alterações nos valores numéricos).

As colunas que nunca mudam, ou cujo histórico não é necessário, não devem ser rastreadas na tabela de histórico para evitar inchaço. Armazenar o delta para valores numéricos pode facilitar as consultas subseqüentes, mesmo que possam ser derivadas dos valores antigos e novos.

A tabela de histórico deve ser segura, com usuários que não são do sistema impedidos de inserir, atualizar ou excluir linhas. Somente a limpeza periódica deve ser suportada para reduzir o tamanho geral (e se permitido pelo caso de uso).

Mark Streatfield
fonte
14

Implementamos uma solução muito parecida com a sugerida por Chris Roberts, e que funciona muito bem para nós.

A única diferença é que apenas armazenamos o novo valor. Afinal, o valor antigo é armazenado na linha do histórico anterior

[ID] [int] IDENTITY(1,1) NOT NULL,
[UserID] [int] NULL,
[EventDate] [datetime] NOT NULL,
[TableName] [varchar](50) NOT NULL,
[RecordID] [varchar](20) NOT NULL,
[FieldName] [varchar](50) NULL,
[NewValue] [varchar](5000) NULL

Digamos que você tenha uma tabela com 20 colunas. Dessa forma, você só precisa armazenar a coluna exata que foi alterada, em vez de ter que armazenar a linha inteira.

Kjetil Watnedal
fonte
14

Evite o design 1; não é muito útil, pois você precisará, por exemplo, reverter para versões antigas dos registros - automática ou "manualmente" usando o console de administradores.

Realmente não vejo desvantagens do Design 2. Acho que a segunda tabela Histórico deve conter todas as colunas presentes na primeira tabela Registros. Por exemplo, no mysql, você pode criar facilmente tabela com a mesma estrutura que outra tabela ( create table X like Y). E, quando você estiver prestes a alterar a estrutura da tabela Registros em seu banco de dados alter tableativo , precisará usar os comandos de qualquer maneira - e não há grande esforço em executar esses comandos também na tabela Histórico.

Notas

  • A tabela de registros contém apenas a última revisão;
  • A tabela Histórico contém todas as revisões anteriores dos registros na tabela Registros;
  • A chave primária da tabela de histórico é uma chave primária da tabela Registros com RevisionIdcoluna adicionada ;
  • Pense em campos auxiliares adicionais como ModifiedBy- o usuário que criou uma revisão específica. Você também pode querer ter um campo DeletedBypara rastrear quem excluiu uma revisão específica.
  • Pense no que DateModifieddeveria significar - ou significa onde essa revisão específica foi criada ou quando esta revisão específica foi substituída por outra. O primeiro requer que o campo esteja na tabela Registros e parece ser mais intuitivo à primeira vista; a segunda solução, no entanto, parece ser mais prática para registros excluídos (data em que essa revisão específica foi excluída). Se você optar pela primeira solução, provavelmente precisará de um segundo campo DateDeleted(somente se precisar, é claro). Depende de você e do que você realmente deseja gravar.

As operações no Design 2 são muito triviais:

Modificar
  • copie o registro da tabela Registros para a tabela Histórico, forneça o novo RevisionId (se ainda não estiver presente na tabela Registros), manipule DateModified (depende de como você o interpreta, consulte as notas acima)
  • continue com a atualização normal do registro na tabela Registros
Excluir
  • faça exatamente o mesmo que na primeira etapa da operação Modify. Manipule DateModified / DateDeleted de acordo, dependendo da interpretação que você escolheu.
Desfazer exclusão (ou reversão)
  • faça a revisão mais alta (ou alguma particular?) da tabela Histórico e copie-a para a tabela Registros
Listar o histórico de revisões para um registro específico
  • selecione da tabela Histórico e da tabela Registros
  • pense exatamente o que você espera desta operação; provavelmente determinará quais informações você precisa dos campos DateModified / DateDeleted (consulte as notas acima)

Se você optar pelo Design 2, todos os comandos SQL necessários para isso serão muito, muito fáceis, além de manutenção! Talvez seja muito mais fácil se você usar as colunas auxiliares ( RevisionId, DateModified) também na tabela Registros - para manter as duas tabelas exatamente na mesma estrutura (exceto chaves exclusivas)! Isso permitirá comandos SQL simples, que serão tolerantes a qualquer alteração na estrutura de dados:

insert into EmployeeHistory select * from Employe where ID = XX

Não se esqueça de usar transações!

Quanto ao dimensionamento , esta solução é muito eficiente, já que você não transforma nenhum dado de XML, apenas copia linhas inteiras da tabela - consultas muito simples, usando índices - muito eficientes!

TMS
fonte
12

Se você precisar armazenar o histórico, crie uma tabela de sombra com o mesmo esquema da tabela que você está rastreando e as colunas 'Data da revisão' e 'Tipo de revisão' (por exemplo, 'excluir', 'atualizar'). Escreva (ou gere - veja abaixo) um conjunto de gatilhos para preencher a tabela de auditoria.

É bastante simples criar uma ferramenta que leia o dicionário de dados do sistema para uma tabela e gere um script que crie a tabela de sombra e um conjunto de gatilhos para preenchê-la.

Não tente usar XML para isso, o armazenamento XML é muito menos eficiente que o armazenamento da tabela de banco de dados nativo que esse tipo de gatilho usa.

ConcernedOfTunbridgeWells
fonte
3
+1 por simplicidade! Alguns se projetarão em excesso por medo de mudanças posteriores, enquanto na maioria das vezes nenhuma mudança realmente ocorre! Além disso, é muito mais fácil gerenciar os históricos em uma tabela e os registros reais em outra do que tê-los em uma tabela (pesadelo) com algum sinalizador ou status. Chama-se 'KISS' e normalmente irá recompensá-lo a longo prazo.
Jeach 04/12
O +1 concorda completamente, exatamente o que digo na minha resposta ! Simples e poderoso!
TMS
8

Ramesh, eu estava envolvido no desenvolvimento de sistema baseado na primeira abordagem.
Aconteceu que o armazenamento de revisões como XML está levando a um enorme crescimento do banco de dados e a desacelerar significativamente as coisas.
Minha abordagem seria ter uma tabela por entidade:

Employee (Id, Name, ... , IsActive)  

onde IsActive é um sinal da versão mais recente

Se você deseja associar algumas informações adicionais a revisões, é possível criar uma tabela separada contendo essas informações e vinculá-las às tabelas de entidades usando a relação PK \ FK.

Dessa forma, você pode armazenar todas as versões dos funcionários em uma tabela. Prós desta abordagem:

  • Estrutura simples da base de dados
  • Nenhum conflito, pois a tabela se torna somente anexada
  • Você pode reverter para a versão anterior simplesmente alterando o sinalizador IsActive
  • Não há necessidade de junções para obter o histórico do objeto

Observe que você deve permitir que a chave primária não seja exclusiva.

aku
fonte
6
Eu usaria uma coluna "RevisionNumber" ou "RevisionDate" em vez de ou além do IsActive, para que você possa ver todas as revisões em ordem.
Sklivvz
Eu usaria um "parentRowId" porque isso lhe dá acesso fácil às versões anteriores, bem como a capacidade de encontrar a base e o final rapidamente.
chacham15
6

A maneira que eu vi isso feito no passado é

Employees (EmployeeId, DateModified, < Employee Fields > , boolean isCurrent );

Você nunca "atualiza" nesta tabela (exceto para alterar a validade de isCurrent), basta inserir novas linhas. Para qualquer EmployeeId, apenas 1 linha pode ter isCurrent == 1.

A complexidade de manter isso pode ser oculta por visualizações e "em vez de" gatilhos (no oracle, presumo coisas semelhantes a outros RDBMS), você pode até acessar visualizações materializadas se as tabelas forem muito grandes e não puderem ser manipuladas por índices) .

Esse método está ok, mas você pode acabar com algumas consultas complexas.

Pessoalmente, gosto bastante da sua maneira de fazê-lo do Design 2, que também foi como fiz no passado. É simples de entender, simples de implementar e simples de manter.

Ele também cria muito pouca sobrecarga para o banco de dados e o aplicativo, especialmente ao executar consultas de leitura, o que provavelmente é o que você fará 99% do tempo.

Também seria muito fácil automatizar a criação das tabelas de histórico e gatilhos para manter (supondo que isso fosse feito via gatilhos).

Matthew Watson
fonte
4

Revisões de dados são um aspecto do conceito de ' tempo válido ' de um banco de dados temporal. Muita pesquisa foi feita sobre isso, e muitos padrões e diretrizes surgiram. Escrevi uma longa resposta com várias referências a essa pergunta para os interessados.

Henrik Gustafsson
fonte
4

Vou compartilhar com você meu design e é diferente dos dois designs, pois exige uma tabela por cada tipo de entidade. Encontrei a melhor maneira de descrever qualquer design de banco de dados através do ERD, aqui está o meu:

insira a descrição da imagem aqui

Neste exemplo, temos uma entidade chamada employee . A tabela user guarda os registros de seus usuários, e entity e entity_revision são duas tabelas que mantêm o histórico de revisões de todos os tipos de entidade que você terá em seu sistema. Veja como esse design funciona:

Os dois campos de entity_id e revision_id

Cada entidade em seu sistema terá um ID de entidade exclusivo. Sua entidade pode passar por revisões, mas seu entity_id permanecerá o mesmo. Você precisa manter esse ID de entidade na tabela de funcionários (como uma chave estrangeira). Você também deve armazenar o tipo de sua entidade na tabela de entidades (por exemplo, 'funcionário'). Agora, quanto ao revision_id, como o nome mostra, ele acompanha as revisões da sua entidade. A melhor maneira que encontrei para isso é usar o employee_id como sua revision_id. Isso significa que você terá IDs de revisão duplicados para diferentes tipos de entidades, mas isso não é um prazer para mim (não tenho certeza sobre o seu caso). A única observação importante a ser feita é que a combinação de entity_id e revision_id deve ser única.

Há também um campo de estado na tabela entity_revision que indica o estado da revisão. Ele pode ter um dos três estados: latest, obsoleteou deleted(não contando com a data de revisões ajuda muito a aumentar suas consultas).

Uma última observação em revision_id, não criei uma chave estrangeira conectando employee_id a revision_id porque não queremos alterar a tabela entity_revision para cada tipo de entidade que poderemos adicionar no futuro.

INSERÇÃO

Para cada funcionário que você deseja inserir no banco de dados, você também vai adicionar um registro de entidade e entity_revision . Esses dois últimos registros ajudarão você a controlar quem e quando um registro foi inserido no banco de dados.

ATUALIZAR

Cada atualização para um registro de funcionário existente será implementada como duas inserções, uma na tabela de funcionários e outra na entidade_revisão. O segundo ajudará você a saber por quem e quando o registro foi atualizado.

ELIMINAÇÃO

Para excluir um funcionário, um registro é inserido em entity_revision informando a exclusão e concluída.

Como você pode ver nesse design, nenhum dado é alterado ou removido do banco de dados e, mais importante, cada tipo de entidade requer apenas uma tabela. Pessoalmente, acho esse design realmente flexível e fácil de trabalhar. Mas não tenho certeza sobre você, pois suas necessidades podem ser diferentes.

[ATUALIZAR]

Tendo suportado partições nas novas versões do MySQL, acredito que meu design também vem com uma das melhores performances. Pode-se particionar entitytabela usando typecampo enquanto particionar entity_revisionusando seu statecampo. Isso aumentará SELECTde longe as consultas, mantendo o design simples e limpo.

Mehran
fonte
3

Se de fato uma trilha de auditoria é tudo o que você precisa, eu me inclinaria para a solução da tabela de auditoria (com cópias desnormalizadas da coluna importante em outras tabelas, por exemplo UserName). Lembre-se, porém, que a experiência amarga indica que uma única tabela de auditoria será um enorme gargalo no caminho; provavelmente vale a pena o esforço para criar tabelas de auditoria individuais para todas as suas tabelas auditadas.

Se você precisar rastrear as versões históricas (e / ou futuras) reais, a solução padrão é rastrear a mesma entidade com várias linhas usando alguma combinação de valores de início, fim e duração. Você pode usar uma visualização para tornar conveniente o acesso aos valores atuais. Se essa é a abordagem adotada, você pode encontrar problemas se os dados com versão referenciarem dados mutáveis, mas não versionados.

Hank Gay
fonte
3

Se você quiser fazer o primeiro, também poderá usar XML para a tabela Funcionários. A maioria dos bancos de dados mais novos permite consultar campos XML, portanto, isso nem sempre é um problema. E pode ser mais simples ter uma maneira de acessar os dados dos funcionários, independentemente da versão mais recente ou anterior.

Eu tentaria a segunda abordagem embora. Você pode simplificar isso tendo apenas uma tabela Funcionários com um campo DateModified. O EmployeeId + DateModified seria a chave primária e você pode armazenar uma nova revisão apenas adicionando uma linha. Dessa forma, o arquivamento de versões mais antigas e a restauração de versões do archive também são mais fáceis.

Outra maneira de fazer isso poderia ser o modelo de datavault de Dan Linstedt. Eu fiz um projeto para o departamento de estatística holandês que usou esse modelo e funciona muito bem. Mas não acho que seja diretamente útil para o uso diário do banco de dados. Você pode ter algumas idéias ao ler os artigos dele.

Mendelt
fonte
2

E se:

  • ID do Empregado
  • Data modificada
    • e / ou número de revisão, dependendo de como você deseja rastreá-lo
  • ModifiedByUSerId
    • além de qualquer outra informação que você deseja rastrear
  • Campos de funcionários

Você cria a chave primária (EmployeeId, DateModified) e, para obter os registros "atuais", basta selecionar MAX (DateModified) para cada ID de funcionário. Armazenar um IsCurrent é uma péssima idéia, porque, em primeiro lugar, pode ser calculado e, em segundo lugar, é muito fácil para os dados ficarem fora de sincronia.

Você também pode fazer uma exibição que lista apenas os registros mais recentes e usá-la principalmente enquanto trabalha no aplicativo. O bom dessa abordagem é que você não possui duplicatas de dados e não precisa coletar dados de dois lugares diferentes (atual em Employees e arquivados em EmployeesHistory) para obter todo o histórico ou reversão, etc.) .

gregmac
fonte
Uma desvantagem dessa abordagem é que a tabela crescerá mais rapidamente do que se você usar duas tabelas.
cdmckay
2

Se você deseja confiar nos dados do histórico (por motivos de relatório), deve usar a estrutura mais ou menos assim:

// Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

// Holds the Employee revisions in rows.
"EmployeeHistories (HistoryId, EmployeeId, DateModified, OldValue, NewValue, FieldName)"

Ou solução global para aplicação:

// Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

// Holds all entities revisions in rows.
"EntityChanges (EntityName, EntityId, DateModified, OldValue, NewValue, FieldName)"

Você pode salvar suas revisões também em XML, e só possui um registro para uma revisão. Será assim:

// Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

// Holds all entities revisions in rows.
"EntityChanges (EntityName, EntityId, DateModified, XMLChanges)"
dariol
fonte
1
Melhor: o uso de eventos de sourcing :)
Dariol
1

Tivemos requisitos semelhantes e o que descobrimos foi que, muitas vezes, o usuário quer apenas ver o que foi alterado, não necessariamente reverter as alterações.

Não tenho certeza de qual é o seu caso de uso, mas o que fizemos foi criar e tabela de auditoria que é atualizada automaticamente com alterações em uma entidade comercial, incluindo o nome amigável de qualquer referência de chave estrangeira e enumerações.

Sempre que o usuário salva suas alterações, recarregamos o objeto antigo, executamos uma comparação, registramos as alterações e salvamos a entidade (tudo é feito em uma única transação do banco de dados, caso haja algum problema).

Isso parece funcionar muito bem para nossos usuários e economiza a dor de cabeça de ter uma tabela de auditoria completamente separada com os mesmos campos da nossa entidade comercial.

mattruma
fonte
0

Parece que você deseja rastrear alterações em entidades específicas ao longo do tempo, por exemplo, ID 3, "bob", "123 main street" e, em seguida, outro ID 3, "bob" "234 elm st" e assim por diante, essencialmente, podendo vomitar um histórico de revisões mostrando todos os endereços em que "bob" esteve.

A melhor maneira de fazer isso é ter um campo "é atual" em cada registro e (provavelmente) um carimbo de data / hora ou FK para uma tabela de data / hora.

As inserções precisam definir o "é atual" e também desabilitar o "é atual" no registro "é atual" anterior. As consultas precisam especificar o "é atual", a menos que você queira todo o histórico.

Existem outros ajustes para isso se for uma tabela muito grande ou se for esperado um grande número de revisões, mas essa é uma abordagem bastante padrão.

Steve Moon
fonte