65.9K
CodeProject 正在变化。 阅读更多。
Home

通过 .NET (CLR) 触发器详细 SQL Server 数据审计

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (14投票s)

2011 年 12 月 19 日

CPOL

15分钟阅读

viewsIcon

71777

downloadIcon

1165

创建用于 SQL Server 的 .NET 触发器,该触发器将记录(精确到字段级别)对表进行的所有更改。

引言

我一直在寻找一个可靠(且精细)的审计解决方案,用于跟踪 SQL Server 数据库中数据的更改,但一直找不到(除了商业解决方案)。我想要的很简单:每当表中的一条记录被插入、删除或更新时,我希望某种形式的审计能够显示采取了什么操作,由谁执行,何时执行,以及具体的更改是什么(即,显示任何受影响字段的更改前和更改后的值)。

微软似乎意识到这是一个迫切的需求,因为他们似乎在 SQL Server 2008 中包含了类似的功能,但据我所知,这只记录了针对表的实际执行语句,而没有跟踪更改前后的值。后者对于任何良好的日志或审计都至关重要。

在查找过程中,我看到了一些尝试编写 SQL 触发器来显示此信息的例子,但我可以理解 SQL 触发器可能非常高效,但我不喜欢这样一个事实:当它们变得冗长而复杂时,会难以维护,有时甚至难以阅读。我看到 SQL 触发器可以使用 `COLUMNS_UPDATED` 等项,但这些项的使用并不十分直接。本质上,我正在寻找一种能够正确执行日志记录、易于维护并且——非常重要——优雅的解决方案。这时 CLR 触发器就派上用场了。

我发现您可以创建 .NET 触发器并将其附加到服务器,它们可以访问与使用常规 SQL 触发器时相同的对象和表。我还找到了一篇不错的入门文章(http://social.msdn.microsoft.com/Forums/en-US/sqlnetfx/thread/60ed909b-51ac-425c-8e00-4d7978f39f70/),该文章涉及为审计编写 CLR 触发器,我的文章大致基于它。

必备组件

在使用此功能之前,有几点需要注意:

  • 必须在服务器上启用 CLR 对象的执行。通过在 Enterprise Manager 中执行以下语句来启用它:
  • exec sp_configure 'clr enabled', 1
    reconfigure
    exec sp_configure  
  • 据我所知,CLR 触发器支持 SQL Server 2005 及更高版本。不确定更早的版本。
  • Visual Studio Express 不支持数据库项目。
  • 项目必须配置为使用 .NET Framework 3.5。

表结构

我正在使用的示例表名为 Customers,它非常简单。以下是它的 `CREATE` 语句:

CREATE TABLE [dbo].[Customers] 
  ( 
     [ID]       [INT] IDENTITY(1, 1) NOT NULL, 
     [Name]     [NVARCHAR](50) NOT NULL, 
     [Address1] [NVARCHAR](100) NULL, 
     [Address2] [NVARCHAR](100) NULL, 
     [City]     [NVARCHAR](100) NULL, 
     [Zip]      [NVARCHAR](100) NULL, 
     [Country]  [NVARCHAR](100) NULL, 
     CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED ( [ID] ASC ) 
  )

为了进行审计,数据已规范化并存储在成对的头表和明细表中。头表包含表名、受影响记录的主键值、一个批次标识符(帮助查看哪些记录属于同一 `INSERT`/`UPDATE`/`DELETE` 语句)、用户名和日期,以及一个备注字段,用于跟踪异常情况(有关更多详细信息,请参阅下文)。明细表然后保存受影响字段的更改前和更改后的值。

审计头表结构

CREATE TABLE [dbo].[AuditHeader] 
  ( 
     [AuditHeaderID]  [INT] IDENTITY(1, 1) NOT NULL, 
     [Action]         [NVARCHAR](5) NOT NULL, 
     [BatchID]        [NVARCHAR](MAX) NOT NULL, 
     [TableName]      [NVARCHAR](MAX) NOT NULL, 
     [PrimaryKey]     [NVARCHAR](MAX) NOT NULL, 
     [Note]           [NVARCHAR](MAX) NULL, 
     [UpdateDate]     [DATETIME] NOT NULL, 
     [UpdateUserName] [NVARCHAR](MAX) NOT NULL, 
     CONSTRAINT [PK_AuditHeader] PRIMARY KEY CLUSTERED ( [AuditHeaderID] ASC ) 
  )

审计明细表结构

CREATE TABLE [dbo].[AuditDetail] 
  ( 
     [AuditHeaderID] [INT] NOT NULL, 
     [AuditDetailID] [INT] IDENTITY(1, 1) NOT NULL, 
     [FieldName]     [NVARCHAR](100) NOT NULL, 
     [OldValue]      [NVARCHAR](MAX) NULL, 
     [NewValue]      [NVARCHAR](MAX) NULL, 
     CONSTRAINT [PK_AuditDetail] PRIMARY KEY CLUSTERED ( [AuditDetailID] ASC ) 
  )

ALTER TABLE [dbo].[AuditDetail] WITH CHECK ADD CONSTRAINT 
[FK_AuditDetail_AuditHeader] FOREIGN KEY([AuditHeaderID]) REFERENCES 
[dbo].[AuditHeader] ([AuditHeaderID]) 

关于效率的重要说明

对于其中的 SQL 纯粹主义者,我确定您在看到文章中的“CLR”和“触发器”这两个词时就开始翻白眼了。我不会责怪您。编写纯 SQL 触发器可能是您可以创建的最快的触发器类型,但它确实付出了巨大的代价——您能编写的代码类型受到严重限制,可用的调试功能非常少(与调试 .NET 应用程序相比),面向对象的优势缺失严重,等等。所以我选择了 CLR 路线,但我对审计以这种方式维护时所花费的时间进行了一些度量。结果并不令人意外。

我的测试是在一台运行 Windows 7、双核 CPU 的普通台式机上进行的。关闭审计时,向 Customers 表插入 20,000 条记录平均需要 10 秒。一旦打开 CLR 触发器,这个时间就膨胀到 10 分钟。但通过移除触发器中最耗时的语句(请参阅下方的选项 #3),时间缩短到更合理的 1 分 40 秒。公平地说,我没有编写类似的 SQL 触发器,所以我不知道它需要多长时间。SQL 触发器仍然需要获取有关表名、主键值、来自审计头表的自动增量值等的相同信息。我的直觉是,它可能介于这两个数字之间。

为了降低执行时间,有几种选择:

  1. 多线程——一旦进入触发器,就会启动一个单独的线程来执行所有审计操作,从而使整个过程异步。理论上,所需时间会回到接近原始的 10 秒。不过,在研究这个问题时,我发现存在一些安全方面的复杂性需要考虑,这使得整个过程过于复杂而难以管理。请记住,我正在寻找一种优雅的解决方案;而不是一个需要 5 名开发人员和 2 名管理员来管理的解决方案。
  2. 消息队列——这似乎是迄今为止最好的选择。不再在现场处理所有审计逻辑,而是将 DELETED 和 INSERTED 表的内容,以及一些额外信息,序列化为 XML 并写入队列。我尝试了此方法,对于 20,000 条记录,时间降至约 30 秒。这绝对是一个不错的选择,但您需要构建一个始终监视队列的服务,从队列中提取序列化数据,并将其处理以插入到审计表中。这很可能是我将要采取的路线。
  3. 我发现有一个特别耗时的语句——负责检索表名。删除此语句可以节省大量时间(约七分钟)。我还没有找到合适的替代方案,SQL Server 似乎也没有将此信息提供给触发器。您可能会认为 `SqlTriggerContext.EventData` 应该包含此信息,但根据 MSDN,它只在运行数据定义语言触发器(DDL,即 `CREATE`、`ALTER` 等)时包含数据,而不是数据操作语言触发器(DML,即 `INSERT`、`UPDATE` 等)。他们为什么会遗漏这一点,我实在不解。也许这还在开发中?一种绕过此语句的方法是创建多个公共触发器(每个表一个),并在方法本身中硬编码表名(请参阅“深入代码”下的示例)。

底线

CLR 触发器无疑功能强大且潜力巨大。然而,当记录更改数量很高时,执行时间会受到影响,如果您希望触发器真正动态。因此,在数据经常被大量操作的系统中实施此功能之前,我会三思(或者实施它,但使用下面描述的特定于表的触发器)。但是,如果您正在处理一个最多只处理数十条(甚至数百条)记录更改的系统,那么额外的延迟几乎察觉不到,因此审计的优势肯定会得到回报。

设置

CLR 触发器的一大优点是,您可以从 Visual Studio 中管理编译、部署和测试。

编译很简单——只需像往常一样打开解决方案并编译。要部署,您需要先设置数据库连接设置,这可以通过转到项目属性并在“数据库”选项卡中设置连接字符串来完成。

测试也很简单——在项目“测试脚本”文件夹下,您会找到 `Test.Sql`。添加要测试的 SQL 语句,在代码(或 SQL 语句)上设置断点,然后按 F5。项目将被编译,部署到服务器,然后执行测试 SQL 语句。

不过,有一个重要的事情要注意,即主 `AuditTrigger` 方法上的 `SqlTrigger` 属性在调试代码时需要三个参数:`Name`(将在数据库中创建的触发器名称)、`Event`(触发器将触发的事件)和 `Target`(触发器将附加到的表)。最后一个参数对于从 Visual Studio 测试触发器至关重要,但如果您的触发器是通用的并附加到多个表,则应将其省略。

如果触发器是通用的,那么在完成测试后,只需删除 `Target` 参数,然后通过单击“生成”\“生成审计”来重新编译项目。不要尝试部署它,否则会因为缺少目标而导致错误。然后,您需要像下面这样从 Enterprise Manager 中将触发器附加到需要的表(请注意,您应该一次运行一个,而不是一次性全部运行,因为 `CREATE TRIGGER` 需要是批处理中运行的第一个语句)。

IF EXISTS (SELECT * 
           FROM   sys.triggers 
           WHERE  object_id = Object_id(N'[dbo].[AuditTrigger]')) 
  DROP TRIGGER [dbo].[AuditTrigger] 
IF EXISTS (SELECT * 
           FROM   sys.assemblies asms 
           WHERE  asms.name = N'Audit' 
                  AND is_user_defined = 1) 
  DROP ASSEMBLY [Audit] 
CREATE ASSEMBLY audit FROM '[your solution's path]\Audit\bin\Debug\Audit.dll' WITH permission_set 
= safe 
CREATE TRIGGER [dbo].[AuditTrigger] 
ON [dbo].[Customers] 
AFTER INSERT, DELETE, UPDATE 
AS 
external name [Audit].[Triggers].[AuditTrigger]

EXEC sys.Sp_addextendedproperty 
  @name=N'SqlAssemblyFile', 
  @value=N'Trigger.cs', 
  @level0type=N'SCHEMA', 
  @level0name=N'dbo', 
  @level1type=N'TABLE', 
  @level1name=N'Customers', 
  @level2type=N'TRIGGER', 
  @level2name=N'AuditTrigger'

EXEC sys.Sp_addextendedproperty 
  @name=N'SqlAssemblyFileLine', 
  @value=N'21', 
  @level0type=N'SCHEMA', 
  @level0name=N'dbo', 
  @level1type=N'TABLE', 
  @level1name=N'Customers', 
  @level2type=N'TRIGGER', 
  @level2name=N'AuditTrigger'

审计的样子

审计显示了对表执行的所有操作的历史记录,无论是删除、插入还是更新。数据存储在两个表中:头表和明细表。

AuditHeader 结构

  • AuditHeaderID - 表的主键(只是一个自动增量字段)。作为 AuditDetail 的外键。
  • Action - “I”、“D”或“U”。表示所执行的操作是 `INSERT`、`DELETE` 还是 `UPDATE`。
  • BatchID - 列中捕获的值本身没有意义,但用于链接作为同一 `INSERT`/`DELETE`/`UPDATE` 语句一部分而生成的其他审计记录。例如,如果执行具有 `WHERE` 子句且影响 100 条客户记录的 `UPDATE`,则所有 100 条 AuditHeader 记录将具有相同的 BatchID 值。一个更有用的值是事务 ID,但该 ID 有一些安全问题需要处理(参见下面的“未来改进”部分)。
  • TableName - 执行数据库操作的表。
  • PrimaryKey - 受影响记录的主键字段名和值的逗号分隔列表。
  • Note - 如果在为特定案例生成审计记录时出现问题,此字段将包含有关该问题的消息。目前存在两种会生成解释性注释的情况(对于这两种情况,请参阅下文“记录 INSERT 或 UPDATE”部分以获取更多详细信息)。
    • 如果 `UPDATE` 语句导致主键列发生更改,则触发器无法生成包含更改的 AuditDetail 记录。
    • 如果受影响的表没有定义主键,则触发器无法生成包含更改的 AuditDetail 记录。
  • UpdateDate - 更改发生的日期。
  • UpdateUserName - 执行更改的用户名。

示例屏幕截图

AuditHeader.jpg

AuditDetail

AuditDetail 将包含有关记录及其经过的具体更改的详细信息。请注意,仅记录值已更改的列。

  • AuditHeaderID - AuditHeader 的主键。作为当前表的外键。
  • AuditDetailID - 表的主键(只是一个自动增量字段)。
  • FieldName - 受影响字段的名称。
  • OldValue - 更改前的字段值。
  • NewValue - 更改后的字段值。

示例屏幕截图(请注意,AuditHeaderID 13 有更多条目未在图像中捕获)

AuditDetails.jpg

深入代码

触发器使用 `DataTable` 来存储和操作已修改的数据和审计数据,并使用 `DataAdapter` 快速填充它们或将它们刷新回数据库。它还使用 `SqlCommandBuilder` 根据 `DataAdapter` 中的 `SELECT` 语句自动生成审计表的 `INSERT` 语句。

由于 AuditHeader 上的主键是自动生成的字段,而 AuditDetail 需要此值作为外键,因此我们需要检索数据库生成的值,以便将其存储在 AuditDetail 中。为此,会捕获 `AuditHeaderAdapter` 上的 `RowUpdated` 事件,检索生成的主键,然后将其存储在 AuditHeader `DataTable` 中。这使其可用于 AuditDetail 的 `DataTable` 更新。

SqlDataAdapter AuditHeaderAdapter = new SqlDataAdapter("SELECT * FROM AuditHeader WHERE 1=0", conn);
...
AuditHeaderAdapter.RowUpdated += delegate(object sender, SqlRowUpdatedEventArgs args)
{
  // Include a variable and a command to retrieve the identity value from the Access database.
  int newRecordNumber = 0;
  SqlCommand idCmd = new SqlCommand("SELECT @@IDENTITY", conn);

  if (args.StatementType == StatementType.Insert)
  {
    // Retrieve the identity value and store it in the AuditHeaderID column.
    newRecordNumber = int.Parse(idCmd.ExecuteScalar().ToString());
    args.Row["AuditHeaderID"] = newRecordNumber;
  }
};

如前所述,获取受影响表名的操作是在触发器执行过程中最耗时的操作之一,如下面的查询所示:

SqlCommand cmd = new SqlCommand("SELECT object_name(resource_associated_entity_id)" + 
   " FROM sys.dm_tran_locks WHERE request_session_id = @@spid and resource_type = 'OBJECT'", conn);
affectedTableName = cmd.ExecuteScalar().ToString();

我还没有找到其他方法来做到这一点,除了为每个表创建一个公共触发器方法,让它们中的每一个都调用同一个私有方法并将表名作为参数传递。

[Microsoft.SqlServer.Server.SqlTrigger(Name = "AuditTrigger", 
       Event = "FOR INSERT, UPDATE, DELETE", Target = "Customers")]
public static void AuditTrigger()
{
  UpdateAuditTables("Customers");
}

[Microsoft.SqlServer.Server.SqlTrigger(Name = "AuditTrigger", 
     Event = "FOR INSERT, UPDATE, DELETE", Target = "Customers")]
public static void AuditTrigger()
{
  UpdateAuditTables("Clients");
}

private static void UpdateAuditTables(string tableName)
{
  ...
}

获取用户名是一个相当直接的事情:

SqlCommand curUserCommand = new SqlCommand("SELECT system_user", conn);
affectingUserName = curUserCommand.ExecuteScalar().ToString();

通过以下查询检索构成受影响表主键的字段列表。它基本上将字段名存储在 `DataTable` 中,并在稍后提供给审计代码,以便记录该记录的键,并且我们可以找到旧/新值。

SqlDataAdapter PKTableAdapter = new SqlDataAdapter(@"SELECT c.COLUMN_NAME
    from INFORMATION_SCHEMA.TABLE_CONSTRAINTS pk ,
    INFORMATION_SCHEMA.KEY_COLUMN_USAGE c
    where pk.TABLE_NAME = '" + affectedTableName + @"' 
    and CONSTRAINT_TYPE = 'PRIMARY KEY'
    and c.TABLE_NAME = pk.TABLE_NAME
    and c.CONSTRAINT_NAME = pk.CONSTRAINT_NAME", conn);
    DataTable primaryKeyTable = new DataTable();
    PKTableAdapter.Fill(primaryKeyTable);

记录 DELETE

这些语句相当简单,因为它们的所有数据都包含在 DELETED 表中。我们只需循环遍历所有行(因为一条语句可能删除多行),然后为每一行创建一个 AuditHeader 记录和 AuditDetail 记录(其中列值不为 null)。

记录 INSERT 或 UPDATE

这两个操作类似,因为它们都将新数据集存储在 INSERTED 表中。此外,`UPDATE` 命令会将旧数据存储在 DELETED 表中。

对于 `INSERT` 情况,工作很简单,其逻辑与删除记录完全相同,只是数据存在于 INSERTED 表中。

但是,对于 `UPDATE` 情况,事情就变得棘手了。INSERTED 中的每个记录在 DELETED 表中都有一个包含旧值的对应项,并且需要将它们匹配起来,以便审计显示每条记录的旧值和新值。最简单的方法是为主键添加到两个 `DataTable` 中,然后从 INSERTED 表添加一个指向 DELETED 表的 `ChildRelationship`。然后,当您循环遍历 INSERTED 行时,只需获取子行即可完成。唯一的问题是,再次是执行成本。遵循此方法会在上述测试中增加几分钟,因为创建主键对象、表关系等的后台工作所涉及的开销。用于处理这些关系的代码在项目中可用,供您好奇,但已被注释掉。

DataColumn[] insertedPrimaryKeyColumns = new DataColumn[primaryKeyTable.Rows.Count];
DataColumn[] deletedPrimaryKeyColumns = new DataColumn[primaryKeyTable.Rows.Count];

int colCtr = 0;
foreach (DataRow primaryKeyRow in primaryKeyTable.Rows)
{
  insertedPrimaryKeyColumns[colCtr] = inserted.Columns[primaryKeyRow[0].ToString()];
  deletedPrimaryKeyColumns[colCtr] = deleted.Columns[primaryKeyRow[0].ToString()];
}
inserted.PrimaryKey = insertedPrimaryKeyColumns;
deleted.PrimaryKey = deletedPrimaryKeyColumns;

inserted.ChildRelations.Add("FK_HeaderDetailRelation", 
   insertedPrimaryKeyColumns, deletedPrimaryKeyColumns);

DataRow[] childDeletedRows = insertedRow.GetChildRows("FK_HeaderDetailRelation");

if (childDeletedRows.Length == 0)
{
  auditHeaderRow["Note"] = "Unable to log field level changes " + 
     "because the primary key of the record may have changed.";
}
else if (childDeletedRows.Length > 1)
{
  auditHeaderRow["Note"] = "Unable to log field level changes " + 
     "because a distinct match between INSERTED and DELETED could not be made.";
}
else
{
  deletedRow = childDeletedRows[0];
}

相反,匹配项是通过一种稍微复杂(但更快)的方法找到的。我们创建一个索引列表,该列表跟踪 DELETED 表中的所有记录位置。然后,我们循环遍历主键表中的字段,并将索引列表中的每个已删除行与每个已插入行进行比较,从索引列表中删除任何不匹配的已删除行。

List<int> matchingDeletedRows = new List<int>();
for (int ctr = 0; ctr < deleted.Rows.Count; ctr++)
{
  matchingDeletedRows.Add(ctr);
}

foreach (DataRow primaryKeyRow in primaryKeyTable.Rows)
{
  for (int ctr = 0; ctr < matchingDeletedRows.Count; ctr++)
  {
    int deleteRowIndex = matchingDeletedRows[ctr];
    string deletedPrimaryKeyValue = 
      deleted.Rows[deleteRowIndex][primaryKeyRow[0].ToString()].ToString();
    string insertedPrimaryKeyValue = 
      insertedRow[primaryKeyRow[0].ToString()].ToString();
    if (deletedPrimaryKeyValue != insertedPrimaryKeyValue)
    {
      // current primary key field value does not match,
      // so the index for this deleted record can be removed.
      matchingDeletedRows.RemoveAt(ctr);
      ctr--;
    }
  }
}

当所有主键字段都已比较完毕后,我们应该会遇到以下三种情况之一:

  1. 如果索引列表为空,则未找到匹配的 DELETED 记录。这可能是因为 `UPDATE` 语句更改了主键字段之一,因此我们找不到匹配项。向 `AuditHeader.Note` 添加一条消息,并跳过记录字段更改。
  2. 如果索引包含多个值,则不知何故找到了多个匹配项,因此我们再次无法记录字段更改。我不确定这种情况可能在什么场景下出现,但您永远不知道。我们在此处向 `AuditHeader.Note` 添加一条消息,然后继续。
  3. `matchingDeletedRows` 中留下了一个条目,这意味着我们找到了匹配项。使用此索引从 DELETED 表中获取正确的记录,并记录它与当前 INSERTED 记录之间的差异。

其他一些注意事项...

如果表没有主键信息,则无法进行 INSERTED 和 DELETED 之间的比较,因此会在 AuditHeader 记录上留下相应的注释,并跳过 AuditDetail 部分。

另外,如果 INSERTED 和 DELETED 都包含一条记录,我们就不费力去匹配记录,而是假定它们是相关的,并像这样进行记录。我想不出它们不相关的场景,但如果有人能想到,请告诉我。

未来改进

目前,我有两项改进希望进行研究:

  1. 寻找一种更快速、更有效的方法来获取受影响的表名。
  2. 将事务 ID 存储在头级别,以便可以一起审查所有相关更改。这有一些安全设置需要审查,因为默认情况下,非管理员用户似乎无法访问包含事务信息的系统表。

结论

正如开头提到的,这种方法有一些非常大的优点,但也存在一些缺点。如果您想在小型/中型数据库中实现审计,通常一次不会影响大量记录,那么此方法就可以很好地工作。但是,如果您确实如此,那么您可以实施它,但我强烈建议将获取受影响表名的代码替换为单个的、针对特定表的触发器,这些触发器已经在其中硬编码了表名。这不优雅,但直到微软修复 `SqlTriggerContext.EventData` 对 DML 语句为 null 的问题,我暂时看不到其他解决方法。

如果有人对改进此代码有任何建议,请随时告知我。如果您能想到其他获取受影响表名的方法,我将非常感兴趣。

一些好的链接和参考资料

© . All rights reserved.