SQL 使用临时 table 的服务器连接上下文不能用于使用 SqlDataAdapter.Fill 调用的存储过程

SQL Server connection context using temporary table cannot be used in stored procedures called with SqlDataAdapter.Fill

我想要一些可用于任何存储过程的信息,例如当前用户。按照 here 指示的临时 table 方法,我尝试了以下方法:

1) 在连接打开时创建临时 table

        private void setConnectionContextInfo(SqlConnection connection)
        {
            if (!AllowInsertConnectionContextInfo)
                return;

            var username = HttpContext.Current?.User?.Identity?.Name ?? "";

            var commandBuilder = new StringBuilder($@"
CREATE TABLE #ConnectionContextInfo(
    AttributeName VARCHAR(64) PRIMARY KEY, 
    AttributeValue VARCHAR(1024)
);

INSERT INTO #ConnectionContextInfo VALUES('Username', @Username);
");

            using (var command = connection.CreateCommand())
            {
                command.Parameters.AddWithValue("Username", username);
                command.ExecuteNonQuery();
            }
        }

        /// <summary>
        /// checks if current connection exists / is closed and creates / opens it if necessary
        /// also takes care of the special authentication required by V3 by building a windows impersonation context
        /// </summary>
        public override void EnsureConnection()
        {
            try
            {
                lock (connectionLock)
                {
                    if (Connection == null)
                    {
                        Connection = new SqlConnection(ConnectionString);
                        Connection.Open();
                        setConnectionContextInfo(Connection);
                    }

                    if (Connection.State == ConnectionState.Closed)
                    {
                        Connection.Open();
                        setConnectionContextInfo(Connection);
                    }
                }
            }  
            catch (Exception ex)
            {
                if (Connection != null && Connection.State != ConnectionState.Open)
                    Connection.Close();

                throw new ApplicationException("Could not open SQL Server Connection.", ex);
            }
        }

2) 使用以下函数测试了用于使用 SqlDataAdapter.Fill 填充 DataTable 的过程:

    public DataTable GetDataTable(String proc, Dictionary<String, object> parameters, CommandType commandType)
    {
        EnsureConnection();

        using (var command = Connection.CreateCommand())
        {
            if (Transaction != null)
                command.Transaction = Transaction;

            SqlDataAdapter adapter = new SqlDataAdapter(proc, Connection);
            adapter.SelectCommand.CommandTimeout = CommonConstants.DataAccess.DefaultCommandTimeout;
            adapter.SelectCommand.CommandType = commandType;

            if (Transaction != null)
                adapter.SelectCommand.Transaction = Transaction;

            ConstructCommandParameters(adapter.SelectCommand, parameters);

            DataTable dt = new DataTable();
            try
            {
                adapter.Fill(dt);
                return dt;
            }
            catch (SqlException ex)
            {
                var err = String.Format("Error executing stored procedure '{0}' - {1}.", proc, ex.Message);
                throw new TptDataAccessException(err, ex);
            }
        }
    }

3) 被调用的过程会像这样尝试获取用户名:

DECLARE @username VARCHAR(128) = (select AttributeValue FROM #ConnectionContextInfo where AttributeName = 'Username')

但是 #ConnectionContextInfo 在上下文中不再可用。

我已经在数据库中放置了一个 SQL 分析器,以检查发生了什么:

为什么临时 table 在过程范围内不可用?

在 T-SQL 中进行以下工作:

谢谢。

如本 answer 所示,当 CommandTypeCommandType.Text 并且命令具有参数时,ExecuteNonQuery 使用 sp_executesql

这个问题中的 C# 代码没有明确设置 CommandType,它是 Text by default,所以代码的最终结果很可能是 CREATE TABLE #ConnectionContextInfo 被包装到 sp_executesql。您可以在 SQL Profiler 中验证它。

众所周知sp_executesql是运行ning在自己的范围内(本质上是一个嵌套的存储过程)。搜索 "sp_executesql temp table"。这是一个示例:Execute sp_executeSql for select...into #table but Can't Select out Temp Table Data

因此,在 sp_executesql 的嵌套范围内创建了临时 table #ConnectionContextInfo,并在 sp_executesql returns 后立即自动删除。 adapter.Fill 运行 的以下查询没有看到此临时 table。


怎么办?

确保 CREATE TABLE #ConnectionContextInfo 语句未包含在 sp_executesql.

在您的情况下,您可以尝试将包含 CREATE TABLE #ConnectionContextInfoINSERT INTO #ConnectionContextInfo 的单个批次拆分为两个批次。第一个 batch/query 将只包含没有任何参数的 CREATE TABLE 语句。第二个 batch/query 将包含带有参数的 INSERT INTO 语句。

我不确定它是否有帮助,但值得一试。

如果这不起作用,您可以构建一个 T-SQL 批处理来创建一个临时 table,将数据插入其中并调用您的存储过程。全部在一个 SqlCommand 中,全部在一个批次中。整个 SQL 将被包裹在 sp_executesql 中,但这并不重要,因为创建 temp table 的范围与调用存储过程的范围相同。从技术上讲它会起作用,但我不建议遵循此路径。


这里不是问题的答案,而是解决问题的建议。

老实说,整个方法看起来很奇怪。如果您想将一些数据传递到存储过程中,为什么不使用此存储过程的参数。这就是它们的用途 - 将数据传递到过程中。没有真正需要为此使用 temp table 。如果您传递的数据很复杂,您可以使用 table 值参数 (T-SQL, .NET)。如果只是一个 Username.

绝对是大材小用

您的存储过程需要知道临时 table,它需要知道它的名称和结构,所以我不明白显式 table 值有什么问题参数代替。即使您的程序代码也不会改变太多。您将使用 @ConnectionContextInfo 而不是 #ConnectionContextInfo

仅当您使用 SQL Server 2005 或更早版本(没有 table 值参数)时,对您描述的内容使用临时 tables 才对我有意义。它们是在 SQL Server 2008 中添加的。

"There are two types of temporary tables: local and global. They differ from each other in their names, their visibility, and their availability. Local temporary tables have a single number sign (#) as the first character of their names; they are visible only to the current connection for the user, and they are deleted when the user disconnects from the instance of SQL Server. Global temporary tables have two number signs (##) as the first characters of their names; they are visible to any user after they are created, and they are deleted when all users referencing the table disconnect from the instance of SQL Server." -- 来自 here

所以你的问题的答案是 ## 而不是 # 使本地临时 table 成为全局。

小问题:我暂时假设问题中发布的代码不是 运行 的完整代码。不仅使用了我们没有看到声明的变量(例如 AllowInsertConnectionContextInfo),而且 setConnectionContextInfo 方法中还有一个明显的遗漏:command 对象被创建但从未被创建它的 CommandText 属性 设置为 commandBuilder.ToString(),因此它看起来是一个空的 SQL 批次。我确定这实际上得到了正确处理,因为 1) 我相信提交一个空批次会产生异常,并且 2) 问题确实提到了临时 table 创建出现在 SQL 探查器中输出。尽管如此,我还是要指出这一点,因为这意味着可能存在与问题中未显示的观察到的行为相关的其他代码,这使得给出准确答案变得更加困难。

如@Vladimir 的罚款 所述,由于子流程中的查询 运行(即 sp_executesql),本地临时对象 -- tables 和存储过程 -- 在该子进程完成后无法存活,因此在父上下文中不可用。

全局临时对象和 permanent/non-temporary 对象将在子进程完成后继续存在,但是这两个选项在它们的典型用法中都会引入并发问题:您需要先测试是否存在试图创建 table、 您需要一种方法来区分一个进程与另一个进程。所以这些并不是一个很好的选择,至少在它们的典型用法中不是(稍后会详细介绍)。

假设您不能将任何值传递到存储过程中(否则您可以按照@Vladimir 在他的回答中建议的那样简单地传递 username),您有几个选择:

  1. 根据当前代码,最简单的解决方案是将本地临时 table 的创建与 INSERT 命令分开(@Vladimir 的回答中也提到) .如前所述,您遇到的问题是由 sp_executesql 中的查询 运行 引起的。使用 sp_executesql 的原因是为了处理参数 @Username。因此,修复可以像将当前代码更改为以下一样简单:

    string _Command = @"
         CREATE TABLE #ConnectionContextInfo(
         AttributeName VARCHAR(64) PRIMARY KEY, 
         AttributeValue VARCHAR(1024)
         );";
    
    using (var command = connection.CreateCommand())
    {
        command.CommandText = _Command;
        command.ExecuteNonQuery();
    }
    
    _Command = @"
         INSERT INTO #ConnectionContextInfo VALUES ('Username', @Username);
    ");
    
    using (var command = connection.CreateCommand())
    {
        command.CommandText = _Command;
    
        // do not use AddWithValue()!
        SqlParameter _UserName = new SqlParameter("@Username", SqlDbType.NVarChar, 128);
        _UserName.Value = username;
        command.Parameters.Add(_UserName);
    
        command.ExecuteNonQuery();
    }
    

    请注意,在 T-SQL 用户定义函数或 Table-Valued 函数中无法访问临时对象 -- 局部和全局。

  2. 更好的解决方案(最有可能)是使用 CONTEXT_INFO,它本质上是会话内存。它是一个 VARBINARY(128) 值,但对它的更改在任何子进程中都有效,因为它不是对象。这不仅消除了您当前面临的复杂情况,而且还减少了 tempdb I/O 考虑到每次此过程运行时您正在创建和删除临时 table,并执行 INSERT,所有这 3 个操作都写入磁盘两次:首先在事务日志中,然后在数据文件中。您可以按以下方式使用它:

    string _Command = @"
        DECLARE @User VARBINARY(128) = CONVERT(VARBINARY(128), @Username);
        SET CONTEXT_INFO @User;
         ";
    
    using (var command = connection.CreateCommand())
    {
        command.CommandText = _Command;
    
        // do not use AddWithValue()!
        SqlParameter _UserName = new SqlParameter("@Username", SqlDbType.NVarChar, 128);
        _UserName.Value = username;
        command.Parameters.Add(_UserName);
    
        command.ExecuteNonQuery();
    }
    

    然后您通过以下方式获取存储过程/用户定义函数/Table-Valued 函数/触发器中的值:

    DECLARE @Username NVARCHAR(128) = CONVERT(NVARCHAR(128), CONTEXT_INFO());
    

    这对单个值来说效果很好,但如果您需要多个值,或者如果您已经将 CONTEXT_INFO 用于其他目的,那么您要么需要返回到所描述的其他方法之一在这里,或者,如果使用 SQL Server 2016(或更新版本),您可以使用 SESSION_CONTEXT,它类似于 CONTEXT_INFO,但是是一个 HashTable / Key-Value 对.

    这种方法的另一个好处是 CONTEXT_INFO(至少,我还没有尝试过 SESSION_CONTEXT)在 T-SQL 用户定义函数和 Table-值函数。

  3. 最后,另一种选择是创建一个全局临时文件 table。上面说了,全局对象有子进程存活的好处,但是也有并发复杂化的缺点。一个很少被用来获得好处而没有缺点的方法是给临时对象一个唯一的、基于会话的 name,而不是添加一个列来保存一个唯一的、基于会话的 。使用会话唯一的名称可以消除任何并发问题,同时允许您使用一个在连接关闭时自动清理的对象(因此无需担心创建全局临时文件的进程 table然后在完成之前遇到错误,而使用永久 table 将需要清理,或者至少在开始时进行存在性检查。

    记住我们不能将任何值传递给存储过程的限制,我们需要使用数据层已经存在的值。要使用的值是 session_id / SPID。当然,这个值在app层是不存在的,所以要取回,但是没有限制往那个方向去。

    int _SessionId;
    
    using (var command = connection.CreateCommand())
    {
        command.CommandText = @"SET @SessionID = @@SPID;";
    
        SqlParameter _paramSessionID = new SqlParameter("@SessionID", SqlDbType.Int);
        _paramSessionID.Direction = ParameterDirection.Output;
        command.Parameters.Add(_UserName);
    
        command.ExecuteNonQuery();
    
        _SessionId = (int)_paramSessionID.Value;
    }
    
    string _Command = String.Format(@"
      CREATE TABLE ##ConnectionContextInfo_{0}(
        AttributeName VARCHAR(64) PRIMARY KEY, 
        AttributeValue VARCHAR(1024)
      );
    
      INSERT INTO ##ConnectionContextInfo_{0} VALUES('Username', @Username);", _SessionId);
    
    using (var command = connection.CreateCommand())
    {
        command.CommandText = _Command;
    
        SqlParameter _UserName = new SqlParameter("@Username", SqlDbType.NVarChar, 128);
        _UserName.Value = username;
        command.Parameters.Add(_UserName);
    
        command.ExecuteNonQuery();
    }
    

    然后您通过以下方式获取存储过程/触发器中的值:

    DECLARE @Username NVARCHAR(128),
            @UsernameQuery NVARCHAR(4000);
    
    SET @UsernameQuery = CONCAT(N'SELECT @tmpUserName = [AttributeValue]
         FROM ##ConnectionContextInfo_', @@SPID, N' WHERE [AttributeName] = ''Username'';');
    
    EXEC sp_executesql
      @UsernameQuery,
      N'@tmpUserName NVARCHAR(128) OUTPUT',
      @Username OUTPUT;
    

    请注意,在 T-SQL 用户定义函数或 Table-Valued 函数中无法访问临时对象 -- 局部和全局。

  4. 最后,可以使用真实/永久(即非临时)Table,前提是您包含一个列来保存特定于当前会话的值。此附加列将允许并发操作正常工作。

    您可以在 tempdb 中创建 table(是的,您可以将 tempdb 用作常规数据库,不需要只是以 [=39 开头的临时对象=] 或 ##)。使用 tempdb 的优点是 table 不影响其他一切(毕竟它只是临时值,不需要恢复,所以 tempdb使用 SIMPLE 恢复模型是完美的),并且在实例重新启动时它会被清理(仅供参考:每次 SQL 服务器时 tempdb 作为 model 的副本创建全新开始)。

    就像上面的选项 #3 一样,我们可以再次使用 session_id / SPID 值,因为它对该连接上的所有操作都是通用的(只要连接保持打开状态)。但是,与选项 #3 不同的是,应用程序代码不需要 SPID 值:它可以使用默认约束自动插入到每一行中。这样就简化了一些操作。

    这里的概念是先查看tempdb中的永久table是否存在。如果是,则确保 table 中没有当前 SPID 的条目。如果没有,则创建 table。由于它是一个永久的table,它会继续存在,即使在当前进程关闭它的连接之后。最后,插入 @Username 参数,SPID 值将自动填充。

    // assume _Connection is already open
    using (SqlCommand _Command = _Connection.CreateCommand())
    {
        _Command.CommandText = @"
           IF (OBJECT_ID(N'tempdb.dbo.Usernames') IS NOT NULL)
           BEGIN
              IF (EXISTS(SELECT *
                         FROM   [tempdb].[dbo].[Usernames]
                         WHERE  [SessionID] = @@SPID
                        ))
              BEGIN
                 DELETE FROM [tempdb].[dbo].[Usernames]
                 WHERE  [SessionID] = @@SPID;
              END;
           END;
           ELSE
           BEGIN
              CREATE TABLE [tempdb].[dbo].[Usernames]
              (
                 [SessionID]  INT NOT NULL
                              CONSTRAINT [PK_Usernames] PRIMARY KEY
                              CONSTRAINT [DF_Usernames_SessionID] DEFAULT (@@SPID),
                 [Username]   NVARCHAR(128) NULL,
                 [InsertTime] DATETIME NOT NULL
                              CONSTRAINT [DF_Usernames_InsertTime] DEFAULT (GETDATE())
              );
           END;
    
           INSERT INTO [tempdb].[dbo].[Usernames] ([Username]) VALUES (@UserName);
                ";
    
        SqlParameter _UserName = new SqlParameter("@Username", SqlDbType.NVarChar, 128);
        _UserName.Value = username;
        command.Parameters.Add(_UserName);
    
        _Command.ExecuteNonQuery();
    }
    

    然后您通过以下方式获取存储过程/用户定义函数/Table-Valued 函数/触发器中的值:

    SELECT [Username]
    FROM   [tempdb].[dbo].[Usernames]
    WHERE  [SessionID] = @@SPID;
    

    此方法的另一个好处是永久性 table 可在 T-SQL 用户定义函数和 Table 值函数中访问。