Oracle - 触发器以在更新时创建历史记录行

Posted

技术标签:

【中文标题】Oracle - 触发器以在更新时创建历史记录行【英文标题】:Oracle - Triggers to create a history row on update 【发布时间】:2011-01-15 08:54:51 【问题描述】:

首先,我们目前具有所需的行为,但在需要对数据库进行任何更改时维护它并非易事。我正在寻找任何更简单、更高效或更易于维护的东西(任何能做到这三个的东西都会受到欢迎)。当我们执行更新时,会创建一个历史记录行,它是 current 行的副本,然后更新当前行的值。结果是我们拥有了该行在更新之前的历史记录。

推理:我们必须遵守许多联邦规则,并且走这条路来获得所有内容的完整审计历史,并且我们可以随时查看数据库并了解情况如何(未来的要求)。 出于类似原因,我无法更改记录历史记录的方式...任何解决方案都必须产生与当前触发器创建的数据相同的数据。

这是Contact 表的当前触发器的样子:(为简洁起见,去掉了无用的字段,字段的数量无关紧要)

更新前(每一行):

DECLARE
     indexnb number;
BEGIN
  :new.date_modified := '31-DEC-9999';
  indexnb := STATE_PKG.newCONTACTRows.count + 1;
  :new.date_start := sysdate;
  :new.version := :old.version + 1;
  state_pkg.newCONTACTRows(indexnb).ID := :old.ID;
  state_pkg.newCONTACTRows(indexnb).PREFIX := :old.PREFIX;
  state_pkg.newCONTACTRows(indexnb).FIRST_NAME := :old.FIRST_NAME;
  state_pkg.newCONTACTRows(indexnb).MIDDLE_NAME := :old.MIDDLE_NAME;
  state_pkg.newCONTACTRows(indexnb).LAST_NAME := :old.LAST_NAME;
  --Audit columns after this
  state_pkg.newCONTACTRows(indexnb).OWNER := :old.OWNER;
  state_pkg.newCONTACTRows(indexnb).LAST_USER := :old.LAST_USER;
  state_pkg.newCONTACTRows(indexnb).DATE_CREATED := :old.DATE_CREATED;
  state_pkg.newCONTACTRows(indexnb).DATE_MODIFIED := sysdate;
  state_pkg.newCONTACTRows(indexnb).VERSION := :old.VERSION;
  state_pkg.newCONTACTRows(indexnb).ENTITY_ID := :old.id;
  state_pkg.newCONTACTRows(indexnb).RECORD_STATUS := :old.RECORD_STATUS;
  state_pkg.newCONTACTRows(indexnb).DATE_START := :old.DATE_START;
END;

更新前(所有行一次):

BEGIN
  state_pkg.newCONTACTRows := state_pkg.eCONTACTRows;
END;

更新后(所有行一次):

DECLARE
BEGIN
  for i in 1 .. STATE_PKG.newCONTACTRows.COUNT loop
    INSERT INTO "CONTACT" (
      ID, 
      PREFIX, 
      FIRST_NAME, 
      MIDDLE_NAME, 
      LAST_NAME, 
      OWNER, 
      LAST_USER, 
      DATE_CREATED, 
      DATE_MODIFIED, 
      VERSION, 
      ENTITY_ID, 
      RECORD_STATUS, 
      DATE_START)
    VALUES (
      CONTACT_SEQ.NEXTVAL, 
      state_pkg.newCONTACTRows(i).PREFIX,
      state_pkg.newCONTACTRows(i).FIRST_NAME,
      state_pkg.newCONTACTRows(i).MIDDLE_NAME,
      state_pkg.newCONTACTRows(i).LAST_NAME,
      state_pkg.newCONTACTRows(i).OWNER,
      state_pkg.newCONTACTRows(i).LAST_USER,
      state_pkg.newCONTACTRows(i).DATE_CREATED,
      state_pkg.newCONTACTRows(i).DATE_MODIFIED,
      state_pkg.newCONTACTRows(i).VERSION,
      state_pkg.newCONTACTRows(i).ENTITY_ID,
      state_pkg.newCONTACTRows(i).RECORD_STATUS,
      state_pkg.newCONTACTRows(i).DATE_START
    );
  end loop;
END;

包定义为(修剪后的完整版本只是每个表的副本):

PACKAGE STATE_PKG IS
  TYPE CONTACTArray IS TABLE OF CONTACT%ROWTYPE INDEX BY BINARY_INTEGER; 
  newCONTACTRows CONTACTArray; 
  eCONTACTRows CONTACTArray;
END;

当前结果

这是一个生成的历史样本:

ID    First Last   Ver  Entity_ID  Date_Start              Date_Modified  
1196  John  Smith  5    0          12/11/2009 10:20:11 PM  12/31/9999 12:00:00 AM
1201  John  Smith  0    1196       12/11/2009 09:35:20 PM  12/11/2009 10:16:49 PM
1203  John  Smith  1    1196       12/11/2009 10:16:49 PM  12/11/2009 10:17:07 PM
1205  John  Smith  2    1196       12/11/2009 10:17:07 PM  12/11/2009 10:17:19 PM
1207  John  Smith  3    1196       12/11/2009 10:17:19 PM  12/11/2009 10:20:00 PM
1209  John  Smith  4    1196       12/11/2009 10:20:00 PM  12/11/2009 10:20:11 PM

每个历史记录都有一个 Entity_ID,它是当前行的 ID,新记录上的 Date_Start 与最后一个历史记录行的 Date_Modified 匹配。这允许我们进行类似Where Entity_ID = :id Or ID = :id And :myDate < Date_Modified And :myDate >= Date_Start 的查询。历史可以通过Entity_ID = :current_id获取。

有没有更好的方法,希望更易于维护/更灵活?这个概念很简单,当更新一行时,通过插入旧值将其复制到同一个表中,然后更新当前行...但实际上这样做,我还没有找到更简单的方法。我希望甲骨文中更狡猾/更聪明的人对此有更好的方法。速度并不重要,我们像大多数 Web 应用程序一样,99% 读取 1% 写入,所有批量操作都是插入,而不是不会创建任何历史记录的更新。

如果有人有任何想法来简化这方面的维护,我将非常感激,谢谢!

【问题讨论】:

@Nick - 对不起,我第一次错过了重点。我已经重写了我的回复以实际解决您的问题。 【参考方案1】:

不幸的是,没有办法避免在触发器中引用所有列名(:OLD.this、:OLD.that 等)。但是,您可以编写一个程序,从表定义(在 USER_TAB_COLS 中)生成触发代码。然后,无论何时更改表,您都可以生成并编译触发器的新副本。

请参阅this AskTom thread 了解如何执行此操作。

【讨论】:

@Tony - 这是我们目前所做的,运行它并同步数据库的多个副本是一场真正的噩梦......希望有人知道另一种选择,比如select * into temp,只更新几个审计领域,做一个插入......虽然我不知道如何解决这个问题,Oracle不是我的主要领域:(【参考方案2】:

好的,这是重写。当我第一次响应时,我错过的是应用程序将其历史记录存储在主表中。现在我明白为什么@NickCraver 对代码如此抱歉了。

首先要做的是追捕这种设计的肇事者,并确保他们再也不会这样做。像这样存储历史不会扩展,使正常(非历史)查询更加复杂并破坏关系完整性。显然,在某些情况下,这些都不重要,也许您的网站就是其中之一,但总的来说,这是一个非常糟糕的实现。

最好的方法是Oracle 11g Total Recall。这是一个优雅的解决方案,具有完全不可见且高效的实施,并且 - 按照 Oracle 其他收费附加服务的标准 - 价格相当合理。

但如果 Total Recall 是不可能的,而你真的必须这样做,不允许更新。对现有 CONTACT 记录的更改应该是插入。为了完成这项工作,您可能需要使用 INSTEAD OF 触发器构建视图。它仍然很糟糕,但不如你现在拥有的那么糟糕。


从 Oracle 11.2.0.4 开始,Total Recall 已更名为 Flashback Archive,并作为企业许可证的一部分包含在内(尽管除非我们购买了 Advanced Compress 选项,否则压缩日志表会被删减)。

Oracle 的慷慨解囊应该使 FDA 成为存储历史的常规方式:它高效、高性能,它是 Oracle 内置的,具有支持历史查询的标准语法。唉,我希望看到半生不熟的实现,多年来触发器、主键损坏和糟糕的性能。因为日志似乎是开发人员喜欢的那些干扰之一,尽管事实上它是低级管道,与 99.99% 的所有业务操作在很大程度上无关。

【讨论】:

你是说在一个触发器中只结合:new.date_modified := '31-DEC-9999'; :new.date_start := sysdate; :new.version := :old.version + 1;?这可能会奏效……我们之前无法进行单次触发是有原因的,但这是很久以前的情况之一,我不记得原因是什么,让我试试你的方法,看看会发生什么。谢谢! 我是这么想的,但是多行插入是否存在“表正在变异”的危险?或者这些天还可以吗?我不记得了。 @APC - 我试过了...托尼是正确的,这会产生ORA-04091: table CONTACT is mutating, trigger/function may not see it,这就是为什么我们以前不能走这条路...看到它记住了,比如最古老的东西。 11g 不是不可能的,但我们目前在 10 上,无法在没有公平通知的情况下升级。对于缩放,这是所有层中最不复杂的问题。我们经常在 Linq-to-Oracle 中引用历史记录,DB.Contacts.Current()DB.Contacts 可以轻松地以通用方式访问历史记录……这就是它背后的原因。在我们的例子中,基于 Entity_Id (=0, !=0) 的分区保持了良好的性能......但是,系统还不是很大。通过更改数据库历史记录和 linq 层可能会获得更好的解决方案,但目前没有时间。 @APc - 您的原始解决方案经过一些修改有效,我们没有理由不能为此目的使用匿名交易,我之前没有遇到过这种解决方法,感谢您让我接受仔细看看更简单的方法!【参考方案3】:

如果您想开发通用解决方案,您可能需要查看 DBMS_SQL 包。有了它,您可以开发一个包/过程,该程序包/过程将表名作为输入并基于此构建更新,方法是检查字典中的表结构并动态构建更新。这将是不平凡的前期开发,但未来的维护会少得多,因为如果表结构发生变化,代码会感知并适应。此方法适用于您愿意使用的任何表。

【讨论】:

【参考方案4】:

根据数据库的复杂性(表的数量、大小、PK/FK 关系的深度、触发器中的其他逻辑),您可能需要查看Oracle Workspace Management。您进行 API 调用以将表置于工作区管理之下,这会导致 Oracle 将表替换为可更新的视图和其他相应的对象,这些对象维护所有行版本的历史记录。

我使用过这个,虽然有缺点,但审计的一个优点是代码对象都是由 Oracle 生成的,并且通常假定它们的正确性。

【讨论】:

我查看了工作区管理工具。这真的很甜蜜,但有人告诉我我们需要一个单独的许可证,他们不想为此付费。 我不确定低端的 Oracle 产品,但 WM 是企业版的一部分。【参考方案5】:

我可能会建议将历史记录存储在与“当前”记录相同的表中的唯一情况是当 FK 链接到记录必须或可能需要链接到它们时。例如,我见过的一个应用程序有一些 FK 链接,这些链接会链接到“时间点”的记录,也就是说,如果记录被更新,FK 仍然会链接到历史记录 - 这是一个设计的重要部分并将历史记录分离到第二个表中会使其更加笨拙。

除此之外,我更希望跟踪所有更改的业务需求应该为每个表使用单独的“历史”表来解决。当然,这意味着更多的 DDL,但它极大地简化了应用程序代码,您还将受益于更好的性能和可扩展性。

【讨论】:

实际上,在我们的例子中,一个单独的历史表使应用程序代码更加复杂......我们通过 Linq 访问它并且访问历史或当前非常快速/容易(在少数通用运算符中处理/扩展方法)。几乎没有代码过滤...但是从另一个来源获取历史记录的反向操作将是巨大的在我们的特定情况下。我当然同意你的看法,我们碰巧有一个非常专业的使用/情况,它更适合......但当然,如果整体解决方案更简单,我总是愿意接受替代方案,更容易或更高效。【参考方案6】:

如果有人有与我们相同的高度专业化案例(Linq 访问使单表历史记录更清晰/更容易,这就是我最终为简化我们所做的事情,欢迎任何改进......这只是一个每当数据库更改时将运行的脚本,重新生成审计触发器,主要更改是 PRAGMA AUTONOMOUS_TRANSACTION; 将生成的历史记录放在自主事务上,而不关心突变(这与我们如何审计无关):

Declare
  cur_trig varchar(4000);
  has_ver number;
Begin
    For seq in (Select table_name, sequence_name 
              From user_tables ut, user_sequences us
              Where sequence_name = replace(table_name, '_','') || '_SEQ'
                And table_name Not Like '%$%'
                And Exists (Select 1
                            From User_Tab_Columns utc
                            Where Column_Name = 'ID' And ut.table_name = utc.table_name)
                And Exists (Select 1
                            From User_Tab_Columns utc
                            Where Column_Name = 'DATE_START' And ut.table_name = utc.table_name)
                And Exists (Select 1
                            From User_Tab_Columns utc
                            Where Column_Name = 'DATE_MODIFIED' And ut.table_name = utc.table_name))
    Loop
     --ID Insert Triggers (Autonumber for oracle!)
     cur_trig := 'CREATE OR REPLACE TRIGGER ' || seq.table_name || 'CR' || chr(10)
              || 'BEFORE INSERT ON ' || seq.table_name || chr(10)
              || 'FOR EACH ROW' || chr(10)
              || 'BEGIN' || chr(10)
              || '  SELECT ' || seq.sequence_name || '.NEXTVAL INTO :new.ID FROM DUAL;' || chr(10)
              || '  IF(:NEW.ENTITY_ID = 0) THEN' || chr(10)
              || '    SELECT sysdate, sysdate, ''31-DEC-9999'' INTO :NEW.DATE_CREATED, :NEW.DATE_START, :NEW.DATE_MODIFIED FROM DUAL;' || chr(10)
              || '  END IF;' || chr(10)
              || 'END;' || chr(10);

     Execute Immediate cur_trig;

     --History on update Triggers
     cur_trig := 'CREATE OR REPLACE TRIGGER ' || seq.table_name || '_HIST' || chr(10)
              || '  BEFORE UPDATE ON ' || seq.table_name || ' FOR EACH ROW' || chr(10)
              || 'DECLARE' || chr(10)
              || '  PRAGMA AUTONOMOUS_TRANSACTION;' || chr(10)
              || 'BEGIN' || chr(10)
              || '  INSERT INTO ' || seq.table_name || ' (' || chr(10)
              || '   DATE_MODIFIED ' || chr(10)
              || '   ,ENTITY_ID ' || chr(10);

       For col in (Select column_name
                 From user_tab_columns ut
                 Where table_name = seq.table_name
                   And column_name NOT In ('ID','DATE_MODIFIED','ENTITY_ID')
                 Order By column_name)
     Loop
       cur_trig := cur_trig || '   ,' || col.column_name || chr(10);
     End Loop;

     cur_trig := cur_trig || ') VALUES ( --ID is Automatic via another trigger' || chr(10)
                          || '   SYSDATE --DateModified Set' || chr(10)
                          || '   ,:old.ID --EntityID Set' || chr(10);

     has_ver := 0;
       For col in (Select column_name
                 From user_tab_columns ut
                 Where table_name = seq.table_name
                   And column_name NOT In ('ID','DATE_MODIFIED','ENTITY_ID')
                 Order By column_name)
     Loop
       cur_trig := cur_trig || '   ,:old.' || col.column_name || chr(10);
       If Upper(col.column_name) = 'VERSION' Then 
         has_ver := 1; 
       End If;
     End Loop;

     cur_trig := cur_trig || ');' || chr(10)
                          || ':new.DATE_MODIFIED := ''31-DEC-9999'';' || chr(10)
                          || ':new.DATE_START := SYSDATE;' || chr(10);
     If has_ver = 1 Then
       cur_trig := cur_trig || ':new.version := :old.version + 1;' || chr(10);
     End If;
     cur_trig := cur_trig || 'COMMIT;' || chr(10)
                          || 'END;' || chr(10);

     Execute Immediate cur_trig;
    End Loop;
End;
/

如果您可以改进,请随意...我只编写了少数 PL/SQL 脚本,这种需求不会经常出现...可能还有很多不足之处。

感谢 APC 让我更加努力地看待这个问题。我不推荐这种历史布局,除非它是您的模型/应用程序/堆栈的其余部分非常好。对于这个应用程序,我们不断地显示历史和当前的混合,当涉及到 Linq-to-SQL 样式访问时,过滤远比组合简单。谢谢大家的回答,所有好的建议......当我有更多时间并且没有被发布时间表所困扰时,我会重新审视它,看看它是否可以进一步改进。

【讨论】:

【参考方案7】:

我了解您将历史记录和当前值放在同一个表中的特定应用程序要求,但也许这可以通过采用更常见的方法来处理,即拥有一个单独的审计表,但将其构建为伪物化视图呈现应用程序的组合视图。

对我来说,这具有一个简单的“当前”视图和一个单独但完全自动化的“审核”视图(在这种情况下也有当前视图)的优势。

类似:

create sequence seq_contact start with 1000 increment by 1 nocache nocycle;

create table contact (
    contact_id integer,
    first_name varchar2(120 char),
    last_name varchar2(120 char),
    last_update_date date
    );

alter table contact add constraint pk_contact primary key (contact_id);

create table a$contact (
    version_id integer,
    contact_id integer,
    first_name varchar2(120 char),
    last_name varchar2(120 char),
    last_update_date date
    );

alter table a$contact add constraint pk_a$contact primary key
        (contact_id, version_id);

create or replace trigger trg_contact
before insert or delete or update on contact 
for each row
declare

    v_row contact%rowtype;
    v_audit a$contact%rowtype;

begin

    select seq_contact.nextval into v_audit.version_id from dual;

    if not deleting then

        :new.last_update_date := sysdate;

    end if;

    if inserting or updating then

        v_audit.contact_id := :new.contact_id;
        v_audit.first_name := :new.first_name;
        v_audit.last_name := :new.last_name;
        v_audit.last_update_date := :new.last_update_date;

    elsif deleting then

        v_audit.contact_id := :old.contact_id;
        v_audit.first_name := :old.first_name;
        v_audit.last_name := :old.last_name;
        v_audit.last_update_date := sysdate;

    end if;

    insert into a$contact values v_audit;

end trg_contact;
/

insert into contact (contact_id, first_name, last_name) values
    (1,'Nick','Pierpoint');

insert into contact (contact_id, first_name, last_name) values
    (2, 'John', 'Coltrane');

insert into contact (contact_id, first_name, last_name) values
    (3, 'Sonny', 'Rollins');

insert into contact (contact_id, first_name, last_name) values
    (4, 'Kenny', 'Wheeler');

update contact set last_name = 'Cage' where contact_id = 1;

delete from contact where contact_id = 1;

update contact set first_name = 'Zowie' where contact_id in  (2,3);

select * from a$contact order by contact_id, version_id;

VERSION_ID  CONTACT_ID  FIRST_NAME  LAST_NAME  LAST_UPDATE_DATE
1000        1           Nick        Pierpoint  11/02/2010 14:53:49
1004        1           Nick        Cage       11/02/2010 14:54:00
1005        1           Nick        Cage       11/02/2010 14:54:06
1001        2           John        Coltrane   11/02/2010 14:53:50
1006        2           Zowie       Coltrane   11/02/2010 14:54:42
1002        3           Sonny       Rollins    11/02/2010 14:53:51
1007        3           Zowie       Rollins    11/02/2010 14:54:42
1003        4           Kenny       Wheeler    11/02/2010 14:53:53

【讨论】:

以上是关于Oracle - 触发器以在更新时创建历史记录行的主要内容,如果未能解决你的问题,请参考以下文章

PL/SQL ORACLE:删除时触发更新

尝试创建我的第一个 oracle 触发器时出现问题

SQL 创建触发器以在特定条件下更新表

创建触发器以在更新表之后插入

oracle触发器---记录修改历史

Oracle-触发器和程序包