从SqlDataReader读取字符串时出现内存不足的问题。

15
我遇到了一个奇怪的问题,无法解决。我有一个 SQL 表格,其中有一大堆报告存储在 ntext 字段中。当我将其中一个报告的值复制粘贴到记事本中并保存时(使用 Visual Studio 从不同行中的较小报告中获取该值),原始文本文件大小约为 5MB。但是,当我尝试使用 SqlDataReader 获取相同的数据并将其转换为字符串时,会出现内存不足异常。以下是我的尝试方法:
string output = "";
string cmdtext = "SELECT ReportData FROM Reporting_Compiled WHERE CompiledReportTimeID = @CompiledReportTimeID";
SqlCommand cmd = new SqlCommand(cmdtext, conn);
cmd.Parameters.Add(new SqlParameter("CompiledReportTimeID", CompiledReportTimeID));
SqlDataReader reader = cmd.ExecuteReader();
while (reader.Read())
{
    output = reader.GetString(0); // <--- exception happens here
}
reader.Close();

我尝试创建一个对象和一个stringbuilder来获取数据,但我仍然得到相同的内存溢出异常。我也尝试使用reader.GetValue(0).ToString(),但也无济于事。查询只返回1行,当我在SQL管理工具中运行它时,它是很顺利的。
抛出的异常是:
System.OutOfMemoryException was unhandled by user code  
Message=Exception of type 'System.OutOfMemoryException' was thrown.  
Source=mscorlib  
 StackTrace:  
 at System.String.CreateStringFromEncoding(Byte* bytes, Int32 byteLength, Encoding       encoding)  
   at System.Text.UnicodeEncoding.GetString(Byte[] bytes, Int32 index, Int32 count)  
   at System.Data.SqlClient.TdsParserStateObject.ReadString(Int32 length)  
   at System.Data.SqlClient.TdsParser.ReadSqlStringValue(SqlBuffer value, Byte type, Int32 length, Encoding encoding, Boolean isPlp, TdsParserStateObject stateObj)  
   at System.Data.SqlClient.TdsParser.ReadSqlValue(SqlBuffer value, SqlMetaDataPriv md, Int32 length, TdsParserStateObject stateObj)  
   at System.Data.SqlClient.SqlDataReader.ReadColumnData()  
   at System.Data.SqlClient.SqlDataReader.ReadColumn(Int32 i, Boolean setTimeout)  
   at System.Data.SqlClient.SqlDataReader.GetString(Int32 i)  
   at Reporting.Web.Services.InventoryService.GetPrecompiledReportingData(DateTime ReportTime, String ReportType) in   C:\Projects\Reporting\Reporting.Web\Services\InventoryService.svc.cs:line 3244  
   at SyncInvokeGetPrecompiledReportingData(Object , Object[] , Object[] )  
   at System.ServiceModel.Dispatcher.SyncMethodInvoker.Invoke(Object instance, Object[] inputs, Object[]& outputs)  
   at System.ServiceModel.Dispatcher.DispatchOperationRuntime.InvokeBegin(MessageRpc& rpc)  
 InnerException:   
    null

我曾经测试了其他行号,看起来它们是有效的,但那是一个误报,因为这些测试ID没有数据。在查看包含几乎相同报告的表后,我提取了一些其他测试ID,并得到了相同的异常。也许这是字符串编码的问题?表中存储的数据是一个JSON编码的字符串,该字符串是从我在其他地方创建的一个非常复杂的类中生成的,如果这有帮助的话。
以下是前面的代码块:
// get the report time ID
int CompiledReportTimeTypeID = CompiledReportTypeIDs[ReportType];
int CompiledReportTimeID = -1;
cmdtext = "SELECT CompiledReportTimeID FROM Reporting_CompiledReportTime WHERE CompiledReportTimeTypeID = @CompiledReportTimeTypeID AND CompiledReportTime = @ReportTime";
cmd = new SqlCommand(cmdtext, conn);
cmd.Parameters.Add(new SqlParameter("CompiledReportTimeTypeID", CompiledReportTimeTypeID));
cmd.Parameters.Add(new SqlParameter("ReportTime", ReportTime));
reader = cmd.ExecuteReader();
while (reader.Read())
{
    CompiledReportTimeID = Convert.ToInt32(reader.GetValue(0));
}
reader.Close();

CompiledReportTypeIDs是一个字典,根据在方法开始时提供的字符串参数来获取正确的CompiledReportTimeTypeID。ReportTime是之前提供的DateTime。
编辑:我将删除该表并重新创建它,将ReportData字段更改为nvarchar(MAX),而不是ntext,只是为了排除SQL数据类型问题。这是一个很小的可能性,我会再次更新我的发现。
编辑2:将表中的字段更改为nvarchar(max)没有影响。我还尝试使用output = cmd.ExecuteScalar()。ToString()作为替代方式,但没有影响。我正在尝试查看SqlDataReader的最大大小。当我从SQL Mgmt Studio复制文本值时,保存在记事本中的文件大小仅为43Kb。为了验证这一点,我拉出了一个已知工作ID的报告(较小的报告),当我直接从Visual Studio中复制该值并将其转储到记事本中时,它约为5MB!这意味着这些大报告可能在一个nvarchar(max)字段中达到约20MB的范围。

编辑3: 我重新启动了所有设备,包括我的开发IIS服务器、SQL服务器和我的笔记本电脑。现在似乎可以工作了。但这并不是为什么会发生这种情况的答案。我将保持这个问题的开放状态,等待解释,并将其中一个标记为答案。

编辑4: 话虽如此,我进行了另一个测试,未更改任何内容,但相同的异常返回了。我真的开始认为这是一个SQL问题。我正在更新此问题的标签。我创建了一个单独的应用程序,运行完全相同的查询,它可以正常运行。

编辑5: 我按照下面一个答案所述实现了顺序访问。一切都被正确地读入流中,但当我尝试将其写入字符串时,仍然出现内存不足异常。这是否表明获取连续内存块的问题?以下是我实现缓冲的方式:

                reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess);
            long startIndex = 0;
            long retval = 0;
            int bufferSize = 100;
            byte[] buffer = new byte[bufferSize];
            MemoryStream stream = new MemoryStream();
            BinaryWriter writer = new BinaryWriter(stream);
            while (reader.Read())
            {
                // Reset the starting byte for the new CLOB.
                startIndex = 0;

                // Read bytes into buffer[] and retain the number of bytes returned.
                retval = reader.GetBytes(0, startIndex, buffer, 0, bufferSize);

                // Continue while there are bytes beyond the size of the buffer.
                while (retval == bufferSize)
                {
                    writer.Write(buffer);
                    writer.Flush();

                    // Reposition start index to end of last buffer and fill buffer.
                    startIndex += bufferSize;
                    retval = reader.GetBytes(0, startIndex, buffer, 0, bufferSize);
                }

                //output = reader.GetString(0);
            }
            reader.Close();
            stream.Position = 0L;
            StreamReader sr = new StreamReader(stream);
            output = sr.ReadToEnd(); <---- Exception happens here
            //output = new string(buffer);

编辑6: 此外,当发生OOM异常时,我看到持有正在运行的方法的IIS工作进程几乎达到了700MB。这是在IIS Express上运行而不是生产服务器上的完整IIS。这是否与之有关?另外,当我调用Byte [] data = stream.ToArray()时,有时也会出现OOM。我想我真正需要的是一种给该进程更多内存的方法,但我不知道在哪里配置。
编辑7: 我刚刚将我的开发服务器从在本地机器上使用的IIS Express更改为内置的Visual Studio Web服务器。OOM异常现在已经消失了。我真的认为这是分配连续块内存的问题,由于某种原因,IIS Express无法分配它。现在它运行得很好,我将发布到运行常规IIS7的2008R2上的完整服务器上,以查看情况如何。

1
你应该同时包含完整的错误信息。 - Austin Salonen
1
返回的字符串有多大?换句话说,ReportData有多大? - Chris Dunaway
显示异常的完整堆栈跟踪。 - Jim Mischel
1
你可以尝试在 SqlCommandSqlDataReaderSqlConnection 对象周围使用 using 块。 - John Saunders
1
在SSMS中复制大字符串并不是一个可靠的方法,因为SSMS设置了返回字符串大小的上限。如果您想要在SSMS中可靠地测量字符串长度,请在查询中添加 Len(col) - RBarryYoung
显示剩余6条评论
3个回答

13
你应该尝试通过在执行读取器时指定命令行为来按顺序读取数据。根据文档,使用SequentialAccess检索大量值和二进制数据。否则,可能会发生OutOfMemoryException并关闭连接
虽然顺序访问通常用于大型二进制数据,但根据MSDN文档,您也可以使用它来读取大量字符数据。
访问BLOB字段中的数据时,请使用DataReader的GetBytes或GetChars类型访问器,它们将数据填充到数组中。您也可以使用GetString来处理字符数据;但是,为了节省系统资源,您可能不想将整个BLOB值加载到单个字符串变量中。相反,您可以指定要返回的特定数据缓冲区大小和从返回数据读取的第一个字节或字符的起始位置。 GetBytes和GetChars将返回表示返回的字节数或字符数的长整型值。如果向GetBytes或GetChars传递null数组,则返回的长整型值将是BLOB中的总字节数或字符数。您还可以选择在数组中指定索引作为正在读取的数据的起始位置。
MSDN示例显示如何执行顺序访问。我认为您可以使用GetChars方法来读取文本数据。

缓冲工作得很好,但是当我尝试将所创建的流写入字符串时,我会遇到OOM异常。当我使用.GetChars()而不是.GetBytes()时,由于我正在尝试获取字段的长度以实例化包含结果的字符数组,因此我会立即遇到OOM。 - Bill Sambrone
您可以使用 DATALENGTH 作为结果集的一部分返回总长度,然后使用该值在读取结果之前构造数组以分块读取。 - Oppositional
我会尝试这个!我下周才能回到办公室,但我会在星期一早上第一件事就去尝试它。 - Bill Sambrone
我认为你可能需要使用缓冲区读取流,而不是使用ReadToEnd()。祝好运。 - Oppositional
接受这个答案,因为它最接近实际情况。事实证明,IIS Express是罪魁祸首,但分块返回字符串是正确的方法。 - Bill Sambrone
显示剩余2条评论

7
基本上,一个System.OutOfMemoryException并不仅仅是在你的内存用完时发生,而是当你无法为一个对象分配单个连续的内存块时发生。当尝试创建非常大的数组、加载大型位图对象或者创建大型XmlDocuments时,通常会看到这个错误... ArrayString通常需要被连续地分配,即无法被分成几段并分配到内存中的空白区域。
这可能不是SQL问题,而更多是SqlReader尝试分配足以包含一行数据的字符串时出现的问题。
你提到在重新启动后它能正常运行,所以我们假设你的代码基本正确(可能仍然可以优化为暴露数据作为流而不是缓冲记录集),当前症状是环境问题。一个刚刚重启的机器可能没有那么多的内存碎片,但随着您的使用,内存会碎片化,错误就会返回...
你也许可以通过尽可能关闭其他程序,并在具有错误的代码之前添加强制GC.Collect(GC.MaxGeneration) (参考)来证明连续内存理论。这并不是一定的,因为分配给您的进程的内存仍然可能是碎片化的。
我认为流式传输值可能是防止错误发生的方法,最好避免尝试将所有内容缓冲到字符串中。这样做的缺点是,在结果被流传/被程序的其余部分消耗时,你将保持数据库连接开启,这会带来自己的开销。我不知道你的代码需要对结果做什么,但如果它需要使用String实例,你可能需要扩展进程可用的内存(有多种方法可以帮助,但可能超出了范围 - 留下评论,如果需要,我可以添加到这个答案中)。

我试图强制进行垃圾收集,但没有成功(虽然是个好主意!)。我根据Oppositional的回答实现了缓冲,并且在尝试将流转储到字符串时出现了OOM。这让我同意存在内存分配问题。你有一个链接指南可以用来扩展可用进程内存吗?当然,我应该找到一种无需这样做就能完成需要的方法,但现在类似这样的解决办法会起作用。 - Bill Sambrone
我真的不建议尝试调整环境设置来使内存工作。你有将数据流式传输到目标的选项吗?例如,想象一下你是两个水坝之间的泵站,你不能在将水抽入另一个水坝之前将一个水坝中的所有水都抽干。你必须一次刷新一个缓冲区。我认为问题在于试图将所有数据转储到一个字符串中-最好避免这样做。 - Neil Fenwick

0

这里只是猜测。

cmd.Parameters.Add(new SqlParameter("CompiledReportTimeID", CompiledReportTimeID));

你错过了@符号。所以它用id替换了CompiledReportTimeID的两个实例,因为它们相等,所以你得到了所有的结果?


如果我在SQL参数的第一个参数中添加@,我仍然会得到相同的结果。Stack Overflow以有趣的方式格式化了CompiledReportTimeID,但它只是一个int。我将参数命名为与int相同的名称,这也恰好是表中字段的名称。可能不是最佳实践以这种方式命名它(一旦我让这个愚蠢的东西工作起来,我会稍后修复它)。 - Bill Sambrone

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接