128

我们在项目中需要存储数据库中实体的所有修订(更改历史)。目前,我们为此设计了 2 个提案:

例如对于“员工”实体

设计一:

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

设计二:

-- 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, .., ..)"

有没有其他方法可以做这件事?

“设计 1”的问题在于,每次需要访问数据时,我们都必须解析 XML。这将减慢进程并增加一些限制,例如我们无法在修订数据字段上添加连接。

“设计 2”的问题是我们必须在所有实体上复制每个字段(我们有大约 70-80 个实体需要维护修订)。

4

16 回答 16

58

我认为这里要问的关键问题是“谁/什么将使用历史”?

如果它主要用于报告/人类可读的历史,我们过去已经实现了这个方案......

创建一个名为“AuditTrail”的表或具有以下字段的表...

[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

然后,您可以将“LastUpdatedByUserID”列添加到所有表中,每次对表执行更新/插入时都应设置该列。

然后,您可以向每个表添加触发器以捕获发生的任何插入/更新,并在此表中为每个更改的字段创建一个条目。由于该表还为每个更新/插入提供了“LastUpdateByUserID”,因此您可以在触发器中访问此值并在添加到审计表时使用它。

我们使用 RecordID 字段来存储正在更新的表的键字段的值。如果它是组合键,我们只需在字段之间使用“~”进行字符串连接。

我确信这个系统可能有缺点 - 对于大量更新的数据库,性能可能会受到影响,但对于我的网络应用程序,我们得到的读取比写入多得多,而且它似乎表现得很好。我们甚至编写了一个小的 VB.NET 实用程序来根据表定义自动编写触发器。

只是一个想法!

于 2008-09-02T11:46:44.827 回答
41
  1. 不要它们全部放在一个具有 IsCurrent 鉴别器属性的表中。这只会导致问题,需要代理键和各种其他问题。
  2. 设计 2 在架构更改方面确实存在问题。如果您更改Employees 表,您必须更改EmployeeHistories 表和所有相关的sprocs。可能会使您的架构更改工作加倍。
  3. 设计 1 运行良好,如果做得好,就性能损失而言不会花费太多。您可以使用 xml 架构甚至索引来克服可能的性能问题。您关于解析 xml 的评论是有效的,但您可以使用 xquery 轻松创建视图 - 您可以将其包含在查询中并加入。像这样的东西...
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 
于 2008-09-02T12:13:47.380 回答
24

Database Programmer博客中的History Tables文章可能很有用 - 涵盖了此处提出的一些要点并讨论了 delta 的存储。

编辑

History Tables文章中,作者 ( Kenneth Downs ) 建议维护一个至少包含七列的历史表:

  1. 更改的时间戳,
  2. 进行更改的用户,
  3. 用于标识已更改记录的标记(其中历史记录与当前状态分开维护),
  4. 无论更改是插入、更新还是删除,
  5. 旧值,
  6. 新的价值,
  7. 增量(用于更改数值)。

不应该在历史记录表中跟踪从不更改或不需要其历史记录的列以避免膨胀。存储数值的增量可以使后续查询更容易,即使它可以从旧值和新值派生。

历史表必须是安全的,防止非系统用户插入、更新或删除行。仅应支持定期清除以减小整体大小(如果用例允许)。

于 2008-09-24T10:53:31.403 回答
17

我们已经实施了一个与 Chris Roberts 建议的解决方案非常相似的解决方案,并且对我们来说效果很好。

唯一的区别是我们只存储新值。旧值毕竟存储在上一个历史记录行中

[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

假设您有一个包含 20 列的表。这样,您只需存储已更改的确切列,而不必存储整行。

于 2008-09-02T12:12:17.870 回答
17

避免设计1;一旦您需要例如回滚到旧版本的记录 - 使用管理员控制台自动或“手动”回滚,它就不是很方便。

我真的没有看到设计 2 的缺点。我认为第二个历史表应该包含第一个记录表中存在的所有列。例如,在 mysql 中,您可以轻松地创建与另一个表 ( create table X like Y) 具有相同结构的表。而且,当您要更改实时数据库中 Records 表的结构时,alter table无论如何您都必须使用命令 - 而且为您的 History 表运行这些命令也没有太大的努力。

笔记

  • 记录表仅包含最新修订;
  • History 表包含 Records 表中记录的所有以前的修订;
  • 历史表的主键是添加RevisionId列的Records表的主键;
  • 考虑其他辅助字段,例如ModifiedBy- 创建特定修订的用户。您可能还希望有一个字段DeletedBy来跟踪谁删除了特定修订。
  • 想想DateModified应该是什么意思——要么是这个特定修订版的创建位置,要么意味着这个特定修订版被另一个修订版取代的时间。前者要求字段在Records表中,乍一看似乎更直观;然而,第二种解决方案似乎更适用于已删除的记录(删除此特定修订的日期)。如果您选择第一个解决方案,您可能需要第二个字段DateDeleted(当然只有当您需要它时)。取决于您以及您实际想要记录的内容。

设计 2 中的操作非常简单:

调整
  • 将记录从 Records 表复制到 History 表,给它新的 RevisionId(如果它不存在于 Records 表中),处理 DateModified(取决于您如何解释它,请参阅上面的注释)
  • 继续正常更新 Records 表中的记录
删除
  • 与修改操作的第一步完全相同。根据您选择的解释,相应地处理 DateModified/DateDeleted。
取消删除(或回滚)
  • 从 History 表中获取最高(或某些特定?)修订并将其复制到 Records 表
列出特定记录的修订历史
  • 从历史表和记录表中选择
  • 想一想您对该操作的期望是什么;它可能会从 DateModified/DateDeleted 字段中确定您需要哪些信息(请参阅上面的注释)

如果您选择设计 2,那么执行此操作所需的所有 SQL 命令将非常容易,而且维护也非常简单!也许,如果您也在 Records 表中使用辅助列 ( RevisionId,DateModified )会容易得多- 使两个表保持完全相同的结构(唯一键除外)!这将允许简单的 SQL 命令,这将容忍任何数据结构的变化:

insert into EmployeeHistory select * from Employe where ID = XX

不要忘记使用交易!

至于缩放,这个解决方案非常有效,因为您不需要来回转换 XML 中的任何数据,只需复制整个表行 - 非常简单的查询,使用索引 - 非常有效!

于 2013-06-08T17:52:38.730 回答
13

如果您必须存储历史记录,请创建一个与您正在跟踪的表具有相同架构的影子表以及一个“修订日期”和“修订类型”列(例如“删除”、“更新”)。编写(或生成 - 见下文)一组触发器来填充审计表。

制作一个工具来读取表的系统数据字典并生成一个脚本来创建影子表和一组触发器来填充它是相当简单的。

不要尝试为此使用 XML,XML 存储的效率远低于此类触发器使用的本机数据库表存储。

于 2010-09-23T08:22:46.287 回答
9

Ramesh,我参与了基于第一种方法的系统开发。
事实证明,将修订存储为 XML 会导致数据库的巨大增长并显着减慢速度。
我的方法是每个实体有一个表:

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

其中IsActive是最新版本的标志

如果您想将一些附加信息与修订相关联,您可以创建包含该信息的单独表,并使用 PK\FK 关系将其与实体表链接。

这样,您可以将所有版本的员工存储在一个表中。这种方法的优点:

  • 简单的数据库结构
  • 没有冲突,因为 table 变成了 append-only
  • 您可以通过简单地更改 IsActive 标志回滚到以前的版本
  • 无需连接即可获取对象历史记录

请注意,您应该允许主键是非唯一的。

于 2008-09-02T11:49:28.767 回答
7

我过去看到的方法是

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

您永远不会在此表上“更新”(除了更改 isCurrent 的有效性),只需插入新行。对于任何给定的 EmployeeId,只有 1 行可以有 isCurrent == 1。

维护这一点的复杂性可以通过视图和“而不是”触发器来隐藏(在 oracle 中,我认为其他 RDBMS 类似),如果表太大并且无法通过索引处理,您甚至可以转到物化视图) .

这种方法没问题,但最终可能会遇到一些复杂的查询。

就个人而言,我非常喜欢你的 Design 2 方式,这也是我过去的方式。它易于理解、易于实现和易于维护。

它还为数据库和应用程序创建了非常少的开销,尤其是在执行读取查询时,这很可能是您 99% 的时间要做的事情。

自动创建历史表和要维护的触发器也很容易(假设它将通过触发器完成)。

于 2008-09-02T11:43:50.463 回答
4

数据修订是时态数据库“有效时间”概念的一个方面。对此进行了大量研究,并出现了许多模式和指南。我为那些感兴趣的人写了一个冗长的回复,其中包含一堆对这个问题的引用。

于 2008-12-31T11:33:45.547 回答
4

我将与您分享我的设计,它与您的两种设计不同,因为它需要每个实体类型一个表。我发现描述任何数据库设计的最佳方式是通过 ERD,这是我的:

在此处输入图像描述

在此示例中,我们有一个名为employee的实体。user表保存您的用户记录,entityentity_revision是两个表,它们保存您系统中所有实体类型的修订历史记录。以下是此设计的工作原理:

entity_idrevision_id这两个字段

系统中的每个实体都有自己的唯一实体 ID。您的实体可能会经历修订,但其 entity_id 将保持不变。您需要将此实体 ID 保留在您的员工表中(作为外键)。您还应该将实体的类型存储在实体表中(例如“员工”)。现在至于 revision_id,正如其名称所示,它会跟踪您的实体修订。我为此找到的最佳方法是使用employee_id作为您的revision_id。这意味着对于不同类型的实体,您将拥有重复的修订 ID,但这对我来说不是一种享受(我不确定您的情况)。唯一需要注意的是 entity_id 和 revision_id 的组合应该是唯一的。

entity_revision表中还有一个state字段,指示修订状态。它可以具有以下三种状态之一:,或(不依赖于修订日期可以极大地帮助您提高查询)。latestobsoletedeleted

关于 revision_id 的最后一点,我没有创建将employee_id 连接到revision_id 的外键,因为我们不想为将来可能添加的每种实体类型更改entity_revision 表。

插入

对于要插入数据库的每个员工,您还将向entityentity_revision添加一条记录。最后两条记录将帮助您跟踪记录由谁以及何时插入数据库。

更新

现有员工记录的每次更新都将作为两个插入实现,一个在员工表中,一个在 entity_revision 中。第二个将帮助您了解记录的更新者和时间。

删除

为了删除员工,将一条记录插入到 entity_revision 中,说明删除并完成。

正如您在此设计中看到的那样,不会从数据库中更改或删除任何数据,更重要的是,每种实体类型只需要一个表。就我个人而言,我发现这种设计非常灵活且易于使用。但我不确定您的情况,因为您的需求可能会有所不同。

[更新]

在新的 MySQL 版本中支持分区后,我相信我的设计也具有最好的性能之一。可以使用字段对entity表进行分区,同时使用其字段进行分区。这将大大增加查询,同时保持设计简单和干净。typeentity_revisionstateSELECT

于 2013-06-11T11:32:59.393 回答
3

如果您想做第一个,您可能也想对Employees 表使用XML。大多数较新的数据库允许您查询 XML 字段,因此这并不总是一个问题。无论是最新版本还是早期版本,使用一种访问员工数据的方法可能会更简单。

我会尝试第二种方法。您可以通过只有一个带有 DateModified 字段的员工表来简化此操作。EmployeeId + DateModified 将是主键,您只需添加一行即可存储新修订。这样存档旧版本和从存档中恢复版本也更容易。

另一种方法是 Dan Linstedt 的datavault 模型。我为荷兰统计局做了一个项目,使用了这个模型,效果很好。但我不认为它对日常数据库使用直接有用。不过,您可能会从阅读他的论文中得到一些想法。

于 2008-09-02T11:48:34.510 回答
3

如果确实只需要审计跟踪,我会倾向于审计表解决方案(完成其他表上重要列的非规范化副本,例如,UserName)。但请记住,这种痛苦的经历表明,单个审计表将成为未来的巨大瓶颈。为所有被审计的表创建单独的审计表可能是值得的。

如果您需要跟踪实际的历史(和/或未来)版本,那么标准解决方案是使用开始、结束和持续时间值的某种组合来跟踪具有多行的同一实体。您可以使用视图来方便地访问当前值。如果这是您采用的方法,如果您的版本化数据引用可变但未版本化的数据,您可能会遇到问题。

于 2008-09-02T19:47:02.440 回答
2

怎么样:

  • 员工ID
  • 修改日期
    • 和/或修订号,具体取决于您要如何跟踪它
  • ModifiedByUSerId
    • 加上您要跟踪的任何其他信息
  • 员工字段

您创建主键(EmployeeId,DateModified),并获得“当前”记录,您只需为每个employeeid 选择MAX(DateModified)。存储 IsCurrent 是一个非常糟糕的主意,因为首先它可以计算,其次,数据太容易不同步。

您还可以创建一个仅列出最新记录的视图,并且在您的应用程序中工作时主要使用它。这种方法的好处是您没有重复的数据,并且您不必从两个不同的地方(当前在员工中,并在员工历史中存档)收集数据来获取所有历史记录或回滚等) .

于 2008-10-18T17:29:57.420 回答
2

如果您想依赖历史数据(出于报告原因),您应该使用如下结构:

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

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

或全球应用解决方案:

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

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

您也可以将您的修订保存在 XML 中,然后您只有一个记录对应一个修订。这看起来像:

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

// Holds all entities revisions in rows.
"EntityChanges (EntityName, EntityId, DateModified, XMLChanges)"
于 2009-06-25T09:21:56.417 回答
1

我们也有类似的需求,我们发现很多时候用户只是想看看发生了什么变化,而不一定要回滚任何变化。

我不确定您的用例是什么,但我们所做的是创建和审核表,该表会随着业务实体的更改自动更新,包括任何外键引用和枚举的友好名称。

每当用户保存他们的更改时,我们都会重新加载旧对象,运行比较,记录更改并保存实体(所有这些都在单个数据库事务中完成,以防出现任何问题)。

这似乎对我们的用户非常有效,并且让我们不必担心拥有一个与我们的业务实体具有相同字段的完全独立的审计表。

于 2008-09-02T11:43:55.190 回答
0

听起来您想跟踪特定实体随时间的变化,例如 ID 3、“bob”、“123 main street”,然后是另一个 ID 3、“bob”、“234 elm st”等等,本质上是能够吐出一个修订历史,显示“鲍勃”的每个地址。

最好的方法是在每条记录上都有一个“当前”字段,以及(可能)日期/时间表的时间戳或 FK。

然后插入必须设置“是当前的”并且还取消设置先前“是当前的”记录上的“是当前的”。查询必须指定“是当前的”,除非你想要所有的历史。

如果它是一个非常大的表,或者预计会有大量的修订,则可以对此进行进一步的调整,但这是一种相当标准的方法。

于 2008-09-02T11:47:14.667 回答