iT邦幫忙

0

匯出壓縮檔ZipArchive_System.OutOfMemoryException

  • 分享至 

  • twitterImage

請教一下各位先進:
  我需要將server上的檔案整批匯出一個壓縮檔給user操作使用,整批實體檔案大小約1.5G以下,整批匯出時遇到已發生類型 'System.OutOfMemoryException' 的例外狀況,查看server的記憶體狀況,落在12GB以上,server共有16G記憶體可用,將server重開後,佔用記憶體約8.5GB,執行整批匯出功能測試記憶體並沒有往上衝至12G,最高大約落在9.5GB,程式依然報System.OutOfMemoryException,初步判斷不是server 記憶體不夠問題。
想請問這樣整批檔案(例如有100個檔案)匯整成一個壓縮檔匯出的功能,有檔案大小的限制嗎?如果有限制,是否有什麼方法突破檔案大小的限制呢?
先謝謝了~

public ActionResult ExportPapersZip(PaperViewModel PV)
{
    var ms = new MemoryStream();
    FileStream sFile;
    string FileName = "";
    byte[] toBytes;
    List<ConferencePaperFilesViewModel> cpfVM = new List<ConferencePaperFilesViewModel>();
    cpfVM = fileSrv.GetConferencePaperFilesList(PV.ConferenceId, 5);
    using (var archive = new System.IO.Compression.ZipArchive(ms, ZipArchiveMode.Create, true))
    {
        cpfVM.ForEach(x =>
        {
            try
            {
                string MemberName = memberSrv.GetMemberName(x.MemberId);
                string TrackName = conferenceSrv.GetTrackTitle(x.TrackId);
                string SubFileName = Path.GetExtension(x.OriginalFileName).ToLower();
                sFile = new FileStream(x.SysPath + x.OriginalFileName, FileMode.Open, FileAccess.Read);
                toBytes = fileSrv.BinaryReadToEnd(sFile);
                if(x.CleanTitle == null)
                {
                    FileName = x.paperId.ToString();
                }
                else
                {
                    FileName = x.CleanTitle;
                }
                var zipEntry = archive.CreateEntry(FileName + "_F" + SubFileName, System.IO.Compression.CompressionLevel.Fastest);
                using (var zipStream = zipEntry.Open())
                {
                    zipStream.Write(toBytes, 0, toBytes.Length);
                }
            }
            catch (Exception)
            {
                throw;
            }
        });
    }//end using (var archive = new System.IO.Compression.ZipArchive(ms, ZipArchiveMode.Create, true))
    return File(ms.ToArray(), "application/zip", "Files.zip");
}

MVC 版本是 .NET Core 嗎?
leo226 iT邦新手 4 級 ‧ 2020-08-19 13:49:09 檢舉
版本是VS 的MVC 5,不是.NET Core
leo226 iT邦新手 4 級 ‧ 2020-08-19 14:28:37 檢舉
目前在做測試,取出前200份檔案,匯出來的壓縮檔大小為200MB,平均一個檔案約1MB,若我設定取出前210檔案,系統就報錯System.OutOfMemoryException,但還是不知道瓶頸卡在什麼環節,不知道有沒有什麼方法可以解決的?
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 個回答

1
淺水員
iT邦大師 6 級 ‧ 2020-08-18 19:10:32

雖然我對微軟不熟,不過有些概念應該是共通的。
上面的程式因為是用 MemoryStream 暫存壓縮後的結果,太吃記憶體。
建議可以參考 ZipArchive 說明裡面的第一個範例,先把檔案寫到檔案中,再把檔案送給使用者。
另外從範例中可以看到 StreamWriter 可以分段寫入的,所以也不需要一次讀取整個要壓縮的檔案,改為分段讀取送給 StreamWriter 即可。
這樣應該可以避免佔用大量記憶體。

看更多先前的回應...收起先前的回應...
leo226 iT邦新手 4 級 ‧ 2020-08-19 12:12:03 檢舉

試了一個早上的修改,目前還是不知道該如何修改~
我的實體檔案是PDF檔,舊方法是將PDF檔案讀取後,全部轉成Binary格式,放在MemoryStream,最後再全部輸出打包成zip檔(檔案少時功能是可以正常輸出的)。
參考範例輸出的壓縮檔案,檔案是損毀無法開啓的,可能是我寫法錯誤,參考的範例我不知道如何改寫成可以正常整批檔案輸出的功能~

using (FileStream zipToOpen = new FileStream(x.SysPath + x.OriginalFileName, FileMode.Open, FileAccess.Read))
{
    toBytes = fileSrv.BinaryReadToEnd(zipToOpen);
    using (ZipArchive archive = new ZipArchive(zipToOpen, ZipArchiveMode.Create, true))
    {
        ZipArchiveEntry readmeEntry = archive.CreateEntry(FileName + "_F" + SubFileName, System.IO.Compression.CompressionLevel.Fastest);
        using (StreamWriter writer = new StreamWriter(readmeEntry.Open()))
        {
            writer.WriteLine("Information about this package.");
            writer.WriteLine("========================");
        }

    }
}
淺水員 iT邦大師 6 級 ‧ 2020-08-19 15:44:39 檢舉

小碼農米爾 Mir 的回答,直接寫入網路串流更方便

下面這只是我改之前的,應該是用不到了
寫出來主要是說明邏輯的部分
而且我手邊也沒有微軟的環境能測試,所以可能還要修一下

//建立暫存的 zip 檔案,這邊的 tmp.zip 再自己改成不會重複的
using (FileStream zipToWrite = new FileStream("tmp.zip", FileMode.Open))
{
    //建立 ZipArchive 物件,並指定寫入到 zipToWrite
    using (ZipArchive archive = new ZipArchive(zipToWrite, ZipArchiveMode.Update))
    {
        //依序將讀取每個檔案,並寫入
        cpfVM.ForEach(iFile => {
            string fpath = iFile.SysPath + iFile.OriginalFileName;
            //讀取檔案為 binary
            byte[] readBytes = File.ReadAllBytes(fpath);
            //在 zip 檔案內建立同樣名稱的檔案
            ZipArchiveEntry zipEntry = archive.CreateEntry(fpath);
            //寫入 zip 檔
            using (var zipStream = zipEntry.Open())
            {
                zipStream.Write(readBytes, 0, readBytes.Length);
            }
        });
    }
}
leo226 iT邦新手 4 級 ‧ 2020-08-20 11:26:38 檢舉

目前改成以下樣子,想請問最後return的部份該怎麼改,之前是寫入MemoryStream再輸出,現在如果改成FileStream,那return輸出的部份該怎麼修正呢?

return File(ms.ToArray(), "application/zip", "Files.zip");
var ms = new MemoryStream();
string FileName = "";
byte[] toBytes;
List<ConferencePaperFilesViewModel> cpfVM = new List<ConferencePaperFilesViewModel>();
cpfVM = fileSrv.GetConferencePaperFilesList(PV.ConferenceId, 5);
try
{
    //依序將讀取每個檔案,並寫入
    cpfVM.ForEach(x =>
    {
        //建立暫存的 zip 檔案,這邊的 tmp.zip 再自己改成不會重複的
        //FileStream – 用於讀取和寫入檔案。File.Open(filename, FileMode.OpenOrCreate)
        using (FileStream zipToOpen = new FileStream(x.SysPath + x.OriginalFileName, FileMode.Open, FileAccess.ReadWrite))
        {
            string SubFileName = Path.GetExtension(x.OriginalFileName).ToLower();
            //sFile = new FileStream(x.SysPath + x.OriginalFileName, FileMode.Open, FileAccess.Read);
            //toBytes = fileSrv.BinaryReadToEnd(sFile);
            if (x.CleanTitle == null)
            {
                FileName = x.paperId.ToString();
            }
            else
            {
                FileName = x.CleanTitle;
            }
            //toBytes = fileSrv.BinaryReadToEnd(zipToOpen);
            toBytes = System.IO.File.ReadAllBytes(x.SysPath + x.OriginalFileName);
            //建立 ZipArchive 物件,並指定寫入到 zipToWrite
            //ZipArchive – 用於建立和擷取 zip 封存中的項目。
            using (ZipArchive archive = new ZipArchive(zipToOpen, ZipArchiveMode.Update, true))
            {
                //在 zip 檔案內建立同樣名稱的檔案
                //ZipArchiveEntry – 用於表示壓縮檔。
                ZipArchiveEntry readmeEntry = archive.CreateEntry(FileName + "_F" + SubFileName);
                //寫入 zip 檔
                using (var zipStream = readmeEntry.Open())
                {
                    zipStream.Write(toBytes, 0, toBytes.Length);
                }
            }
        }
    });
}
catch (Exception)
{
    throw;
}
return File(ms.ToArray(), "application/zip", "Files.zip");

以上程式目前執行有出現"找不到中央目錄記錄的結尾"的Error,應該是最後return時的錯誤,FileStream的部份最後該怎麼return呢?。

另外請問那一段程式碼是指"直接寫入網路串流"呢?謝謝

淺水員 iT邦大師 6 級 ‧ 2020-08-20 22:07:44 檢舉

抱歉,語法上的問題我目前比較難幫忙,因為手邊真的沒環境測試。
其他的方式我還是提一下,你可以當作參考,要不要採用再自己衡量。
其實對於大檔案傳輸的部分,有種作法是把資料切分成幾段,然後前端依序拿到這些資料後,再拼湊回完整的資料。
這樣做的好處是,當我們把原本一次請求分散成多次請求,如果其中某次請求發生錯誤,只需要重新請求錯誤的那個區塊就可。就不會中間因為網路問題出錯就要全部重來。
至於缺點的部分是,這種做法會吃使用者瀏覽器的記憶體,所以檔案大小無法超過使用者的記憶體大小。(類似 mega.nz)

File 傳入 zip 路徑就可以

return File(x.SysPath + x.OriginalFileName, "application/zip");

FileStream 會在下面這個路徑產生檔案

x.SysPath + x.OriginalFileName

另外 淺水員 有發現 cpfVM.ForEach 跑到外面了
ZipArchive 和 FileStream 要放在 ForEach 外,才不會重複產生 zip 物件。

2
小碼農米爾
iT邦高手 1 級 ‧ 2020-08-19 15:26:53

可以將寫入記憶體的部分,改成寫入網路串流,這樣就不會超出記憶體的使用限制

不過 MVC5 沒有內建的功能可以用,所以需要做一些修改

1. 新增 PushStreamResult

public class PushStreamResult : FileResult
{
    private readonly Action<Stream> stream;

    public PushStreamResult(string fileName, string contentType, Action<Stream> stream)
        : base(contentType)
    {
        this.stream = stream;
        this.FileDownloadName = fileName;
    }

    protected override void WriteFile(HttpResponseBase response)
    {
        response.Buffer = false;
        stream(response.OutputStream);
    }
}

參考文章:
https://stackoverflow.com/questions/943122/writing-to-output-stream-from-action

2. 新增 ZipWrapStream

public class ZipWrapStream : Stream
{
    private readonly Stream stream;

    private long position = 0;

    public ZipWrapStream(Stream stream)
    {
        this.stream = stream;
    }

    public override bool CanSeek { get { return false; } }

    public override bool CanWrite { get { return true; } }

    public override long Position
    {
        get { return position; }
        set { throw new NotImplementedException(); }
    }

    public override bool CanRead { get { throw new NotImplementedException(); } }

    public override long Length { get { throw new NotImplementedException(); } }

    public override void Flush()
    {
        stream.Flush();
    }

    public override int Read(byte[] buffer, int offset, int count)
    {
        throw new NotImplementedException();
    }

    public override long Seek(long offset, SeekOrigin origin)
    {
        throw new NotImplementedException();
    }

    public override void SetLength(long value)
    {
        throw new NotImplementedException();
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        position += count;
        stream.Write(buffer, offset, count);
    }
}

參考文章:
https://stackoverflow.com/questions/16585488/writing-to-ziparchive-using-the-httpcontext-outputstream

3. 調整 Controller

改成回傳 PushStreamResult 並使用 ZipWrapStream 修補 OutputStream

public ActionResult Zip()
{
    var funcZIP = (Action<Stream>)((stream) =>
    {
        using (var archive = new ZipArchive(stream, ZipArchiveMode.Create))
        {
            var zipEntry = archive.CreateEntry(@"aaa.txt", CompressionLevel.Fastest);
            using (var zipStream = zipEntry.Open())
            {
                var bytes = System.IO.File.ReadAllBytes(@"D:\專案\aaa.txt");
                zipStream.Write(bytes, 0, bytes.Length);
            }
        }
    });
    return new PushStreamResult("aaa.zip", "application/zip",
        stream => funcZIP(new ZipWrapStream(stream)));
}

大致上就是寫入 Zip 時,將檔案直接寫到 OutputStream 串流裡,這樣就不會占用記憶體空間,適用於大檔案的下載。

看更多先前的回應...收起先前的回應...
leo226 iT邦新手 4 級 ‧ 2020-08-19 15:59:24 檢舉

我在新增 ZipWrapStream時遇到未實作繼承的抽象成員'Stream.Position.set'的error。
程式碼裡看似有實作,不過錯報的部份不知道如何修補?

public override long Position
{
    get => position; set => throw new NotImplementedException();
}

應該是不支援屬性的 => 語法,我調整回覆了

leo226 iT邦新手 4 級 ‧ 2020-08-19 17:01:47 檢舉

測試完了,好像更慘,原版程式早上測試200個檔案系統才爆掉System.OutOfMemoryException。
改成前輩的方法,下載100個檔案可以,但150個檔案時就無法下載,但不會報錯,會如下圖所示,在下載到118MB時就會卡住一直loading旋轉直到顯示失敗—網路錯誤後停止,好像極限只到118MB?
原始程式下載200個檔案,壓縮檔大小約200MB左右。
以上為目前測試狀況~
感謝大大提供方法測試~

int count = 1;
string FileName = "";
List<ConferencePaperFilesViewModel> cpfVM = new List<ConferencePaperFilesViewModel>();
cpfVM = fileSrv.GetConferencePaperFilesList(PV.ConferenceId, 5);
var funcZIP = (Action<Stream>)((stream) =>
{
    using (var archive = new ZipArchive(stream, ZipArchiveMode.Create))
    {
        cpfVM.ForEach(x =>
        {
            try
            {
                if(count <= 150)
                {
                    string SubFileName = Path.GetExtension(x.OriginalFileName).ToLower();
                    if (x.CleanTitle == null)
                    {
                        FileName = x.paperId.ToString();
                    }
                    else
                    {
                        FileName = x.CleanTitle;
                    }
                    var zipEntry = archive.CreateEntry(FileName + "_F" + SubFileName, System.IO.Compression.CompressionLevel.Fastest);
                    using (var zipStream = zipEntry.Open())
                    {
                        var bytes = System.IO.File.ReadAllBytes(x.SysPath + x.OriginalFileName);
                        zipStream.Write(bytes, 0, bytes.Length);
                    }
                }
                count++;
            }
            catch (Exception)
            {
                throw;
            }
        });
    }
});
return new PushStreamResult("Files.zip", "application/zip", stream => funcZIP(new ZipWrapStream(stream)));

--

淺水員 iT邦大師 6 級 ‧ 2020-08-19 18:53:06 檢舉

會不會是連線逾時?

會不會是出現 Exception 被 Debug 停住了 ((測不出來開始亂猜

leo226 iT邦新手 4 級 ‧ 2020-08-20 11:35:05 檢舉

我覺得連線逾時和出現 Exception 被 Debug 停住了都不像。
當檔案下載到118MB時,會固定卡在118MB,一直loading轉圈約1~2分鐘以上的時間才會失敗-網路錯誤
Exception我己經全開了,好像沒有理由其它段程式會報錯,而這一段程式不會報錯~

淺水員 iT邦大師 6 級 ‧ 2020-08-21 18:09:32 檢舉

突然想到會不會跟 leo226cpfVM.ForEach 在最外層有關?他好像會重複 new ZipArchive

這邊的 Code cpfVM.ForEach 在裡面
不過上面後來改 FileStream 的地方跑到外面了,哈哈哈

leo226 iT邦新手 4 級 ‧ 2020-08-24 14:00:51 檢舉

ZipArchive不能夠重複new嗎?
實際上我不是很清楚每個方法底層的限制,會需要寫進foreach裡也是因為FileStream需要不斷的代入不同的檔案。
改成小碼農米爾 Mir建議的方法,其實就沒有重複new ZipArchive了。

我要發表回答

立即登入回答