【问题标题】:How to export large DataGridView to Excel without 'Out of Memory' exception?如何在没有“内存不足”异常的情况下将大型 DataGridView 导出到 Excel?
【发布时间】:2021-01-18 17:58:15
【问题描述】:

我的问题是我需要将 90.000+ 行/143 列从 DataGridView(从 MySQL 数据库填充)导出到 Excel。无论我做什么,我总是在 45k-60k 行之后出现“System.Out.Of.Memory”异常,具体取决于解决方案。我知道可能会有诸如“为什么需要这么多行”之类的问题,我会回答“不幸的是,这是需要的”。我搜索了有关我的问题的论坛,但没有找到任何可行的解决方案。我尝试将 StreamWriter 转换为 CSV,分块处理数据(下面的解决方案),还使用多个 Excel 或 CSV 文件,但没有任何帮助。每次执行期间,当我尝试使用较少的行数时,RAM 使用量都会增长,并且在成功导出后不会释放。我不知道在成功执行后何时以及是否释放 RAM。

测试机器有 8 GB 的 RAM,并且使用的是 Windows 10。不幸的是,我无法使用 MySQL 服务器的资源在那里处理 Excel 导出,然后输出文件以与用户共享,所以我需要使用客户端机器。

以下是我最新的不起作用的解决方案,其中数据从 DGV 读取并以块的形式写入 Excel。改变块的大小并不会减少内存消耗,如果我把它变小(比如 500 到 2000),唯一的影响就是导出速度越来越慢。

Imports Excel = Microsoft.Office.Interop.Excel

    Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click

        If DataGridView1.Rows.Count > 0 Then
            Dim filename As String = ""
            Dim SV As SaveFileDialog = New SaveFileDialog()
            SV.FileName = "Worst_cells"

            SV.Filter = "xlsx files (*.xlsx)|*.xlsx|All files (*.*)|*.*"
            SV.FilterIndex = 1
            SV.RestoreDirectory = True

            Dim result As DialogResult = SV.ShowDialog()

            If result = DialogResult.OK Then

                filename = SV.FileName

                Dim XCELAPP As Microsoft.Office.Interop.Excel.Application = Nothing
                Dim XWORKBOOK As Microsoft.Office.Interop.Excel.Workbook = Nothing
                Dim XSHEET As Microsoft.Office.Interop.Excel.Worksheet = Nothing
                Dim misValue As Object = System.Reflection.Missing.Value
                XCELAPP = New Excel.Application()
                XWORKBOOK = XCELAPP.Workbooks.Add(misValue)
                XCELAPP.DisplayAlerts = False
                XCELAPP.Visible = False
                XSHEET = XWORKBOOK.ActiveSheet

                XSHEET.Range("B1").ColumnWidth = 11

                For Each column As DataGridViewColumn In DataGridView1.Columns
                    XSHEET.Cells(1, column.Index + 1) = column.HeaderText
                Next

                Dim rowCnt As Integer = DataGridView1.Rows.Count
                Dim colCnt As Integer = DataGridView1.Columns.Count

                Dim batchSize As Integer = 10000
                Dim currentRow As Integer = 0
                Dim valueObjArray As Object(,) = New Object(batchSize - 1, colCnt - 1) {}

                While currentRow < rowCnt
                    Dim rowIndex As Integer = 0

                    While rowIndex < batchSize AndAlso currentRow + rowIndex < rowCnt

                        For colIndex As Integer = 0 To colCnt - 1
                            valueObjArray(rowIndex, colIndex) = DataGridView1(colIndex, currentRow + rowIndex).Value
                        Next

                        rowIndex += 1
                    End While
                    Dim colName As String = ColumnLetter(colCnt)

                    If (currentRow + batchSize + 1) < rowCnt Then
                        XSHEET.Range("A" + (currentRow + 2).ToString(), colName + (currentRow + batchSize + 1).ToString()).Value2 = valueObjArray
                    Else
                        XSHEET.Range("A" + (currentRow + 2).ToString(), colName + (rowCnt + 1).ToString()).Value2 = valueObjArray
                    End If
                    XWORKBOOK.SaveAs(filename)
                    currentRow += batchSize
                End While

                XCELAPP.DisplayAlerts = True

                XWORKBOOK.Close(False)
                XCELAPP.Quit()

                Try
                    System.Runtime.InteropServices.Marshal.ReleaseComObject(XSHEET)
                    System.Runtime.InteropServices.Marshal.ReleaseComObject(XWORKBOOK)
                    System.Runtime.InteropServices.Marshal.ReleaseComObject(XCELAPP)
                Catch
                End Try

                GC.Collect()
                GC.WaitForPendingFinalizers()
                GC.Collect()
                GC.WaitForPendingFinalizers()
            
            End If
        End If

    End Sub

【问题讨论】:

  • 您是否尝试过 Excel 的 Oledb 提供程序?网格中怎么可能需要 90,000 行。用户不能查看 90,000 行。
  • 我还没有检查过 Oledb。你认为值得检查吗?关于您如何需要 90k 行的问题:假设您有一个包含 90k+ 个单元的移动网络,并且网络存在一个重大问题,有成千上万的客户投诉。在这种情况下,工程师需要能够识别最大的单元贡献者,还需要能够对单元列表进行后处理,以发现流量变化、KPI 降级等。
  • 不确定是否有区别,但您没有释放任何 Range 对象。通常它会类似于Range r = ...; r.Value2 = ...; Marshal.ReleaseComObject(r); 应避免在代码中使用双点。
  • DGV 是否绑定到DataTable?如果是这样,请考虑从 DataRow.ItemArray 中提取值,而不是通过 DGV Cell.Value 来避免复制数据。
  • 您不回应提供更多信息的请求。你还在寻找解决这个问题的方法吗?我怀疑问题是由于迭代 DGV 行导致它们变得不共享并消耗大量内存。这可以通过在行集合上运行一个空的For Each 循环来验证。

标签: excel vb.net winforms


【解决方案1】:

确认在Range 对象上使用Marshal.ReleaseComObject(...); 可修复OutOfMemory 异常。下面是用于测试的代码。您将不得不用自己的代码替换几行代码。代码的第一部分是生成大量随机数据。第二部分以块的形式写出DataTable 行。通过设置xls.Visible = true;,您可以通过Excel窗口底部的进度条看到Excel处理每个块。

public static void TestExcel(String filename, int maxRows) {
    int numCols = 100;
    Type[] availTypes = new Type[] { typeof(bool), typeof(int), typeof(double), typeof(String), typeof(DateTime) };
    Type[] types = new Type[numCols];
    Random r = new Random();
    DataTable table = new DataTable();
    for (int i = 0; i < numCols; i++) {
        Type ty = availTypes[r.Next(availTypes.Length)];
        types[i] = ty;
        table.Columns.Add("Col" + i, ty);
    }
    DateTime minDate = new DateTime(1901,01,01);
    for (int i = 0; i < maxRows; i++) {
        Object[] arr2 = new Object[numCols];
        for (int j = 0; j < numCols; j++) {
            Object o = null;
            Type ty = types[j];
            if (ty == typeof(bool))
                o = (r.Next(2) == 0 ? false : true);
            else if (ty == typeof(int))
                o = r.Next(int.MinValue, int.MaxValue);
            else if (ty == typeof(double))
                o = r.NextDouble();
            else if (ty == typeof(String)) {
                int len = r.Next(0, 256);
                char c = ExcelUtils.ToLetters(r.Next(26))[0];
                o = new String(c, len);
            }
            else if (ty == typeof(DateTime))
                o = minDate.AddSeconds(r.Next(int.MaxValue));

            arr2[j] = o;
        }
        table.Rows.Add(arr2);   
    }

    XlFileFormat format = XlFileFormat.xlWorkbookDefault;
    if (File.Exists(filename))
        File.Delete(filename);

    DateTime utcNow = DateTime.UtcNow;
    Workbook wb = null;
    Worksheet ws = null;

    Excel xls = new Excel(); // replace with Application.Excel
    xls.Visible = true;
    xls.DisplayAlerts = false;
    if (xls.Workbooks.Count == 0)
        wb = xls.Workbooks.Add();
    else
        wb = xls.Workbooks[1];

    if (wb.Worksheets.Count == 0)
        ws = wb.Worksheets.Add();
    else
        ws = wb.Worksheets[1];

    int maxCellsPerInsert = 1000000; // inserting too much data at once results in an out of memory exception
    int batchSize = maxCellsPerInsert / table.Columns.Count; 
    int fromIndex = 0;
    int n = table.Rows.Count;
    while (fromIndex < n) {
        int toIndex = Math.Min(fromIndex + batchSize, n);
        Range r0 = ws.get_Range("A" + (fromIndex + 1));
        Object[,] arr = DataTableUtils.ToObjectArray(table, false, true, null, fromIndex, toIndex); // replace with your own arr[,] code
        Range r00 = r0.Resize(arr.GetLength(0), arr.GetLength(1));
        r00.Value = arr;
        r00.Dispose(); // replace with Marshal.Release
        r0.Dispose(); // replace with Marshal.Release
        fromIndex = toIndex;
    }

    wb.SaveAs(filename, format, AccessMode: XlSaveAsAccessMode.xlNoChange);
    wb.Close(false, filename, null);
    xls.Quit(false, false);

    long length = FileEx.GetFileLengthFast(filename);
    double totalSeconds = (DateTime.UtcNow - utcNow).TotalSeconds;
    String message = "NumRows: " + maxRows + " duration: " + Math.Round(totalSeconds, 1) + " seconds. File length: " + length + "  rows/sec: " + Math.Round(1.0* maxRows / totalSeconds);
}

【讨论】:

  • 我昨天尝试了您在我原来的帖子中来自 cmets 的关于释放 Range 对象的建议(见下文)。不幸的是,这并没有改变内存消耗,并以抛出异常再次结束。我将尝试根据我的需要修改您的新代码。谢谢!我更改的代码没有帮助(行由;分隔):Dim r As Excel.Range = XSHEET.Range("A" + (currentRow + 2).ToString(), colName + (currentRow + batchSize + 1).ToString()); r.Value2 = valueObjArray; System.Runtime.InteropServices.Marshal.ReleaseComObject(r)
  • @Ivaylo 编辑您的原始问题并放置整个更新的代码。您的原始代码在每个块之后调用SaveAs,这不是必需的。
  • 此外,最大行批量大小应为 6,993(基于 100 万个单元格/143 列)。如果您的测试仍在使用 10,000,那么这将解释异常。
  • 代码中还有其他地方没有释放对象,例如:XSHEET.Range("B1").ColumnWidth = 11XSHEET.Cells(1, column.Index + 1)
  • 我将发布更新的代码作为附加答案。似乎问题出在 DGV 本身,尤其是读取它的值,这些值显然保留在内存中。导出 DGV 后面的数据表解决了这个问题。我还没有测试过只保存并避免保存为。我的测试在处理 Excel 范围时没有显示任何变化,但我将它们保存在代码中以防万一。
【解决方案2】:

经过大量测试和其他用户(尤其是 Loathing)的帮助后,我发现使用标准方法无法将大型 DataGridView 导出到 Excel 而不抛出异常内存不足(我没有测试过 Oledb 或 Xml) .对我来说,一个可行的解决方案是导出非常数据表,它是 DGV 的数据源。请注意,当您在 DGV 填充数据之后在同一过程中执行导出时,这种解决方案是合适的。否则,如果您想在之后导出数据,例如单击按钮后,则需要将数据表声明为 Public,我不会这样做。似乎直接从 DGV 导出时发生的内存中断是在从 DGV 读取数据块然后将它们复制到 Excel 范围后,这些块仍保留在内存中(我不知道为什么会这样)。此解决方案中的一个关键是从数据表读取然后写入 Excel 是分批完成的。我的情况是我需要将 90.000+ 行导出到 Excel。对于 90.000 行,我使用了 25.000 行的批量大小,这对于 90k 单元格效果很好。但是对于我测试的 270k 或 360k 等大量行,我使用了 10.000 行的较小批量值。这是因为我的 WinForm 已经通过显示大型 DGV 来增加内存负担。因此,如果在 DGV 中有 270k 行,那么以 25.000 的批次导出会出现异常。但是对于 10.000 这很好,尽管导出时间更长。关于导出时间:90k 行和 25k 批次在我的环境中占用了 1 分 05 秒;批量为 10k 的 270k 行耗时 9 分钟,批量为 10k 的 360k 行耗时 15 分钟。

        Dim filename As String = ""
        Dim SV As SaveFileDialog = New SaveFileDialog()
        SV.FileName = "Excel export"

        SV.Filter = "xlsx files (*.xlsx)|*.xlsx|All files (*.*)|*.*"
        SV.FilterIndex = 1
        SV.RestoreDirectory = True

        Dim result As DialogResult = SV.ShowDialog()

        If result = DialogResult.OK Then

            filename = SV.FileName

            Dim xcelApp As Microsoft.Office.Interop.Excel.Application = Nothing
            Dim xWorkbook As Microsoft.Office.Interop.Excel.Workbook = Nothing
            Dim xSheet As Microsoft.Office.Interop.Excel.Worksheet = Nothing
            Dim misValue As Object = System.Reflection.Missing.Value
            xcelApp = New Excel.Application()
            xWorkbook = xcelApp.Workbooks.Add(misValue)
            xcelApp.DisplayAlerts = False
            xcelApp.Visible = False
            xSheet = xWorkbook.ActiveSheet

            xSheet.Range("B1").ColumnWidth = 11

            'export column headers to Excel is shown below
            Dim i As Integer = 1
            For Each column As DataColumn In dataTab.Columns
                xSheet.Cells(1, i) = column.ColumnName
                i = i + 1
            Next

            Dim rowCnt As Integer = dataTab.Rows.Count
            Dim colCnt As Integer = dataTab.Columns.Count

            Dim batchSize As Integer = 10000 'export will de done in batches
            Dim startRow As Integer = 0 'starting row for each batch
            Dim valueObjArray As Object(,) = New Object(batchSize - 1, colCnt - 1) {}
            'object array with a size of the batch x number of columns

            While startRow < rowCnt 'iterate until max row number is exceeded
                Dim rowIndex As Integer = 0

                'iterate each until row index reaches batch size
                While rowIndex < batchSize AndAlso startRow + rowIndex < rowCnt

                    'iterate each cell in the row until last column is reached
                    'and assign the value of the cell in datatable to the object array
                    For colIndex As Integer = 0 To colCnt - 1
                        valueObjArray(rowIndex, colIndex) =
                            dataTab.Rows(startRow + rowIndex).Item(colIndex)
                    Next

                    rowIndex += 1 'go to new row
                End While

                Dim colName As String = ColumnLetter(colCnt) 'transform column index to Excel column name

                '("if" below) assign object array to Excel range if batch range + starting row is less than total rows
                If (startRow + batchSize + 1) < rowCnt Then
                    Dim r As Excel.Range = xSheet.Range("A" + (startRow + 2).ToString(),
                                                        colName + (startRow + batchSize + 1).ToString())
                    r.Value2 = valueObjArray
                    System.Runtime.InteropServices.Marshal.ReleaseComObject(r) 'this might be not needed
                Else 'if batch range + starting row is more than total rows assign to Excel range only the remaining rows
                    Dim r As Excel.Range = xSheet.Range("A" + (startRow + 2).ToString(),
                                                        colName + (rowCnt + 1).ToString())
                    r.Value2 = valueObjArray
                    System.Runtime.InteropServices.Marshal.ReleaseComObject(r) 'this might be not needed
                End If
                xWorkbook.SaveAs(filename)
                startRow += batchSize
            End While

            xcelApp.DisplayAlerts = True

            xWorkbook.Close(False)
            xcelApp.Quit()

            Try
                System.Runtime.InteropServices.Marshal.ReleaseComObject(xSheet)
                System.Runtime.InteropServices.Marshal.ReleaseComObject(xWorkbook)
                System.Runtime.InteropServices.Marshal.ReleaseComObject(xcelApp)
            Catch
            End Try

            GC.Collect()
            GC.WaitForPendingFinalizers()
            GC.Collect()
            GC.WaitForPendingFinalizers()

        End If

列索引到Excel列名的转换函数如下。

    Function ColumnLetter(ColumnNumber As Long) As String
        Dim n As Long
        Dim c As Byte
        Dim s As String

        n = ColumnNumber
        Do
            c = ((n - 1) Mod 26)
            s = Chr(c + 65) & s
            n = (n - c) \ 26
        Loop While n > 0
        ColumnLetter = s
    End Function

【讨论】:

    猜你喜欢
    • 2018-08-24
    • 2012-08-06
    • 2014-07-25
    • 2012-05-13
    • 2023-03-11
    • 2014-07-18
    • 2016-10-30
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多